huskies: merge 1187 story status chat command shows that project's status
This commit is contained in:
@@ -147,11 +147,16 @@ impl BotContext {
|
|||||||
pub async fn active_project_url(&self) -> Option<String> {
|
pub async fn active_project_url(&self) -> Option<String> {
|
||||||
let ap = self.gateway_active_project.as_ref()?;
|
let ap = self.gateway_active_project.as_ref()?;
|
||||||
let name = ap.read().await.clone();
|
let name = ap.read().await.clone();
|
||||||
|
self.project_url(&name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the base URL for a named project from the live gateway store.
|
||||||
|
pub async fn project_url(&self, name: &str) -> Option<String> {
|
||||||
let store = self.gateway_projects_store.as_ref()?;
|
let store = self.gateway_projects_store.as_ref()?;
|
||||||
store
|
store
|
||||||
.read()
|
.read()
|
||||||
.await
|
.await
|
||||||
.get(&name)
|
.get(name)
|
||||||
.and_then(|entry| entry.url.clone())
|
.and_then(|entry| entry.url.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,18 +167,28 @@ impl BotContext {
|
|||||||
/// `rpc_response` frame. Returns an error message string if the
|
/// `rpc_response` frame. Returns an error message string if the
|
||||||
/// connection or command fails.
|
/// connection or command fails.
|
||||||
pub async fn proxy_bot_command(&self, command: &str, args: &str) -> Option<String> {
|
pub async fn proxy_bot_command(&self, command: &str, args: &str) -> Option<String> {
|
||||||
|
let base_url = self.active_project_url().await?;
|
||||||
|
Some(Self::run_proxy_bot_command(&base_url, command, args).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the `bot.command` WebSocket RPC call against `base_url` and return
|
||||||
|
/// the Markdown response, or an error message string on failure.
|
||||||
|
///
|
||||||
|
/// `pub(crate)` so callers that need to target a project by name (rather
|
||||||
|
/// than always the active one, as [`Self::proxy_bot_command`] does — e.g.
|
||||||
|
/// the `status <project>` command) can resolve their own URL and reuse
|
||||||
|
/// this transport logic.
|
||||||
|
pub(crate) async fn run_proxy_bot_command(base_url: &str, command: &str, args: &str) -> String {
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use tokio_tungstenite::tungstenite::Message as WsMsg;
|
use tokio_tungstenite::tungstenite::Message as WsMsg;
|
||||||
|
|
||||||
let base_url = self.active_project_url().await?;
|
|
||||||
|
|
||||||
// Convert http(s):// → ws(s)://
|
// Convert http(s):// → ws(s)://
|
||||||
let ws_base = if let Some(rest) = base_url.strip_prefix("https://") {
|
let ws_base = if let Some(rest) = base_url.strip_prefix("https://") {
|
||||||
format!("wss://{rest}")
|
format!("wss://{rest}")
|
||||||
} else if let Some(rest) = base_url.strip_prefix("http://") {
|
} else if let Some(rest) = base_url.strip_prefix("http://") {
|
||||||
format!("ws://{rest}")
|
format!("ws://{rest}")
|
||||||
} else {
|
} else {
|
||||||
base_url.clone()
|
base_url.to_string()
|
||||||
};
|
};
|
||||||
let ws_url = format!("{ws_base}/ws");
|
let ws_url = format!("{ws_base}/ws");
|
||||||
|
|
||||||
@@ -188,7 +203,7 @@ impl BotContext {
|
|||||||
});
|
});
|
||||||
let request_text = match serde_json::to_string(&request) {
|
let request_text = match serde_json::to_string(&request) {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => return Some(format!("Failed to serialize RPC request: {e}")),
|
Err(e) => return format!("Failed to serialize RPC request: {e}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let connect_timeout = std::time::Duration::from_secs(5);
|
let connect_timeout = std::time::Duration::from_secs(5);
|
||||||
@@ -200,21 +215,19 @@ impl BotContext {
|
|||||||
{
|
{
|
||||||
Ok(Ok((stream, _))) => stream,
|
Ok(Ok((stream, _))) => stream,
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
return Some(format!(
|
return format!("Failed to connect to project server at {ws_url}: {e}");
|
||||||
"Failed to connect to project server at {ws_url}: {e}"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return Some(format!(
|
return format!(
|
||||||
"Project server at {ws_url} is unreachable (connect timed out after {connect_timeout:?})"
|
"Project server at {ws_url} is unreachable (connect timed out after {connect_timeout:?})"
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (mut sink, mut stream) = ws_stream.split();
|
let (mut sink, mut stream) = ws_stream.split();
|
||||||
|
|
||||||
if let Err(e) = sink.send(WsMsg::Text(request_text.into())).await {
|
if let Err(e) = sink.send(WsMsg::Text(request_text.into())).await {
|
||||||
return Some(format!("Failed to send RPC request: {e}"));
|
return format!("Failed to send RPC request: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let response_timeout = std::time::Duration::from_secs(30);
|
let response_timeout = std::time::Duration::from_secs(30);
|
||||||
@@ -243,24 +256,24 @@ impl BotContext {
|
|||||||
.and_then(|r| r.get("response"))
|
.and_then(|r| r.get("response"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(String::from)
|
.map(String::from)
|
||||||
.or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
Some("Command succeeded with no response text".to_string())
|
"Command succeeded with no response text".to_string()
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
let err = frame
|
let err = frame
|
||||||
.get("error")
|
.get("error")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("unknown error");
|
.unwrap_or("unknown error");
|
||||||
return Some(format!("Project server command failed: {err}"));
|
return format!("Project server command failed: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(WsMsg::Close(_)) => break,
|
Ok(WsMsg::Close(_)) => break,
|
||||||
Err(e) => return Some(format!("WebSocket error: {e}")),
|
Err(e) => return format!("WebSocket error: {e}"),
|
||||||
_ => continue,
|
_ => continue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Some("Project server did not respond in time (connection closed or timed out)".to_string())
|
"Project server did not respond in time (connection closed or timed out)".to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,50 @@ fn extract_rebuild_gateway_command(message: &str, bot_name: &str, bot_user_id: &
|
|||||||
.unwrap_or(false)
|
.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.
|
/// 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
|
||||||
@@ -304,6 +348,29 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
|||||||
None => (stripped.to_ascii_lowercase(), String::new()),
|
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).
|
// 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())
|
||||||
@@ -1188,7 +1255,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::eval_switch_command;
|
use super::{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;
|
||||||
@@ -1259,4 +1326,82 @@ mod tests {
|
|||||||
"usage should list available projects: {resp}"
|
"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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user