huskies: merge 484_story_story_dependencies_in_pipeline_auto_assign

This commit is contained in:
dave
2026-04-04 21:43:29 +00:00
parent 26de009259
commit 5413a26406
6 changed files with 665 additions and 3 deletions
+162
View File
@@ -53,6 +53,9 @@ pub struct StoryMetadata {
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>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -83,6 +86,8 @@ struct FrontMatter {
retry_count: Option<u32>,
/// When `true`, auto-assign will skip this story (retry limit exceeded).
blocked: Option<bool>,
/// Story numbers this story depends on.
depends_on: Option<Vec<u32>>,
}
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
@@ -122,6 +127,7 @@ fn build_metadata(front: FrontMatter) -> StoryMetadata {
qa,
retry_count: front.retry_count,
blocked: front.blocked,
depends_on: front.depends_on,
}
}
@@ -279,6 +285,72 @@ pub fn write_blocked(path: &Path) -> Result<(), String> {
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<String> = 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<u32> {
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`.
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
}
/// Append rejection notes to a story file body.
///
/// Adds a `## QA Rejection Notes` section at the end of the file so the coder
@@ -529,6 +601,96 @@ workflow: tdd
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(&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();
assert!(dep_is_done(tmp.path(), 100));
assert!(!dep_is_done(tmp.path(), 101));
}
#[test]
fn write_rejection_notes_appends_section() {
let tmp = tempfile::tempdir().unwrap();