//! 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 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")) } // ── 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."); } }