huskies: merge 1187 story status chat command shows that project's status

This commit is contained in:
Huskies Agent
2026-07-17 11:49:43 +00:00
parent 0d26ac5a2a
commit eda14976d0
2 changed files with 175 additions and 17 deletions
@@ -68,6 +68,50 @@ fn extract_rebuild_gateway_command(message: &str, bot_name: &str, bot_user_id: &
.unwrap_or(false)
}
/// Evaluate a `status <arg>` command in gateway mode, routing it to the
/// right project before falling back to the generic per-command proxy.
///
/// - Empty arg: proxies bare `status` to the active project (pipeline status).
/// - All-digit arg: proxies `status <arg>` to the active project — stories
/// live within whichever project is currently active, not by name.
/// - Arg matching a known project name: proxies bare `status` to *that*
/// project rather than whichever one is active.
/// - Anything else: a clear error listing the valid project names.
async fn eval_gateway_status_command(
arg: &str,
active_project: &tokio::sync::RwLock<String>,
store: &tokio::sync::RwLock<
std::collections::BTreeMap<String, crate::service::gateway::config::ProjectEntry>,
>,
) -> String {
const NO_ACTIVE_PROJECT: &str = "No active project selected or project URL not configured.";
if arg.is_empty() || arg.chars().all(|c| c.is_ascii_digit()) {
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) => {
super::super::context::BotContext::run_proxy_bot_command(&url, "status", arg).await
}
None => NO_ACTIVE_PROJECT.to_string(),
};
}
let known: Vec<String> = store.read().await.keys().cloned().collect();
if known.iter().any(|p| p == arg) {
let url = store.read().await.get(arg).and_then(|e| e.url.clone());
return match url {
Some(url) => {
super::super::context::BotContext::run_proxy_bot_command(&url, "status", "").await
}
None => format!("Project `{arg}` has no URL configured."),
};
}
let available = known.join(", ");
format!("Unknown project or story number `{arg}`. Available projects: {available}")
}
/// Evaluate a `switch <arg>` command against the live project store.
///
/// Reads valid project names from the store at call time so newly added
@@ -304,6 +348,29 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
None => (stripped.to_ascii_lowercase(), String::new()),
};
// `status <arg>` is special-cased ahead of the generic per-command proxy
// below so `<arg>` can resolve to a *named* project rather than always
// being forwarded to whichever project is currently active.
if cmd == "status" {
slog!("[matrix-bot] Handling 'status {args}' from {sender}");
let response = match (&ctx.gateway_active_project, &ctx.gateway_projects_store) {
(Some(active_project), Some(store)) => {
eval_gateway_status_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())
@@ -1188,7 +1255,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
#[cfg(test)]
mod tests {
use super::eval_switch_command;
use super::{eval_gateway_status_command, eval_switch_command};
use crate::service::gateway::config::ProjectEntry;
use std::collections::BTreeMap;
use tokio::sync::RwLock;
@@ -1259,4 +1326,82 @@ mod tests {
"usage should list available projects: {resp}"
);
}
/// Build a one-project store entry with no URL, to exercise the
/// no-network-call branches of `eval_gateway_status_command`.
fn project_without_url() -> ProjectEntry {
ProjectEntry {
url: None,
auth_token: None,
ssh_port: None,
host_path: None,
expected_node_id: None,
}
}
#[tokio::test]
async fn status_empty_arg_targets_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_status_command("", &active, &store).await;
assert!(
resp.contains("No active project selected"),
"should report no URL configured for the active project: {resp}"
);
}
#[tokio::test]
async fn status_numeric_arg_targets_active_project_not_by_name() {
// A project literally named "42" must not shadow story-number routing:
// digits always mean "look up this story on the 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()),
("42".to_string(), project_without_url()),
]));
let resp = eval_gateway_status_command("42", &active, &store).await;
assert!(
resp.contains("No active project selected"),
"numeric arg should resolve via the active project, not the '42' project: {resp}"
);
}
#[tokio::test]
async fn status_known_project_name_targets_that_project() {
let active = RwLock::new("huskies".to_string());
let store: RwLock<BTreeMap<String, ProjectEntry>> = RwLock::new(BTreeMap::from([
("huskies".to_string(), project_without_url()),
("robot-studio".to_string(), project_without_url()),
]));
let resp = eval_gateway_status_command("robot-studio", &active, &store).await;
assert!(
resp.contains("robot-studio") && resp.contains("no URL configured"),
"should target the named project, not the active one: {resp}"
);
}
#[tokio::test]
async fn status_unrecognized_arg_lists_valid_projects() {
let active = RwLock::new("huskies".to_string());
let store: RwLock<BTreeMap<String, ProjectEntry>> = RwLock::new(BTreeMap::from([
("huskies".to_string(), project_without_url()),
("robot-studio".to_string(), project_without_url()),
]));
let resp = eval_gateway_status_command("not-a-project", &active, &store).await;
assert!(
resp.contains("Unknown project or story number"),
"should give a clear unrecognized-argument error: {resp}"
);
assert!(
resp.contains("huskies") && resp.contains("robot-studio"),
"error should list valid projects: {resp}"
);
}
}