story-kit: merge 310_story_bot_delete_command_removes_a_story_from_the_pipeline

This commit is contained in:
Dave
2026-03-19 16:03:03 +00:00
parent a23fe71232
commit c4282ab2fa
4 changed files with 412 additions and 0 deletions

View File

@@ -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 <number>`", 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 {

View File

@@ -108,6 +108,11 @@ pub fn commands() -> &'static [BotCommand] {
description: "Display the full text of a work item: `show <number>`",
handler: handle_show,
},
BotCommand {
name: "delete",
description: "Remove a work item from the pipeline: `delete <number>`",
handler: handle_delete_fallback,
},
]
}
@@ -624,6 +629,16 @@ fn handle_show(ctx: &CommandContext) -> Option<String> {
))
}
/// 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<String> {
None
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

362
server/src/matrix/delete.rs Normal file
View File

@@ -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<DeleteCommand> {
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<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.
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");
}
}

View File

@@ -18,6 +18,7 @@
mod bot;
pub mod commands;
mod config;
pub mod delete;
pub mod htop;
pub mod notifications;