//! Bug item MCP tools (create, list, close). #![allow(unused_imports, dead_code)] #[allow(unused_imports)] use crate::agents::{ close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done, }; use crate::http::context::AppContext; use crate::http::workflow::{ add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file, create_spike_file, create_story_file, edit_criterion_in_file, list_bug_files, list_refactor_files, load_pipeline_state, load_upcoming_stories, remove_criterion_from_file, update_story_in_file, validate_story_dirs, }; use crate::io::story_metadata::{ check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos, }; use crate::service::story::parse_test_cases; use crate::slog_warn; #[allow(unused_imports)] use crate::workflow::{TestCaseResult, TestStatus, evaluate_acceptance_with_coverage}; use serde_json::{Value, json}; use std::collections::HashMap; use std::fs; pub(crate) 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: Vec = args .get("acceptance_criteria") .and_then(|v| serde_json::from_value(v.clone()).ok()) .ok_or("Missing required argument: acceptance_criteria")?; if acceptance_criteria.is_empty() { return Err("acceptance_criteria must contain at least one entry".to_string()); } const JUNK_AC: &[&str] = &["", "todo", "tbd", "fixme", "xxx", "???"]; let all_junk = acceptance_criteria .iter() .all(|ac| JUNK_AC.contains(&ac.trim().to_lowercase().as_str())); if all_junk { return Err( "acceptance_criteria must contain at least one real entry (not just TODO/TBD/FIXME/XXX/???)." .to_string(), ); } let depends_on: Option> = args .get("depends_on") .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, Some(&acceptance_criteria), depends_on.as_deref(), )?; Ok(format!("Created bug: {bug_id}")) } pub(crate) 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}")) } pub(crate) 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.services.agents.get_project_root(&ctx.state)?; close_bug_to_archive(&root, bug_id)?; ctx.services.agents.remove_agents_for_story(bug_id); Ok(format!( "Bug '{bug_id}' closed, moved to bugs/archive/, and committed to master." )) } #[cfg(test)] mod tests { use super::*; use crate::http::test_helpers::test_ctx; 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(); } use super::super::super::tools_list::handle_tools_list; use serde_json::Value; #[test] fn create_bug_in_tools_list() { use super::super::super::tools_list::handle_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(); let desc = t["description"].as_str().unwrap(); assert!( desc.contains("work/1_backlog/"), "create_bug description should reference work/1_backlog/, got: {desc}" ); assert!( !desc.contains(".huskies/bugs"), "create_bug description should not reference nonexistent .huskies/bugs/, got: {desc}" ); 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() { use super::super::super::tools_list::handle_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"); let t = tool.unwrap(); let desc = t["description"].as_str().unwrap(); assert!( desc.contains("work/1_backlog/"), "list_bugs description should reference work/1_backlog/, got: {desc}" ); assert!( !desc.contains(".huskies/bugs"), "list_bugs description should not reference nonexistent .huskies/bugs/, got: {desc}" ); } #[test] fn close_bug_in_tools_list() { use super::super::super::tools_list::handle_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 desc = t["description"].as_str().unwrap(); assert!( !desc.contains(".huskies/bugs"), "close_bug description should not reference nonexistent .huskies/bugs/, got: {desc}" ); assert!( desc.contains("work/5_done/"), "close_bug description should reference work/5_done/, got: {desc}" ); 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", "acceptance_criteria": ["Login succeeds without error"] }), &ctx, ) .unwrap(); assert!( result.contains("_bug_login_crash"), "result should contain bug ID: {result}" ); // Extract the actual bug ID from the result message (format: "Created bug: "). let bug_id = result.trim_start_matches("Created bug: ").trim(); // Bug content should exist in the CRDT content store. assert!( crate::db::read_content(bug_id).is_some(), "expected bug content in CRDT for {bug_id}" ); } #[test] fn tool_list_bugs_no_crash_on_empty_root() { // list_bugs reads from the global CRDT, not the filesystem. // Verify it returns valid JSON without panicking. let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_list_bugs(&ctx).unwrap(); // Verify result is valid JSON array (may contain bugs from // the shared global CRDT populated by other tests). let _parsed: Vec = serde_json::from_str(&result).unwrap(); } #[test] fn tool_list_bugs_returns_open_bugs() { let tmp = tempfile::tempdir().unwrap(); crate::db::ensure_content_store(); crate::db::write_item_with_content( "9902_bug_crash", "1_backlog", "---\nname: \"App Crash\"\n---\n# Bug 9902: App Crash\n", ); crate::db::write_item_with_content( "9903_bug_typo", "1_backlog", "---\nname: \"Typo in Header\"\n---\n# Bug 9903: Typo in Header\n", ); let ctx = test_ctx(tmp.path()); let result = tool_list_bugs(&ctx).unwrap(); let parsed: Vec = serde_json::from_str(&result).unwrap(); assert!( parsed .iter() .any(|b| b["bug_id"] == "9902_bug_crash" && b["name"] == "App Crash"), "expected 9902_bug_crash in bugs list: {parsed:?}" ); assert!( parsed .iter() .any(|b| b["bug_id"] == "9903_bug_typo" && b["name"] == "Typo in Header"), "expected 9903_bug_typo in bugs list: {parsed:?}" ); } #[test] fn tool_create_bug_rejects_missing_acceptance_criteria() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_create_bug( &json!({ "name": "Some Bug", "description": "d", "steps_to_reproduce": "s", "actual_result": "a", "expected_result": "e" }), &ctx, ); assert!(result.is_err()); assert!( result.unwrap_err().contains("acceptance_criteria"), "error should mention acceptance_criteria" ); } #[test] fn tool_create_bug_rejects_empty_acceptance_criteria() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_create_bug( &json!({ "name": "Some Bug", "description": "d", "steps_to_reproduce": "s", "actual_result": "a", "expected_result": "e", "acceptance_criteria": [] }), &ctx, ); assert!(result.is_err()); assert!( result.unwrap_err().contains("acceptance_criteria"), "error should mention acceptance_criteria" ); } #[test] fn tool_create_bug_accepts_single_criterion() { let tmp = tempfile::tempdir().unwrap(); setup_git_repo_in(tmp.path()); let ctx = test_ctx(tmp.path()); let result = tool_create_bug( &json!({ "name": "Single Criterion Bug", "description": "d", "steps_to_reproduce": "s", "actual_result": "a", "expected_result": "e", "acceptance_criteria": ["Bug is fixed"] }), &ctx, ); assert!(result.is_ok(), "expected ok: {result:?}"); assert!(result.unwrap().contains("Created bug:")); } #[test] fn tool_create_bug_rejects_all_junk_acceptance_criteria() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_create_bug( &json!({ "name": "Junk Bug", "description": "d", "steps_to_reproduce": "s", "actual_result": "a", "expected_result": "e", "acceptance_criteria": ["TODO", "TBD"] }), &ctx, ); assert!(result.is_err()); assert!( result.unwrap_err().contains("real entry"), "error should mention real entry" ); } #[test] fn tool_create_bug_accepts_mixed_junk_and_real_acceptance_criteria() { let tmp = tempfile::tempdir().unwrap(); setup_git_repo_in(tmp.path()); let ctx = test_ctx(tmp.path()); let result = tool_create_bug( &json!({ "name": "Mixed Bug", "description": "d", "steps_to_reproduce": "s", "actual_result": "a", "expected_result": "e", "acceptance_criteria": ["TODO", "Real AC"] }), &ctx, ); assert!(result.is_ok(), "expected ok for mixed AC: {result:?}"); assert!(result.unwrap().contains("Created bug:")); } #[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 backlog_dir = tmp.path().join(".huskies/work/1_backlog"); std::fs::create_dir_all(&backlog_dir).unwrap(); let bug_file = backlog_dir.join("9901_bug_crash.md"); let content = "# Bug 9901: Crash\n"; std::fs::write(&bug_file, content).unwrap(); crate::db::ensure_content_store(); crate::db::write_content("9901_bug_crash", content); // 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": "9901_bug_crash"}), &ctx).unwrap(); assert!(result.contains("9901_bug_crash")); assert!( crate::db::read_content("9901_bug_crash").is_some(), "content store should have the bug after close" ); } }