//! 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(".storkit").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!("storkit: delete {story_id}"); let work_rel = std::path::PathBuf::from(".storkit").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(".storkit").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(".storkit").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"); } }