diff --git a/src/post-artifacts.ts b/src/post-artifacts.ts deleted file mode 100644 index 683dfb3..0000000 --- a/src/post-artifacts.ts +++ /dev/null @@ -1,114 +0,0 @@ -// Reads what the agent phase left behind: its outcome, and the review and -// anchors files it wrote into a profile home. -import type { Stats } from "node:fs"; -import { lstat, readFile } from "node:fs/promises"; -import { join } from "node:path"; - -import { isNotFound } from "./errors.ts"; -import { isObject } from "./json.ts"; -import { isFailureReason, isProfileType } from "./profiles.ts"; -import type { ProfileFailure } from "./profiles.ts"; -import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts"; - -export interface Anchor { - path: string; - line: number; - side: "new" | "old"; - body: string; -} - -function parseAnchor(entry: unknown, at: string): Anchor { - 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 }; -} - -export 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) => parseAnchor(entry, `${sourcePath}[${index}]`)); -} - -// Only known labels reach the comment, never raw agent output. -function knownFailures(failures: unknown): ProfileFailure[] { - return (Array.isArray(failures) ? failures : []).flatMap((entry: unknown) => { - if (!isObject(entry)) { - return []; - } - const { type, model, reason } = entry; - return isProfileType(type) && typeof model === "string" && isFailureReason(reason) - ? [{ type, model, reason }] - : []; - }); -} - -export 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"); - } - if (value.ok === false) { - return { ok: false, failures: knownFailures(value.failures) }; - } - const { ok, body, reviewPath, anchorsPath, profile } = value; - if ( - ok !== true || - typeof body !== "string" || - typeof reviewPath !== "string" || - typeof anchorsPath !== "string" || - !isObject(profile) || - !isProfileType(profile.type) || - typeof profile.model !== "string" - ) { - throw new Error("invalid agent outcome"); - } - return { - ok: true, - body, - reviewPath, - anchorsPath, - profile: { type: profile.type, model: profile.model }, - }; -} - -export 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; -} diff --git a/src/post.ts b/src/post.ts index 99af124..cccdf05 100644 --- a/src/post.ts +++ b/src/post.ts @@ -1,10 +1,11 @@ -import { readFile, rm } from "node:fs/promises"; +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 { parseAnchors, readAgentArtifact, readOutcome } from "./post-artifacts.ts"; -import type { Anchor } from "./post-artifacts.ts"; -import { failureFooter, withProfile } from "./profiles.ts"; +import { isObject } from "./json.ts"; +import { failureFooter, isFailureReason, isProfileType, withProfile } from "./profiles.ts"; import type { ProfileFailure } from "./profiles.ts"; import { readRuntimeManifest } from "./runtime.ts"; import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts"; @@ -16,15 +17,11 @@ export interface PostContext { eventName: string; } -type SuccessOutcome = Extract; - -type GiteaRequest = (path: string, body: unknown) => Promise; - -// The reviewer's view of one issue or pull request. -interface Publisher { - context: PostContext; - request: GiteaRequest; - postComment(body: string): Promise; +interface Anchor { + path: string; + line: number; + side: "new" | "old"; + body: string; } const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu"); @@ -38,120 +35,101 @@ const failureComment = (failures: ProfileFailure[]): string => class GiteaRequestError extends Error {} -function giteaRequest(apiUrl: string, reviewerToken: string, fetcher: typeof fetch): GiteaRequest { - return async (path, body) => { - let response: Response; - try { - response = await fetcher(`${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()}`); - } - }; -} - -async function readSuccess( - manifest: RuntimeManifest, - publisher: Publisher, -): Promise { - let outcome: RuntimeOutcome; - try { - outcome = await readOutcome(manifest.outcomeFile); - } catch (error) { - await publisher.postComment(failureComment([])); - throw new Error("agent phase failed", { cause: 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`); } - if (!outcome.ok) { - await publisher.postComment(failureComment(outcome.failures)); - throw new Error("agent phase failed"); - } - return outcome; -} - -async function postReview( - publisher: Publisher, - outcome: SuccessOutcome, - review: string, - anchors: Anchor[], -): Promise { - const { context, request } = publisher; - const [verdict = "", ...rest] = review.split("\n"); - const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"]; - const send = 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 send(anchors).catch(async (error: unknown) => { - if (anchors.length === 0) throw error; - console.error( - `inline comments rejected: ${error instanceof Error ? error.message : String(error)}`, - ); - await send([]); + 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 publishResult( - manifest: RuntimeManifest, - outcome: SuccessOutcome, - publisher: Publisher, -): Promise { - const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md"); - if (review === null) { - if (publisher.context.eventName === "pull_request") { - throw new Error("agent did not write a pull request review"); - } - await publisher.postComment(withProfile(outcome.body, outcome.profile)); - return; +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 anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json"); - const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath); - await postReview(publisher, outcome, review, anchors); -} - -async function publish( - manifest: RuntimeManifest, - context: PostContext, - fetcher: typeof fetch, -): Promise { - const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim(); - const request = giteaRequest(context.apiUrl, reviewerToken, fetcher); - const publisher: Publisher = { - context, - request, - postComment: async (body) => { - await request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, { - body: stripAnsi(body), - }); + 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, }, }; - const outcome = await readSuccess(manifest, publisher); +} + +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 { - await publishResult(manifest, outcome, publisher); + stat = await lstat(path); } catch (error) { - if (error instanceof GiteaRequestError) throw error; - await publisher.postComment(failureComment([{ ...outcome.profile, reason: "failed" }])); + 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( @@ -161,7 +139,81 @@ export async function postRuntime( ): Promise { const manifest = await readRuntimeManifest(manifestPath); try { - await publish(manifest, context, fetcher); + 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;