//! Gateway aggregation — pure functions for cross-project pipeline status. //! //! Formats aggregated pipeline data into compact text suitable for chat //! transports (Matrix, Slack). Uses `service::pipeline::aggregate_pipeline_counts` //! for per-project parsing. use super::identity::SledIdentityReport; use serde_json::Value; use std::collections::BTreeMap; /// Format an aggregated status map as a compact, one-line-per-project string /// suitable for Matrix/Slack messages. /// /// Healthy projects: `🟢 **name** — B:5 C:2 Q:1 M:0 D:12` /// Blocked items appended on the same line: `| blocked: 42 [story]` /// Unreachable projects: `🔴 **name** — UNREACHABLE` pub fn format_aggregate_status_compact(statuses: &BTreeMap) -> String { let mut lines: Vec = Vec::new(); for (name, status) in statuses { if let Some(err) = status.get("error").and_then(|e| e.as_str()) { lines.push(format!("\u{1F534} **{name}** — UNREACHABLE: {err}")); } else { let counts = status.get("counts"); let b = counts .and_then(|c| c.get("backlog")) .and_then(|n| n.as_u64()) .unwrap_or(0); let c = counts .and_then(|c| c.get("current")) .and_then(|n| n.as_u64()) .unwrap_or(0); let q = counts .and_then(|c| c.get("qa")) .and_then(|n| n.as_u64()) .unwrap_or(0); let m = counts .and_then(|c| c.get("merge")) .and_then(|n| n.as_u64()) .unwrap_or(0); let d = counts .and_then(|c| c.get("done")) .and_then(|n| n.as_u64()) .unwrap_or(0); let blocked_arr = status .get("blocked") .and_then(|a| a.as_array()) .cloned() .unwrap_or_default(); let indicator = if blocked_arr.is_empty() { "\u{1F7E2}" // 🟢 } else { "\u{1F7E0}" // 🟠 }; let mut line = format!("{indicator} **{name}** — B:{b} C:{c} Q:{q} M:{m} D:{d}"); if !blocked_arr.is_empty() { let ids: Vec = blocked_arr .iter() .filter_map(|item| item.get("story_id").and_then(|s| s.as_str())) .map(|s| s.to_string()) .collect(); line.push_str(&format!(" | blocked: {}", ids.join(", "))); } lines.push(line); } } if lines.is_empty() { return "No projects registered.".to_string(); } 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")) } /// Format `fleet_identity` read-mode reports as Markdown, one line per sled. /// /// Matches, first-contacts (no pin recorded yet), and unreachable sleds get a /// plain status line. A verified mismatch is called out with the same /// wording as the `upgrade` command's identity check /// (`chat::transport::matrix::sled_upgrade::verify_sled_identity`) so an /// operator sees one consistent message for "wrong container answered" /// regardless of which command surfaced it — including naming the container /// as `huskies-{project}`. pub fn format_identity_reports(reports: &[SledIdentityReport]) -> String { if reports.is_empty() { return "No projects registered.".to_string(); } let lines: Vec = reports .iter() .map(|r| { let container_name = format!("huskies-{}", r.project); let url = r.url.as_deref().unwrap_or("(no url configured)"); if !r.connected { return format!("\u{1F534} **{}** — unreachable at `{url}`", r.project); } match (&r.expected_pin, &r.live_node_id, r.matched) { (_, _, true) => format!("\u{1F7E2} **{}** — matches pin `{url}`", r.project), (Some(expected), Some(live), false) => format!( "\u{1F7E0} **identity mismatch** for `{container_name}` at `{url}`: expected \ node_id `{expected}`, but the container that answered identified as \ `{live}`. Refusing to proceed — this may not be the sled you expect." ), (None, Some(live), false) => format!( "\u{1F7E1} **{}** — no pin recorded yet at `{url}`; live node_id `{live}` \ (re-pin to trust it)", r.project ), (_, None, false) => format!( "\u{1F534} **identity verification failed** for `{container_name}` at `{url}`: \ the `/identity` response's signature is missing or did not verify. \ Refusing to proceed — the container's identity cannot be trusted." ), } }) .collect(); format!("**Fleet Identity**\n\n{}", lines.join("\n")) } /// Find the registered project whose pipeline contains a story with the /// given numeric ID prefix, searching `active`, `backlog`, and `archived` /// alike so gateway `status ` resolves regardless of which project is /// currently "active" (story 1203). /// /// `items_by_project` is the output of `fetch_all_project_pipeline_items`. /// Projects whose entry carries an `error` key (unreachable, no URL) are /// skipped rather than matched. Returns the owning project's name, or `None` /// if no registered, reachable project has a matching story. pub fn find_project_containing_story( items_by_project: &BTreeMap, num_str: &str, ) -> Option { for (name, status) in items_by_project { if status.get("error").is_some() { continue; } for key in ["active", "backlog", "archived"] { let Some(items) = status.get(key).and_then(|a| a.as_array()) else { continue; }; let found = items.iter().any(|item| { item.get("story_id") .and_then(|s| s.as_str()) .map(|story_id| story_number_prefix(story_id) == num_str) .unwrap_or(false) }); if found { return Some(name.clone()); } } } None } /// Extract the leading numeric prefix from a `story_id` like `"42_story_x"`, /// or `""` if the ID has no purely-digit prefix before the first `_`. fn story_number_prefix(story_id: &str) -> &str { story_id .split('_') .next() .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) .unwrap_or("") } // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn format_healthy_project() { let mut statuses = BTreeMap::new(); statuses.insert( "huskies".to_string(), json!({ "counts": { "backlog": 5, "current": 2, "qa": 1, "merge": 0, "done": 12 }, "blocked": [] }), ); let output = format_aggregate_status_compact(&statuses); assert!(output.contains("huskies")); assert!(output.contains("B:5")); assert!(output.contains("C:2")); assert!(output.contains("Q:1")); assert!(output.contains("D:12")); assert!(!output.contains("blocked:")); } #[test] fn format_unreachable_project() { let mut statuses = BTreeMap::new(); statuses.insert( "broken".to_string(), json!({ "error": "connection refused" }), ); let output = format_aggregate_status_compact(&statuses); assert!(output.contains("broken")); assert!(output.contains("UNREACHABLE")); assert!(output.contains("connection refused")); } #[test] fn format_blocked_items_shown() { let mut statuses = BTreeMap::new(); statuses.insert( "myproj".to_string(), json!({ "counts": { "backlog": 0, "current": 1, "qa": 0, "merge": 0, "done": 0 }, "blocked": [{ "story_id": "42_story_x", "name": "X", "stage": "current", "reason": "blocked" }] }), ); let output = format_aggregate_status_compact(&statuses); assert!(output.contains("blocked:")); assert!(output.contains("42_story_x")); } #[test] fn format_empty_projects() { let statuses = BTreeMap::new(); 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()); } // ── format_identity_reports (story 1206 AC3) ─────────────────────────── #[test] fn identity_report_mismatch_names_container_consistent_with_upgrade_sweep() { let reports = vec![SledIdentityReport { project: "myapp".to_string(), url: Some("http://sled:3001".to_string()), connected: true, expected_pin: Some("expected-id".to_string()), live_node_id: Some("different-id".to_string()), matched: false, }]; let output = format_identity_reports(&reports); assert!(output.contains("**identity mismatch**")); assert!( output.contains("`huskies-myapp`"), "must name the container as huskies-: {output}" ); assert!(output.contains("expected node_id `expected-id`")); assert!(output.contains("identified as `different-id`")); assert!(output.contains("Refusing to proceed")); } #[test] fn identity_report_match_is_a_plain_status_line() { let reports = vec![SledIdentityReport { project: "myapp".to_string(), url: Some("http://sled:3001".to_string()), connected: true, expected_pin: Some("abc".to_string()), live_node_id: Some("abc".to_string()), matched: true, }]; let output = format_identity_reports(&reports); assert!(output.contains("myapp")); assert!(output.contains("matches pin")); assert!(!output.contains("mismatch")); } #[test] fn identity_report_unreachable_sled_is_marked() { let reports = vec![SledIdentityReport { project: "myapp".to_string(), url: Some("http://sled:3001".to_string()), connected: false, expected_pin: Some("abc".to_string()), live_node_id: None, matched: false, }]; let output = format_identity_reports(&reports); assert!(output.contains("unreachable")); } #[test] fn identity_report_no_pin_recorded_invites_a_repin() { let reports = vec![SledIdentityReport { project: "myapp".to_string(), url: Some("http://sled:3001".to_string()), connected: true, expected_pin: None, live_node_id: Some("some-id".to_string()), matched: false, }]; let output = format_identity_reports(&reports); assert!(output.contains("no pin recorded")); assert!(output.contains("some-id")); } #[test] fn identity_report_invalid_signature_is_flagged() { let reports = vec![SledIdentityReport { project: "myapp".to_string(), url: Some("http://sled:3001".to_string()), connected: true, expected_pin: Some("abc".to_string()), live_node_id: None, matched: false, }]; let output = format_identity_reports(&reports); assert!(output.contains("**identity verification failed**")); assert!(output.contains("`huskies-myapp`")); } #[test] fn identity_report_empty_list() { let output = format_identity_reports(&[]); assert_eq!(output, "No projects registered."); } // ── find_project_containing_story ────────────────────────────────────── #[test] fn find_story_matches_active_array() { let mut items = BTreeMap::new(); items.insert( "huskies".to_string(), json!({ "active": [{ "story_id": "42_story_x", "name": "X", "stage": "current" }], "backlog": [], "archived": [] }), ); assert_eq!( find_project_containing_story(&items, "42"), Some("huskies".to_string()) ); } #[test] fn find_story_matches_backlog_array() { let mut items = BTreeMap::new(); items.insert( "huskies".to_string(), json!({ "active": [], "backlog": [{ "story_id": "77_story_y", "name": "Y" }], "archived": [] }), ); assert_eq!( find_project_containing_story(&items, "77"), Some("huskies".to_string()) ); } #[test] fn find_story_matches_archived_array() { let mut items = BTreeMap::new(); items.insert( "huskies".to_string(), json!({ "active": [], "backlog": [], "archived": [{ "story_id": "13_story_z", "name": "Z" }] }), ); assert_eq!( find_project_containing_story(&items, "13"), Some("huskies".to_string()) ); } #[test] fn find_story_returns_none_when_not_present_anywhere() { let mut items = BTreeMap::new(); items.insert( "huskies".to_string(), json!({ "active": [{ "story_id": "1_story_a", "name": "A" }], "backlog": [], "archived": [] }), ); assert_eq!(find_project_containing_story(&items, "999"), None); } #[test] fn find_story_skips_unreachable_projects() { let mut items = BTreeMap::new(); items.insert( "broken".to_string(), json!({ "error": "connection refused" }), ); items.insert( "huskies".to_string(), json!({ "active": [{ "story_id": "5_story_b", "name": "B" }], "backlog": [], "archived": [] }), ); assert_eq!( find_project_containing_story(&items, "5"), Some("huskies".to_string()) ); } /// A project literally named after the digits (e.g. "42") must not /// spuriously match — only `story_id` prefixes are compared. #[test] fn find_story_does_not_match_on_project_name() { let mut items = BTreeMap::new(); items.insert( "42".to_string(), json!({ "active": [{ "story_id": "1_story_a", "name": "A" }], "backlog": [], "archived": [] }), ); assert_eq!(find_project_containing_story(&items, "42"), None); } }