Document that package.json aliases may target only direct subfolders of src #21
@@ -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;
|
||||
}
|
||||
+169
-117
@@ -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,120 +35,101 @@ const failureComment = (failures: ProfileFailure[]): string =>
|
||||
|
||||
class GiteaRequestError extends Error {}
|
||||
|
||||
function giteaRequest(apiUrl: string, reviewerToken: string, fetcher: typeof fetch): GiteaRequest {
|
||||
return async (path, body) => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(`${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()}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function readSuccess(
|
||||
manifest: RuntimeManifest,
|
||||
publisher: Publisher,
|
||||
): Promise<SuccessOutcome> {
|
||||
let outcome: RuntimeOutcome;
|
||||
try {
|
||||
outcome = await readOutcome(manifest.outcomeFile);
|
||||
} catch (error) {
|
||||
await publisher.postComment(failureComment([]));
|
||||
throw new Error("agent phase failed", { cause: error });
|
||||
function parseAnchors(text: string, sourcePath: string): Anchor[] {
|
||||
const entries: unknown = JSON.parse(text);
|
||||
if (!Array.isArray(entries)) {
|
||||
throw new TypeError(`${sourcePath}: not an array`);
|
||||
}
|
||||
if (!outcome.ok) {
|
||||
await publisher.postComment(failureComment(outcome.failures));
|
||||
throw new Error("agent phase failed");
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
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 send(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([]);
|
||||
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 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;
|
||||
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 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 = 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,
|
||||
},
|
||||
};
|
||||
const outcome = await readSuccess(manifest, publisher);
|
||||
}
|
||||
|
||||
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 {
|
||||
await publishResult(manifest, outcome, publisher);
|
||||
stat = await lstat(path);
|
||||
} catch (error) {
|
||||
if (error instanceof GiteaRequestError) throw error;
|
||||
await publisher.postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
|
||||
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(
|
||||
@@ -161,7 +139,81 @@ export async function postRuntime(
|
||||
): Promise<void> {
|
||||
const manifest = await readRuntimeManifest(manifestPath);
|
||||
try {
|
||||
await publish(manifest, context, fetcher);
|
||||
const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim();
|
||||
const request = async (path: string, body: unknown): Promise<void> => {
|
||||
let response: Response;
|
||||
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 = (body: string): Promise<void> =>
|
||||
request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
|
||||
body: stripAnsi(body),
|
||||
});
|
||||
|
||||
let outcome: RuntimeOutcome;
|
||||
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[verdict.trim()] ?? ["COMMENT", "💬"];
|
||||
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, 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;
|
||||
|
||||
Reference in New Issue
Block a user