diff --git a/server/src/pipeline_state/tests.rs b/server/src/pipeline_state/tests.rs index c42039e9..e4a11144 100644 --- a/server/src/pipeline_state/tests.rs +++ b/server/src/pipeline_state/tests.rs @@ -197,6 +197,42 @@ fn unblock_returns_to_coding() { assert!(matches!(result, Stage::Coding)); } +#[test] +fn blocked_demote_returns_to_backlog() { + // Stuck-story parking lane: `Blocked + Demote → Backlog` lets operators + // move a blocked story back to the backlog without losing it to + // Archived. Complements `Blocked + Unblock → Coding` which re-enters + // active work. + let s = Stage::Blocked { + reason: "waiting on dep".into(), + }; + let result = transition(s, PipelineEvent::Demote).unwrap(); + assert!(matches!(result, Stage::Backlog)); +} + +#[test] +fn cannot_demote_from_done() { + // Sanity: Demote remains illegal from terminal/archived stages — the + // new `Blocked + Demote → Backlog` rule must NOT broaden it further. + let s = Stage::Done { + merged_at: chrono::Utc::now(), + merge_commit: sha("x"), + }; + assert!(matches!( + transition(s, PipelineEvent::Demote), + Err(TransitionError::InvalidTransition { .. }) + )); +} + +#[test] +fn cannot_demote_from_upcoming() { + let s = Stage::Upcoming; + assert!(matches!( + transition(s, PipelineEvent::Demote), + Err(TransitionError::InvalidTransition { .. }) + )); +} + #[test] fn legacy_unblock_archived_blocked_returns_to_backlog() { let s = Stage::Archived { diff --git a/server/src/pipeline_state/transition.rs b/server/src/pipeline_state/transition.rs index 37510e4b..2061d3fc 100644 --- a/server/src/pipeline_state/transition.rs +++ b/server/src/pipeline_state/transition.rs @@ -217,7 +217,13 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result Ok(Backlog), + // `Blocked + Demote → Backlog` lets operators park a stuck story in + // the backlog while waiting on dependent fixes, without losing it to + // Archived. Unlike `Unblock` (Blocked → Coding), this does not + // re-enter the active flow. + (Coding, Demote) | (Qa, Demote) | (Merge { .. }, Demote) | (Blocked { .. }, Demote) => { + Ok(Backlog) + } // ── Close: direct completion from any active stage ───────────── (Backlog, Close) | (Coding, Close) | (Qa, Close) | (Merge { .. }, Close) => Ok(Done {