2026-04-24 18:39:16 +00:00
|
|
|
//! 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, Value>) -> String {
|
|
|
|
|
let mut lines: Vec<String> = 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<String> = 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"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 17:23:03 +00:00
|
|
|
/// 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, Value>) -> String {
|
|
|
|
|
if items_by_project.is_empty() {
|
|
|
|
|
return "No projects registered.".to_string();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut sections: Vec<String> = 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<String> = 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"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
// ── 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.");
|
|
|
|
|
}
|
2026-07-17 17:23:03 +00:00
|
|
|
|
|
|
|
|
// ── 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());
|
|
|
|
|
}
|
2026-04-24 18:39:16 +00:00
|
|
|
}
|