This commit is contained in:
+1
-1
@@ -18,7 +18,7 @@ runs:
|
|||||||
# This step assumes this is `node:24-bookworm` container.
|
# This step assumes this is `node:24-bookworm` container.
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
run: npm install -g @openai/codex @anthropic-ai/claude-code deno
|
run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai deno
|
||||||
# A local action lives inside the writable checkout. Execute a root-owned
|
# A local action lives inside the writable checkout. Execute a root-owned
|
||||||
# copy so an agent changing this repository cannot replace a later phase.
|
# copy so an agent changing this repository cannot replace a later phase.
|
||||||
- name: Protect action source
|
- name: Protect action source
|
||||||
|
|||||||
+5
-2
@@ -1,7 +1,10 @@
|
|||||||
reviewer-token: v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY
|
reviewer-token: v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY
|
||||||
profiles:
|
profiles:
|
||||||
|
- type: codex
|
||||||
|
model: gpt-5.6-terra
|
||||||
- type: claude
|
- type: claude
|
||||||
model: claude-sonnet-5
|
model: claude-sonnet-5
|
||||||
token: v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg
|
token: v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg
|
||||||
- type: codex
|
- type: opencode
|
||||||
model: gpt-5.6-terra
|
model: openrouter/qwen/qwen3.8-27b
|
||||||
|
token: v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0
|
||||||
|
|||||||
+9
-3
@@ -4,6 +4,7 @@ import type { BotProfile } from "./profiles.ts";
|
|||||||
const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
|
const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
|
||||||
claude: "claude-sonnet-5",
|
claude: "claude-sonnet-5",
|
||||||
codex: "gpt-5.6-terra",
|
codex: "gpt-5.6-terra",
|
||||||
|
opencode: "openrouter/qwen/qwen3.8-27b",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BotCredentials = {
|
export type BotCredentials = {
|
||||||
@@ -110,13 +111,18 @@ export async function parseCredentials(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { type, model, token } = profile;
|
const { type, model, token } = profile;
|
||||||
if (type !== "claude" && type !== "codex") {
|
if (type !== "claude" && type !== "codex" && type !== "opencode") {
|
||||||
throw new Error(`${at}.type: expected \"claude\" or \"codex\"`);
|
throw new Error(
|
||||||
|
`${at}.type: expected \"claude\", \"codex\", or \"opencode\"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (model !== undefined && (typeof model !== "string" || model === "")) {
|
if (model !== undefined && (typeof model !== "string" || model === "")) {
|
||||||
throw new Error(`${at}.model: expected a model name`);
|
throw new Error(`${at}.model: expected a model name`);
|
||||||
}
|
}
|
||||||
if (type === "claude" && typeof token !== "string") {
|
if (
|
||||||
|
(type === "claude" || type === "opencode") &&
|
||||||
|
typeof token !== "string"
|
||||||
|
) {
|
||||||
throw new Error(`${at}.token: expected an encrypted credential`);
|
throw new Error(`${at}.token: expected an encrypted credential`);
|
||||||
}
|
}
|
||||||
if (token !== undefined && typeof token !== "string") {
|
if (token !== undefined && typeof token !== "string") {
|
||||||
|
|||||||
+10
-3
@@ -46,22 +46,29 @@ Deno.test("decrypts the central reviewer token and ordered profiles", async () =
|
|||||||
`
|
`
|
||||||
reviewer-token: ${ENCRYPTED}
|
reviewer-token: ${ENCRYPTED}
|
||||||
profiles:
|
profiles:
|
||||||
- type: claude
|
|
||||||
token: ${ENCRYPTED}
|
|
||||||
- type: codex
|
- type: codex
|
||||||
model: gpt-custom
|
model: gpt-custom
|
||||||
|
- type: claude
|
||||||
|
token: ${ENCRYPTED}
|
||||||
|
- type: opencode
|
||||||
|
token: ${ENCRYPTED}
|
||||||
`,
|
`,
|
||||||
MASTER_KEY,
|
MASTER_KEY,
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
reviewerToken: "secret-token",
|
reviewerToken: "secret-token",
|
||||||
profiles: [
|
profiles: [
|
||||||
|
{ type: "codex", model: "gpt-custom" },
|
||||||
{
|
{
|
||||||
type: "claude",
|
type: "claude",
|
||||||
model: "claude-sonnet-5",
|
model: "claude-sonnet-5",
|
||||||
token: "secret-token",
|
token: "secret-token",
|
||||||
},
|
},
|
||||||
{ type: "codex", model: "gpt-custom" },
|
{
|
||||||
|
type: "opencode",
|
||||||
|
model: "openrouter/qwen/qwen3.8-27b",
|
||||||
|
token: "secret-token",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
export function openCodeArgs(model: string, prompt: string): string[] {
|
||||||
|
return [
|
||||||
|
"run",
|
||||||
|
"--format",
|
||||||
|
"json",
|
||||||
|
"--auto",
|
||||||
|
"--model",
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>;
|
||||||
|
|
||||||
|
export async function collectOpenCodeOutput(
|
||||||
|
stdout: ReadableStream<Uint8Array>,
|
||||||
|
stderr: ReadableStream<Uint8Array>,
|
||||||
|
writeStdout: ChunkWriter,
|
||||||
|
writeStderr: ChunkWriter,
|
||||||
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
const collect = async (
|
||||||
|
stream: ReadableStream<Uint8Array>,
|
||||||
|
write: ChunkWriter,
|
||||||
|
) => {
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
await write(chunk);
|
||||||
|
}
|
||||||
|
const length = chunks.reduce((total, chunk) => total + chunk.length, 0);
|
||||||
|
const joined = new Uint8Array(length);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
joined.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(joined);
|
||||||
|
};
|
||||||
|
const [output, error] = await Promise.all([
|
||||||
|
collect(stdout, writeStdout),
|
||||||
|
collect(stderr, writeStderr),
|
||||||
|
]);
|
||||||
|
return { stdout: output, stderr: error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOpenCodeEvents(output: string): string {
|
||||||
|
let result: string | undefined;
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
if (line.trim() === "") continue;
|
||||||
|
const event: unknown = JSON.parse(line);
|
||||||
|
if (typeof event !== "object" || event === null) continue;
|
||||||
|
const { type, part } = event as Record<string, unknown>;
|
||||||
|
if (type !== "text" || typeof part !== "object" || part === null) continue;
|
||||||
|
const text = (part as Record<string, unknown>).text;
|
||||||
|
if (typeof text === "string" && text.trim() !== "") result = text;
|
||||||
|
}
|
||||||
|
if (result === undefined) throw new Error("OpenCode produced no text output");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
|
||||||
|
import {
|
||||||
|
collectOpenCodeOutput,
|
||||||
|
openCodeArgs,
|
||||||
|
parseOpenCodeEvents,
|
||||||
|
} from "./opencode.ts";
|
||||||
|
|
||||||
|
Deno.test("builds a non-interactive auto-approved OpenCode command", () => {
|
||||||
|
assertEquals(
|
||||||
|
openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this"),
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"--format",
|
||||||
|
"json",
|
||||||
|
"--auto",
|
||||||
|
"--model",
|
||||||
|
"openrouter/qwen/qwen3.8-27b",
|
||||||
|
"Review this",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("returns the final visible OpenCode text event", () => {
|
||||||
|
assertEquals(
|
||||||
|
parseOpenCodeEvents([
|
||||||
|
JSON.stringify({ type: "step_start", part: { type: "step-start" } }),
|
||||||
|
JSON.stringify({ type: "text", part: { type: "text", text: "draft" } }),
|
||||||
|
JSON.stringify({ type: "text", part: { type: "text", text: "done" } }),
|
||||||
|
JSON.stringify({ type: "step_finish", part: { type: "step-finish" } }),
|
||||||
|
].join("\n")),
|
||||||
|
"done",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("rejects a successful OpenCode run without visible text", async () => {
|
||||||
|
await assertRejects(
|
||||||
|
async () => {
|
||||||
|
parseOpenCodeEvents('{"type":"step_start"}');
|
||||||
|
await Promise.resolve();
|
||||||
|
},
|
||||||
|
Error,
|
||||||
|
"no text output",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("streams OpenCode output while collecting it", async () => {
|
||||||
|
const encode = (value: string) => new TextEncoder().encode(value);
|
||||||
|
const stdout = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encode("first\n"));
|
||||||
|
controller.enqueue(encode("second\n"));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const stderr = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encode("warning\n"));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const shown: string[] = [];
|
||||||
|
|
||||||
|
const result = await collectOpenCodeOutput(
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
(chunk: Uint8Array) => {
|
||||||
|
shown.push(`out:${new TextDecoder().decode(chunk)}`);
|
||||||
|
},
|
||||||
|
(chunk: Uint8Array) => {
|
||||||
|
shown.push(`err:${new TextDecoder().decode(chunk)}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(result, { stdout: "first\nsecond\n", stderr: "warning\n" });
|
||||||
|
assertEquals(shown, ["out:first\n", "err:warning\n", "out:second\n"]);
|
||||||
|
});
|
||||||
+31
-7
@@ -4,6 +4,7 @@ import {
|
|||||||
type RuntimeManifest,
|
type RuntimeManifest,
|
||||||
type RuntimeOutcome,
|
type RuntimeOutcome,
|
||||||
} from "./runtime.ts";
|
} from "./runtime.ts";
|
||||||
|
import { withProfile } from "./profiles.ts";
|
||||||
|
|
||||||
export type PostContext = {
|
export type PostContext = {
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
@@ -25,6 +26,9 @@ const VERDICTS: Record<string, [event: string, mark: string]> = {
|
|||||||
Approved: ["APPROVED", "✅"],
|
Approved: ["APPROVED", "✅"],
|
||||||
"Changes requested": ["REQUEST_CHANGES", "🛑"],
|
"Changes requested": ["REQUEST_CHANGES", "🛑"],
|
||||||
};
|
};
|
||||||
|
const FAILURE = "AI bot failed.\n\n> Profile: none · all profiles failed";
|
||||||
|
|
||||||
|
class GiteaRequestError extends Error {}
|
||||||
|
|
||||||
function parseAnchors(text: string, sourcePath: string): Anchor[] {
|
function parseAnchors(text: string, sourcePath: string): Anchor[] {
|
||||||
const entries: unknown = JSON.parse(text);
|
const entries: unknown = JSON.parse(text);
|
||||||
@@ -61,15 +65,24 @@ async function readOutcome(path: string): Promise<RuntimeOutcome> {
|
|||||||
if (
|
if (
|
||||||
outcome.ok !== true || typeof outcome.body !== "string" ||
|
outcome.ok !== true || typeof outcome.body !== "string" ||
|
||||||
typeof outcome.reviewPath !== "string" ||
|
typeof outcome.reviewPath !== "string" ||
|
||||||
typeof outcome.anchorsPath !== "string"
|
typeof outcome.anchorsPath !== "string" ||
|
||||||
|
typeof outcome.profile !== "object" || outcome.profile === null
|
||||||
) {
|
) {
|
||||||
throw new Error("invalid agent outcome");
|
throw new Error("invalid agent outcome");
|
||||||
}
|
}
|
||||||
|
const profile = outcome.profile as Record<string, unknown>;
|
||||||
|
if (typeof profile.type !== "string" || typeof profile.model !== "string") {
|
||||||
|
throw new Error("invalid agent outcome");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
body: outcome.body,
|
body: outcome.body,
|
||||||
reviewPath: outcome.reviewPath,
|
reviewPath: outcome.reviewPath,
|
||||||
anchorsPath: outcome.anchorsPath,
|
anchorsPath: outcome.anchorsPath,
|
||||||
|
profile: {
|
||||||
|
type: profile.type as "claude" | "codex" | "opencode",
|
||||||
|
model: profile.model,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +131,9 @@ export async function postRuntime(
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`${path}: ${response.status} ${await response.text()}`);
|
throw new GiteaRequestError(
|
||||||
|
`${path}: ${response.status} ${await response.text()}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const postComment = (body: string) =>
|
const postComment = (body: string) =>
|
||||||
@@ -131,14 +146,15 @@ export async function postRuntime(
|
|||||||
try {
|
try {
|
||||||
outcome = await readOutcome(manifest.outcomeFile);
|
outcome = await readOutcome(manifest.outcomeFile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await postComment("AI bot failed.");
|
await postComment(FAILURE);
|
||||||
throw new Error("agent phase failed", { cause: error });
|
throw new Error("agent phase failed", { cause: error });
|
||||||
}
|
}
|
||||||
if (!outcome.ok) {
|
if (!outcome.ok) {
|
||||||
await postComment("AI bot failed.");
|
await postComment(FAILURE);
|
||||||
throw new Error("agent phase failed");
|
throw new Error("agent phase failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const review = await readAgentArtifact(
|
const review = await readAgentArtifact(
|
||||||
manifest,
|
manifest,
|
||||||
outcome.reviewPath,
|
outcome.reviewPath,
|
||||||
@@ -148,7 +164,7 @@ export async function postRuntime(
|
|||||||
if (context.eventName === "pull_request") {
|
if (context.eventName === "pull_request") {
|
||||||
throw new Error("agent did not write a pull request review");
|
throw new Error("agent did not write a pull request review");
|
||||||
}
|
}
|
||||||
await postComment(outcome.body);
|
await postComment(withProfile(outcome.body, outcome.profile));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,11 +182,14 @@ export async function postRuntime(
|
|||||||
request(
|
request(
|
||||||
`repos/${context.repository}/pulls/${context.issueIndex}/reviews`,
|
`repos/${context.repository}/pulls/${context.issueIndex}/reviews`,
|
||||||
{
|
{
|
||||||
body: stripAnsi([`${mark} ${verdict.trim()}`, ...rest].join("\n")),
|
body: stripAnsi(withProfile(
|
||||||
|
[`${mark} ${verdict.trim()}`, ...rest].join("\n"),
|
||||||
|
outcome.profile,
|
||||||
|
)),
|
||||||
event,
|
event,
|
||||||
comments: entries.map(({ path, line, side, body }) => ({
|
comments: entries.map(({ path, line, side, body }) => ({
|
||||||
path,
|
path,
|
||||||
body: stripAnsi(body),
|
body: stripAnsi(withProfile(body, outcome.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,
|
||||||
})),
|
})),
|
||||||
@@ -181,6 +200,11 @@ export async function postRuntime(
|
|||||||
console.error(`inline comments rejected: ${error.message}`);
|
console.error(`inline comments rejected: ${error.message}`);
|
||||||
await postReview([]);
|
await postReview([]);
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof GiteaRequestError) throw error;
|
||||||
|
await postComment(FAILURE);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(
|
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|||||||
+42
-4
@@ -29,6 +29,7 @@ async function fixture(
|
|||||||
const outcome: RuntimeOutcome = source.ok
|
const outcome: RuntimeOutcome = source.ok
|
||||||
? {
|
? {
|
||||||
...source,
|
...source,
|
||||||
|
profile: { type: "codex", model: "gpt-test" },
|
||||||
reviewPath: `${profileHome}/review.md`,
|
reviewPath: `${profileHome}/review.md`,
|
||||||
anchorsPath: `${profileHome}/anchors.json`,
|
anchorsPath: `${profileHome}/anchors.json`,
|
||||||
}
|
}
|
||||||
@@ -96,7 +97,9 @@ Deno.test("posts a successful issue result and removes runtime credentials", asy
|
|||||||
requests[0].url,
|
requests[0].url,
|
||||||
`${context.apiUrl}/repos/owner/repo/issues/7/comments`,
|
`${context.apiUrl}/repos/owner/repo/issues/7/comments`,
|
||||||
);
|
);
|
||||||
assertEquals(JSON.parse(String(requests[0].init?.body)), { body: "done" });
|
assertEquals(JSON.parse(String(requests[0].init?.body)), {
|
||||||
|
body: "done\n\n> Profile: `codex` · `gpt-test`",
|
||||||
|
});
|
||||||
assertEquals(requests[0].init?.headers, {
|
assertEquals(requests[0].init?.headers, {
|
||||||
Authorization: "token reviewer-secret",
|
Authorization: "token reviewer-secret",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -123,7 +126,15 @@ Deno.test("retries a rejected inline review without anchors", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assertEquals((bodies[0] as { comments: unknown[] }).comments.length, 1);
|
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 { comments: unknown[] }).comments, []);
|
||||||
|
assertEquals(
|
||||||
|
(bodies[1] as { body: string }).body,
|
||||||
|
"✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`",
|
||||||
|
);
|
||||||
assert(await missing(result.runtimeDir));
|
assert(await missing(result.runtimeDir));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,7 +152,9 @@ Deno.test("reports a generic agent failure and keeps the action failed", async (
|
|||||||
"agent phase failed",
|
"agent phase failed",
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(bodies, [{ body: "AI bot failed." }]);
|
assertEquals(bodies, [{
|
||||||
|
body: "AI bot failed.\n\n> Profile: none · all profiles failed",
|
||||||
|
}]);
|
||||||
assert(!JSON.stringify(bodies).includes("reviewer-secret"));
|
assert(!JSON.stringify(bodies).includes("reviewer-secret"));
|
||||||
assert(await missing(result.runtimeDir));
|
assert(await missing(result.runtimeDir));
|
||||||
});
|
});
|
||||||
@@ -161,7 +174,9 @@ Deno.test("reports a generic failure when the runner wrote no outcome", async ()
|
|||||||
"agent phase failed",
|
"agent phase failed",
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(bodies, [{ body: "AI bot failed." }]);
|
assertEquals(bodies, [{
|
||||||
|
body: "AI bot failed.\n\n> Profile: none · all profiles failed",
|
||||||
|
}]);
|
||||||
assert(await missing(result.runtimeDir));
|
assert(await missing(result.runtimeDir));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,6 +212,29 @@ Deno.test("refuses to follow an agent artifact symlink", async () => {
|
|||||||
"regular private file",
|
"regular private file",
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(requests, 0);
|
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> Profile: none · all profiles failed",
|
||||||
|
}]);
|
||||||
assert(await missing(result.runtimeDir));
|
assert(await missing(result.runtimeDir));
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-4
@@ -1,9 +1,22 @@
|
|||||||
export type BotProfile = {
|
export type BotProfile = {
|
||||||
type: "claude" | "codex";
|
type: "claude" | "codex" | "opencode";
|
||||||
model: string;
|
model: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function profileFooter(
|
||||||
|
profile: Pick<BotProfile, "type" | "model">,
|
||||||
|
): string {
|
||||||
|
return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withProfile(
|
||||||
|
body: string,
|
||||||
|
profile: Pick<BotProfile, "type" | "model">,
|
||||||
|
): string {
|
||||||
|
return `${body}\n\n${profileFooter(profile)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function isUsageExhausted(error: unknown): boolean {
|
function isUsageExhausted(error: unknown): boolean {
|
||||||
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/i
|
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/i
|
||||||
.test(String(error));
|
.test(String(error));
|
||||||
@@ -75,9 +88,12 @@ export function agentEnvironment(
|
|||||||
result.HOME = profile.home;
|
result.HOME = profile.home;
|
||||||
if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`;
|
if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`;
|
||||||
if (token) {
|
if (token) {
|
||||||
result[
|
const tokenName = {
|
||||||
profile.type === "claude" ? "CLAUDE_CODE_OAUTH_TOKEN" : "OPENAI_API_KEY"
|
claude: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||||
] = token;
|
codex: "OPENAI_API_KEY",
|
||||||
|
opencode: "OPENROUTER_API_KEY",
|
||||||
|
}[profile.type];
|
||||||
|
result[tokenName] = token;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
|
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
|
||||||
import {
|
import {
|
||||||
agentEnvironment,
|
agentEnvironment,
|
||||||
|
profileFooter,
|
||||||
runProfiles,
|
runProfiles,
|
||||||
sanitizedEnvironment,
|
sanitizedEnvironment,
|
||||||
} from "./profiles.ts";
|
} from "./profiles.ts";
|
||||||
|
|
||||||
|
Deno.test("formats profile attribution as a Markdown quote", () => {
|
||||||
|
assertEquals(
|
||||||
|
profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" }),
|
||||||
|
"> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("falls through only after usage exhaustion", async () => {
|
Deno.test("falls through only after usage exhaustion", async () => {
|
||||||
const visited: string[] = [];
|
const visited: string[] = [];
|
||||||
const result = await runProfiles(
|
const result = await runProfiles(
|
||||||
@@ -158,3 +166,21 @@ Deno.test("uses a private Codex home without inventing an API credential", () =>
|
|||||||
CODEX_HOME: "/runtime/profile-1/.codex",
|
CODEX_HOME: "/runtime/profile-1/.codex",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("exposes an OpenRouter key only to the OpenCode profile", () => {
|
||||||
|
const profile = {
|
||||||
|
index: 2,
|
||||||
|
type: "opencode" as const,
|
||||||
|
model: "openrouter/qwen/qwen3.8-27b",
|
||||||
|
uid: 20_002,
|
||||||
|
gid: 20_002,
|
||||||
|
home: "/runtime/profile-2",
|
||||||
|
tokenFile: "/runtime/profile-2/token",
|
||||||
|
};
|
||||||
|
|
||||||
|
assertEquals(agentEnvironment({ PATH: "/bin" }, profile, "selected"), {
|
||||||
|
PATH: "/bin",
|
||||||
|
HOME: "/runtime/profile-2",
|
||||||
|
OPENROUTER_API_KEY: "selected",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+56
-8
@@ -17,7 +17,13 @@ import {
|
|||||||
agentEnvironment,
|
agentEnvironment,
|
||||||
runProfiles,
|
runProfiles,
|
||||||
sanitizedEnvironment,
|
sanitizedEnvironment,
|
||||||
|
withProfile,
|
||||||
} from "./profiles.ts";
|
} from "./profiles.ts";
|
||||||
|
import {
|
||||||
|
collectOpenCodeOutput,
|
||||||
|
openCodeArgs,
|
||||||
|
parseOpenCodeEvents,
|
||||||
|
} from "./opencode.ts";
|
||||||
import {
|
import {
|
||||||
readRuntimeManifest,
|
readRuntimeManifest,
|
||||||
type RuntimeOutcome,
|
type RuntimeOutcome,
|
||||||
@@ -30,6 +36,7 @@ type AttemptResult = {
|
|||||||
body: string;
|
body: string;
|
||||||
reviewPath: string;
|
reviewPath: string;
|
||||||
anchorsPath: string;
|
anchorsPath: string;
|
||||||
|
profile: Pick<RuntimeProfile, "type" | "model">;
|
||||||
};
|
};
|
||||||
|
|
||||||
const env = (name: string): string => {
|
const env = (name: string): string => {
|
||||||
@@ -109,7 +116,6 @@ async function runAgentCommand(
|
|||||||
|
|
||||||
async function prepareProfile(
|
async function prepareProfile(
|
||||||
profile: RuntimeProfile,
|
profile: RuntimeProfile,
|
||||||
token: string | undefined,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await runAgentCommand(profile, "git", [
|
await runAgentCommand(profile, "git", [
|
||||||
"config",
|
"config",
|
||||||
@@ -117,15 +123,15 @@ async function prepareProfile(
|
|||||||
"--add",
|
"--add",
|
||||||
"safe.directory",
|
"safe.directory",
|
||||||
WORKSPACE,
|
WORKSPACE,
|
||||||
], token);
|
]);
|
||||||
await runAgentCommand(profile, "npx", [
|
await runAgentCommand(profile, "npx", [
|
||||||
"-y",
|
"-y",
|
||||||
...SKILLS_INSTALL.split(" "),
|
...SKILLS_INSTALL.split(" "),
|
||||||
"--global",
|
"--global",
|
||||||
"--agent",
|
"--agent",
|
||||||
profile.type === "claude" ? "claude-code" : "codex",
|
profile.type === "claude" ? "claude-code" : profile.type,
|
||||||
"--yes",
|
"--yes",
|
||||||
], token);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderPrompt(
|
async function renderPrompt(
|
||||||
@@ -253,7 +259,7 @@ async function codexDeviceLogin(
|
|||||||
]);
|
]);
|
||||||
if (exited) throw new Error("AI bot login produced no instructions");
|
if (exited) throw new Error("AI bot login produced no instructions");
|
||||||
}
|
}
|
||||||
await postAuthorComment(shown);
|
await postAuthorComment(withProfile(shown, profile));
|
||||||
const [result] = await Promise.all([status, drained]);
|
const [result] = await Promise.all([status, drained]);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(`codex login exited with ${result.code}`);
|
throw new Error(`codex login exited with ${result.code}`);
|
||||||
@@ -357,6 +363,41 @@ async function runCodex(
|
|||||||
return outputPath;
|
return outputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runOpenCode(
|
||||||
|
profile: RuntimeProfile,
|
||||||
|
token: string,
|
||||||
|
prompt: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const process = agentCommand(
|
||||||
|
profile,
|
||||||
|
"opencode",
|
||||||
|
openCodeArgs(profile.model, prompt),
|
||||||
|
{
|
||||||
|
cwd: WORKSPACE,
|
||||||
|
env: agentEnvironment(PROCESS_ENV, profile, token),
|
||||||
|
clearEnv: true,
|
||||||
|
stdout: "piped",
|
||||||
|
stderr: "piped",
|
||||||
|
},
|
||||||
|
).spawn();
|
||||||
|
const output = collectOpenCodeOutput(
|
||||||
|
process.stdout,
|
||||||
|
process.stderr,
|
||||||
|
async (chunk) => {
|
||||||
|
await Deno.stdout.write(chunk);
|
||||||
|
},
|
||||||
|
async (chunk) => {
|
||||||
|
await Deno.stderr.write(chunk);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [status, streams] = await Promise.all([process.status, output]);
|
||||||
|
const error = streams.stderr.trim();
|
||||||
|
if (!status.success) {
|
||||||
|
throw new Error(error || `opencode exited with ${status.code}`);
|
||||||
|
}
|
||||||
|
return parseOpenCodeEvents(streams.stdout);
|
||||||
|
}
|
||||||
|
|
||||||
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
|
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
|
||||||
const token = await readProfileToken(profile);
|
const token = await readProfileToken(profile);
|
||||||
const authentication = profile.type === "codex"
|
const authentication = profile.type === "codex"
|
||||||
@@ -376,14 +417,16 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
|
|||||||
profile.gid,
|
profile.gid,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await prepareProfile(profile, token);
|
await prepareProfile(profile);
|
||||||
const rendered = await renderPrompt(profile);
|
const rendered = await renderPrompt(profile);
|
||||||
reviewPath = rendered.reviewPath;
|
reviewPath = rendered.reviewPath;
|
||||||
anchorsPath = rendered.anchorsPath;
|
anchorsPath = rendered.anchorsPath;
|
||||||
if (profile.type === "claude") {
|
if (profile.type === "claude") {
|
||||||
body = await runClaude(profile, token ?? "", rendered.prompt);
|
body = await runClaude(profile, token ?? "", rendered.prompt);
|
||||||
} else {
|
} else if (profile.type === "codex") {
|
||||||
resultPath = await runCodex(profile, rendered.prompt, authentication!);
|
resultPath = await runCodex(profile, rendered.prompt, authentication!);
|
||||||
|
} else {
|
||||||
|
body = await runOpenCode(profile, token ?? "", rendered.prompt);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await killProfileProcesses(profile.uid);
|
await killProfileProcesses(profile.uid);
|
||||||
@@ -397,7 +440,12 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
|
|||||||
) {
|
) {
|
||||||
throw new Error("agent attempt produced no result");
|
throw new Error("agent attempt produced no result");
|
||||||
}
|
}
|
||||||
return { body, reviewPath, anchorsPath };
|
return {
|
||||||
|
body,
|
||||||
|
reviewPath,
|
||||||
|
anchorsPath,
|
||||||
|
profile: { type: profile.type, model: profile.model },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
|
async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type RuntimeOutcome =
|
|||||||
body: string;
|
body: string;
|
||||||
reviewPath: string;
|
reviewPath: string;
|
||||||
anchorsPath: string;
|
anchorsPath: string;
|
||||||
|
profile: Pick<RuntimeProfile, "type" | "model">;
|
||||||
}
|
}
|
||||||
| { ok: false };
|
| { ok: false };
|
||||||
|
|
||||||
|
|||||||
@@ -163,3 +163,26 @@ Deno.test("uses the standard Skills installer without custom discovery", async (
|
|||||||
assert(error instanceof Deno.errors.NotFound);
|
assert(error instanceof Deno.errors.NotFound);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("installs the OpenCode CLI", async () => {
|
||||||
|
const action = await Deno.readTextFile("action.yml");
|
||||||
|
assert(action.includes("opencode-ai"));
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("profile preparation does not receive provider credentials", async () => {
|
||||||
|
const runner = await Deno.readTextFile("src/run.ts");
|
||||||
|
assert(runner.includes("await prepareProfile(profile);"));
|
||||||
|
assertEquals(runner.includes("prepareProfile(profile, token)"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("central profiles fall back from Codex to Claude to OpenCode", async () => {
|
||||||
|
const credentials = await yaml("profiles.yml");
|
||||||
|
const profiles = sequence(credentials.profiles, "profiles");
|
||||||
|
assertEquals(profiles.map((profile) => profile.type), [
|
||||||
|
"codex",
|
||||||
|
"claude",
|
||||||
|
"opencode",
|
||||||
|
]);
|
||||||
|
assertEquals(profiles[2].model, "openrouter/qwen/qwen3.8-27b");
|
||||||
|
assert(String(profiles[2].token).startsWith("v1:"));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user