From 69030599d3ac01ea0c41e168b9c8a17209b679f5 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 20 Mar 2026 08:36:51 +0000 Subject: [PATCH] story-kit: merge 334_story_bot_move_command_to_move_stories_between_pipeline_stages --- server/src/matrix/commands/mod.rs | 6 + server/src/matrix/commands/move_story.rs | 297 +++++++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 server/src/matrix/commands/move_story.rs diff --git a/server/src/matrix/commands/mod.rs b/server/src/matrix/commands/mod.rs index 23cdf1a..e114aeb 100644 --- a/server/src/matrix/commands/mod.rs +++ b/server/src/matrix/commands/mod.rs @@ -9,6 +9,7 @@ mod ambient; mod cost; mod git; mod help; +mod move_story; mod overview; mod show; mod status; @@ -110,6 +111,11 @@ pub fn commands() -> &'static [BotCommand] { description: "Show token spend: 24h total, top stories, breakdown by agent type, and all-time total", handler: cost::handle_cost, }, + BotCommand { + name: "move", + description: "Move a work item to a pipeline stage: `move ` (stages: backlog, current, qa, merge, done)", + handler: move_story::handle_move, + }, BotCommand { name: "show", description: "Display the full text of a work item: `show `", diff --git a/server/src/matrix/commands/move_story.rs b/server/src/matrix/commands/move_story.rs new file mode 100644 index 0000000..9c786d5 --- /dev/null +++ b/server/src/matrix/commands/move_story.rs @@ -0,0 +1,297 @@ +//! 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"]; + +/// All pipeline stage directories to search when finding a work item by number. +const SEARCH_DIRS: &[&str] = &[ + "1_backlog", + "2_current", + "3_qa", + "4_merge", + "5_done", + "6_archived", +]; + +/// Handle the `move` command. +/// +/// Parses ` ` 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 { + 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 `\n\nValid stages: {}", + ctx.bot_name, + 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 `", + ctx.bot_name + )); + } + + 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(", ") + )); + } + + // Find the story file across all pipeline stages by numeric prefix. + let mut found_story_id: Option = None; + let mut found_name: Option = None; + + 'outer: for stage_dir in SEARCH_DIRS { + let dir = ctx + .project_root + .join(".story_kit") + .join("work") + .join(stage_dir); + 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()) { + let file_num = stem + .split('_') + .next() + .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) + .unwrap_or(""); + if file_num == num_str { + found_story_id = Some(stem.to_string()); + found_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) + }); + break 'outer; + } + } + } + } + } + + let story_id = match found_story_id { + Some(id) => id, + None => { + return Some(format!( + "No story, bug, or spike with number **{num_str}** found." + )); + } + }; + + let display_name = found_name.as_deref().unwrap_or(&story_id); + + match move_story_to_stage(ctx.project_root, &story_id, &target_stage) { + 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 crate::agents::AgentPool; + use std::collections::HashSet; + use std::sync::{Arc, Mutex}; + + use super::super::{CommandDispatch, try_handle_command}; + + fn move_cmd_with_root(root: &std::path::Path, args: &str) -> Option { + let agents = Arc::new(AgentPool::new_test(3000)); + let ambient_rooms = Arc::new(Mutex::new(HashSet::new())); + let room_id = "!test:example.com".to_string(); + let dispatch = CommandDispatch { + bot_name: "Timmy", + bot_user_id: "@timmy:homeserver.local", + project_root: root, + agents: &agents, + ambient_rooms: &ambient_rooms, + room_id: &room_id, + is_addressed: true, + }; + try_handle_command(&dispatch, &format!("@timmy move {args}")) + } + + fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) { + let dir = root.join(".story_kit/work").join(stage); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(filename), content).unwrap(); + } + + #[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}" + ); + + // Verify the file was actually moved. + let new_path = tmp + .path() + .join(".story_kit/work/2_current/42_story_some_feature.md"); + assert!(new_path.exists(), "story file should be in 2_current/"); + } + + #[test] + fn move_command_case_insensitive_stage() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "2_current", + "10_story_test.md", + "---\nname: Test\n---\n", + ); + let output = move_cmd_with_root(tmp.path(), "10 BACKLOG").unwrap(); + assert!( + output.contains("Test") && output.contains("backlog"), + "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"); + } +}