3565988249
Codex re-runs device login on revoked, expired, or reused sessions and token_revoked/401 errors, not only invalid_refresh_token. Claude and OpenCode credential rejections are classified too. Sign-in failures fall through to the next profile, and the failure comment names each tried profile with a fixed reason label.
297 lines
8.7 KiB
TypeScript
297 lines
8.7 KiB
TypeScript
import { assert, assertEquals, assertRejects } from "jsr:@std/assert@1";
|
|
import { type PostContext, postRuntime } from "./post.ts";
|
|
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
|
|
|
|
const context: PostContext = {
|
|
apiUrl: "https://gitea.example/api/v1",
|
|
repository: "owner/repo",
|
|
issueIndex: "7",
|
|
eventName: "issue_comment",
|
|
};
|
|
|
|
async function fixture(
|
|
source: Extract<RuntimeOutcome, { ok: false }> | { ok: true; body: string },
|
|
review?: string,
|
|
anchors?: unknown,
|
|
): Promise<{
|
|
manifestPath: string;
|
|
runtimeDir: string;
|
|
reviewerFile: string;
|
|
reviewPath: string;
|
|
outcomeFile: string;
|
|
}> {
|
|
const runtimeDir = await Deno.makeTempDir();
|
|
const reviewerFile = `${runtimeDir}/reviewer-token`;
|
|
const outcomeFile = `${runtimeDir}/outcome.json`;
|
|
const profileHome = `${runtimeDir}/profile-0`;
|
|
await Deno.mkdir(profileHome);
|
|
await Deno.writeTextFile(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 Deno.writeTextFile(outcomeFile, JSON.stringify(outcome));
|
|
if (outcome.ok) {
|
|
if (review !== undefined) {
|
|
await Deno.writeTextFile(outcome.reviewPath, review, { mode: 0o600 });
|
|
await Deno.chmod(outcome.reviewPath, 0o600);
|
|
}
|
|
if (anchors !== undefined) {
|
|
await Deno.writeTextFile(outcome.anchorsPath, JSON.stringify(anchors), {
|
|
mode: 0o600,
|
|
});
|
|
await Deno.chmod(outcome.anchorsPath, 0o600);
|
|
}
|
|
}
|
|
const manifestPath = `${runtimeDir}/manifest.json`;
|
|
const manifest: RuntimeManifest = {
|
|
runtimeDir,
|
|
manifestPath,
|
|
reviewerFile,
|
|
outcomeFile,
|
|
profiles: [{
|
|
index: 0,
|
|
type: "claude",
|
|
model: "test",
|
|
uid: Deno.uid()!,
|
|
gid: Deno.gid()!,
|
|
home: profileHome,
|
|
tokenFile: null,
|
|
}],
|
|
};
|
|
await Deno.writeTextFile(manifestPath, JSON.stringify(manifest));
|
|
return {
|
|
manifestPath,
|
|
runtimeDir,
|
|
reviewerFile,
|
|
reviewPath: `${profileHome}/review.md`,
|
|
outcomeFile,
|
|
};
|
|
}
|
|
|
|
const missing = async (path: string): Promise<boolean> => {
|
|
try {
|
|
await Deno.stat(path);
|
|
return false;
|
|
} catch (error) {
|
|
if (error instanceof Deno.errors.NotFound) return true;
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
Deno.test("posts a successful issue result and removes runtime credentials", async () => {
|
|
const result = await fixture({ ok: true, body: "done" });
|
|
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
|
|
|
await postRuntime(result.manifestPath, context, (url, init) => {
|
|
requests.push({ url: String(url), init });
|
|
return Promise.resolve(new Response(null, { status: 201 }));
|
|
});
|
|
|
|
assertEquals(requests.length, 1);
|
|
assertEquals(
|
|
requests[0].url,
|
|
`${context.apiUrl}/repos/owner/repo/issues/7/comments`,
|
|
);
|
|
assertEquals(JSON.parse(String(requests[0].init?.body)), {
|
|
body: "done\n\n> Profile: `codex` · `gpt-test`",
|
|
});
|
|
assertEquals(requests[0].init?.headers, {
|
|
Authorization: "token reviewer-secret",
|
|
"Content-Type": "application/json",
|
|
});
|
|
assert(await missing(result.runtimeDir));
|
|
});
|
|
|
|
Deno.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(JSON.parse(String(init?.body)));
|
|
return Promise.resolve(
|
|
new Response(null, { status: bodies.length === 1 ? 422 : 200 }),
|
|
);
|
|
});
|
|
|
|
assertEquals((bodies[0] as { comments: unknown[] }).comments.length, 1);
|
|
assertEquals(
|
|
(bodies[0] as { comments: Array<{ body: string }> }).comments[0].body,
|
|
"Finding\n\n> Profile: `codex` · `gpt-test`",
|
|
);
|
|
assertEquals((bodies[1] as { comments: unknown[] }).comments, []);
|
|
assertEquals(
|
|
(bodies[1] as { body: string }).body,
|
|
"✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`",
|
|
);
|
|
assert(await missing(result.runtimeDir));
|
|
});
|
|
|
|
Deno.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 assertRejects(
|
|
() =>
|
|
postRuntime(result.manifestPath, context, (_url, init) => {
|
|
bodies.push(JSON.parse(String(init?.body)));
|
|
return Promise.resolve(new Response(null, { status: 201 }));
|
|
}),
|
|
Error,
|
|
"agent phase failed",
|
|
);
|
|
|
|
assertEquals(bodies, [{
|
|
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));
|
|
});
|
|
|
|
Deno.test("drops failure entries that are not known labels", async () => {
|
|
const result = await fixture({ ok: false, failures: [] });
|
|
await Deno.writeTextFile(
|
|
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 assertRejects(
|
|
() =>
|
|
postRuntime(result.manifestPath, context, (_url, init) => {
|
|
bodies.push(JSON.parse(String(init?.body)));
|
|
return Promise.resolve(new Response(null, { status: 201 }));
|
|
}),
|
|
Error,
|
|
"agent phase failed",
|
|
);
|
|
|
|
assertEquals(bodies, [{
|
|
body:
|
|
"AI bot failed.\n\n> Tried: `claude` · `claude-test` (sign-in failed)",
|
|
}]);
|
|
});
|
|
|
|
Deno.test("reports a generic failure when the runner wrote no outcome", async () => {
|
|
const result = await fixture({ ok: false, failures: [] });
|
|
await Deno.remove(result.outcomeFile);
|
|
const bodies: unknown[] = [];
|
|
|
|
await assertRejects(
|
|
() =>
|
|
postRuntime(result.manifestPath, context, (_url, init) => {
|
|
bodies.push(JSON.parse(String(init?.body)));
|
|
return Promise.resolve(new Response(null, { status: 201 }));
|
|
}),
|
|
Error,
|
|
"agent phase failed",
|
|
);
|
|
|
|
assertEquals(bodies, [{ body: "AI bot failed.\n\n> Profile: none" }]);
|
|
assert(await missing(result.runtimeDir));
|
|
});
|
|
|
|
Deno.test("removes runtime credentials when posting fails", async () => {
|
|
const result = await fixture({ ok: true, body: "done" });
|
|
|
|
await assertRejects(
|
|
() =>
|
|
postRuntime(
|
|
result.manifestPath,
|
|
context,
|
|
() => Promise.resolve(new Response("failed", { status: 500 })),
|
|
),
|
|
Error,
|
|
"500",
|
|
);
|
|
assert(await missing(result.runtimeDir));
|
|
});
|
|
|
|
Deno.test("preserves a Gitea transport failure without retrying a comment", async () => {
|
|
const result = await fixture({ ok: true, body: "done" });
|
|
let requests = 0;
|
|
|
|
await assertRejects(
|
|
() =>
|
|
postRuntime(result.manifestPath, context, () => {
|
|
requests++;
|
|
return Promise.reject(new Error(`network failure ${requests}`));
|
|
}),
|
|
Error,
|
|
"network failure 1",
|
|
);
|
|
|
|
assertEquals(requests, 1);
|
|
assert(await missing(result.runtimeDir));
|
|
});
|
|
|
|
Deno.test("refuses to follow an agent artifact symlink", async () => {
|
|
if (Deno.build.os === "windows") return;
|
|
const result = await fixture({ ok: true, body: "done" });
|
|
await Deno.symlink(result.reviewerFile, result.reviewPath);
|
|
let requests = 0;
|
|
|
|
await assertRejects(
|
|
() =>
|
|
postRuntime(result.manifestPath, context, () => {
|
|
requests++;
|
|
return Promise.resolve(new Response(null, { status: 200 }));
|
|
}),
|
|
Error,
|
|
"regular private file",
|
|
);
|
|
|
|
assertEquals(requests, 1);
|
|
assert(await missing(result.runtimeDir));
|
|
});
|
|
|
|
Deno.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 assertRejects(
|
|
() =>
|
|
postRuntime(result.manifestPath, {
|
|
...context,
|
|
eventName: "pull_request",
|
|
}, (_url, init) => {
|
|
bodies.push(JSON.parse(String(init?.body)));
|
|
return Promise.resolve(new Response(null, { status: 201 }));
|
|
}),
|
|
Error,
|
|
"did not write",
|
|
);
|
|
|
|
assertEquals(bodies, [{
|
|
body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)",
|
|
}]);
|
|
assert(await missing(result.runtimeDir));
|
|
});
|