huskies: merge 824

This commit is contained in:
dave
2026-04-29 13:38:34 +00:00
parent b4854cf693
commit 59b626d3ba
13 changed files with 658 additions and 4 deletions
@@ -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}"
);
}
}
+2
View File
@@ -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;