Commit58915a9unknown_key
feat(issues): "Re-run AI triage" button on issue detail page
feat(issues): "Re-run AI triage" button on issue detail page
Symmetric to the PR re-review button shipped in the previous commit.
After an issue body has been edited the original AI triage is stale,
but triggerIssueTriage() short-circuits via ISSUE_TRIAGE_MARKER. This
adds the same escape hatch for issues.
Changes:
- triggerIssueTriage signature gains an optional
`options: { force?: boolean }` parameter (default {}). When true,
the alreadyTriaged marker check is bypassed. Existing callers stay
green.
- POST /:owner/:repo/issues/:number/ai-retriage (write-access).
Loads the issue fresh (so the new title/body get used), fires
triggerIssueTriage with { force: true }, redirects back with
`?info=AI re-triage queued. The new comment will appear in 10-30s;
reload to see it.`
- Issue detail render:
* Reads `?info=` query and renders a styled info banner above the
title.
* For users with `canManage` access on open issues, the comment-
form footer now shows a "Re-run AI triage" button next to
"Comment" / "Close issue". Uses formaction + formnovalidate so
the comment textarea (`required`) doesn't block submission.
Tests: +3 (auth-guard contracts on /:owner/:repo/issues/:n/ai-retriage;
triggerIssueTriage no-throw with the new force option). Total suite
1192 pass / 8 skip / 0 fail (was 1189 / 8 / 0).
https://claude.ai/code/session_0163vJChUuZqqtBBznUW6xBU3 files changed+139−258915a984a38feb9e38e709063d4044d6315df96
3 changed files+139−2
Addedsrc/__tests__/issues-ai-retriage.test.ts+69−0View fileUnifiedSplit
@@ -0,0 +1,69 @@
1/**
2 * Smoke tests for the on-demand issue re-triage endpoint:
3 * POST /:owner/:repo/issues/:number/ai-retriage
4 *
5 * Write-access only. Verifies auth-guard contracts and the
6 * triggerIssueTriage `force` parameter never-throws contract.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11
12describe("POST /:owner/:repo/issues/:number/ai-retriage — auth guard", () => {
13 it("redirects to /login when unauthenticated", async () => {
14 const res = await app.request(
15 "/alice/demo/issues/1/ai-retriage",
16 {
17 method: "POST",
18 headers: { "content-type": "application/x-www-form-urlencoded" },
19 body: "",
20 redirect: "manual",
21 }
22 );
23 expect([301, 302, 303, 307, 401, 403, 404, 503]).toContain(res.status);
24 if (res.status === 302 || res.status === 303 || res.status === 307) {
25 const loc = res.headers.get("location") || "";
26 expect(loc).toContain("/login");
27 }
28 });
29
30 it("rejects bogus bearer tokens", async () => {
31 const res = await app.request(
32 "/alice/demo/issues/1/ai-retriage",
33 {
34 method: "POST",
35 headers: {
36 "content-type": "application/x-www-form-urlencoded",
37 authorization: "Bearer glct_definitely-not-valid",
38 },
39 body: "",
40 }
41 );
42 expect([401, 403, 404, 503]).toContain(res.status);
43 });
44});
45
46describe("triggerIssueTriage — force option (idempotency bypass)", () => {
47 it("accepts and propagates the force flag without throwing", async () => {
48 const { triggerIssueTriage } = await import("../lib/issue-triage");
49 let threw = false;
50 try {
51 await triggerIssueTriage(
52 {
53 ownerName: "alice",
54 repoName: "demo",
55 repositoryId: "00000000-0000-0000-0000-000000000000",
56 issueId: "00000000-0000-0000-0000-000000000000",
57 issueNumber: 1,
58 authorId: "00000000-0000-0000-0000-000000000000",
59 title: "Test",
60 body: "",
61 },
62 { force: true }
63 );
64 } catch {
65 threw = true;
66 }
67 expect(threw).toBe(false);
68 });
69});
Modifiedsrc/lib/issue-triage.ts+3−2View fileUnifiedSplit
@@ -134,7 +134,8 @@ export function renderIssueTriageComment(t: IssueTriage): string {
134134}
135135
136136export async function triggerIssueTriage(
137 input: IssueTriageInput
137 input: IssueTriageInput,
138 options: { force?: boolean } = {}
138139): Promise<void> {
139140 try {
140141 if (process.env.DEBUG_ISSUE_TRIAGE === "1") {
@@ -146,7 +147,7 @@ export async function triggerIssueTriage(
146147 );
147148 }
148149 if (!isAiAvailable()) return;
149 if (await alreadyTriaged(input.issueId)) return;
150 if (!options.force && (await alreadyTriaged(input.issueId))) return;
150151
151152 const [availableLabels, recent] = await Promise.all([
152153 loadAvailableLabels(input.repositoryId),
Modifiedsrc/routes/issues.tsx+67−0View fileUnifiedSplit
@@ -349,6 +349,7 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("rea
349349 const canManage =
350350 user &&
351351 (user.id === resolved.owner.id || user.id === issue.authorId);
352 const info = c.req.query("info");
352353
353354 return c.html(
354355 <Layout
@@ -376,6 +377,11 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("rea
376377 }}
377378 />
378379 <div class="issue-detail">
380 {info && (
381 <div style="margin: 12px 0; padding: 10px 14px; border-radius: 6px; background: rgba(56, 139, 253, 0.1); border: 1px solid var(--accent); color: var(--text); font-size: 14px">
382 {decodeURIComponent(info)}
383 </div>
384 )}
379385 <h2>
380386 {issue.title}{" "}
381387 <span style="color:var(--text-muted);font-weight:400">
@@ -450,6 +456,17 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("rea
450456 : "Reopen issue"}
451457 </button>
452458 )}
459 {canManage && issue.state === "open" && (
460 <button
461 type="submit"
462 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
463 formnovalidate
464 class="btn"
465 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
466 >
467 Re-run AI triage
468 </button>
469 )}
453470 </div>
454471 </form>
455472 </div>
@@ -605,5 +622,55 @@ const IssueNav = ({
605622 />
606623);
607624
625// Re-run AI triage on demand (e.g. after the issue body has been edited).
626// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
627issueRoutes.post(
628 "/:owner/:repo/issues/:number/ai-retriage",
629 softAuth,
630 requireAuth,
631 requireRepoAccess("write"),
632 async (c) => {
633 const { owner: ownerName, repo: repoName } = c.req.param();
634 const issueNum = parseInt(c.req.param("number"), 10);
635 const user = c.get("user")!;
636 const resolved = await resolveRepo(ownerName, repoName);
637 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
638
639 const [issue] = await db
640 .select()
641 .from(issues)
642 .where(
643 and(
644 eq(issues.repositoryId, resolved.repo.id),
645 eq(issues.number, issueNum)
646 )
647 )
648 .limit(1);
649 if (!issue) {
650 return c.redirect(`/${ownerName}/${repoName}/issues`);
651 }
652
653 triggerIssueTriage(
654 {
655 ownerName,
656 repoName,
657 repositoryId: resolved.repo.id,
658 issueId: issue.id,
659 issueNumber: issue.number,
660 authorId: user.id,
661 title: issue.title,
662 body: issue.body || "",
663 },
664 { force: true }
665 ).catch(() => {});
666
667 return c.redirect(
668 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
669 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
670 )}`
671 );
672 }
673);
674
608675export default issueRoutes;
609676export { IssueNav };
610677