import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { mkdtemp, rm, mkdir } from "node:fs/promises"; import path from "node:path"; import crypto from "node:crypto"; import { authenticatedCloneUrl, createBranch, createPullRequest } from "./gitea.js"; const execFileAsync = promisify(execFile); const WORKSPACE_ROOT = "/workspace"; async function run(cmd, args, opts = {}) { const { stdout } = await execFileAsync(cmd, args, { maxBuffer: 1024 * 1024 * 32, ...opts, }); return stdout; } async function withWorkspace(fn) { await mkdir(WORKSPACE_ROOT, { recursive: true }); const dir = await mkdtemp(path.join(WORKSPACE_ROOT, "job-")); try { return await fn(dir); } finally { await rm(dir, { recursive: true, force: true }); } } // Runs Claude Code headless. Unattended containers have no TTY to answer permission // prompts, so this trusts the sandboxing of the throwaway clone dir instead: // bypassPermissions to avoid hanging, plus --disallowedTools as defense in depth so // Claude can never push/commit/checkout itself — this script owns those steps. async function runClaude(cwd, prompt, { allowEdits }) { const disallowed = ["Bash(git push:*)", "Bash(git commit:*)", "Bash(git checkout:*)"]; if (!allowEdits) disallowed.push("Edit", "Write", "NotebookEdit"); const args = [ "-p", prompt, "--output-format", "text", "--permission-mode", "bypassPermissions", "--disallowedTools", disallowed.join(","), ]; return run("claude", args, { cwd }); } export async function reviewPullRequest({ owner, repo, ref, cloneUrl, prTitle, prBody }) { return withWorkspace(async (dir) => { const authedUrl = authenticatedCloneUrl(cloneUrl); await run("git", ["clone", "--quiet", authedUrl, dir]); await run("git", ["fetch", "--quiet", "origin", ref], { cwd: dir }); await run("git", ["checkout", "--quiet", ref], { cwd: dir }); const prompt = [ "You are reviewing a pull request. Diff HEAD against the base branch (origin/main)", "and give a concise, specific code review: correctness bugs first, then", "simplification/reuse/efficiency notes. Read-only — do not edit any files.", `PR title: ${prTitle}`, `PR description:\n${prBody}`, ].join("\n"); return runClaude(dir, prompt, { allowEdits: false }); }); } export async function runChatTask({ owner, repo, cloneUrl, instruction }) { return withWorkspace(async (dir) => { const authedUrl = authenticatedCloneUrl(cloneUrl); await run("git", ["clone", "--quiet", authedUrl, dir]); const branch = `claude/${crypto.randomBytes(4).toString("hex")}`; await createBranch(owner, repo, branch); await run("git", ["fetch", "--quiet", "origin", branch], { cwd: dir }); await run("git", ["checkout", "--quiet", branch], { cwd: dir }); const prompt = [ "Implement the change described below in this repository. Make the smallest", "correct change that satisfies it. Do not run git commit, git push, or git", "checkout yourself — just edit files; committing and pushing happens separately.", `Instruction: ${instruction}`, ].join("\n"); await runClaude(dir, prompt, { allowEdits: true }); await run("git", ["add", "-A"], { cwd: dir }); const status = await run("git", ["status", "--porcelain"], { cwd: dir }); if (!status.trim()) { throw new Error("Claude made no changes for this instruction"); } await run("git", ["commit", "-m", `claude: ${instruction}`.slice(0, 200)], { cwd: dir }); await run("git", ["push", "--quiet", "origin", branch], { cwd: dir }); return createPullRequest(owner, repo, { head: branch, title: `claude: ${instruction}`.slice(0, 200), body: `Requested via Matrix:\n\n> ${instruction}`, }); }); }