297 lines
8.9 KiB
TypeScript
297 lines
8.9 KiB
TypeScript
import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
|
|
import { assert, expect, test } from "vite-plus/test";
|
|
|
|
import { isNotFound } from "./errors.ts";
|
|
import { postRuntime } from "./post.ts";
|
|
import type { PostContext } from "./post.ts";
|
|
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
|
|
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
|
|
|
|
const context: PostContext = {
|
|
apiUrl: "https://gitea.example/api/v1",
|
|
repository: "owner/repo",
|
|
issueIndex: "7",
|
|
eventName: "issue_comment",
|
|
};
|
|
|
|
interface Fixture {
|
|
manifestPath: string;
|
|
runtimeDir: string;
|
|
reviewerFile: string;
|
|
reviewPath: string;
|
|
outcomeFile: string;
|
|
}
|
|
|
|
type FixtureSource = Extract<RuntimeOutcome, { ok: false }> | { ok: true; body: string };
|
|
|
|
async function writePrivate(path: string, text: string): Promise<void> {
|
|
await writeFile(path, text, { mode: 0o600 });
|
|
await chmod(path, 0o600);
|
|
}
|
|
|
|
async function writeArtifacts(
|
|
outcome: RuntimeOutcome,
|
|
review: string | undefined,
|
|
anchors: unknown,
|
|
): Promise<void> {
|
|
if (!outcome.ok) return;
|
|
if (review !== undefined) await writePrivate(outcome.reviewPath, review);
|
|
if (anchors !== undefined) await writePrivate(outcome.anchorsPath, JSON.stringify(anchors));
|
|
}
|
|
|
|
function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
|
|
return {
|
|
runtimeDir,
|
|
manifestPath: `${runtimeDir}/manifest.json`,
|
|
reviewerFile: `${runtimeDir}/reviewer-token`,
|
|
outcomeFile: `${runtimeDir}/outcome.json`,
|
|
profiles: [
|
|
{
|
|
index: 0,
|
|
type: "claude",
|
|
model: "test",
|
|
uid: currentUid(),
|
|
gid: currentGid(),
|
|
home: profileHome,
|
|
tokenFile: null,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
async function fixture(
|
|
source: FixtureSource,
|
|
review?: string,
|
|
anchors?: unknown,
|
|
): Promise<Fixture> {
|
|
const runtimeDir = await makeTempDir();
|
|
const profileHome = `${runtimeDir}/profile-0`;
|
|
const manifest = manifestFor(runtimeDir, profileHome);
|
|
await mkdir(profileHome);
|
|
await writeFile(manifest.reviewerFile, "reviewer-secret");
|
|
const outcome: RuntimeOutcome = source.ok
|
|
? {
|
|
...source,
|
|
profile: { type: "codex", model: "gpt-test" },
|
|
reviewPath: `${profileHome}/review.md`,
|
|
anchorsPath: `${profileHome}/anchors.json`,
|
|
}
|
|
: source;
|
|
await writeFile(manifest.outcomeFile, JSON.stringify(outcome));
|
|
await writeArtifacts(outcome, review, anchors);
|
|
await writeFile(manifest.manifestPath, JSON.stringify(manifest));
|
|
const { manifestPath, reviewerFile, outcomeFile } = manifest;
|
|
return {
|
|
manifestPath,
|
|
runtimeDir,
|
|
reviewerFile,
|
|
reviewPath: `${profileHome}/review.md`,
|
|
outcomeFile,
|
|
};
|
|
}
|
|
|
|
const missing = async (path: string): Promise<boolean> => {
|
|
try {
|
|
await stat(path);
|
|
return false;
|
|
} catch (error) {
|
|
if (isNotFound(error)) return true;
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// The JSON body of one request the fake Gitea received.
|
|
function requestBody(init?: RequestInit): unknown {
|
|
return typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
|
|
}
|
|
|
|
// A fake Gitea that records each request body and answers with `status`.
|
|
function recordingFetch(bodies: unknown[], status = 201): typeof fetch {
|
|
return (_url, init) => {
|
|
bodies.push(requestBody(init));
|
|
return Promise.resolve(new Response(null, { status }));
|
|
};
|
|
}
|
|
|
|
test("posts a successful issue result and removes runtime credentials", async () => {
|
|
const result = await fixture({ ok: true, body: "done" });
|
|
const requests: { url: unknown; init: RequestInit | undefined }[] = [];
|
|
|
|
await postRuntime(result.manifestPath, context, (url, init) => {
|
|
requests.push({ url, init });
|
|
return Promise.resolve(new Response(null, { status: 201 }));
|
|
});
|
|
|
|
expect(requests.length).toBe(1);
|
|
const [request] = requests;
|
|
expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`);
|
|
expect(requestBody(request?.init)).toStrictEqual({
|
|
body: "done\n\n> Profile: `codex` · `gpt-test`",
|
|
});
|
|
expect(request?.init?.headers).toStrictEqual({
|
|
Authorization: "token reviewer-secret",
|
|
"Content-Type": "application/json",
|
|
});
|
|
assert(await missing(result.runtimeDir));
|
|
});
|
|
|
|
test("retries a rejected inline review without anchors", async () => {
|
|
const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [
|
|
{ path: "run.ts", line: 1, side: "new", body: "Finding" },
|
|
]);
|
|
const bodies: unknown[] = [];
|
|
|
|
await postRuntime(
|
|
result.manifestPath,
|
|
{
|
|
...context,
|
|
eventName: "pull_request",
|
|
},
|
|
(_url, init) => {
|
|
bodies.push(requestBody(init));
|
|
return Promise.resolve(new Response(null, { status: bodies.length === 1 ? 422 : 200 }));
|
|
},
|
|
);
|
|
|
|
expect(bodies[0]).toMatchObject({
|
|
comments: [{ body: "Finding\n\n> Profile: `codex` · `gpt-test`" }],
|
|
});
|
|
expect(bodies[1]).toMatchObject({
|
|
comments: [],
|
|
body: "✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`",
|
|
});
|
|
assert(await missing(result.runtimeDir));
|
|
});
|
|
|
|
test("reports each tried profile and keeps the action failed", async () => {
|
|
const result = await fixture({
|
|
ok: false,
|
|
failures: [
|
|
{ type: "codex", model: "gpt-test", reason: "sign-in failed" },
|
|
{ type: "claude", model: "claude-test", reason: "usage exhausted" },
|
|
{ type: "opencode", model: "openrouter/test", reason: "failed" },
|
|
],
|
|
});
|
|
const bodies: unknown[] = [];
|
|
|
|
await expect(() =>
|
|
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
|
|
).rejects.toThrow("agent phase failed");
|
|
|
|
expect(bodies).toStrictEqual([
|
|
{
|
|
body:
|
|
"AI bot failed.\n\n> Tried: `codex` · `gpt-test` (sign-in failed), " +
|
|
"`claude` · `claude-test` (usage exhausted), " +
|
|
"`opencode` · `openrouter/test` (failed)",
|
|
},
|
|
]);
|
|
assert(!JSON.stringify(bodies).includes("reviewer-secret"));
|
|
assert(await missing(result.runtimeDir));
|
|
});
|
|
|
|
test("drops failure entries that are not known labels", async () => {
|
|
const result = await fixture({ ok: false, failures: [] });
|
|
await writeFile(
|
|
result.outcomeFile,
|
|
JSON.stringify({
|
|
ok: false,
|
|
failures: [
|
|
{ type: "codex", model: "gpt-test", reason: "token sk-secret leaked" },
|
|
{ type: "shell", model: "x", reason: "failed" },
|
|
{ type: "claude", model: "claude-test", reason: "sign-in failed" },
|
|
],
|
|
}),
|
|
);
|
|
const bodies: unknown[] = [];
|
|
|
|
await expect(() =>
|
|
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
|
|
).rejects.toThrow("agent phase failed");
|
|
|
|
expect(bodies).toStrictEqual([
|
|
{
|
|
body: "AI bot failed.\n\n> Tried: `claude` · `claude-test` (sign-in failed)",
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("reports a generic failure when the runner wrote no outcome", async () => {
|
|
const result = await fixture({ ok: false, failures: [] });
|
|
await rm(result.outcomeFile);
|
|
const bodies: unknown[] = [];
|
|
|
|
await expect(() =>
|
|
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
|
|
).rejects.toThrow("agent phase failed");
|
|
|
|
expect(bodies).toStrictEqual([{ body: "AI bot failed.\n\n> Profile: none" }]);
|
|
assert(await missing(result.runtimeDir));
|
|
});
|
|
|
|
test("removes runtime credentials when posting fails", async () => {
|
|
const result = await fixture({ ok: true, body: "done" });
|
|
|
|
await expect(() =>
|
|
postRuntime(result.manifestPath, context, () =>
|
|
Promise.resolve(new Response("failed", { status: 500 })),
|
|
),
|
|
).rejects.toThrow("500");
|
|
assert(await missing(result.runtimeDir));
|
|
});
|
|
|
|
test("preserves a Gitea transport failure without retrying a comment", async () => {
|
|
const result = await fixture({ ok: true, body: "done" });
|
|
let requests = 0;
|
|
|
|
await expect(() =>
|
|
postRuntime(result.manifestPath, context, () => {
|
|
requests += 1;
|
|
return Promise.reject(new Error(`network failure ${requests}`));
|
|
}),
|
|
).rejects.toThrow("network failure 1");
|
|
|
|
expect(requests).toBe(1);
|
|
assert(await missing(result.runtimeDir));
|
|
});
|
|
|
|
test.skipIf(IS_WINDOWS)("refuses to follow an agent artifact symlink", async () => {
|
|
const result = await fixture({ ok: true, body: "done" });
|
|
await symlink(result.reviewerFile, result.reviewPath);
|
|
let requests = 0;
|
|
|
|
await expect(() =>
|
|
postRuntime(result.manifestPath, context, () => {
|
|
requests += 1;
|
|
return Promise.resolve(new Response(null, { status: 200 }));
|
|
}),
|
|
).rejects.toThrow("regular private file");
|
|
|
|
expect(requests).toBe(1);
|
|
assert(await missing(result.runtimeDir));
|
|
});
|
|
|
|
test("reports an attributed failure when a pull request review is missing", async () => {
|
|
const result = await fixture({ ok: true, body: "done" });
|
|
const bodies: unknown[] = [];
|
|
|
|
await expect(() =>
|
|
postRuntime(
|
|
result.manifestPath,
|
|
{
|
|
...context,
|
|
eventName: "pull_request",
|
|
},
|
|
recordingFetch(bodies),
|
|
),
|
|
).rejects.toThrow("did not write");
|
|
|
|
expect(bodies).toStrictEqual([
|
|
{
|
|
body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)",
|
|
},
|
|
]);
|
|
assert(await missing(result.runtimeDir));
|
|
});
|