Files
bot-agents/src/post/post.ts
T
Danny Kim 063eb09750 Replace NodeJS dict aliases with maps and named field reads
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-10-20 05:01:49 +00:00

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;
}
},
);
}
}