From b9d130bf6459ed268e8d47699744a7e4de0fe69c Mon Sep 17 00:00:00 2001 From: Huskies Agent Date: Fri, 17 Jul 2026 17:23:03 +0000 Subject: [PATCH] huskies: merge 1193 story `overview` chat command: active work across all connected sleds --- .../matrix/bot/messages/on_room_message.rs | 118 ++++++++++++++- server/src/gateway/mod.rs | 5 +- server/src/service/gateway/aggregation.rs | 134 ++++++++++++++++++ server/src/service/gateway/mod.rs | 5 +- 4 files changed, 257 insertions(+), 5 deletions(-) diff --git a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs index dafd401a..a1ba2424 100644 --- a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs +++ b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs @@ -112,6 +112,56 @@ async fn eval_gateway_status_command( format!("Unknown project or story number `{arg}`. Available projects: {available}") } +/// Evaluate a gateway-local `overview` command (story 1193). +/// +/// - Non-empty `args`: unchanged legacy behavior — proxies `overview ` +/// to the active project's own per-story overview handler. +/// - Empty `args`: fetches active pipeline items from every registered +/// project and renders a compact cross-sled overview of In Progress / QA / +/// Merge work. Projects with no configured URL are surfaced as +/// unreachable rather than silently dropped, matching AC 2. +async fn eval_gateway_overview_command( + args: &str, + active_project: &tokio::sync::RwLock, + store: &tokio::sync::RwLock< + std::collections::BTreeMap, + >, +) -> String { + if !args.is_empty() { + let name = active_project.read().await.clone(); + let url = store.read().await.get(&name).and_then(|e| e.url.clone()); + return match url { + Some(url) => BotContext::run_proxy_bot_command(&url, "overview", args).await, + None => "No active project selected or project URL not configured.".to_string(), + }; + } + + let entries: Vec<(String, Option)> = store + .read() + .await + .iter() + .map(|(name, entry)| (name.clone(), entry.url.clone())) + .collect(); + + let with_url: std::collections::BTreeMap = entries + .iter() + .filter_map(|(name, url)| url.clone().map(|u| (name.clone(), u))) + .collect(); + + let client = reqwest::Client::new(); + let mut items_by_project = + crate::gateway::fetch_all_project_pipeline_items(&with_url, &client).await; + for (name, url) in &entries { + if url.is_none() { + items_by_project + .entry(name.clone()) + .or_insert_with(|| serde_json::json!({ "error": "no URL configured" })); + } + } + + crate::gateway::format_overview_compact(&items_by_project) +} + /// Evaluate a `switch ` command against the live project store. /// /// Reads valid project names from the store at call time so newly added @@ -332,6 +382,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( "upgrade", "health", "projects", + "overview", ]; let stripped = crate::chat::util::strip_bot_mention( @@ -371,6 +422,30 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( return; } + // `overview` (bare) is special-cased ahead of the generic per-command + // proxy below so it can render a cross-sled overview locally instead + // of proxying to a single project (story 1193). `overview ` + // still proxies, preserving the existing per-story overview handler. + if cmd == "overview" { + slog!("[matrix-bot] Handling 'overview {args}' from {sender}"); + let response = match (&ctx.gateway_active_project, &ctx.gateway_projects_store) { + (Some(active_project), Some(store)) => { + eval_gateway_overview_command(&args, active_project, store).await + } + _ => "Gateway projects store unavailable.".to_string(), + }; + let html = markdown_to_html(&response); + if let Ok(msg_id) = ctx + .transport + .send_message(&room_id_str, &response, &html) + .await + && let Ok(event_id) = msg_id.parse() + { + ctx.bot_sent_event_ids.lock().await.insert(event_id); + } + return; + } + // Only proxy if the first word is a known bot command (sync or async). let is_known_command = !cmd.is_empty() && !GATEWAY_LOCAL_COMMANDS.contains(&cmd.as_str()) @@ -1285,7 +1360,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( #[cfg(test)] mod tests { - use super::{eval_gateway_status_command, eval_switch_command}; + use super::{eval_gateway_overview_command, eval_gateway_status_command, eval_switch_command}; use crate::service::gateway::config::ProjectEntry; use std::collections::BTreeMap; use tokio::sync::RwLock; @@ -1434,4 +1509,45 @@ mod tests { "error should list valid projects: {resp}" ); } + + // ── eval_gateway_overview_command (story 1193) ─────────────────────── + + #[tokio::test] + async fn overview_empty_arg_marks_no_url_project_unreachable_not_omitted() { + let active = RwLock::new("huskies".to_string()); + let store: RwLock> = RwLock::new(BTreeMap::from([( + "huskies".to_string(), + project_without_url(), + )])); + + let resp = eval_gateway_overview_command("", &active, &store).await; + assert!( + resp.contains("huskies") && resp.contains("unreachable"), + "a project with no URL must be reported unreachable, not silently dropped: {resp}" + ); + } + + #[tokio::test] + async fn overview_empty_arg_empty_store_reports_no_projects() { + let active = RwLock::new("huskies".to_string()); + let store: RwLock> = RwLock::new(BTreeMap::new()); + + let resp = eval_gateway_overview_command("", &active, &store).await; + assert_eq!(resp, "No projects registered."); + } + + #[tokio::test] + async fn overview_nonempty_arg_no_active_url_reports_no_active_project() { + let active = RwLock::new("huskies".to_string()); + let store: RwLock> = RwLock::new(BTreeMap::from([( + "huskies".to_string(), + project_without_url(), + )])); + + let resp = eval_gateway_overview_command("42", &active, &store).await; + assert!( + resp.contains("No active project selected"), + "numeric arg should proxy to the active project, reporting no URL: {resp}" + ); + } } diff --git a/server/src/gateway/mod.rs b/server/src/gateway/mod.rs index d78fd887..7d5e27bd 100644 --- a/server/src/gateway/mod.rs +++ b/server/src/gateway/mod.rs @@ -17,8 +17,9 @@ use std::sync::Arc; // Re-export public types that callers reference as `crate::gateway::*`. pub use crate::service::gateway::{ GatewayConfig, GatewayState as GatewayStateType, GatewayStatusEvent, ProjectEntry, - broadcast_status_event, fetch_all_project_pipeline_statuses, format_aggregate_status_compact, - spawn_gateway_broadcaster_forwarder, subscribe_status_events, + broadcast_status_event, fetch_all_project_pipeline_items, fetch_all_project_pipeline_statuses, + format_aggregate_status_compact, format_overview_compact, spawn_gateway_broadcaster_forwarder, + subscribe_status_events, }; /// Build the complete gateway route tree. diff --git a/server/src/service/gateway/aggregation.rs b/server/src/service/gateway/aggregation.rs index d55478a9..0dcc7e04 100644 --- a/server/src/service/gateway/aggregation.rs +++ b/server/src/service/gateway/aggregation.rs @@ -73,6 +73,58 @@ pub fn format_aggregate_status_compact(statuses: &BTreeMap) -> St format!("**All Projects**\n\n{}", lines.join("\n\n")) } +/// Format per-project active pipeline items as a compact cross-sled overview +/// for the `overview` gateway chat command (story 1193). +/// +/// Only items in the In Progress (`current`), QA (`qa`), and Merge (`merge`) +/// stages are listed — one line per item, grouped under a `**project**` +/// heading. Projects with no items in those stages get a one-line "idle" +/// note. Projects whose status carries an `error` key (fetch failure or no +/// URL configured) are marked unreachable rather than being dropped from the +/// output, so a disconnected sled is never silently omitted. +pub fn format_overview_compact(items_by_project: &BTreeMap) -> String { + if items_by_project.is_empty() { + return "No projects registered.".to_string(); + } + + let mut sections: Vec = Vec::new(); + for (name, status) in items_by_project { + if let Some(err) = status.get("error").and_then(|e| e.as_str()) { + sections.push(format!("**{name}** — unreachable: {err}")); + continue; + } + + let active = status + .get("active") + .and_then(|a| a.as_array()) + .cloned() + .unwrap_or_default(); + + let lines: Vec = active + .iter() + .filter_map(|item| { + let label = match item.get("stage").and_then(|s| s.as_str()).unwrap_or("") { + "current" => "In Progress", + "qa" => "QA", + "merge" => "Merge", + _ => return None, + }; + let story_id = item.get("story_id").and_then(|s| s.as_str()).unwrap_or("?"); + let story_name = item.get("name").and_then(|s| s.as_str()).unwrap_or(""); + Some(format!(" • {story_id} — {story_name} ({label})")) + }) + .collect(); + + if lines.is_empty() { + sections.push(format!("**{name}** — idle")); + } else { + sections.push(format!("**{name}**\n{}", lines.join("\n"))); + } + } + + format!("**Overview: Active Work**\n\n{}", sections.join("\n\n")) +} + // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -133,4 +185,86 @@ mod tests { let output = format_aggregate_status_compact(&statuses); assert_eq!(output, "No projects registered."); } + + // ── format_overview_compact ──────────────────────────────────────────── + + #[test] + fn overview_lists_items_grouped_by_project_with_stage_labels() { + let mut items = BTreeMap::new(); + items.insert( + "huskies".to_string(), + json!({ + "active": [ + { "story_id": "42_story_a", "name": "A", "stage": "current" }, + { "story_id": "43_story_b", "name": "B", "stage": "qa" }, + { "story_id": "44_story_c", "name": "C", "stage": "merge" }, + { "story_id": "45_story_d", "name": "D", "stage": "done" }, + ] + }), + ); + let output = format_overview_compact(&items); + assert!(output.contains("**huskies**")); + assert!(output.contains("42_story_a — A (In Progress)")); + assert!(output.contains("43_story_b — B (QA)")); + assert!(output.contains("44_story_c — C (Merge)")); + assert!( + !output.contains("45_story_d"), + "done-stage items must not appear in the overview: {output}" + ); + } + + #[test] + fn overview_idle_project_gets_idle_note() { + let mut items = BTreeMap::new(); + items.insert("quiet-proj".to_string(), json!({ "active": [] })); + let output = format_overview_compact(&items); + assert!(output.contains("**quiet-proj** — idle")); + } + + #[test] + fn overview_unreachable_project_is_marked_not_omitted() { + let mut items = BTreeMap::new(); + items.insert( + "down-proj".to_string(), + json!({ "error": "connection refused" }), + ); + let output = format_overview_compact(&items); + assert!(output.contains("down-proj")); + assert!(output.contains("unreachable")); + assert!(output.contains("connection refused")); + } + + #[test] + fn overview_no_url_project_marked_unreachable() { + let mut items = BTreeMap::new(); + items.insert( + "no-url-proj".to_string(), + json!({ "error": "no URL configured" }), + ); + let output = format_overview_compact(&items); + assert!(output.contains("no-url-proj")); + assert!(output.contains("unreachable: no URL configured")); + } + + #[test] + fn overview_empty_projects_map() { + let items = BTreeMap::new(); + let output = format_overview_compact(&items); + assert_eq!(output, "No projects registered."); + } + + #[test] + fn overview_multiple_projects_one_section_each() { + let mut items = BTreeMap::new(); + items.insert( + "alpha".to_string(), + json!({ "active": [{ "story_id": "1_x", "name": "X", "stage": "current" }] }), + ); + items.insert("beta".to_string(), json!({ "active": [] })); + let output = format_overview_compact(&items); + assert!(output.contains("**alpha**")); + assert!(output.contains("**beta** — idle")); + // alpha's section should come before beta's (BTreeMap sorted order). + assert!(output.find("alpha").unwrap() < output.find("beta").unwrap()); + } } diff --git a/server/src/service/gateway/mod.rs b/server/src/service/gateway/mod.rs index 84e1f0f3..8bc93283 100644 --- a/server/src/service/gateway/mod.rs +++ b/server/src/service/gateway/mod.rs @@ -19,11 +19,12 @@ pub mod polling; /// Pure signed release-manifest verification (signature, sha256, rollback) — no I/O. pub mod release_manifest; -pub use aggregation::format_aggregate_status_compact; +pub use aggregation::{format_aggregate_status_compact, format_overview_compact}; pub use config::{GatewayConfig, ProjectEntry}; pub use identity::{IdentityCheck, check_identity}; pub use io::{ - fetch_all_project_pipeline_statuses, probe_identity, spawn_gateway_broadcaster_forwarder, + fetch_all_project_pipeline_items, fetch_all_project_pipeline_statuses, probe_identity, + spawn_gateway_broadcaster_forwarder, }; use crate::http::context::PermissionForward;