Files
bot-agents/run.ts
T
temeddix 70d71aa4fd Inline comments (#10)
A finding about one line now lands on that line of the diff: the agent writes anchors as JSON, and the review posts them with the body in one call. Bad entries fail the run, and an anchor Gitea refuses falls back to posting the body alone.

Verified with deno fmt, lint and check.

Reviewed-on: #10
Co-authored-by: bot <temeddix@gmail.com>
Co-committed-by: bot <temeddix@gmail.com>
2026-09-14 00:11:35 +00:00

300 lines
11 KiB
TypeScript

// 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 mid tiers: a run follows a fixed template and the project's checks.
const DEFAULT_MODELS: Record<string, string> = {
claude: "claude-sonnet-5",
codex: "gpt-5.6-terra",
};
const model = (bot: string): string =>
Deno.env.get("MODEL") || DEFAULT_MODELS[bot];
// 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<Response> {
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<void> {
await gitea(REVIEWER_TOKEN, `repos/${REPO}/issues/${INDEX}/comments`, {
body: stripAnsi(body),
});
}
// A review is posted whenever the agent wrote one, whether a review request or
// a comment asked for it. It comes through a file, because a final chat message
// picks up narration while a file's first line is written on purpose. That line
// is the verdict, matched whole; anything unexpected only comments, never
// approves. The mark in front is added here, so it is never part of the match.
const REVIEW_DIR = await Deno.makeTempDir();
const REVIEW_PATH = `${REVIEW_DIR}/review.md`;
const VERDICTS: Record<string, [event: string, mark: string]> = {
Approved: ["APPROVED", "✅"],
"Changes requested": ["REQUEST_CHANGES", "🛑"],
};
// A finding about one line is posted on that line of the diff rather than as
// `file:line` prose in the body. Those anchors come as JSON, so the file and
// line are structured instead of parsed back out of English; a malformed entry
// fails the run, because a silently dropped finding is worse than a red run.
const ANCHORS_PATH = `${REVIEW_DIR}/anchors.json`;
type Anchor = { path: string; line: number; side: "new" | "old"; body: string };
function parseAnchors(text: string): Anchor[] {
const entries: unknown = JSON.parse(text);
if (!Array.isArray(entries)) throw new Error(`${ANCHORS_PATH}: not an array`);
return entries.map((entry: unknown, index) => {
const at = `${ANCHORS_PATH}[${index}]`;
if (typeof entry !== "object" || entry === null) {
throw new Error(`${at}: not an object`);
}
const { path, line, side = "new", body } = entry as Record<string, unknown>;
if (typeof path !== "string" || path === "") {
throw new Error(`${at}.path: expected a repository path`);
}
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
throw new Error(`${at}.line: expected a line number`);
}
if (side !== "new" && side !== "old") {
throw new Error(`${at}.side: expected "new" or "old"`);
}
if (typeof body !== "string" || body.trim() === "") {
throw new Error(`${at}.body: expected the finding`);
}
return { path, line, side, body };
});
}
async function readAnchors(): Promise<Anchor[]> {
const written = await Deno.readTextFile(ANCHORS_PATH).catch(() => null);
return written === null ? [] : parseAnchors(written);
}
async function postResult(body: string): Promise<void> {
const review = await Deno.readTextFile(REVIEW_PATH).catch(() => null);
if (review === null) {
if (EVENT === "pull_request") {
throw new Error(`no review was written to ${REVIEW_PATH}`);
}
return postComment(body);
}
const [verdict, ...rest] = review.split("\n");
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
const post = (anchors: Anchor[]) =>
gitea(REVIEWER_TOKEN, `repos/${REPO}/pulls/${INDEX}/reviews`, {
body: stripAnsi([`${mark} ${verdict.trim()}`, ...rest].join("\n")),
event,
comments: anchors.map(({ path, line, side, body }) => ({
path,
body: stripAnsi(body),
new_position: side === "new" ? line : 0,
old_position: side === "old" ? line : 0,
})),
});
const anchors = await readAnchors();
// Gitea rejects the whole review when an anchor names a line outside the
// diff, and a verdict that never lands blocks the pull request, so the body
// goes up alone rather than not at all.
await post(anchors).catch(async (error: Error) => {
if (anchors.length === 0) throw error;
console.error(`inline comments rejected: ${error.message}`);
await post([]);
});
}
async function run(command: string, args: string[]): Promise<void> {
const { success, code } = await new Deno.Command(command, { args }).output();
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
}
// Commits belong to the same account as the pull request they end up in.
async function configureGitAuthor(): Promise<void> {
const user: GiteaUser = await (await gitea(AUTHOR_TOKEN, "user")).json();
for (const [key, value] of [["name", user.login], ["email", user.email]]) {
await run("git", ["config", "--global", `user.${key}`, value]);
}
}
// The superpowers plugin gives the agent its skills, including the code review
// one that the prompt asks for. Both installs are idempotent on the persisted
// home.
async function installSuperpowers(bot: string): Promise<void> {
const commands = bot === "claude"
? [
["plugin", "marketplace", "add", "obra/superpowers-marketplace"],
["plugin", "install", "-y", "superpowers@superpowers-marketplace"],
]
: [["plugin", "add", "superpowers@openai-curated-remote"]];
for (const args of commands) await run(bot, args);
}
async function renderPrompt(): Promise<string> {
const comments: GiteaComment[] = await (await gitea(
REVIEWER_TOKEN,
`repos/${REPO}/issues/${INDEX}/comments?limit=100`,
)).json();
const values: Record<string, string> = {
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,
REVIEW_PATH,
ANCHORS_PATH,
};
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<string> {
const token = Deno.env.get("BOT_TOKEN");
if (!token) {
throw new Error(
"Run `claude setup-token` locally and set the `bot-token` action input.",
);
}
await installSuperpowers("claude");
const claude = new Deno.Command("claude", {
args: [
"--print",
"--dangerously-skip-permissions",
"--model",
model("claude"),
"--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: { result?: string; subtype: string } | undefined;
// 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;
}
await claude.status;
if (result?.result === undefined) {
throw new Error(`claude ended with ${result?.subtype ?? "no result"}`);
}
return result.result;
}
// Posts the device code so a human can finish the login on the persisted home.
async function codexDeviceLogin(): Promise<void> {
const login = new Deno.Command("codex", {
args: ["login", "--device-auth"],
stdout: "piped",
stderr: "piped",
}).spawn();
let shown = "";
const collect = (stream: ReadableStream<Uint8Array>): Promise<void> =>
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<string> {
const loggedIn = await new Deno.Command("codex", {
args: ["login", "status"],
})
.output();
if (!loggedIn.success) await codexDeviceLogin();
await installSuperpowers("codex");
const file = await Deno.makeTempFile();
const status = await new Deno.Command("codex", {
args: [
"exec",
"--model",
model("codex"),
"--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);
}
try {
await configureGitAuthor();
const prompt = await renderPrompt();
const result = env("BOT_TYPE") === "claude"
? await runClaude(prompt)
: await runCodex(prompt);
await postResult(result);
} catch (error) {
await postComment(`Bot failed: ${error}`);
throw error;
}