huskies: merge 867
This commit is contained in:
@@ -349,6 +349,7 @@ fn stage_to_name(s: &Stage) -> &'static str {
|
||||
Stage::Merge { .. } => "merge",
|
||||
Stage::Done { .. } => "done",
|
||||
Stage::Archived { .. } => "archived",
|
||||
Stage::Frozen { .. } => "frozen",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,16 +93,14 @@ pub(super) fn check_archived_dependencies(
|
||||
crate::io::story_metadata::check_archived_deps(project_root, stage_dir, story_id)
|
||||
}
|
||||
|
||||
/// Return `true` if the story file has `frozen: true` in its front matter.
|
||||
pub(super) fn is_story_frozen(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
let contents = match read_story_contents(project_root, story_id) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
parse_front_matter(&contents)
|
||||
/// Return `true` if the story is in the `Frozen` pipeline stage.
|
||||
///
|
||||
/// Checks the typed CRDT stage via `read_typed`.
|
||||
pub(super) fn is_story_frozen(_project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
||||
crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.and_then(|m| m.frozen)
|
||||
.flatten()
|
||||
.map(|item| item.stage.is_frozen())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
//! Handler for the `freeze` and `unfreeze` commands.
|
||||
//!
|
||||
//! `freeze <number>` sets `frozen: true` on the story, halting pipeline
|
||||
//! advancement and auto-assign until `unfreeze <number>` clears the flag.
|
||||
//! `freeze <number>` transitions the story to `Stage::Frozen`, halting pipeline
|
||||
//! advancement and auto-assign until `unfreeze <number>` restores the prior stage.
|
||||
|
||||
use super::CommandContext;
|
||||
use crate::io::story_metadata::{
|
||||
clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field,
|
||||
};
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
use std::path::Path;
|
||||
|
||||
/// Handle the `freeze` command.
|
||||
@@ -52,23 +50,22 @@ fn freeze_by_story_id(story_id: &str) -> String {
|
||||
|
||||
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
|
||||
|
||||
if meta.frozen == Some(true) {
|
||||
// Check if already frozen via the typed stage.
|
||||
if crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|i| i.stage.is_frozen())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return format!("**{story_name}** ({story_id}) is already frozen.");
|
||||
}
|
||||
|
||||
let updated = set_front_matter_field(&contents, "frozen", "true");
|
||||
|
||||
crate::db::write_content(story_id, &updated);
|
||||
let stage = crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|i| i.stage.dir_name().to_string())
|
||||
.unwrap_or_else(|| "2_current".to_string());
|
||||
crate::db::write_item_with_content(story_id, &stage, &updated);
|
||||
|
||||
format!(
|
||||
"Frozen **{story_name}** ({story_id}). Pipeline advancement and auto-assign suppressed until unfrozen."
|
||||
)
|
||||
match crate::pipeline_state::transition_to_frozen(story_id) {
|
||||
Ok(_) => format!(
|
||||
"Frozen **{story_name}** ({story_id}). Pipeline advancement and auto-assign suppressed until unfrozen."
|
||||
),
|
||||
Err(e) => format!("Failed to freeze **{story_name}** ({story_id}): {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle the `unfreeze` command.
|
||||
@@ -112,21 +109,23 @@ fn unfreeze_by_story_id(story_id: &str) -> String {
|
||||
|
||||
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
|
||||
|
||||
if meta.frozen != Some(true) {
|
||||
// Check frozen via typed stage.
|
||||
let is_frozen = crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|i| i.stage.is_frozen())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_frozen {
|
||||
return format!("**{story_name}** ({story_id}) is not frozen. Nothing to unfreeze.");
|
||||
}
|
||||
|
||||
let updated = clear_front_matter_field_in_content(&contents, "frozen");
|
||||
|
||||
crate::db::write_content(story_id, &updated);
|
||||
let stage = crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|i| i.stage.dir_name().to_string())
|
||||
.unwrap_or_else(|| "2_current".to_string());
|
||||
crate::db::write_item_with_content(story_id, &stage, &updated);
|
||||
|
||||
format!("Unfrozen **{story_name}** ({story_id}). Normal pipeline behaviour resumed.")
|
||||
match crate::pipeline_state::transition_to_unfrozen(story_id) {
|
||||
Ok(_) => {
|
||||
format!("Unfrozen **{story_name}** ({story_id}). Normal pipeline behaviour resumed.")
|
||||
}
|
||||
Err(e) => format!("Failed to unfreeze **{story_name}** ({story_id}): {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -212,8 +211,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeze_command_sets_frozen_flag() {
|
||||
fn freeze_command_sets_stage_to_frozen() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
@@ -226,40 +226,54 @@ mod tests {
|
||||
output.contains("Frozen") && output.contains("Freeze Me"),
|
||||
"should confirm freeze with story name: {output}"
|
||||
);
|
||||
let contents = crate::db::read_content("9940_story_freezeme")
|
||||
.expect("story content should be readable after freeze");
|
||||
let item = crate::pipeline_state::read_typed("9940_story_freezeme")
|
||||
.expect("read_typed should succeed")
|
||||
.expect("item should be present");
|
||||
assert!(
|
||||
contents.contains("frozen: true"),
|
||||
"frozen flag should be set: {contents}"
|
||||
item.stage.is_frozen(),
|
||||
"stage should be Frozen after freeze: {:?}",
|
||||
item.stage
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfreeze_command_clears_frozen_flag() {
|
||||
fn unfreeze_command_restores_prior_stage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"9941_story_frozen.md",
|
||||
"---\nname: Frozen Story\nfrozen: true\n---\n# Story\n",
|
||||
"---\nname: Frozen Story\n---\n# Story\n",
|
||||
);
|
||||
// Freeze first.
|
||||
let freeze_out = freeze_cmd_with_root(tmp.path(), "9941").unwrap();
|
||||
assert!(
|
||||
freeze_out.contains("Frozen"),
|
||||
"should confirm freeze: {freeze_out}"
|
||||
);
|
||||
// Now unfreeze.
|
||||
let output = unfreeze_cmd_with_root(tmp.path(), "9941").unwrap();
|
||||
assert!(
|
||||
output.contains("Unfrozen") && output.contains("Frozen Story"),
|
||||
"should confirm unfreeze with story name: {output}"
|
||||
);
|
||||
let contents = crate::db::read_content("9941_story_frozen")
|
||||
.expect("story content should be readable after unfreeze");
|
||||
let item = crate::pipeline_state::read_typed("9941_story_frozen")
|
||||
.expect("read_typed should succeed")
|
||||
.expect("item should be present");
|
||||
assert!(
|
||||
!contents.contains("frozen:"),
|
||||
"frozen flag should be removed: {contents}"
|
||||
matches!(item.stage, crate::pipeline_state::Stage::Coding),
|
||||
"stage should be restored to Coding: {:?}",
|
||||
item.stage
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfreeze_command_not_frozen_returns_error() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
@@ -276,12 +290,17 @@ mod tests {
|
||||
#[test]
|
||||
fn freeze_command_already_frozen_returns_message() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"9943_story_alreadyfrozen.md",
|
||||
"---\nname: Already Frozen\nfrozen: true\n---\n# Story\n",
|
||||
"---\nname: Already Frozen\n---\n# Story\n",
|
||||
);
|
||||
// Freeze it first.
|
||||
freeze_cmd_with_root(tmp.path(), "9943").unwrap();
|
||||
// Try to freeze again.
|
||||
let output = freeze_cmd_with_root(tmp.path(), "9943").unwrap();
|
||||
assert!(
|
||||
output.contains("already frozen"),
|
||||
|
||||
@@ -113,6 +113,7 @@ fn stage_display_name(stage: &str) -> &str {
|
||||
Some(Stage::Merge { .. }) => "merge",
|
||||
Some(Stage::Done { .. }) => "done",
|
||||
Some(Stage::Archived { .. }) => "archived",
|
||||
Some(Stage::Frozen { .. }) => "frozen",
|
||||
None => stage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
|
||||
Stage::Merge { .. } => "merge",
|
||||
Stage::Done { .. } => "done",
|
||||
Stage::Archived { .. } => "archived",
|
||||
Stage::Frozen { .. } => "frozen",
|
||||
};
|
||||
member_items.push(json!({
|
||||
"story_id": sid,
|
||||
|
||||
@@ -150,6 +150,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
||||
Stage::Merge { .. } => state.merge.push(story),
|
||||
Stage::Done { .. } => state.done.push(story),
|
||||
Stage::Archived { .. } => {} // skip archived
|
||||
Stage::Frozen { .. } => state.backlog.push(story), // show frozen with backlog
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ pub(super) struct FrontMatter {
|
||||
pub depends_on: Option<Vec<u32>>,
|
||||
/// When `true`, the story is frozen.
|
||||
pub frozen: Option<bool>,
|
||||
/// Stage directory to restore on unfreeze (e.g. `"2_current"`).
|
||||
pub resume_to_stage: Option<String>,
|
||||
/// Set to `true` when an agent's `run_tests` call returns `passed=true`.
|
||||
/// Used by the bug-645 salvage path to distinguish a genuine test-passing
|
||||
/// session from one that merely compiled.
|
||||
@@ -76,6 +78,7 @@ fn build_metadata(front: FrontMatter) -> StoryMetadata {
|
||||
blocked: front.blocked,
|
||||
depends_on: front.depends_on,
|
||||
frozen: front.frozen,
|
||||
resume_to_stage: front.resume_to_stage,
|
||||
run_tests_passed: front.run_tests_passed,
|
||||
item_type: front.item_type,
|
||||
mergemaster_attempted: front.mergemaster_attempted,
|
||||
@@ -131,17 +134,15 @@ pub fn resolve_qa_mode_from_content(story_id: &str, contents: &str, default: QaM
|
||||
}
|
||||
}
|
||||
|
||||
/// Return `true` if the story has `frozen: true` in the content store.
|
||||
/// Return `true` if the story is in the `Frozen` pipeline stage.
|
||||
///
|
||||
/// Used by the pipeline advance code to suppress stage transitions for frozen stories.
|
||||
/// Checks the typed CRDT stage via `read_typed`. Used by the pipeline advance
|
||||
/// code to suppress stage transitions for frozen stories.
|
||||
pub fn is_story_frozen_in_store(story_id: &str) -> bool {
|
||||
let contents = match crate::db::read_content(story_id) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
parse_front_matter(&contents)
|
||||
crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.and_then(|m| m.frozen)
|
||||
.flatten()
|
||||
.map(|item| item.stage.is_frozen())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ pub struct StoryMetadata {
|
||||
/// When `true`, the story is frozen: auto-assign skips it, the pipeline
|
||||
/// does not advance it, and no mergemaster is spawned.
|
||||
pub frozen: Option<bool>,
|
||||
/// Pipeline stage to restore when unfreezing (e.g. `"2_current"`).
|
||||
/// Written by `transition_to_frozen`; cleared by `transition_to_unfrozen`.
|
||||
pub resume_to_stage: Option<String>,
|
||||
/// Set to `true` when an agent's `run_tests` call returns `passed=true`.
|
||||
/// Used by the bug-645 salvage path to require real test evidence, not just
|
||||
/// compilation success.
|
||||
|
||||
@@ -56,6 +56,7 @@ pub fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, Strin
|
||||
Stage::Merge { .. } => ("merge", format!("huskies: queue {item_id} for merge")),
|
||||
Stage::Done { .. } => ("done", format!("huskies: done {item_id}")),
|
||||
Stage::Archived { .. } => ("accept", format!("huskies: accept {item_id}")),
|
||||
Stage::Frozen { .. } => ("freeze", format!("huskies: freeze {item_id}")),
|
||||
};
|
||||
Some((action, msg))
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +209,7 @@ pub fn get_work_item_content(
|
||||
crate::pipeline_state::Stage::Merge { .. } => "merge",
|
||||
crate::pipeline_state::Stage::Done { .. } => "done",
|
||||
crate::pipeline_state::Stage::Archived { .. } => "archived",
|
||||
crate::pipeline_state::Stage::Frozen { .. } => "frozen",
|
||||
})
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
@@ -17,6 +17,7 @@ pub fn stage_display_name(stage: &str) -> &'static str {
|
||||
Some(Stage::Merge { .. }) => "Merge",
|
||||
Some(Stage::Done { .. }) => "Done",
|
||||
Some(Stage::Archived { .. }) => "Archived",
|
||||
Some(Stage::Frozen { .. }) => "Frozen",
|
||||
None => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user