2026-03-22 19:07:07 +00:00
|
|
|
//! 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};
|
2026-03-28 18:33:22 +00:00
|
|
|
use crate::chat::util::strip_bot_mention;
|
2026-03-22 19:07:07 +00:00
|
|
|
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<DeleteCommand> {
|
2026-03-28 18:33:22 +00:00
|
|
|
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
2026-03-22 19:07:07 +00:00
|
|
|
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 {
|
2026-04-03 16:12:52 +01:00
|
|
|
let dir = project_root.join(".huskies").join("work").join(stage);
|
2026-03-22 19:07:07 +00:00
|
|
|
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<String> = 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.
|
2026-04-03 16:12:52 +01:00
|
|
|
let commit_msg = format!("huskies: delete {story_id}");
|
|
|
|
|
let work_rel = std::path::PathBuf::from(".huskies").join("work");
|
2026-03-22 19:07:07 +00:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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",
|
|
|
|
|
] {
|
2026-04-03 16:12:52 +01:00
|
|
|
std::fs::create_dir_all(project_root.join(".huskies").join("work").join(stage))
|
2026-03-22 19:07:07 +00:00
|
|
|
.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();
|
|
|
|
|
|
2026-04-03 16:12:52 +01:00
|
|
|
let backlog_dir = project_root.join(".huskies").join("work").join("1_backlog");
|
2026-03-22 19:07:07 +00:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
}
|