huskies: merge 1193 story overview chat command: active work across all connected sleds
This commit is contained in:
@@ -112,6 +112,56 @@ async fn eval_gateway_status_command(
|
|||||||
format!("Unknown project or story number `{arg}`. Available projects: {available}")
|
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 <args>`
|
||||||
|
/// 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<String>,
|
||||||
|
store: &tokio::sync::RwLock<
|
||||||
|
std::collections::BTreeMap<String, crate::service::gateway::config::ProjectEntry>,
|
||||||
|
>,
|
||||||
|
) -> 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<String>)> = store
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.map(|(name, entry)| (name.clone(), entry.url.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let with_url: std::collections::BTreeMap<String, String> = 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 <arg>` command against the live project store.
|
/// Evaluate a `switch <arg>` command against the live project store.
|
||||||
///
|
///
|
||||||
/// Reads valid project names from the store at call time so newly added
|
/// 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",
|
"upgrade",
|
||||||
"health",
|
"health",
|
||||||
"projects",
|
"projects",
|
||||||
|
"overview",
|
||||||
];
|
];
|
||||||
|
|
||||||
let stripped = crate::chat::util::strip_bot_mention(
|
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;
|
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 <arg>`
|
||||||
|
// 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).
|
// Only proxy if the first word is a known bot command (sync or async).
|
||||||
let is_known_command = !cmd.is_empty()
|
let is_known_command = !cmd.is_empty()
|
||||||
&& !GATEWAY_LOCAL_COMMANDS.contains(&cmd.as_str())
|
&& !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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 crate::service::gateway::config::ProjectEntry;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
@@ -1434,4 +1509,45 @@ mod tests {
|
|||||||
"error should list valid projects: {resp}"
|
"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<BTreeMap<String, ProjectEntry>> = 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<BTreeMap<String, ProjectEntry>> = 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<BTreeMap<String, ProjectEntry>> = 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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ use std::sync::Arc;
|
|||||||
// Re-export public types that callers reference as `crate::gateway::*`.
|
// Re-export public types that callers reference as `crate::gateway::*`.
|
||||||
pub use crate::service::gateway::{
|
pub use crate::service::gateway::{
|
||||||
GatewayConfig, GatewayState as GatewayStateType, GatewayStatusEvent, ProjectEntry,
|
GatewayConfig, GatewayState as GatewayStateType, GatewayStatusEvent, ProjectEntry,
|
||||||
broadcast_status_event, fetch_all_project_pipeline_statuses, format_aggregate_status_compact,
|
broadcast_status_event, fetch_all_project_pipeline_items, fetch_all_project_pipeline_statuses,
|
||||||
spawn_gateway_broadcaster_forwarder, subscribe_status_events,
|
format_aggregate_status_compact, format_overview_compact, spawn_gateway_broadcaster_forwarder,
|
||||||
|
subscribe_status_events,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Build the complete gateway route tree.
|
/// Build the complete gateway route tree.
|
||||||
|
|||||||
@@ -73,6 +73,58 @@ pub fn format_aggregate_status_compact(statuses: &BTreeMap<String, Value>) -> St
|
|||||||
format!("**All Projects**\n\n{}", lines.join("\n\n"))
|
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, 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"))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -133,4 +185,86 @@ mod tests {
|
|||||||
let output = format_aggregate_status_compact(&statuses);
|
let output = format_aggregate_status_compact(&statuses);
|
||||||
assert_eq!(output, "No projects registered.");
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ pub mod polling;
|
|||||||
/// Pure signed release-manifest verification (signature, sha256, rollback) — no I/O.
|
/// Pure signed release-manifest verification (signature, sha256, rollback) — no I/O.
|
||||||
pub mod release_manifest;
|
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 config::{GatewayConfig, ProjectEntry};
|
||||||
pub use identity::{IdentityCheck, check_identity};
|
pub use identity::{IdentityCheck, check_identity};
|
||||||
pub use io::{
|
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;
|
use crate::http::context::PermissionForward;
|
||||||
|
|||||||
Reference in New Issue
Block a user