From f1c96595de8d2779937f2e2d586785c7dd7a4189 Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 14 May 2026 13:11:26 +0000 Subject: [PATCH] huskies: merge 1035 --- server/src/gateway/tests.rs | 6 ++ server/src/gateway_relay.rs | 13 ++- server/src/http/events.rs | 4 + server/src/service/events/buffer.rs | 13 +++ server/src/service/events/io.rs | 3 + server/src/service/gateway/polling.rs | 110 ++++++++++++++++++++++++-- 6 files changed, 141 insertions(+), 8 deletions(-) diff --git a/server/src/gateway/tests.rs b/server/src/gateway/tests.rs index c8b448fb..4e2c1615 100644 --- a/server/src/gateway/tests.rs +++ b/server/src/gateway/tests.rs @@ -144,6 +144,7 @@ async fn gateway_notification_poller_continues_when_one_project_unreachable() { let event = vec![StoredEvent::StoryBlocked { story_id: "10_story_ok".to_string(), + story_name: String::new(), reason: "retry limit".to_string(), timestamp_ms: 500, }]; @@ -249,6 +250,7 @@ async fn gateway_notification_poller_sends_only_to_configured_gateway_rooms() { let event = vec![StoredEvent::MergeFailure { story_id: "5_story_x".to_string(), + story_name: String::new(), reason: "conflict".to_string(), timestamp_ms: 300, }]; @@ -607,6 +609,7 @@ async fn gateway_notification_poller_delivers_events_from_two_projects_with_proj let alpha_events = vec![StoredEvent::StageTransition { story_id: "1_story_alpha".to_string(), + story_name: String::new(), from_stage: "2_current".to_string(), to_stage: "3_qa".to_string(), timestamp_ms: 100, @@ -632,6 +635,7 @@ async fn gateway_notification_poller_delivers_events_from_two_projects_with_proj let beta_events = vec![StoredEvent::MergeFailure { story_id: "2_story_beta".to_string(), + story_name: String::new(), reason: "merge conflict in lib.rs".to_string(), timestamp_ms: 200, }]; @@ -765,6 +769,7 @@ async fn broadcaster_forwarder_forwards_events_with_project_tag() { project: "my-project".to_string(), event: StoredEvent::StageTransition { story_id: "7_story_x".to_string(), + story_name: String::new(), from_stage: "2_current".to_string(), to_stage: "3_qa".to_string(), timestamp_ms: 100, @@ -841,6 +846,7 @@ async fn broadcaster_forwarder_resubscribes_on_lag() { project: "p".to_string(), event: StoredEvent::StageTransition { story_id: format!("{n}_story"), + story_name: String::new(), from_stage: "2_current".to_string(), to_stage: "3_qa".to_string(), timestamp_ms: n, diff --git a/server/src/gateway_relay.rs b/server/src/gateway_relay.rs index bbf7de37..60b3a250 100644 --- a/server/src/gateway_relay.rs +++ b/server/src/gateway_relay.rs @@ -138,26 +138,33 @@ fn status_to_stored(event: StatusEvent) -> Option { match event { StatusEvent::StageTransition { story_id, + story_name, from_stage, to_stage, - .. } => Some(StoredEvent::StageTransition { story_id, + story_name, from_stage, to_stage, timestamp_ms: now_ms, }), StatusEvent::MergeFailure { - story_id, reason, .. + story_id, + story_name, + reason, } => Some(StoredEvent::MergeFailure { story_id, + story_name, reason, timestamp_ms: now_ms, }), StatusEvent::StoryBlocked { - story_id, reason, .. + story_id, + story_name, + reason, } => Some(StoredEvent::StoryBlocked { story_id, + story_name, reason, timestamp_ms: now_ms, }), diff --git a/server/src/http/events.rs b/server/src/http/events.rs index 2203c41d..4b3aca06 100644 --- a/server/src/http/events.rs +++ b/server/src/http/events.rs @@ -56,11 +56,13 @@ mod tests { let buf = EventBuffer::new(); buf.push(StoredEvent::MergeFailure { story_id: "42_story_x".to_string(), + story_name: String::new(), reason: "conflict".to_string(), timestamp_ms: 1000, }); buf.push(StoredEvent::StoryBlocked { story_id: "43_story_y".to_string(), + story_name: String::new(), reason: "retry limit".to_string(), timestamp_ms: 2000, }); @@ -79,6 +81,7 @@ mod tests { for i in 0..MAX_BUFFER_SIZE + 1 { buf.push(StoredEvent::MergeFailure { story_id: format!("{i}_story_x"), + story_name: String::new(), reason: "x".to_string(), timestamp_ms: i as u64, }); @@ -93,6 +96,7 @@ mod tests { fn stage_transition_timestamp_ms_accessor() { let e = StoredEvent::StageTransition { story_id: "1".to_string(), + story_name: String::new(), from_stage: "2_current".to_string(), to_stage: "3_qa".to_string(), timestamp_ms: 9999, diff --git a/server/src/service/events/buffer.rs b/server/src/service/events/buffer.rs index 20efdbe5..120600fa 100644 --- a/server/src/service/events/buffer.rs +++ b/server/src/service/events/buffer.rs @@ -18,6 +18,8 @@ pub enum StoredEvent { StageTransition { /// Work item ID (e.g. `"42_story_my_feature"`). story_id: String, + /// Human-readable story name (empty string when unset). + story_name: String, /// The stage the item moved FROM (display name, e.g. `"Current"`). from_stage: String, /// The stage the item moved TO (directory key, e.g. `"3_qa"`). @@ -29,6 +31,8 @@ pub enum StoredEvent { MergeFailure { /// Work item ID (e.g. `"42_story_my_feature"`). story_id: String, + /// Human-readable story name (empty string when unset). + story_name: String, /// Human-readable description of the failure. reason: String, /// Unix timestamp in milliseconds when this event was recorded. @@ -38,6 +42,8 @@ pub enum StoredEvent { StoryBlocked { /// Work item ID (e.g. `"42_story_my_feature"`). story_id: String, + /// Human-readable story name (empty string when unset). + story_name: String, /// Human-readable reason the story was blocked. reason: String, /// Unix timestamp in milliseconds when this event was recorded. @@ -104,11 +110,13 @@ mod tests { let buf = EventBuffer::new(); buf.push(StoredEvent::MergeFailure { story_id: "42_story_x".to_string(), + story_name: String::new(), reason: "conflict".to_string(), timestamp_ms: 1000, }); buf.push(StoredEvent::StoryBlocked { story_id: "43_story_y".to_string(), + story_name: String::new(), reason: "retry limit".to_string(), timestamp_ms: 2000, }); @@ -127,6 +135,7 @@ mod tests { for i in 0..MAX_BUFFER_SIZE + 1 { buf.push(StoredEvent::MergeFailure { story_id: format!("{i}_story_x"), + story_name: String::new(), reason: "x".to_string(), timestamp_ms: i as u64, }); @@ -140,17 +149,20 @@ mod tests { let variants = [ StoredEvent::StageTransition { story_id: "1".to_string(), + story_name: String::new(), from_stage: "2_current".to_string(), to_stage: "3_qa".to_string(), timestamp_ms: 100, }, StoredEvent::MergeFailure { story_id: "2".to_string(), + story_name: String::new(), reason: "x".to_string(), timestamp_ms: 200, }, StoredEvent::StoryBlocked { story_id: "3".to_string(), + story_name: String::new(), reason: "y".to_string(), timestamp_ms: 300, }, @@ -166,6 +178,7 @@ mod tests { for ts in [100u64, 200, 300] { buf.push(StoredEvent::MergeFailure { story_id: "x".to_string(), + story_name: String::new(), reason: "r".to_string(), timestamp_ms: ts, }); diff --git a/server/src/service/events/io.rs b/server/src/service/events/io.rs index af8b17c8..e3434c53 100644 --- a/server/src/service/events/io.rs +++ b/server/src/service/events/io.rs @@ -33,6 +33,7 @@ pub fn subscribe_to_watcher(buffer: EventBuffer, mut rx: broadcast::Receiver { buffer.push(StoredEvent::MergeFailure { story_id, + story_name: String::new(), reason, timestamp_ms: now_ms(), }); @@ -49,6 +51,7 @@ pub fn subscribe_to_watcher(buffer: EventBuffer, mut rx: broadcast::Receiver { buffer.push(StoredEvent::StoryBlocked { story_id, + story_name: String::new(), reason, timestamp_ms: now_ms(), }); diff --git a/server/src/service/gateway/polling.rs b/server/src/service/gateway/polling.rs index e731eb9f..ce7e5763 100644 --- a/server/src/service/gateway/polling.rs +++ b/server/src/service/gateway/polling.rs @@ -20,25 +20,33 @@ pub fn format_gateway_event(project_name: &str, event: &StoredEvent) -> (String, match event { StoredEvent::StageTransition { story_id, + story_name, from_stage, to_stage, .. } => { 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, "", &from_typed, &to_typed); + let (plain, html) = + format_stage_notification(story_id, story_name, &from_typed, &to_typed); (format!("{prefix}{plain}"), format!("{prefix}{html}")) } StoredEvent::MergeFailure { - story_id, reason, .. + story_id, + story_name, + reason, + .. } => { - let (plain, html) = format_error_notification(story_id, "", reason); + let (plain, html) = format_error_notification(story_id, story_name, reason); (format!("{prefix}{plain}"), format!("{prefix}{html}")) } StoredEvent::StoryBlocked { - story_id, reason, .. + story_id, + story_name, + reason, + .. } => { - let (plain, html) = format_blocked_notification(story_id, "", reason); + let (plain, html) = format_blocked_notification(story_id, story_name, reason); (format!("{prefix}{plain}"), format!("{prefix}{html}")) } } @@ -54,6 +62,7 @@ mod tests { fn stage_transition_prefixes_project_name() { let event = StoredEvent::StageTransition { story_id: "42_story_my_feature".to_string(), + story_name: String::new(), from_stage: "coding".to_string(), to_stage: "qa".to_string(), timestamp_ms: 1000, @@ -69,6 +78,7 @@ mod tests { fn merge_failure_prefixes_project_name() { let event = StoredEvent::MergeFailure { story_id: "42_story_my_feature".to_string(), + story_name: String::new(), reason: "merge conflict".to_string(), timestamp_ms: 1000, }; @@ -81,6 +91,7 @@ mod tests { fn story_blocked_prefixes_project_name() { let event = StoredEvent::StoryBlocked { story_id: "43_story_bar".to_string(), + story_name: String::new(), reason: "retry limit exceeded".to_string(), timestamp_ms: 2000, }; @@ -88,4 +99,93 @@ mod tests { assert!(plain.starts_with("[huskies] ")); assert!(plain.contains("BLOCKED")); } + + #[test] + fn stage_transition_with_story_name_includes_title() { + let event = StoredEvent::StageTransition { + story_id: "10_story_feat".to_string(), + story_name: "My New Feature".to_string(), + from_stage: "backlog".to_string(), + to_stage: "coding".to_string(), + timestamp_ms: 100, + }; + let (plain, html) = format_gateway_event("huskies", &event); + assert!(plain.starts_with("[huskies] ")); + assert!( + plain.contains("My New Feature"), + "plain must include story title; got: {plain}" + ); + assert!( + html.contains("My New Feature"), + "html must include story title; got: {html}" + ); + assert!(plain.contains("#10")); + assert!(plain.contains("Backlog")); + assert!(plain.contains("Current")); + } + + #[test] + fn merge_failure_with_story_name_includes_title() { + let event = StoredEvent::MergeFailure { + story_id: "7_story_bar".to_string(), + story_name: "Bar Feature".to_string(), + reason: "conflict in lib.rs".to_string(), + timestamp_ms: 200, + }; + let (plain, html) = format_gateway_event("robot-studio", &event); + assert!(plain.starts_with("[robot-studio] ")); + assert!( + plain.contains("Bar Feature"), + "plain must include story title; got: {plain}" + ); + assert!( + html.contains("Bar Feature"), + "html must include story title; got: {html}" + ); + assert!(plain.contains("#7")); + assert!(plain.contains("conflict in lib.rs")); + } + + #[test] + fn story_blocked_with_story_name_includes_title() { + let event = StoredEvent::StoryBlocked { + story_id: "3_story_baz".to_string(), + story_name: "Baz Story".to_string(), + reason: "retry limit exceeded".to_string(), + timestamp_ms: 300, + }; + let (plain, html) = format_gateway_event("huskies", &event); + assert!(plain.starts_with("[huskies] ")); + assert!( + plain.contains("Baz Story"), + "plain must include story title; got: {plain}" + ); + assert!( + html.contains("Baz Story"), + "html must include story title; got: {html}" + ); + assert!(plain.contains("#3")); + assert!(plain.contains("BLOCKED")); + } + + #[test] + fn unnamed_story_falls_back_gracefully() { + let event = StoredEvent::StageTransition { + story_id: "5_story_unnamed".to_string(), + story_name: String::new(), + from_stage: "backlog".to_string(), + to_stage: "qa".to_string(), + timestamp_ms: 50, + }; + let (plain, _html) = format_gateway_event("proj", &event); + assert!(plain.starts_with("[proj] ")); + assert!(plain.contains("#5")); + assert!(plain.contains("Backlog")); + assert!(plain.contains("QA")); + // Must NOT contain stray empty-name artefacts between # and — + assert!( + !plain.contains(" "), + "should not have double spaces from empty name; got: {plain}" + ); + } }