huskies: merge 982
This commit is contained in:
@@ -40,8 +40,8 @@ mod tests;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use types::{
|
||||
AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, NodePubkey, PipelineItem, Stage,
|
||||
StoryId, TransitionError, stage_dir_name, stage_label,
|
||||
AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, MergeFailureKind, NodePubkey,
|
||||
PipelineItem, Stage, StoryId, TransitionError, stage_dir_name, stage_label,
|
||||
};
|
||||
|
||||
#[allow(unused_imports)]
|
||||
|
||||
@@ -648,16 +648,20 @@ fn merge_failure_transition_emits_event_with_full_reason() {
|
||||
let fired = super::apply::apply_transition(
|
||||
story_id,
|
||||
PipelineEvent::MergeFailed {
|
||||
reason: reason.to_string(),
|
||||
kind: MergeFailureKind::Other(reason.to_string()),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.expect("MergeFailed transition should succeed");
|
||||
|
||||
// The emitted event payload carries the full reason string.
|
||||
// The emitted event payload carries the full reason via display_reason().
|
||||
match &fired.event {
|
||||
PipelineEvent::MergeFailed { reason: r } => {
|
||||
assert_eq!(r, reason, "emitted event should carry the full reason");
|
||||
PipelineEvent::MergeFailed { kind } => {
|
||||
assert_eq!(
|
||||
kind.display_reason(),
|
||||
reason,
|
||||
"emitted event should carry the full reason"
|
||||
);
|
||||
}
|
||||
other => panic!("expected MergeFailed event, got: {other:?}"),
|
||||
}
|
||||
@@ -686,14 +690,14 @@ fn merge_failure_transition_emits_event_with_full_reason() {
|
||||
#[test]
|
||||
fn merge_failure_plus_merge_failed_is_self_loop() {
|
||||
let s = Stage::MergeFailure {
|
||||
reason: "initial failure".into(),
|
||||
kind: MergeFailureKind::Other("initial failure".into()),
|
||||
feature_branch: fb("feature/story-1"),
|
||||
commits_ahead: nz(1),
|
||||
};
|
||||
let result = transition(
|
||||
s,
|
||||
PipelineEvent::MergeFailed {
|
||||
reason: "second failure".into(),
|
||||
kind: MergeFailureKind::Other("second failure".into()),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
@@ -722,7 +726,7 @@ fn repeated_merge_failure_apply_transition_no_error_no_duplicate_notification()
|
||||
let fired = super::apply::apply_transition(
|
||||
story_id,
|
||||
PipelineEvent::MergeFailed {
|
||||
reason: "duplicate failure".into(),
|
||||
kind: MergeFailureKind::Other("duplicate failure".into()),
|
||||
},
|
||||
None,
|
||||
)
|
||||
@@ -766,7 +770,7 @@ fn repeated_merge_failure_apply_transition_no_error_no_duplicate_notification()
|
||||
#[test]
|
||||
fn merge_failure_unblock_returns_to_merge() {
|
||||
let s = Stage::MergeFailure {
|
||||
reason: "conflicts in server/src/main.rs".into(),
|
||||
kind: MergeFailureKind::ConflictDetected(Some("conflicts in server/src/main.rs".into())),
|
||||
feature_branch: fb("feature/story-42"),
|
||||
commits_ahead: nz(3),
|
||||
};
|
||||
@@ -781,7 +785,7 @@ fn merge_failure_unblock_returns_to_merge() {
|
||||
#[test]
|
||||
fn merge_failure_demote_returns_to_backlog() {
|
||||
let s = Stage::MergeFailure {
|
||||
reason: "conflicts".into(),
|
||||
kind: MergeFailureKind::Other("conflicts".into()),
|
||||
feature_branch: fb("feature/story-1"),
|
||||
commits_ahead: nz(1),
|
||||
};
|
||||
@@ -799,7 +803,7 @@ fn merge_failure_demote_returns_to_backlog() {
|
||||
#[test]
|
||||
fn merge_failure_accept_pure_transition() {
|
||||
let s = Stage::MergeFailure {
|
||||
reason: "conflicts unresolvable".into(),
|
||||
kind: MergeFailureKind::ConflictDetected(Some("conflicts unresolvable".into())),
|
||||
feature_branch: fb("feature/story-1"),
|
||||
commits_ahead: nz(1),
|
||||
};
|
||||
|
||||
@@ -4,8 +4,8 @@ use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, Stage, StoryId, TransitionError,
|
||||
stage_label,
|
||||
AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, MergeFailureKind, Stage, StoryId,
|
||||
TransitionError, stage_label,
|
||||
};
|
||||
|
||||
// ── Pipeline events ─────────────────────────────────────────────────────────
|
||||
@@ -35,7 +35,7 @@ pub enum PipelineEvent {
|
||||
MergeSucceeded { merge_commit: GitSha },
|
||||
/// Merge pipeline failed (conflicts or gate failures); story moves to
|
||||
/// `Stage::MergeFailure` awaiting human intervention or retry.
|
||||
MergeFailed { reason: String },
|
||||
MergeFailed { kind: MergeFailureKind },
|
||||
/// Mergemaster gave up after retry budget.
|
||||
MergeFailedFinal { reason: String },
|
||||
/// Story accepted (Done → Archived).
|
||||
@@ -214,9 +214,9 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
|
||||
feature_branch,
|
||||
commits_ahead,
|
||||
},
|
||||
MergeFailed { reason },
|
||||
MergeFailed { kind },
|
||||
) => Ok(MergeFailure {
|
||||
reason,
|
||||
kind,
|
||||
feature_branch,
|
||||
commits_ahead,
|
||||
}),
|
||||
@@ -231,9 +231,9 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
|
||||
commits_ahead,
|
||||
..
|
||||
},
|
||||
MergeFailed { reason },
|
||||
MergeFailed { kind },
|
||||
) => Ok(MergeFailure {
|
||||
reason,
|
||||
kind,
|
||||
feature_branch,
|
||||
commits_ahead,
|
||||
}),
|
||||
@@ -326,8 +326,8 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
|
||||
(Done { .. }, HotfixRequested) => Ok(Coding),
|
||||
|
||||
// ── MergemasterAttempted: MergeFailure → MergeFailureFinal ─────
|
||||
(MergeFailure { reason, .. }, MergemasterAttempted) => Ok(MergeFailureFinal { reason }),
|
||||
(MergeFailureFinal { reason }, MergemasterAttempted) => Ok(MergeFailureFinal { reason }),
|
||||
(MergeFailure { kind, .. }, MergemasterAttempted) => Ok(MergeFailureFinal { kind }),
|
||||
(MergeFailureFinal { kind }, MergemasterAttempted) => Ok(MergeFailureFinal { kind }),
|
||||
|
||||
// ── Unblock: from Frozen/ReviewHold → resume_to ────────────────
|
||||
(Frozen { resume_to }, Unblock) => Ok(*resume_to),
|
||||
|
||||
@@ -39,6 +39,76 @@ impl fmt::Display for AgentName {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Merge failure kind ──────────────────────────────────────────────────────
|
||||
|
||||
/// Typed reason for a merge pipeline failure.
|
||||
///
|
||||
/// Replaces the freeform `reason: String` that was previously stored on
|
||||
/// `Stage::MergeFailure` (story 982). Consumers match on the variant instead
|
||||
/// of scanning substrings of an opaque string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MergeFailureKind {
|
||||
/// Git content conflicts detected during the squash-rebase. The payload
|
||||
/// is the raw conflict output (e.g. the git CONFLICT lines), if available.
|
||||
ConflictDetected(Option<String>),
|
||||
/// Quality gates (fmt, clippy, tests) failed after the squash merge.
|
||||
/// The payload is the gate runner output.
|
||||
GatesFailed(String),
|
||||
/// Feature branch has no code changes ahead of master (empty diff).
|
||||
EmptyDiff,
|
||||
/// Squash merge produced no commits (e.g. empty-commit history).
|
||||
NoCommits,
|
||||
/// Unclassified merge failure; the payload carries the original message.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl MergeFailureKind {
|
||||
/// Human-readable description for display tools and `MergeJob.error`.
|
||||
pub fn display_reason(&self) -> String {
|
||||
match self {
|
||||
MergeFailureKind::ConflictDetected(details) => format!(
|
||||
"Merge conflict: {}",
|
||||
details.as_deref().unwrap_or("conflicts detected")
|
||||
),
|
||||
MergeFailureKind::GatesFailed(output) => format!("Quality gates failed: {output}"),
|
||||
MergeFailureKind::EmptyDiff => "Feature branch has no code changes".to_string(),
|
||||
MergeFailureKind::NoCommits => "No commits to merge".to_string(),
|
||||
MergeFailureKind::Other(reason) => reason.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// String to persist in `ContentKey::GateOutput` so the CRDT projection
|
||||
/// layer can reconstruct the kind after a server restart.
|
||||
pub fn to_gate_output(&self) -> String {
|
||||
match self {
|
||||
// Always prefix with "Merge conflict:" so `infer_from_gate_output`
|
||||
// can identify ConflictDetected on reload.
|
||||
MergeFailureKind::ConflictDetected(details) => format!(
|
||||
"Merge conflict: {}",
|
||||
details.as_deref().unwrap_or("conflicts detected")
|
||||
),
|
||||
// Raw gate output is the natural identifier for a GatesFailed kind.
|
||||
MergeFailureKind::GatesFailed(output) => output.clone(),
|
||||
MergeFailureKind::EmptyDiff => "empty diff".to_string(),
|
||||
MergeFailureKind::NoCommits => "no commits to merge".to_string(),
|
||||
MergeFailureKind::Other(reason) => reason.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Infer a kind from a `ContentKey::GateOutput` string persisted by a
|
||||
/// previous call to [`Self::to_gate_output`] or by legacy code that wrote
|
||||
/// conflict markers directly. Used by the CRDT projection layer.
|
||||
pub fn infer_from_gate_output(gate_output: &str) -> Self {
|
||||
if gate_output.contains("CONFLICT (content):") || gate_output.contains("Merge conflict") {
|
||||
MergeFailureKind::ConflictDetected(Some(gate_output.to_string()))
|
||||
} else if gate_output.is_empty() {
|
||||
MergeFailureKind::Other(String::new())
|
||||
} else {
|
||||
MergeFailureKind::GatesFailed(gate_output.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Synced pipeline stage (lives in CRDT, converges across nodes) ───────────
|
||||
|
||||
/// The pipeline stage for a work item.
|
||||
@@ -111,7 +181,9 @@ pub enum Stage {
|
||||
/// this is a recoverable intermediate state — `Unblock` returns to `Merge`
|
||||
/// (re-queues the merge) and `Demote` returns to `Backlog` (manual park).
|
||||
MergeFailure {
|
||||
reason: String,
|
||||
/// Typed failure reason — callers match on the variant instead of
|
||||
/// substring-scanning a freeform string (story 982).
|
||||
kind: MergeFailureKind,
|
||||
/// Branch and commit count preserved from the preceding `Merge` state
|
||||
/// so `Unblock` can reconstruct the exact `Merge` variant.
|
||||
feature_branch: BranchName,
|
||||
@@ -122,7 +194,10 @@ pub enum Stage {
|
||||
/// recover; the agent gave up. The story stays here awaiting human
|
||||
/// intervention — the auto-assigner will NOT spawn mergemaster again.
|
||||
/// Replaces the legacy `mergemaster_attempted: true` boolean flag.
|
||||
MergeFailureFinal { reason: String },
|
||||
MergeFailureFinal {
|
||||
/// Typed failure reason carried through from the preceding `MergeFailure`.
|
||||
kind: MergeFailureKind,
|
||||
},
|
||||
|
||||
/// Story is frozen — kept at this stage as a snapshot of its previous
|
||||
/// stage. Replaces the legacy `frozen: true` boolean flag: there is no
|
||||
@@ -229,12 +304,12 @@ impl Stage {
|
||||
commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
|
||||
}),
|
||||
"merge_failure" => Some(Stage::MergeFailure {
|
||||
reason: String::new(),
|
||||
kind: MergeFailureKind::Other(String::new()),
|
||||
feature_branch: BranchName(String::new()),
|
||||
commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
|
||||
}),
|
||||
"merge_failure_final" => Some(Stage::MergeFailureFinal {
|
||||
reason: String::new(),
|
||||
kind: MergeFailureKind::Other(String::new()),
|
||||
}),
|
||||
"frozen" => Some(Stage::Frozen {
|
||||
resume_to: Box::new(Stage::Coding),
|
||||
|
||||
Reference in New Issue
Block a user