Restore post.ts from main

This commit is contained in:
Danny Kim
2026-10-17 18:48:15 +09:00
parent c6fc183ad6
commit 0c5059e3b4
2 changed files with 169 additions and 231 deletions
-114
View File
@@ -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<RuntimeOutcome> {
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<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;
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;
}
+142 -90
View File
@@ -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<RuntimeOutcome, { ok: true }>;
type GiteaRequest = (path: string, body: unknown) => Promise<void>;
// The reviewer's view of one issue or pull request.
interface Publisher {
context: PostContext;
request: GiteaRequest;
postComment(body: string): Promise<void>;
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,11 +35,115 @@ const failureComment = (failures: ProfileFailure[]): string =>
class GiteaRequestError extends Error {}
function giteaRequest(apiUrl: string, reviewerToken: string, fetcher: typeof fetch): GiteaRequest {
return async (path, body) => {
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, 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 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 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,
},
};
}
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;
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: typeof fetch = fetch,
): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath);
try {
const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim();
const request = async (path: string, body: unknown): Promise<void> => {
let response: Response;
try {
response = await fetcher(`${apiUrl}/${path}`, {
response = await fetcher(`${context.apiUrl}/${path}`, {
method: "POST",
headers: {
Authorization: `token ${reviewerToken}`,
@@ -58,110 +159,61 @@ function giteaRequest(apiUrl: string, reviewerToken: string, fetcher: typeof fet
throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`);
}
};
}
const postComment = (body: string): Promise<void> =>
request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
body: stripAnsi(body),
});
async function readSuccess(
manifest: RuntimeManifest,
publisher: Publisher,
): Promise<SuccessOutcome> {
let outcome: RuntimeOutcome;
try {
outcome = await readOutcome(manifest.outcomeFile);
} catch (error) {
await publisher.postComment(failureComment([]));
await postComment(failureComment([]));
throw new Error("agent phase failed", { cause: error });
}
if (!outcome.ok) {
await publisher.postComment(failureComment(outcome.failures));
await postComment(failureComment(outcome.failures));
throw new Error("agent phase failed");
}
return outcome;
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;
}
async function postReview(
publisher: Publisher,
outcome: SuccessOutcome,
review: string,
anchors: Anchor[],
): Promise<void> {
const { context, request } = publisher;
const [verdict = "", ...rest] = review.split("\n");
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
const send = 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),
),
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<void> =>
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, outcome.profile)),
body: stripAnsi(withProfile(body, profile)),
new_position: side === "new" ? line : 0,
old_position: side === "old" ? line : 0,
})),
});
};
await send(anchors).catch(async (error: unknown) => {
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 send([]);
await postReview([]);
});
}
async function publishResult(
manifest: RuntimeManifest,
outcome: SuccessOutcome,
publisher: Publisher,
): Promise<void> {
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;
}
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<void> {
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 = await readSuccess(manifest, publisher);
try {
await publishResult(manifest, outcome, publisher);
} catch (error) {
if (error instanceof GiteaRequestError) throw error;
await publisher.postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
await postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
throw error;
}
}
export async function postRuntime(
manifestPath: string,
context: PostContext,
fetcher: typeof fetch = fetch,
): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath);
try {
await publish(manifest, context, fetcher);
} finally {
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => {
if (!isNotFound(error)) throw error;