story-kit: merge 304_story_mcp_tool_to_move_stories_between_pipeline_stages
This commit is contained in:
@@ -265,6 +265,78 @@ pub fn reject_story_from_qa(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move any work item to an arbitrary pipeline stage by searching all stages.
|
||||
///
|
||||
/// Accepts `target_stage` as one of: `backlog`, `current`, `qa`, `merge`, `done`.
|
||||
/// Idempotent: if the item is already in the target stage, returns Ok.
|
||||
/// Returns `(from_stage, to_stage)` on success.
|
||||
pub fn move_story_to_stage(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
target_stage: &str,
|
||||
) -> Result<(String, String), String> {
|
||||
let stage_dirs: &[(&str, &str)] = &[
|
||||
("backlog", "1_backlog"),
|
||||
("current", "2_current"),
|
||||
("qa", "3_qa"),
|
||||
("merge", "4_merge"),
|
||||
("done", "5_done"),
|
||||
];
|
||||
|
||||
let target_dir_name = stage_dirs
|
||||
.iter()
|
||||
.find(|(name, _)| *name == target_stage)
|
||||
.map(|(_, dir)| *dir)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Invalid target_stage '{target_stage}'. Must be one of: backlog, current, qa, merge, done"
|
||||
)
|
||||
})?;
|
||||
|
||||
let sk = project_root.join(".story_kit").join("work");
|
||||
let target_dir = sk.join(target_dir_name);
|
||||
let target_path = target_dir.join(format!("{story_id}.md"));
|
||||
|
||||
if target_path.exists() {
|
||||
return Ok((target_stage.to_string(), target_stage.to_string()));
|
||||
}
|
||||
|
||||
// Search all named stages plus the archive stage.
|
||||
let search_dirs: &[(&str, &str)] = &[
|
||||
("backlog", "1_backlog"),
|
||||
("current", "2_current"),
|
||||
("qa", "3_qa"),
|
||||
("merge", "4_merge"),
|
||||
("done", "5_done"),
|
||||
("archived", "6_archived"),
|
||||
];
|
||||
|
||||
let mut found_path: Option<std::path::PathBuf> = None;
|
||||
let mut from_stage = "";
|
||||
for (stage_name, dir_name) in search_dirs {
|
||||
let candidate = sk.join(dir_name).join(format!("{story_id}.md"));
|
||||
if candidate.exists() {
|
||||
found_path = Some(candidate);
|
||||
from_stage = stage_name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let source_path =
|
||||
found_path.ok_or_else(|| format!("Work item '{story_id}' not found in any pipeline stage."))?;
|
||||
|
||||
std::fs::create_dir_all(&target_dir)
|
||||
.map_err(|e| format!("Failed to create work/{target_dir_name}/ directory: {e}"))?;
|
||||
std::fs::rename(&source_path, &target_path)
|
||||
.map_err(|e| format!("Failed to move '{story_id}' to work/{target_dir_name}/: {e}"))?;
|
||||
|
||||
slog!(
|
||||
"[lifecycle] Moved '{story_id}' from work/{from_stage}/ to work/{target_dir_name}/"
|
||||
);
|
||||
|
||||
Ok((from_stage.to_string(), target_stage.to_string()))
|
||||
}
|
||||
|
||||
/// Move a bug from `work/2_current/` or `work/1_backlog/` to `work/5_done/` and auto-commit.
|
||||
///
|
||||
/// * If the bug is in `2_current/`, it is moved to `5_done/` and committed.
|
||||
@@ -645,4 +717,95 @@ mod tests {
|
||||
reject_story_from_qa(root, "51_story_test", "notes").unwrap();
|
||||
assert!(current_dir.join("51_story_test.md").exists());
|
||||
}
|
||||
|
||||
// ── move_story_to_stage tests ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn move_story_to_stage_moves_from_backlog_to_current() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let backlog = root.join(".story_kit/work/1_backlog");
|
||||
let current = root.join(".story_kit/work/2_current");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(backlog.join("60_story_move.md"), "test").unwrap();
|
||||
|
||||
let (from, to) = move_story_to_stage(root, "60_story_move", "current").unwrap();
|
||||
|
||||
assert_eq!(from, "backlog");
|
||||
assert_eq!(to, "current");
|
||||
assert!(!backlog.join("60_story_move.md").exists());
|
||||
assert!(current.join("60_story_move.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_stage_moves_from_current_to_backlog() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let current = root.join(".story_kit/work/2_current");
|
||||
let backlog = root.join(".story_kit/work/1_backlog");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(current.join("61_story_back.md"), "test").unwrap();
|
||||
|
||||
let (from, to) = move_story_to_stage(root, "61_story_back", "backlog").unwrap();
|
||||
|
||||
assert_eq!(from, "current");
|
||||
assert_eq!(to, "backlog");
|
||||
assert!(!current.join("61_story_back.md").exists());
|
||||
assert!(backlog.join("61_story_back.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_stage_idempotent_when_already_in_target() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let current = root.join(".story_kit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(current.join("62_story_idem.md"), "test").unwrap();
|
||||
|
||||
let (from, to) = move_story_to_stage(root, "62_story_idem", "current").unwrap();
|
||||
|
||||
assert_eq!(from, "current");
|
||||
assert_eq!(to, "current");
|
||||
assert!(current.join("62_story_idem.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_stage_invalid_target_returns_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = move_story_to_stage(tmp.path(), "1_story_test", "invalid");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid target_stage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_stage_not_found_returns_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = move_story_to_stage(tmp.path(), "99_story_ghost", "current");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found in any pipeline stage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_stage_finds_in_qa_dir() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let qa_dir = root.join(".story_kit/work/3_qa");
|
||||
let backlog = root.join(".story_kit/work/1_backlog");
|
||||
fs::create_dir_all(&qa_dir).unwrap();
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(qa_dir.join("63_story_qa.md"), "test").unwrap();
|
||||
|
||||
let (from, to) = move_story_to_stage(root, "63_story_qa", "backlog").unwrap();
|
||||
|
||||
assert_eq!(from, "qa");
|
||||
assert_eq!(to, "backlog");
|
||||
assert!(!qa_dir.join("63_story_qa.md").exists());
|
||||
assert!(backlog.join("63_story_qa.md").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use lifecycle::{
|
||||
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_archived,
|
||||
move_story_to_merge, move_story_to_qa, reject_story_from_qa,
|
||||
move_story_to_merge, move_story_to_qa, move_story_to_stage, reject_story_from_qa,
|
||||
};
|
||||
pub use pool::AgentPool;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user