From f9b140add9ff6cfcd8be58896502fe5ba2b5643a Mon Sep 17 00:00:00 2001 From: dave Date: Fri, 15 May 2026 00:30:07 +0000 Subject: [PATCH] huskies: merge 1073 --- server/src/http/mcp/story_tools/bug.rs | 8 ++ server/src/http/mcp/story_tools/refactor.rs | 8 ++ server/src/http/mcp/story_tools/spike.rs | 8 ++ .../src/http/mcp/story_tools/story/create.rs | 8 ++ server/src/io/watcher/events.rs | 10 +++ server/src/service/notifications/events.rs | 13 +++ server/src/service/notifications/format.rs | 81 +++++++++++++++++++ .../src/service/notifications/io/listener.rs | 25 +++++- server/src/service/ws/message/convert.rs | 2 + 9 files changed, 161 insertions(+), 2 deletions(-) diff --git a/server/src/http/mcp/story_tools/bug.rs b/server/src/http/mcp/story_tools/bug.rs index 8174d232..872bed1c 100644 --- a/server/src/http/mcp/story_tools/bug.rs +++ b/server/src/http/mcp/story_tools/bug.rs @@ -38,6 +38,14 @@ pub(crate) fn tool_create_bug(args: &Value, ctx: &AppContext) -> Result Result Result Result = depends_on_ids .as_deref() diff --git a/server/src/io/watcher/events.rs b/server/src/io/watcher/events.rs index df55575f..9c441ee5 100644 --- a/server/src/io/watcher/events.rs +++ b/server/src/io/watcher/events.rs @@ -90,4 +90,14 @@ pub enum WatcherEvent { /// `true` if acceptance gates passed; `false` if they failed. success: bool, }, + /// A new work item was successfully created and added to the backlog. + /// Triggers a creation notification to configured chat rooms. + NewItemCreated { + /// Work item ID (e.g. `"1075_refactor_split_stage_enum"`). + item_id: String, + /// Human-readable item type (`"story"`, `"bug"`, `"refactor"`, `"spike"`). + item_type: String, + /// Human-readable item name. + name: String, + }, } diff --git a/server/src/service/notifications/events.rs b/server/src/service/notifications/events.rs index fb12935a..d839b89d 100644 --- a/server/src/service/notifications/events.rs +++ b/server/src/service/notifications/events.rs @@ -26,6 +26,8 @@ pub enum EventAction { /// `true` if acceptance gates passed. success: bool, }, + /// Post a new-item-created notification. + NewItemCreated, /// Log server-side only; do not post to chat (e.g. hard rate-limit blocks). LogOnly, /// Reload the project configuration. @@ -51,6 +53,7 @@ pub fn classify(event: &WatcherEvent) -> EventAction { WatcherEvent::AgentCompleted { success, .. } => { EventAction::AgentCompleted { success: *success } } + WatcherEvent::NewItemCreated { .. } => EventAction::NewItemCreated, _ => EventAction::Skip, } } @@ -178,4 +181,14 @@ mod tests { EventAction::AgentCompleted { success: false } ); } + + #[test] + fn new_item_created_is_classified_correctly() { + let event = WatcherEvent::NewItemCreated { + item_id: "1075_refactor_split_stage".to_string(), + item_type: "refactor".to_string(), + name: "Split Stage enum".to_string(), + }; + assert_eq!(classify(&event), EventAction::NewItemCreated); + } } diff --git a/server/src/service/notifications/format.rs b/server/src/service/notifications/format.rs index 467cbdc6..6219ba1e 100644 --- a/server/src/service/notifications/format.rs +++ b/server/src/service/notifications/format.rs @@ -220,6 +220,26 @@ pub fn format_agent_completed_notification( (plain, html) } +/// Format a new-work-item creation notification. +/// +/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. +pub fn format_new_item_notification( + item_id: &str, + item_type: &str, + name: &str, +) -> (String, String) { + let number = extract_item_number(item_id).unwrap_or(item_id); + let emoji = match item_type { + "bug" => "\u{1f41b}", // 🐛 + "refactor" => "\u{1f4dd}", // 📝 + "spike" => "\u{1f52c}", // 🔬 + _ => "\u{1f4d6}", // 📖 (story and unknown) + }; + let plain = format!("{emoji} New {item_type} #{number} \u{2014} {name}"); + let html = format!("{emoji} New {item_type} #{number} \u{2014} {name}"); + (plain, html) +} + /// Extract the first non-empty line from a merge failure reason, truncated to `max_len` chars. /// /// Used to produce a compact snippet for chat notifications. @@ -599,6 +619,67 @@ mod tests { ); } + // ── format_new_item_notification ────────────────────────────────────────── + + #[test] + fn format_new_item_notification_story() { + let (plain, html) = + format_new_item_notification("42_story_my_feature", "story", "My Feature"); + assert_eq!(plain, "\u{1f4d6} New story #42 \u{2014} My Feature"); + assert_eq!( + html, + "\u{1f4d6} New story #42 \u{2014} My Feature" + ); + } + + #[test] + fn format_new_item_notification_bug() { + let (plain, html) = + format_new_item_notification("99_bug_login_crash", "bug", "Login Crash"); + assert_eq!(plain, "\u{1f41b} New bug #99 \u{2014} Login Crash"); + assert_eq!( + html, + "\u{1f41b} New bug #99 \u{2014} Login Crash" + ); + } + + #[test] + fn format_new_item_notification_refactor() { + let (plain, html) = format_new_item_notification( + "1075_refactor_split_stage", + "refactor", + "Split Stage enum into Pipeline + Status", + ); + assert_eq!( + plain, + "\u{1f4dd} New refactor #1075 \u{2014} Split Stage enum into Pipeline + Status" + ); + assert_eq!( + html, + "\u{1f4dd} New refactor #1075 \u{2014} Split Stage enum into Pipeline + Status" + ); + } + + #[test] + fn format_new_item_notification_spike() { + let (plain, html) = + format_new_item_notification("7_spike_encoder_comparison", "spike", "Compare Encoders"); + assert_eq!(plain, "\u{1f52c} New spike #7 \u{2014} Compare Encoders"); + assert_eq!( + html, + "\u{1f52c} New spike #7 \u{2014} Compare Encoders" + ); + } + + #[test] + fn format_new_item_notification_non_numeric_id_uses_full_id() { + let (plain, _html) = format_new_item_notification("abc_story_thing", "story", "Some Story"); + assert_eq!( + plain, + "\u{1f4d6} New story #abc_story_thing \u{2014} Some Story" + ); + } + #[test] fn format_agent_completed_notification_empty_name_falls_back_to_number() { let (plain, _html) = diff --git a/server/src/service/notifications/io/listener.rs b/server/src/service/notifications/io/listener.rs index b614f4a8..f8752bbe 100644 --- a/server/src/service/notifications/io/listener.rs +++ b/server/src/service/notifications/io/listener.rs @@ -15,8 +15,9 @@ use super::super::events::classify; use super::super::filter::{AGENT_EVENT_DEBOUNCE, should_send_rate_limit}; 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, merge_failure_snippet, + format_blocked_notification, format_error_notification, format_new_item_notification, + format_oauth_account_swapped, format_oauth_accounts_exhausted, format_rate_limit_notification, + merge_failure_snippet, }; use super::super::route::rooms_for_notification; use super::{find_story_name_any_stage, read_story_name}; @@ -276,6 +277,26 @@ pub fn spawn_notification_listener( pending_agent_events.insert(key, (plain, html)); agent_flush_deadline = Some(tokio::time::Instant::now() + AGENT_EVENT_DEBOUNCE); } + EventAction::NewItemCreated => { + if !config.status_push_enabled { + continue; + } + let WatcherEvent::NewItemCreated { + ref item_id, + ref item_type, + ref name, + } = event + else { + continue; + }; + let (plain, html) = format_new_item_notification(item_id, item_type, name); + slog!("[bot] Sending new-item notification: {plain}"); + for room_id in &rooms_for_notification(&get_room_ids) { + if let Err(e) = transport.send_message(room_id, &plain, &html).await { + slog!("[bot] Failed to send new-item notification to {room_id}: {e}"); + } + } + } EventAction::LogOnly => { // Hard-block: log server-side for debugging; do NOT post to chat. // Hard-block auto-resume is normal operation — the status command diff --git a/server/src/service/ws/message/convert.rs b/server/src/service/ws/message/convert.rs index ec30e300..02b53c6a 100644 --- a/server/src/service/ws/message/convert.rs +++ b/server/src/service/ws/message/convert.rs @@ -37,6 +37,8 @@ pub fn watcher_event_to_response(e: WatcherEvent) -> Option { // Agent lifecycle events are forwarded to chat transports only; no WebSocket message. WatcherEvent::AgentStarted { .. } => None, WatcherEvent::AgentCompleted { .. } => None, + // Creation notifications are forwarded to chat transports only; no WebSocket message. + WatcherEvent::NewItemCreated { .. } => None, } }