Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitcb6fd59unknown_key

feat(pr-triage): wire triggerPrTriage from stub to real Claude triage

feat(pr-triage): wire triggerPrTriage from stub to real Claude triage

Previously triggerPrTriage() was a stub that did nothing — it logged
when DEBUG_PR_TRIAGE=1 was set but never called the AI helper or
posted a comment. The PR open path (src/routes/pulls.tsx:324) was
fire-and-forgetting into a no-op while ai-generators.ts had a fully
implemented triagePullRequest() ready to use.

This connects them:

1. On PR open (API key present, not previously triaged):
   - Builds a tiny diff summary via `git diff --numstat base...head`
     (capped at 8KB — only paths + line counts, not bodies).
   - Loads available labels from the repo.
   - Loads candidate reviewers: repo owner + recent PR authors
     (last 50 PRs), excluding the current author, capped at 12.
   - Calls triagePullRequest() for {labels, reviewers, priority,
     riskArea, summary}.
   - Renders a "## AI Triage" markdown comment via the pure
     renderTriageComment() helper and inserts as isAiReview=true,
     authored by the PR author.

2. Idempotency via PR_TRIAGE_MARKER (HTML comment embedded in body —
   invisible in render, searchable via LIKE). Re-firing on draft →
   ready toggle is a no-op when a prior triage exists.

3. Suggestions only — nothing applied. Bible §3 D3 contract preserved.

4. Never throws. Outer try/catch + per-step .catch handlers. The
   fire-and-forget call site stays unchanged.

5. Pure helper renderTriageComment exported alongside the existing
   __test bundle so the format can be pinned by unit tests without
   an Anthropic dependency. No emoji per project style.

Tests: +13 (marker stability, 6 renderTriageComment cases including
no-pictographic-emoji guard and "no labels/reviewers" placeholders,
4 fail-open contracts for the DB helpers, 2 triggerPrTriage no-throw
cases). Total suite 1143 pass / 8 skip / 0 fail (was 1130 / 8 / 0).

