huskies: merge 544_story_add_run_build_and_run_lint_mcp_tools_backed_by_script_build_and_script_lint
This commit is contained in:
@@ -1161,6 +1161,34 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
"required": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "run_build",
|
||||
"description": "Run the project's build script (script/build) in the given worktree and return the result as truncated JSON with passed, exit_code, and output fields.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"worktree_path": {
|
||||
"type": "string",
|
||||
"description": "Optional absolute path to a worktree to run the build in. Must be inside .huskies/worktrees/. Defaults to the project root."
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "run_lint",
|
||||
"description": "Run the project's lint script (script/lint) in the given worktree and return the result as truncated JSON with passed, exit_code, and output fields.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"worktree_path": {
|
||||
"type": "string",
|
||||
"description": "Optional absolute path to a worktree to run lint in. Must be inside .huskies/worktrees/. Defaults to the project root."
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "git_status",
|
||||
"description": "Return the working tree status of an agent's worktree (staged, unstaged, and untracked files). The worktree_path must be inside .huskies/worktrees/. Push and remote operations are not available.",
|
||||
@@ -1418,6 +1446,8 @@ async fn handle_tools_call(
|
||||
"run_command" => shell_tools::tool_run_command(&args, ctx).await,
|
||||
"run_tests" => shell_tools::tool_run_tests(&args, ctx).await,
|
||||
"get_test_result" => shell_tools::tool_get_test_result(&args, ctx).await,
|
||||
"run_build" => shell_tools::tool_run_build(&args, ctx).await,
|
||||
"run_lint" => shell_tools::tool_run_lint(&args, ctx).await,
|
||||
// Git operations
|
||||
"git_status" => git_tools::tool_git_status(&args, ctx).await,
|
||||
"git_diff" => git_tools::tool_git_diff(&args, ctx).await,
|
||||
@@ -1543,6 +1573,8 @@ mod tests {
|
||||
assert!(names.contains(&"run_command"));
|
||||
assert!(names.contains(&"run_tests"));
|
||||
assert!(names.contains(&"get_test_result"));
|
||||
assert!(names.contains(&"run_build"));
|
||||
assert!(names.contains(&"run_lint"));
|
||||
assert!(names.contains(&"git_status"));
|
||||
assert!(names.contains(&"git_diff"));
|
||||
assert!(names.contains(&"git_add"));
|
||||
@@ -1551,7 +1583,7 @@ mod tests {
|
||||
assert!(names.contains(&"status"));
|
||||
assert!(names.contains(&"loc_file"));
|
||||
assert!(names.contains(&"dump_crdt"));
|
||||
assert_eq!(tools.len(), 60);
|
||||
assert_eq!(tools.len(), 62);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -639,6 +639,69 @@ fn collect_child_result(
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared implementation for run_build and run_lint: runs a named script
|
||||
/// (`script/<name>`) in the working directory, captures output, and returns
|
||||
/// truncated JSON with `passed`, `exit_code`, and `output`.
|
||||
async fn run_script_tool(
|
||||
script_name: &str,
|
||||
args: &Value,
|
||||
ctx: &AppContext,
|
||||
) -> Result<String, String> {
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
|
||||
let working_dir = match args.get("worktree_path").and_then(|v| v.as_str()) {
|
||||
Some(wt) => validate_working_dir(wt, ctx)?,
|
||||
None => project_root
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot canonicalize project root: {e}"))?,
|
||||
};
|
||||
|
||||
let script_path = working_dir.join("script").join(script_name);
|
||||
if !script_path.exists() {
|
||||
return Err(format!(
|
||||
"{script_name} script not found: {}",
|
||||
script_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let result = tokio::task::spawn_blocking({
|
||||
let script = script_path.clone();
|
||||
let dir = working_dir.clone();
|
||||
move || {
|
||||
std::process::Command::new("bash")
|
||||
.arg(&script)
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {e}"))?
|
||||
.map_err(|e| format!("Failed to spawn {script_name} script: {e}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&result.stdout);
|
||||
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||
let combined = format!("{stdout}{stderr}");
|
||||
let output = truncate_output(&combined, MAX_OUTPUT_LINES);
|
||||
let exit_code = result.status.code().unwrap_or(-1);
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"passed": result.status.success(),
|
||||
"exit_code": exit_code,
|
||||
"output": output,
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
/// Run the project's build script (`script/build`) and return the result.
|
||||
pub(super) async fn tool_run_build(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
run_script_tool("build", args, ctx).await
|
||||
}
|
||||
|
||||
/// Run the project's lint script (`script/lint`) and return the result.
|
||||
pub(super) async fn tool_run_lint(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
run_script_tool("lint", args, ctx).await
|
||||
}
|
||||
|
||||
/// Format a `TestJobResult` as the JSON string returned to the agent.
|
||||
fn format_test_result(
|
||||
result: &crate::http::context::TestJobResult,
|
||||
@@ -1019,6 +1082,95 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── tool_run_build / tool_run_lint ────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_run_build_missing_script_returns_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_run_build(&json!({}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_run_build_passes_when_script_exits_zero() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let script_dir = tmp.path().join("script");
|
||||
std::fs::create_dir_all(&script_dir).unwrap();
|
||||
let script_path = script_dir.join("build");
|
||||
std::fs::write(&script_path, "#!/usr/bin/env bash\nexit 0\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_run_build(&json!({}), &ctx).await.unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["passed"], true);
|
||||
assert_eq!(parsed["exit_code"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_run_build_worktree_path_must_be_inside_worktrees() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let wt_dir = tmp.path().join(".huskies").join("worktrees");
|
||||
std::fs::create_dir_all(&wt_dir).unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result =
|
||||
tool_run_build(&json!({"worktree_path": tmp.path().to_str().unwrap()}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("worktrees"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_run_lint_missing_script_returns_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_run_lint(&json!({}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_run_lint_passes_when_script_exits_zero() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let script_dir = tmp.path().join("script");
|
||||
std::fs::create_dir_all(&script_dir).unwrap();
|
||||
let script_path = script_dir.join("lint");
|
||||
std::fs::write(&script_path, "#!/usr/bin/env bash\nexit 0\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_run_lint(&json!({}), &ctx).await.unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["passed"], true);
|
||||
assert_eq!(parsed["exit_code"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_run_lint_fails_when_script_exits_nonzero() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let script_dir = tmp.path().join("script");
|
||||
std::fs::create_dir_all(&script_dir).unwrap();
|
||||
let script_path = script_dir.join("lint");
|
||||
std::fs::write(&script_path, "#!/usr/bin/env bash\nexit 1\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_run_lint(&json!({}), &ctx).await.unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["passed"], false);
|
||||
assert_eq!(parsed["exit_code"], 1);
|
||||
}
|
||||
|
||||
// ── truncate_output ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user