use crate::agents::move_story_to_merge; use crate::http::context::AppContext; use crate::io::story_metadata::write_merge_failure; use crate::slog; use crate::slog_warn; use serde_json::{json, Value}; pub(super) fn tool_merge_agent_work(args: &Value, ctx: &AppContext) -> Result { let story_id = args .get("story_id") .and_then(|v| v.as_str()) .ok_or("Missing required argument: story_id")?; let project_root = ctx.agents.get_project_root(&ctx.state)?; ctx.agents.start_merge_agent_work(&project_root, story_id)?; serde_json::to_string_pretty(&json!({ "story_id": story_id, "status": "started", "message": "Merge pipeline started. Poll get_merge_status(story_id) every 10-15 seconds until status is 'completed' or 'failed'." })) .map_err(|e| format!("Serialization error: {e}")) } pub(super) fn tool_get_merge_status(args: &Value, ctx: &AppContext) -> Result { let story_id = args .get("story_id") .and_then(|v| v.as_str()) .ok_or("Missing required argument: story_id")?; let job = ctx.agents.get_merge_status(story_id) .ok_or_else(|| format!("No merge job found for story '{story_id}'. Call merge_agent_work first."))?; match &job.status { crate::agents::merge::MergeJobStatus::Running => { serde_json::to_string_pretty(&json!({ "story_id": story_id, "status": "running", "message": "Merge pipeline is still running. Poll again in 10-15 seconds." })) .map_err(|e| format!("Serialization error: {e}")) } crate::agents::merge::MergeJobStatus::Completed(report) => { let status_msg = if report.success && report.gates_passed && report.conflicts_resolved { "Merge complete: conflicts were auto-resolved and all quality gates passed. Story moved to done and worktree cleaned up." } else if report.success && report.gates_passed { "Merge complete: all quality gates passed. Story moved to done and worktree cleaned up." } else if report.had_conflicts && !report.conflicts_resolved { "Merge failed: conflicts detected that could not be auto-resolved. Merge was aborted — master is untouched. Call report_merge_failure with the conflict details so the human can resolve them. Do NOT manually move the story file or call accept_story." } else if report.success && !report.gates_passed { "Merge committed but quality gates failed. Review gate_output and fix issues before re-running." } else { "Merge failed. Review gate_output for details. Call report_merge_failure to record the failure. Do NOT manually move the story file or call accept_story." }; serde_json::to_string_pretty(&json!({ "story_id": story_id, "status": "completed", "success": report.success, "had_conflicts": report.had_conflicts, "conflicts_resolved": report.conflicts_resolved, "conflict_details": report.conflict_details, "gates_passed": report.gates_passed, "gate_output": report.gate_output, "worktree_cleaned_up": report.worktree_cleaned_up, "story_archived": report.story_archived, "message": status_msg, })) .map_err(|e| format!("Serialization error: {e}")) } crate::agents::merge::MergeJobStatus::Failed(err) => { serde_json::to_string_pretty(&json!({ "story_id": story_id, "status": "failed", "error": err, "message": format!("Merge pipeline failed: {err}. Call report_merge_failure to record the failure.") })) .map_err(|e| format!("Serialization error: {e}")) } } } pub(super) async fn tool_move_story_to_merge(args: &Value, ctx: &AppContext) -> Result { let story_id = args .get("story_id") .and_then(|v| v.as_str()) .ok_or("Missing required argument: story_id")?; let agent_name = args .get("agent_name") .and_then(|v| v.as_str()) .unwrap_or("mergemaster"); let project_root = ctx.agents.get_project_root(&ctx.state)?; // Move story from work/2_current/ to work/4_merge/ move_story_to_merge(&project_root, story_id)?; // Start the mergemaster agent on the story worktree let info = ctx .agents .start_agent(&project_root, story_id, Some(agent_name), None) .await?; serde_json::to_string_pretty(&json!({ "story_id": info.story_id, "agent_name": info.agent_name, "status": info.status.to_string(), "worktree_path": info.worktree_path, "message": format!( "Story '{story_id}' moved to work/4_merge/ and mergemaster agent '{}' started.", info.agent_name ), })) .map_err(|e| format!("Serialization error: {e}")) } pub(super) fn tool_report_merge_failure(args: &Value, ctx: &AppContext) -> Result { let story_id = args .get("story_id") .and_then(|v| v.as_str()) .ok_or("Missing required argument: story_id")?; let reason = args .get("reason") .and_then(|v| v.as_str()) .ok_or("Missing required argument: reason")?; slog!("[mergemaster] Merge failure reported for '{story_id}': {reason}"); ctx.agents.set_merge_failure_reported(story_id); // Broadcast the failure so the Matrix notification listener can post an // error message to configured rooms without coupling this tool to the bot. let _ = ctx.watcher_tx.send(crate::io::watcher::WatcherEvent::MergeFailure { story_id: story_id.to_string(), reason: reason.to_string(), }); // Persist the failure reason to the story file's front matter so it // survives server restarts and is visible in the web UI. if let Ok(project_root) = ctx.state.get_project_root() { let story_file = project_root .join(".huskies") .join("work") .join("4_merge") .join(format!("{story_id}.md")); if story_file.exists() { if let Err(e) = write_merge_failure(&story_file, reason) { slog_warn!( "[mergemaster] Failed to persist merge_failure to story file for '{story_id}': {e}" ); } } else { slog_warn!( "[mergemaster] Story file not found in 4_merge/ for '{story_id}'; \ merge_failure not persisted to front matter" ); } } Ok(format!( "Merge failure for '{story_id}' recorded. Story remains in work/4_merge/. Reason: {reason}" )) } #[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(); } #[test] fn merge_agent_work_in_tools_list() { use super::super::{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"] == "merge_agent_work"); assert!(tool.is_some(), "merge_agent_work 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(&"story_id")); // agent_name is optional assert!(!req_names.contains(&"agent_name")); } #[test] fn move_story_to_merge_in_tools_list() { use super::super::{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"] == "move_story_to_merge"); assert!(tool.is_some(), "move_story_to_merge 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(&"story_id")); // agent_name is optional assert!(!req_names.contains(&"agent_name")); } #[test] fn tool_merge_agent_work_missing_story_id() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_merge_agent_work(&json!({}), &ctx); assert!(result.is_err()); assert!(result.unwrap_err().contains("story_id")); } #[tokio::test] async fn tool_move_story_to_merge_missing_story_id() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_move_story_to_merge(&json!({}), &ctx).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("story_id")); } #[tokio::test] async fn tool_move_story_to_merge_moves_file() { let tmp = tempfile::tempdir().unwrap(); setup_git_repo_in(tmp.path()); let current_dir = tmp.path().join(".huskies/work/2_current"); std::fs::create_dir_all(¤t_dir).unwrap(); let content = "---\nname: Test\n---\n"; let story_file = current_dir.join("24_story_test.md"); std::fs::write(&story_file, content).unwrap(); crate::db::ensure_content_store(); crate::db::write_content("24_story_test", content); 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 ctx = test_ctx(tmp.path()); // The agent start will fail in test (no worktree/config), but the move should succeed let result = tool_move_story_to_merge(&json!({"story_id": "24_story_test"}), &ctx).await; // Content store should still have the item after the move assert!( crate::db::read_content("24_story_test").is_some(), "content store should have the story after move" ); // Result is either Ok (agent started) or Err (agent failed - acceptable in tests) let _ = result; } #[tokio::test] async fn tool_merge_agent_work_returns_started() { let tmp = tempfile::tempdir().unwrap(); setup_git_repo_in(tmp.path()); let ctx = test_ctx(tmp.path()); let result = tool_merge_agent_work( &json!({"story_id": "99_nonexistent", "agent_name": "coder-1"}), &ctx, ) .unwrap(); let parsed: Value = serde_json::from_str(&result).unwrap(); assert_eq!(parsed["story_id"], "99_nonexistent"); assert_eq!(parsed["status"], "started"); assert!(parsed.get("message").is_some()); } #[test] fn tool_get_merge_status_no_job() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_get_merge_status(&json!({"story_id": "99_nonexistent"}), &ctx); assert!(result.is_err()); assert!(result.unwrap_err().contains("No merge job")); } #[tokio::test] async fn tool_get_merge_status_returns_running() { let tmp = tempfile::tempdir().unwrap(); setup_git_repo_in(tmp.path()); let ctx = test_ctx(tmp.path()); // Start a merge (it will run in background) tool_merge_agent_work( &json!({"story_id": "99_nonexistent"}), &ctx, ) .unwrap(); // Immediately check — should be running (or already finished if very fast) let result = tool_get_merge_status(&json!({"story_id": "99_nonexistent"}), &ctx).unwrap(); let parsed: Value = serde_json::from_str(&result).unwrap(); let status = parsed["status"].as_str().unwrap(); assert!( status == "running" || status == "completed" || status == "failed", "unexpected status: {status}" ); } #[test] fn report_merge_failure_in_tools_list() { use super::super::{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"] == "report_merge_failure"); assert!( tool.is_some(), "report_merge_failure 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(&"story_id")); assert!(req_names.contains(&"reason")); } #[test] fn tool_report_merge_failure_missing_story_id() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_report_merge_failure(&json!({"reason": "conflicts"}), &ctx); assert!(result.is_err()); assert!(result.unwrap_err().contains("story_id")); } #[test] fn tool_report_merge_failure_missing_reason() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_report_merge_failure(&json!({"story_id": "42_story_foo"}), &ctx); assert!(result.is_err()); assert!(result.unwrap_err().contains("reason")); } #[test] fn tool_report_merge_failure_returns_confirmation() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_report_merge_failure( &json!({ "story_id": "42_story_foo", "reason": "Unresolvable merge conflicts in src/main.rs" }), &ctx, ); assert!(result.is_ok()); let msg = result.unwrap(); assert!(msg.contains("42_story_foo")); assert!(msg.contains("work/4_merge/")); assert!(msg.contains("Unresolvable merge conflicts")); } }