Restore post.ts from main
This commit is contained in:
@@ -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;
|
|
||||||
}
|
|
||||||
+143
-91
@@ -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 process from "node:process";
|
||||||
|
|
||||||
import { isNotFound } from "./errors.ts";
|
import { isNotFound } from "./errors.ts";
|
||||||
import { parseAnchors, readAgentArtifact, readOutcome } from "./post-artifacts.ts";
|
import { isObject } from "./json.ts";
|
||||||
import type { Anchor } from "./post-artifacts.ts";
|
import { failureFooter, isFailureReason, isProfileType, withProfile } from "./profiles.ts";
|
||||||
import { failureFooter, withProfile } from "./profiles.ts";
|
|
||||||
import type { ProfileFailure } from "./profiles.ts";
|
import type { ProfileFailure } from "./profiles.ts";
|
||||||
import { readRuntimeManifest } from "./runtime.ts";
|
import { readRuntimeManifest } from "./runtime.ts";
|
||||||
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
|
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
|
||||||
@@ -16,15 +17,11 @@ export interface PostContext {
|
|||||||
eventName: string;
|
eventName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SuccessOutcome = Extract<RuntimeOutcome, { ok: true }>;
|
interface Anchor {
|
||||||
|
path: string;
|
||||||
type GiteaRequest = (path: string, body: unknown) => Promise<void>;
|
line: number;
|
||||||
|
side: "new" | "old";
|
||||||
// The reviewer's view of one issue or pull request.
|
body: string;
|
||||||
interface Publisher {
|
|
||||||
context: PostContext;
|
|
||||||
request: GiteaRequest;
|
|
||||||
postComment(body: string): Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
|
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 {}
|
class GiteaRequestError extends Error {}
|
||||||
|
|
||||||
function giteaRequest(apiUrl: string, reviewerToken: string, fetcher: typeof fetch): GiteaRequest {
|
function parseAnchors(text: string, sourcePath: string): Anchor[] {
|
||||||
return async (path, body) => {
|
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;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetcher(`${apiUrl}/${path}`, {
|
response = await fetcher(`${context.apiUrl}/${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `token ${reviewerToken}`,
|
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()}`);
|
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;
|
let outcome: RuntimeOutcome;
|
||||||
try {
|
try {
|
||||||
outcome = await readOutcome(manifest.outcomeFile);
|
outcome = await readOutcome(manifest.outcomeFile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await publisher.postComment(failureComment([]));
|
await postComment(failureComment([]));
|
||||||
throw new Error("agent phase failed", { cause: error });
|
throw new Error("agent phase failed", { cause: error });
|
||||||
}
|
}
|
||||||
if (!outcome.ok) {
|
if (!outcome.ok) {
|
||||||
await publisher.postComment(failureComment(outcome.failures));
|
await postComment(failureComment(outcome.failures));
|
||||||
throw new Error("agent phase failed");
|
throw new Error("agent phase failed");
|
||||||
}
|
}
|
||||||
return outcome;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postReview(
|
try {
|
||||||
publisher: Publisher,
|
const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md");
|
||||||
outcome: SuccessOutcome,
|
if (review === null) {
|
||||||
review: string,
|
if (context.eventName === "pull_request") {
|
||||||
anchors: Anchor[],
|
throw new Error("agent did not write a pull request review");
|
||||||
): Promise<void> {
|
}
|
||||||
const { context, request } = publisher;
|
await postComment(withProfile(outcome.body, outcome.profile));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const [verdict = "", ...rest] = review.split("\n");
|
const [verdict = "", ...rest] = review.split("\n");
|
||||||
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
|
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
|
||||||
const send = async (entries: Anchor[]): Promise<void> => {
|
const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json");
|
||||||
await request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
|
const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath);
|
||||||
body: stripAnsi(
|
const { profile } = outcome;
|
||||||
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile),
|
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,
|
event,
|
||||||
comments: entries.map(({ path, line, side, body }) => ({
|
comments: entries.map(({ path, line, side, body }) => ({
|
||||||
path,
|
path,
|
||||||
body: stripAnsi(withProfile(body, outcome.profile)),
|
body: stripAnsi(withProfile(body, profile)),
|
||||||
new_position: side === "new" ? line : 0,
|
new_position: side === "new" ? line : 0,
|
||||||
old_position: side === "old" ? line : 0,
|
old_position: side === "old" ? line : 0,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
};
|
await postReview(anchors).catch(async (error: unknown) => {
|
||||||
await send(anchors).catch(async (error: unknown) => {
|
|
||||||
if (anchors.length === 0) throw error;
|
if (anchors.length === 0) throw error;
|
||||||
console.error(
|
console.error(
|
||||||
`inline comments rejected: ${error instanceof Error ? error.message : String(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) {
|
} catch (error) {
|
||||||
if (error instanceof GiteaRequestError) throw error;
|
if (error instanceof GiteaRequestError) throw error;
|
||||||
await publisher.postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
|
await postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
|
||||||
throw error;
|
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 {
|
} finally {
|
||||||
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => {
|
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => {
|
||||||
if (!isNotFound(error)) throw error;
|
if (!isNotFound(error)) throw error;
|
||||||
|
|||||||
Reference in New Issue
Block a user