import { join } from "jsr:@std/path@1"; import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "./runtime.ts"; import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "./profiles.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", "🛑"], }; const PROFILE_TYPES = ["claude", "codex", "opencode"]; const FAILURE_REASONS = ["sign-in failed", "usage exhausted", "failed"]; 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 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) { // Only known labels reach the comment, never raw agent output. const failures = Array.isArray(outcome.failures) ? outcome.failures : []; return { ok: false, failures: failures.flatMap((entry: unknown) => { const { type, model, reason } = (entry ?? {}) as Record; return typeof type === "string" && PROFILE_TYPES.includes(type) && typeof model === "string" && typeof reason === "string" && FAILURE_REASONS.includes(reason) ? [ { type: type as ProfileFailure["type"], model, reason: reason as FailureReason, }, ] : []; }), }; } if ( outcome.ok !== true || typeof outcome.body !== "string" || typeof outcome.reviewPath !== "string" || typeof outcome.anchorsPath !== "string" || typeof outcome.profile !== "object" || outcome.profile === null ) { throw new Error("invalid agent outcome"); } const profile = outcome.profile as Record; if (typeof profile.type !== "string" || 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 as "claude" | "codex" | "opencode", 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: 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 => { 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) => 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 postReview = (entries: Anchor[]) => 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: Error) => { if (anchors.length === 0) throw error; console.error(`inline comments rejected: ${error.message}`); await postReview([]); }); } catch (error) { if (error instanceof GiteaRequestError) throw error; await postComment(failureComment([{ ...outcome.profile, reason: "failed" }])); throw error; } } 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"), }); }