import { join } from "jsr:@std/path@1"; import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome, } from "./runtime.ts"; export type PostContext = { apiUrl: string; repository: string; issueIndex: string; eventName: string; }; type Anchor = { path: string; line: number; side: "new" | "old"; body: string; }; const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g"); const stripAnsi = (text: string): string => text.replace(ANSI, ""); const VERDICTS: Record = { Approved: ["APPROVED", "✅"], "Changes requested": ["REQUEST_CHANGES", "🛑"], }; function parseAnchors(text: string, sourcePath: string): Anchor[] { const entries: unknown = JSON.parse(text); if (!Array.isArray(entries)) throw new Error(`${sourcePath}: not an array`); return entries.map((entry: unknown, index) => { const at = `${sourcePath}[${index}]`; if (typeof entry !== "object" || entry === null) { throw new Error(`${at}: not an object`); } const { path, line, side = "new", body } = entry as Record; 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 readOutcome(path: string): Promise { const value: unknown = JSON.parse(await Deno.readTextFile(path)); if (typeof value !== "object" || value === null || !("ok" in value)) { throw new Error("invalid agent outcome"); } const outcome = value as Record; if (outcome.ok === false) return { ok: false }; if ( outcome.ok !== true || typeof outcome.body !== "string" || typeof outcome.reviewPath !== "string" || typeof outcome.anchorsPath !== "string" ) { throw new Error("invalid agent outcome"); } return { ok: true, body: outcome.body, reviewPath: outcome.reviewPath, anchorsPath: outcome.anchorsPath, }; } async function readAgentArtifact( manifest: RuntimeManifest, path: string, name: string, ): Promise { const profile = manifest.profiles.find((entry) => join(entry.home, name) === path ); if (profile === undefined) throw new Error(`${name}: outside profile home`); let stat: Deno.FileInfo; try { stat = await Deno.lstat(path); } catch (error) { if (error instanceof Deno.errors.NotFound) return null; throw error; } if ( !stat.isFile || stat.isSymlink || stat.uid !== profile.uid || (stat.mode! & 0o077) !== 0 ) { throw new Error(`${name}: expected a profile-owned regular private file`); } const text = await Deno.readTextFile(path); return text === "" ? null : text; } export async function postRuntime( manifestPath: string, context: PostContext, fetcher: typeof fetch = fetch, ): Promise { const manifest = await readRuntimeManifest(manifestPath); try { const reviewerToken = (await Deno.readTextFile(manifest.reviewerFile)) .trim(); const request = async (path: string, body: unknown): Promise => { const response = await fetcher(`${context.apiUrl}/${path}`, { method: "POST", headers: { Authorization: `token ${reviewerToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); if (!response.ok) { throw new Error(`${path}: ${response.status} ${await response.text()}`); } }; const postComment = (body: string) => request( `repos/${context.repository}/issues/${context.issueIndex}/comments`, { body: stripAnsi(body) }, ); let outcome: RuntimeOutcome; try { outcome = await readOutcome(manifest.outcomeFile); } catch (error) { await postComment("AI bot failed."); throw new Error("agent phase failed", { cause: error }); } if (!outcome.ok) { await postComment("AI bot failed."); throw new Error("agent phase failed"); } const review = await readAgentArtifact( manifest, outcome.reviewPath, "review.md", ); if (review === null) { if (context.eventName === "pull_request") { throw new Error("agent did not write a pull request review"); } await postComment(outcome.body); return; } const [verdict, ...rest] = review.split("\n"); const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"]; const anchorText = await readAgentArtifact( manifest, outcome.anchorsPath, "anchors.json", ); const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath); const postReview = (entries: Anchor[]) => request( `repos/${context.repository}/pulls/${context.issueIndex}/reviews`, { body: stripAnsi([`${mark} ${verdict.trim()}`, ...rest].join("\n")), event, comments: entries.map(({ path, line, side, body }) => ({ path, body: stripAnsi(body), new_position: side === "new" ? line : 0, old_position: side === "old" ? line : 0, })), }, ); await postReview(anchors).catch(async (error: Error) => { if (anchors.length === 0) throw error; console.error(`inline comments rejected: ${error.message}`); await postReview([]); }); } finally { await Deno.remove(manifest.runtimeDir, { recursive: true }).catch( (error) => { if (!(error instanceof Deno.errors.NotFound)) throw error; }, ); } } if (import.meta.main) { const required = (name: string): string => { const value = Deno.env.get(name); if (!value) throw new Error(`${name} is not set`); return value; }; await postRuntime(required("BOT_AGENTS_MANIFEST"), { apiUrl: required("GITEA_API_URL"), repository: required("GITEA_REPOSITORY"), issueIndex: required("ISSUE_INDEX"), eventName: required("EVENT_NAME"), }); }