Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

pr-triage.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

pr-triage.tsBlame261 lines · 1 contributor
0316dbbClaude1/**
cb6fd59Claude2 * 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.
0316dbbClaude16 */
17
cb6fd59Claude18import { 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
0316dbbClaude35export interface PrTriageInput {
36 ownerName: string;
37 repoName: string;
38 repositoryId: string;
39 prId: string;
40 prAuthorId: string;
41 title: string;
42 body: string;
43 baseBranch: string;
44 headBranch: string;
45}
46
cb6fd59Claude47async 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
0316dbbClaude199export async function triggerPrTriage(input: PrTriageInput): Promise<void> {
cb6fd59Claude200 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
0316dbbClaude229 );
cb6fd59Claude230
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 })
976d7f7Claude241 .catch((err) => {
242 console.error(
243 `[pr-triage] failed to insert triage comment for PR ${input.prId}:`,
244 err instanceof Error ? err.message : err
245 );
246 });
cb6fd59Claude247 } catch (err) {
248 if (process.env.DEBUG_PR_TRIAGE === "1") {
249 console.error("[pr-triage] crashed:", err);
250 }
0316dbbClaude251 }
252}
cb6fd59Claude253
254/** Test-only export of internals so DB-less tests can reach the helpers. */
255export const __test = {
256 alreadyTriaged,
257 loadAvailableLabels,
258 loadCandidateReviewers,
259 buildDiffSummary,
260 renderTriageComment,
261};