feat: add Blocked → Backlog legal transition (Demote)
Pipeline gap: the state machine refused `move_story(... target='backlog')`
from a Blocked story, leaving stuck items with no way to be parked while
waiting on dependent fixes — operators had to either Unblock (which
re-enters the active flow) or Archive (which loses the item).
Extend the existing Demote rule so `Blocked + Demote → Backlog` is a
legal transition, alongside the existing `Coding/Qa/Merge + Demote`.
Also update `map_stage_move_to_event` in agents/lifecycle.rs so the
chat/MCP `move_story` API recognises Blocked → backlog and routes it
through `PipelineEvent::Demote`.
Tests:
- `blocked_demote_returns_to_backlog` — happy path.
- `cannot_demote_from_done` / `cannot_demote_from_upcoming` — sanity
checks that the broadened rule does NOT permit Demote from
terminal or pre-triage stages.
Pattern follows 892 (MergeFailure → Done) and 893 (MergeFailure →
Coding) — pure transition.rs extension plus matching event mapping in
lifecycle.rs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -217,7 +217,13 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
|
||||
}),
|
||||
|
||||
// ── Demote: send an active item back to backlog ────────────────
|
||||
(Coding, Demote) | (Qa, Demote) | (Merge { .. }, Demote) => 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 {
|
||||
|
||||
Reference in New Issue
Block a user