https://claude.ai/code/session_0163vJChUuZqqtBBznUW6xBU
Claude committed on April 30, 2026Parent: 665c8bf
2 files changed+40911cb6fd59396e9c205109ad1e27f554140a64873fb
2 changed files+409−11
Addedsrc/__tests__/pr-triage.test.ts+172−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/pr-triage.ts.
3 *
4 * Pure helpers (renderTriageComment) covered exhaustively. The
5 * DB-touching paths (alreadyTriaged, loadAvailableLabels,
6 * loadCandidateReviewers, buildDiffSummary) are exercised via the
7 * fail-open contracts — they must return reasonable values when no
8 * DB / no repo on disk, never throw.
9 *
10 * triggerPrTriage itself is verified to be a no-throw fire-and-forget
11 * even when called with garbage inputs and no API key.
12 */
13
14import { describe, it, expect } from "bun:test";
15import {
16 PR_TRIAGE_MARKER,
17 triggerPrTriage,
18 __test,
19} from "../lib/pr-triage";
20import type { PrTriage } from "../lib/ai-generators";
21
22const triageFixture = (overrides: Partial<PrTriage> = {}): PrTriage => ({
23 suggestedLabels: ["bug", "ui"],
24 suggestedReviewerUsernames: ["alice", "bob"],
25 priority: "medium",
26 riskArea: "frontend",
27 summary: "Fix login form colour contrast.",
28 ...overrides,
29});
30
31describe("PR_TRIAGE_MARKER", () => {
32 it("is a stable HTML comment so future searches keep working", () => {
33 expect(PR_TRIAGE_MARKER).toBe("<!-- gluecron-pr-triage:summary -->");
34 expect(PR_TRIAGE_MARKER.startsWith("<!--")).toBe(true);
35 expect(PR_TRIAGE_MARKER.endsWith("-->")).toBe(true);
36 });
37});
38
39describe("renderTriageComment", () => {
40 it("includes the marker, summary, priority, risk area, labels, reviewers", () => {
41 const out = __test.renderTriageComment(triageFixture());
42 expect(out).toContain(PR_TRIAGE_MARKER);
43 expect(out).toContain("## AI Triage");
44 expect(out).toContain("Fix login form colour contrast.");
45 expect(out).toContain("**Priority:** medium");
46 expect(out).toContain("**Risk area:** frontend");
47 expect(out).toContain("`bug`");
48 expect(out).toContain("`ui`");
49 expect(out).toContain("@alice");
50 expect(out).toContain("@bob");
51 });
52
53 it("uses an italic placeholder when there are no label suggestions", () => {
54 const out = __test.renderTriageComment(triageFixture({ suggestedLabels: [] }));
55 expect(out).toContain("_(no label suggestions)_");
56 });
57
58 it("uses an italic placeholder when there are no reviewer suggestions", () => {
59 const out = __test.renderTriageComment(
60 triageFixture({ suggestedReviewerUsernames: [] })
61 );
62 expect(out).toContain("_(no reviewer suggestions)_");
63 });
64
65 it("renders an italic placeholder when summary is missing/whitespace", () => {
66 const a = __test.renderTriageComment(triageFixture({ summary: "" }));
67 const b = __test.renderTriageComment(triageFixture({ summary: " " }));
68 expect(a).toContain("_(no summary)_");
69 expect(b).toContain("_(no summary)_");
70 });
71
72 it("ends with the suggestions-only disclaimer", () => {
73 const out = __test.renderTriageComment(triageFixture());
74 expect(out.trimEnd().endsWith(
75 "_Suggestions only — nothing has been applied. The PR author stays in control._"
76 )).toBe(true);
77 });
78
79 it("formats critical priority literally (no pictographic emoji)", () => {
80 // Reminder: tests guard the "no emoji" rule. We use the tighter
81 // Extended_Pictographic class so legitimate punctuation (em-dash,
82 // asterisks) doesn't match.
83 const out = __test.renderTriageComment(
84 triageFixture({ priority: "critical" })
85 );
86 expect(out).toContain("**Priority:** critical");
87 expect(/\p{Extended_Pictographic}/u.test(out)).toBe(false);
88 });
89});
90
91describe("__test fail-open contracts", () => {
92 it("alreadyTriaged returns false when no DB / unknown PR", async () => {
93 const out = await __test.alreadyTriaged(
94 "00000000-0000-0000-0000-000000000000"
95 );
96 expect(out).toBe(false);
97 });
98
99 it("loadAvailableLabels returns [] for an unknown repo", async () => {
100 const out = await __test.loadAvailableLabels(
101 "00000000-0000-0000-0000-000000000000"
102 );
103 expect(Array.isArray(out)).toBe(true);
104 });
105
106 it("loadCandidateReviewers returns [] for an unknown repo", async () => {
107 const out = await __test.loadCandidateReviewers(
108 "00000000-0000-0000-0000-000000000000",
109 "00000000-0000-0000-0000-000000000000"
110 );
111 expect(Array.isArray(out)).toBe(true);
112 });
113
114 it("buildDiffSummary returns '' for an unknown repo", async () => {
115 const out = await __test.buildDiffSummary(
116 "definitely-not-a-real-owner",
117 "definitely-not-a-real-repo",
118 "main",
119 "feature"
120 );
121 expect(out).toBe("");
122 });
123});
124
125describe("triggerPrTriage — fire-and-forget never throws", () => {
126 const before = process.env.ANTHROPIC_API_KEY;
127
128 it("returns cleanly when API key is absent", async () => {
129 delete process.env.ANTHROPIC_API_KEY;
130 try {
131 let threw = false;
132 try {
133 await triggerPrTriage({
134 ownerName: "alice",
135 repoName: "demo",
136 repositoryId: "00000000-0000-0000-0000-000000000000",
137 prId: "00000000-0000-0000-0000-000000000000",
138 prAuthorId: "00000000-0000-0000-0000-000000000000",
139 title: "Test",
140 body: "",
141 baseBranch: "main",
142 headBranch: "feature",
143 });
144 } catch {
145 threw = true;
146 }
147 expect(threw).toBe(false);
148 } finally {
149 if (before) process.env.ANTHROPIC_API_KEY = before;
150 }
151 });
152
153 it("never throws on empty/garbage inputs", async () => {
154 let threw = false;
155 try {
156 await triggerPrTriage({
157 ownerName: "",
158 repoName: "",
159 repositoryId: "not-a-uuid",
160 prId: "not-a-uuid",
161 prAuthorId: "not-a-uuid",
162 title: "",
163 body: "",
164 baseBranch: "",
165 headBranch: "",
166 });
167 } catch {
168 threw = true;
169 }
170 expect(threw).toBe(false);
171 });
172});
Modifiedsrc/lib/pr-triage.ts+237−11View fileUnifiedSplit
11/**
2 * PR triage — fire-and-forget hook that would suggest labels and reviewers.
3 * Stub implementation: logs the request but does not call out to the AI yet.
2 * PR triage — fire-and-forget hook on PR open. Runs the AI-driven
3 * triage helper from ai-generators.ts and posts a "## AI Triage"
4 * comment with suggestions (labels, reviewers, priority, risk area,
5 * one-line summary).
6 *
7 * Suggestions only — nothing is applied automatically. The PR author
8 * stays in control. The Bible §3 D3 documents this contract.
9 *
10 * Idempotency: every comment carries an HTML-comment marker so a second
11 * trigger (e.g. draft → ready toggle) won't re-post the same advice.
12 *
13 * Failure modes (DB hiccup, missing key, AI parse error) are all
14 * funnelled through fallback / try-catch paths so the autopilot loop
15 * and the route handler never see a thrown promise.
416 */
517
18import { and, asc, eq, like } from "drizzle-orm";
19import { db } from "../db";
20import {
21 labels,
22 prComments,
23 pullRequests,
24 repositories,
25 users,
26} from "../db/schema";
27import { getRepoPath } from "../git/repository";
28import { triagePullRequest, type PrTriage } from "./ai-generators";
29import { isAiAvailable } from "./ai-client";
30
31export const PR_TRIAGE_MARKER = "<!-- gluecron-pr-triage:summary -->";
32
33const DIFF_BYTE_CAP = 8_000;
34
635export interface PrTriageInput {
736 ownerName: string;
837 repoName: string;
1544 headBranch: string;
1645}
1746
47async function alreadyTriaged(prId: string): Promise<boolean> {
48 try {
49 const [row] = await db
50 .select({ id: prComments.id })
51 .from(prComments)
52 .where(
53 and(
54 eq(prComments.pullRequestId, prId),
55 eq(prComments.isAiReview, true),
56 like(prComments.body, `%${PR_TRIAGE_MARKER}%`)
57 )
58 )
59 .limit(1);
60 return !!row;
61 } catch {
62 return false;
63 }
64}
65
66async function loadAvailableLabels(repositoryId: string): Promise<string[]> {
67 try {
68 const rows = await db
69 .select({ name: labels.name })
70 .from(labels)
71 .where(eq(labels.repositoryId, repositoryId))
72 .orderBy(asc(labels.name));
73 return rows.map((r) => r.name);
74 } catch {
75 return [];
76 }
77}
78
79/**
80 * Candidate reviewers v1: the repo owner + recent contributors via the
81 * `pull_requests.authorId` join (last 50 PRs). Excludes the current PR
82 * author. Truncated to 12 entries to keep the prompt small.
83 */
84async function loadCandidateReviewers(
85 repositoryId: string,
86 excludeUserId: string
87): Promise<string[]> {
88 try {
89 const out = new Set<string>();
90
91 const [repoRow] = await db
92 .select({ ownerId: repositories.ownerId })
93 .from(repositories)
94 .where(eq(repositories.id, repositoryId))
95 .limit(1);
96 if (repoRow && repoRow.ownerId !== excludeUserId) {
97 const [owner] = await db
98 .select({ username: users.username })
99 .from(users)
100 .where(eq(users.id, repoRow.ownerId))
101 .limit(1);
102 if (owner) out.add(owner.username);
103 }
104
105 const recent = await db
106 .select({ authorId: pullRequests.authorId })
107 .from(pullRequests)
108 .where(eq(pullRequests.repositoryId, repositoryId))
109 .limit(50);
110 const ids = [
111 ...new Set(
112 recent
113 .map((r) => r.authorId)
114 .filter((id): id is string => !!id && id !== excludeUserId)
115 ),
116 ];
117 if (ids.length > 0) {
118 const us = await db
119 .select({ id: users.id, username: users.username })
120 .from(users);
121 const byId = new Map(us.map((u) => [u.id, u.username]));
122 for (const id of ids) {
123 const u = byId.get(id);
124 if (u) out.add(u);
125 if (out.size >= 12) break;
126 }
127 }
128 return [...out];
129 } catch {
130 return [];
131 }
132}
133
134/**
135 * Build a tiny diff summary suitable for the prompt — file path, +/-
136 * lines per file. Skips bodies entirely (we only need shape, not
137 * content). Cap total size at DIFF_BYTE_CAP. Empty string on failure.
138 */
139async function buildDiffSummary(
140 ownerName: string,
141 repoName: string,
142 baseBranch: string,
143 headBranch: string
144): Promise<string> {
145 try {
146 const cwd = getRepoPath(ownerName, repoName);
147 const proc = Bun.spawn(
148 [
149 "git",
150 "diff",
151 "--numstat",
152 `${baseBranch}...${headBranch}`,
153 "--",
154 ],
155 { cwd, stdout: "pipe", stderr: "pipe" }
156 );
157 let text = await new Response(proc.stdout).text();
158 await proc.exited;
159 text = text.trim();
160 if (!text) return "";
161 if (text.length > DIFF_BYTE_CAP) {
162 text = text.slice(0, DIFF_BYTE_CAP) + "\n...(truncated)";
163 }
164 return text;
165 } catch {
166 return "";
167 }
168}
169
170/**
171 * Pure helper: render the "## AI Triage" comment body. Exported for
172 * unit tests so we can pin the format without an Anthropic dependency.
173 */
174export function renderTriageComment(t: PrTriage): string {
175 const labels = t.suggestedLabels.length
176 ? t.suggestedLabels.map((l) => `\`${l}\``).join(", ")
177 : "_(no label suggestions)_";
178 const reviewers = t.suggestedReviewerUsernames.length
179 ? t.suggestedReviewerUsernames.map((u) => `@${u}`).join(", ")
180 : "_(no reviewer suggestions)_";
181 const summary = t.summary?.trim() || "_(no summary)_";
182
183 return [
184 PR_TRIAGE_MARKER,
185 "## AI Triage",
186 "",
187 `> ${summary}`,
188 "",
189 `**Priority:** ${t.priority}`,
190 `**Risk area:** ${t.riskArea}`,
191 "",
192 `**Suggested labels:** ${labels}`,
193 `**Suggested reviewers:** ${reviewers}`,
194 "",
195 "_Suggestions only — nothing has been applied. The PR author stays in control._",
196 ].join("\n");
197}
198
18199export async function triggerPrTriage(input: PrTriageInput): Promise<void> {
19 // Intentionally a no-op for now; downstream consumers only await the
20 // promise for catch handlers. The implementation will be filled in
21 // alongside the AI triage pipeline.
22 if (process.env.DEBUG_PR_TRIAGE === "1") {
23 console.log(
24 "[pr-triage] queued",
25 input.ownerName,
26 input.repoName,
27 input.prId,
200 try {
201 if (process.env.DEBUG_PR_TRIAGE === "1") {
202 console.log(
203 "[pr-triage] queued",
204 input.ownerName,
205 input.repoName,
206 input.prId
207 );
208 }
209 if (!isAiAvailable()) return;
210 if (await alreadyTriaged(input.prId)) return;
211
212 const [diffSummary, availableLabels, candidateReviewers] = await Promise.all([
213 buildDiffSummary(
214 input.ownerName,
215 input.repoName,
216 input.baseBranch,
217 input.headBranch
218 ),
219 loadAvailableLabels(input.repositoryId),
220 loadCandidateReviewers(input.repositoryId, input.prAuthorId),
221 ]);
222
223 const triage = await triagePullRequest(
224 input.title,
225 input.body,
226 diffSummary,
227 availableLabels,
228 candidateReviewers
28229 );
230
231 const body = renderTriageComment(triage);
232
233 await db
234 .insert(prComments)
235 .values({
236 pullRequestId: input.prId,
237 authorId: input.prAuthorId,
238 body,
239 isAiReview: true,
240 })
241 .catch(() => {});
242 } catch (err) {
243 if (process.env.DEBUG_PR_TRIAGE === "1") {
244 console.error("[pr-triage] crashed:", err);
245 }
29246 }
30247}
248
249/** Test-only export of internals so DB-less tests can reach the helpers. */
250export const __test = {
251 alreadyTriaged,
252 loadAvailableLabels,
253 loadCandidateReviewers,
254 buildDiffSummary,
255 renderTriageComment,
256};
31257