huskies: merge 599_story_cross_project_status_notifications_in_chat

This commit is contained in:
dave
2026-04-23 12:05:27 +00:00
parent 4b765bbc39
commit 3521649cbf
8 changed files with 1080 additions and 3 deletions
+559
View File
@@ -2223,6 +2223,135 @@ fn spawn_gateway_bot(
)
}
// ── Cross-project notification poller ─────────────────────────────────
/// Spawn a background task that polls `GET /api/events?since={ts_ms}` on every
/// registered project server and forwards new events to the gateway's chat rooms.
///
/// Each event is prefixed with `[project-name]` so users can distinguish which
/// project emitted the notification. Unreachable projects produce a log warning
/// and are skipped; the poller continues with all other projects.
///
/// This is only called when the gateway bot is enabled (`bot.toml enabled = true`).
///
/// # Arguments
///
/// * `transport` — the gateway-level [`ChatTransport`] used to send messages.
/// * `room_ids` — the list of room IDs to send notifications to.
/// * `project_urls` — the map of project name → base URL (e.g. `http://host:3001`).
/// * `poll_interval_secs` — how often to poll each project (default 5).
pub fn spawn_gateway_notification_poller(
transport: Arc<dyn crate::chat::ChatTransport>,
room_ids: Vec<String>,
project_urls: BTreeMap<String, String>,
poll_interval_secs: u64,
) {
tokio::spawn(async move {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new());
let interval = std::time::Duration::from_secs(poll_interval_secs.max(1));
// Track the last seen timestamp per project so we only receive new events.
let mut last_ts: HashMap<String, u64> = project_urls
.keys()
.map(|name| (name.clone(), 0u64))
.collect();
loop {
for (project_name, base_url) in &project_urls {
let since = last_ts.get(project_name).copied().unwrap_or(0);
let url = format!("{base_url}/api/events?since={since}");
let response = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => {
crate::slog!(
"[gateway-poller] {project_name}: unreachable ({e}); skipping"
);
continue;
}
};
let events: Vec<crate::http::events::StoredEvent> = match response.json().await {
Ok(v) => v,
Err(e) => {
crate::slog!(
"[gateway-poller] {project_name}: failed to parse events: {e}"
);
continue;
}
};
for event in &events {
// Advance the cursor.
let ts = event.timestamp_ms();
if ts > *last_ts.get(project_name).unwrap_or(&0) {
last_ts.insert(project_name.clone(), ts);
}
let (plain, html) = format_gateway_event(project_name, event);
for room_id in &room_ids {
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
crate::slog!(
"[gateway-poller] Failed to send notification to {room_id}: {e}"
);
}
}
}
}
tokio::time::sleep(interval).await;
}
});
}
/// Format a [`crate::http::events::StoredEvent`] from a project into a gateway notification.
///
/// Prefixes the message with `[project-name]` so users can distinguish which
/// project emitted the event. Story names are not available at the gateway
/// level, so the item ID is used as a fallback (the formatting functions
/// extract the numeric story number from it automatically).
fn format_gateway_event(
project_name: &str,
event: &crate::http::events::StoredEvent,
) -> (String, String) {
use crate::chat::transport::matrix::notifications::{
format_blocked_notification, format_error_notification, format_stage_notification,
stage_display_name,
};
use crate::http::events::StoredEvent;
let prefix = format!("[{project_name}] ");
match event {
StoredEvent::StageTransition {
story_id,
from_stage,
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);
(format!("{prefix}{plain}"), format!("{prefix}{html}"))
}
StoredEvent::MergeFailure {
story_id, reason, ..
} => {
let (plain, html) = format_error_notification(story_id, None, reason);
(format!("{prefix}{plain}"), format!("{prefix}{html}"))
}
StoredEvent::StoryBlocked {
story_id, reason, ..
} => {
let (plain, html) = format_blocked_notification(story_id, None, reason);
(format!("{prefix}{plain}"), format!("{prefix}{html}"))
}
}
}
// ── Tests ────────────────────────────────────────────────────────────
#[cfg(test)]
@@ -2673,6 +2802,233 @@ enabled = false
assert_eq!(resp.0.status(), StatusCode::NOT_FOUND);
}
/// AC5: When one project server is unreachable the poller continues delivering
/// events from the remaining reachable projects without failing.
#[tokio::test]
async fn gateway_notification_poller_continues_when_one_project_unreachable() {
use crate::chat::{ChatTransport, MessageId};
use crate::http::events::StoredEvent;
use async_trait::async_trait;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
type CallLog = Arc<std::sync::Mutex<Vec<String>>>;
struct MockTransport {
calls: CallLog,
}
#[async_trait]
impl ChatTransport for MockTransport {
async fn send_message(
&self,
_room_id: &str,
plain: &str,
_html: &str,
) -> Result<MessageId, String> {
self.calls.lock().unwrap().push(plain.to_string());
Ok("id".to_string())
}
async fn edit_message(
&self,
_room_id: &str,
_id: &str,
_plain: &str,
_html: &str,
) -> Result<(), String> {
Ok(())
}
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
Ok(())
}
}
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
let transport = Arc::new(MockTransport {
calls: Arc::clone(&calls),
});
// Start a reachable mock project server that returns one event.
let event = vec![StoredEvent::StoryBlocked {
story_id: "10_story_ok".to_string(),
reason: "retry limit".to_string(),
timestamp_ms: 500,
}];
let event_body = serde_json::to_vec(&event).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let good_port = listener.local_addr().unwrap().port();
let good_url = format!("http://127.0.0.1:{good_port}");
tokio::spawn(async move {
for _ in 0..4 {
if let Ok((mut stream, _)) = listener.accept().await {
let mut buf = vec![0u8; 4096];
let _ = stream.read(&mut buf).await;
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
event_body.len()
);
let _ = stream.write_all(header.as_bytes()).await;
let _ = stream.write_all(&event_body).await;
}
}
});
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
// An unreachable URL (port 1 cannot be bound).
let bad_url = "http://127.0.0.1:1".to_string();
let mut project_urls = BTreeMap::new();
project_urls.insert("good-project".to_string(), good_url);
project_urls.insert("unreachable-project".to_string(), bad_url);
spawn_gateway_notification_poller(
transport as Arc<dyn crate::chat::ChatTransport>,
vec!["!room:example.org".to_string()],
project_urls,
1,
);
// Wait for at least one poll cycle.
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
// Events from the reachable project must still arrive.
let messages = calls.lock().unwrap();
assert!(
!messages.is_empty(),
"Expected notifications from the reachable project; got none"
);
let has_good = messages
.iter()
.any(|m| m.contains("[good-project]") && m.contains("10_story_ok"));
assert!(
has_good,
"Expected a notification from [good-project]; got: {messages:?}"
);
// Unreachable project must not produce any notifications.
let has_bad = messages.iter().any(|m| m.contains("[unreachable-project]"));
assert!(
!has_bad,
"Unreachable project must not produce notifications; got: {messages:?}"
);
}
/// AC4: When both a per-project bot and the gateway aggregated stream are
/// configured, events go to each room exactly once.
///
/// The gateway notification poller only sends to the gateway room IDs it is
/// given — it never forwards events to per-project rooms. Conversely, the
/// per-project notification listener subscribes to watcher broadcasts which
/// are completely separate from the HTTP-polled event buffer. This test
/// verifies the poller respects its room list (no cross-room leakage).
#[tokio::test]
async fn gateway_notification_poller_sends_only_to_configured_gateway_rooms() {
use crate::chat::{ChatTransport, MessageId};
use crate::http::events::StoredEvent;
use async_trait::async_trait;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
type RoomLog = Arc<std::sync::Mutex<Vec<String>>>;
struct RoomCapture {
rooms: RoomLog,
}
#[async_trait]
impl ChatTransport for RoomCapture {
async fn send_message(
&self,
room_id: &str,
_plain: &str,
_html: &str,
) -> Result<MessageId, String> {
self.rooms.lock().unwrap().push(room_id.to_string());
Ok("id".to_string())
}
async fn edit_message(
&self,
_room_id: &str,
_id: &str,
_plain: &str,
_html: &str,
) -> Result<(), String> {
Ok(())
}
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
Ok(())
}
}
let rooms: RoomLog = Arc::new(std::sync::Mutex::new(Vec::new()));
let transport = Arc::new(RoomCapture {
rooms: Arc::clone(&rooms),
});
// Serve one event from a mock project server.
let event = vec![StoredEvent::MergeFailure {
story_id: "5_story_x".to_string(),
reason: "conflict".to_string(),
timestamp_ms: 300,
}];
let event_body = serde_json::to_vec(&event).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let url = format!("http://127.0.0.1:{port}");
tokio::spawn(async move {
for _ in 0..4 {
if let Ok((mut stream, _)) = listener.accept().await {
let mut buf = vec![0u8; 4096];
let _ = stream.read(&mut buf).await;
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
event_body.len()
);
let _ = stream.write_all(header.as_bytes()).await;
let _ = stream.write_all(&event_body).await;
}
}
});
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
const GATEWAY_ROOM: &str = "!gateway:example.org";
const PER_PROJECT_ROOM: &str = "!project:example.org";
let mut project_urls = BTreeMap::new();
project_urls.insert("myproj".to_string(), url);
// Poller is given only the gateway room — per-project room must never receive.
spawn_gateway_notification_poller(
transport as Arc<dyn crate::chat::ChatTransport>,
vec![GATEWAY_ROOM.to_string()],
project_urls,
1,
);
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
let room_calls = rooms.lock().unwrap();
// Every notification must go to the gateway room only.
assert!(
!room_calls.is_empty(),
"Expected at least one notification; got none"
);
for room in room_calls.iter() {
assert_eq!(
room, GATEWAY_ROOM,
"Notification must only go to the gateway room, not {room}"
);
}
// The per-project room must never have been contacted.
assert!(
!room_calls.iter().any(|r| r == PER_PROJECT_ROOM),
"Per-project room must not receive gateway aggregated notifications"
);
}
/// Build the full gateway route tree and verify it does not panic.
///
/// Poem panics at construction time when duplicate routes are registered.
@@ -3136,4 +3492,207 @@ enabled = false
"unreachable project must have error field: {broken}"
);
}
// ── format_gateway_event unit tests ─────────────────────────────────
#[test]
fn format_gateway_event_stage_transition_prefixes_project_name() {
use crate::http::events::StoredEvent;
let event = StoredEvent::StageTransition {
story_id: "42_story_my_feature".to_string(),
from_stage: "2_current".to_string(),
to_stage: "3_qa".to_string(),
timestamp_ms: 1000,
};
let (plain, html) = format_gateway_event("huskies", &event);
assert!(plain.starts_with("[huskies] "), "plain: {plain}");
assert!(html.starts_with("[huskies] "), "html: {html}");
assert!(plain.contains("Current"), "plain: {plain}");
assert!(plain.contains("QA"), "plain: {plain}");
}
#[test]
fn format_gateway_event_merge_failure_prefixes_project_name() {
use crate::http::events::StoredEvent;
let event = StoredEvent::MergeFailure {
story_id: "42_story_my_feature".to_string(),
reason: "merge conflict".to_string(),
timestamp_ms: 1000,
};
let (plain, _html) = format_gateway_event("robot-studio", &event);
assert!(plain.starts_with("[robot-studio] "), "plain: {plain}");
assert!(plain.contains("merge conflict"), "plain: {plain}");
}
#[test]
fn format_gateway_event_story_blocked_prefixes_project_name() {
use crate::http::events::StoredEvent;
let event = StoredEvent::StoryBlocked {
story_id: "43_story_bar".to_string(),
reason: "retry limit exceeded".to_string(),
timestamp_ms: 2000,
};
let (plain, _html) = format_gateway_event("huskies", &event);
assert!(plain.starts_with("[huskies] "), "plain: {plain}");
assert!(plain.contains("BLOCKED"), "plain: {plain}");
}
/// AC7 integration test: two mock HTTP servers, trigger events, assert
/// aggregated stream gets both with project tags.
#[tokio::test]
async fn gateway_notification_poller_delivers_events_from_two_projects_with_project_tags() {
use crate::chat::{ChatTransport, MessageId};
use crate::http::events::StoredEvent;
use async_trait::async_trait;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// ── MockTransport ──────────────────────────────────────────────────
type CallLog = Arc<std::sync::Mutex<Vec<(String, String, String)>>>;
struct MockTransport {
calls: CallLog,
}
#[async_trait]
impl ChatTransport for MockTransport {
async fn send_message(
&self,
room_id: &str,
plain: &str,
html: &str,
) -> Result<MessageId, String> {
self.calls.lock().unwrap().push((
room_id.to_string(),
plain.to_string(),
html.to_string(),
));
Ok("mock-id".to_string())
}
async fn edit_message(
&self,
_room_id: &str,
_id: &str,
_plain: &str,
_html: &str,
) -> Result<(), String> {
Ok(())
}
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
Ok(())
}
}
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
let transport = Arc::new(MockTransport {
calls: Arc::clone(&calls),
});
// ── Mock HTTP server for project "alpha" ───────────────────────────
let alpha_events = vec![StoredEvent::StageTransition {
story_id: "1_story_alpha".to_string(),
from_stage: "2_current".to_string(),
to_stage: "3_qa".to_string(),
timestamp_ms: 100,
}];
let alpha_body = serde_json::to_vec(&alpha_events).unwrap();
let alpha_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let alpha_port = alpha_listener.local_addr().unwrap().port();
let alpha_url = format!("http://127.0.0.1:{alpha_port}");
tokio::spawn(async move {
// Handle two requests (poller might poll more than once).
for _ in 0..4 {
if let Ok((mut stream, _)) = alpha_listener.accept().await {
let mut buf = vec![0u8; 4096];
let _ = stream.read(&mut buf).await;
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
alpha_body.len()
);
let _ = stream.write_all(header.as_bytes()).await;
let _ = stream.write_all(&alpha_body).await;
}
}
});
// ── Mock HTTP server for project "beta" ────────────────────────────
let beta_events = vec![StoredEvent::MergeFailure {
story_id: "2_story_beta".to_string(),
reason: "merge conflict in lib.rs".to_string(),
timestamp_ms: 200,
}];
let beta_body = serde_json::to_vec(&beta_events).unwrap();
let beta_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let beta_port = beta_listener.local_addr().unwrap().port();
let beta_url = format!("http://127.0.0.1:{beta_port}");
tokio::spawn(async move {
for _ in 0..4 {
if let Ok((mut stream, _)) = beta_listener.accept().await {
let mut buf = vec![0u8; 4096];
let _ = stream.read(&mut buf).await;
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
beta_body.len()
);
let _ = stream.write_all(header.as_bytes()).await;
let _ = stream.write_all(&beta_body).await;
}
}
});
// Give mock servers a moment to bind.
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
// ── Run the poller ─────────────────────────────────────────────────
let mut project_urls = BTreeMap::new();
project_urls.insert("alpha".to_string(), alpha_url);
project_urls.insert("beta".to_string(), beta_url);
spawn_gateway_notification_poller(
transport as Arc<dyn crate::chat::ChatTransport>,
vec!["!room:example.org".to_string()],
project_urls,
1, // poll every 1 second
);
// Wait long enough for at least one poll cycle.
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
// ── Assert both events appear with correct [project-name] prefix ───
let calls = calls.lock().unwrap();
assert!(
!calls.is_empty(),
"Expected at least one notification; got none"
);
let plains: Vec<&str> = calls.iter().map(|(_, p, _)| p.as_str()).collect();
let alpha_notification = plains
.iter()
.any(|p| p.contains("[alpha]") && p.contains("1"));
let beta_notification = plains
.iter()
.any(|p| p.contains("[beta]") && p.contains("merge conflict"));
assert!(
alpha_notification,
"Expected a notification from [alpha] containing story ID '1'; got: {plains:?}"
);
assert!(
beta_notification,
"Expected a notification from [beta] containing 'merge conflict'; got: {plains:?}"
);
// All notifications must go to the configured room.
for (room_id, _, _) in calls.iter() {
assert_eq!(
room_id, "!room:example.org",
"All notifications must go to the gateway room"
);
}
}
}