huskies: merge 1141 story Convert work-item type between spike/story/bug/refactor (or at least spike→story)
This commit is contained in:
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user