From c4282ab2fa0e78dae4247518bcf34032160bf2a4 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Mar 2026 16:03:03 +0000 Subject: [PATCH] story-kit: merge 310_story_bot_delete_command_removes_a_story_from_the_pipeline --- server/src/matrix/bot.rs | 34 ++++ server/src/matrix/commands.rs | 15 ++ server/src/matrix/delete.rs | 362 ++++++++++++++++++++++++++++++++++ server/src/matrix/mod.rs | 1 + 4 files changed, 412 insertions(+) create mode 100644 server/src/matrix/delete.rs diff --git a/server/src/matrix/bot.rs b/server/src/matrix/bot.rs index daf0db6..3c0b27d 100644 --- a/server/src/matrix/bot.rs +++ b/server/src/matrix/bot.rs @@ -827,6 +827,40 @@ async fn on_room_message( return; } + // Check for the delete command, which requires async agent/worktree ops + // and cannot be handled by the sync command registry. + if let Some(del_cmd) = super::delete::extract_delete_command( + &user_message, + &ctx.bot_name, + ctx.bot_user_id.as_str(), + ) { + let response = match del_cmd { + super::delete::DeleteCommand::Delete { story_number } => { + slog!( + "[matrix-bot] Handling delete command from {sender}: story {story_number}" + ); + super::delete::handle_delete( + &ctx.bot_name, + &story_number, + &ctx.project_root, + &ctx.agents, + ) + .await + } + super::delete::DeleteCommand::BadArgs => { + format!("Usage: `{} delete `", ctx.bot_name) + } + }; + let html = markdown_to_html(&response); + if let Ok(resp) = room + .send(RoomMessageEventContent::text_html(response, html)) + .await + { + ctx.bot_sent_event_ids.lock().await.insert(resp.event_id); + } + return; + } + // Spawn a separate task so the Matrix sync loop is not blocked while we // wait for the LLM response (which can take several seconds). tokio::spawn(async move { diff --git a/server/src/matrix/commands.rs b/server/src/matrix/commands.rs index f1fdff8..4083879 100644 --- a/server/src/matrix/commands.rs +++ b/server/src/matrix/commands.rs @@ -108,6 +108,11 @@ pub fn commands() -> &'static [BotCommand] { description: "Display the full text of a work item: `show `", handler: handle_show, }, + BotCommand { + name: "delete", + description: "Remove a work item from the pipeline: `delete `", + handler: handle_delete_fallback, + }, ] } @@ -624,6 +629,16 @@ fn handle_show(ctx: &CommandContext) -> Option { )) } +/// Fallback handler for the `delete` command when it is not intercepted by +/// the async handler in `on_room_message`. In practice this is never called — +/// delete 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 "delete" as a prompt. +fn handle_delete_fallback(_ctx: &CommandContext) -> Option { + None +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/server/src/matrix/delete.rs b/server/src/matrix/delete.rs new file mode 100644 index 0000000..a673050 --- /dev/null +++ b/server/src/matrix/delete.rs @@ -0,0 +1,362 @@ +//! Delete command: remove a story/bug/spike from the pipeline. +//! +//! `{bot_name} delete {number}` finds the work item by number across all pipeline +//! stages, stops any running agent, removes the worktree, deletes the file, and +//! commits the change to git. + +use crate::agents::{AgentPool, AgentStatus}; +use std::path::Path; + +/// A parsed delete command from a Matrix message body. +#[derive(Debug, PartialEq)] +pub enum DeleteCommand { + /// Delete the story with this number (digits only, e.g. `"42"`). + Delete { story_number: String }, + /// The user typed `delete` but without a valid numeric argument. + BadArgs, +} + +/// Parse a delete command from a raw Matrix message body. +/// +/// Strips the bot mention prefix and checks whether the first word is `delete`. +/// Returns `None` when the message is not a delete command at all. +pub fn extract_delete_command( + message: &str, + bot_name: &str, + bot_user_id: &str, +) -> Option { + let stripped = strip_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("delete") { + return None; + } + + if !args.is_empty() && args.chars().all(|c| c.is_ascii_digit()) { + Some(DeleteCommand::Delete { + story_number: args.to_string(), + }) + } else { + Some(DeleteCommand::BadArgs) + } +} + +/// Handle a delete command asynchronously. +/// +/// Finds the work item by `story_number` across all pipeline stages, stops any +/// running agent, removes the worktree, deletes the file, and commits to git. +/// Returns a markdown-formatted response string. +pub async fn handle_delete( + bot_name: &str, + story_number: &str, + project_root: &Path, + agents: &AgentPool, +) -> String { + const STAGES: &[&str] = &[ + "1_backlog", + "2_current", + "3_qa", + "4_merge", + "5_done", + "6_archived", + ]; + + // Find the story file across all pipeline stages. + let mut found: Option<(std::path::PathBuf, &str, String)> = None; // (path, stage, story_id) + 'outer: for stage in STAGES { + let dir = project_root.join(".story_kit").join("work").join(stage); + if !dir.exists() { + continue; + } + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + if let Some(stem) = path + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + { + let file_num = stem + .split('_') + .next() + .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) + .unwrap_or("") + .to_string(); + if file_num == story_number { + found = Some((path, stage, stem)); + break 'outer; + } + } + } + } + } + + let (path, stage, story_id) = match found { + Some(f) => f, + None => { + return format!( + "No story, bug, or spike with number **{story_number}** found." + ); + } + }; + + // Read the human-readable name from front matter for the confirmation message. + let story_name = std::fs::read_to_string(&path) + .ok() + .and_then(|contents| { + crate::io::story_metadata::parse_front_matter(&contents) + .ok() + .and_then(|m| m.name) + }) + .unwrap_or_else(|| story_id.clone()); + + // Stop any running or pending agents for this story. + let running_agents: Vec<(String, String)> = agents + .list_agents() + .unwrap_or_default() + .into_iter() + .filter(|a| { + a.story_id == story_id + && matches!(a.status, AgentStatus::Running | AgentStatus::Pending) + }) + .map(|a| (a.story_id.clone(), a.agent_name.clone())) + .collect(); + + let mut stopped_agents: Vec = Vec::new(); + for (sid, agent_name) in &running_agents { + if let Err(e) = agents.stop_agent(project_root, sid, agent_name).await { + return format!( + "Failed to stop agent '{agent_name}' for story {story_number}: {e}" + ); + } + stopped_agents.push(agent_name.clone()); + } + + // Remove the worktree if one exists (best-effort; ignore errors). + let _ = crate::worktree::prune_worktree_sync(project_root, &story_id); + + // Delete the story file. + if let Err(e) = std::fs::remove_file(&path) { + return format!("Failed to delete story {story_number}: {e}"); + } + + // Commit the deletion to git. + let commit_msg = format!("story-kit: delete {story_id}"); + let work_rel = std::path::PathBuf::from(".story_kit").join("work"); + let _ = std::process::Command::new("git") + .args(["add", "-A"]) + .arg(&work_rel) + .current_dir(project_root) + .output(); + let _ = std::process::Command::new("git") + .args(["commit", "-m", &commit_msg]) + .current_dir(project_root) + .output(); + + // Build the response. + let stage_label = stage_display_name(stage); + let mut response = format!("Deleted **{story_name}** from **{stage_label}**."); + if !stopped_agents.is_empty() { + let agent_list = stopped_agents.join(", "); + response.push_str(&format!(" Stopped agent(s): {agent_list}.")); + } + + crate::slog!( + "[matrix-bot] delete command: removed {story_id} from {stage} (bot={bot_name})" + ); + + response +} + +/// Human-readable label for a pipeline stage directory name. +fn stage_display_name(stage: &str) -> &str { + match stage { + "1_backlog" => "backlog", + "2_current" => "in-progress", + "3_qa" => "QA", + "4_merge" => "merge", + "5_done" => "done", + "6_archived" => "archived", + other => other, + } +} + +/// Strip the bot mention prefix from a raw Matrix message body. +/// +/// Mirrors the logic in `commands::strip_bot_mention` and `htop::strip_mention` +/// so delete detection works without depending on private symbols. +fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str { + let trimmed = message.trim(); + if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) { + return rest; + } + if let Some(localpart) = bot_user_id.split(':').next() + && let Some(rest) = strip_prefix_ci(trimmed, localpart) + { + return rest; + } + if let Some(rest) = strip_prefix_ci(trimmed, bot_name) { + return rest; + } + trimmed +} + +fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { + if text.len() < prefix.len() { + return None; + } + if !text[..prefix.len()].eq_ignore_ascii_case(prefix) { + return None; + } + let rest = &text[prefix.len()..]; + match rest.chars().next() { + None => Some(rest), + Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None, + _ => Some(rest), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- extract_delete_command --------------------------------------------- + + #[test] + fn extract_with_full_user_id() { + let cmd = + extract_delete_command("@timmy:home.local delete 42", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(DeleteCommand::Delete { story_number: "42".to_string() })); + } + + #[test] + fn extract_with_display_name() { + let cmd = extract_delete_command("Timmy delete 310", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(DeleteCommand::Delete { story_number: "310".to_string() })); + } + + #[test] + fn extract_with_localpart() { + let cmd = extract_delete_command("@timmy delete 7", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(DeleteCommand::Delete { story_number: "7".to_string() })); + } + + #[test] + fn extract_case_insensitive_command() { + let cmd = extract_delete_command("Timmy DELETE 99", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(DeleteCommand::Delete { story_number: "99".to_string() })); + } + + #[test] + fn extract_no_args_is_bad_args() { + let cmd = extract_delete_command("Timmy delete", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(DeleteCommand::BadArgs)); + } + + #[test] + fn extract_non_numeric_arg_is_bad_args() { + let cmd = extract_delete_command("Timmy delete foo", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(DeleteCommand::BadArgs)); + } + + #[test] + fn extract_non_delete_command_returns_none() { + let cmd = extract_delete_command("Timmy help", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, None); + } + + #[test] + fn extract_no_bot_prefix_returns_none() { + let cmd = extract_delete_command("delete 42", "Timmy", "@timmy:home.local"); + // Without mention prefix the raw text is "delete 42" — cmd is "delete", args "42" + // strip_mention returns the full trimmed text when no prefix matches, + // so this is a valid delete command addressed to no-one (ambient mode). + assert_eq!(cmd, Some(DeleteCommand::Delete { story_number: "42".to_string() })); + } + + // -- handle_delete (integration-style, uses temp filesystem) ----------- + + #[tokio::test] + async fn handle_delete_returns_not_found_for_unknown_number() { + let tmp = tempfile::tempdir().unwrap(); + let project_root = tmp.path(); + // Create the pipeline directories. + for stage in &["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] { + std::fs::create_dir_all(project_root.join(".story_kit").join("work").join(stage)) + .unwrap(); + } + let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000)); + let response = handle_delete("Timmy", "999", project_root, &agents).await; + assert!( + response.contains("No story") && response.contains("999"), + "unexpected response: {response}" + ); + } + + #[tokio::test] + async fn handle_delete_removes_story_file_and_confirms() { + let tmp = tempfile::tempdir().unwrap(); + let project_root = tmp.path(); + + // Init a bare git repo so the commit step doesn't fail fatally. + std::process::Command::new("git") + .args(["init"]) + .current_dir(project_root) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(project_root) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(project_root) + .output() + .unwrap(); + + let backlog_dir = project_root.join(".story_kit").join("work").join("1_backlog"); + std::fs::create_dir_all(&backlog_dir).unwrap(); + let story_path = backlog_dir.join("42_story_some_feature.md"); + std::fs::write( + &story_path, + "---\nname: Some Feature\n---\n\n# Story 42\n", + ) + .unwrap(); + + // Initial commit so git doesn't complain about no commits. + std::process::Command::new("git") + .args(["add", "-A"]) + .current_dir(project_root) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(project_root) + .output() + .unwrap(); + + let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000)); + let response = handle_delete("Timmy", "42", project_root, &agents).await; + + assert!( + response.contains("Some Feature") && response.contains("backlog"), + "unexpected response: {response}" + ); + assert!(!story_path.exists(), "story file should have been deleted"); + } +} diff --git a/server/src/matrix/mod.rs b/server/src/matrix/mod.rs index f09ffad..d2725a9 100644 --- a/server/src/matrix/mod.rs +++ b/server/src/matrix/mod.rs @@ -18,6 +18,7 @@ mod bot; pub mod commands; mod config; +pub mod delete; pub mod htop; pub mod notifications;