248 lines
8.5 KiB
TypeScript
248 lines
8.5 KiB
TypeScript
import type { Stats } from "node:fs";
|
|
import { lstat, readFile, rm } from "node:fs/promises";
|
|
import { join } 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<string, [event: string, mark: string]>([
|
|
["Approved", ["APPROVED", "✅"]],
|
|
["Changes requested", ["REQUEST_CHANGES", "🛑"]],
|
|
]);
|
|
const PROFILE_TYPES = new Set<unknown>(["CLAUDE", "CODEX", "OPENCODE"]);
|
|
const FAILURE_REASONS = new Set<unknown>(["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)}`;
|
|
|
|
// The `fetch` subset the reviewer posts through, which tests replace.
|
|
export type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
|
|
|
|
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" : 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<RuntimeOutcome> {
|
|
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<string | null> {
|
|
const profile = manifest.profiles.find((entry) => 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<void> {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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;
|
|
}
|
|
});
|
|
}
|
|
}
|