huskies: merge 868
This commit is contained in:
@@ -91,6 +91,14 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
|
||||
commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
|
||||
})
|
||||
}
|
||||
"4_merge_failure" => {
|
||||
// The reason is persisted in front-matter (merge_failure: "...") but
|
||||
// is not part of the raw CRDT view; the projection uses an empty
|
||||
// string here. Consumers that need the reason should read content.
|
||||
Ok(Stage::MergeFailure {
|
||||
reason: String::new(),
|
||||
})
|
||||
}
|
||||
"5_done" => {
|
||||
// Use the stored merged_at timestamp if present. Legacy items
|
||||
// that pre-date this field have merged_at = None, so we fall back
|
||||
|
||||
@@ -655,4 +655,57 @@ fn regression_freeze_unfreeze_restores_crdt_stage() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Story 868: MergeFailure regression ─────────────────────────────
|
||||
|
||||
/// Regression test (story 868): applying `PipelineEvent::MergeFailed` to a story
|
||||
/// in `Stage::Merge` transitions it to `Stage::MergeFailure` and the emitted
|
||||
/// `TransitionFired` event carries the full reason string in its payload.
|
||||
#[test]
|
||||
fn merge_failure_transition_emits_event_with_full_reason() {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
|
||||
let story_id = "99868_story_merge_failure_event";
|
||||
crate::db::write_item_with_content(
|
||||
story_id,
|
||||
"4_merge",
|
||||
"---\nname: Merge Failure Event Test\n---\n# Story\n",
|
||||
);
|
||||
|
||||
let reason = "Conflict in server/src/main.rs: both modified";
|
||||
let fired = super::apply::apply_transition(
|
||||
story_id,
|
||||
PipelineEvent::MergeFailed {
|
||||
reason: reason.to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.expect("MergeFailed transition should succeed");
|
||||
|
||||
// The emitted event payload carries the full reason string.
|
||||
match &fired.event {
|
||||
PipelineEvent::MergeFailed { reason: r } => {
|
||||
assert_eq!(r, reason, "emitted event should carry the full reason");
|
||||
}
|
||||
other => panic!("expected MergeFailed event, got: {other:?}"),
|
||||
}
|
||||
|
||||
// The story transitioned to MergeFailure.
|
||||
assert!(
|
||||
matches!(fired.after, Stage::MergeFailure { .. }),
|
||||
"after-stage should be MergeFailure: {:?}",
|
||||
fired.after
|
||||
);
|
||||
|
||||
// Verify CRDT reflects the new stage.
|
||||
let item = read_typed(story_id)
|
||||
.expect("CRDT read should succeed")
|
||||
.expect("item should exist");
|
||||
assert_eq!(
|
||||
item.stage.dir_name(),
|
||||
"4_merge_failure",
|
||||
"CRDT stage should be 4_merge_failure"
|
||||
);
|
||||
}
|
||||
|
||||
// ── ProjectionError Display ─────────────────────────────────────────
|
||||
|
||||
@@ -33,6 +33,9 @@ pub enum PipelineEvent {
|
||||
},
|
||||
/// Mergemaster squash succeeded.
|
||||
MergeSucceeded { merge_commit: GitSha },
|
||||
/// Merge pipeline failed (conflicts or gate failures); story moves to
|
||||
/// `Stage::MergeFailure` awaiting human intervention or retry.
|
||||
MergeFailed { reason: String },
|
||||
/// Mergemaster gave up after retry budget.
|
||||
MergeFailedFinal { reason: String },
|
||||
/// Story accepted (Done → Archived).
|
||||
@@ -87,6 +90,7 @@ pub fn event_label(e: &PipelineEvent) -> &'static str {
|
||||
PipelineEvent::GatesFailed { .. } => "GatesFailed",
|
||||
PipelineEvent::QaSkipped { .. } => "QaSkipped",
|
||||
PipelineEvent::MergeSucceeded { .. } => "MergeSucceeded",
|
||||
PipelineEvent::MergeFailed { .. } => "MergeFailed",
|
||||
PipelineEvent::MergeFailedFinal { .. } => "MergeFailedFinal",
|
||||
PipelineEvent::Accepted => "Accepted",
|
||||
PipelineEvent::Block { .. } => "Block",
|
||||
@@ -174,6 +178,9 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
|
||||
reason: ArchiveReason::ReviewHeld { reason },
|
||||
}),
|
||||
|
||||
// ── MergeFailed: Merge → MergeFailure (recoverable intermediate) ──
|
||||
(Merge { .. }, MergeFailed { reason }) => Ok(MergeFailure { reason }),
|
||||
|
||||
(Merge { .. }, MergeFailedFinal { reason }) => Ok(Archived {
|
||||
archived_at: now,
|
||||
reason: ArchiveReason::MergeFailed { reason },
|
||||
@@ -221,6 +228,9 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
|
||||
// ── Unblock: Blocked → Coding ─────────────────────────────────
|
||||
(Blocked { .. }, Unblock) => Ok(Coding),
|
||||
|
||||
// ── Unblock MergeFailure → Backlog ───────────────────────────────
|
||||
(MergeFailure { .. }, Unblock) => Ok(Backlog),
|
||||
|
||||
// ── Legacy unblock: Archived(Blocked|MergeFailed) → Backlog ──
|
||||
(
|
||||
Archived {
|
||||
|
||||
@@ -60,9 +60,9 @@ impl fmt::Display for AgentName {
|
||||
/// | current | `Coding` |
|
||||
/// | qa_pending | `Qa` |
|
||||
/// | merge_pending | `Merge { .. }` |
|
||||
/// | merge_failure | `MergeFailure { .. }` |
|
||||
/// | done | `Done { .. }` |
|
||||
/// | blocked | `Blocked { .. }` |
|
||||
/// | merge_failure | `Archived { MergeFailed { .. } }` |
|
||||
/// | archived | `Archived { Completed }` |
|
||||
/// | superseded | `Archived { Superseded { .. } }` |
|
||||
/// | rejected | `Archived { Rejected { .. } }` |
|
||||
@@ -106,6 +106,11 @@ pub enum Stage {
|
||||
reason: ArchiveReason,
|
||||
},
|
||||
|
||||
/// Merge pipeline failed (conflicts or gate failures). Story is held here
|
||||
/// awaiting human intervention or retry. Unlike `Archived(MergeFailed)`,
|
||||
/// this is a recoverable intermediate state — `Unblock` returns to `Backlog`.
|
||||
MergeFailure { reason: String },
|
||||
|
||||
/// Pipeline advancement and auto-assign are suspended. Resumes to
|
||||
/// `resume_to` when unfrozen.
|
||||
Frozen { resume_to: Box<Stage> },
|
||||
@@ -154,12 +159,13 @@ impl Stage {
|
||||
stage_dir_name(self)
|
||||
}
|
||||
|
||||
/// Returns true if this is the `Blocked` variant (or the legacy
|
||||
/// `Archived(Blocked)` for backward-compatible reads).
|
||||
/// Returns true if this is the `Blocked` or `MergeFailure` variant (or the
|
||||
/// legacy `Archived(Blocked)` for backward-compatible reads).
|
||||
pub fn is_blocked(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Stage::Blocked { .. }
|
||||
| Stage::MergeFailure { .. }
|
||||
| Stage::Archived {
|
||||
reason: ArchiveReason::Blocked { .. },
|
||||
..
|
||||
@@ -198,6 +204,11 @@ impl Stage {
|
||||
}),
|
||||
// Frozen: stub with Coding as resume_to — rich resume_to is loaded
|
||||
// from front matter by the projection layer.
|
||||
"4_merge_failure" => Some(Stage::MergeFailure {
|
||||
reason: String::new(),
|
||||
}),
|
||||
// Frozen: stub with Coding as resume_to — rich resume_to is loaded
|
||||
// from front matter by the projection layer.
|
||||
"7_frozen" => Some(Stage::Frozen {
|
||||
resume_to: Box::new(Stage::Coding),
|
||||
}),
|
||||
@@ -278,6 +289,7 @@ pub fn stage_label(s: &Stage) -> &'static str {
|
||||
Stage::Coding => "Coding",
|
||||
Stage::Qa => "Qa",
|
||||
Stage::Merge { .. } => "Merge",
|
||||
Stage::MergeFailure { .. } => "MergeFailure",
|
||||
Stage::Done { .. } => "Done",
|
||||
Stage::Blocked { .. } => "Blocked",
|
||||
Stage::Archived { .. } => "Archived",
|
||||
@@ -294,6 +306,7 @@ pub fn stage_dir_name(s: &Stage) -> &'static str {
|
||||
Stage::Blocked { .. } => "2_blocked",
|
||||
Stage::Qa => "3_qa",
|
||||
Stage::Merge { .. } => "4_merge",
|
||||
Stage::MergeFailure { .. } => "4_merge_failure",
|
||||
Stage::Done { .. } => "5_done",
|
||||
Stage::Archived { .. } => "6_archived",
|
||||
Stage::Frozen { .. } => "7_frozen",
|
||||
|
||||
Reference in New Issue
Block a user