import type { Stats } from "node:fs"; import { lstat, readFile, rm } from "node:fs/promises"; import nodePath from "node:path"; import { field, isNotFound, isObject } from "#common"; import { failureFooter, type FailureReason, type ProfileFailure, withProfile, } from "#profiles"; import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome, } from "#runtime"; export interface PostContext { apiUrl: string; repository: string; issueIndex: string; eventName: string; } interface Anchor { path: string; line: number; side: "NEW" | "OLD"; body: string; } const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu"); const stripAnsi = (text: string): string => text.replace(ANSI, ""); const VERDICTS = new Map([ ["Approved", ["APPROVED", "✅"]], ["Changes requested", ["REQUEST_CHANGES", "🛑"]], ]); const PROFILE_TYPES = new Set(["CLAUDE", "CODEX", "OPENCODE"]); const FAILURE_REASONS = new Set([ "SIGN_IN_FAILED", "USAGE_EXHAUSTED", "FAILED", ]); // Anchors written before the UPPER_SNAKE switch spell sides in lowercase. const OLD_SIDES = new Map([ ["new", "NEW"], ["old", "OLD"], ]); const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value); const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value); const failureComment = (failures: ProfileFailure[]): string => `AI bot failed.\n\n${failureFooter(failures)}`; // The `fetch` subset the reviewer posts through, which tests replace. export type Fetcher = (url: string, init: RequestInit) => Promise; class GiteaRequestError extends Error { constructor(message: string, options?: ErrorOptions) { super(message, options); this.name = "GiteaRequestError"; } } function parseAnchors(text: string, sourcePath: string): Anchor[] { const entries: unknown = JSON.parse(text); if (!Array.isArray(entries)) { throw new TypeError(`${sourcePath}: not an array`); } return entries.map((entry: unknown, index) => { const at = `${sourcePath}[${index}]`; if (!isObject(entry)) { throw new TypeError(`${at}: not an object`); } const path = field(entry, "path"); const line = field(entry, "line"); const givenSide = field(entry, "side"); const side = givenSide === undefined ? "NEW" : (OLD_SIDES.get(givenSide) ?? givenSide); const body = field(entry, "body"); if (typeof path !== "string" || path === "") { throw new TypeError(`${at}.path: expected a repository path`); } if (typeof line !== "number" || !Number.isInteger(line) || line < 1) { throw new TypeError(`${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 TypeError(`${at}.body: expected the finding`); } return { path, line, side, body }; }); } async function readOutcome(path: string): Promise { const value: unknown = JSON.parse(await readFile(path, "utf8")); if (!isObject(value) || !("ok" in value)) { throw new Error("invalid agent outcome"); } const ok = field(value, "ok"); if (ok === false) { // Only known labels reach the comment, never raw agent output. const listed = field(value, "failures"); const failures: unknown[] = Array.isArray(listed) ? listed : []; return { ok: false, failures: failures.flatMap((entry: unknown) => { const type = field(entry, "type"); const model = field(entry, "model"); const reason = field(entry, "reason"); return isProfileType(type) && typeof model === "string" && isFailureReason(reason) ? [{ type, model, reason }] : []; }), }; } const body = field(value, "body"); const reviewPath = field(value, "reviewPath"); const anchorsPath = field(value, "anchorsPath"); const profile = field(value, "profile"); if ( ok !== true || typeof body !== "string" || typeof reviewPath !== "string" || typeof anchorsPath !== "string" || !isObject(profile) ) { throw new Error("invalid agent outcome"); } const type = field(profile, "type"); const model = field(profile, "model"); if (!isProfileType(type) || typeof model !== "string") { throw new Error("invalid agent outcome"); } return { ok: true, body, reviewPath, anchorsPath, profile: { type, model } }; } async function readAgentArtifact( manifest: RuntimeManifest, path: string, name: string, ): Promise { const profile = manifest.profiles.find( (entry) => nodePath.join(entry.home, name) === path, ); if (profile === undefined) { throw new Error(`${name}: outside profile home`); } let stat: Stats | null = null; try { stat = await lstat(path); } catch (error) { if (isNotFound(error)) { return null; } throw error; } if ( !stat.isFile() || stat.isSymbolicLink() || stat.uid !== profile.uid || (stat.mode & 0o077) !== 0 ) { throw new Error(`${name}: expected a profile-owned regular private file`); } const text = await readFile(path, "utf8"); return text === "" ? null : text; } export async function postRuntime( manifestPath: string, context: PostContext, fetcher: Fetcher = fetch, ): Promise { const manifest = await readRuntimeManifest(manifestPath); try { const reviewerText = await readFile(manifest.reviewerFile, "utf8"); const reviewerToken = reviewerText.trim(); const request = async (path: string, body: unknown): Promise => { let response: Response | null = null; try { response = await fetcher(`${context.apiUrl}/${path}`, { method: "POST", headers: { Authorization: `token ${reviewerToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new GiteaRequestError(`${path}: ${detail}`, { cause: error }); } if (!response.ok) { throw new GiteaRequestError( `${path}: ${response.status} ${await response.text()}`, ); } }; const postComment = async (body: string): Promise => { await request( `repos/${context.repository}/issues/${context.issueIndex}/comments`, { body: stripAnsi(body), }, ); }; let outcome: RuntimeOutcome | null = null; try { outcome = await readOutcome(manifest.outcomeFile); } catch (error) { await postComment(failureComment([])); throw new Error("agent phase failed", { cause: error }); } if (!outcome.ok) { await postComment(failureComment(outcome.failures)); throw new Error("agent phase failed"); } try { 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(withProfile(outcome.body, outcome.profile)); return; } const [verdict = "", ...rest] = review.split("\n"); const [event, mark] = VERDICTS.get(verdict.trim()) ?? ["COMMENT", "💬"]; const anchorText = await readAgentArtifact( manifest, outcome.anchorsPath, "anchors.json", ); const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath); const postReview = async (entries: Anchor[]): Promise => { await request( `repos/${context.repository}/pulls/${context.issueIndex}/reviews`, { body: stripAnsi( withProfile( [`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile, ), ), event, comments: entries.map(({ path, line, side, body }) => ({ path, body: stripAnsi(withProfile(body, outcome.profile)), new_position: side === "NEW" ? line : 0, old_position: side === "OLD" ? line : 0, })), }, ); }; await postReview(anchors).catch(async (error: unknown) => { if (anchors.length === 0) { throw error; } console.error( `inline comments rejected: ${error instanceof Error ? error.message : String(error)}`, ); await postReview([]); }); } catch (error) { if (error instanceof GiteaRequestError) { throw error; } await postComment( failureComment([{ ...outcome.profile, reason: "FAILED" }]), ); throw error; } } finally { await rm(manifest.runtimeDir, { recursive: true }).catch( (error: unknown) => { if (!isNotFound(error)) { throw error; } }, ); } }