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.tsBlame274 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";
479dcd9Claude30import {
31 getAutomationSettings,
32 type AutomationSettingsLoader,
33} from "./automation-settings";
cb6fd59Claude34
35export const PR_TRIAGE_MARKER = "<!-- gluecron-pr-triage:summary -->";
36
37const DIFF_BYTE_CAP = 8_000;
38
0316dbbClaude39export interface PrTriageInput {
40 ownerName: string;
41 repoName: string;
42 repositoryId: string;
43 prId: string;
44 prAuthorId: string;
45 title: string;
46 body: string;
47 baseBranch: string;
48 headBranch: string;
49}
50
cb6fd59Claude51async function alreadyTriaged(prId: string): Promise<boolean> {
52 try {
53 const [row] = await db
54 .select({ id: prComments.id })
55 .from(prComments)
56 .where(
57 and(
58 eq(prComments.pullRequestId, prId),
59 eq(prComments.isAiReview, true),
60 like(prComments.body, `%${PR_TRIAGE_MARKER}%`)
61 )
62 )
63 .limit(1);
64 return !!row;
65 } catch {
66 return false;
67 }
68}
69
70async function loadAvailableLabels(repositoryId: string): Promise<string[]> {
71 try {
72 const rows = await db
73 .select({ name: labels.name })
74 .from(labels)
75 .where(eq(labels.repositoryId, repositoryId))
76 .orderBy(asc(labels.name));
77 return rows.map((r) => r.name);
78 } catch {
79 return [];
80 }
81}
82
83/**
84 * Candidate reviewers v1: the repo owner + recent contributors via the
85 * `pull_requests.authorId` join (last 50 PRs). Excludes the current PR
86 * author. Truncated to 12 entries to keep the prompt small.
87 */
88async function loadCandidateReviewers(
89 repositoryId: string,
90 excludeUserId: string
91): Promise<string[]> {
92 try {
93 const out = new Set<string>();
94
95 const [repoRow] = await db
96 .select({ ownerId: repositories.ownerId })
97 .from(repositories)
98 .where(eq(repositories.id, repositoryId))
99 .limit(1);
100 if (repoRow && repoRow.ownerId !== excludeUserId) {
101 const [owner] = await db
102 .select({ username: users.username })
103 .from(users)
104 .where(eq(users.id, repoRow.ownerId))
105 .limit(1);
106 if (owner) out.add(owner.username);
107 }
108
109 const recent = await db
110 .select({ authorId: pullRequests.authorId })
111 .from(pullRequests)
112 .where(eq(pullRequests.repositoryId, repositoryId))
113 .limit(50);
114 const ids = [
115 ...new Set(
116 recent
117 .map((r) => r.authorId)
118 .filter((id): id is string => !!id && id !== excludeUserId)
119 ),
120 ];
121 if (ids.length > 0) {
122 const us = await db
123 .select({ id: users.id, username: users.username })
124 .from(users);
125 const byId = new Map(us.map((u) => [u.id, u.username]));
126 for (const id of ids) {
127 const u = byId.get(id);
128 if (u) out.add(u);
129 if (out.size >= 12) break;
130 }
131 }
132 return [...out];
133 } catch {
134 return [];
135 }
136}
137
138/**
139 * Build a tiny diff summary suitable for the prompt — file path, +/-
140 * lines per file. Skips bodies entirely (we only need shape, not
141 * content). Cap total size at DIFF_BYTE_CAP. Empty string on failure.
142 */
143async function buildDiffSummary(
144 ownerName: string,
145 repoName: string,
146 baseBranch: string,
147 headBranch: string
148): Promise<string> {
149 try {
150 const cwd = getRepoPath(ownerName, repoName);
151 const proc = Bun.spawn(
152 [
153 "git",
154 "diff",
155 "--numstat",
156 `${baseBranch}...${headBranch}`,
157 "--",
158 ],
159 { cwd, stdout: "pipe", stderr: "pipe" }
160 );
161 let text = await new Response(proc.stdout).text();
162 await proc.exited;
163 text = text.trim();
164 if (!text) return "";
165 if (text.length > DIFF_BYTE_CAP) {
166 text = text.slice(0, DIFF_BYTE_CAP) + "\n...(truncated)";
167 }
168 return text;
169 } catch {
170 return "";
171 }
172}
173
174/**
175 * Pure helper: render the "## AI Triage" comment body. Exported for
176 * unit tests so we can pin the format without an Anthropic dependency.
177 */
178export function renderTriageComment(t: PrTriage): string {
179 const labels = t.suggestedLabels.length
180 ? t.suggestedLabels.map((l) => `\`${l}\``).join(", ")
181 : "_(no label suggestions)_";
182 const reviewers = t.suggestedReviewerUsernames.length
183 ? t.suggestedReviewerUsernames.map((u) => `@${u}`).join(", ")
184 : "_(no reviewer suggestions)_";
185 const summary = t.summary?.trim() || "_(no summary)_";
186
187 return [
188 PR_TRIAGE_MARKER,
189 "## AI Triage",
190 "",
191 `> ${summary}`,
192 "",
193 `**Priority:** ${t.priority}`,
194 `**Risk area:** ${t.riskArea}`,
195 "",
196 `**Suggested labels:** ${labels}`,
197 `**Suggested reviewers:** ${reviewers}`,
198 "",
199 "_Suggestions only — nothing has been applied. The PR author stays in control._",
200 ].join("\n");
201}
202
479dcd9Claude203export async function triggerPrTriage(
204 input: PrTriageInput,
205 options: { loadSettings?: AutomationSettingsLoader } = {}
206): Promise<void> {
cb6fd59Claude207 try {
208 if (process.env.DEBUG_PR_TRIAGE === "1") {
209 console.log(
210 "[pr-triage] queued",
211 input.ownerName,
212 input.repoName,
213 input.prId
214 );
215 }
216 if (!isAiAvailable()) return;
479dcd9Claude217 // Per-repo automation gate — 'off' skips triage; loader fails open to
218 // the default ('suggest' = current behavior). Env stays supreme above.
219 const automation = await (options.loadSettings ?? getAutomationSettings)(
220 input.repositoryId
221 );
222 if (automation.prTriageMode === "off") return;
cb6fd59Claude223 if (await alreadyTriaged(input.prId)) return;
224
225 const [diffSummary, availableLabels, candidateReviewers] = await Promise.all([
226 buildDiffSummary(
227 input.ownerName,
228 input.repoName,
229 input.baseBranch,
230 input.headBranch
231 ),
232 loadAvailableLabels(input.repositoryId),
233 loadCandidateReviewers(input.repositoryId, input.prAuthorId),
234 ]);
235
236 const triage = await triagePullRequest(
237 input.title,
238 input.body,
239 diffSummary,
240 availableLabels,
241 candidateReviewers
0316dbbClaude242 );
cb6fd59Claude243
244 const body = renderTriageComment(triage);
245
246 await db
247 .insert(prComments)
248 .values({
249 pullRequestId: input.prId,
250 authorId: input.prAuthorId,
251 body,
252 isAiReview: true,
253 })
976d7f7Claude254 .catch((err) => {
255 console.error(
256 `[pr-triage] failed to insert triage comment for PR ${input.prId}:`,
257 err instanceof Error ? err.message : err
258 );
259 });
cb6fd59Claude260 } catch (err) {
261 if (process.env.DEBUG_PR_TRIAGE === "1") {
262 console.error("[pr-triage] crashed:", err);
263 }
0316dbbClaude264 }
265}
cb6fd59Claude266
267/** Test-only export of internals so DB-less tests can reach the helpers. */
268export const __test = {
269 alreadyTriaged,
270 loadAvailableLabels,
271 loadCandidateReviewers,
272 buildDiffSummary,
273 renderTriageComment,
274};