063eb09750
Co-authored-by: Danny Kim <temeddix@gmail.com> Co-committed-by: Danny Kim <temeddix@gmail.com>
299 lines
9.1 KiB
TypeScript
299 lines
9.1 KiB
TypeScript
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<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",
|
|
]);
|
|
// Anchors written before the UPPER_SNAKE switch spell sides in lowercase.
|
|
const OLD_SIDES = new Map<unknown, Anchor["side"]>([
|
|
["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<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" : (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<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) => 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<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;
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|