import type { Stats } from "node:fs"; import { lstat, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; import process from "node:process"; import { isNotFound } from "./errors.ts"; import { isObject } from "./json.ts"; import { failureFooter, withProfile } from "./profiles.ts"; import type { FailureReason, ProfileFailure } from "./profiles.ts"; import { readRuntimeManifest } from "./runtime.ts"; import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts"; 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: Record = { Approved: ["APPROVED", "✅"], "Changes requested": ["REQUEST_CHANGES", "🛑"], }; const PROFILE_TYPES: Set = new Set(["claude", "codex", "opencode"]); const FAILURE_REASONS: Set = new Set(["sign-in failed", "usage exhausted", "failed"]); 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)}`; class GiteaRequestError extends Error {} 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, line, side = "new", body } = entry; 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 outcome = value; if (outcome.ok === false) { // Only known labels reach the comment, never raw agent output. const failures: unknown[] = Array.isArray(outcome.failures) ? outcome.failures : []; return { ok: false, failures: failures.flatMap((entry: unknown) => { const { type, model, reason } = isObject(entry) ? entry : {}; return isProfileType(type) && typeof model === "string" && isFailureReason(reason) ? [{ type, model, reason }] : []; }), }; } if ( outcome.ok !== true || typeof outcome.body !== "string" || typeof outcome.reviewPath !== "string" || typeof outcome.anchorsPath !== "string" || !isObject(outcome.profile) ) { throw new Error("invalid agent outcome"); } const profile = outcome.profile; if (!isProfileType(profile.type) || typeof profile.model !== "string") { throw new Error("invalid agent outcome"); } return { ok: true, body: outcome.body, reviewPath: outcome.reviewPath, anchorsPath: outcome.anchorsPath, profile: { type: profile.type, model: profile.model, }, }; } 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: Stats; 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: typeof fetch = fetch, ): Promise { const manifest = await readRuntimeManifest(manifestPath); try { const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim(); const request = async (path: string, body: unknown): Promise => { let response: Response; 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 = (body: string): Promise => request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, { body: stripAnsi(body), }); let outcome: RuntimeOutcome; 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[verdict.trim()] ?? ["COMMENT", "💬"]; const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json"); const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath); const { profile } = outcome; const postReview = (entries: Anchor[]): Promise => request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, { body: stripAnsi(withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), profile)), event, comments: entries.map(({ path, line, side, body }) => ({ path, body: stripAnsi(withProfile(body, 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; }); } } function required(name: string): string { const value = process.env[name]; if (value === undefined || value === "") throw new Error(`${name} is not set`); return value; } if (import.meta.main) { await postRuntime(required("BOT_AGENTS_MANIFEST"), { apiUrl: required("GITEA_API_URL"), repository: required("GITEA_REPOSITORY"), issueIndex: required("ISSUE_INDEX"), eventName: required("EVENT_NAME"), }); }