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:
@@ -1,3 +1,4 @@
|
||||
use crate::agents::git_stage_and_commit;
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use crate::io::story_metadata::{StoryMetadata, parse_front_matter, parse_unchecked_todos};
|
||||
use crate::workflow::{
|
||||
@@ -8,7 +9,7 @@ use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Tags)]
|
||||
@@ -668,40 +669,136 @@ pub fn create_story_file(
|
||||
.map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||
|
||||
if commit {
|
||||
git_commit_story_file(root, &filepath, name)?;
|
||||
git_commit_story_file(root, &filepath, &story_id)?;
|
||||
}
|
||||
|
||||
Ok(story_id)
|
||||
}
|
||||
|
||||
/// Git-add and git-commit a newly created story file to the current branch.
|
||||
fn git_commit_story_file(
|
||||
root: &std::path::Path,
|
||||
filepath: &std::path::Path,
|
||||
name: &str,
|
||||
/// Git-add and git-commit a newly created story file using a deterministic message.
|
||||
fn git_commit_story_file(root: &Path, filepath: &Path, story_id: &str) -> Result<(), String> {
|
||||
let msg = format!("story-kit: create story {story_id}");
|
||||
git_stage_and_commit(root, &[filepath], &msg)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let output = Command::new("git")
|
||||
.args(["add", &filepath.to_string_lossy()])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.map_err(|e| format!("git add: {e}"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git add failed: {stderr}"));
|
||||
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 unchecked_count: usize = 0;
|
||||
let mut found = false;
|
||||
let new_lines: Vec<String> = contents
|
||||
.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 output = Command::new("git")
|
||||
.args(["commit", "-m", &msg])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.map_err(|e| format!("git commit: {e}"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git commit failed: {stderr}"));
|
||||
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: check criterion {criterion_index} for story {story_id}");
|
||||
git_stage_and_commit(project_root, &[filepath.as_path()], &msg)
|
||||
}
|
||||
|
||||
/// 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."
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
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 {
|
||||
@@ -1201,4 +1298,202 @@ mod tests {
|
||||
// Simulate the check
|
||||
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