diff --git a/server/src/chat/transport/matrix/delete.rs b/server/src/chat/transport/matrix/delete.rs index 5a3e0ae8..3261ce40 100644 --- a/server/src/chat/transport/matrix/delete.rs +++ b/server/src/chat/transport/matrix/delete.rs @@ -87,7 +87,10 @@ pub async fn handle_delete( }; // Build the response. - let stage_label = stage_display_name(&stage); + let stage_label = match crate::pipeline_state::Stage::from_dir(&stage) { + Some(s) => stage_display_label(&s), + None => stage.as_str(), + }; let mut response = format!("Deleted **{story_name}** from **{stage_label}**."); if !outcome.agents_stopped.is_empty() { let agent_list = outcome.agents_stopped.join(", "); @@ -99,20 +102,19 @@ pub async fn handle_delete( response } -/// Human-readable label for a pipeline stage directory name. -fn stage_display_name(stage: &str) -> &str { +/// Human-readable label for a typed pipeline [`Stage`]. +fn stage_display_label(stage: &crate::pipeline_state::Stage) -> &'static str { use crate::pipeline_state::Stage; - match Stage::from_dir(stage) { - Some(Stage::Upcoming) => "upcoming", - Some(Stage::Backlog) => "backlog", - Some(Stage::Coding) => "in-progress", - Some(Stage::Blocked { .. }) => "blocked", - Some(Stage::Qa) => "QA", - Some(Stage::Merge { .. }) => "merge", - Some(Stage::Done { .. }) => "done", - Some(Stage::Archived { .. }) => "archived", - Some(Stage::MergeFailure { .. }) => "merge-failure", - None => stage, + match stage { + Stage::Upcoming => "upcoming", + Stage::Backlog => "backlog", + Stage::Coding => "in-progress", + Stage::Blocked { .. } => "blocked", + Stage::Qa => "QA", + Stage::Merge { .. } => "merge", + Stage::Done { .. } => "done", + Stage::Archived { .. } => "archived", + Stage::MergeFailure { .. } => "merge-failure", } } diff --git a/server/src/http/agents/mod.rs b/server/src/http/agents/mod.rs index 5aff6d85..41b37158 100644 --- a/server/src/http/agents/mod.rs +++ b/server/src/http/agents/mod.rs @@ -92,9 +92,20 @@ struct WorkItemContentResponse { impl From for WorkItemContentResponse { fn from(w: WorkItemContent) -> Self { + use crate::pipeline_state::Stage; + // Frozen items report "frozen" so the UI can render them distinctly; + // otherwise we emit the canonical clean stage directory name. + let stage = if w.frozen { + "frozen".to_string() + } else { + match &w.stage { + Stage::Coding => "current".to_string(), + other => other.dir_name().to_string(), + } + }; Self { content: w.content, - stage: w.stage, + stage, name: w.name, agent: w.agent, } diff --git a/server/src/http/mcp/story_tools/epic.rs b/server/src/http/mcp/story_tools/epic.rs index fbf797df..aa82ce0c 100644 --- a/server/src/http/mcp/story_tools/epic.rs +++ b/server/src/http/mcp/story_tools/epic.rs @@ -120,6 +120,7 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result = Vec::new(); + let mut done = 0usize; for item in &all_items { let sid = &item.story_id.0; let Some(member_view) = crate::crdt_state::read_item(sid) else { @@ -141,6 +142,9 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result "blocked", } }; + if matches!(item.stage, Stage::Done { .. }) { + done += 1; + } member_items.push(json!({ "story_id": sid, "name": item.name, @@ -150,7 +154,6 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result, pub agent: Option, } @@ -171,13 +173,24 @@ pub fn get_work_item_content( project_root: &Path, story_id: &str, ) -> Result { + use crate::pipeline_state::Stage; + let stages = [ - ("1_backlog", "backlog"), - ("2_current", "current"), - ("3_qa", "qa"), - ("4_merge", "merge"), - ("5_done", "done"), - ("6_archived", "archived"), + ("1_backlog", Stage::Backlog), + ("2_current", Stage::Coding), + ("3_qa", Stage::Qa), + ( + "4_merge", + Stage::from_dir("merge").expect("merge is a valid stage dir"), + ), + ( + "5_done", + Stage::from_dir("done").expect("done is a valid stage dir"), + ), + ( + "6_archived", + Stage::from_dir("archived").expect("archived is a valid stage dir"), + ), ]; let work_dir = project_root.join(".huskies").join("work"); @@ -191,11 +204,12 @@ pub fn get_work_item_content( .as_ref() .and_then(|v| v.agent().map(str::to_string)); - for (stage_dir, stage_name) in &stages { + for (stage_dir, stage) in &stages { if let Some(content) = io::read_work_item_from_stage(&work_dir, stage_dir, &filename)? { return Ok(WorkItemContent { content, - stage: stage_name.to_string(), + stage: stage.clone(), + frozen: false, name: crdt_name.clone(), agent: crdt_agent.clone(), }); @@ -206,31 +220,14 @@ pub fn get_work_item_content( if let Some(content) = crate::db::read_content(story_id) { let item = crate::pipeline_state::read_typed(story_id) .map_err(|e| Error::Io(format!("Pipeline read error: {e}")))?; - let stage = item - .as_ref() - .map(|i| { - // Frozen is now an orthogonal CRDT flag (story 934, stage 4). - if i.is_frozen() { - "frozen" - } else { - match &i.stage { - crate::pipeline_state::Stage::Upcoming => "upcoming", - crate::pipeline_state::Stage::Backlog => "backlog", - crate::pipeline_state::Stage::Coding => "current", - crate::pipeline_state::Stage::Blocked { .. } => "blocked", - crate::pipeline_state::Stage::Qa => "qa", - crate::pipeline_state::Stage::Merge { .. } => "merge", - crate::pipeline_state::Stage::MergeFailure { .. } => "merge_failure", - crate::pipeline_state::Stage::Done { .. } => "done", - crate::pipeline_state::Stage::Archived { .. } => "archived", - } - } - }) - .unwrap_or("unknown") - .to_string(); + let (stage, frozen) = match item.as_ref() { + Some(i) => (i.stage.clone(), i.is_frozen()), + None => (Stage::Upcoming, false), + }; return Ok(WorkItemContent { content, stage, + frozen, name: crdt_name, agent: crdt_agent, }); @@ -364,7 +361,8 @@ max_budget_usd = 5.0 ); let item = get_work_item_content(tmp.path(), "42_story_foo").unwrap(); assert!(item.content.contains("Some content.")); - assert_eq!(item.stage, "backlog"); + assert_eq!(item.stage, crate::pipeline_state::Stage::Backlog); + assert!(!item.frozen); assert_eq!(item.name, Some("Foo Story".to_string())); } diff --git a/server/src/service/gateway/polling.rs b/server/src/service/gateway/polling.rs index cff6b81b..6bd32455 100644 --- a/server/src/service/gateway/polling.rs +++ b/server/src/service/gateway/polling.rs @@ -4,10 +4,10 @@ //! with `[project-name]` prefixes. The actual I/O (HTTP polling, spawning //! tasks, sending messages) lives in `io.rs`. +use crate::pipeline_state::Stage; use crate::service::events::StoredEvent; use crate::service::notifications::{ format_blocked_notification, format_error_notification, format_stage_notification, - stage_display_name, }; /// Format a [`StoredEvent`] from a project into a gateway notification. @@ -24,9 +24,9 @@ pub fn format_gateway_event(project_name: &str, event: &StoredEvent) -> (String, to_stage, .. } => { - let from_display = stage_display_name(from_stage); - let to_display = stage_display_name(to_stage); - let (plain, html) = format_stage_notification(story_id, None, from_display, to_display); + let from_typed = Stage::from_dir(from_stage).unwrap_or(Stage::Upcoming); + let to_typed = Stage::from_dir(to_stage).unwrap_or(Stage::Upcoming); + let (plain, html) = format_stage_notification(story_id, None, &from_typed, &to_typed); (format!("{prefix}{plain}"), format!("{prefix}{html}")) } StoredEvent::MergeFailure { diff --git a/server/src/service/notifications/format.rs b/server/src/service/notifications/format.rs index c423dc46..f9dbf26c 100644 --- a/server/src/service/notifications/format.rs +++ b/server/src/service/notifications/format.rs @@ -4,22 +4,21 @@ //! or borrowed string data. They return `(plain_text, html)` pairs suitable //! for `ChatTransport::send_message`. +use crate::pipeline_state::Stage; use crate::service::common::item_id::extract_item_number; -/// Human-readable display name for a pipeline stage directory. -pub fn stage_display_name(stage: &str) -> &'static str { - use crate::pipeline_state::Stage; - match Stage::from_dir(stage) { - Some(Stage::Upcoming) => "Upcoming", - Some(Stage::Backlog) => "Backlog", - Some(Stage::Coding) => "Current", - Some(Stage::Blocked { .. }) => "Blocked", - Some(Stage::Qa) => "QA", - Some(Stage::Merge { .. }) => "Merge", - Some(Stage::Done { .. }) => "Done", - Some(Stage::Archived { .. }) => "Archived", - Some(Stage::MergeFailure { .. }) => "MergeFailure", - None => "Unknown", +/// Human-readable display name for a typed pipeline [`Stage`]. +pub fn stage_display_name(stage: &Stage) -> &'static str { + match stage { + Stage::Upcoming => "Upcoming", + Stage::Backlog => "Backlog", + Stage::Coding => "Current", + Stage::Blocked { .. } => "Blocked", + Stage::Qa => "QA", + Stage::Merge { .. } => "Merge", + Stage::Done { .. } => "Done", + Stage::Archived { .. } => "Archived", + Stage::MergeFailure { .. } => "MergeFailure", } } @@ -29,8 +28,8 @@ pub fn stage_display_name(stage: &str) -> &'static str { pub fn format_stage_notification( item_id: &str, story_name: Option<&str>, - from_stage: &str, - to_stage: &str, + from_stage: &Stage, + to_stage: &Stage, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let effective_name = story_name.filter(|n| !n.is_empty()); @@ -39,10 +38,17 @@ pub fn format_stage_notification( .map(|n| format!("{n} ")) .unwrap_or_default(); - let prefix = if to_stage == "Done" { "\u{1f389} " } else { "" }; - let plain = format!("{prefix}#{number} {name_plain}\u{2014} {from_stage} \u{2192} {to_stage}"); + let from_display = stage_display_name(from_stage); + let to_display = stage_display_name(to_stage); + let prefix = if matches!(to_stage, Stage::Done { .. }) { + "\u{1f389} " + } else { + "" + }; + let plain = + format!("{prefix}#{number} {name_plain}\u{2014} {from_display} \u{2192} {to_display}"); let html = format!( - "{prefix}#{number} {name_html}\u{2014} {from_stage} \u{2192} {to_stage}" + "{prefix}#{number} {name_html}\u{2014} {from_display} \u{2192} {to_display}" ); (plain, html) } @@ -207,29 +213,37 @@ mod tests { // ── stage_display_name ──────────────────────────────────────────────────── - #[test] - fn stage_display_name_maps_all_known_stages() { - assert_eq!(stage_display_name("backlog"), "Backlog"); - assert_eq!(stage_display_name("coding"), "Current"); - assert_eq!(stage_display_name("qa"), "QA"); - assert_eq!(stage_display_name("merge"), "Merge"); - assert_eq!(stage_display_name("done"), "Done"); - assert_eq!(stage_display_name("archived"), "Archived"); - assert_eq!(stage_display_name("unknown"), "Unknown"); + fn done_stage() -> Stage { + Stage::from_dir("done").unwrap() + } + fn merge_stage() -> Stage { + Stage::from_dir("merge").unwrap() } #[test] - fn stage_display_name_unknown_slug_returns_unknown() { - assert_eq!(stage_display_name("99_future"), "Unknown"); - assert_eq!(stage_display_name(""), "Unknown"); + fn stage_display_name_maps_all_known_stages() { + assert_eq!(stage_display_name(&Stage::Backlog), "Backlog"); + assert_eq!(stage_display_name(&Stage::Coding), "Current"); + assert_eq!(stage_display_name(&Stage::Qa), "QA"); + assert_eq!(stage_display_name(&merge_stage()), "Merge"); + assert_eq!(stage_display_name(&done_stage()), "Done"); + assert_eq!( + stage_display_name(&Stage::from_dir("archived").unwrap()), + "Archived" + ); + assert_eq!(stage_display_name(&Stage::Upcoming), "Upcoming"); } // ── format_stage_notification ───────────────────────────────────────────── #[test] fn format_notification_done_stage_includes_party_emoji() { - let (plain, html) = - format_stage_notification("353_story_done", Some("Done Story"), "Merge", "Done"); + let (plain, html) = format_stage_notification( + "353_story_done", + Some("Done Story"), + &merge_stage(), + &done_stage(), + ); assert_eq!( plain, "\u{1f389} #353 Done Story \u{2014} Merge \u{2192} Done" @@ -242,8 +256,12 @@ mod tests { #[test] fn format_notification_non_done_stage_has_no_emoji() { - let (plain, _html) = - format_stage_notification("42_story_thing", Some("Some Story"), "Backlog", "Current"); + let (plain, _html) = format_stage_notification( + "42_story_thing", + Some("Some Story"), + &Stage::Backlog, + &Stage::Coding, + ); assert!(!plain.contains("\u{1f389}")); } @@ -252,8 +270,8 @@ mod tests { let (plain, html) = format_stage_notification( "261_story_bot_notifications", Some("Bot notifications"), - "Upcoming", - "Current", + &Stage::Upcoming, + &Stage::Coding, ); assert_eq!( plain, @@ -267,15 +285,20 @@ mod tests { #[test] fn format_stage_notification_without_story_name_falls_back_to_number() { - let (plain, html) = format_stage_notification("42_bug_fix_thing", None, "Current", "QA"); + let (plain, html) = + format_stage_notification("42_bug_fix_thing", None, &Stage::Coding, &Stage::Qa); assert_eq!(plain, "#42 \u{2014} Current \u{2192} QA"); assert_eq!(html, "#42 \u{2014} Current \u{2192} QA"); } #[test] fn format_notification_non_numeric_id_uses_full_id() { - let (plain, _html) = - format_stage_notification("abc_story_thing", Some("Some Story"), "QA", "Merge"); + let (plain, _html) = format_stage_notification( + "abc_story_thing", + Some("Some Story"), + &Stage::Qa, + &merge_stage(), + ); assert_eq!( plain, "#abc_story_thing Some Story \u{2014} QA \u{2192} Merge" @@ -286,21 +309,26 @@ mod tests { fn format_stage_notification_long_name_is_preserved() { let long_name = "A".repeat(300); let (plain, _html) = - format_stage_notification("1_story_long", Some(&long_name), "Current", "QA"); + format_stage_notification("1_story_long", Some(&long_name), &Stage::Coding, &Stage::Qa); assert!(plain.contains(&long_name)); } #[test] fn format_stage_notification_empty_story_name_falls_back_to_number() { - let (plain, html) = format_stage_notification("42_story_empty", Some(""), "Current", "QA"); + let (plain, html) = + format_stage_notification("42_story_empty", Some(""), &Stage::Coding, &Stage::Qa); assert_eq!(plain, "#42 \u{2014} Current \u{2192} QA"); assert_eq!(html, "#42 \u{2014} Current \u{2192} QA"); } #[test] fn format_stage_notification_unicode_name() { - let (plain, html) = - format_stage_notification("7_story_i18n", Some("Ünïcödé Ñämé 🎉"), "QA", "Merge"); + let (plain, html) = format_stage_notification( + "7_story_i18n", + Some("Ünïcödé Ñämé 🎉"), + &Stage::Qa, + &merge_stage(), + ); assert!(plain.contains("Ünïcödé Ñämé 🎉")); assert!(html.contains("Ünïcödé Ñämé 🎉")); } diff --git a/server/src/service/notifications/io/listener.rs b/server/src/service/notifications/io/listener.rs index 2d056e5c..9c8789ad 100644 --- a/server/src/service/notifications/io/listener.rs +++ b/server/src/service/notifications/io/listener.rs @@ -4,6 +4,7 @@ use crate::chat::ChatTransport; use crate::config::ProjectConfig; use crate::io::watcher::WatcherEvent; +use crate::pipeline_state::Stage; use crate::slog; use std::collections::HashMap; use std::path::PathBuf; @@ -19,7 +20,7 @@ use super::super::format::{ format_agent_completed_notification, format_agent_started_notification, format_blocked_notification, format_error_notification, format_oauth_account_swapped, format_oauth_accounts_exhausted, format_rate_limit_notification, format_stage_notification, - merge_failure_snippet, stage_display_name, + merge_failure_snippet, }; use super::super::route::rooms_for_notification; use super::{find_story_name_any_stage, read_story_name}; @@ -47,11 +48,11 @@ pub fn spawn_notification_listener( let mut rate_limit_last_notified: HashMap = HashMap::new(); // Pending stage-transition notifications, keyed by item_id. - // Value: (from_display, to_stage_key, story_name). + // Value: (from_stage, to_stage, story_name). // Rapid successive transitions for the same item are coalesced: the - // original from_display is kept while to_stage_key is updated to the + // original `from_stage` is kept while `to_stage` is updated to the // latest destination, so only one notification fires for the final stage. - let mut pending_transitions: HashMap)> = + let mut pending_transitions: HashMap)> = HashMap::new(); let mut flush_deadline: Option = None; @@ -83,15 +84,13 @@ pub fn spawn_notification_listener( let now = tokio::time::Instant::now(); // Flush stage transitions if their deadline has passed. if flush_deadline.is_some_and(|d| d <= now) { - for (item_id, (from_display, to_stage_key, story_name)) in - pending_transitions.drain() + for (item_id, (from_stage, to_stage, story_name)) in pending_transitions.drain() { - let to_display = stage_display_name(&to_stage_key); let (plain, html) = format_stage_notification( &item_id, story_name.as_deref(), - &from_display, - to_display, + &from_stage, + &to_stage, ); slog!("[bot] Sending stage notification: {plain}"); if config.status_push_enabled { @@ -135,15 +134,14 @@ pub fn spawn_notification_listener( slog!("[bot] Watcher channel closed, stopping notification listener"); // Flush any coalesced transitions that haven't fired yet. if config.status_push_enabled { - for (item_id, (from_display, to_stage_key, story_name)) in + for (item_id, (from_stage, to_stage, story_name)) in pending_transitions.drain() { - let to_display = stage_display_name(&to_stage_key); let (plain, html) = format_stage_notification( &item_id, story_name.as_deref(), - &from_display, - to_display, + &from_stage, + &to_stage, ); slog!("[bot] Sending stage notification: {plain}"); for room_id in &rooms_for_notification(&get_room_ids) { @@ -185,7 +183,11 @@ pub fn spawn_notification_listener( else { continue; }; - let from_display = stage_display_name(from_stage.as_deref().unwrap_or("")); + let from_typed = from_stage + .as_deref() + .and_then(Stage::from_dir) + .unwrap_or(Stage::Upcoming); + let to_typed = Stage::from_dir(stage).unwrap_or(Stage::Upcoming); // Look up the story name in the expected stage directory; fall // back to a full search so stale events still show the name. @@ -193,17 +195,17 @@ pub fn spawn_notification_listener( .or_else(|| find_story_name_any_stage(&project_root, item_id)); // Buffer the transition. If this item_id is already pending (rapid - // succession), update to_stage_key to the latest destination while - // preserving the original from_display. + // succession), update the destination stage to the latest while + // preserving the original from_stage. pending_transitions .entry(item_id.clone()) .and_modify(|e| { - e.1 = stage.clone(); + e.1 = to_typed.clone(); if story_name.is_some() { e.2 = story_name.clone(); } }) - .or_insert_with(|| (from_display.to_string(), stage.clone(), story_name)); + .or_insert_with(|| (from_typed, to_typed, story_name)); // Start or extend the debounce window. flush_deadline = Some(tokio::time::Instant::now() + STAGE_TRANSITION_DEBOUNCE); diff --git a/server/src/service/notifications/mod.rs b/server/src/service/notifications/mod.rs index 00a696cb..69a7c36a 100644 --- a/server/src/service/notifications/mod.rs +++ b/server/src/service/notifications/mod.rs @@ -20,7 +20,6 @@ pub(super) mod route; pub use format::{ format_blocked_notification, format_error_notification, format_stage_notification, - stage_display_name, }; pub use io::spawn_notification_listener; diff --git a/server/src/service/status/format.rs b/server/src/service/status/format.rs index a720a284..c0d2fa86 100644 --- a/server/src/service/status/format.rs +++ b/server/src/service/status/format.rs @@ -4,6 +4,7 @@ //! a human-readable string. Adding a new event type means adding one match arm //! here — no per-transport duplication anywhere in the codebase. +use crate::pipeline_state::Stage; use crate::service::common::item_id::extract_item_number; use crate::service::notifications::format::stage_display_name; use crate::service::status::StatusEvent; @@ -25,9 +26,15 @@ pub fn format_status_event(event: &StatusEvent) -> String { } => { let number = extract_item_number(story_id).unwrap_or(story_id.as_str()); let name = story_name.as_deref().unwrap_or(story_id.as_str()); - let from = stage_display_name(from_stage); - let to = stage_display_name(to_stage); - let prefix = if to == "Done" { "\u{1f389} " } else { "" }; + let from_typed = Stage::from_dir(from_stage).unwrap_or(Stage::Upcoming); + let to_typed = Stage::from_dir(to_stage).unwrap_or(Stage::Upcoming); + let from = stage_display_name(&from_typed); + let to = stage_display_name(&to_typed); + let prefix = if matches!(to_typed, Stage::Done { .. }) { + "\u{1f389} " + } else { + "" + }; format!("{prefix}#{number} {name} \u{2014} {from} \u{2192} {to}") } StatusEvent::MergeFailure {