huskies: merge 867
This commit is contained in:
@@ -97,3 +97,28 @@ pub fn apply_transition_str(
|
||||
) -> Result<TransitionFired, String> {
|
||||
apply_transition(story_id, event, content_transform).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Freeze a story at its current stage.
|
||||
///
|
||||
/// Transitions the story to `Stage::Frozen { resume_to: current_stage }` and
|
||||
/// writes `resume_to_stage` into the front matter so the projection layer can
|
||||
/// reconstruct the full typed stage on subsequent reads.
|
||||
pub fn transition_to_frozen(story_id: &str) -> Result<TransitionFired, ApplyError> {
|
||||
let item = read_typed(story_id)?.ok_or_else(|| ApplyError::NotFound(story_id.to_string()))?;
|
||||
let resume_dir = item.stage.dir_name().to_string();
|
||||
let transform = move |content: &str| -> String {
|
||||
crate::io::story_metadata::set_front_matter_field(content, "resume_to_stage", &resume_dir)
|
||||
};
|
||||
apply_transition(story_id, PipelineEvent::Freeze, Some(&transform))
|
||||
}
|
||||
|
||||
/// Unfreeze a story, restoring it to the stage it was in before freezing.
|
||||
///
|
||||
/// Transitions `Stage::Frozen { resume_to }` back to `resume_to` and removes
|
||||
/// the `resume_to_stage` field from the front matter.
|
||||
pub fn transition_to_unfrozen(story_id: &str) -> Result<TransitionFired, ApplyError> {
|
||||
let transform = |content: &str| -> String {
|
||||
crate::io::story_metadata::clear_front_matter_field_in_content(content, "resume_to_stage")
|
||||
};
|
||||
apply_transition(story_id, PipelineEvent::Unfreeze, Some(&transform))
|
||||
}
|
||||
|
||||
@@ -54,7 +54,10 @@ pub use projection::{ProjectionError, project_stage};
|
||||
pub use projection::{read_all_typed, read_typed};
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use apply::{ApplyError, apply_transition, apply_transition_str};
|
||||
pub use apply::{
|
||||
ApplyError, apply_transition, apply_transition_str, transition_to_frozen,
|
||||
transition_to_unfrozen,
|
||||
};
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use subscribers::{
|
||||
|
||||
@@ -119,6 +119,21 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
|
||||
reason,
|
||||
})
|
||||
}
|
||||
"7_frozen" => {
|
||||
// The stage to resume to is stored in front matter as `resume_to_stage`.
|
||||
// Fall back to Coding if the field is absent (e.g. legacy frozen items).
|
||||
let resume_to = crate::db::read_content(&view.story_id)
|
||||
.and_then(|content| {
|
||||
crate::io::story_metadata::parse_front_matter(&content)
|
||||
.ok()
|
||||
.and_then(|m| m.resume_to_stage)
|
||||
.and_then(|dir| Stage::from_dir(&dir))
|
||||
})
|
||||
.unwrap_or(Stage::Coding);
|
||||
Ok(Stage::Frozen {
|
||||
resume_to: Box::new(resume_to),
|
||||
})
|
||||
}
|
||||
other => Err(ProjectionError::UnknownStage(other.to_string())),
|
||||
}
|
||||
}
|
||||
@@ -137,6 +152,7 @@ impl PipelineItem {
|
||||
..
|
||||
}
|
||||
);
|
||||
// Frozen stories map to "7_frozen"; they are not "blocked" in the CRDT sense.
|
||||
(dir, blocked)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,4 +538,124 @@ fn cannot_reject_from_archived() {
|
||||
));
|
||||
}
|
||||
|
||||
// ── Freeze / Unfreeze ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn freeze_from_active_stages() {
|
||||
for s in [Stage::Upcoming, Stage::Backlog, Stage::Coding, Stage::Qa] {
|
||||
let result = transition(s.clone(), PipelineEvent::Freeze).unwrap();
|
||||
assert!(
|
||||
matches!(result, Stage::Frozen { .. }),
|
||||
"expected Frozen from {s:?}"
|
||||
);
|
||||
if let Stage::Frozen { resume_to } = result {
|
||||
assert_eq!(*resume_to, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeze_from_merge() {
|
||||
let m = Stage::Merge {
|
||||
feature_branch: fb("f"),
|
||||
commits_ahead: nz(1),
|
||||
};
|
||||
let result = transition(m.clone(), PipelineEvent::Freeze).unwrap();
|
||||
assert!(matches!(result, Stage::Frozen { .. }));
|
||||
if let Stage::Frozen { resume_to } = result {
|
||||
assert_eq!(*resume_to, m);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfreeze_restores_prior_stage() {
|
||||
let prior = Stage::Coding;
|
||||
let frozen = Stage::Frozen {
|
||||
resume_to: Box::new(prior.clone()),
|
||||
};
|
||||
let result = transition(frozen, PipelineEvent::Unfreeze).unwrap();
|
||||
assert_eq!(result, prior);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cannot_freeze_done() {
|
||||
let s = Stage::Done {
|
||||
merged_at: chrono::Utc::now(),
|
||||
merge_commit: sha("abc"),
|
||||
};
|
||||
let result = transition(s, PipelineEvent::Freeze);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(TransitionError::InvalidTransition { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cannot_freeze_archived() {
|
||||
let s = Stage::Archived {
|
||||
archived_at: chrono::Utc::now(),
|
||||
reason: ArchiveReason::Completed,
|
||||
};
|
||||
let result = transition(s, PipelineEvent::Freeze);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(TransitionError::InvalidTransition { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cannot_unfreeze_coding() {
|
||||
let result = transition(Stage::Coding, PipelineEvent::Unfreeze);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(TransitionError::InvalidTransition { .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// Regression test: freeze → unfreeze round-trip via `apply_transition`.
|
||||
/// Verifies that the CRDT shows the correct prior stage restored.
|
||||
#[test]
|
||||
fn regression_freeze_unfreeze_restores_crdt_stage() {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
|
||||
let story_id = "9950_story_freeze_regression";
|
||||
let content = "---\nname: Freeze Regression\n---\n# Story\n";
|
||||
crate::db::write_item_with_content(story_id, "2_current", content);
|
||||
|
||||
// Confirm starting stage.
|
||||
let item = read_typed(story_id).unwrap().unwrap();
|
||||
assert!(
|
||||
matches!(item.stage, Stage::Coding),
|
||||
"should start at Coding"
|
||||
);
|
||||
|
||||
// Freeze.
|
||||
super::apply::transition_to_frozen(story_id).expect("freeze should succeed");
|
||||
|
||||
let item = read_typed(story_id).unwrap().unwrap();
|
||||
assert!(
|
||||
matches!(item.stage, Stage::Frozen { .. }),
|
||||
"should be Frozen after freeze: {:?}",
|
||||
item.stage
|
||||
);
|
||||
if let Stage::Frozen { ref resume_to } = item.stage {
|
||||
assert!(
|
||||
matches!(**resume_to, Stage::Coding),
|
||||
"resume_to should be Coding: {:?}",
|
||||
resume_to
|
||||
);
|
||||
}
|
||||
|
||||
// Unfreeze.
|
||||
super::apply::transition_to_unfrozen(story_id).expect("unfreeze should succeed");
|
||||
|
||||
let item = read_typed(story_id).unwrap().unwrap();
|
||||
assert!(
|
||||
matches!(item.stage, Stage::Coding),
|
||||
"should be restored to Coding after unfreeze: {:?}",
|
||||
item.stage
|
||||
);
|
||||
}
|
||||
|
||||
// ── ProjectionError Display ─────────────────────────────────────────
|
||||
|
||||
@@ -56,6 +56,10 @@ pub enum PipelineEvent {
|
||||
Close,
|
||||
/// Manual demotion back to backlog from an active stage.
|
||||
Demote,
|
||||
/// Freeze the story at its current stage (suspends pipeline and auto-assign).
|
||||
Freeze,
|
||||
/// Unfreeze the story, restoring it to the stage it was at when frozen.
|
||||
Unfreeze,
|
||||
}
|
||||
|
||||
// ── Per-node execution events ───────────────────────────────────────────────
|
||||
@@ -94,6 +98,8 @@ pub fn event_label(e: &PipelineEvent) -> &'static str {
|
||||
PipelineEvent::Triage => "Triage",
|
||||
PipelineEvent::Close => "Close",
|
||||
PipelineEvent::Demote => "Demote",
|
||||
PipelineEvent::Freeze => "Freeze",
|
||||
PipelineEvent::Unfreeze => "Unfreeze",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +237,17 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
|
||||
Unblock,
|
||||
) => Ok(Backlog),
|
||||
|
||||
// ── Freeze: any active stage → Frozen(resume_to=current) ────────
|
||||
(stage @ (Upcoming | Backlog | Coding | Qa), Freeze) => Ok(Frozen {
|
||||
resume_to: Box::new(stage),
|
||||
}),
|
||||
(stage @ Merge { .. }, Freeze) => Ok(Frozen {
|
||||
resume_to: Box::new(stage),
|
||||
}),
|
||||
|
||||
// ── Unfreeze: Frozen → resume_to ─────────────────────────────────
|
||||
(Frozen { resume_to }, Unfreeze) => Ok(*resume_to),
|
||||
|
||||
// ── Everything else is invalid ──────────────────────────────────
|
||||
_ => Err(invalid()),
|
||||
}
|
||||
|
||||
@@ -100,6 +100,10 @@ pub enum Stage {
|
||||
archived_at: DateTime<Utc>,
|
||||
reason: ArchiveReason,
|
||||
},
|
||||
|
||||
/// Pipeline advancement and auto-assign are suspended. Resumes to
|
||||
/// `resume_to` when unfrozen.
|
||||
Frozen { resume_to: Box<Stage> },
|
||||
}
|
||||
|
||||
/// Why a story was archived. Subsumes the old `blocked`, `merge_failure`,
|
||||
@@ -130,6 +134,11 @@ impl Stage {
|
||||
matches!(self, Stage::Coding | Stage::Qa | Stage::Merge { .. })
|
||||
}
|
||||
|
||||
/// Returns true if this stage is `Frozen`.
|
||||
pub fn is_frozen(&self) -> bool {
|
||||
matches!(self, Stage::Frozen { .. })
|
||||
}
|
||||
|
||||
/// Returns true if this is the Upcoming variant.
|
||||
pub fn is_upcoming(&self) -> bool {
|
||||
matches!(self, Stage::Upcoming)
|
||||
@@ -177,6 +186,11 @@ impl Stage {
|
||||
archived_at: DateTime::<Utc>::UNIX_EPOCH,
|
||||
reason: ArchiveReason::Completed,
|
||||
}),
|
||||
// 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),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -256,6 +270,7 @@ pub fn stage_label(s: &Stage) -> &'static str {
|
||||
Stage::Merge { .. } => "Merge",
|
||||
Stage::Done { .. } => "Done",
|
||||
Stage::Archived { .. } => "Archived",
|
||||
Stage::Frozen { .. } => "Frozen",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,5 +284,6 @@ pub fn stage_dir_name(s: &Stage) -> &'static str {
|
||||
Stage::Merge { .. } => "4_merge",
|
||||
Stage::Done { .. } => "5_done",
|
||||
Stage::Archived { .. } => "6_archived",
|
||||
Stage::Frozen { .. } => "7_frozen",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user