huskies: merge 1035

This commit is contained in:
dave
2026-05-14 13:11:26 +00:00
parent c353c0a6be
commit f1c96595de
6 changed files with 141 additions and 8 deletions
+6
View File
@@ -144,6 +144,7 @@ async fn gateway_notification_poller_continues_when_one_project_unreachable() {
let event = vec![StoredEvent::StoryBlocked { let event = vec![StoredEvent::StoryBlocked {
story_id: "10_story_ok".to_string(), story_id: "10_story_ok".to_string(),
story_name: String::new(),
reason: "retry limit".to_string(), reason: "retry limit".to_string(),
timestamp_ms: 500, timestamp_ms: 500,
}]; }];
@@ -249,6 +250,7 @@ async fn gateway_notification_poller_sends_only_to_configured_gateway_rooms() {
let event = vec![StoredEvent::MergeFailure { let event = vec![StoredEvent::MergeFailure {
story_id: "5_story_x".to_string(), story_id: "5_story_x".to_string(),
story_name: String::new(),
reason: "conflict".to_string(), reason: "conflict".to_string(),
timestamp_ms: 300, 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 { let alpha_events = vec![StoredEvent::StageTransition {
story_id: "1_story_alpha".to_string(), story_id: "1_story_alpha".to_string(),
story_name: String::new(),
from_stage: "2_current".to_string(), from_stage: "2_current".to_string(),
to_stage: "3_qa".to_string(), to_stage: "3_qa".to_string(),
timestamp_ms: 100, 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 { let beta_events = vec![StoredEvent::MergeFailure {
story_id: "2_story_beta".to_string(), story_id: "2_story_beta".to_string(),
story_name: String::new(),
reason: "merge conflict in lib.rs".to_string(), reason: "merge conflict in lib.rs".to_string(),
timestamp_ms: 200, timestamp_ms: 200,
}]; }];
@@ -765,6 +769,7 @@ async fn broadcaster_forwarder_forwards_events_with_project_tag() {
project: "my-project".to_string(), project: "my-project".to_string(),
event: StoredEvent::StageTransition { event: StoredEvent::StageTransition {
story_id: "7_story_x".to_string(), story_id: "7_story_x".to_string(),
story_name: String::new(),
from_stage: "2_current".to_string(), from_stage: "2_current".to_string(),
to_stage: "3_qa".to_string(), to_stage: "3_qa".to_string(),
timestamp_ms: 100, timestamp_ms: 100,
@@ -841,6 +846,7 @@ async fn broadcaster_forwarder_resubscribes_on_lag() {
project: "p".to_string(), project: "p".to_string(),
event: StoredEvent::StageTransition { event: StoredEvent::StageTransition {
story_id: format!("{n}_story"), story_id: format!("{n}_story"),
story_name: String::new(),
from_stage: "2_current".to_string(), from_stage: "2_current".to_string(),
to_stage: "3_qa".to_string(), to_stage: "3_qa".to_string(),
timestamp_ms: n, timestamp_ms: n,
+10 -3
View File
@@ -138,26 +138,33 @@ fn status_to_stored(event: StatusEvent) -> Option<StoredEvent> {
match event { match event {
StatusEvent::StageTransition { StatusEvent::StageTransition {
story_id, story_id,
story_name,
from_stage, from_stage,
to_stage, to_stage,
..
} => Some(StoredEvent::StageTransition { } => Some(StoredEvent::StageTransition {
story_id, story_id,
story_name,
from_stage, from_stage,
to_stage, to_stage,
timestamp_ms: now_ms, timestamp_ms: now_ms,
}), }),
StatusEvent::MergeFailure { StatusEvent::MergeFailure {
story_id, reason, .. story_id,
story_name,
reason,
} => Some(StoredEvent::MergeFailure { } => Some(StoredEvent::MergeFailure {
story_id, story_id,
story_name,
reason, reason,
timestamp_ms: now_ms, timestamp_ms: now_ms,
}), }),
StatusEvent::StoryBlocked { StatusEvent::StoryBlocked {
story_id, reason, .. story_id,
story_name,
reason,
} => Some(StoredEvent::StoryBlocked { } => Some(StoredEvent::StoryBlocked {
story_id, story_id,
story_name,
reason, reason,
timestamp_ms: now_ms, timestamp_ms: now_ms,
}), }),
+4
View File
@@ -56,11 +56,13 @@ mod tests {
let buf = EventBuffer::new(); let buf = EventBuffer::new();
buf.push(StoredEvent::MergeFailure { buf.push(StoredEvent::MergeFailure {
story_id: "42_story_x".to_string(), story_id: "42_story_x".to_string(),
story_name: String::new(),
reason: "conflict".to_string(), reason: "conflict".to_string(),
timestamp_ms: 1000, timestamp_ms: 1000,
}); });
buf.push(StoredEvent::StoryBlocked { buf.push(StoredEvent::StoryBlocked {
story_id: "43_story_y".to_string(), story_id: "43_story_y".to_string(),
story_name: String::new(),
reason: "retry limit".to_string(), reason: "retry limit".to_string(),
timestamp_ms: 2000, timestamp_ms: 2000,
}); });
@@ -79,6 +81,7 @@ mod tests {
for i in 0..MAX_BUFFER_SIZE + 1 { for i in 0..MAX_BUFFER_SIZE + 1 {
buf.push(StoredEvent::MergeFailure { buf.push(StoredEvent::MergeFailure {
story_id: format!("{i}_story_x"), story_id: format!("{i}_story_x"),
story_name: String::new(),
reason: "x".to_string(), reason: "x".to_string(),
timestamp_ms: i as u64, timestamp_ms: i as u64,
}); });
@@ -93,6 +96,7 @@ mod tests {
fn stage_transition_timestamp_ms_accessor() { fn stage_transition_timestamp_ms_accessor() {
let e = StoredEvent::StageTransition { let e = StoredEvent::StageTransition {
story_id: "1".to_string(), story_id: "1".to_string(),
story_name: String::new(),
from_stage: "2_current".to_string(), from_stage: "2_current".to_string(),
to_stage: "3_qa".to_string(), to_stage: "3_qa".to_string(),
timestamp_ms: 9999, timestamp_ms: 9999,
+13
View File
@@ -18,6 +18,8 @@ pub enum StoredEvent {
StageTransition { StageTransition {
/// Work item ID (e.g. `"42_story_my_feature"`). /// Work item ID (e.g. `"42_story_my_feature"`).
story_id: String, 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"`). /// The stage the item moved FROM (display name, e.g. `"Current"`).
from_stage: String, from_stage: String,
/// The stage the item moved TO (directory key, e.g. `"3_qa"`). /// The stage the item moved TO (directory key, e.g. `"3_qa"`).
@@ -29,6 +31,8 @@ pub enum StoredEvent {
MergeFailure { MergeFailure {
/// Work item ID (e.g. `"42_story_my_feature"`). /// Work item ID (e.g. `"42_story_my_feature"`).
story_id: String, story_id: String,
/// Human-readable story name (empty string when unset).
story_name: String,
/// Human-readable description of the failure. /// Human-readable description of the failure.
reason: String, reason: String,
/// Unix timestamp in milliseconds when this event was recorded. /// Unix timestamp in milliseconds when this event was recorded.
@@ -38,6 +42,8 @@ pub enum StoredEvent {
StoryBlocked { StoryBlocked {
/// Work item ID (e.g. `"42_story_my_feature"`). /// Work item ID (e.g. `"42_story_my_feature"`).
story_id: String, story_id: String,
/// Human-readable story name (empty string when unset).
story_name: String,
/// Human-readable reason the story was blocked. /// Human-readable reason the story was blocked.
reason: String, reason: String,
/// Unix timestamp in milliseconds when this event was recorded. /// Unix timestamp in milliseconds when this event was recorded.
@@ -104,11 +110,13 @@ mod tests {
let buf = EventBuffer::new(); let buf = EventBuffer::new();
buf.push(StoredEvent::MergeFailure { buf.push(StoredEvent::MergeFailure {
story_id: "42_story_x".to_string(), story_id: "42_story_x".to_string(),
story_name: String::new(),
reason: "conflict".to_string(), reason: "conflict".to_string(),
timestamp_ms: 1000, timestamp_ms: 1000,
}); });
buf.push(StoredEvent::StoryBlocked { buf.push(StoredEvent::StoryBlocked {
story_id: "43_story_y".to_string(), story_id: "43_story_y".to_string(),
story_name: String::new(),
reason: "retry limit".to_string(), reason: "retry limit".to_string(),
timestamp_ms: 2000, timestamp_ms: 2000,
}); });
@@ -127,6 +135,7 @@ mod tests {
for i in 0..MAX_BUFFER_SIZE + 1 { for i in 0..MAX_BUFFER_SIZE + 1 {
buf.push(StoredEvent::MergeFailure { buf.push(StoredEvent::MergeFailure {
story_id: format!("{i}_story_x"), story_id: format!("{i}_story_x"),
story_name: String::new(),
reason: "x".to_string(), reason: "x".to_string(),
timestamp_ms: i as u64, timestamp_ms: i as u64,
}); });
@@ -140,17 +149,20 @@ mod tests {
let variants = [ let variants = [
StoredEvent::StageTransition { StoredEvent::StageTransition {
story_id: "1".to_string(), story_id: "1".to_string(),
story_name: String::new(),
from_stage: "2_current".to_string(), from_stage: "2_current".to_string(),
to_stage: "3_qa".to_string(), to_stage: "3_qa".to_string(),
timestamp_ms: 100, timestamp_ms: 100,
}, },
StoredEvent::MergeFailure { StoredEvent::MergeFailure {
story_id: "2".to_string(), story_id: "2".to_string(),
story_name: String::new(),
reason: "x".to_string(), reason: "x".to_string(),
timestamp_ms: 200, timestamp_ms: 200,
}, },
StoredEvent::StoryBlocked { StoredEvent::StoryBlocked {
story_id: "3".to_string(), story_id: "3".to_string(),
story_name: String::new(),
reason: "y".to_string(), reason: "y".to_string(),
timestamp_ms: 300, timestamp_ms: 300,
}, },
@@ -166,6 +178,7 @@ mod tests {
for ts in [100u64, 200, 300] { for ts in [100u64, 200, 300] {
buf.push(StoredEvent::MergeFailure { buf.push(StoredEvent::MergeFailure {
story_id: "x".to_string(), story_id: "x".to_string(),
story_name: String::new(),
reason: "r".to_string(), reason: "r".to_string(),
timestamp_ms: ts, timestamp_ms: ts,
}); });
+3
View File
@@ -33,6 +33,7 @@ pub fn subscribe_to_watcher(buffer: EventBuffer, mut rx: broadcast::Receiver<Wat
if let Some(from) = from_stage { if let Some(from) = from_stage {
buffer.push(StoredEvent::StageTransition { buffer.push(StoredEvent::StageTransition {
story_id: item_id, story_id: item_id,
story_name: String::new(),
from_stage: from, from_stage: from,
to_stage: stage, to_stage: stage,
timestamp_ms: now_ms(), timestamp_ms: now_ms(),
@@ -42,6 +43,7 @@ pub fn subscribe_to_watcher(buffer: EventBuffer, mut rx: broadcast::Receiver<Wat
Ok(WatcherEvent::MergeFailure { story_id, reason }) => { Ok(WatcherEvent::MergeFailure { story_id, reason }) => {
buffer.push(StoredEvent::MergeFailure { buffer.push(StoredEvent::MergeFailure {
story_id, story_id,
story_name: String::new(),
reason, reason,
timestamp_ms: now_ms(), timestamp_ms: now_ms(),
}); });
@@ -49,6 +51,7 @@ pub fn subscribe_to_watcher(buffer: EventBuffer, mut rx: broadcast::Receiver<Wat
Ok(WatcherEvent::StoryBlocked { story_id, reason }) => { Ok(WatcherEvent::StoryBlocked { story_id, reason }) => {
buffer.push(StoredEvent::StoryBlocked { buffer.push(StoredEvent::StoryBlocked {
story_id, story_id,
story_name: String::new(),
reason, reason,
timestamp_ms: now_ms(), timestamp_ms: now_ms(),
}); });
+105 -5
View File
@@ -20,25 +20,33 @@ pub fn format_gateway_event(project_name: &str, event: &StoredEvent) -> (String,
match event { match event {
StoredEvent::StageTransition { StoredEvent::StageTransition {
story_id, story_id,
story_name,
from_stage, from_stage,
to_stage, to_stage,
.. ..
} => { } => {
let from_typed = Stage::from_dir(from_stage).unwrap_or(Stage::Upcoming); 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 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}")) (format!("{prefix}{plain}"), format!("{prefix}{html}"))
} }
StoredEvent::MergeFailure { 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}")) (format!("{prefix}{plain}"), format!("{prefix}{html}"))
} }
StoredEvent::StoryBlocked { 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}")) (format!("{prefix}{plain}"), format!("{prefix}{html}"))
} }
} }
@@ -54,6 +62,7 @@ mod tests {
fn stage_transition_prefixes_project_name() { fn stage_transition_prefixes_project_name() {
let event = StoredEvent::StageTransition { let event = StoredEvent::StageTransition {
story_id: "42_story_my_feature".to_string(), story_id: "42_story_my_feature".to_string(),
story_name: String::new(),
from_stage: "coding".to_string(), from_stage: "coding".to_string(),
to_stage: "qa".to_string(), to_stage: "qa".to_string(),
timestamp_ms: 1000, timestamp_ms: 1000,
@@ -69,6 +78,7 @@ mod tests {
fn merge_failure_prefixes_project_name() { fn merge_failure_prefixes_project_name() {
let event = StoredEvent::MergeFailure { let event = StoredEvent::MergeFailure {
story_id: "42_story_my_feature".to_string(), story_id: "42_story_my_feature".to_string(),
story_name: String::new(),
reason: "merge conflict".to_string(), reason: "merge conflict".to_string(),
timestamp_ms: 1000, timestamp_ms: 1000,
}; };
@@ -81,6 +91,7 @@ mod tests {
fn story_blocked_prefixes_project_name() { fn story_blocked_prefixes_project_name() {
let event = StoredEvent::StoryBlocked { let event = StoredEvent::StoryBlocked {
story_id: "43_story_bar".to_string(), story_id: "43_story_bar".to_string(),
story_name: String::new(),
reason: "retry limit exceeded".to_string(), reason: "retry limit exceeded".to_string(),
timestamp_ms: 2000, timestamp_ms: 2000,
}; };
@@ -88,4 +99,93 @@ mod tests {
assert!(plain.starts_with("[huskies] ")); assert!(plain.starts_with("[huskies] "));
assert!(plain.contains("BLOCKED")); 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}"
);
}
} }