huskies: merge 866

This commit is contained in:
dave
2026-04-29 22:42:59 +00:00
parent a49f668b5a
commit 9a3f60d5d3
19 changed files with 289 additions and 144 deletions
+103
View File
@@ -230,6 +230,45 @@ pub fn reject_story_from_qa(story_id: &str, notes: &str) -> Result<(), String> {
.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> {
let transform = fields_to_clear_transform(&["blocked"]);
apply_transition(
story_id,
PipelineEvent::Block {
reason: reason.to_string(),
},
transform.as_ref().map(|f| f.as_ref()),
)
.map(|_| ())
.map_err(|e| e.to_string())
}
/// Transition a story out of `Blocked` back to `Coding` via the state machine.
///
/// Builds a `PipelineEvent::Unblock`, validates the transition, writes the
/// resulting `Stage::Coding` to the CRDT, and resets `retry_count` to 0.
/// Returns `Err` on `TransitionError` — callers must NOT fall back to direct
/// register writes.
pub fn transition_to_unblocked(story_id: &str) -> Result<(), String> {
let transform = fields_to_clear_transform(&["blocked", "merge_failure", "retry_count"]);
apply_transition(
story_id,
PipelineEvent::Unblock,
transform.as_ref().map(|f| f.as_ref()),
)
.map(|_| ())
.map_err(|e| e.to_string())?;
// Reset the retry counter in the CRDT so the story gets a fresh budget.
crate::crdt_state::set_retry_count(story_id, 0);
Ok(())
}
/// Map a (current stage, target stage name) pair to the appropriate PipelineEvent.
fn map_stage_move_to_event(
from: &Stage,
@@ -261,6 +300,7 @@ fn map_stage_move_to_event(
merge_commit: GitSha("manual".to_string()),
}),
(Stage::Coding | Stage::Qa | Stage::Backlog, "done") => Ok(PipelineEvent::Close),
(Stage::Blocked { .. }, "current") => Ok(PipelineEvent::Unblock),
(
Stage::Archived {
reason: ArchiveReason::Blocked { .. },
@@ -345,6 +385,7 @@ fn stage_to_name(s: &Stage) -> &'static str {
Stage::Upcoming => "upcoming",
Stage::Backlog => "backlog",
Stage::Coding => "current",
Stage::Blocked { .. } => "blocked",
Stage::Qa => "qa",
Stage::Merge { .. } => "merge",
Stage::Done { .. } => "done",
@@ -452,6 +493,68 @@ mod tests {
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",
);
// 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(),
"2_current",
"should start in 2_current"
);
// 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(),
"2_blocked",
"should be in 2_blocked after transition_to_blocked"
);
assert!(item.stage.is_blocked(), "is_blocked() should return true");
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(),
"2_current",
"should return to 2_current after unblock"
);
assert!(
matches!(item.stage, Stage::Coding),
"stage should be Stage::Coding after unblock"
);
}
// ── feature_branch_has_unmerged_changes tests ────────────────────────────
fn init_git_repo(repo: &std::path::Path) {