diff --git a/server/src/agents.rs b/server/src/agents.rs index 1bde91d..8bfcb61 100644 --- a/server/src/agents.rs +++ b/server/src/agents.rs @@ -819,7 +819,7 @@ pub fn close_bug_to_archive(project_root: &Path, bug_id: &str) -> Result<(), Str let sk = project_root.join(".story_kit"); let current_path = sk.join("current").join(format!("{bug_id}.md")); let bugs_path = sk.join("bugs").join(format!("{bug_id}.md")); - let archive_dir = sk.join("bugs").join("archive"); + let archive_dir = item_archive_dir(project_root, bug_id); let archive_path = archive_dir.join(format!("{bug_id}.md")); if archive_path.exists() { diff --git a/server/src/http/mcp.rs b/server/src/http/mcp.rs index 75c5540..8c02105 100644 --- a/server/src/http/mcp.rs +++ b/server/src/http/mcp.rs @@ -3,9 +3,10 @@ use crate::config::ProjectConfig; use crate::http::context::AppContext; use crate::http::settings::get_editor_command_from_store; use crate::http::workflow::{ - check_criterion_in_file, create_story_file, load_upcoming_stories, set_test_plan_in_file, - validate_story_dirs, + check_criterion_in_file, create_bug_file, create_story_file, list_bug_files, + load_upcoming_stories, set_test_plan_in_file, validate_story_dirs, }; +use crate::agents::close_bug_to_archive; use crate::worktree; use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos}; use crate::workflow::{evaluate_acceptance_with_coverage, TestCaseResult, TestStatus}; @@ -653,6 +654,63 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { }, "required": ["story_id", "status"] } + }, + { + "name": "create_bug", + "description": "Create a bug file in .story_kit/bugs/ with a deterministic filename and auto-commit to master. Returns the bug_id.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Short human-readable bug name" + }, + "description": { + "type": "string", + "description": "Description of the bug" + }, + "steps_to_reproduce": { + "type": "string", + "description": "Steps to reproduce the bug" + }, + "actual_result": { + "type": "string", + "description": "What actually happens" + }, + "expected_result": { + "type": "string", + "description": "What should happen" + }, + "acceptance_criteria": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional list of acceptance criteria for the fix" + } + }, + "required": ["name", "description", "steps_to_reproduce", "actual_result", "expected_result"] + } + }, + { + "name": "list_bugs", + "description": "List all open bugs (files in .story_kit/bugs/ excluding archive/).", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "close_bug", + "description": "Move a bug from .story_kit/bugs/ (or current/) to .story_kit/bugs/archive/ and auto-commit to master.", + "inputSchema": { + "type": "object", + "properties": { + "bug_id": { + "type": "string", + "description": "Bug identifier (e.g. 'bug-3-login_crash')" + } + }, + "required": ["bug_id"] + } } ] }), @@ -701,6 +759,10 @@ async fn handle_tools_call( // Story mutation tools (auto-commit to master) "check_criterion" => tool_check_criterion(&args, ctx), "set_test_plan" => tool_set_test_plan(&args, ctx), + // Bug lifecycle tools + "create_bug" => tool_create_bug(&args, ctx), + "list_bugs" => tool_list_bugs(ctx), + "close_bug" => tool_close_bug(&args, ctx), _ => Err(format!("Unknown tool: {tool_name}")), }; @@ -1148,6 +1210,71 @@ fn tool_set_test_plan(args: &Value, ctx: &AppContext) -> Result )) } +// ── Bug lifecycle tool implementations ─────────────────────────── + +fn tool_create_bug(args: &Value, ctx: &AppContext) -> Result { + let name = args + .get("name") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: name")?; + let description = args + .get("description") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: description")?; + let steps_to_reproduce = args + .get("steps_to_reproduce") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: steps_to_reproduce")?; + let actual_result = args + .get("actual_result") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: actual_result")?; + let expected_result = args + .get("expected_result") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: expected_result")?; + let acceptance_criteria: Option> = args + .get("acceptance_criteria") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + let root = ctx.state.get_project_root()?; + let bug_id = create_bug_file( + &root, + name, + description, + steps_to_reproduce, + actual_result, + expected_result, + acceptance_criteria.as_deref(), + )?; + + Ok(format!("Created bug: {bug_id}")) +} + +fn tool_list_bugs(ctx: &AppContext) -> Result { + let root = ctx.state.get_project_root()?; + let bugs = list_bug_files(&root)?; + serde_json::to_string_pretty(&json!(bugs + .iter() + .map(|(id, name)| json!({ "bug_id": id, "name": name })) + .collect::>())) + .map_err(|e| format!("Serialization error: {e}")) +} + +fn tool_close_bug(args: &Value, ctx: &AppContext) -> Result { + let bug_id = args + .get("bug_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: bug_id")?; + + let root = ctx.agents.get_project_root(&ctx.state)?; + close_bug_to_archive(&root, bug_id)?; + + Ok(format!( + "Bug '{bug_id}' closed, moved to bugs/archive/, and committed to master." + )) +} + /// Run `git log ..HEAD --oneline` in the worktree and return the commit /// summaries, or `None` if git is unavailable or there are no new commits. async fn get_worktree_commits(worktree_path: &str, base_branch: &str) -> Option> { @@ -1302,7 +1429,10 @@ mod tests { assert!(names.contains(&"accept_story")); assert!(names.contains(&"check_criterion")); assert!(names.contains(&"set_test_plan")); - assert_eq!(tools.len(), 21); + assert!(names.contains(&"create_bug")); + assert!(names.contains(&"list_bugs")); + assert!(names.contains(&"close_bug")); + assert_eq!(tools.len(), 24); } #[test] @@ -1699,4 +1829,196 @@ mod tests { let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); assert!(req_names.contains(&"worktree_path")); } + + // ── Bug lifecycle tool tests ────────────────────────────────── + + fn setup_git_repo_in(dir: &std::path::Path) { + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(dir) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(dir) + .output() + .unwrap(); + } + + #[test] + fn create_bug_in_tools_list() { + let resp = handle_tools_list(Some(json!(1))); + let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); + let tool = tools.iter().find(|t| t["name"] == "create_bug"); + assert!(tool.is_some(), "create_bug missing from tools list"); + let t = tool.unwrap(); + assert!(t["description"].is_string()); + let required = t["inputSchema"]["required"].as_array().unwrap(); + let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(req_names.contains(&"name")); + assert!(req_names.contains(&"description")); + assert!(req_names.contains(&"steps_to_reproduce")); + assert!(req_names.contains(&"actual_result")); + assert!(req_names.contains(&"expected_result")); + } + + #[test] + fn list_bugs_in_tools_list() { + let resp = handle_tools_list(Some(json!(1))); + let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); + let tool = tools.iter().find(|t| t["name"] == "list_bugs"); + assert!(tool.is_some(), "list_bugs missing from tools list"); + } + + #[test] + fn close_bug_in_tools_list() { + let resp = handle_tools_list(Some(json!(1))); + let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); + let tool = tools.iter().find(|t| t["name"] == "close_bug"); + assert!(tool.is_some(), "close_bug missing from tools list"); + let t = tool.unwrap(); + let required = t["inputSchema"]["required"].as_array().unwrap(); + let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(req_names.contains(&"bug_id")); + } + + #[test] + fn tool_create_bug_missing_name() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_create_bug( + &json!({ + "description": "d", + "steps_to_reproduce": "s", + "actual_result": "a", + "expected_result": "e" + }), + &ctx, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("name")); + } + + #[test] + fn tool_create_bug_missing_description() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_create_bug( + &json!({ + "name": "Bug", + "steps_to_reproduce": "s", + "actual_result": "a", + "expected_result": "e" + }), + &ctx, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("description")); + } + + #[test] + fn tool_create_bug_creates_file_and_commits() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo_in(tmp.path()); + let ctx = test_ctx(tmp.path()); + + let result = tool_create_bug( + &json!({ + "name": "Login Crash", + "description": "The app crashes on login.", + "steps_to_reproduce": "1. Open app\n2. Click login", + "actual_result": "500 error", + "expected_result": "Successful login" + }), + &ctx, + ) + .unwrap(); + + assert!(result.contains("bug-1-login_crash")); + let bug_file = tmp + .path() + .join(".story_kit/bugs/bug-1-login_crash.md"); + assert!(bug_file.exists()); + } + + #[test] + fn tool_list_bugs_empty() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_list_bugs(&ctx).unwrap(); + let parsed: Vec = serde_json::from_str(&result).unwrap(); + assert!(parsed.is_empty()); + } + + #[test] + fn tool_list_bugs_returns_open_bugs() { + let tmp = tempfile::tempdir().unwrap(); + let bugs_dir = tmp.path().join(".story_kit/bugs"); + std::fs::create_dir_all(&bugs_dir).unwrap(); + std::fs::write( + bugs_dir.join("bug-1-crash.md"), + "# Bug 1: App Crash\n", + ) + .unwrap(); + std::fs::write( + bugs_dir.join("bug-2-typo.md"), + "# Bug 2: Typo in Header\n", + ) + .unwrap(); + + let ctx = test_ctx(tmp.path()); + let result = tool_list_bugs(&ctx).unwrap(); + let parsed: Vec = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0]["bug_id"], "bug-1-crash"); + assert_eq!(parsed[0]["name"], "App Crash"); + assert_eq!(parsed[1]["bug_id"], "bug-2-typo"); + assert_eq!(parsed[1]["name"], "Typo in Header"); + } + + #[test] + fn tool_close_bug_missing_bug_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_close_bug(&json!({}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("bug_id")); + } + + #[test] + fn tool_close_bug_moves_to_archive() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo_in(tmp.path()); + let bugs_dir = tmp.path().join(".story_kit/bugs"); + std::fs::create_dir_all(&bugs_dir).unwrap(); + let bug_file = bugs_dir.join("bug-1-crash.md"); + std::fs::write(&bug_file, "# Bug 1: Crash\n").unwrap(); + // Stage the file so it's tracked + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "add bug"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + + let ctx = test_ctx(tmp.path()); + let result = tool_close_bug(&json!({"bug_id": "bug-1-crash"}), &ctx).unwrap(); + assert!(result.contains("bug-1-crash")); + assert!(!bug_file.exists()); + assert!(bugs_dir.join("archive/bug-1-crash.md").exists()); + } } diff --git a/server/src/http/workflow.rs b/server/src/http/workflow.rs index dbf6cc6..8096a22 100644 --- a/server/src/http/workflow.rs +++ b/server/src/http/workflow.rs @@ -699,6 +699,157 @@ fn git_commit_story_file(root: &Path, filepath: &Path, story_id: &str) -> Result git_stage_and_commit(root, &[filepath], &msg) } +// ── Bug file helpers ────────────────────────────────────────────── + +/// Determine the next bug number by scanning `.story_kit/bugs/` and `.story_kit/bugs/archive/`. +fn next_bug_number(root: &Path) -> Result { + let bugs_base = root.join(".story_kit").join("bugs"); + let mut max_num: u32 = 0; + + for dir in [bugs_base.clone(), bugs_base.join("archive")] { + if !dir.exists() { + continue; + } + for entry in + fs::read_dir(dir).map_err(|e| format!("Failed to read bugs directory: {e}"))? + { + let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?; + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + // Bug filenames: bug-N-slug.md — extract the N after "bug-" + if let Some(rest) = name_str.strip_prefix("bug-") { + let num_str: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + if let Ok(n) = num_str.parse::() + && n > max_num + { + max_num = n; + } + } + } + } + + Ok(max_num + 1) +} + +/// Create a bug file in `.story_kit/bugs/` with a deterministic filename and auto-commit. +/// +/// Returns the bug_id (e.g. `"bug-3-login_crash"`). +pub fn create_bug_file( + root: &Path, + name: &str, + description: &str, + steps_to_reproduce: &str, + actual_result: &str, + expected_result: &str, + acceptance_criteria: Option<&[String]>, +) -> Result { + let bug_number = next_bug_number(root)?; + let slug = slugify_name(name); + + if slug.is_empty() { + return Err("Name must contain at least one alphanumeric character.".to_string()); + } + + let filename = format!("bug-{bug_number}-{slug}.md"); + let bugs_dir = root.join(".story_kit").join("bugs"); + fs::create_dir_all(&bugs_dir) + .map_err(|e| format!("Failed to create bugs directory: {e}"))?; + + let filepath = bugs_dir.join(&filename); + if filepath.exists() { + return Err(format!("Bug file already exists: {filename}")); + } + + let bug_id = filepath + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_string(); + + let mut content = String::new(); + content.push_str(&format!("# Bug {bug_number}: {name}\n\n")); + content.push_str("## Description\n\n"); + content.push_str(description); + content.push_str("\n\n"); + content.push_str("## How to Reproduce\n\n"); + content.push_str(steps_to_reproduce); + content.push_str("\n\n"); + content.push_str("## Actual Result\n\n"); + content.push_str(actual_result); + content.push_str("\n\n"); + content.push_str("## Expected Result\n\n"); + content.push_str(expected_result); + content.push_str("\n\n"); + content.push_str("## Acceptance Criteria\n\n"); + if let Some(criteria) = acceptance_criteria { + for criterion in criteria { + content.push_str(&format!("- [ ] {criterion}\n")); + } + } else { + content.push_str("- [ ] Bug is fixed and verified\n"); + } + + fs::write(&filepath, &content).map_err(|e| format!("Failed to write bug file: {e}"))?; + + let msg = format!("story-kit: create bug {bug_id}"); + git_stage_and_commit(root, &[filepath.as_path()], &msg)?; + + Ok(bug_id) +} + +/// Extract the human-readable name from a bug file's first heading. +fn extract_bug_name(path: &Path) -> Option { + let contents = fs::read_to_string(path).ok()?; + for line in contents.lines() { + if let Some(rest) = line.strip_prefix("# Bug ") { + // Format: "N: Name" + if let Some(colon_pos) = rest.find(": ") { + return Some(rest[colon_pos + 2..].to_string()); + } + } + } + None +} + +/// List all open bugs — files directly in `.story_kit/bugs/` (excluding `archive/` subdir). +/// +/// Returns a sorted list of `(bug_id, name)` pairs. +pub fn list_bug_files(root: &Path) -> Result, String> { + let bugs_dir = root.join(".story_kit").join("bugs"); + if !bugs_dir.exists() { + return Ok(Vec::new()); + } + + let mut bugs = Vec::new(); + for entry in + fs::read_dir(&bugs_dir).map_err(|e| format!("Failed to read bugs directory: {e}"))? + { + let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?; + let path = entry.path(); + + // Skip subdirectories (archive/) + if path.is_dir() { + continue; + } + + if path.extension().and_then(|ext| ext.to_str()) != Some("md") { + continue; + } + + let bug_id = path + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| "Invalid bug file name.".to_string())? + .to_string(); + + let name = extract_bug_name(&path).unwrap_or_else(|| bug_id.clone()); + bugs.push((bug_id, name)); + } + + bugs.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(bugs) +} + /// Locate a story file by searching .story_kit/current/ then stories/upcoming/. fn find_story_file(project_root: &Path, story_id: &str) -> Result { let filename = format!("{story_id}.md"); @@ -917,7 +1068,7 @@ pub fn validate_story_dirs( continue; } for entry in - fs::read_dir(&dir).map_err(|e| format!("Failed to read {subdir} directory: {e}"))? + fs::read_dir(dir).map_err(|e| format!("Failed to read {subdir} directory: {e}"))? { let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?; let path = entry.path(); @@ -1540,4 +1691,146 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().contains("not found")); } + + // ── Bug file helper tests ────────────────────────────────────────────────── + + #[test] + fn next_bug_number_starts_at_1_when_empty() { + let tmp = tempfile::tempdir().unwrap(); + assert_eq!(next_bug_number(tmp.path()).unwrap(), 1); + } + + #[test] + fn next_bug_number_increments_from_existing() { + let tmp = tempfile::tempdir().unwrap(); + let bugs_dir = tmp.path().join(".story_kit/bugs"); + fs::create_dir_all(&bugs_dir).unwrap(); + fs::write(bugs_dir.join("bug-1-crash.md"), "").unwrap(); + fs::write(bugs_dir.join("bug-3-another.md"), "").unwrap(); + assert_eq!(next_bug_number(tmp.path()).unwrap(), 4); + } + + #[test] + fn next_bug_number_scans_archive_too() { + let tmp = tempfile::tempdir().unwrap(); + let bugs_dir = tmp.path().join(".story_kit/bugs"); + let archive_dir = bugs_dir.join("archive"); + fs::create_dir_all(&bugs_dir).unwrap(); + fs::create_dir_all(&archive_dir).unwrap(); + fs::write(archive_dir.join("bug-5-old.md"), "").unwrap(); + assert_eq!(next_bug_number(tmp.path()).unwrap(), 6); + } + + #[test] + fn list_bug_files_empty_when_no_bugs_dir() { + let tmp = tempfile::tempdir().unwrap(); + let result = list_bug_files(tmp.path()).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn list_bug_files_excludes_archive_subdir() { + let tmp = tempfile::tempdir().unwrap(); + let bugs_dir = tmp.path().join(".story_kit/bugs"); + let archive_dir = bugs_dir.join("archive"); + fs::create_dir_all(&bugs_dir).unwrap(); + fs::create_dir_all(&archive_dir).unwrap(); + fs::write(bugs_dir.join("bug-1-open.md"), "# Bug 1: Open Bug\n").unwrap(); + fs::write(archive_dir.join("bug-2-closed.md"), "# Bug 2: Closed Bug\n").unwrap(); + + let result = list_bug_files(tmp.path()).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].0, "bug-1-open"); + assert_eq!(result[0].1, "Open Bug"); + } + + #[test] + fn list_bug_files_sorted_by_id() { + let tmp = tempfile::tempdir().unwrap(); + let bugs_dir = tmp.path().join(".story_kit/bugs"); + fs::create_dir_all(&bugs_dir).unwrap(); + fs::write(bugs_dir.join("bug-3-third.md"), "# Bug 3: Third\n").unwrap(); + fs::write(bugs_dir.join("bug-1-first.md"), "# Bug 1: First\n").unwrap(); + fs::write(bugs_dir.join("bug-2-second.md"), "# Bug 2: Second\n").unwrap(); + + let result = list_bug_files(tmp.path()).unwrap(); + assert_eq!(result.len(), 3); + assert_eq!(result[0].0, "bug-1-first"); + assert_eq!(result[1].0, "bug-2-second"); + assert_eq!(result[2].0, "bug-3-third"); + } + + #[test] + fn extract_bug_name_parses_heading() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("bug-1-crash.md"); + fs::write(&path, "# Bug 1: Login page crashes\n\n## Description\n").unwrap(); + let name = extract_bug_name(&path).unwrap(); + assert_eq!(name, "Login page crashes"); + } + + #[test] + fn create_bug_file_writes_correct_content() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo(tmp.path()); + + let bug_id = create_bug_file( + tmp.path(), + "Login Crash", + "The login page crashes on submit.", + "1. Go to /login\n2. Click submit", + "Page crashes with 500 error", + "Login succeeds", + Some(&["Login form submits without error".to_string()]), + ) + .unwrap(); + + assert_eq!(bug_id, "bug-1-login_crash"); + + let filepath = tmp + .path() + .join(".story_kit/bugs/bug-1-login_crash.md"); + assert!(filepath.exists()); + let contents = fs::read_to_string(&filepath).unwrap(); + assert!(contents.contains("# Bug 1: Login Crash")); + assert!(contents.contains("## Description")); + assert!(contents.contains("The login page crashes on submit.")); + assert!(contents.contains("## How to Reproduce")); + assert!(contents.contains("1. Go to /login")); + assert!(contents.contains("## Actual Result")); + assert!(contents.contains("Page crashes with 500 error")); + assert!(contents.contains("## Expected Result")); + assert!(contents.contains("Login succeeds")); + assert!(contents.contains("## Acceptance Criteria")); + assert!(contents.contains("- [ ] Login form submits without error")); + } + + #[test] + fn create_bug_file_rejects_empty_name() { + let tmp = tempfile::tempdir().unwrap(); + let result = create_bug_file(tmp.path(), "!!!", "desc", "steps", "actual", "expected", None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("alphanumeric")); + } + + #[test] + fn create_bug_file_uses_default_acceptance_criterion() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo(tmp.path()); + + create_bug_file( + tmp.path(), + "Some Bug", + "desc", + "steps", + "actual", + "expected", + None, + ) + .unwrap(); + + let filepath = tmp.path().join(".story_kit/bugs/bug-1-some_bug.md"); + let contents = fs::read_to_string(&filepath).unwrap(); + assert!(contents.contains("- [ ] Bug is fixed and verified")); + } }