import { readFile } from "node:fs/promises";
const {
GOBARE_API = "https://api.gobare.dev", GOBARE_TOKEN,
GITHUB_API_URL = "https://api.github.com", GITHUB_TOKEN, GITHUB_REPOSITORY,
GITHUB_EVENT_PATH, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT = "1",
} = process.env;
const MARKER = "<!-- gobare-review -->";
const pr = JSON.parse(await readFile(GITHUB_EVENT_PATH, "utf8")).pull_request;
function github(path, init = {}) {
return fetch(`${GITHUB_API_URL}${path}`, {
...init,
headers: { authorization: `Bearer ${GITHUB_TOKEN}`, accept: "application/vnd.github+json", "x-github-api-version": "2022-11-28", ...init.headers },
});
}
async function gobare(method, path, body, headers = {}) {
for (let attempt = 1; ; attempt++) {
try {
const res = await fetch(`${GOBARE_API}${path}`, {
method, body: body && JSON.stringify(body),
headers: { authorization: `Bearer ${GOBARE_TOKEN}`, "content-type": "application/json", ...headers },
});
if (res.status >= 500 && attempt < 4) throw new Error(`${res.status}`);
if (!res.ok) throw Object.assign(new Error(`${method} ${path}: ${res.status} ${await res.text()}`), { final: true });
return res.json();
} catch (err) {
if (err.final || attempt >= 4) throw err;
await new Promise((r) => setTimeout(r, attempt * 2000));
}
}
}
const diff = await (await github(`/repos/${GITHUB_REPOSITORY}/pulls/${pr.number}`, { headers: { accept: "application/vnd.github.diff" } })).text();
const schema = {
type: "object",
required: ["summary", "findings"],
properties: {
summary: { type: "string" },
findings: {
type: "array",
items: {
type: "object",
required: ["file", "severity", "comment"],
properties: {
file: { type: "string" },
line: { type: ["integer", "null"] },
severity: { enum: ["blocking", "suggestion", "nit"] },
comment: { type: "string" },
},
},
},
},
};
const session = await gobare("POST", "/v1/sessions", {
title: `${GITHUB_REPOSITORY}#${pr.number}`,
metadata: { repo: GITHUB_REPOSITORY, pr: String(pr.number), sha: pr.head.sha },
agent: {
instructions: [
"You review pull requests. Read the whole diff before commenting.",
"Only report problems you can point at in the diff; an empty findings list is a valid review. Never ask a question.",
`Write the review to /workspace/outputs/review.json as JSON matching this schema, then reply "done": ${JSON.stringify(schema)}`,
].join("\n"),
},
environment: { files: [{ type: "inline", path: "pr.diff", data: Buffer.from(diff).toString("base64") }] },
input: `Review the pull request whose unified diff is in /workspace/pr.diff.\n\nTitle: ${pr.title}\n\n${pr.body ?? ""}`,
}, { "idempotency-key": `review-${pr.id}-${pr.head.sha}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}` });
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, async () => {
await gobare("DELETE", `/v1/sessions/${session.id}`).catch(() => {});
process.exit(130);
});
}
async function settledTurn(after) {
for (;;) {
await new Promise((r) => setTimeout(r, 3000));
const turn = (await gobare("GET", `/v1/sessions/${session.id}/turns?limit=1`)).data[0];
if (!turn || turn.id === after) continue;
if (["failed", "cancelled"].includes(turn.status)) throw new Error(`review turn ${turn.status}: ${JSON.stringify(turn.error)}`);
if (turn.status === "completed" && turn.artifacts !== "pending") return turn;
}
}
async function readReview(turn) {
const artifacts = await gobare("GET", `/v1/sessions/${session.id}/artifacts?turn_id=${turn.id}`);
const file = artifacts.data.find((a) => a.path.endsWith("/review.json"));
if (!file) return { problem: "/workspace/outputs/review.json was not written" };
const res = await fetch(`${GOBARE_API}/v1/sessions/${session.id}/artifacts/${file.id}/content`, { headers: { authorization: `Bearer ${GOBARE_TOKEN}` } });
try {
const review = JSON.parse(await res.text());
if (typeof review.summary !== "string" || !Array.isArray(review.findings)) return { problem: "summary must be a string and findings an array" };
const bad = review.findings.find((f) => !["blocking", "suggestion", "nit"].includes(f.severity) || typeof f.file !== "string");
if (bad) return { problem: `finding ${JSON.stringify(bad)} does not match the schema` };
return { review };
} catch (err) {
return { problem: `review.json is not valid JSON: ${err.message}` };
}
}
try {
let turn = await settledTurn();
let { review, problem } = await readReview(turn);
if (problem) {
await gobare("POST", `/v1/sessions/${session.id}/events`, { events: [{ type: "input.message", content: [{ type: "input_text", text: `${problem}. Write /workspace/outputs/review.json again, matching the schema exactly.` }] }] });
turn = await settledTurn(turn.id);
({ review, problem } = await readReview(turn));
if (problem) throw new Error(`the review is still unusable after one correction: ${problem}`);
}
const icon = { blocking: "🛑", suggestion: "💡", nit: "·" };
const body = [
MARKER,
`### Agent review of \`${pr.head.sha.slice(0, 7)}\``,
review.summary,
...review.findings.map((f) => `- ${icon[f.severity]} **${f.severity}** \`${f.file}${f.line ? `:${f.line}` : ""}\` — ${f.comment}`),
].join("\n\n");
const comments = await (await github(`/repos/${GITHUB_REPOSITORY}/issues/${pr.number}/comments?per_page=100`)).json();
const mine = comments.find((c) => c.body?.startsWith(MARKER));
const res = mine
? await github(`/repos/${GITHUB_REPOSITORY}/issues/comments/${mine.id}`, { method: "PATCH", body: JSON.stringify({ body }) })
: await github(`/repos/${GITHUB_REPOSITORY}/issues/${pr.number}/comments`, { method: "POST", body: JSON.stringify({ body }) });
if (!res.ok) throw new Error(`posting the review answered ${res.status}: ${await res.text()}`);
console.log(`${mine ? "updated" : "posted"} review: ${review.findings.length} finding(s)`);
if (review.findings.some((f) => f.severity === "blocking")) process.exitCode = 1;
} finally {
await gobare("DELETE", `/v1/sessions/${session.id}`);
}