diff --git a/server/src/agents/lifecycle.rs b/server/src/agents/lifecycle.rs index f0501568..0bd173fb 100644 --- a/server/src/agents/lifecycle.rs +++ b/server/src/agents/lifecycle.rs @@ -349,6 +349,7 @@ fn stage_to_name(s: &Stage) -> &'static str { Stage::Merge { .. } => "merge", Stage::Done { .. } => "done", Stage::Archived { .. } => "archived", + Stage::Frozen { .. } => "frozen", } } diff --git a/server/src/agents/pool/auto_assign/story_checks.rs b/server/src/agents/pool/auto_assign/story_checks.rs index 13d9cdd7..b5a4270a 100644 --- a/server/src/agents/pool/auto_assign/story_checks.rs +++ b/server/src/agents/pool/auto_assign/story_checks.rs @@ -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) } diff --git a/server/src/chat/commands/freeze.rs b/server/src/chat/commands/freeze.rs index 85e60c99..31b6d1ea 100644 --- a/server/src/chat/commands/freeze.rs +++ b/server/src/chat/commands/freeze.rs @@ -1,12 +1,10 @@ //! Handler for the `freeze` and `unfreeze` commands. //! -//! `freeze ` sets `frozen: true` on the story, halting pipeline -//! advancement and auto-assign until `unfreeze ` clears the flag. +//! `freeze ` transitions the story to `Stage::Frozen`, halting pipeline +//! advancement and auto-assign until `unfreeze ` 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"), diff --git a/server/src/chat/transport/matrix/delete.rs b/server/src/chat/transport/matrix/delete.rs index 1592a2ea..3778b68e 100644 --- a/server/src/chat/transport/matrix/delete.rs +++ b/server/src/chat/transport/matrix/delete.rs @@ -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, } } diff --git a/server/src/http/mcp/story_tools/epic.rs b/server/src/http/mcp/story_tools/epic.rs index f21e0960..eef71365 100644 --- a/server/src/http/mcp/story_tools/epic.rs +++ b/server/src/http/mcp/story_tools/epic.rs @@ -144,6 +144,7 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result "merge", Stage::Done { .. } => "done", Stage::Archived { .. } => "archived", + Stage::Frozen { .. } => "frozen", }; member_items.push(json!({ "story_id": sid, diff --git a/server/src/http/workflow/pipeline.rs b/server/src/http/workflow/pipeline.rs index d2c4f94f..7a1b77cc 100644 --- a/server/src/http/workflow/pipeline.rs +++ b/server/src/http/workflow/pipeline.rs @@ -150,6 +150,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result { 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 } } diff --git a/server/src/io/story_metadata/parser.rs b/server/src/io/story_metadata/parser.rs index 2535fb9c..8fa5e55c 100644 --- a/server/src/io/story_metadata/parser.rs +++ b/server/src/io/story_metadata/parser.rs @@ -22,6 +22,8 @@ pub(super) struct FrontMatter { pub depends_on: Option>, /// When `true`, the story is frozen. pub frozen: Option, + /// Stage directory to restore on unfreeze (e.g. `"2_current"`). + pub resume_to_stage: Option, /// 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) } diff --git a/server/src/io/story_metadata/types.rs b/server/src/io/story_metadata/types.rs index 655719a2..77f5f05f 100644 --- a/server/src/io/story_metadata/types.rs +++ b/server/src/io/story_metadata/types.rs @@ -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, + /// 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, /// 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. diff --git a/server/src/io/watcher/mod.rs b/server/src/io/watcher/mod.rs index 8e4df853..cd849dd1 100644 --- a/server/src/io/watcher/mod.rs +++ b/server/src/io/watcher/mod.rs @@ -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)) } diff --git a/server/src/pipeline_state/apply.rs b/server/src/pipeline_state/apply.rs index 49b0558b..f2d6f40e 100644 --- a/server/src/pipeline_state/apply.rs +++ b/server/src/pipeline_state/apply.rs @@ -97,3 +97,28 @@ pub fn apply_transition_str( ) -> Result { 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 { + 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 { + 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)) +} diff --git a/server/src/pipeline_state/mod.rs b/server/src/pipeline_state/mod.rs index b1abfa1e..22313a09 100644 --- a/server/src/pipeline_state/mod.rs +++ b/server/src/pipeline_state/mod.rs @@ -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::{ diff --git a/server/src/pipeline_state/projection.rs b/server/src/pipeline_state/projection.rs index 2e1e2b1e..f8eb96c4 100644 --- a/server/src/pipeline_state/projection.rs +++ b/server/src/pipeline_state/projection.rs @@ -119,6 +119,21 @@ pub fn project_stage(view: &PipelineItemView) -> Result 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) } } diff --git a/server/src/pipeline_state/tests.rs b/server/src/pipeline_state/tests.rs index 358c6861..0447b6ea 100644 --- a/server/src/pipeline_state/tests.rs +++ b/server/src/pipeline_state/tests.rs @@ -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 ───────────────────────────────────────── diff --git a/server/src/pipeline_state/transition.rs b/server/src/pipeline_state/transition.rs index ceb244da..a258edb6 100644 --- a/server/src/pipeline_state/transition.rs +++ b/server/src/pipeline_state/transition.rs @@ -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 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()), } diff --git a/server/src/pipeline_state/types.rs b/server/src/pipeline_state/types.rs index 36e92278..009d6070 100644 --- a/server/src/pipeline_state/types.rs +++ b/server/src/pipeline_state/types.rs @@ -100,6 +100,10 @@ pub enum Stage { archived_at: DateTime, reason: ArchiveReason, }, + + /// Pipeline advancement and auto-assign are suspended. Resumes to + /// `resume_to` when unfrozen. + Frozen { resume_to: Box }, } /// 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::::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", } } diff --git a/server/src/service/agents/mod.rs b/server/src/service/agents/mod.rs index 61939521..d385bea3 100644 --- a/server/src/service/agents/mod.rs +++ b/server/src/service/agents/mod.rs @@ -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(); diff --git a/server/src/service/notifications/format.rs b/server/src/service/notifications/format.rs index 0923a13b..93d79e78 100644 --- a/server/src/service/notifications/format.rs +++ b/server/src/service/notifications/format.rs @@ -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", } }