huskies: merge 1141 story Convert work-item type between spike/story/bug/refactor (or at least spike→story)

This commit is contained in:
dave
2026-05-18 14:50:00 +00:00
parent 5062e008c6
commit 95c0aafb68
12 changed files with 487 additions and 7 deletions
+188
View File
@@ -0,0 +1,188 @@
//! Handler for the `convert` chat command (story 1141).
//!
//! `convert <number> <type>` changes the item-type register of a work item
//! in place. All other CRDT registers (ACs, epic, name, stage, …) are
//! untouched. Rejected for archived items.
use super::CommandContext;
/// Handle the `convert` command.
///
/// Parses `<number> <type>` from `ctx.args` and delegates to
/// [`convert_by_number`]. Returns `None` (route to LLM) when args do not
/// look like a numeric ID followed by a type keyword.
pub(super) fn handle_convert(ctx: &CommandContext) -> Option<String> {
let args = ctx.args.trim();
let (num_str, type_str) = args.split_once(char::is_whitespace)?;
let num_str = num_str.trim();
let type_str = type_str.trim();
// Route to LLM if the first token is not a bare number.
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
return None;
}
// Route to LLM if the type looks like natural language (contains spaces).
if type_str.is_empty() || type_str.contains(char::is_whitespace) {
return None;
}
Some(convert_by_number(ctx.effective_root(), num_str, type_str))
}
/// Core convert logic: find item by numeric prefix and change its type.
///
/// Returns a Markdown-formatted response suitable for all chat transports.
pub(crate) fn convert_by_number(
project_root: &std::path::Path,
story_number: &str,
new_type_str: &str,
) -> String {
let Some(new_type) = crate::io::story_metadata::ItemType::from_str(new_type_str) else {
return format!(
"Unknown type **{new_type_str}**. Accepted types: story, bug, spike, refactor, epic."
);
};
let (story_id, _, _, _) =
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
Some(found) => found,
None => {
return format!(
"No story, bug, spike, or refactor with number **{story_number}** found."
);
}
};
let item = match crate::crdt_state::read_item(&story_id) {
Some(i) => i,
None => {
return format!("Work item **{story_number}** ({story_id}) not found in CRDT.");
}
};
if matches!(item.stage(), crate::pipeline_state::Stage::Archived { .. }) {
return format!(
"Cannot convert **{story_id}**: type change on an archived item is not allowed."
);
}
let old_type = item.item_type().map(|t| t.as_str()).unwrap_or("(inferred)");
let story_name = item.name().to_string();
let new_type_s = new_type.as_str();
if !crate::crdt_state::set_item_type(&story_id, Some(new_type)) {
return format!("Failed to convert **{story_id}**: CRDT write rejected.");
}
format!("Converted **{story_name}** ({story_id}) from type `{old_type}` to `{new_type_s}`.")
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::super::{CommandDispatch, try_handle_command};
fn convert_cmd(root: &std::path::Path, args: &str) -> Option<String> {
let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy convert {args}"))
}
#[test]
fn convert_command_is_registered() {
use super::super::commands;
assert!(
commands().iter().any(|c| c.name == "convert"),
"convert command must be in the registry"
);
}
#[test]
fn convert_no_args_routes_to_llm() {
let tmp = tempfile::TempDir::new().unwrap();
let result = convert_cmd(tmp.path(), "");
assert!(result.is_none(), "no args should route to LLM: {result:?}");
}
#[test]
fn convert_natural_language_routes_to_llm() {
let tmp = tempfile::TempDir::new().unwrap();
let result = convert_cmd(tmp.path(), "the login bug to a story");
assert!(
result.is_none(),
"natural-language args should route to LLM: {result:?}"
);
}
#[test]
fn convert_well_formed_runs_handler() {
let tmp = tempfile::TempDir::new().unwrap();
let result = convert_cmd(tmp.path(), "999 story");
assert!(
result.is_some(),
"well-formed args should run the handler: {result:?}"
);
}
#[test]
fn convert_invalid_type_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
let result = convert_cmd(tmp.path(), "999 banana").unwrap();
assert!(
result.contains("Unknown type") || result.contains("banana"),
"unknown type should show error: {result}"
);
}
#[test]
fn convert_not_found_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
let result = convert_cmd(tmp.path(), "9988 story").unwrap();
assert!(
result.contains("9988") && result.contains("found"),
"not-found message should include number and 'found': {result}"
);
}
#[test]
fn convert_changes_item_type_in_crdt() {
let tmp = tempfile::TempDir::new().unwrap();
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
crate::chat::test_helpers::write_story_file(
tmp.path(),
"backlog",
"9120_spike_convert_chat.md",
"# Spike\n",
Some("Convert Chat Test"),
);
crate::crdt_state::set_item_type(
"9120_spike_convert_chat",
Some(crate::io::story_metadata::ItemType::Spike),
);
let result = convert_cmd(tmp.path(), "9120 story").unwrap();
assert!(
result.contains("story") || result.contains("Converted"),
"should confirm conversion: {result}"
);
let item =
crate::crdt_state::read_item("9120_spike_convert_chat").expect("item should exist");
assert_eq!(
item.item_type(),
Some(crate::io::story_metadata::ItemType::Story),
"item_type should be Story after conversion: {:?}",
item.item_type()
);
}
}
+6
View File
@@ -9,6 +9,7 @@ mod ambient;
mod assign;
mod backlog;
mod cleanup_worktrees;
mod convert;
mod cost;
mod coverage;
mod depends;
@@ -233,6 +234,11 @@ pub fn commands() -> &'static [BotCommand] {
description: "Schedule a deferred agent start: `timer <story_id> <HH:MM>`, `timer list`, `timer cancel <story_id>`",
handler: timer::handle_timer,
},
BotCommand {
name: "convert",
description: "Convert a work item's type: `convert <number> <type>` (types: story, bug, spike, refactor, epic)",
handler: convert::handle_convert,
},
BotCommand {
name: "unblock",
description: "Reset a blocked story: `unblock <number>` (clears blocked flag and resets retry count)",