Commitc3e0c07unknown_key
feat(pulls): "Re-run AI review" button on PR detail page
feat(pulls): "Re-run AI review" button on PR detail page
After a force-push, the existing AI review is stale — but
triggerAiReview() short-circuits via the AI_REVIEW_MARKER
idempotency check, so a fresh review never gets posted. Until now
there was no escape hatch.
Changes:
- triggerAiReview signature gains an optional
`options: { force?: boolean }` parameter (default {}). When
`force === true`, the alreadyReviewed marker check is skipped.
Existing callers stay green — no signature break.
- POST /:owner/:repo/pulls/:number/ai-rereview (write-access).
Loads the PR, fires triggerAiReview with { force: true },
redirects back to the PR with `?info=AI re-review queued. The
new comment will appear in 10-30s; reload to see it.`
- PR detail render:
* Reads `?info=` query and renders an info banner styled with
var(--accent) above the gate-checks panel.
* For users with `canManage` access on open PRs, when
isAiReviewEnabled() the comment-form footer shows a
"Re-run AI review" button next to "Merge pull request" and
"Close". Uses formaction + formnovalidate so the comment
textarea (which is `required`) doesn't block submission.
Tests: +4 (auth-guard contracts on /:owner/:repo/pulls/:n/ai-rereview;
two no-throw verifications for triggerAiReview with and without the
new force option). Total suite 1189 pass / 8 skip / 0 fail
(was 1185 / 8 / 0).
https://claude.ai/code/session_0163vJChUuZqqtBBznUW6xBU3 files changed+163−1c3e0c077efc5d9ab112e61ae5ea329fa6c009e2e
3 changed files+163−1
Addedsrc/__tests__/pulls-ai-rereview.test.ts+87−0View fileUnifiedSplit
@@ -0,0 +1,87 @@
1/**
2 * Smoke tests for the new on-demand AI re-review endpoint:
3 * POST /:owner/:repo/pulls/:number/ai-rereview
4 *
5 * Write-access only. Verifies auth-guard contracts.
6 */
7
8import { describe, it, expect } from "bun:test";
9import app from "../app";
10
11describe("POST /:owner/:repo/pulls/:number/ai-rereview — auth guard", () => {
12 it("redirects to /login when unauthenticated", async () => {
13 const res = await app.request(
14 "/alice/demo/pulls/1/ai-rereview",
15 {
16 method: "POST",
17 headers: { "content-type": "application/x-www-form-urlencoded" },
18 body: "",
19 redirect: "manual",
20 }
21 );
22 expect([301, 302, 303, 307, 401, 403, 404, 503]).toContain(res.status);
23 if (res.status === 302 || res.status === 303 || res.status === 307) {
24 const loc = res.headers.get("location") || "";
25 expect(loc).toContain("/login");
26 }
27 });
28
29 it("rejects bogus bearer tokens", async () => {
30 const res = await app.request(
31 "/alice/demo/pulls/1/ai-rereview",
32 {
33 method: "POST",
34 headers: {
35 "content-type": "application/x-www-form-urlencoded",
36 authorization: "Bearer glct_definitely-not-valid",
37 },
38 body: "",
39 }
40 );
41 expect([401, 403, 404, 503]).toContain(res.status);
42 });
43});
44
45describe("triggerAiReview — force option (idempotency bypass)", () => {
46 it("accepts and propagates the force flag without throwing", async () => {
47 const { triggerAiReview } = await import("../lib/ai-review");
48 let threw = false;
49 try {
50 // No API key in test env → should bail at isAiReviewEnabled check.
51 // The force flag is just a parameter pass-through; we verify it
52 // doesn't change the never-throw contract.
53 await triggerAiReview(
54 "alice",
55 "demo",
56 "00000000-0000-0000-0000-000000000000",
57 "Title",
58 "Body",
59 "main",
60 "feature",
61 { force: true }
62 );
63 } catch {
64 threw = true;
65 }
66 expect(threw).toBe(false);
67 });
68
69 it("default options (no force) still works", async () => {
70 const { triggerAiReview } = await import("../lib/ai-review");
71 let threw = false;
72 try {
73 await triggerAiReview(
74 "alice",
75 "demo",
76 "00000000-0000-0000-0000-000000000000",
77 "Title",
78 "Body",
79 "main",
80 "feature"
81 );
82 } catch {
83 threw = true;
84 }
85 expect(threw).toBe(false);
86 });
87});
Modifiedsrc/lib/ai-review.ts+2−1View fileUnifiedSplit
@@ -221,10 +221,11 @@ export async function triggerAiReview(
221221 body: string,
222222 baseBranch: string,
223223 headBranch: string,
224 options: { force?: boolean } = {}
224225): Promise<void> {
225226 try {
226227 if (!isAiReviewEnabled()) return;
227 if (await alreadyReviewed(prId)) return;
228 if (!options.force && (await alreadyReviewed(prId))) return;
228229
229230 const [pr] = await db
230231 .select({ id: pullRequests.id, authorId: pullRequests.authorId })
Modifiedsrc/routes/pulls.tsx+74−0View fileUnifiedSplit
@@ -520,6 +520,7 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
520520 (user.id === resolved.owner.id || user.id === pr.authorId);
521521
522522 const error = c.req.query("error");
523 const info = c.req.query("info");
523524
524525 // Get gate check status for open PRs
525526 let gateChecks: GateCheckResult[] = [];
@@ -683,6 +684,12 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
683684 </div>
684685 )}
685686
687 {info && (
688 <div style="margin-top: 16px; padding: 12px; background: rgba(56, 139, 253, 0.1); border: 1px solid var(--accent); border-radius: var(--radius); color: var(--text)">
689 {decodeURIComponent(info)}
690 </div>
691 )}
692
686693 {pr.state === "open" && gateChecks.length > 0 && (
687694 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
688695 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
@@ -734,6 +741,17 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
734741 >
735742 Merge pull request
736743 </button>
744 {isAiReviewEnabled() && (
745 <button
746 type="submit"
747 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
748 formnovalidate
749 class="btn"
750 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
751 >
752 Re-run AI review
753 </button>
754 )}
737755 <Button
738756 type="submit"
739757 variant="danger"
@@ -1165,4 +1183,60 @@ pulls.post(
11651183 }
11661184);
11671185
1186// Re-run AI review on demand (e.g. after a force-push). Bypasses the
1187// idempotency marker via { force: true }. Write-access only.
1188pulls.post(
1189 "/:owner/:repo/pulls/:number/ai-rereview",
1190 softAuth,
1191 requireAuth,
1192 requireRepoAccess("write"),
1193 async (c) => {
1194 const { owner: ownerName, repo: repoName } = c.req.param();
1195 const prNum = parseInt(c.req.param("number"), 10);
1196 const resolved = await resolveRepo(ownerName, repoName);
1197 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1198
1199 const [pr] = await db
1200 .select()
1201 .from(pullRequests)
1202 .where(
1203 and(
1204 eq(pullRequests.repositoryId, resolved.repo.id),
1205 eq(pullRequests.number, prNum)
1206 )
1207 )
1208 .limit(1);
1209 if (!pr) {
1210 return c.redirect(`/${ownerName}/${repoName}/pulls`);
1211 }
1212
1213 if (!isAiReviewEnabled()) {
1214 return c.redirect(
1215 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
1216 "AI review is not configured (ANTHROPIC_API_KEY)."
1217 )}`
1218 );
1219 }
1220
1221 // Fire-and-forget but with { force: true } to bypass the
1222 // already-reviewed marker. The function still never throws.
1223 triggerAiReview(
1224 ownerName,
1225 repoName,
1226 pr.id,
1227 pr.title || "",
1228 pr.body || "",
1229 pr.baseBranch,
1230 pr.headBranch,
1231 { force: true }
1232 ).catch(() => {});
1233
1234 return c.redirect(
1235 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
1236 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
1237 )}`
1238 );
1239 }
1240);
1241
11681242export default pulls;
11691243