huskies: merge 865

This commit is contained in:
dave
2026-05-08 14:24:20 +00:00
parent fac4442969
commit 9be438e6d3
47 changed files with 733 additions and 731 deletions
+55 -153
View File
@@ -1,18 +1,15 @@
//! Dependency resolution: check whether story dependencies are satisfied.
//! Dependency resolution helpers — filesystem-backed lookups that don't
//! require any in-memory CRDT state.
//!
//! The CRDT-backed equivalents (`check_unmet_deps_crdt`,
//! `check_archived_deps_crdt`) live in `crate::crdt_state::read`; callers
//! that already have a CRDT entry should prefer those. This module exists
//! for the story-creation path, where dependency IDs are known in memory
//! before any CRDT entry has been written.
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();
@@ -35,8 +32,7 @@ fn dep_is_done(project_root: &Path, dep_number: u32) -> bool {
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).
/// Return `true` if a story with the given numeric ID exists specifically in `6_archived`.
fn dep_is_archived(project_root: &Path, dep_number: u32) -> bool {
let prefix = format!("{dep_number}_");
let exact = dep_number.to_string();
@@ -60,11 +56,37 @@ fn dep_is_archived(project_root: &Path, dep_number: u32) -> bool {
false
}
/// Return the list of dependency story numbers from `story_id`'s front matter
/// that have **not** yet reached `5_done` or `6_archived`.
/// Given an explicit list of dep numbers, return those that have NOT 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).
/// Used by callers that have the dep list in memory (e.g. story update at
/// promotion time) and want a filesystem fact rather than an in-memory CRDT
/// state which may be stale during transitions.
pub fn check_unmet_deps_from_list(project_root: &Path, deps: &[u32]) -> Vec<u32> {
deps.iter()
.copied()
.filter(|&dep| !dep_is_done(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<u32> {
deps.iter()
.copied()
.filter(|&dep| dep_is_archived(project_root, dep))
.collect()
}
/// Filesystem-backed unmet-dep check for a story file in `<stage_dir>/`.
///
/// Reads the story's `depends_on` list from its YAML front matter and returns
/// the numeric deps still pending (not yet in `5_done` or `6_archived`). This
/// is the legacy API used by the auto-assigner when the CRDT layer is not yet
/// initialised; CRDT-aware callers should prefer `check_unmet_deps_crdt`.
pub fn check_unmet_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec<u32> {
let path = project_root
.join(".huskies")
@@ -75,23 +97,20 @@ pub fn check_unmet_deps(project_root: &Path, stage_dir: &str, story_id: &str) ->
Ok(c) => c,
Err(_) => return Vec::new(),
};
let deps = match parse_front_matter(&contents)
let deps = match crate::db::yaml_legacy::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()
check_unmet_deps_from_list(project_root, &deps)
}
/// 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).
/// Filesystem-backed archived-dep check for a story file in `<stage_dir>/`.
///
/// 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.
/// Reads the story's `depends_on` list from its YAML front matter and returns
/// the numeric deps that satisfied via `6_archived` rather than `5_done`.
pub fn check_archived_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec<u32> {
let path = project_root
.join(".huskies")
@@ -102,141 +121,40 @@ pub fn check_archived_deps(project_root: &Path, stage_dir: &str, story_id: &str)
Ok(c) => c,
Err(_) => return Vec::new(),
};
let deps = match parse_front_matter(&contents)
let deps = match crate::db::yaml_legacy::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<u32> {
deps.iter()
.copied()
.filter(|&dep| dep_is_archived(project_root, dep))
.collect()
check_archived_deps_from_list(project_root, &deps)
}
#[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(&current).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(&current).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();
std::fs::write(archived.join("100_story_old.md"), "# old\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() {
fn check_unmet_deps_from_list_returns_unmet_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(&current).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());
std::fs::write(done.join("477_story_dep.md"), "# dep\n").unwrap();
let unmet = check_unmet_deps_from_list(tmp.path(), &[477, 478]);
assert_eq!(unmet, vec![478]);
}
/// 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(&current).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();
@@ -244,25 +162,12 @@ mod tests {
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.
std::fs::write(done.join("10_story_done.md"), "# done\n").unwrap();
std::fs::write(archived.join("20_story_old.md"), "# old\n").unwrap();
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();
@@ -270,13 +175,10 @@ mod tests {
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.
std::fs::write(done.join("10_story_done.md"), "# done\n").unwrap();
std::fs::write(archived.join("20_story_old.md"), "# old\n").unwrap();
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));
}
}
-212
View File
@@ -1,212 +0,0 @@
//! 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<String> = 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<String> = 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 `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 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 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")
}
#[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"));
}
}
+7 -14
View File
@@ -1,24 +1,17 @@
//! Story metadata — parses and modifies YAML front matter in story markdown files.
//! Story metadata helpers — CRDT-backed lookups plus pure-content parsers.
//!
//! 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.)
//! Story 865 stripped YAML front matter from the content store; this module
//! no longer parses or writes YAML. What remains:
//! - `types` — `QaMode` enum.
//! - `parser` — `parse_unchecked_todos`, `resolve_qa_mode`, `is_story_frozen_in_store`.
//! - `deps` — dependency satisfaction checks (CRDT-backed).
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_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::{
is_story_frozen_in_store, parse_front_matter, parse_unchecked_todos, resolve_qa_mode,
resolve_qa_mode_from_content,
is_story_frozen_in_store, parse_unchecked_todos, resolve_qa_mode, resolve_qa_mode_from_content,
};
pub use types::QaMode;
+31 -232
View File
@@ -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
);
}
}
+1 -56
View File
@@ -1,4 +1,4 @@
//! Core data types for story front-matter metadata.
//! Core data types for story metadata.
/// QA mode for a story: determines how the pipeline handles post-coder review.
///
@@ -39,58 +39,3 @@ impl std::fmt::Display for QaMode {
f.write_str(self.as_str())
}
}
/// Parsed YAML front-matter fields from a story markdown file.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StoryMetadata {
pub name: Option<String>,
pub coverage_baseline: Option<String>,
pub merge_failure: Option<String>,
pub agent: Option<String>,
pub review_hold: Option<bool>,
pub qa: Option<QaMode>,
/// 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. Auto-assign will skip this story
/// until all dependencies have reached `5_done` or `6_archived`.
pub depends_on: Option<Vec<u32>>,
/// When `true`, the story is frozen: auto-assign skips it, the pipeline
/// does not advance it, and no mergemaster is spawned.
pub frozen: Option<bool>,
/// Pipeline stage to restore when unfreezing (e.g. `"2_current"`).
/// Written by `transition_to_frozen`; cleared by `transition_to_unfrozen`.
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 require real test evidence, not just
/// compilation success.
pub run_tests_passed: Option<bool>,
/// 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<String>,
/// 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<bool>,
/// Epic this item belongs to. The value is the epic's numeric ID (e.g. "880").
/// Set on story/bug/spike/refactor items to declare membership in an epic.
pub epic: Option<String>,
}
/// Errors that can occur when parsing story front-matter metadata.
#[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}"),
}
}
}