huskies: merge 824
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
//! Cleanup-worktrees bot command — stub module required by the commands registry.
|
||||
//!
|
||||
//! The real async implementation lives in
|
||||
//! `chat::transport::matrix::cleanup_worktrees`. This file exists so that
|
||||
//! `commands/mod.rs` can declare the module, keeping the registry consistent
|
||||
//! with the pattern used by other async commands (rmtree, start, rebuild, …).
|
||||
//!
|
||||
//! The fallback handler `handle_cleanup_worktrees_fallback` in `mod.rs` always
|
||||
//! returns `None`; the matrix transport intercepts the command before
|
||||
//! `try_handle_command` is reached.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::chat::commands::tests::{commands, try_cmd_addressed};
|
||||
|
||||
#[test]
|
||||
fn cleanup_worktrees_is_registered() {
|
||||
assert!(
|
||||
commands().iter().any(|c| c.name == "cleanup_worktrees"),
|
||||
"cleanup_worktrees must be in the command registry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_worktrees_has_description() {
|
||||
let cmd = commands()
|
||||
.iter()
|
||||
.find(|c| c.name == "cleanup_worktrees")
|
||||
.expect("cleanup_worktrees must be registered");
|
||||
assert!(
|
||||
!cmd.description.is_empty(),
|
||||
"cleanup_worktrees must have a description"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_worktrees_fallback_returns_none() {
|
||||
// The sync fallback returns None — the async handler in the Matrix
|
||||
// transport handles the real work.
|
||||
let result = try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy cleanup_worktrees",
|
||||
);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"cleanup_worktrees fallback must return None (handled async): {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
mod ambient;
|
||||
mod assign;
|
||||
mod backlog;
|
||||
mod cleanup_worktrees;
|
||||
mod cost;
|
||||
mod coverage;
|
||||
mod depends;
|
||||
@@ -256,6 +257,11 @@ pub fn commands() -> &'static [BotCommand] {
|
||||
description: "Show setup wizard progress; or `setup generate` / `setup confirm` / `setup skip` / `setup retry` to drive the wizard from chat",
|
||||
handler: setup::handle_setup,
|
||||
},
|
||||
BotCommand {
|
||||
name: "cleanup_worktrees",
|
||||
description: "List orphaned worktrees (dry run), or `cleanup_worktrees --confirm` to remove them",
|
||||
handler: handle_cleanup_worktrees_fallback,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -402,6 +408,17 @@ fn handle_rebuild_fallback(_ctx: &CommandContext) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Fallback handler for the `cleanup_worktrees` command when it is not
|
||||
/// intercepted by the async handler in `on_room_message`. In practice this is
|
||||
/// never called — cleanup_worktrees is detected and handled before
|
||||
/// `try_handle_command` is invoked. The entry exists in the registry only so
|
||||
/// `help` lists it.
|
||||
///
|
||||
/// Returns `None` to prevent the LLM from receiving "cleanup_worktrees" as a prompt.
|
||||
fn handle_cleanup_worktrees_fallback(_ctx: &CommandContext) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -565,6 +565,35 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||
}
|
||||
}
|
||||
|
||||
// Check for the cleanup_worktrees command, which requires async worktree
|
||||
// removal and cannot be handled by the sync command registry.
|
||||
if let Some(cleanup_cmd) =
|
||||
super::super::super::cleanup_worktrees::extract_cleanup_worktrees_command(
|
||||
&user_message,
|
||||
&ctx.services.bot_name,
|
||||
ctx.matrix_user_id.as_str(),
|
||||
)
|
||||
{
|
||||
let confirm =
|
||||
cleanup_cmd == super::super::super::cleanup_worktrees::CleanupWorktreesCommand::Confirm;
|
||||
slog!("[matrix-bot] Handling cleanup_worktrees command from {sender}: confirm={confirm}");
|
||||
let response = super::super::super::cleanup_worktrees::handle_cleanup_worktrees(
|
||||
&effective_root,
|
||||
confirm,
|
||||
)
|
||||
.await;
|
||||
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;
|
||||
}
|
||||
|
||||
// Check for the timer command, which requires async file I/O and cannot
|
||||
// be handled by the sync command registry.
|
||||
if let Some(timer_cmd) = crate::service::timer::extract_timer_command(
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Cleanup-worktrees command: list or remove orphaned worktrees from chat.
|
||||
//!
|
||||
//! `{bot_name} cleanup_worktrees` lists orphaned worktrees (dry run).
|
||||
//! `{bot_name} cleanup_worktrees --confirm` removes them.
|
||||
//!
|
||||
//! Both paths share the [`crate::worktree::run_cleanup`] handler.
|
||||
|
||||
use crate::chat::util::strip_bot_mention;
|
||||
use crate::config::ProjectConfig;
|
||||
use std::path::Path;
|
||||
|
||||
/// A parsed cleanup_worktrees command from a Matrix message body.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum CleanupWorktreesCommand {
|
||||
/// List orphaned worktrees without removing (dry run).
|
||||
DryRun,
|
||||
/// Remove all orphaned worktrees.
|
||||
Confirm,
|
||||
}
|
||||
|
||||
/// Parse a cleanup_worktrees command from a raw Matrix message body.
|
||||
///
|
||||
/// Strips the bot mention prefix and checks whether the first word is
|
||||
/// `cleanup_worktrees`. Returns `None` when the message is not this command.
|
||||
pub fn extract_cleanup_worktrees_command(
|
||||
message: &str,
|
||||
bot_name: &str,
|
||||
bot_user_id: &str,
|
||||
) -> Option<CleanupWorktreesCommand> {
|
||||
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
||||
let trimmed = stripped
|
||||
.trim()
|
||||
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||
|
||||
let (cmd, args) = match trimmed.split_once(char::is_whitespace) {
|
||||
Some((c, a)) => (c, a.trim()),
|
||||
None => (trimmed, ""),
|
||||
};
|
||||
|
||||
if !cmd.eq_ignore_ascii_case("cleanup_worktrees") {
|
||||
return None;
|
||||
}
|
||||
|
||||
if args == "--confirm" {
|
||||
Some(CleanupWorktreesCommand::Confirm)
|
||||
} else {
|
||||
Some(CleanupWorktreesCommand::DryRun)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a cleanup_worktrees command asynchronously.
|
||||
///
|
||||
/// Delegates to [`crate::worktree::run_cleanup`] and formats the result as
|
||||
/// a Markdown string suitable for posting to the chat room.
|
||||
pub async fn handle_cleanup_worktrees(project_root: &Path, confirm: bool) -> String {
|
||||
let config = match ProjectConfig::load(project_root) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return format!("Failed to load project config: {e}"),
|
||||
};
|
||||
|
||||
let report = crate::worktree::run_cleanup(project_root, &config, confirm).await;
|
||||
crate::worktree::format_report(&report, confirm)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- extract_cleanup_worktrees_command -----------------------------------
|
||||
|
||||
#[test]
|
||||
fn extract_dry_run_with_full_user_id() {
|
||||
let cmd = extract_cleanup_worktrees_command(
|
||||
"@timmy:home.local cleanup_worktrees",
|
||||
"Timmy",
|
||||
"@timmy:home.local",
|
||||
);
|
||||
assert_eq!(cmd, Some(CleanupWorktreesCommand::DryRun));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_confirm_with_display_name() {
|
||||
let cmd = extract_cleanup_worktrees_command(
|
||||
"Timmy cleanup_worktrees --confirm",
|
||||
"Timmy",
|
||||
"@timmy:home.local",
|
||||
);
|
||||
assert_eq!(cmd, Some(CleanupWorktreesCommand::Confirm));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_case_insensitive() {
|
||||
let cmd = extract_cleanup_worktrees_command(
|
||||
"Timmy CLEANUP_WORKTREES",
|
||||
"Timmy",
|
||||
"@timmy:home.local",
|
||||
);
|
||||
assert_eq!(cmd, Some(CleanupWorktreesCommand::DryRun));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_with_localpart() {
|
||||
let cmd = extract_cleanup_worktrees_command(
|
||||
"@timmy cleanup_worktrees --confirm",
|
||||
"Timmy",
|
||||
"@timmy:home.local",
|
||||
);
|
||||
assert_eq!(cmd, Some(CleanupWorktreesCommand::Confirm));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_other_command_returns_none() {
|
||||
let cmd =
|
||||
extract_cleanup_worktrees_command("Timmy rmtree 42", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_unrelated_text_returns_none() {
|
||||
let cmd = extract_cleanup_worktrees_command("Timmy help", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, None);
|
||||
}
|
||||
|
||||
// -- handle_cleanup_worktrees integration --------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_dry_run_no_worktrees() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join(".huskies").join("worktrees")).unwrap();
|
||||
let response = handle_cleanup_worktrees(tmp.path(), false).await;
|
||||
assert!(
|
||||
response.contains("No orphaned"),
|
||||
"unexpected response: {response}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_confirm_no_worktrees() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join(".huskies").join("worktrees")).unwrap();
|
||||
let response = handle_cleanup_worktrees(tmp.path(), true).await;
|
||||
assert!(
|
||||
response.contains("No orphaned"),
|
||||
"unexpected response: {response}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
/// Auto-assign handler — listens for pipeline events and assigns stories to free agents.
|
||||
pub mod assign;
|
||||
mod bot;
|
||||
/// Cleanup worktrees command — removes stale worktrees for completed or archived stories.
|
||||
pub mod cleanup_worktrees;
|
||||
/// Matrix bot command handlers — parses and routes bot commands from Matrix messages.
|
||||
pub mod commands;
|
||||
pub(crate) mod config;
|
||||
|
||||
Reference in New Issue
Block a user