From dcacac18e8522c526a659e641ddead4568d341b5 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Mon, 14 Sep 2026 08:49:49 +0000 Subject: [PATCH] Make bot review setup self-contained (#11) Co-authored-by: Danny Kim Co-committed-by: Danny Kim --- auth.ts | 12 +++++++++ auth_test.ts | 44 +++++++++++++++++++++++++++++++ prompt.md | 37 +++++++++++++------------- run.ts | 73 ++++++++++++++++++++++++++++------------------------ 4 files changed, 113 insertions(+), 53 deletions(-) create mode 100644 auth.ts create mode 100644 auth_test.ts diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..feb8acb --- /dev/null +++ b/auth.ts @@ -0,0 +1,12 @@ +export async function retryInvalidToken< + T extends { status: { success: boolean }; error: string }, +>(attempt: () => Promise, relogin: () => Promise): Promise { + let result = await attempt(); + if ( + !result.status.success && result.error.includes("invalid_refresh_token") + ) { + await relogin(); + result = await attempt(); + } + return result; +} diff --git a/auth_test.ts b/auth_test.ts new file mode 100644 index 0000000..eaa5ca5 --- /dev/null +++ b/auth_test.ts @@ -0,0 +1,44 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import { retryInvalidToken } from "./auth.ts"; + +Deno.test("retries once after an invalid refresh token", async () => { + let attempts = 0; + let relogins = 0; + const result = await retryInvalidToken( + () => + Promise.resolve({ + status: { success: false }, + error: attempts++ === 0 ? "invalid_refresh_token" : "still failed", + }), + () => { + relogins++; + return Promise.resolve(); + }, + ); + + assertEquals({ attempts, relogins, error: result.error }, { + attempts: 2, + relogins: 1, + error: "still failed", + }); +}); + +Deno.test("does not retry an unrelated failure", async () => { + let attempts = 0; + let relogins = 0; + await retryInvalidToken( + () => { + attempts++; + return Promise.resolve({ + status: { success: false }, + error: "rate limited", + }); + }, + () => { + relogins++; + return Promise.resolve(); + }, + ); + + assertEquals({ attempts, relogins }, { attempts: 1, relogins: 0 }); +}); diff --git a/prompt.md b/prompt.md index b0df847..8983ab1 100644 --- a/prompt.md +++ b/prompt.md @@ -19,25 +19,24 @@ author's push credentials. Read the code there, run its checks and tests when they bear on the task, and push from there. For a `pull_request` event, and for a comment on a pull request that asks you to -review it, review the PR without changing code, using the -`requesting-code-review` skill from superpowers: run its code reviewer template -against the PR's base and head. Run the project's checks on the head and treat a -failure as at least Important. Check the whole repository against the code rules -at the end of this prompt, not only the diff; a violation is at least Important -even when the diff did not cause it. Write the complete review, and nothing -else, to the file `${REVIEW_PATH}`: it is posted verbatim as a pull request -review from the bot account, and your final response is not posted at all. The -file's first line must be exactly the verdict and nothing else: `Approved` when -the template's answer is yes, `Changes requested` otherwise. The mark in front -of it is added when posting, so write the words alone; any other first line is -posted as a plain comment, which wastes the run. Minor issues alone never block, -and neither does a finding the author has answered in the comment history below -as intended or a false alarm, once the code or docs make that clear. When the -verdict is `Changes requested`, the second line names what must change in one -line, addressed to the author; the author's own agent picks the fixes up, so -never ask `@bot` to make them. For UI changes, check that the result is aligned, -clean, and pixel-perfect, and that included screenshots prove the intended -result was achieved. +review it, review the PR without changing code against its base and head. Run +the project's checks on the head and treat a failure as at least Important. +Check the whole repository against the code rules at the end of this prompt, not +only the diff; a violation is at least Important even when the diff did not +cause it. Write the complete review, and nothing else, to the file +`${REVIEW_PATH}`: it is posted verbatim as a pull request review from the bot +account, and your final response is not posted at all. The file's first line +must be exactly the verdict and nothing else: `Approved` when the head is ready +to merge, `Changes requested` otherwise. The mark in front of it is added when +posting, so write the words alone; any other first line is posted as a plain +comment, which wastes the run. Minor issues alone never block, and neither does +a finding the author has answered in the comment history below as intended or a +false alarm, once the code or docs make that clear. When the verdict is +`Changes requested`, the second line names what must change in one line, +addressed to the author; the author's own agent picks the fixes up, so never ask +`@bot` to make them. For UI changes, check that the result is aligned, clean, +and pixel-perfect, and that included screenshots prove the intended result was +achieved. Every finding that belongs to one line of the diff goes on that line instead of into the body. Write those to `${ANCHORS_PATH}` as a JSON array, each entry diff --git a/run.ts b/run.ts index 21d4672..cc60a93 100644 --- a/run.ts +++ b/run.ts @@ -3,6 +3,7 @@ // GITEA_TOKEN; everything this script posts goes through the reviewer token, // so it appears as the bot account. import { TextLineStream } from "jsr:@std/streams@1/text-line-stream"; +import { retryInvalidToken } from "./auth.ts"; type GiteaUser = { login: string; email: string }; type GiteaComment = { user: GiteaUser; created_at: string; body: string }; @@ -153,19 +154,6 @@ async function configureGitAuthor(): Promise { } } -// The superpowers plugin gives the agent its skills, including the code review -// one that the prompt asks for. Both installs are idempotent on the persisted -// home. -async function installSuperpowers(bot: string): Promise { - const commands = bot === "claude" - ? [ - ["plugin", "marketplace", "add", "obra/superpowers-marketplace"], - ["plugin", "install", "-y", "superpowers@superpowers-marketplace"], - ] - : [["plugin", "add", "superpowers@openai-curated-remote"]]; - for (const args of commands) await run(bot, args); -} - async function renderPrompt(): Promise { const comments: GiteaComment[] = await (await gitea( REVIEWER_TOKEN, @@ -200,7 +188,6 @@ async function runClaude(prompt: string): Promise { "Run `claude setup-token` locally and set the `bot-token` action input.", ); } - await installSuperpowers("claude"); const claude = new Deno.Command("claude", { args: [ "--print", @@ -251,12 +238,17 @@ async function codexDeviceLogin(): Promise { new WritableStream({ write: (chunk) => void (shown += chunk) }), ); const drained = Promise.all([collect(login.stdout), collect(login.stderr)]); + const status = login.status; await new Promise((resolve) => setTimeout(resolve, 3000)); + while (shown.trim() === "") { + const exited = await Promise.race([ + status.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 100)), + ]); + if (exited) throw new Error("AI bot login produced no instructions"); + } await postComment(shown); - await Promise.all([login.status, drained]); - const status = await new Deno.Command("codex", { args: ["login", "status"] }) - .output(); - await postComment(new TextDecoder().decode(status.stdout)); + await Promise.all([status, drained]); } async function runCodex(prompt: string): Promise { @@ -265,23 +257,36 @@ async function runCodex(prompt: string): Promise { }) .output(); if (!loggedIn.success) await codexDeviceLogin(); - await installSuperpowers("codex"); const file = await Deno.makeTempFile(); - const status = await new Deno.Command("codex", { - args: [ - "exec", - "--model", - model("codex"), - "--dangerously-bypass-approvals-and-sandbox", - "--output-last-message", - file, - prompt, - ], - env: agentEnv, - clearEnv: true, - stdout: "inherit", - stderr: "inherit", - }).spawn().status; + const attempt = async () => { + const codex = new Deno.Command("codex", { + args: [ + "exec", + "--model", + model("codex"), + "--dangerously-bypass-approvals-and-sandbox", + "--output-last-message", + file, + prompt, + ], + env: agentEnv, + clearEnv: true, + stdout: "inherit", + stderr: "piped", + }).spawn(); + const decoder = new TextDecoder(); + let error = ""; + for await (const chunk of codex.stderr) { + await Deno.stderr.write(chunk); + error += decoder.decode(chunk, { stream: true }); + } + error += decoder.decode(); + return { status: await codex.status, error }; + }; + const { status } = await retryInvalidToken(attempt, async () => { + await run("codex", ["logout"]); + await codexDeviceLogin(); + }); if (!status.success) throw new Error(`codex exited with ${status.code}`); return await Deno.readTextFile(file); }