From 30dd4b3a0ad2aead75254381951b9314a945e39b Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 28 Apr 2026 16:25:47 +0000 Subject: [PATCH] huskies: merge 796 --- server/src/io/story_metadata.rs | 944 ------------------------- server/src/io/story_metadata/deps.rs | 282 ++++++++ server/src/io/story_metadata/fields.rs | 303 ++++++++ server/src/io/story_metadata/mod.rs | 25 + server/src/io/story_metadata/parser.rs | 294 ++++++++ server/src/io/story_metadata/types.rs | 87 +++ 6 files changed, 991 insertions(+), 944 deletions(-) delete mode 100644 server/src/io/story_metadata.rs create mode 100644 server/src/io/story_metadata/deps.rs create mode 100644 server/src/io/story_metadata/fields.rs create mode 100644 server/src/io/story_metadata/mod.rs create mode 100644 server/src/io/story_metadata/parser.rs create mode 100644 server/src/io/story_metadata/types.rs diff --git a/server/src/io/story_metadata.rs b/server/src/io/story_metadata.rs deleted file mode 100644 index 0d273719..00000000 --- a/server/src/io/story_metadata.rs +++ /dev/null @@ -1,944 +0,0 @@ -//! Story metadata — parses and modifies YAML front matter in story markdown files. -use serde::Deserialize; -use std::fs; -use std::path::Path; - -/// QA mode for a story: determines how the pipeline handles post-coder review. -/// -/// - `Server` — skip the QA agent; rely on server gate checks (clippy + tests). -/// If gates pass, advance straight to merge. -/// - `Agent` — spin up a QA agent (Claude session) to review code and run gates. -/// - `Human` — hold in QA for human approval after server gates pass. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum QaMode { - Server, - Agent, - Human, -} - -impl QaMode { - /// Parse a string into a `QaMode`. Returns `None` for unrecognised values. - pub fn from_str(s: &str) -> Option { - match s.trim().to_lowercase().as_str() { - "server" => Some(Self::Server), - "agent" => Some(Self::Agent), - "human" => Some(Self::Human), - _ => None, - } - } - - pub fn as_str(&self) -> &'static str { - match self { - Self::Server => "server", - Self::Agent => "agent", - Self::Human => "human", - } - } -} - -impl std::fmt::Display for QaMode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct StoryMetadata { - pub name: Option, - pub coverage_baseline: Option, - pub merge_failure: Option, - pub agent: Option, - pub review_hold: Option, - pub qa: Option, - /// Number of times this story has been retried at its current pipeline stage. - pub retry_count: Option, - /// When `true`, auto-assign will skip this story (retry limit exceeded). - pub blocked: Option, - /// Story numbers this story depends on. Auto-assign will skip this story - /// until all dependencies have reached `5_done` or `6_archived`. - pub depends_on: Option>, - /// When `true`, the story is frozen: auto-assign skips it, the pipeline - /// does not advance it, and no mergemaster is spawned. - pub frozen: Option, - /// Set to `true` when an agent's `run_tests` call returns `passed=true`. - /// Used by the bug-645 salvage path to require real test evidence, not just - /// compilation success. - pub run_tests_passed: Option, - /// Item type: "story", "bug", "spike", or "refactor". - /// - /// Present on items created with numeric-only IDs (no slug suffix). - /// Used by the pipeline to determine routing (e.g. spikes skip QA). - pub item_type: Option, - /// Set to `true` when the auto-assigner has already spawned a mergemaster - /// session for a content-conflict failure. Prevents repeated spawns. - pub mergemaster_attempted: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum StoryMetaError { - MissingFrontMatter, - InvalidFrontMatter(String), -} - -impl std::fmt::Display for StoryMetaError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - StoryMetaError::MissingFrontMatter => write!(f, "Missing front matter"), - StoryMetaError::InvalidFrontMatter(msg) => write!(f, "Invalid front matter: {msg}"), - } - } -} - -#[derive(Debug, Deserialize)] -struct FrontMatter { - name: Option, - coverage_baseline: Option, - merge_failure: Option, - agent: Option, - review_hold: Option, - /// Configurable QA mode field: "human", "server", or "agent". - qa: Option, - /// Number of times this story has been retried at its current pipeline stage. - retry_count: Option, - /// When `true`, auto-assign will skip this story (retry limit exceeded). - blocked: Option, - /// Story numbers this story depends on. - depends_on: Option>, - /// When `true`, the story is frozen. - frozen: Option, - /// 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. - run_tests_passed: Option, - /// Item type: "story", "bug", "spike", or "refactor". - #[serde(rename = "type")] - item_type: Option, - /// Set to `true` when the auto-assigner has already spawned a mergemaster - /// session for a content-conflict failure. - mergemaster_attempted: Option, -} - -pub fn parse_front_matter(contents: &str) -> Result { - 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, - run_tests_passed: front.run_tests_passed, - item_type: front.item_type, - mergemaster_attempted: front.mergemaster_attempted, - } -} - -/// Write or update a `merge_failure:` field in the YAML front matter of a story file. -/// -/// The reason is stored as a quoted YAML string so that colons, hashes, and newlines -/// in the failure message do not break front-matter parsing. -/// If no front matter is present, this is a no-op (returns Ok). -pub fn write_merge_failure(path: &Path, reason: &str) -> Result<(), String> { - let contents = - fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; - - // Produce a YAML-safe inline quoted string: collapse newlines, escape inner quotes. - let escaped = reason - .replace('"', "\\\"") - .replace('\n', " ") - .replace('\r', ""); - let yaml_value = format!("\"{escaped}\""); - - let updated = set_front_matter_field(&contents, "merge_failure", &yaml_value); - fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; - Ok(()) -} - -/// Write `review_hold: true` to the YAML front matter of a story file. -/// -/// Used to mark spikes that have passed QA and are waiting for human review. -pub fn write_review_hold(path: &Path) -> Result<(), String> { - let contents = - fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; - let updated = set_front_matter_field(&contents, "review_hold", "true"); - fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; - Ok(()) -} - -/// Remove a key from the YAML front matter of a story file on disk. -/// -/// If front matter is present and contains the key, the line is removed. -/// If no front matter or key is not found, the file is left unchanged. -pub fn clear_front_matter_field(path: &Path, key: &str) -> Result<(), String> { - let contents = - fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; - let updated = remove_front_matter_field(&contents, key); - if updated != contents { - fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; - } - Ok(()) -} - -/// Remove a key: value line from the YAML front matter of a markdown string. -/// -/// If no front matter (opening `---`) is found or the key is absent, returns content unchanged. -fn remove_front_matter_field(contents: &str, key: &str) -> String { - let mut lines: Vec = contents.lines().map(String::from).collect(); - if lines.is_empty() || lines[0].trim() != "---" { - return contents.to_string(); - } - - let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") { - Some(i) => i + 1, - None => return contents.to_string(), - }; - - let key_prefix = format!("{key}:"); - if let Some(idx) = lines[1..close_idx] - .iter() - .position(|l| l.trim_start().starts_with(&key_prefix)) - .map(|i| i + 1) - { - lines.remove(idx); - } else { - return contents.to_string(); - } - - let mut result = lines.join("\n"); - if contents.ends_with('\n') { - result.push('\n'); - } - result -} - -/// Insert or update a key: value pair in the YAML front matter of a markdown string. -/// -/// If no front matter (opening `---`) is found, returns the content unchanged. -pub fn set_front_matter_field(contents: &str, key: &str, value: &str) -> String { - let mut lines: Vec = contents.lines().map(String::from).collect(); - if lines.is_empty() || lines[0].trim() != "---" { - return contents.to_string(); - } - - // Find closing --- (search from index 1) - let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") { - Some(i) => i + 1, - None => return contents.to_string(), - }; - - let key_prefix = format!("{key}:"); - let existing_idx = lines[1..close_idx] - .iter() - .position(|l| l.trim_start().starts_with(&key_prefix)) - .map(|i| i + 1); - - let new_line = format!("{key}: {value}"); - if let Some(idx) = existing_idx { - lines[idx] = new_line; - } else { - lines.insert(close_idx, new_line); - } - - let mut result = lines.join("\n"); - if contents.ends_with('\n') { - result.push('\n'); - } - result -} - -/// Write `blocked: true` to the YAML front matter of a story file. -/// -/// Used to mark stories that have exceeded the retry limit and should not -/// be auto-assigned again. -pub fn write_blocked(path: &Path) -> Result<(), String> { - let contents = - fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; - let updated = set_front_matter_field(&contents, "blocked", "true"); - fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; - Ok(()) -} - -/// Write or update a `depends_on:` field in the YAML front matter of a story file. -/// -/// Serialises `deps` as an inline YAML sequence, e.g. `[477, 478]`. -/// If `deps` is empty the field is removed. -/// If no front matter is present, this is a no-op (returns Ok). -pub fn write_depends_on(path: &Path, deps: &[u32]) -> Result<(), String> { - let contents = - fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; - let updated = if deps.is_empty() { - remove_front_matter_field(&contents, "depends_on") - } else { - let nums: Vec = deps.iter().map(|n| n.to_string()).collect(); - let yaml_value = format!("[{}]", nums.join(", ")); - set_front_matter_field(&contents, "depends_on", &yaml_value) - }; - fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; - Ok(()) -} - -/// Return the list of dependency story numbers from `story_id`'s front matter -/// that have **not** yet reached `5_done` or `6_archived`. -/// -/// Returns an empty `Vec` when there are no unmet dependencies (including when -/// the story has no `depends_on` field at all). -pub fn check_unmet_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec { - let path = project_root - .join(".huskies") - .join("work") - .join(stage_dir) - .join(format!("{story_id}.md")); - let contents = match fs::read_to_string(&path) { - Ok(c) => c, - Err(_) => return Vec::new(), - }; - let deps = match parse_front_matter(&contents) - .ok() - .and_then(|m| m.depends_on) - { - Some(d) => d, - None => return Vec::new(), - }; - deps.into_iter() - .filter(|&dep| !dep_is_done(project_root, dep)) - .collect() -} - -/// Return `true` if a story with the given numeric ID exists in `5_done` or `6_archived`. -/// -/// **Dependency semantics:** Both `5_done` and `6_archived` satisfy a `depends_on` entry. -/// Stories auto-sweep from `5_done` to `6_archived` after 4 hours, so by the time a dep -/// reaches `6_archived`, the dependent story has already been promoted. When a dep is -/// already in `6_archived` at the moment of promotion (e.g., it was manually archived or -/// abandoned before the dependent story was created), the dependency is still considered -/// satisfied — but a warning is logged so the user can see that the dep was archived, not -/// cleanly completed. Use `check_archived_deps` to detect this case. -fn dep_is_done(project_root: &Path, dep_number: u32) -> bool { - let prefix = format!("{dep_number}_"); - let exact = dep_number.to_string(); - for stage in &["5_done", "6_archived"] { - let dir = project_root.join(".huskies").join("work").join(stage); - if let Ok(entries) = fs::read_dir(&dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("md") { - continue; - } - if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) - && (stem == exact || stem.starts_with(&prefix)) - { - return true; - } - } - } - } - false -} - -/// Return `true` if a story with the given numeric ID exists specifically in `6_archived` -/// (i.e., it satisfies a `depends_on` but via the archive rather than via a clean done). -fn dep_is_archived(project_root: &Path, dep_number: u32) -> bool { - let prefix = format!("{dep_number}_"); - let exact = dep_number.to_string(); - let dir = project_root - .join(".huskies") - .join("work") - .join("6_archived"); - if let Ok(entries) = fs::read_dir(&dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("md") { - continue; - } - if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) - && (stem == exact || stem.starts_with(&prefix)) - { - return true; - } - } - } - false -} - -/// Return the list of dependency story numbers from `story_id`'s front matter -/// that are in `6_archived` (satisfied via archive rather than via normal done). -/// -/// Used to emit a warning when backlog promotion fires because a dep was archived -/// rather than cleanly completed. Returns an empty `Vec` when no deps are archived. -pub fn check_archived_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec { - let path = project_root - .join(".huskies") - .join("work") - .join(stage_dir) - .join(format!("{story_id}.md")); - let contents = match fs::read_to_string(&path) { - Ok(c) => c, - Err(_) => return Vec::new(), - }; - let deps = match parse_front_matter(&contents) - .ok() - .and_then(|m| m.depends_on) - { - Some(d) => d, - None => return Vec::new(), - }; - deps.into_iter() - .filter(|&dep| dep_is_archived(project_root, dep)) - .collect() -} - -/// Given an explicit list of dep numbers, return those already in `6_archived`. -/// -/// Used at story-creation time when the dep list is known in memory (before the -/// story file has been written), so the caller does not need to parse the story. -pub fn check_archived_deps_from_list(project_root: &Path, deps: &[u32]) -> Vec { - deps.iter() - .copied() - .filter(|&dep| dep_is_archived(project_root, dep)) - .collect() -} - -// ── In-memory content variants (no filesystem access) ─────────────── - -/// Remove a key from the YAML front matter of a markdown string (pure function). -/// -/// Returns the updated content. If no front matter or key is not found, -/// returns the original content unchanged. -pub fn clear_front_matter_field_in_content(contents: &str, key: &str) -> String { - remove_front_matter_field(contents, key) -} - -/// Append rejection notes to a markdown string (pure function). -/// -/// Returns the updated content with a `## QA Rejection Notes` section appended. -pub fn write_rejection_notes_to_content(contents: &str, notes: &str) -> String { - let section = format!("\n\n## QA Rejection Notes\n\n{notes}\n"); - format!("{contents}{section}") -} - -/// Resolve the effective QA mode from story content (no filesystem access). -/// -/// Parses front matter from `contents` and returns the `qa` field if present, -/// otherwise returns `default`. -pub fn resolve_qa_mode_from_content(contents: &str, default: QaMode) -> QaMode { - match parse_front_matter(contents) { - Ok(meta) => meta.qa.unwrap_or(default), - Err(_) => default, - } -} - -/// Increment the `retry_count` field in story content (pure function). -/// -/// Returns `(updated_content, new_count)`. -pub fn increment_retry_count_in_content(contents: &str) -> (String, u32) { - let current = parse_front_matter(contents) - .ok() - .and_then(|m| m.retry_count) - .unwrap_or(0); - let new_count = current + 1; - let updated = set_front_matter_field(contents, "retry_count", &new_count.to_string()); - (updated, new_count) -} - -/// Return `true` if the story has `frozen: true` in the content store. -/// -/// Used by the pipeline advance code to suppress stage transitions for frozen stories. -pub fn is_story_frozen_in_store(story_id: &str) -> bool { - let contents = match crate::db::read_content(story_id) { - Some(c) => c, - None => return false, - }; - parse_front_matter(&contents) - .ok() - .and_then(|m| m.frozen) - .unwrap_or(false) -} - -/// Write `blocked: true` to story content (pure function). -pub fn write_blocked_in_content(contents: &str) -> String { - set_front_matter_field(contents, "blocked", "true") -} - -/// Write or update `merge_failure` in story content (pure function). -pub fn write_merge_failure_in_content(contents: &str, reason: &str) -> String { - let escaped = reason - .replace('"', "\\\"") - .replace('\n', " ") - .replace('\r', ""); - let yaml_value = format!("\"{escaped}\""); - set_front_matter_field(contents, "merge_failure", &yaml_value) -} - -/// Write `review_hold: true` to story content (pure function). -pub fn write_review_hold_in_content(contents: &str) -> String { - set_front_matter_field(contents, "review_hold", "true") -} - -/// Write `mergemaster_attempted: true` to story content (pure function). -/// -/// Used by the auto-assigner to record that a mergemaster session has been -/// spawned for a content-conflict failure, preventing repeated auto-spawns. -pub fn write_mergemaster_attempted_in_content(contents: &str) -> String { - set_front_matter_field(contents, "mergemaster_attempted", "true") -} - -/// Write or update `depends_on` in story content (pure function). -/// -/// Serialises `deps` as an inline YAML sequence, e.g. `[477, 478]`. -/// If `deps` is empty the field is removed. -pub fn write_depends_on_in_content(contents: &str, deps: &[u32]) -> String { - if deps.is_empty() { - remove_front_matter_field(contents, "depends_on") - } else { - let nums: Vec = deps.iter().map(|n| n.to_string()).collect(); - let yaml_value = format!("[{}]", nums.join(", ")); - set_front_matter_field(contents, "depends_on", &yaml_value) - } -} - -/// Resolve the effective QA mode for a story file. -/// -/// 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, - } -} - -pub fn parse_unchecked_todos(contents: &str) -> Vec { - contents - .lines() - .filter_map(|line| { - let trimmed = line.trim(); - trimmed.strip_prefix("- [ ] ").map(|text| text.to_string()) - }) - .collect() -} - -#[cfg(test)] -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 set_front_matter_field_inserts_new_key() { - let input = "---\nname: My Story\n---\n# Body\n"; - let output = set_front_matter_field(input, "coverage_baseline", "55.0%"); - assert!(output.contains("coverage_baseline: 55.0%")); - assert!(output.contains("name: My Story")); - assert!(output.ends_with('\n')); - } - - #[test] - fn set_front_matter_field_updates_existing_key() { - let input = "---\nname: My Story\ncoverage_baseline: 40.0%\n---\n# Body\n"; - let output = set_front_matter_field(input, "coverage_baseline", "55.0%"); - assert!(output.contains("coverage_baseline: 55.0%")); - assert!(!output.contains("40.0%")); - } - - #[test] - fn set_front_matter_field_no_op_without_front_matter() { - let input = "# No front matter\n"; - let output = set_front_matter_field(input, "coverage_baseline", "55.0%"); - assert_eq!(output, input); - } - - #[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 remove_front_matter_field_removes_key() { - let input = "---\nname: My Story\nmerge_failure: \"something broke\"\n---\n# Body\n"; - let output = remove_front_matter_field(input, "merge_failure"); - assert!(!output.contains("merge_failure")); - assert!(output.contains("name: My Story")); - assert!(output.ends_with('\n')); - } - - #[test] - fn remove_front_matter_field_no_op_when_absent() { - let input = "---\nname: My Story\n---\n# Body\n"; - let output = remove_front_matter_field(input, "merge_failure"); - assert_eq!(output, input); - } - - #[test] - fn remove_front_matter_field_no_op_without_front_matter() { - let input = "# No front matter\n"; - let output = remove_front_matter_field(input, "merge_failure"); - assert_eq!(output, input); - } - - #[test] - fn clear_front_matter_field_updates_file() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("story.md"); - std::fs::write( - &path, - "---\nname: Test\nmerge_failure: \"bad\"\n---\n# Story\n", - ) - .unwrap(); - clear_front_matter_field(&path, "merge_failure").unwrap(); - let contents = std::fs::read_to_string(&path).unwrap(); - assert!(!contents.contains("merge_failure")); - assert!(contents.contains("name: Test")); - } - - #[test] - fn parse_unchecked_todos_mixed() { - let input = "## AC\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n"; - assert_eq!( - parse_unchecked_todos(input), - vec!["First thing", "Second thing"] - ); - } - - #[test] - fn parse_unchecked_todos_all_checked() { - let input = "- [x] Done\n- [x] Also done\n"; - assert!(parse_unchecked_todos(input).is_empty()); - } - - #[test] - fn parse_unchecked_todos_no_checkboxes() { - let input = "# Story\nSome text\n- A bullet\n"; - assert!(parse_unchecked_todos(input).is_empty()); - } - - #[test] - fn parse_unchecked_todos_leading_whitespace() { - let input = " - [ ] Indented item\n"; - assert_eq!(parse_unchecked_todos(input), vec!["Indented item"]); - } - - #[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 write_review_hold_sets_field() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("spike.md"); - std::fs::write(&path, "---\nname: My Spike\n---\n# Spike\n").unwrap(); - write_review_hold(&path).unwrap(); - let contents = std::fs::read_to_string(&path).unwrap(); - assert!(contents.contains("review_hold: true")); - assert!(contents.contains("name: My Spike")); - } - - #[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); - } - - #[test] - fn write_depends_on_sets_field() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("story.md"); - std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap(); - write_depends_on(&path, &[477, 478]).unwrap(); - let contents = std::fs::read_to_string(&path).unwrap(); - assert!(contents.contains("depends_on: [477, 478]"), "{contents}"); - } - - #[test] - fn write_depends_on_removes_field_when_empty() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("story.md"); - std::fs::write(&path, "---\nname: Test\ndepends_on: [477]\n---\n# Story\n").unwrap(); - write_depends_on(&path, &[]).unwrap(); - let contents = std::fs::read_to_string(&path).unwrap(); - assert!(!contents.contains("depends_on"), "{contents}"); - } - - #[test] - fn check_unmet_deps_returns_empty_when_no_deps() { - let tmp = tempfile::tempdir().unwrap(); - let stage = tmp.path().join(".huskies/work/2_current"); - std::fs::create_dir_all(&stage).unwrap(); - std::fs::write(stage.join("10_story_foo.md"), "---\nname: Foo\n---\n").unwrap(); - let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo"); - assert!(unmet.is_empty()); - } - - #[test] - fn check_unmet_deps_returns_unmet_numbers() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".huskies/work/2_current"); - let done = tmp.path().join(".huskies/work/5_done"); - std::fs::create_dir_all(¤t).unwrap(); - std::fs::create_dir_all(&done).unwrap(); - // Dep 477 is done, dep 478 is not. - std::fs::write(done.join("477_story_dep.md"), "---\nname: Dep\n---\n").unwrap(); - std::fs::write( - current.join("10_story_foo.md"), - "---\nname: Foo\ndepends_on: [477, 478]\n---\n", - ) - .unwrap(); - let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo"); - assert_eq!(unmet, vec![478]); - } - - #[test] - fn check_unmet_deps_returns_empty_when_all_deps_done() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".huskies/work/2_current"); - let done = tmp.path().join(".huskies/work/5_done"); - std::fs::create_dir_all(¤t).unwrap(); - std::fs::create_dir_all(&done).unwrap(); - std::fs::write(done.join("477_story_a.md"), "---\nname: A\n---\n").unwrap(); - std::fs::write(done.join("478_story_b.md"), "---\nname: B\n---\n").unwrap(); - std::fs::write( - current.join("10_story_foo.md"), - "---\nname: Foo\ndepends_on: [477, 478]\n---\n", - ) - .unwrap(); - let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo"); - assert!(unmet.is_empty()); - } - - #[test] - fn dep_is_done_finds_story_in_archived() { - let tmp = tempfile::tempdir().unwrap(); - let archived = tmp.path().join(".huskies/work/6_archived"); - std::fs::create_dir_all(&archived).unwrap(); - std::fs::write(archived.join("100_story_old.md"), "---\nname: Old\n---\n").unwrap(); - assert!(dep_is_done(tmp.path(), 100)); - assert!(!dep_is_done(tmp.path(), 101)); - } - - // ── Bug 503: archived-dep visibility ───────────────────────────────────── - - /// check_archived_deps returns the dep IDs that are in 6_archived. - #[test] - fn check_archived_deps_returns_archived_dep_numbers() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".huskies/work/2_current"); - let archived = tmp.path().join(".huskies/work/6_archived"); - std::fs::create_dir_all(¤t).unwrap(); - std::fs::create_dir_all(&archived).unwrap(); - // Dep 100 is in 6_archived; dep 101 is not anywhere. - std::fs::write(archived.join("100_spike_old.md"), "---\nname: Old\n---\n").unwrap(); - std::fs::write( - current.join("5_story_dependent.md"), - "---\nname: Dep\ndepends_on: [100, 101]\n---\n", - ) - .unwrap(); - let archived_deps = check_archived_deps(tmp.path(), "2_current", "5_story_dependent"); - assert_eq!(archived_deps, vec![100]); - } - - /// check_archived_deps returns empty when no deps are in 6_archived. - #[test] - fn check_archived_deps_returns_empty_when_dep_in_done() { - let tmp = tempfile::tempdir().unwrap(); - let backlog = tmp.path().join(".huskies/work/1_backlog"); - let done = tmp.path().join(".huskies/work/5_done"); - std::fs::create_dir_all(&backlog).unwrap(); - std::fs::create_dir_all(&done).unwrap(); - // Dep 200 is in 5_done (not archived). - std::fs::write(done.join("200_story_done.md"), "---\nname: Done\n---\n").unwrap(); - std::fs::write( - backlog.join("5_story_waiting.md"), - "---\nname: Waiting\ndepends_on: [200]\n---\n", - ) - .unwrap(); - let archived_deps = check_archived_deps(tmp.path(), "1_backlog", "5_story_waiting"); - assert!(archived_deps.is_empty()); - } - - /// check_archived_deps returns empty when story has no depends_on. - #[test] - fn check_archived_deps_returns_empty_when_no_deps() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".huskies/work/2_current"); - std::fs::create_dir_all(¤t).unwrap(); - std::fs::write(current.join("3_story_free.md"), "---\nname: Free\n---\n").unwrap(); - let archived_deps = check_archived_deps(tmp.path(), "2_current", "3_story_free"); - assert!(archived_deps.is_empty()); - } - - /// check_archived_deps_from_list returns archived dep IDs from an in-memory list. - #[test] - fn check_archived_deps_from_list_returns_archived_ids() { - let tmp = tempfile::tempdir().unwrap(); - let done = tmp.path().join(".huskies/work/5_done"); - let archived = tmp.path().join(".huskies/work/6_archived"); - std::fs::create_dir_all(&done).unwrap(); - std::fs::create_dir_all(&archived).unwrap(); - std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap(); - std::fs::write(archived.join("20_story_old.md"), "---\nname: Old\n---\n").unwrap(); - // Only 20 is archived; 10 is in done, 30 is nowhere. - let result = check_archived_deps_from_list(tmp.path(), &[10, 20, 30]); - assert_eq!(result, vec![20]); - } - - /// check_archived_deps_from_list returns empty when no deps are archived. - #[test] - fn check_archived_deps_from_list_empty_when_no_archived_deps() { - let tmp = tempfile::tempdir().unwrap(); - let done = tmp.path().join(".huskies/work/5_done"); - std::fs::create_dir_all(&done).unwrap(); - std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap(); - let result = check_archived_deps_from_list(tmp.path(), &[10]); - assert!(result.is_empty()); - } - - /// dep_is_archived returns true only for stories in 6_archived, not 5_done. - #[test] - fn dep_is_archived_distinguishes_done_from_archived() { - let tmp = tempfile::tempdir().unwrap(); - let done = tmp.path().join(".huskies/work/5_done"); - let archived = tmp.path().join(".huskies/work/6_archived"); - std::fs::create_dir_all(&done).unwrap(); - std::fs::create_dir_all(&archived).unwrap(); - std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap(); - std::fs::write(archived.join("20_story_old.md"), "---\nname: Old\n---\n").unwrap(); - // 10 is in 5_done only — not archived. - assert!(!dep_is_archived(tmp.path(), 10)); - // 20 is in 6_archived — archived. - assert!(dep_is_archived(tmp.path(), 20)); - // 99 doesn't exist anywhere. - assert!(!dep_is_archived(tmp.path(), 99)); - } -} diff --git a/server/src/io/story_metadata/deps.rs b/server/src/io/story_metadata/deps.rs new file mode 100644 index 00000000..4a0549b7 --- /dev/null +++ b/server/src/io/story_metadata/deps.rs @@ -0,0 +1,282 @@ +//! Dependency resolution: check whether story dependencies are satisfied. +use std::fs; +use std::path::Path; + +use super::parser::parse_front_matter; + +/// Return `true` if a story with the given numeric ID exists in `5_done` or `6_archived`. +/// +/// **Dependency semantics:** Both `5_done` and `6_archived` satisfy a `depends_on` entry. +/// Stories auto-sweep from `5_done` to `6_archived` after 4 hours, so by the time a dep +/// reaches `6_archived`, the dependent story has already been promoted. When a dep is +/// already in `6_archived` at the moment of promotion (e.g., it was manually archived or +/// abandoned before the dependent story was created), the dependency is still considered +/// satisfied — but a warning is logged so the user can see that the dep was archived, not +/// cleanly completed. Use `check_archived_deps` to detect this case. +fn dep_is_done(project_root: &Path, dep_number: u32) -> bool { + let prefix = format!("{dep_number}_"); + let exact = dep_number.to_string(); + for stage in &["5_done", "6_archived"] { + let dir = project_root.join(".huskies").join("work").join(stage); + if let Ok(entries) = fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) + && (stem == exact || stem.starts_with(&prefix)) + { + return true; + } + } + } + } + false +} + +/// Return `true` if a story with the given numeric ID exists specifically in `6_archived` +/// (i.e., it satisfies a `depends_on` but via the archive rather than via a clean done). +fn dep_is_archived(project_root: &Path, dep_number: u32) -> bool { + let prefix = format!("{dep_number}_"); + let exact = dep_number.to_string(); + let dir = project_root + .join(".huskies") + .join("work") + .join("6_archived"); + if let Ok(entries) = fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) + && (stem == exact || stem.starts_with(&prefix)) + { + return true; + } + } + } + false +} + +/// Return the list of dependency story numbers from `story_id`'s front matter +/// that have **not** yet reached `5_done` or `6_archived`. +/// +/// Returns an empty `Vec` when there are no unmet dependencies (including when +/// the story has no `depends_on` field at all). +pub fn check_unmet_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec { + let path = project_root + .join(".huskies") + .join("work") + .join(stage_dir) + .join(format!("{story_id}.md")); + let contents = match fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + let deps = match parse_front_matter(&contents) + .ok() + .and_then(|m| m.depends_on) + { + Some(d) => d, + None => return Vec::new(), + }; + deps.into_iter() + .filter(|&dep| !dep_is_done(project_root, dep)) + .collect() +} + +/// Return the list of dependency story numbers from `story_id`'s front matter +/// that are in `6_archived` (satisfied via archive rather than via normal done). +/// +/// Used to emit a warning when backlog promotion fires because a dep was archived +/// rather than cleanly completed. Returns an empty `Vec` when no deps are archived. +pub fn check_archived_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec { + let path = project_root + .join(".huskies") + .join("work") + .join(stage_dir) + .join(format!("{story_id}.md")); + let contents = match fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + let deps = match parse_front_matter(&contents) + .ok() + .and_then(|m| m.depends_on) + { + Some(d) => d, + None => return Vec::new(), + }; + deps.into_iter() + .filter(|&dep| dep_is_archived(project_root, dep)) + .collect() +} + +/// Given an explicit list of dep numbers, return those already in `6_archived`. +/// +/// Used at story-creation time when the dep list is known in memory (before the +/// story file has been written), so the caller does not need to parse the story. +pub fn check_archived_deps_from_list(project_root: &Path, deps: &[u32]) -> Vec { + deps.iter() + .copied() + .filter(|&dep| dep_is_archived(project_root, dep)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_unmet_deps_returns_empty_when_no_deps() { + let tmp = tempfile::tempdir().unwrap(); + let stage = tmp.path().join(".huskies/work/2_current"); + std::fs::create_dir_all(&stage).unwrap(); + std::fs::write(stage.join("10_story_foo.md"), "---\nname: Foo\n---\n").unwrap(); + let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo"); + assert!(unmet.is_empty()); + } + + #[test] + fn check_unmet_deps_returns_unmet_numbers() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".huskies/work/2_current"); + let done = tmp.path().join(".huskies/work/5_done"); + std::fs::create_dir_all(¤t).unwrap(); + std::fs::create_dir_all(&done).unwrap(); + // Dep 477 is done, dep 478 is not. + std::fs::write(done.join("477_story_dep.md"), "---\nname: Dep\n---\n").unwrap(); + std::fs::write( + current.join("10_story_foo.md"), + "---\nname: Foo\ndepends_on: [477, 478]\n---\n", + ) + .unwrap(); + let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo"); + assert_eq!(unmet, vec![478]); + } + + #[test] + fn check_unmet_deps_returns_empty_when_all_deps_done() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".huskies/work/2_current"); + let done = tmp.path().join(".huskies/work/5_done"); + std::fs::create_dir_all(¤t).unwrap(); + std::fs::create_dir_all(&done).unwrap(); + std::fs::write(done.join("477_story_a.md"), "---\nname: A\n---\n").unwrap(); + std::fs::write(done.join("478_story_b.md"), "---\nname: B\n---\n").unwrap(); + std::fs::write( + current.join("10_story_foo.md"), + "---\nname: Foo\ndepends_on: [477, 478]\n---\n", + ) + .unwrap(); + let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo"); + assert!(unmet.is_empty()); + } + + #[test] + fn dep_is_done_finds_story_in_archived() { + let tmp = tempfile::tempdir().unwrap(); + let archived = tmp.path().join(".huskies/work/6_archived"); + std::fs::create_dir_all(&archived).unwrap(); + std::fs::write(archived.join("100_story_old.md"), "---\nname: Old\n---\n").unwrap(); + assert!(dep_is_done(tmp.path(), 100)); + assert!(!dep_is_done(tmp.path(), 101)); + } + + // ── Bug 503: archived-dep visibility ───────────────────────────────────── + + /// check_archived_deps returns the dep IDs that are in 6_archived. + #[test] + fn check_archived_deps_returns_archived_dep_numbers() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".huskies/work/2_current"); + let archived = tmp.path().join(".huskies/work/6_archived"); + std::fs::create_dir_all(¤t).unwrap(); + std::fs::create_dir_all(&archived).unwrap(); + // Dep 100 is in 6_archived; dep 101 is not anywhere. + std::fs::write(archived.join("100_spike_old.md"), "---\nname: Old\n---\n").unwrap(); + std::fs::write( + current.join("5_story_dependent.md"), + "---\nname: Dep\ndepends_on: [100, 101]\n---\n", + ) + .unwrap(); + let archived_deps = check_archived_deps(tmp.path(), "2_current", "5_story_dependent"); + assert_eq!(archived_deps, vec![100]); + } + + /// check_archived_deps returns empty when no deps are in 6_archived. + #[test] + fn check_archived_deps_returns_empty_when_dep_in_done() { + let tmp = tempfile::tempdir().unwrap(); + let backlog = tmp.path().join(".huskies/work/1_backlog"); + let done = tmp.path().join(".huskies/work/5_done"); + std::fs::create_dir_all(&backlog).unwrap(); + std::fs::create_dir_all(&done).unwrap(); + // Dep 200 is in 5_done (not archived). + std::fs::write(done.join("200_story_done.md"), "---\nname: Done\n---\n").unwrap(); + std::fs::write( + backlog.join("5_story_waiting.md"), + "---\nname: Waiting\ndepends_on: [200]\n---\n", + ) + .unwrap(); + let archived_deps = check_archived_deps(tmp.path(), "1_backlog", "5_story_waiting"); + assert!(archived_deps.is_empty()); + } + + /// check_archived_deps returns empty when story has no depends_on. + #[test] + fn check_archived_deps_returns_empty_when_no_deps() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".huskies/work/2_current"); + std::fs::create_dir_all(¤t).unwrap(); + std::fs::write(current.join("3_story_free.md"), "---\nname: Free\n---\n").unwrap(); + let archived_deps = check_archived_deps(tmp.path(), "2_current", "3_story_free"); + assert!(archived_deps.is_empty()); + } + + /// check_archived_deps_from_list returns archived dep IDs from an in-memory list. + #[test] + fn check_archived_deps_from_list_returns_archived_ids() { + let tmp = tempfile::tempdir().unwrap(); + let done = tmp.path().join(".huskies/work/5_done"); + let archived = tmp.path().join(".huskies/work/6_archived"); + std::fs::create_dir_all(&done).unwrap(); + std::fs::create_dir_all(&archived).unwrap(); + std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap(); + std::fs::write(archived.join("20_story_old.md"), "---\nname: Old\n---\n").unwrap(); + // Only 20 is archived; 10 is in done, 30 is nowhere. + let result = check_archived_deps_from_list(tmp.path(), &[10, 20, 30]); + assert_eq!(result, vec![20]); + } + + /// check_archived_deps_from_list returns empty when no deps are archived. + #[test] + fn check_archived_deps_from_list_empty_when_no_archived_deps() { + let tmp = tempfile::tempdir().unwrap(); + let done = tmp.path().join(".huskies/work/5_done"); + std::fs::create_dir_all(&done).unwrap(); + std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap(); + let result = check_archived_deps_from_list(tmp.path(), &[10]); + assert!(result.is_empty()); + } + + /// dep_is_archived returns true only for stories in 6_archived, not 5_done. + #[test] + fn dep_is_archived_distinguishes_done_from_archived() { + let tmp = tempfile::tempdir().unwrap(); + let done = tmp.path().join(".huskies/work/5_done"); + let archived = tmp.path().join(".huskies/work/6_archived"); + std::fs::create_dir_all(&done).unwrap(); + std::fs::create_dir_all(&archived).unwrap(); + std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap(); + std::fs::write(archived.join("20_story_old.md"), "---\nname: Old\n---\n").unwrap(); + // 10 is in 5_done only — not archived. + assert!(!dep_is_archived(tmp.path(), 10)); + // 20 is in 6_archived — archived. + assert!(dep_is_archived(tmp.path(), 20)); + // 99 doesn't exist anywhere. + assert!(!dep_is_archived(tmp.path(), 99)); + } +} diff --git a/server/src/io/story_metadata/fields.rs b/server/src/io/story_metadata/fields.rs new file mode 100644 index 00000000..fa1fbc3f --- /dev/null +++ b/server/src/io/story_metadata/fields.rs @@ -0,0 +1,303 @@ +//! Front-matter field manipulation: insert, update, remove, and write helpers. +use std::fs; +use std::path::Path; + +/// Insert or update a key: value pair in the YAML front matter of a markdown string. +/// +/// If no front matter (opening `---`) is found, returns the content unchanged. +pub fn set_front_matter_field(contents: &str, key: &str, value: &str) -> String { + let mut lines: Vec = contents.lines().map(String::from).collect(); + if lines.is_empty() || lines[0].trim() != "---" { + return contents.to_string(); + } + + // Find closing --- (search from index 1) + let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") { + Some(i) => i + 1, + None => return contents.to_string(), + }; + + let key_prefix = format!("{key}:"); + let existing_idx = lines[1..close_idx] + .iter() + .position(|l| l.trim_start().starts_with(&key_prefix)) + .map(|i| i + 1); + + let new_line = format!("{key}: {value}"); + if let Some(idx) = existing_idx { + lines[idx] = new_line; + } else { + lines.insert(close_idx, new_line); + } + + let mut result = lines.join("\n"); + if contents.ends_with('\n') { + result.push('\n'); + } + result +} + +/// Remove a key: value line from the YAML front matter of a markdown string. +/// +/// If no front matter (opening `---`) is found or the key is absent, returns content unchanged. +pub(super) fn remove_front_matter_field(contents: &str, key: &str) -> String { + let mut lines: Vec = contents.lines().map(String::from).collect(); + if lines.is_empty() || lines[0].trim() != "---" { + return contents.to_string(); + } + + let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") { + Some(i) => i + 1, + None => return contents.to_string(), + }; + + let key_prefix = format!("{key}:"); + if let Some(idx) = lines[1..close_idx] + .iter() + .position(|l| l.trim_start().starts_with(&key_prefix)) + .map(|i| i + 1) + { + lines.remove(idx); + } else { + return contents.to_string(); + } + + let mut result = lines.join("\n"); + if contents.ends_with('\n') { + result.push('\n'); + } + result +} + +/// Remove a key from the YAML front matter of a story file on disk. +/// +/// If front matter is present and contains the key, the line is removed. +/// If no front matter or key is not found, the file is left unchanged. +pub fn clear_front_matter_field(path: &Path, key: &str) -> Result<(), String> { + let contents = + fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; + let updated = remove_front_matter_field(&contents, key); + if updated != contents { + fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; + } + Ok(()) +} + +/// Write or update a `merge_failure:` field in the YAML front matter of a story file. +/// +/// The reason is stored as a quoted YAML string so that colons, hashes, and newlines +/// in the failure message do not break front-matter parsing. +/// If no front matter is present, this is a no-op (returns Ok). +pub fn write_merge_failure(path: &Path, reason: &str) -> Result<(), String> { + let contents = + fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; + + // Produce a YAML-safe inline quoted string: collapse newlines, escape inner quotes. + let escaped = reason + .replace('"', "\\\"") + .replace('\n', " ") + .replace('\r', ""); + let yaml_value = format!("\"{escaped}\""); + + let updated = set_front_matter_field(&contents, "merge_failure", &yaml_value); + fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; + Ok(()) +} + +/// Write `review_hold: true` to the YAML front matter of a story file. +/// +/// Used to mark spikes that have passed QA and are waiting for human review. +pub fn write_review_hold(path: &Path) -> Result<(), String> { + let contents = + fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; + let updated = set_front_matter_field(&contents, "review_hold", "true"); + fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; + Ok(()) +} + +/// Write `blocked: true` to the YAML front matter of a story file. +/// +/// Used to mark stories that have exceeded the retry limit and should not +/// be auto-assigned again. +pub fn write_blocked(path: &Path) -> Result<(), String> { + let contents = + fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; + let updated = set_front_matter_field(&contents, "blocked", "true"); + fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; + Ok(()) +} + +/// Write or update `depends_on:` field in the YAML front matter of a story file. +/// +/// Serialises `deps` as an inline YAML sequence, e.g. `[477, 478]`. +/// If `deps` is empty the field is removed. +/// If no front matter is present, this is a no-op (returns Ok). +pub fn write_depends_on(path: &Path, deps: &[u32]) -> Result<(), String> { + let contents = + fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; + let updated = if deps.is_empty() { + remove_front_matter_field(&contents, "depends_on") + } else { + let nums: Vec = deps.iter().map(|n| n.to_string()).collect(); + let yaml_value = format!("[{}]", nums.join(", ")); + set_front_matter_field(&contents, "depends_on", &yaml_value) + }; + fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; + Ok(()) +} + +/// Remove a key from the YAML front matter of a markdown string (pure function). +/// +/// Returns the updated content. If no front matter or key is not found, +/// returns the original content unchanged. +pub fn clear_front_matter_field_in_content(contents: &str, key: &str) -> String { + remove_front_matter_field(contents, key) +} + +/// Append rejection notes to a markdown string (pure function). +/// +/// Returns the updated content with a `## QA Rejection Notes` section appended. +pub fn write_rejection_notes_to_content(contents: &str, notes: &str) -> String { + let section = format!("\n\n## QA Rejection Notes\n\n{notes}\n"); + format!("{contents}{section}") +} + +/// Write `blocked: true` to story content (pure function). +pub fn write_blocked_in_content(contents: &str) -> String { + set_front_matter_field(contents, "blocked", "true") +} + +/// Write or update `merge_failure` in story content (pure function). +pub fn write_merge_failure_in_content(contents: &str, reason: &str) -> String { + let escaped = reason + .replace('"', "\\\"") + .replace('\n', " ") + .replace('\r', ""); + let yaml_value = format!("\"{escaped}\""); + set_front_matter_field(contents, "merge_failure", &yaml_value) +} + +/// Write `review_hold: true` to story content (pure function). +pub fn write_review_hold_in_content(contents: &str) -> String { + set_front_matter_field(contents, "review_hold", "true") +} + +/// Write `mergemaster_attempted: true` to story content (pure function). +/// +/// Used by the auto-assigner to record that a mergemaster session has been +/// spawned for a content-conflict failure, preventing repeated auto-spawns. +pub fn write_mergemaster_attempted_in_content(contents: &str) -> String { + set_front_matter_field(contents, "mergemaster_attempted", "true") +} + +/// Write or update `depends_on` in story content (pure function). +/// +/// Serialises `deps` as an inline YAML sequence, e.g. `[477, 478]`. +/// If `deps` is empty the field is removed. +pub fn write_depends_on_in_content(contents: &str, deps: &[u32]) -> String { + if deps.is_empty() { + remove_front_matter_field(contents, "depends_on") + } else { + let nums: Vec = deps.iter().map(|n| n.to_string()).collect(); + let yaml_value = format!("[{}]", nums.join(", ")); + set_front_matter_field(contents, "depends_on", &yaml_value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn set_front_matter_field_inserts_new_key() { + let input = "---\nname: My Story\n---\n# Body\n"; + let output = set_front_matter_field(input, "coverage_baseline", "55.0%"); + assert!(output.contains("coverage_baseline: 55.0%")); + assert!(output.contains("name: My Story")); + assert!(output.ends_with('\n')); + } + + #[test] + fn set_front_matter_field_updates_existing_key() { + let input = "---\nname: My Story\ncoverage_baseline: 40.0%\n---\n# Body\n"; + let output = set_front_matter_field(input, "coverage_baseline", "55.0%"); + assert!(output.contains("coverage_baseline: 55.0%")); + assert!(!output.contains("40.0%")); + } + + #[test] + fn set_front_matter_field_no_op_without_front_matter() { + let input = "# No front matter\n"; + let output = set_front_matter_field(input, "coverage_baseline", "55.0%"); + assert_eq!(output, input); + } + + #[test] + fn remove_front_matter_field_removes_key() { + let input = "---\nname: My Story\nmerge_failure: \"something broke\"\n---\n# Body\n"; + let output = remove_front_matter_field(input, "merge_failure"); + assert!(!output.contains("merge_failure")); + assert!(output.contains("name: My Story")); + assert!(output.ends_with('\n')); + } + + #[test] + fn remove_front_matter_field_no_op_when_absent() { + let input = "---\nname: My Story\n---\n# Body\n"; + let output = remove_front_matter_field(input, "merge_failure"); + assert_eq!(output, input); + } + + #[test] + fn remove_front_matter_field_no_op_without_front_matter() { + let input = "# No front matter\n"; + let output = remove_front_matter_field(input, "merge_failure"); + assert_eq!(output, input); + } + + #[test] + fn clear_front_matter_field_updates_file() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("story.md"); + std::fs::write( + &path, + "---\nname: Test\nmerge_failure: \"bad\"\n---\n# Story\n", + ) + .unwrap(); + clear_front_matter_field(&path, "merge_failure").unwrap(); + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(!contents.contains("merge_failure")); + assert!(contents.contains("name: Test")); + } + + #[test] + fn write_review_hold_sets_field() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("spike.md"); + std::fs::write(&path, "---\nname: My Spike\n---\n# Spike\n").unwrap(); + write_review_hold(&path).unwrap(); + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(contents.contains("review_hold: true")); + assert!(contents.contains("name: My Spike")); + } + + #[test] + fn write_depends_on_sets_field() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("story.md"); + std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap(); + write_depends_on(&path, &[477, 478]).unwrap(); + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(contents.contains("depends_on: [477, 478]"), "{contents}"); + } + + #[test] + fn write_depends_on_removes_field_when_empty() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("story.md"); + std::fs::write(&path, "---\nname: Test\ndepends_on: [477]\n---\n# Story\n").unwrap(); + write_depends_on(&path, &[]).unwrap(); + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(!contents.contains("depends_on"), "{contents}"); + } +} diff --git a/server/src/io/story_metadata/mod.rs b/server/src/io/story_metadata/mod.rs new file mode 100644 index 00000000..b9f21c6f --- /dev/null +++ b/server/src/io/story_metadata/mod.rs @@ -0,0 +1,25 @@ +//! Story metadata — parses and modifies YAML front matter in story markdown files. +//! +//! Submodules: +//! - `types` — core data types (`QaMode`, `StoryMetadata`, `StoryMetaError`) — types used internally by the other submodules +//! - `parser` — YAML front-matter parsing and QA-mode resolution +//! - `fields` — front-matter field insertion, update, and removal helpers +//! - `deps` — dependency satisfaction checks (`check_unmet_deps`, etc.) + +mod deps; +mod fields; +mod parser; +mod types; + +pub use deps::{check_archived_deps, check_archived_deps_from_list, check_unmet_deps}; +pub use fields::{ + clear_front_matter_field, clear_front_matter_field_in_content, set_front_matter_field, + write_blocked, write_blocked_in_content, write_depends_on, write_depends_on_in_content, + write_merge_failure, write_merge_failure_in_content, write_mergemaster_attempted_in_content, + write_rejection_notes_to_content, write_review_hold, write_review_hold_in_content, +}; +pub use parser::{ + increment_retry_count_in_content, is_story_frozen_in_store, parse_front_matter, + parse_unchecked_todos, resolve_qa_mode, resolve_qa_mode_from_content, +}; +pub use types::QaMode; diff --git a/server/src/io/story_metadata/parser.rs b/server/src/io/story_metadata/parser.rs new file mode 100644 index 00000000..05804f6d --- /dev/null +++ b/server/src/io/story_metadata/parser.rs @@ -0,0 +1,294 @@ +//! Parsing logic for story YAML front matter and todo checkboxes. +use serde::Deserialize; +use std::fs; +use std::path::Path; + +use super::fields::set_front_matter_field; +use super::types::{QaMode, StoryMetaError, StoryMetadata}; + +#[derive(Debug, Deserialize)] +pub(super) struct FrontMatter { + pub name: Option, + pub coverage_baseline: Option, + pub merge_failure: Option, + pub agent: Option, + pub review_hold: Option, + /// Configurable QA mode field: "human", "server", or "agent". + pub qa: Option, + /// Number of times this story has been retried at its current pipeline stage. + pub retry_count: Option, + /// When `true`, auto-assign will skip this story (retry limit exceeded). + pub blocked: Option, + /// Story numbers this story depends on. + pub depends_on: Option>, + /// When `true`, the story is frozen. + pub frozen: Option, + /// 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, + /// Item type: "story", "bug", "spike", or "refactor". + #[serde(rename = "type")] + pub item_type: Option, + /// Set to `true` when the auto-assigner has already spawned a mergemaster + /// session for a content-conflict failure. + pub mergemaster_attempted: Option, +} + +/// Parse the YAML front matter block from a story markdown string. +pub fn parse_front_matter(contents: &str) -> Result { + 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, + run_tests_passed: front.run_tests_passed, + item_type: front.item_type, + mergemaster_attempted: front.mergemaster_attempted, + } +} + +/// Parse unchecked todo items (`- [ ] ...`) from a markdown string. +pub fn parse_unchecked_todos(contents: &str) -> Vec { + contents + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + trimmed.strip_prefix("- [ ] ").map(|text| text.to_string()) + }) + .collect() +} + +/// Resolve the effective QA mode for a story file. +/// +/// 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, + } +} + +/// Resolve the effective QA mode from story content (no filesystem access). +/// +/// Parses front matter from `contents` and returns the `qa` field if present, +/// otherwise returns `default`. +pub fn resolve_qa_mode_from_content(contents: &str, default: QaMode) -> QaMode { + match parse_front_matter(contents) { + Ok(meta) => meta.qa.unwrap_or(default), + Err(_) => default, + } +} + +/// Return `true` if the story has `frozen: true` in the content store. +/// +/// Used by the pipeline advance code to suppress stage transitions for frozen stories. +pub fn is_story_frozen_in_store(story_id: &str) -> bool { + let contents = match crate::db::read_content(story_id) { + Some(c) => c, + None => return false, + }; + parse_front_matter(&contents) + .ok() + .and_then(|m| m.frozen) + .unwrap_or(false) +} + +/// Increment the `retry_count` field in story content (pure function). +/// +/// Returns `(updated_content, new_count)`. +pub fn increment_retry_count_in_content(contents: &str) -> (String, u32) { + let current = parse_front_matter(contents) + .ok() + .and_then(|m| m.retry_count) + .unwrap_or(0); + let new_count = current + 1; + let updated = set_front_matter_field(contents, "retry_count", &new_count.to_string()); + (updated, new_count) +} + +#[cfg(test)] +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"; + assert_eq!( + parse_unchecked_todos(input), + vec!["First thing", "Second thing"] + ); + } + + #[test] + fn parse_unchecked_todos_all_checked() { + let input = "- [x] Done\n- [x] Also done\n"; + assert!(parse_unchecked_todos(input).is_empty()); + } + + #[test] + fn parse_unchecked_todos_no_checkboxes() { + let input = "# Story\nSome text\n- A bullet\n"; + assert!(parse_unchecked_todos(input).is_empty()); + } + + #[test] + fn parse_unchecked_todos_leading_whitespace() { + let input = " - [ ] Indented item\n"; + assert_eq!(parse_unchecked_todos(input), vec!["Indented item"]); + } + + #[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); + } +} diff --git a/server/src/io/story_metadata/types.rs b/server/src/io/story_metadata/types.rs new file mode 100644 index 00000000..10a32f59 --- /dev/null +++ b/server/src/io/story_metadata/types.rs @@ -0,0 +1,87 @@ +//! Core data types for story front-matter metadata. + +/// QA mode for a story: determines how the pipeline handles post-coder review. +/// +/// - `Server` — skip the QA agent; rely on server gate checks (clippy + tests). +/// If gates pass, advance straight to merge. +/// - `Agent` — spin up a QA agent (Claude session) to review code and run gates. +/// - `Human` — hold in QA for human approval after server gates pass. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QaMode { + Server, + Agent, + Human, +} + +impl QaMode { + /// Parse a string into a `QaMode`. Returns `None` for unrecognised values. + pub fn from_str(s: &str) -> Option { + match s.trim().to_lowercase().as_str() { + "server" => Some(Self::Server), + "agent" => Some(Self::Agent), + "human" => Some(Self::Human), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Server => "server", + Self::Agent => "agent", + Self::Human => "human", + } + } +} + +impl std::fmt::Display for QaMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct StoryMetadata { + pub name: Option, + pub coverage_baseline: Option, + pub merge_failure: Option, + pub agent: Option, + pub review_hold: Option, + pub qa: Option, + /// Number of times this story has been retried at its current pipeline stage. + pub retry_count: Option, + /// When `true`, auto-assign will skip this story (retry limit exceeded). + pub blocked: Option, + /// Story numbers this story depends on. Auto-assign will skip this story + /// until all dependencies have reached `5_done` or `6_archived`. + pub depends_on: Option>, + /// When `true`, the story is frozen: auto-assign skips it, the pipeline + /// does not advance it, and no mergemaster is spawned. + pub frozen: Option, + /// Set to `true` when an agent's `run_tests` call returns `passed=true`. + /// Used by the bug-645 salvage path to require real test evidence, not just + /// compilation success. + pub run_tests_passed: Option, + /// Item type: "story", "bug", "spike", or "refactor". + /// + /// Present on items created with numeric-only IDs (no slug suffix). + /// Used by the pipeline to determine routing (e.g. spikes skip QA). + pub item_type: Option, + /// Set to `true` when the auto-assigner has already spawned a mergemaster + /// session for a content-conflict failure. Prevents repeated spawns. + pub mergemaster_attempted: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StoryMetaError { + MissingFrontMatter, + InvalidFrontMatter(String), +} + +impl std::fmt::Display for StoryMetaError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StoryMetaError::MissingFrontMatter => write!(f, "Missing front matter"), + StoryMetaError::InvalidFrontMatter(msg) => write!(f, "Invalid front matter: {msg}"), + } + } +}