//! Story lifecycle helpers — archival and stage transitions for pipeline items. //! //! All pipeline state lives in the CRDT. These functions never consult the //! filesystem for work-item data — CRDT lookup failures propagate as errors. use std::path::Path; use std::process::Command; use crate::io::story_metadata::clear_front_matter_field_in_content; use crate::slog; type ContentTransform = Option String>>; /// Determine the item type ("story", "bug", "spike", or "refactor") from the item ID. /// /// For slug-format IDs (e.g. `"4_bug_login_crash"`), the type is embedded in the ID. /// For numeric-only IDs (e.g. `"4"`), the type is read from the `type:` field in /// the content-store front matter. Falls back to `"story"` if not found. pub(crate) fn item_type_from_id(item_id: &str) -> &'static str { let after_num = item_id.trim_start_matches(|c: char| c.is_ascii_digit()); if after_num.starts_with("_bug_") { return "bug"; } else if after_num.starts_with("_spike_") { return "spike"; } else if after_num.starts_with("_refactor_") { return "refactor"; } // Numeric-only ID: check content store front matter for explicit type. if after_num.is_empty() && let Some(content) = crate::db::read_content(item_id) && let Ok(meta) = crate::io::story_metadata::parse_front_matter(&content) && let Some(t) = meta.item_type.as_deref() { return match t { "bug" => "bug", "spike" => "spike", "refactor" => "refactor", _ => "story", }; } "story" } /// Move a work item to a new pipeline stage via the database. /// /// Looks up the item in the CRDT to verify it exists in one of the expected /// `sources` stages, then updates the stage. Optionally clears front-matter /// fields from the stored content. Returns the source stage on success. fn move_item<'a>( story_id: &str, sources: &'a [&'a str], target_dir: &str, extra_done_dirs: &[&str], missing_ok: bool, fields_to_clear: &[&str], ) -> Result, String> { // Check if the item is already in the target stage or a done stage. // Use the typed projection for compile-safe stage comparison. if let Ok(Some(typed_item)) = crate::pipeline_state::read_typed(story_id) { let current_dir = typed_item.stage.dir_name(); if current_dir == target_dir || extra_done_dirs.contains(¤t_dir) { return Ok(None); // Idempotent: already there. } // Verify it's in one of the expected source stages. let src_dir = sources.iter().find(|&&s| current_dir == s).copied(); let src_dir = match src_dir { Some(s) => s, None if missing_ok => { // Item is in CRDT but not in an expected source stage — do not // overwrite its current stage. This prevents promote_ready_backlog_stories // from demoting a story that has already advanced to merge/done (bug 524). return Ok(None); } None => { let locs = sources .iter() .map(|s| format!("work/{s}/")) .collect::>() .join(" or "); return Err(format!("Work item '{story_id}' not found in {locs}.")); } }; // Optionally clear front-matter fields from the stored content. let transform: ContentTransform = if fields_to_clear.is_empty() { None } else { let fields: Vec = fields_to_clear.iter().map(|s| s.to_string()).collect(); Some(Box::new(move |content: &str| { let mut result = content.to_string(); for field in &fields { result = clear_front_matter_field_in_content(&result, field); } result })) }; crate::db::move_item_stage(story_id, target_dir, transform.as_ref().map(|f| f.as_ref())); slog!("[lifecycle] Moved '{story_id}' from work/{src_dir}/ to work/{target_dir}/"); return Ok(Some(src_dir)); } if missing_ok { slog!("[lifecycle] Work item '{story_id}' not found; skipping move to work/{target_dir}/"); return Ok(None); } let locs = sources .iter() .map(|s| format!("work/{s}/")) .collect::>() .join(" or "); Err(format!("Work item '{story_id}' not found in {locs}.")) } /// Move a work item (story, bug, or spike) from `1_backlog` to `work/2_current/`. /// /// Only promotes from `1_backlog` — stories already in later stages (3_qa, 4_merge, /// etc.) are left untouched. This prevents coders from accidentally demoting a story /// that has already advanced past the coding stage. /// Idempotent: if already in `2_current/`, returns Ok. If not found, logs and returns Ok. pub fn move_story_to_current(story_id: &str) -> Result<(), String> { move_item(story_id, &["1_backlog"], "2_current", &[], true, &[]).map(|_| ()) } /// Check whether a feature branch `feature/story-{story_id}` exists and has /// commits that are not yet on master. Returns `true` when there is unmerged /// work, `false` when there is no branch or all its commits are already /// reachable from master. pub fn feature_branch_has_unmerged_changes(project_root: &Path, story_id: &str) -> bool { let branch = format!("feature/story-{story_id}"); // Check if the branch exists. let branch_check = Command::new("git") .args(["rev-parse", "--verify", &branch]) .current_dir(project_root) .output(); match branch_check { Ok(out) if out.status.success() => {} _ => return false, // No feature branch → nothing to merge. } // Check if the branch has commits not reachable from master. let log = Command::new("git") .args(["log", &format!("master..{branch}"), "--oneline"]) .current_dir(project_root) .output(); match log { Ok(out) => { let stdout = String::from_utf8_lossy(&out.stdout); !stdout.trim().is_empty() } Err(_) => false, } } /// Move a story from `work/2_current/`, `work/3_qa/`, or `work/4_merge/` to `work/5_done/`. /// /// Idempotent if already in `5_done/` or `6_archived/`. Errors if not found in any earlier stage. /// Spikes may transition directly from `3_qa/` to `5_done/`, skipping the merge stage. pub fn move_story_to_done(story_id: &str) -> Result<(), String> { move_item( story_id, &["2_current", "3_qa", "4_merge"], "5_done", &["6_archived"], false, &["merge_failure", "retry_count", "blocked"], ) .map(|_| ()) } /// Move a story/bug from `work/2_current/` or `work/3_qa/` to `work/4_merge/`. /// /// Idempotent if already in `4_merge/`. Errors if not found in `2_current/` or `3_qa/`. pub fn move_story_to_merge(story_id: &str) -> Result<(), String> { move_item( story_id, &["2_current", "3_qa"], "4_merge", &["5_done", "6_archived"], false, &["retry_count", "blocked"], ) .map(|_| ()) } /// Move a story/bug from `work/2_current/` to `work/3_qa/`. /// /// Idempotent if already in `3_qa/`. Errors if not found in `2_current/`. pub fn move_story_to_qa(story_id: &str) -> Result<(), String> { move_item( story_id, &["2_current"], "3_qa", &["5_done", "6_archived"], false, &["retry_count", "blocked"], ) .map(|_| ()) } /// Move a story from `work/3_qa/` back to `work/2_current/`, clearing `review_hold` and writing notes. pub fn reject_story_from_qa(story_id: &str, notes: &str) -> Result<(), String> { let moved = move_item( story_id, &["3_qa"], "2_current", &[], false, &["review_hold"], )?; if moved.is_some() && !notes.is_empty() { // Append rejection notes to the stored content. if let Some(content) = crate::db::read_content(story_id) { let updated = crate::io::story_metadata::write_rejection_notes_to_content(&content, notes); crate::db::write_content(story_id, &updated); // Re-sync to DB. crate::db::write_item_with_content(story_id, "2_current", &updated); } } Ok(()) } /// Move any work item to an arbitrary pipeline stage by searching all stages. /// /// Accepts `target_stage` as one of: `backlog`, `current`, `qa`, `merge`, `done`. /// Idempotent: if the item is already in the target stage, returns Ok. /// Returns `(from_stage, to_stage)` on success. pub fn move_story_to_stage(story_id: &str, target_stage: &str) -> Result<(String, String), String> { const STAGES: &[(&str, &str)] = &[ ("backlog", "1_backlog"), ("current", "2_current"), ("qa", "3_qa"), ("merge", "4_merge"), ("done", "5_done"), ("archived", "6_archived"), ]; let target_dir = STAGES .iter() .filter(|(name, _)| *name != "archived") .find(|(name, _)| *name == target_stage) .map(|(_, dir)| *dir) .ok_or_else(|| { format!( "Invalid target_stage '{target_stage}'. Must be one of: backlog, current, qa, merge, done" ) })?; let all_dirs: Vec<&str> = STAGES.iter().map(|(_, dir)| *dir).collect(); match move_item(story_id, &all_dirs, target_dir, &[], false, &[]) .map_err(|_| format!("Work item '{story_id}' not found in any pipeline stage."))? { Some(src_dir) => { let from_stage = STAGES .iter() .find(|(_, dir)| *dir == src_dir) .map(|(name, _)| *name) .unwrap_or(src_dir); Ok((from_stage.to_string(), target_stage.to_string())) } None => Ok((target_stage.to_string(), target_stage.to_string())), } } /// Move a bug from `work/2_current/` or `work/1_backlog/` to `work/5_done/`. /// /// Idempotent if already in `5_done/`. Errors if not found in `2_current/` or `1_backlog/`. pub fn close_bug_to_archive(bug_id: &str) -> Result<(), String> { move_item( bug_id, &["2_current", "1_backlog"], "5_done", &[], false, &[], ) .map(|_| ()) } #[cfg(test)] mod tests { use super::*; // ── move_story_to_current tests ──────────────────────────────────────────── #[test] fn move_story_to_current_from_crdt() { // Seed via CRDT — the sole source of truth for pipeline state. crate::db::ensure_content_store(); crate::db::write_item_with_content( "99950_story_lifecycle", "1_backlog", "---\nname: Lifecycle Test\n---\n# Story\n", ); move_story_to_current("99950_story_lifecycle").unwrap(); // Verify the CRDT now has the item in 2_current. let item = crate::pipeline_state::read_typed("99950_story_lifecycle") .expect("CRDT read should succeed") .expect("item should exist in CRDT after move"); assert_eq!( item.stage.dir_name(), "2_current", "item should be in 2_current after move" ); } #[test] fn move_story_to_current_noop_when_not_found() { assert!(move_story_to_current("99_missing").is_ok()); } /// Lifecycle operation runs to completion using only CRDT state; /// no `.huskies/work//` tree is consulted because no `project_root` /// is passed — the functions operate purely on the CRDT. #[test] fn move_story_uses_only_crdt_no_fs_shadow() { crate::db::ensure_content_store(); crate::db::write_item_with_content( "99951_story_crdt_only", "2_current", "---\nname: CRDT Only Test\n---\n# Story\n", ); // No filesystem path is involved — lifecycle functions no longer // accept a project_root, proving they never touch the filesystem. move_story_to_done("99951_story_crdt_only").unwrap(); let item = crate::pipeline_state::read_typed("99951_story_crdt_only") .expect("CRDT read should succeed") .expect("item should exist in CRDT"); assert_eq!( item.stage.dir_name(), "5_done", "item should be in 5_done after move" ); } // ── item_type_from_id tests ──────────────────────────────────────────────── #[test] fn item_type_from_id_detects_types() { assert_eq!(item_type_from_id("1_bug_test"), "bug"); assert_eq!(item_type_from_id("1_spike_research"), "spike"); assert_eq!(item_type_from_id("50_story_my_story"), "story"); assert_eq!(item_type_from_id("1_story_simple"), "story"); assert_eq!(item_type_from_id("1_refactor_cleanup"), "refactor"); } #[test] fn item_type_from_id_falls_back_to_content_store_for_numeric_ids() { crate::db::ensure_content_store(); // Write a bug item with numeric-only ID into the content store. let bug_content = "---\ntype: bug\nname: \"Test Bug\"\n---\n\n# Bug 9999: Test Bug\n"; crate::db::write_content("9999", bug_content); let spike_content = "---\ntype: spike\nname: \"Test Spike\"\n---\n\n# Spike 9998: Test Spike\n"; crate::db::write_content("9998", spike_content); let refactor_content = "---\ntype: refactor\nname: \"Test Refactor\"\n---\n\n# Refactor 9997: Test Refactor\n"; crate::db::write_content("9997", refactor_content); let story_content = "---\ntype: story\nname: \"Test Story\"\n---\n\n# Story 9996: Test Story\n"; crate::db::write_content("9996", story_content); assert_eq!(item_type_from_id("9999"), "bug"); assert_eq!(item_type_from_id("9998"), "spike"); assert_eq!(item_type_from_id("9997"), "refactor"); assert_eq!(item_type_from_id("9996"), "story"); // No content store entry → defaults to "story". assert_eq!(item_type_from_id("99999"), "story"); } // ── feature_branch_has_unmerged_changes tests ──────────────────────────── fn init_git_repo(repo: &std::path::Path) { Command::new("git") .args(["init"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["config", "user.email", "test@test.com"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["config", "user.name", "Test"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "--allow-empty", "-m", "init"]) .current_dir(repo) .output() .unwrap(); } /// Bug 226: feature_branch_has_unmerged_changes returns true when the /// feature branch has commits not on master. #[test] fn feature_branch_has_unmerged_changes_detects_unmerged_code() { use std::fs; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Create a feature branch with a code commit. Command::new("git") .args(["checkout", "-b", "feature/story-50_story_test"]) .current_dir(repo) .output() .unwrap(); fs::write(repo.join("feature.rs"), "fn main() {}").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "add feature"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); assert!( feature_branch_has_unmerged_changes(repo, "50_story_test"), "should detect unmerged changes on feature branch" ); } /// Bug 226: feature_branch_has_unmerged_changes returns false when no /// feature branch exists. #[test] fn feature_branch_has_unmerged_changes_false_when_no_branch() { use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); assert!( !feature_branch_has_unmerged_changes(repo, "99_nonexistent"), "should return false when no feature branch" ); } }