Story 46: Deterministic Story Mutations with Auto-Commit
- Add git_stage_and_commit() helper for deterministic commits - move_story_to_current() auto-commits on start_agent - accept_story auto-commits move to archived/ - New MCP tools: check_criterion, set_test_plan (total: 21) - create_story MCP always auto-commits - Tests for check_criterion and set_test_plan Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -203,6 +203,9 @@ impl AgentPool {
|
|||||||
status: "pending".to_string(),
|
status: "pending".to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Move story from upcoming/ to current/ and auto-commit before creating the worktree.
|
||||||
|
move_story_to_current(project_root, story_id)?;
|
||||||
|
|
||||||
// Create worktree
|
// Create worktree
|
||||||
let wt_info = worktree::create_worktree(project_root, story_id, &config, self.port).await?;
|
let wt_info = worktree::create_worktree(project_root, story_id, &config, self.port).await?;
|
||||||
|
|
||||||
@@ -578,6 +581,7 @@ impl AgentPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return the port this server is running on.
|
/// Return the port this server is running on.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn port(&self) -> u16 {
|
pub fn port(&self) -> u16 {
|
||||||
self.port
|
self.port
|
||||||
}
|
}
|
||||||
@@ -652,9 +656,84 @@ impl AgentPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move a story file from current/ to archived/ (human accept action).
|
/// Stage one or more file paths and create a deterministic commit in the given git root.
|
||||||
///
|
///
|
||||||
/// * If the story is in current/, it is renamed to archived/.
|
/// Pass deleted paths too so git stages their removal alongside any new files.
|
||||||
|
pub fn git_stage_and_commit(
|
||||||
|
git_root: &Path,
|
||||||
|
paths: &[&Path],
|
||||||
|
message: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut add_cmd = Command::new("git");
|
||||||
|
add_cmd.arg("add").current_dir(git_root);
|
||||||
|
for path in paths {
|
||||||
|
add_cmd.arg(path.to_string_lossy().as_ref());
|
||||||
|
}
|
||||||
|
let output = add_cmd.output().map_err(|e| format!("git add: {e}"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"git add failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["commit", "-m", message])
|
||||||
|
.current_dir(git_root)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("git commit: {e}"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"git commit failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move a story file from upcoming/ to current/ and auto-commit to master.
|
||||||
|
///
|
||||||
|
/// Idempotent: if the story is already in current/, returns Ok without committing.
|
||||||
|
/// If the story is not found in upcoming/, logs a warning and returns Ok (e.g. if
|
||||||
|
/// the user moved it manually before calling start_agent).
|
||||||
|
pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), String> {
|
||||||
|
let stories_dir = project_root.join(".story_kit").join("stories");
|
||||||
|
let upcoming_path = stories_dir.join("upcoming").join(format!("{story_id}.md"));
|
||||||
|
let current_path = stories_dir.join("current").join(format!("{story_id}.md"));
|
||||||
|
|
||||||
|
if current_path.exists() {
|
||||||
|
// Already in current/ — idempotent, nothing to do.
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !upcoming_path.exists() {
|
||||||
|
eprintln!(
|
||||||
|
"[lifecycle] Story '{story_id}' not found in upcoming/; skipping move to current/"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_dir = stories_dir.join("current");
|
||||||
|
std::fs::create_dir_all(¤t_dir)
|
||||||
|
.map_err(|e| format!("Failed to create current stories directory: {e}"))?;
|
||||||
|
|
||||||
|
std::fs::rename(&upcoming_path, ¤t_path)
|
||||||
|
.map_err(|e| format!("Failed to move story '{story_id}' to current/: {e}"))?;
|
||||||
|
|
||||||
|
eprintln!("[lifecycle] Moved story '{story_id}' from upcoming/ to current/");
|
||||||
|
|
||||||
|
let msg = format!("story-kit: start story {story_id}");
|
||||||
|
git_stage_and_commit(
|
||||||
|
project_root,
|
||||||
|
&[current_path.as_path(), upcoming_path.as_path()],
|
||||||
|
&msg,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move a story file from current/ to archived/ (human accept action) and auto-commit.
|
||||||
|
///
|
||||||
|
/// * If the story is in current/, it is renamed to archived/ and committed.
|
||||||
/// * If the story is already in archived/, this is a no-op (idempotent).
|
/// * If the story is already in archived/, this is a no-op (idempotent).
|
||||||
/// * If the story is not found in current/ or archived/, an error is returned.
|
/// * If the story is not found in current/ or archived/, an error is returned.
|
||||||
pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(), String> {
|
pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(), String> {
|
||||||
@@ -674,6 +753,13 @@ pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(),
|
|||||||
std::fs::rename(¤t_path, &archived_path)
|
std::fs::rename(¤t_path, &archived_path)
|
||||||
.map_err(|e| format!("Failed to move story '{story_id}' to archived/: {e}"))?;
|
.map_err(|e| format!("Failed to move story '{story_id}' to archived/: {e}"))?;
|
||||||
eprintln!("[lifecycle] Moved story '{story_id}' from current/ to archived/");
|
eprintln!("[lifecycle] Moved story '{story_id}' from current/ to archived/");
|
||||||
|
|
||||||
|
let msg = format!("story-kit: accept story {story_id}");
|
||||||
|
git_stage_and_commit(
|
||||||
|
project_root,
|
||||||
|
&[archived_path.as_path(), current_path.as_path()],
|
||||||
|
&msg,
|
||||||
|
)?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1110,4 +1196,128 @@ mod tests {
|
|||||||
"expected 'uncommitted' in: {msg}"
|
"expected 'uncommitted' in: {msg}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── move_story_to_current tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn init_git_repo(repo: &std::path::Path) {
|
||||||
|
Command::new("git")
|
||||||
|
.args(["init"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["config", "user.email", "test@test.com"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["config", "user.name", "Test"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "--allow-empty", "-m", "init"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_story_to_current_moves_file_and_commits() {
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_git_repo(repo);
|
||||||
|
|
||||||
|
let upcoming = repo.join(".story_kit/stories/upcoming");
|
||||||
|
let current = repo.join(".story_kit/stories/current");
|
||||||
|
fs::create_dir_all(&upcoming).unwrap();
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
|
||||||
|
let story_file = upcoming.join("10_my_story.md");
|
||||||
|
fs::write(&story_file, "---\nname: Test\ntest_plan: pending\n---\n").unwrap();
|
||||||
|
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "add story"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
move_story_to_current(repo, "10_my_story").unwrap();
|
||||||
|
|
||||||
|
assert!(!story_file.exists(), "upcoming file should be gone");
|
||||||
|
assert!(
|
||||||
|
current.join("10_my_story.md").exists(),
|
||||||
|
"current file should exist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_story_to_current_is_idempotent_when_already_current() {
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_git_repo(repo);
|
||||||
|
|
||||||
|
let current = repo.join(".story_kit/stories/current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
fs::write(
|
||||||
|
current.join("11_my_story.md"),
|
||||||
|
"---\nname: Test\ntest_plan: pending\n---\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Should succeed without error even though there's nothing to move
|
||||||
|
move_story_to_current(repo, "11_my_story").unwrap();
|
||||||
|
|
||||||
|
assert!(current.join("11_my_story.md").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_story_to_current_noop_when_not_in_upcoming() {
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_git_repo(repo);
|
||||||
|
|
||||||
|
// Story doesn't exist anywhere — should return Ok (lenient)
|
||||||
|
let result = move_story_to_current(repo, "99_missing");
|
||||||
|
assert!(result.is_ok(), "should return Ok when story is not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── git_stage_and_commit tests ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_stage_and_commit_creates_commit() {
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_git_repo(repo);
|
||||||
|
|
||||||
|
let file = repo.join("hello.txt");
|
||||||
|
fs::write(&file, "hello").unwrap();
|
||||||
|
|
||||||
|
git_stage_and_commit(repo, &[file.as_path()], "story-kit: test commit").unwrap();
|
||||||
|
|
||||||
|
// Verify the commit exists
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["log", "--oneline", "-1"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let log = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(log.contains("story-kit: test commit"), "commit should appear in log: {log}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ use crate::agents::move_story_to_archived;
|
|||||||
use crate::config::ProjectConfig;
|
use crate::config::ProjectConfig;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
use crate::http::settings::get_editor_command_from_store;
|
use crate::http::settings::get_editor_command_from_store;
|
||||||
use crate::http::workflow::{create_story_file, load_upcoming_stories, validate_story_dirs};
|
use crate::http::workflow::{
|
||||||
|
check_criterion_in_file, create_story_file, load_upcoming_stories, set_test_plan_in_file,
|
||||||
|
validate_story_dirs,
|
||||||
|
};
|
||||||
use crate::worktree;
|
use crate::worktree;
|
||||||
use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos};
|
use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos};
|
||||||
use crate::workflow::{evaluate_acceptance_with_coverage, TestCaseResult, TestStatus};
|
use crate::workflow::{evaluate_acceptance_with_coverage, TestCaseResult, TestStatus};
|
||||||
@@ -603,7 +606,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "accept_story",
|
"name": "accept_story",
|
||||||
"description": "Accept a story: moves it from current/ to archived/.",
|
"description": "Accept a story: moves it from current/ to archived/ and auto-commits to master.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -614,6 +617,42 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
|||||||
},
|
},
|
||||||
"required": ["story_id"]
|
"required": ["story_id"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "check_criterion",
|
||||||
|
"description": "Check off an acceptance criterion (- [ ] → - [x]) by 0-based index among unchecked items, then auto-commit to master. Use get_story_todos to see the current list of unchecked criteria.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"story_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Story identifier (filename stem, e.g. '28_my_story')"
|
||||||
|
},
|
||||||
|
"criterion_index": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "0-based index of the unchecked criterion to check off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["story_id", "criterion_index"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "set_test_plan",
|
||||||
|
"description": "Update the test_plan front-matter field of a story file and auto-commit to master. Common values: 'pending', 'approved'.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"story_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Story identifier (filename stem, e.g. '28_my_story')"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "New value for the test_plan field (e.g. 'approved', 'pending')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["story_id", "status"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
@@ -659,6 +698,9 @@ async fn handle_tools_call(
|
|||||||
"report_completion" => tool_report_completion(&args, ctx).await,
|
"report_completion" => tool_report_completion(&args, ctx).await,
|
||||||
// Lifecycle tools
|
// Lifecycle tools
|
||||||
"accept_story" => tool_accept_story(&args, ctx),
|
"accept_story" => tool_accept_story(&args, ctx),
|
||||||
|
// Story mutation tools (auto-commit to master)
|
||||||
|
"check_criterion" => tool_check_criterion(&args, ctx),
|
||||||
|
"set_test_plan" => tool_set_test_plan(&args, ctx),
|
||||||
_ => Err(format!("Unknown tool: {tool_name}")),
|
_ => Err(format!("Unknown tool: {tool_name}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -690,10 +732,8 @@ fn tool_create_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
|||||||
let acceptance_criteria: Option<Vec<String>> = args
|
let acceptance_criteria: Option<Vec<String>> = args
|
||||||
.get("acceptance_criteria")
|
.get("acceptance_criteria")
|
||||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||||
let commit = args
|
// MCP tool always auto-commits the new story file to master.
|
||||||
.get("commit")
|
let commit = true;
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
let root = ctx.state.get_project_root()?;
|
let root = ctx.state.get_project_root()?;
|
||||||
let story_id = create_story_file(
|
let story_id = create_story_file(
|
||||||
@@ -1067,7 +1107,45 @@ fn tool_accept_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
|||||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||||
move_story_to_archived(&project_root, story_id)?;
|
move_story_to_archived(&project_root, story_id)?;
|
||||||
|
|
||||||
Ok(format!("Story '{story_id}' accepted and moved to archived/."))
|
Ok(format!(
|
||||||
|
"Story '{story_id}' accepted, moved to archived/, and committed to master."
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_check_criterion(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||||
|
let story_id = args
|
||||||
|
.get("story_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("Missing required argument: story_id")?;
|
||||||
|
let criterion_index = args
|
||||||
|
.get("criterion_index")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.ok_or("Missing required argument: criterion_index")? as usize;
|
||||||
|
|
||||||
|
let root = ctx.state.get_project_root()?;
|
||||||
|
check_criterion_in_file(&root, story_id, criterion_index)?;
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"Criterion {criterion_index} checked for story '{story_id}'. Committed to master."
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_set_test_plan(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||||
|
let story_id = args
|
||||||
|
.get("story_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("Missing required argument: story_id")?;
|
||||||
|
let status = args
|
||||||
|
.get("status")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("Missing required argument: status")?;
|
||||||
|
|
||||||
|
let root = ctx.state.get_project_root()?;
|
||||||
|
set_test_plan_in_file(&root, story_id, status)?;
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"test_plan set to '{status}' for story '{story_id}'. Committed to master."
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run `git log <base>..HEAD --oneline` in the worktree and return the commit
|
/// Run `git log <base>..HEAD --oneline` in the worktree and return the commit
|
||||||
@@ -1222,7 +1300,9 @@ mod tests {
|
|||||||
assert!(names.contains(&"get_editor_command"));
|
assert!(names.contains(&"get_editor_command"));
|
||||||
assert!(names.contains(&"report_completion"));
|
assert!(names.contains(&"report_completion"));
|
||||||
assert!(names.contains(&"accept_story"));
|
assert!(names.contains(&"accept_story"));
|
||||||
assert_eq!(tools.len(), 19);
|
assert!(names.contains(&"check_criterion"));
|
||||||
|
assert!(names.contains(&"set_test_plan"));
|
||||||
|
assert_eq!(tools.len(), 21);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1253,11 +1333,32 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn tool_create_story_and_list_upcoming() {
|
fn tool_create_story_and_list_upcoming() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
// The MCP tool always commits, so we need a real git repo.
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["init"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["config", "user.email", "test@test.com"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["config", "user.name", "Test"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["commit", "--allow-empty", "-m", "init"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
|
|
||||||
// Create a story
|
// Create a story (always auto-commits in MCP handler)
|
||||||
let result = tool_create_story(
|
let result = tool_create_story(
|
||||||
&json!({"name": "Test Story", "acceptance_criteria": ["AC1", "AC2"], "commit": false}),
|
&json!({"name": "Test Story", "acceptance_criteria": ["AC1", "AC2"]}),
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::agents::git_stage_and_commit;
|
||||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||||
use crate::io::story_metadata::{StoryMetadata, parse_front_matter, parse_unchecked_todos};
|
use crate::io::story_metadata::{StoryMetadata, parse_front_matter, parse_unchecked_todos};
|
||||||
use crate::workflow::{
|
use crate::workflow::{
|
||||||
@@ -8,7 +9,7 @@ use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::process::Command;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[derive(Tags)]
|
#[derive(Tags)]
|
||||||
@@ -668,40 +669,136 @@ pub fn create_story_file(
|
|||||||
.map_err(|e| format!("Failed to write story file: {e}"))?;
|
.map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||||
|
|
||||||
if commit {
|
if commit {
|
||||||
git_commit_story_file(root, &filepath, name)?;
|
git_commit_story_file(root, &filepath, &story_id)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(story_id)
|
Ok(story_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Git-add and git-commit a newly created story file to the current branch.
|
/// Git-add and git-commit a newly created story file using a deterministic message.
|
||||||
fn git_commit_story_file(
|
fn git_commit_story_file(root: &Path, filepath: &Path, story_id: &str) -> Result<(), String> {
|
||||||
root: &std::path::Path,
|
let msg = format!("story-kit: create story {story_id}");
|
||||||
filepath: &std::path::Path,
|
git_stage_and_commit(root, &[filepath], &msg)
|
||||||
name: &str,
|
}
|
||||||
|
|
||||||
|
/// Locate a story file by searching current/ then upcoming/.
|
||||||
|
fn find_story_file(project_root: &Path, story_id: &str) -> Result<PathBuf, String> {
|
||||||
|
let base = project_root.join(".story_kit").join("stories");
|
||||||
|
let filename = format!("{story_id}.md");
|
||||||
|
for subdir in &["current", "upcoming"] {
|
||||||
|
let path = base.join(subdir).join(&filename);
|
||||||
|
if path.exists() {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(format!(
|
||||||
|
"Story '{story_id}' not found in current/ or upcoming/."
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check off the Nth unchecked acceptance criterion in a story file and auto-commit.
|
||||||
|
///
|
||||||
|
/// `criterion_index` is 0-based among unchecked (`- [ ]`) items.
|
||||||
|
pub fn check_criterion_in_file(
|
||||||
|
project_root: &Path,
|
||||||
|
story_id: &str,
|
||||||
|
criterion_index: usize,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let output = Command::new("git")
|
let filepath = find_story_file(project_root, story_id)?;
|
||||||
.args(["add", &filepath.to_string_lossy()])
|
let contents = fs::read_to_string(&filepath)
|
||||||
.current_dir(root)
|
.map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||||
.output()
|
|
||||||
.map_err(|e| format!("git add: {e}"))?;
|
let mut unchecked_count: usize = 0;
|
||||||
if !output.status.success() {
|
let mut found = false;
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let new_lines: Vec<String> = contents
|
||||||
return Err(format!("git add failed: {stderr}"));
|
.lines()
|
||||||
|
.map(|line| {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if let Some(rest) = trimmed.strip_prefix("- [ ] ") {
|
||||||
|
if unchecked_count == criterion_index {
|
||||||
|
unchecked_count += 1;
|
||||||
|
found = true;
|
||||||
|
let indent_len = line.len() - trimmed.len();
|
||||||
|
let indent = &line[..indent_len];
|
||||||
|
return format!("{indent}- [x] {rest}");
|
||||||
|
}
|
||||||
|
unchecked_count += 1;
|
||||||
|
}
|
||||||
|
line.to_string()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return Err(format!(
|
||||||
|
"Criterion index {criterion_index} out of range. Story '{story_id}' has \
|
||||||
|
{unchecked_count} unchecked criteria (indices 0..{}).",
|
||||||
|
unchecked_count.saturating_sub(1)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let msg = format!("Add story: {name}");
|
let mut new_str = new_lines.join("\n");
|
||||||
let output = Command::new("git")
|
if contents.ends_with('\n') {
|
||||||
.args(["commit", "-m", &msg])
|
new_str.push('\n');
|
||||||
.current_dir(root)
|
}
|
||||||
.output()
|
fs::write(&filepath, &new_str)
|
||||||
.map_err(|e| format!("git commit: {e}"))?;
|
.map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let msg = format!("story-kit: check criterion {criterion_index} for story {story_id}");
|
||||||
return Err(format!("git commit failed: {stderr}"));
|
git_stage_and_commit(project_root, &[filepath.as_path()], &msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
/// Update the `test_plan` front-matter field in a story file and auto-commit.
|
||||||
|
pub fn set_test_plan_in_file(
|
||||||
|
project_root: &Path,
|
||||||
|
story_id: &str,
|
||||||
|
status: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let filepath = find_story_file(project_root, story_id)?;
|
||||||
|
let contents = fs::read_to_string(&filepath)
|
||||||
|
.map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||||
|
|
||||||
|
let mut in_front_matter = false;
|
||||||
|
let mut front_matter_started = false;
|
||||||
|
let mut found = false;
|
||||||
|
|
||||||
|
let new_lines: Vec<String> = contents
|
||||||
|
.lines()
|
||||||
|
.map(|line| {
|
||||||
|
if line.trim() == "---" {
|
||||||
|
if !front_matter_started {
|
||||||
|
front_matter_started = true;
|
||||||
|
in_front_matter = true;
|
||||||
|
} else if in_front_matter {
|
||||||
|
in_front_matter = false;
|
||||||
|
}
|
||||||
|
return line.to_string();
|
||||||
|
}
|
||||||
|
if in_front_matter {
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
if trimmed.starts_with("test_plan:") {
|
||||||
|
found = true;
|
||||||
|
return format!("test_plan: {status}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line.to_string()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return Err(format!(
|
||||||
|
"Story '{story_id}' does not have a 'test_plan' field in its front matter."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut new_str = new_lines.join("\n");
|
||||||
|
if contents.ends_with('\n') {
|
||||||
|
new_str.push('\n');
|
||||||
|
}
|
||||||
|
fs::write(&filepath, &new_str)
|
||||||
|
.map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||||
|
|
||||||
|
let msg = format!("story-kit: set test_plan to {status} for story {story_id}");
|
||||||
|
git_stage_and_commit(project_root, &[filepath.as_path()], &msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn slugify_name(name: &str) -> String {
|
fn slugify_name(name: &str) -> String {
|
||||||
@@ -1201,4 +1298,202 @@ mod tests {
|
|||||||
// Simulate the check
|
// Simulate the check
|
||||||
assert!(filepath.exists());
|
assert!(filepath.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── check_criterion_in_file tests ─────────────────────────────────────────
|
||||||
|
|
||||||
|
fn setup_git_repo(root: &std::path::Path) {
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["init"])
|
||||||
|
.current_dir(root)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["config", "user.email", "test@test.com"])
|
||||||
|
.current_dir(root)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["config", "user.name", "Test"])
|
||||||
|
.current_dir(root)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["commit", "--allow-empty", "-m", "init"])
|
||||||
|
.current_dir(root)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn story_with_criteria(n: usize) -> String {
|
||||||
|
let mut s = "---\nname: Test Story\ntest_plan: pending\n---\n\n## Acceptance Criteria\n\n".to_string();
|
||||||
|
for i in 0..n {
|
||||||
|
s.push_str(&format!("- [ ] Criterion {i}\n"));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_criterion_marks_first_unchecked() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
setup_git_repo(tmp.path());
|
||||||
|
let current = tmp.path().join(".story_kit/stories/current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
let filepath = current.join("1_test.md");
|
||||||
|
fs::write(&filepath, story_with_criteria(3)).unwrap();
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["commit", "-m", "add story"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
check_criterion_in_file(tmp.path(), "1_test", 0).unwrap();
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(&filepath).unwrap();
|
||||||
|
assert!(contents.contains("- [x] Criterion 0"), "first should be checked");
|
||||||
|
assert!(contents.contains("- [ ] Criterion 1"), "second should stay unchecked");
|
||||||
|
assert!(contents.contains("- [ ] Criterion 2"), "third should stay unchecked");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_criterion_marks_second_unchecked() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
setup_git_repo(tmp.path());
|
||||||
|
let current = tmp.path().join(".story_kit/stories/current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
let filepath = current.join("2_test.md");
|
||||||
|
fs::write(&filepath, story_with_criteria(3)).unwrap();
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["commit", "-m", "add story"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
check_criterion_in_file(tmp.path(), "2_test", 1).unwrap();
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(&filepath).unwrap();
|
||||||
|
assert!(contents.contains("- [ ] Criterion 0"), "first should stay unchecked");
|
||||||
|
assert!(contents.contains("- [x] Criterion 1"), "second should be checked");
|
||||||
|
assert!(contents.contains("- [ ] Criterion 2"), "third should stay unchecked");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_criterion_out_of_range_returns_error() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
setup_git_repo(tmp.path());
|
||||||
|
let current = tmp.path().join(".story_kit/stories/current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
let filepath = current.join("3_test.md");
|
||||||
|
fs::write(&filepath, story_with_criteria(2)).unwrap();
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["commit", "-m", "add story"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = check_criterion_in_file(tmp.path(), "3_test", 5);
|
||||||
|
assert!(result.is_err(), "should fail for out-of-range index");
|
||||||
|
assert!(result.unwrap_err().contains("out of range"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── set_test_plan_in_file tests ───────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_test_plan_updates_pending_to_approved() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
setup_git_repo(tmp.path());
|
||||||
|
let current = tmp.path().join(".story_kit/stories/current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
let filepath = current.join("4_test.md");
|
||||||
|
fs::write(
|
||||||
|
&filepath,
|
||||||
|
"---\nname: Test Story\ntest_plan: pending\n---\n\n## Body\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["commit", "-m", "add story"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
set_test_plan_in_file(tmp.path(), "4_test", "approved").unwrap();
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(&filepath).unwrap();
|
||||||
|
assert!(contents.contains("test_plan: approved"), "should be updated to approved");
|
||||||
|
assert!(!contents.contains("test_plan: pending"), "old value should be replaced");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_test_plan_missing_field_returns_error() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
setup_git_repo(tmp.path());
|
||||||
|
let current = tmp.path().join(".story_kit/stories/current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
let filepath = current.join("5_test.md");
|
||||||
|
fs::write(
|
||||||
|
&filepath,
|
||||||
|
"---\nname: Test Story\n---\n\n## Body\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["commit", "-m", "add story"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = set_test_plan_in_file(tmp.path(), "5_test", "approved");
|
||||||
|
assert!(result.is_err(), "should fail if test_plan field is missing");
|
||||||
|
assert!(result.unwrap_err().contains("test_plan"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_story_file_searches_current_then_upcoming() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let current = tmp.path().join(".story_kit/stories/current");
|
||||||
|
let upcoming = tmp.path().join(".story_kit/stories/upcoming");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
fs::create_dir_all(&upcoming).unwrap();
|
||||||
|
|
||||||
|
// Only in upcoming
|
||||||
|
fs::write(upcoming.join("6_test.md"), "").unwrap();
|
||||||
|
let found = find_story_file(tmp.path(), "6_test").unwrap();
|
||||||
|
assert!(found.ends_with("upcoming/6_test.md") || found.ends_with("upcoming\\6_test.md"));
|
||||||
|
|
||||||
|
// Also in current — current should win
|
||||||
|
fs::write(current.join("6_test.md"), "").unwrap();
|
||||||
|
let found = find_story_file(tmp.path(), "6_test").unwrap();
|
||||||
|
assert!(found.ends_with("current/6_test.md") || found.ends_with("current\\6_test.md"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_story_file_returns_error_when_not_found() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let result = find_story_file(tmp.path(), "99_missing");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("not found"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user