2026-03-22 19:07:07 +00:00
|
|
|
//! Handler for the `move` command.
|
|
|
|
|
//!
|
|
|
|
|
//! `{bot_name} move {number} {stage}` finds the work item by number across all
|
|
|
|
|
//! pipeline stages, moves it to the specified stage, and returns a confirmation
|
|
|
|
|
//! with the story title, old stage, and new stage.
|
|
|
|
|
|
|
|
|
|
use super::CommandContext;
|
|
|
|
|
use crate::agents::move_story_to_stage;
|
|
|
|
|
|
|
|
|
|
/// Valid stage names accepted by the move command.
|
|
|
|
|
const VALID_STAGES: &[&str] = &["backlog", "current", "qa", "merge", "done"];
|
|
|
|
|
|
|
|
|
|
/// Handle the `move` command.
|
|
|
|
|
///
|
|
|
|
|
/// Parses `<number> <stage>` from `ctx.args`, locates the work item by its
|
|
|
|
|
/// numeric prefix, moves it to the target stage using the shared lifecycle
|
|
|
|
|
/// function, and returns a Markdown confirmation string.
|
|
|
|
|
pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
|
|
|
|
|
let args = ctx.args.trim();
|
|
|
|
|
|
|
|
|
|
// Parse `number stage` from args.
|
|
|
|
|
let (num_str, stage_raw) = match args.split_once(char::is_whitespace) {
|
|
|
|
|
Some((n, s)) => (n.trim(), s.trim()),
|
|
|
|
|
None => {
|
|
|
|
|
return Some(format!(
|
|
|
|
|
"Usage: `{} move <number> <stage>`\n\nValid stages: {}",
|
2026-04-25 20:37:10 +00:00
|
|
|
ctx.services.bot_name,
|
2026-03-22 19:07:07 +00:00
|
|
|
VALID_STAGES.join(", ")
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
|
|
|
|
return Some(format!(
|
|
|
|
|
"Invalid story number: `{num_str}`. Usage: `{} move <number> <stage>`",
|
2026-04-25 20:37:10 +00:00
|
|
|
ctx.services.bot_name
|
2026-03-22 19:07:07 +00:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let target_stage = stage_raw.to_ascii_lowercase();
|
|
|
|
|
if !VALID_STAGES.contains(&target_stage.as_str()) {
|
|
|
|
|
return Some(format!(
|
|
|
|
|
"Invalid stage: `{stage_raw}`. Valid stages: {}",
|
|
|
|
|
VALID_STAGES.join(", ")
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 23:00:01 +00:00
|
|
|
// Find the story by numeric prefix: CRDT → content store → filesystem.
|
2026-05-12 18:41:43 +01:00
|
|
|
let (story_id, _stage_dir, _path, _content) =
|
2026-04-25 20:37:10 +00:00
|
|
|
match crate::chat::lookup::find_story_by_number(ctx.effective_root(), num_str) {
|
2026-04-09 23:00:01 +00:00
|
|
|
Some(found) => found,
|
|
|
|
|
None => {
|
|
|
|
|
return Some(format!(
|
|
|
|
|
"No story, bug, or spike with number **{num_str}** found."
|
|
|
|
|
));
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
2026-04-09 23:00:01 +00:00
|
|
|
};
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-05-12 18:41:43 +01:00
|
|
|
// Display name comes from the CRDT name register (story 929).
|
|
|
|
|
let found_name =
|
|
|
|
|
crate::crdt_state::read_item(&story_id).and_then(|w| w.name().map(str::to_string));
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
let display_name = found_name.as_deref().unwrap_or(&story_id);
|
|
|
|
|
|
2026-04-27 19:51:27 +00:00
|
|
|
match move_story_to_stage(&story_id, &target_stage) {
|
2026-03-22 19:07:07 +00:00
|
|
|
Ok((from_stage, to_stage)) => Some(format!(
|
|
|
|
|
"Moved **{display_name}** from **{from_stage}** to **{to_stage}**."
|
|
|
|
|
)),
|
|
|
|
|
Err(e) => Some(format!("Failed to move story {num_str}: {e}")),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Tests
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::super::{CommandDispatch, try_handle_command};
|
|
|
|
|
|
|
|
|
|
fn move_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
|
2026-04-25 20:37:10 +00:00
|
|
|
let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
|
2026-03-22 19:07:07 +00:00
|
|
|
let room_id = "!test:example.com".to_string();
|
|
|
|
|
let dispatch = CommandDispatch {
|
2026-04-25 20:37:10 +00:00
|
|
|
services: &services,
|
|
|
|
|
project_root: &services.project_root,
|
2026-03-22 19:07:07 +00:00
|
|
|
bot_user_id: "@timmy:homeserver.local",
|
|
|
|
|
room_id: &room_id,
|
|
|
|
|
};
|
|
|
|
|
try_handle_command(&dispatch, &format!("@timmy move {args}"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 19:47:59 +00:00
|
|
|
use crate::chat::test_helpers::write_story_file;
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_command_is_registered() {
|
|
|
|
|
use super::super::commands;
|
|
|
|
|
let found = commands().iter().any(|c| c.name == "move");
|
|
|
|
|
assert!(found, "move command must be in the registry");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_command_appears_in_help() {
|
|
|
|
|
let result = super::super::tests::try_cmd_addressed(
|
|
|
|
|
"Timmy",
|
|
|
|
|
"@timmy:homeserver.local",
|
|
|
|
|
"@timmy help",
|
|
|
|
|
);
|
|
|
|
|
let output = result.unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("move"),
|
|
|
|
|
"help should list move command: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_command_no_args_returns_usage() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let output = move_cmd_with_root(tmp.path(), "").unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("Usage"),
|
|
|
|
|
"no args should show usage hint: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_command_missing_stage_returns_usage() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let output = move_cmd_with_root(tmp.path(), "42").unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("Usage"),
|
|
|
|
|
"missing stage should show usage hint: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_command_invalid_stage_returns_error() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let output = move_cmd_with_root(tmp.path(), "42 invalid_stage").unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("Invalid stage"),
|
|
|
|
|
"invalid stage should return error: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_command_non_numeric_number_returns_error() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let output = move_cmd_with_root(tmp.path(), "abc current").unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("Invalid story number"),
|
|
|
|
|
"non-numeric number should return error: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_command_not_found_returns_friendly_message() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let output = move_cmd_with_root(tmp.path(), "999 current").unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("999") && output.contains("found"),
|
|
|
|
|
"not-found message should include number and 'found': {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_command_moves_story_and_confirms() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
write_story_file(
|
|
|
|
|
tmp.path(),
|
|
|
|
|
"1_backlog",
|
|
|
|
|
"42_story_some_feature.md",
|
|
|
|
|
"---\nname: Some Feature\n---\n\n# Story 42\n",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let output = move_cmd_with_root(tmp.path(), "42 current").unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("Some Feature"),
|
|
|
|
|
"confirmation should include story name: {output}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("backlog"),
|
|
|
|
|
"confirmation should include old stage: {output}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("current"),
|
|
|
|
|
"confirmation should include new stage: {output}"
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-10 12:56:16 +00:00
|
|
|
// Verify the story is still accessible in the content store after the move.
|
|
|
|
|
assert!(
|
|
|
|
|
crate::db::read_content("42_story_some_feature").is_some(),
|
|
|
|
|
"story should be in the content store after move"
|
|
|
|
|
);
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_command_case_insensitive_stage() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
write_story_file(
|
|
|
|
|
tmp.path(),
|
|
|
|
|
"2_current",
|
2026-04-10 14:56:13 +00:00
|
|
|
"8810_story_case_test.md",
|
|
|
|
|
"---\nname: CaseTest\n---\n",
|
2026-03-22 19:07:07 +00:00
|
|
|
);
|
2026-04-10 14:56:13 +00:00
|
|
|
let output = move_cmd_with_root(tmp.path(), "8810 BACKLOG").unwrap();
|
2026-03-22 19:07:07 +00:00
|
|
|
assert!(
|
2026-04-10 14:56:13 +00:00
|
|
|
output.contains("CaseTest") && output.contains("backlog"),
|
2026-03-22 19:07:07 +00:00
|
|
|
"stage matching should be case-insensitive: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_command_idempotent_when_already_in_target() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
write_story_file(
|
|
|
|
|
tmp.path(),
|
|
|
|
|
"2_current",
|
|
|
|
|
"5_story_already_current.md",
|
|
|
|
|
"---\nname: Already Current\n---\n",
|
|
|
|
|
);
|
|
|
|
|
// Moving to the stage it's already in should return a success message.
|
|
|
|
|
let output = move_cmd_with_root(tmp.path(), "5 current").unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("Moved") || output.contains("current"),
|
|
|
|
|
"idempotent move should succeed: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_command_case_insensitive_command() {
|
|
|
|
|
let result = super::super::tests::try_cmd_addressed(
|
|
|
|
|
"Timmy",
|
|
|
|
|
"@timmy:homeserver.local",
|
|
|
|
|
"@timmy MOVE 1 backlog",
|
|
|
|
|
);
|
|
|
|
|
// Returns Some (the registry matched, regardless of result content)
|
|
|
|
|
assert!(result.is_some(), "MOVE should match case-insensitively");
|
|
|
|
|
}
|
|
|
|
|
}
|