//! 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. //! //! Every lifecycle function routes through the typed state machine //! ([`crate::pipeline_state::apply_transition`]) so that illegal transitions //! are rejected and every stage change emits a [`TransitionFired`] event. use std::num::NonZeroU32; use std::path::Path; use std::process::Command; use crate::pipeline_state::{ ApplyError, ArchiveReason, BranchName, GitSha, MergeFailureKind, PipelineEvent, Stage, StoryId, TransitionFired, apply_transition, stage_label, }; use crate::slog; /// 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 typed CRDT /// `item_type` register (story 933). 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: consult the typed CRDT register. if after_num.is_empty() && let Some(view) = crate::crdt_state::read_item(item_id) && let Some(t) = view.item_type() { use crate::io::story_metadata::ItemType; return match t { ItemType::Bug => "bug", ItemType::Spike => "spike", ItemType::Refactor => "refactor", _ => "story", }; } "story" } /// 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> { match apply_transition(story_id, PipelineEvent::DepsMet, None) { Ok(_) => Ok(()), Err(ApplyError::NotFound(_)) => { slog!("[lifecycle] Work item '{story_id}' not found; skipping move to work/2_current/"); Ok(()) } Err(ApplyError::InvalidTransition(_)) => { // Already promoted or in a later stage — idempotent no-op. Ok(()) } Err(ApplyError::Projection(_)) => Ok(()), } } /// 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> { let item = read_typed_or_err(story_id)?; // Idempotent: already at or past done. if matches!( item.stage, Stage::Done { .. } | Stage::Archived { .. } | Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } ) { return Ok(()); } let event = match &item.stage { Stage::Merge { .. } => PipelineEvent::MergeSucceeded { merge_commit: GitSha("accepted".to_string()), }, Stage::MergeFailure { .. } => PipelineEvent::Accepted, Stage::Coding { .. } | Stage::Qa | Stage::Backlog => PipelineEvent::Close, _ => { return Err(format!( "Work item '{story_id}' is in {} — cannot move to done.", stage_label(&item.stage) )); } }; apply_transition(story_id, event, None) .map(|_| ()) .map_err(|e| e.to_string()) } /// 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> { let item = read_typed_or_err(story_id)?; // Idempotent: already at or past merge. if matches!( item.stage, Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::Done { .. } | Stage::Archived { .. } | Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } ) { return Ok(()); } let branch = BranchName(format!("feature/story-{story_id}")); let commits = NonZeroU32::new(1).expect("1 is non-zero"); let event = match &item.stage { Stage::Coding { .. } => PipelineEvent::QaSkipped { feature_branch: branch, commits_ahead: commits, }, Stage::Qa => PipelineEvent::GatesPassed { feature_branch: branch, commits_ahead: commits, }, _ => { return Err(format!( "Work item '{story_id}' not found in work/2_current/ or work/3_qa/." )); } }; apply_transition(story_id, event, None) .map(|_| ()) .map_err(|e| e.to_string()) } /// 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> { let item = read_typed_or_err(story_id)?; // Idempotent: already at or past qa. if matches!( item.stage, Stage::Qa | Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::Done { .. } | Stage::Archived { .. } | Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } ) { return Ok(()); } apply_transition(story_id, PipelineEvent::GatesStarted, None) .map(|_| ()) .map_err(|e| e.to_string()) } /// Move a story from `work/3_qa/` back to `work/2_current/`, appending /// rejection notes. Story 945: the legacy `review_hold` flag is gone; if a /// story is in `Stage::ReviewHold`, the `GatesFailed` event simply fails to /// transition, which is the correct behaviour (you cannot reject from QA a /// story that is currently parked in review hold). pub fn reject_story_from_qa(story_id: &str, notes: &str) -> Result<(), String> { if notes.is_empty() { apply_transition( story_id, PipelineEvent::GatesFailed { reason: notes.to_string(), }, None, ) .map(|_| ()) .map_err(|e| e.to_string()) } else { let notes_owned = notes.to_string(); let transform = move |content: &str| -> String { format!("{content}\n\n## QA Rejection Notes\n\n{notes_owned}\n") }; apply_transition( story_id, PipelineEvent::GatesFailed { reason: notes.to_string(), }, Some(&transform), ) .map(|_| ()) .map_err(|e| e.to_string()) } } /// Transition a story to the `Blocked` stage via the state machine. /// /// Builds a `PipelineEvent::Block { reason }`, validates the transition, and /// writes the resulting `Stage::Blocked` to the CRDT. Returns `Err` on /// `TransitionError` — callers must NOT fall back to direct register writes. pub fn transition_to_blocked(story_id: &str, reason: &str) -> Result<(), String> { apply_transition( story_id, PipelineEvent::Block { reason: reason.to_string(), }, None, ) .map(|_| ()) .map_err(|e| e.to_string()) } /// Transition a story from `Stage::Merge` (or `Stage::MergeFailure`) to /// `Stage::MergeFailure` via the state machine. /// /// Builds a `PipelineEvent::MergeFailed { kind }`, validates the transition, /// writes the resulting `Stage::MergeFailure` to the CRDT, and persists two /// display-only copies for status tools: /// - `ContentKey::GateOutput`: the kind's gate-output string so the CRDT /// projection layer can reconstruct the kind after a server restart. /// - `MergeJob.error`: human-readable description for status renderers. /// /// When the story is already in `MergeFailure`, this is a silent self-loop: the /// returned `TransitionFired::before` will be `Stage::MergeFailure`. Callers /// should suppress re-notification in that case to avoid duplicate chat messages. /// /// Returns `Err` on `TransitionError` — callers must NOT fall back to direct register writes. pub fn transition_to_merge_failure( story_id: &str, kind: MergeFailureKind, ) -> Result { let display = kind.display_reason(); let gate_output = kind.to_gate_output(); // Serialise the typed kind BEFORE it is moved into the event so both the // JSON key and the legacy string key can be written after the transition. let kind_json = serde_json::to_string(&kind).unwrap_or_default(); let fired = apply_transition(story_id, PipelineEvent::MergeFailed { kind }, None) .map_err(|e| e.to_string())?; // Persist the typed kind as JSON so the CRDT projection can reconstruct it // without substring-scanning the gate output string (story 986). crate::db::write_content( crate::db::ContentKey::MergeFailureKind(story_id), &kind_json, ); // Persist legacy gate-output string for human-readable display and // backward-compatible fallback on pre-986 data. crate::db::write_content(crate::db::ContentKey::GateOutput(story_id), &gate_output); // Persist human-readable description on the MergeJob CRDT entry so display // tools (status renderer, pipeline state view) can surface it. crate::crdt_state::write_merge_job( story_id, "failed", chrono::Utc::now().timestamp() as f64, None, Some(&display), ); Ok(fired) } /// Transition a story out of a blocked state via the state machine. /// /// Builds a `PipelineEvent::Unblock`, validates the transition, writes the /// result to the CRDT, and resets `retry_count` to 0. The destination stage /// depends on the current stage: `Blocked` → `Coding`; `MergeFailure` → `Merge`. /// Returns `Err` on `TransitionError` — callers must NOT fall back to direct /// register writes. pub fn transition_to_unblocked(story_id: &str) -> Result<(), String> { apply_transition(story_id, PipelineEvent::Unblock, None) .map(|_| ()) .map_err(|e| e.to_string())?; // Story 945: the legacy `blocked` boolean flag is gone — `Stage::Blocked` // is the single source of truth. We still reset `retry_count` so a fresh // attempt at the resumed stage starts at zero. crate::crdt_state::set_retry_count(story_id, 0); Ok(()) } /// Abandon a work item, transitioning it to `Stage::Abandoned`. /// /// Valid from any active or done stage. Returns `Err` when the item is not /// found or the transition is invalid for the current stage. #[allow(dead_code)] pub fn abandon_story(story_id: &str) -> Result<(), String> { apply_transition(story_id, PipelineEvent::Abandon, None) .map(|_| ()) .map_err(|e| e.to_string()) } /// Mark a work item as superseded by another, transitioning to `Stage::Superseded`. /// /// `superseded_by` is the story ID of the replacement work item. Valid from /// any active or done stage. Returns `Err` on unknown item or invalid transition. #[allow(dead_code)] pub fn supersede_story(story_id: &str, superseded_by: &str) -> Result<(), String> { apply_transition( story_id, PipelineEvent::Supersede { by: StoryId(superseded_by.to_string()), }, None, ) .map(|_| ()) .map_err(|e| e.to_string()) } /// Permanently reject a work item, transitioning it to `Stage::Rejected`. /// /// `reason` must be non-empty. Valid from any active stage (backlog, coding, /// qa, or merge). Returns `Err` on unknown item or invalid transition. #[allow(dead_code)] pub fn reject_story(story_id: &str, reason: &str) -> Result<(), String> { apply_transition( story_id, PipelineEvent::Reject { reason: reason.to_string(), }, None, ) .map(|_| ()) .map_err(|e| e.to_string()) } /// Map a (current stage, target stage name) pair to the appropriate PipelineEvent. fn map_stage_move_to_event( from: &Stage, target: &str, story_id: &str, ) -> Result { let branch = || BranchName(format!("feature/story-{story_id}")); let nz1 = || NonZeroU32::new(1).expect("1 is non-zero"); match (from, target) { (Stage::Upcoming, "backlog") => Ok(PipelineEvent::Triage), (Stage::Backlog, "current") => Ok(PipelineEvent::DepsMet), (Stage::Coding { .. }, "qa") => Ok(PipelineEvent::GatesStarted), (Stage::Coding { .. }, "merge") => Ok(PipelineEvent::QaSkipped { feature_branch: branch(), commits_ahead: nz1(), }), (Stage::Qa, "merge") => Ok(PipelineEvent::GatesPassed { feature_branch: branch(), commits_ahead: nz1(), }), (Stage::Coding { .. }, "backlog") | (Stage::Qa, "backlog") | (Stage::Merge { .. }, "backlog") | (Stage::Blocked { .. }, "backlog") => Ok(PipelineEvent::Demote), (Stage::Qa, "current") => Ok(PipelineEvent::GatesFailed { reason: "manual move".to_string(), }), (Stage::Merge { .. }, "done") => Ok(PipelineEvent::MergeSucceeded { merge_commit: GitSha("manual".to_string()), }), (Stage::Coding { .. } | Stage::Qa | Stage::Backlog, "done") => Ok(PipelineEvent::Close), (Stage::Blocked { .. }, "current") => Ok(PipelineEvent::Unblock), // Story 919: MergeFailure + Unblock goes to Merge (re-attempt); manual // demotion to backlog uses Demote to park it without a retry. (Stage::MergeFailure { .. }, "backlog") => Ok(PipelineEvent::Demote), // Story 973: abort an in-flight merge, sending the story back to Coding. (Stage::Merge { .. }, "current") => Ok(PipelineEvent::MergeAborted), // Story 971: send MergeFailure story back to Coding so a coder can fix it. (Stage::MergeFailure { .. }, "current") => Ok(PipelineEvent::FixupRequested), // Operator override on the exhausted-respawn terminal state: still // a coder fixup, but reached via the budget-exhausted path. (Stage::MergeFailureFinal { .. }, "current") => Ok(PipelineEvent::FixupRequested), // Story 972: send MergeFailure story back to Qa for a QA agent to re-review. (Stage::MergeFailure { .. }, "qa") => Ok(PipelineEvent::ReQueuedForQa), // Story 974: reopen a Done story for a post-merge hotfix. (Stage::Done { .. }, "current") => Ok(PipelineEvent::HotfixRequested), ( Stage::Archived { reason: ArchiveReason::Blocked { .. }, .. }, "backlog", ) | ( Stage::Archived { reason: ArchiveReason::MergeFailed { .. }, .. }, "backlog", ) => Ok(PipelineEvent::Unblock), _ => Err(format!( "Invalid target_stage '{target}'. Cannot transition from {} to {target}.", stage_label(from), )), } } /// Move any work item to an arbitrary pipeline stage by searching all stages. /// /// Accepts `target_stage` as one of: `backlog`, `current`, `qa`, `merge`, `done`. /// (`current` is the user-facing alias for the `coding` stage.) /// 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> { // Validate target. We accept the user-facing aliases (which include // "current" as the historical alias for "coding") and normalise to the // canonical clean wire form for the idempotency check. let target_wire = match target_stage { "backlog" => "backlog", "current" => "coding", "qa" => "qa", "merge" => "merge", "done" => "done", _ => { return Err(format!( "Invalid target_stage '{target_stage}'. Must be one of: backlog, current, qa, merge, done" )); } }; let item = read_typed_or_err(story_id)?; let from_name = stage_to_name(&item.stage); // Idempotent: already in the target stage. Compare via Stage discriminant // so the check is typed rather than a raw string equality. let already_there = Stage::from_dir(target_wire) .is_some_and(|t| std::mem::discriminant(&item.stage) == std::mem::discriminant(&t)); if already_there { return Ok((target_stage.to_string(), target_stage.to_string())); } let event = map_stage_move_to_event(&item.stage, target_stage, story_id)?; apply_transition(story_id, event.clone(), None).map_err(|e| e.to_string())?; // Story 971: after moving MergeFailure → Coding, set retry_count=1 so // maybe_inject_gate_failure fires on the next spawn. Must happen AFTER // apply_transition because move_item_stage resets retry_count to 0. if matches!(event, PipelineEvent::FixupRequested) { crate::crdt_state::set_retry_count(story_id, 1); } // Story 973: when aborting an in-flight merge, mark the CRDT merge job as // "cancelled" so the background task skips state-machine transitions and // watcher notifications once the git operation finishes. if matches!(event, PipelineEvent::MergeAborted) && let Some(job) = crate::crdt_state::read_merge_job(story_id) { crate::crdt_state::write_merge_job(story_id, "cancelled", job.started_at, None, None); } Ok((from_name.to_string(), target_stage.to_string())) } /// Transition a story from `Stage::MergeFailure` to `Stage::Merge` when the /// deterministic-merge pipeline starts a retry attempt. /// /// Preserves `feature_branch` and `commits_ahead` from the preceding /// `MergeFailure` state so the retry continues with the exact same branch. /// If the story is already in `Stage::Merge` or any other stage, this is a /// silent no-op — callers do not need to pre-check the current stage. /// /// Called by `start_merge_agent_work` before the squash-merge pipeline starts /// so the UI shows an active merge state rather than a stale failure indicator /// (story 1051, AC 1 and AC 4). pub fn transition_merge_failure_to_retry(story_id: &str) -> Result<(), String> { match apply_transition(story_id, PipelineEvent::MergeRetryStarted, None) { Ok(_) => Ok(()), Err(ApplyError::InvalidTransition(_)) | Err(ApplyError::NotFound(_)) => Ok(()), Err(e) => Err(e.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> { let item = read_typed_or_err(bug_id)?; if matches!( item.stage, Stage::Done { .. } | Stage::Archived { .. } | Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } ) { return Ok(()); } apply_transition(bug_id, PipelineEvent::Close, None) .map(|_| ()) .map_err(|e| e.to_string()) } /// Read a typed pipeline item or return a user-facing error. fn read_typed_or_err(story_id: &str) -> Result { crate::pipeline_state::read_typed(story_id) .map_err(|e| format!("Work item '{story_id}': {e}"))? .ok_or_else(|| format!("Work item '{story_id}' not found in any pipeline stage.")) } /// Map a Stage variant to the short name used by `move_story_to_stage` return values. fn stage_to_name(s: &Stage) -> &'static str { match s { Stage::Upcoming => "upcoming", Stage::Backlog => "backlog", Stage::Coding { .. } => "current", Stage::Blocked { .. } => "blocked", Stage::Qa => "qa", Stage::Merge { .. } => "merge", Stage::MergeFailure { .. } => "merge_failure", Stage::MergeFailureFinal { .. } => "merge_failure_final", Stage::Frozen { .. } => "frozen", Stage::ReviewHold { .. } => "review_hold", Stage::Done { .. } => "done", Stage::Archived { .. } => "archived", Stage::Abandoned { .. } => "abandoned", Stage::Superseded { .. } => "superseded", Stage::Rejected { .. } => "rejected", } } #[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", crate::db::ItemMeta::named("Lifecycle Test"), ); 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(), "coding", "item should be in coding 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", crate::db::ItemMeta::named("CRDT Only Test"), ); // 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(), "done", "item should be in 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_uses_crdt_register_for_numeric_ids() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); // Story 933: numeric-only IDs read item_type from the CRDT register. for (id, t) in [ ("9999", "bug"), ("9998", "spike"), ("9997", "refactor"), ("9996", "story"), ] { crate::db::write_item_with_content( id, "1_backlog", &format!("# Test {t}\n"), crate::db::ItemMeta::named(format!("Test {t}")), ); crate::crdt_state::set_item_type(id, crate::io::story_metadata::ItemType::from_str(t)); } 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 CRDT entry → defaults to "story". assert_eq!(item_type_from_id("99999"), "story"); } // ── Story 866: block/unblock round-trip regression test ────────────────── /// Regression test (story 866): block a story via the new state-machine path, /// verify it lands in `Stage::Blocked`, then unblock and verify it returns /// to `Stage::Coding`. #[test] fn block_unblock_round_trip_via_state_machine() { crate::db::ensure_content_store(); crate::db::write_item_with_content( "99866_story_block_test", "2_current", "---\nname: Block Round Trip\n---\n# Story\n", crate::db::ItemMeta::named("Block Round Trip"), ); // Verify starting state is Coding. let item = crate::pipeline_state::read_typed("99866_story_block_test") .expect("read should succeed") .expect("item should exist"); assert_eq!(item.stage.dir_name(), "coding", "should start in coding"); // Block via the state machine. transition_to_blocked("99866_story_block_test", "retry limit exceeded") .expect("transition_to_blocked should succeed"); // Verify the CRDT now shows Stage::Blocked. let item = crate::pipeline_state::read_typed("99866_story_block_test") .expect("read should succeed") .expect("item should exist after block"); assert_eq!( item.stage.dir_name(), "blocked", "should be in blocked after transition_to_blocked" ); assert!( matches!( item.stage, Stage::Blocked { .. } | Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. } | Stage::Archived { reason: ArchiveReason::Blocked { .. }, .. } ), "stage should be a blocked variant" ); assert!( matches!(item.stage, Stage::Blocked { .. }), "stage should be Stage::Blocked variant" ); // Unblock via the state machine. transition_to_unblocked("99866_story_block_test") .expect("transition_to_unblocked should succeed"); // Verify the story returned to Coding. let item = crate::pipeline_state::read_typed("99866_story_block_test") .expect("read should succeed") .expect("item should exist after unblock"); assert_eq!( item.stage.dir_name(), "coding", "should return to coding after unblock" ); assert!( matches!(item.stage, Stage::Coding { .. }), "stage should be Stage::Coding after unblock" ); } // ── Story 919: MergeFailure unblock → Merge regression ────────────────── /// Regression test (story 919): unblocking a story in `MergeFailure` via /// `transition_to_unblocked` transitions it to `Stage::Merge`, not `Coding` /// or `Backlog`. After the unblock, the merge pipeline re-attempts the /// squash-merge immediately. #[test] fn unblock_merge_failure_story_lands_in_merge() { crate::db::ensure_content_store(); crate::db::write_item_with_content( "99893_story_merge_failure_unblock", "merge_failure", "---\nname: MergeFailure Unblock Test\n---\n# Story\n", crate::db::ItemMeta::named("MergeFailure Unblock Test"), ); // Verify starting state is MergeFailure. let item = crate::pipeline_state::read_typed("99893_story_merge_failure_unblock") .expect("CRDT read should succeed") .expect("item should exist"); assert!( matches!(item.stage, Stage::MergeFailure { .. }), "should start in MergeFailure: {:?}", item.stage ); // Unblock routes through transition_to_unblocked (same path as unblock_story MCP). transition_to_unblocked("99893_story_merge_failure_unblock") .expect("transition_to_unblocked should succeed for MergeFailure story"); // Story must land in Merge — the mergemaster re-attempts the squash. let item = crate::pipeline_state::read_typed("99893_story_merge_failure_unblock") .expect("CRDT read should succeed") .expect("item should exist after unblock"); assert_eq!( item.stage.dir_name(), "merge", "MergeFailure story should land in Merge after unblock for immediate re-attempt: {:?}", item.stage ); assert!( matches!(item.stage, Stage::Merge { .. }), "stage should be Stage::Merge after unblock, got: {:?}", item.stage ); assert!( matches!(item.stage, Stage::Merge { .. }), "stage should be Stage::Merge so auto_assign can pick it up: {:?}", item.stage ); } // ── 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" ); } // ── Story 971: MergeFailure → Coding fixup tests ───────────────────────── /// AC1 (story 971): move_story_to_stage to "current" from MergeFailure /// succeeds and lands the story in Coding stage. #[test] fn move_story_to_stage_from_merge_failure_lands_in_coding() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "99960_story_merge_failure_fixup_971"; crate::db::write_item_with_content( story_id, "merge_failure", "---\nname: Merge Failure Fixup\n---\n", crate::db::ItemMeta::named("Merge Failure Fixup"), ); move_story_to_stage(story_id, "current").expect("move to current must succeed"); let item = crate::pipeline_state::read_typed(story_id) .expect("CRDT read must succeed") .expect("item must exist"); assert_eq!( item.stage.dir_name(), "coding", "story must be in coding after fixup move" ); } /// AC3 (story 971): retry_count is set to 1 after fixup move so that /// spawn's maybe_inject_gate_failure will inject the pre-existing gate_output. /// gate_output is seeded here the same way the merge pipeline seeds it. #[test] fn merge_failure_fixup_sets_retry_count_for_gate_output_injection() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "99961_story_merge_failure_context_971"; crate::db::write_item_with_content( story_id, "merge_failure", "---\nname: Merge Failure Context\n---\n", crate::db::ItemMeta::named("Merge Failure Context"), ); // Simulate what the merge pipeline stores when a merge fails. crate::db::write_content( crate::db::ContentKey::GateOutput(story_id), "CONFLICT (content): server/src/lib.rs", ); move_story_to_stage(story_id, "current").expect("move to current must succeed"); let retry_count = crate::crdt_state::read_item(story_id) .expect("CRDT item must exist") .retry_count(); assert_eq!( retry_count, 1, "retry_count must be 1 after fixup move so gate_output injection fires on spawn" ); // gate_output must still hold the merge pipeline's output unchanged. let gate_output = crate::db::read_content(crate::db::ContentKey::GateOutput(story_id)) .expect("gate_output must still be present after fixup move"); assert!( gate_output.contains("CONFLICT"), "gate_output must retain merge failure details; got: {gate_output}" ); } // ── Story 974: Done → Coding hotfix tests ──────────────────────────────── /// AC1 (story 974): move_story_to_stage to "current" from Done succeeds /// and lands the story in Coding stage so auto-assign can pick it up. #[test] fn move_story_to_stage_from_done_lands_in_coding() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "99974_story_hotfix_done_to_coding"; crate::db::write_item_with_content( story_id, "done", "---\nname: Hotfix Test\n---\n", crate::db::ItemMeta::named("Hotfix Test"), ); move_story_to_stage(story_id, "current").expect("move from Done to Coding must succeed"); let item = crate::pipeline_state::read_typed(story_id) .expect("CRDT read must succeed") .expect("item must exist"); assert_eq!( item.stage.dir_name(), "coding", "story must be in coding after hotfix move" ); } /// AC1 (story 974): retry_count is reset to 0 after Done→Coding so the /// fresh coder session starts clean. #[test] fn move_story_from_done_to_coding_resets_retry_count() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "99975_story_hotfix_retry_count"; crate::db::write_item_with_content( story_id, "done", "---\nname: Hotfix Retry Count\n---\n", crate::db::ItemMeta::named("Hotfix Retry Count"), ); move_story_to_stage(story_id, "current").expect("move from Done to Coding must succeed"); let retry_count = crate::crdt_state::read_item(story_id) .expect("CRDT item must exist") .retry_count(); assert_eq!( retry_count, 0, "retry_count must be 0 after hotfix move so coder starts fresh" ); } // ── Story 984: abandon_story / supersede_story / reject_story ─────────────── #[test] fn abandon_story_transitions_to_abandoned() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "99984_story_abandon"; crate::db::write_item_with_content( story_id, "2_current", "---\nname: Abandon Test\n---\n", crate::db::ItemMeta::named("Abandon Test"), ); abandon_story(story_id).expect("abandon_story must succeed"); let item = crate::pipeline_state::read_typed(story_id) .expect("read must succeed") .expect("item must exist"); assert!( matches!(item.stage, Stage::Abandoned { .. }), "stage must be Abandoned after abandon_story: {:?}", item.stage ); } #[test] fn supersede_story_transitions_to_superseded() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "99985_story_supersede"; crate::db::write_item_with_content( story_id, "1_backlog", "---\nname: Supersede Test\n---\n", crate::db::ItemMeta::named("Supersede Test"), ); supersede_story(story_id, "999_story_replacement").expect("supersede_story must succeed"); let item = crate::pipeline_state::read_typed(story_id) .expect("read must succeed") .expect("item must exist"); assert!( matches!(item.stage, Stage::Superseded { ref superseded_by, .. } if superseded_by.0 == "999_story_replacement"), "stage must be Superseded with correct ID: {:?}", item.stage ); } #[test] fn reject_story_transitions_to_rejected() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "99986_story_reject"; crate::db::write_item_with_content( story_id, "3_qa", "---\nname: Reject Test\n---\n", crate::db::ItemMeta::named("Reject Test"), ); reject_story(story_id, "not aligned with roadmap").expect("reject_story must succeed"); let item = crate::pipeline_state::read_typed(story_id) .expect("read must succeed") .expect("item must exist"); assert!( matches!(item.stage, Stage::Rejected { ref reason, .. } if reason == "not aligned with roadmap"), "stage must be Rejected with correct reason: {:?}", item.stage ); } // ── Story 1051: MergeFailure → Merge retry transition ─────────────────── /// AC1 (story 1051): when the deterministic-merge pipeline starts on a /// MergeFailure story, transition_merge_failure_to_retry moves it to Merge. #[test] fn transition_merge_failure_to_retry_moves_to_merge() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "99105_story_merge_retry_1051"; crate::db::write_item_with_content( story_id, "merge_failure", "---\nname: Merge Retry Test\n---\n", crate::db::ItemMeta::named("Merge Retry Test"), ); transition_merge_failure_to_retry(story_id).expect("must succeed for MergeFailure story"); let item = crate::pipeline_state::read_typed(story_id) .expect("CRDT read must succeed") .expect("item must exist"); assert_eq!( item.stage.dir_name(), "merge", "MergeFailure story must be in Merge after retry transition: {:?}", item.stage ); assert!( matches!(item.stage, Stage::Merge { .. }), "stage must be Stage::Merge variant: {:?}", item.stage ); } /// AC1 (story 1051): transition_merge_failure_to_retry is a no-op when the /// story is already in Stage::Merge — callers do not need to pre-check. #[test] fn transition_merge_failure_to_retry_noop_when_already_merge() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "99106_story_already_merge_1051"; crate::db::write_item_with_content( story_id, "4_merge", "---\nname: Already Merge\n---\n", crate::db::ItemMeta::named("Already Merge"), ); // Must not error even though story is in Merge, not MergeFailure. assert!( transition_merge_failure_to_retry(story_id).is_ok(), "must be a no-op when story is in Merge" ); let item = crate::pipeline_state::read_typed(story_id) .expect("CRDT read must succeed") .expect("item must exist"); assert_eq!( item.stage.dir_name(), "merge", "story must still be in merge after no-op: {:?}", item.stage ); } /// AC3 (story 1051): after a MergeRetryStarted transition (MergeFailure → /// Merge), a subsequent MergeFailed event sets a fresh failure — the /// transition's `before` stage is Merge (not MergeFailure), confirming the /// failure flag is not carried over from the previous attempt. #[test] fn merge_retry_followed_by_failure_sets_fresh_merge_failure() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "99107_story_fresh_failure_1051"; crate::db::write_item_with_content( story_id, "merge_failure", "---\nname: Fresh Failure Test\n---\n", crate::db::ItemMeta::named("Fresh Failure Test"), ); // Retry start: MergeFailure → Merge. transition_merge_failure_to_retry(story_id).expect("retry start must succeed"); // Pipeline fails again: Merge → MergeFailure. let fired = transition_to_merge_failure( story_id, MergeFailureKind::GatesFailed("new gate output".to_string()), ) .expect("transition_to_merge_failure must succeed after retry"); // 'before' must be Merge (fresh failure, not a self-loop from MergeFailure). assert!( matches!(fired.before, Stage::Merge { .. }), "before must be Merge, proving this is a fresh failure not a self-loop: {:?}", fired.before ); assert!( matches!(fired.after, Stage::MergeFailure { .. }), "after must be MergeFailure: {:?}", fired.after ); } /// 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" ); } }