huskies: merge 865
This commit is contained in:
@@ -1,90 +1,9 @@
|
||||
//! Parsing logic for story YAML front matter and todo checkboxes.
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::types::{QaMode, StoryMetaError, StoryMetadata};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct FrontMatter {
|
||||
pub name: Option<String>,
|
||||
pub coverage_baseline: Option<String>,
|
||||
pub merge_failure: Option<String>,
|
||||
pub agent: Option<String>,
|
||||
pub review_hold: Option<bool>,
|
||||
/// Configurable QA mode field: "human", "server", or "agent".
|
||||
pub qa: Option<String>,
|
||||
/// Number of times this story has been retried at its current pipeline stage.
|
||||
pub retry_count: Option<u32>,
|
||||
/// When `true`, auto-assign will skip this story (retry limit exceeded).
|
||||
pub blocked: Option<bool>,
|
||||
/// Story numbers this story depends on.
|
||||
pub depends_on: Option<Vec<u32>>,
|
||||
/// When `true`, the story is frozen.
|
||||
pub frozen: Option<bool>,
|
||||
/// Stage directory to restore on unfreeze (e.g. `"2_current"`).
|
||||
pub resume_to_stage: Option<String>,
|
||||
/// Set to `true` when an agent's `run_tests` call returns `passed=true`.
|
||||
/// Used by the bug-645 salvage path to distinguish a genuine test-passing
|
||||
/// session from one that merely compiled.
|
||||
pub run_tests_passed: Option<bool>,
|
||||
/// Item type: "story", "bug", "spike", or "refactor".
|
||||
#[serde(rename = "type")]
|
||||
pub item_type: Option<String>,
|
||||
/// Set to `true` when the auto-assigner has already spawned a mergemaster
|
||||
/// session for a content-conflict failure.
|
||||
pub mergemaster_attempted: Option<bool>,
|
||||
/// Epic this item belongs to (numeric ID as string, e.g. "880").
|
||||
pub epic: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse the YAML front matter block from a story markdown string.
|
||||
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
|
||||
let mut lines = contents.lines();
|
||||
|
||||
let first = lines.next().unwrap_or_default().trim();
|
||||
if first != "---" {
|
||||
return Err(StoryMetaError::MissingFrontMatter);
|
||||
}
|
||||
|
||||
let mut front_lines = Vec::new();
|
||||
for line in &mut lines {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "---" {
|
||||
let raw = front_lines.join("\n");
|
||||
let front: FrontMatter = serde_yaml::from_str(&raw)
|
||||
.map_err(|e| StoryMetaError::InvalidFrontMatter(e.to_string()))?;
|
||||
return Ok(build_metadata(front));
|
||||
}
|
||||
front_lines.push(line);
|
||||
}
|
||||
|
||||
Err(StoryMetaError::InvalidFrontMatter(
|
||||
"Missing closing front matter delimiter".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_metadata(front: FrontMatter) -> StoryMetadata {
|
||||
let qa = front.qa.as_deref().and_then(QaMode::from_str);
|
||||
|
||||
StoryMetadata {
|
||||
name: front.name,
|
||||
coverage_baseline: front.coverage_baseline,
|
||||
merge_failure: front.merge_failure,
|
||||
agent: front.agent,
|
||||
review_hold: front.review_hold,
|
||||
qa,
|
||||
retry_count: front.retry_count,
|
||||
blocked: front.blocked,
|
||||
depends_on: front.depends_on,
|
||||
frozen: front.frozen,
|
||||
resume_to_stage: front.resume_to_stage,
|
||||
run_tests_passed: front.run_tests_passed,
|
||||
item_type: front.item_type,
|
||||
mergemaster_attempted: front.mergemaster_attempted,
|
||||
epic: front.epic,
|
||||
}
|
||||
}
|
||||
//! Pure-content helpers and CRDT-backed metadata lookups.
|
||||
//!
|
||||
//! Story 865 stripped YAML front matter from stored content and the codebase
|
||||
//! at large; the only remaining functions here read the CRDT or operate on
|
||||
//! the markdown body directly.
|
||||
use super::types::QaMode;
|
||||
|
||||
/// Parse unchecked todo items (`- [ ] ...`) from a markdown string.
|
||||
pub fn parse_unchecked_todos(contents: &str) -> Vec<String> {
|
||||
@@ -97,46 +16,32 @@ pub fn parse_unchecked_todos(contents: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve the effective QA mode for a story file.
|
||||
/// Resolve the effective QA mode for a story by ID via the CRDT.
|
||||
///
|
||||
/// Reads the `qa` front matter field. If absent, falls back to `default`.
|
||||
/// Spikes are **not** handled here — the caller is responsible for overriding
|
||||
/// to `Human` for spikes.
|
||||
pub fn resolve_qa_mode(path: &Path, default: QaMode) -> QaMode {
|
||||
let contents = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return default,
|
||||
};
|
||||
match parse_front_matter(&contents) {
|
||||
Ok(meta) => meta.qa.unwrap_or(default),
|
||||
Err(_) => default,
|
||||
}
|
||||
/// Returns `default` when the story has no entry or its `qa_mode` register is
|
||||
/// unset. Spikes are **not** handled here — callers override to `Human` for
|
||||
/// spikes themselves.
|
||||
pub fn resolve_qa_mode(story_id: &str, default: QaMode) -> QaMode {
|
||||
crate::crdt_state::read_item(story_id)
|
||||
.and_then(|view| view.qa_mode)
|
||||
.as_deref()
|
||||
.and_then(QaMode::from_str)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Resolve the effective QA mode for a story by story ID.
|
||||
///
|
||||
/// Checks the typed `qa_mode` CRDT register first. If the register holds a
|
||||
/// recognised value (`"server"`, `"agent"`, or `"human"`), returns it.
|
||||
/// Otherwise falls back to parsing the `qa` YAML front-matter field from
|
||||
/// `contents`. If neither source provides a value, returns `default`.
|
||||
pub fn resolve_qa_mode_from_content(story_id: &str, contents: &str, default: QaMode) -> QaMode {
|
||||
// CRDT register takes precedence over YAML front matter.
|
||||
if let Some(view) = crate::crdt_state::read_item(story_id)
|
||||
&& let Some(ref s) = view.qa_mode
|
||||
&& let Some(mode) = QaMode::from_str(s)
|
||||
{
|
||||
return mode;
|
||||
}
|
||||
// Fall back to YAML front matter for backward compatibility.
|
||||
match parse_front_matter(contents) {
|
||||
Ok(meta) => meta.qa.unwrap_or(default),
|
||||
Err(_) => default,
|
||||
}
|
||||
/// Resolve the effective QA mode by parsing legacy YAML front matter from a
|
||||
/// markdown body. Used during one-time fallbacks when the CRDT register isn't
|
||||
/// set; new code should always read `qa_mode` from the CRDT.
|
||||
pub fn resolve_qa_mode_from_content(_story_id: &str, content: &str, default: QaMode) -> QaMode {
|
||||
crate::db::yaml_legacy::parse_front_matter(content)
|
||||
.ok()
|
||||
.and_then(|m| m.qa)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Return `true` if the story is in the `Frozen` pipeline stage.
|
||||
///
|
||||
/// Checks the typed CRDT stage via `read_typed`. Used by the pipeline advance
|
||||
/// Checks the typed CRDT stage via `read_typed`. Used by the pipeline advance
|
||||
/// code to suppress stage transitions for frozen stories.
|
||||
pub fn is_story_frozen_in_store(story_id: &str) -> bool {
|
||||
crate::pipeline_state::read_typed(story_id)
|
||||
@@ -150,48 +55,6 @@ pub fn is_story_frozen_in_store(story_id: &str) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_front_matter_metadata() {
|
||||
let input = r#"---
|
||||
name: Establish the TDD Workflow and Gates
|
||||
workflow: tdd
|
||||
---
|
||||
# Story 26
|
||||
"#;
|
||||
|
||||
let meta = parse_front_matter(input).expect("front matter");
|
||||
assert_eq!(
|
||||
meta.name.as_deref(),
|
||||
Some("Establish the TDD Workflow and Gates")
|
||||
);
|
||||
assert_eq!(meta.coverage_baseline, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_coverage_baseline_from_front_matter() {
|
||||
let input = "---\nname: Test Story\ncoverage_baseline: 78.5%\n---\n# Story\n";
|
||||
let meta = parse_front_matter(input).expect("front matter");
|
||||
assert_eq!(meta.coverage_baseline.as_deref(), Some("78.5%"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_front_matter() {
|
||||
let input = "# Story 26\n";
|
||||
assert_eq!(
|
||||
parse_front_matter(input),
|
||||
Err(StoryMetaError::MissingFrontMatter)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unclosed_front_matter() {
|
||||
let input = "---\nname: Test\n";
|
||||
assert!(matches!(
|
||||
parse_front_matter(input),
|
||||
Err(StoryMetaError::InvalidFrontMatter(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unchecked_todos_mixed() {
|
||||
let input = "## AC\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n";
|
||||
@@ -220,75 +83,11 @@ workflow: tdd
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_review_hold_from_front_matter() {
|
||||
let input = "---\nname: Spike\nreview_hold: true\n---\n# Spike\n";
|
||||
let meta = parse_front_matter(input).expect("front matter");
|
||||
assert_eq!(meta.review_hold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_hold_defaults_to_none() {
|
||||
let input = "---\nname: Story\n---\n# Story\n";
|
||||
let meta = parse_front_matter(input).expect("front matter");
|
||||
assert_eq!(meta.review_hold, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_qa_mode_from_front_matter() {
|
||||
let input = "---\nname: Story\nqa: server\n---\n# Story\n";
|
||||
let meta = parse_front_matter(input).expect("front matter");
|
||||
assert_eq!(meta.qa, Some(QaMode::Server));
|
||||
|
||||
let input = "---\nname: Story\nqa: agent\n---\n# Story\n";
|
||||
let meta = parse_front_matter(input).expect("front matter");
|
||||
assert_eq!(meta.qa, Some(QaMode::Agent));
|
||||
|
||||
let input = "---\nname: Story\nqa: human\n---\n# Story\n";
|
||||
let meta = parse_front_matter(input).expect("front matter");
|
||||
assert_eq!(meta.qa, Some(QaMode::Human));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qa_mode_defaults_to_none() {
|
||||
let input = "---\nname: Story\n---\n# Story\n";
|
||||
let meta = parse_front_matter(input).expect("front matter");
|
||||
assert_eq!(meta.qa, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_qa_mode_uses_file_value() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("story.md");
|
||||
std::fs::write(&path, "---\nname: Test\nqa: human\n---\n# Story\n").unwrap();
|
||||
assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Human);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_qa_mode_falls_back_to_default() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("story.md");
|
||||
std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap();
|
||||
assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Server);
|
||||
assert_eq!(resolve_qa_mode(&path, QaMode::Agent), QaMode::Agent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_qa_mode_missing_file_uses_default() {
|
||||
let path = std::path::Path::new("/nonexistent/story.md");
|
||||
assert_eq!(resolve_qa_mode(path, QaMode::Server), QaMode::Server);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_depends_on_from_front_matter() {
|
||||
let input = "---\nname: Story\ndepends_on: [477, 478]\n---\n# Story\n";
|
||||
let meta = parse_front_matter(input).expect("front matter");
|
||||
assert_eq!(meta.depends_on, Some(vec![477, 478]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depends_on_defaults_to_none() {
|
||||
let input = "---\nname: Story\n---\n# Story\n";
|
||||
let meta = parse_front_matter(input).expect("front matter");
|
||||
assert_eq!(meta.depends_on, None);
|
||||
fn resolve_qa_mode_falls_back_to_default_when_crdt_empty() {
|
||||
crate::crdt_state::init_for_test();
|
||||
assert_eq!(
|
||||
resolve_qa_mode("9999_no_such_story", QaMode::Server),
|
||||
QaMode::Server
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user