Show the selected bot profile and add OpenRouter fallback (#18)

Uses Codex first, then Claude, then OpenCode through OpenRouter when quota is exhausted.

Every generated comment and review now identifies the selected profile and model in a final quote. The dependent action interface remains one master key.

Verified formatting, lint, type checks, encrypted profile decryption, current OpenCode CLI flags, and 45 tests.

Reviewed-on: #18
Reviewed-by: bot <noreply@capsulizers.com>
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
This commit was merged in pull request #18.
This commit is contained in:
Danny Kim
2026-09-24 01:39:49 +00:00
committed by temeddix
parent 4ecdf5adc0
commit ea20f6d629
13 changed files with 429 additions and 78 deletions
+83 -53
View File
@@ -4,6 +4,7 @@ import {
type RuntimeManifest,
type RuntimeOutcome,
} from "./runtime.ts";
import { withProfile } from "./profiles.ts";
export type PostContext = {
apiUrl: string;
@@ -25,6 +26,9 @@ const VERDICTS: Record<string, [event: string, mark: string]> = {
Approved: ["APPROVED", "✅"],
"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[] {
const entries: unknown = JSON.parse(text);
@@ -61,15 +65,24 @@ async function readOutcome(path: string): Promise<RuntimeOutcome> {
if (
outcome.ok !== true || typeof outcome.body !== "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");
}
const profile = outcome.profile as Record<string, unknown>;
if (typeof profile.type !== "string" || 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 as "claude" | "codex" | "opencode",
model: profile.model,
},
};
}
@@ -109,16 +122,24 @@ export async function postRuntime(
const reviewerToken = (await Deno.readTextFile(manifest.reviewerFile))
.trim();
const request = async (path: string, body: unknown): Promise<void> => {
const response = await fetcher(`${context.apiUrl}/${path}`, {
method: "POST",
headers: {
Authorization: `token ${reviewerToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
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 Error(`${path}: ${response.status} ${await response.text()}`);
throw new GiteaRequestError(
`${path}: ${response.status} ${await response.text()}`,
);
}
};
const postComment = (body: string) =>
@@ -131,56 +152,65 @@ export async function postRuntime(
try {
outcome = await readOutcome(manifest.outcomeFile);
} catch (error) {
await postComment("AI bot failed.");
await postComment(FAILURE);
throw new Error("agent phase failed", { cause: error });
}
if (!outcome.ok) {
await postComment("AI bot failed.");
await postComment(FAILURE);
throw new Error("agent phase failed");
}
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(outcome.body);
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 postReview = (entries: Anchor[]) =>
request(
`repos/${context.repository}/pulls/${context.issueIndex}/reviews`,
{
body: stripAnsi([`${mark} ${verdict.trim()}`, ...rest].join("\n")),
event,
comments: entries.map(({ path, line, side, body }) => ({
path,
body: stripAnsi(body),
new_position: side === "new" ? line : 0,
old_position: side === "old" ? line : 0,
})),
},
try {
const review = await readAgentArtifact(
manifest,
outcome.reviewPath,
"review.md",
);
await postReview(anchors).catch(async (error: Error) => {
if (anchors.length === 0) throw error;
console.error(`inline comments rejected: ${error.message}`);
await postReview([]);
});
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 postReview = (entries: Anchor[]) =>
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 postReview(anchors).catch(async (error: Error) => {
if (anchors.length === 0) throw error;
console.error(`inline comments rejected: ${error.message}`);
await postReview([]);
});
} catch (error) {
if (error instanceof GiteaRequestError) throw error;
await postComment(FAILURE);
throw error;
}
} finally {
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(
(error) => {