Files
huskies/server/src/chat/transport/matrix/delete.rs
T
TimmyandClaude Opus 4.7 69d91d7707 feat(929): delete db/yaml_legacy.rs entirely — CRDT is the sole source of truth
Final 929 sweep: every YAML-shaped helper is gone. No production code
parses or writes YAML front matter anywhere.

Surface removed:
- db/yaml_legacy.rs (FrontMatter/StoryMetadata structs, parse_front_matter,
  set_front_matter_field, yaml_residue marker) — file deleted.
- ItemMeta::from_yaml — deleted; callers pass typed ItemMeta::named(...) or
  ItemMeta::default() and use typed CRDT setters (set_depends_on,
  set_blocked, set_retry_count, set_agent, set_qa_mode, set_review_hold,
  set_item_type, set_epic, set_mergemaster_attempted) for the rest.
- write_coverage_baseline_to_story_file + read_coverage_percent_from_json —
  the coverage_baseline YAML field was write-only (nothing read it back);
  removed along with its caller in agent_tools/lifecycle.rs.
- update_story_in_file's generic `front_matter` HashMap parameter —
  tool_update_story now intercepts every known field name and routes it
  to a typed CRDT setter; unknown keys are rejected with an explicit error
  pointing at the typed setters. The function only takes user_story /
  description sections now.
- All 117 ItemMeta::from_yaml callsites migrated. Where tests previously
  passed a YAML-shaped content blob and relied on the helper to extract
  name/depends_on/blocked/agent/qa, they now pass:
    write_item_with_content(id, stage, content, ItemMeta::named("Foo"))
    crate::crdt_state::set_depends_on(id, &[...])    // when needed
    crate::crdt_state::set_blocked(id, true)         // when needed
    crate::crdt_state::set_agent(id, Some("..."))    // when needed
- write_story_content + write_story_file (test helper) now take an
  explicit `name: Option<&str>` instead of parsing it from content.
- db::ops::move_item_stage stopped re-parsing YAML on every stage
  transition; metadata is read straight from the CRDT view when mirroring
  the row into SQLite.

New CRDT setters added for symmetry:
- crdt_state::set_name (mirrors set_agent — explicit name updates).

cargo fmt --check, clippy --all-targets -- -D warnings, and the
2830-test suite all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:55:25 +01:00

308 lines
10 KiB
Rust

//! 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;
use crate::chat::util::strip_bot_mention;
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_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("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 {
// Find the story by numeric prefix: CRDT → content store → filesystem.
let (story_id, stage, _path, _content) =
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
Some(found) => found,
None => {
return format!("No story, bug, or spike with number **{story_number}** found.");
}
};
// Story name comes from the CRDT name register (story 929).
let story_name = crate::crdt_state::read_item(&story_id)
.and_then(|w| w.name().map(str::to_string))
.unwrap_or_else(|| story_id.clone());
let outcome = match crate::service::work_item::delete::delete_work_item(
&story_id,
project_root,
agents,
None,
)
.await
{
Ok(o) => o,
Err(e) => return e,
};
// Build the response.
let stage_label = stage_display_name(&stage);
let mut response = format!("Deleted **{story_name}** from **{stage_label}**.");
if !outcome.agents_stopped.is_empty() {
let agent_list = outcome.agents_stopped.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 {
use crate::pipeline_state::Stage;
match Stage::from_dir(stage) {
Some(Stage::Upcoming) => "upcoming",
Some(Stage::Backlog) => "backlog",
Some(Stage::Coding) => "in-progress",
Some(Stage::Blocked { .. }) => "blocked",
Some(Stage::Qa) => "QA",
Some(Stage::Merge { .. }) => "merge",
Some(Stage::Done { .. }) => "done",
Some(Stage::Archived { .. }) => "archived",
Some(Stage::MergeFailure { .. }) => "merge-failure",
Some(Stage::Frozen { .. }) => "frozen",
None => stage,
}
}
// ---------------------------------------------------------------------------
// 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(".huskies").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_writes_crdt_tombstone() {
// Initialise the global CRDT singleton (no-op if already done).
crate::crdt_state::init_for_test();
let story_id = "9977_story_crdt_tombstone_check";
let story_number = "9977";
// Seed in CRDT.
crate::crdt_state::write_item(
story_id,
"1_backlog",
Some("CRDT Tombstone Check"),
None,
None,
None,
None,
None,
None,
None,
);
// Seed in content store so find_story_by_number can resolve it.
crate::db::ensure_content_store();
crate::db::write_item_with_content(
story_id,
"1_backlog",
"---\nname: CRDT Tombstone Check\n---\n\n# Story 9977\n",
crate::db::ItemMeta::named("CRDT Tombstone Check"),
);
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3002));
handle_delete("Timmy", story_number, project_root, &agents).await;
// The CRDT dump includes tombstoned entries — verify is_deleted = true.
let dump = crate::crdt_state::dump_crdt_state(Some(story_id));
let deleted = dump
.items
.iter()
.any(|i| i.story_id.as_deref() == Some(story_id) && i.is_deleted);
assert!(
deleted,
"CRDT must show is_deleted=true for '{story_id}' after handle_delete"
);
}
#[tokio::test]
async fn handle_delete_removes_story_file_and_confirms() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
// Seed the story in the content store + CRDT (no filesystem needed).
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"9975_story_some_feature",
"1_backlog",
"---\nname: Some Feature\n---\n\n# Story 9975\n",
crate::db::ItemMeta::named("Some Feature"),
);
let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000));
let response = handle_delete("Timmy", "9975", project_root, &agents).await;
assert!(
response.contains("Some Feature") && response.contains("backlog"),
"unexpected response: {response}"
);
assert!(
crate::db::read_content("9975_story_some_feature").is_none(),
"content store should no longer contain the deleted story"
);
}
}