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:
+83
-53
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user