// Runs a coding agent for one Gitea issue or pull request event and posts its // final response back. The agent works as the author account through // GITEA_TOKEN; everything this script posts goes through the reviewer token, // so it appears as the bot account. import { TextLineStream } from "jsr:@std/streams@1/text-line-stream"; type GiteaUser = { login: string; email: string }; type GiteaComment = { user: GiteaUser; created_at: string; body: string }; const env = (name: string): string => { const value = Deno.env.get(name); if (value === undefined) throw new Error(`${name} is not set`); return value; }; const API = env("GITEA_API_URL"); const REPO = env("GITEA_REPOSITORY"); const INDEX = env("ISSUE_INDEX"); const EVENT = env("EVENT_NAME"); const AUTHOR_TOKEN = env("GITEA_TOKEN"); const REVIEWER_TOKEN = env("REVIEWER_TOKEN"); const RULES_PATH = "repos/commons/code-rules/raw/README.md"; // The reviewer token is withheld so the agent cannot approve as the bot. const { REVIEWER_TOKEN: _, ...agentEnv } = Deno.env.toObject(); async function gitea( token: string, path: string, body?: unknown, ): Promise { const response = await fetch(`${API}/${path}`, { method: body === undefined ? "GET" : "POST", headers: { Authorization: `token ${token}`, "Content-Type": "application/json", }, body: body === undefined ? undefined : JSON.stringify(body), }); if (!response.ok) { throw new Error(`${path}: ${response.status} ${await response.text()}`); } return response; } const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g"); const stripAnsi = (text: string): string => text.replace(ANSI, ""); async function postComment(body: string): Promise { await gitea(REVIEWER_TOKEN, `repos/${REPO}/issues/${INDEX}/comments`, { body: stripAnsi(body), }); } // A pull request event is a review request, so the response becomes a review: // changes are requested when the agent asked @bot to fix something, a failed // run only comments, and anything else approves. async function postResult(body: string): Promise { if (EVENT !== "pull_request") return postComment(body); const event = body.includes("@bot") ? "REQUEST_CHANGES" : body.startsWith("Bot failed:") ? "COMMENT" : "APPROVED"; await gitea(REVIEWER_TOKEN, `repos/${REPO}/pulls/${INDEX}/reviews`, { body: stripAnsi(body), event, }); } // Commits belong to the same account as the pull request they end up in. async function configureGitAuthor(): Promise { const user: GiteaUser = await (await gitea(AUTHOR_TOKEN, "user")).json(); for (const [key, value] of [["name", user.login], ["email", user.email]]) { await new Deno.Command("git", { args: ["config", "--global", `user.${key}`, value], }).output(); } } async function renderPrompt(): Promise { const comments: GiteaComment[] = await (await gitea( REVIEWER_TOKEN, `repos/${REPO}/issues/${INDEX}/comments?limit=100`, )).json(); const values: Record = { COMMENT: env("COMMENT"), ISSUE_COMMENTS: comments .map((c) => `## ${c.user.login} at ${c.created_at}\n\n${c.body}\n`) .join("\n"), CODE_RULES: await (await gitea(REVIEWER_TOKEN, RULES_PATH)).text(), EVENT_NAME: EVENT, GITEA_API_URL: API, GITEA_REPOSITORY: REPO, ISSUE_INDEX: INDEX, }; const template = await Deno.readTextFile( new URL("prompt.md", import.meta.url), ); return template.replace( /\$\{(\w+)\}/g, (match, name) => values[name] ?? match, ); } async function runClaude(prompt: string): Promise { const token = Deno.env.get("BOT_TOKEN"); if (!token) { await postComment( "Run `claude setup-token` locally and set the `bot-token` action input.", ); Deno.exit(1); } const claude = new Deno.Command("claude", { args: [ "--print", "--dangerously-skip-permissions", "--model", "claude-fable-5", "--output-format", "stream-json", "--verbose", prompt, ], // Claude refuses --dangerously-skip-permissions as root outside a sandbox. env: { ...agentEnv, CLAUDE_CODE_OAUTH_TOKEN: token, IS_SANDBOX: "1" }, clearEnv: true, stdout: "piped", }).spawn(); let result = "Bot failed: no result"; // Print events as they stream so the runner does not kill the job as a zombie. const lines = claude.stdout .pipeThrough(new TextDecoderStream()) .pipeThrough(new TextLineStream()); for await (const line of lines) { if (!line) continue; const event = JSON.parse(line); for (const part of event.message?.content ?? []) { const text = part.thinking ?? part.text ?? part.name; if (text) console.log(text); } if (event.type === "result") { result = event.result ?? `Bot failed: ${event.subtype}`; } } await claude.status; return result; } // Posts the device code so a human can finish the login on the persisted home. async function codexDeviceLogin(): Promise { const login = new Deno.Command("codex", { args: ["login", "--device-auth"], stdout: "piped", stderr: "piped", }).spawn(); let shown = ""; const collect = (stream: ReadableStream): Promise => stream.pipeThrough(new TextDecoderStream()).pipeTo( new WritableStream({ write: (chunk) => void (shown += chunk) }), ); const drained = Promise.all([collect(login.stdout), collect(login.stderr)]); await new Promise((resolve) => setTimeout(resolve, 3000)); await postComment(shown); await Promise.all([login.status, drained]); const status = await new Deno.Command("codex", { args: ["login", "status"] }) .output(); await postComment(new TextDecoder().decode(status.stdout)); } async function runCodex(prompt: string): Promise { const loggedIn = await new Deno.Command("codex", { args: ["login", "status"], }) .output(); if (!loggedIn.success) await codexDeviceLogin(); const file = await Deno.makeTempFile(); const status = await new Deno.Command("codex", { args: [ "exec", "--model", "gpt-5.5", "--dangerously-bypass-approvals-and-sandbox", "--output-last-message", file, prompt, ], env: agentEnv, clearEnv: true, stdout: "inherit", stderr: "inherit", }).spawn().status; if (!status.success) throw new Error(`codex exited with ${status.code}`); return await Deno.readTextFile(file); } await configureGitAuthor(); const prompt = await renderPrompt(); const result = env("BOT_TYPE") === "claude" ? await runClaude(prompt) : await runCodex(prompt); await postResult(result);