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.
| 0316dbb | 1 | /** |
| cb6fd59 | 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. | |
| 0316dbb | 16 | */ |
| 17 | ||
| cb6fd59 | 18 | import { and, asc, eq, like } from "drizzle-orm"; |
| 19 | import { db } from "../db"; | |
| 20 | import { | |
| 21 | labels, | |
| 22 | prComments, | |
| 23 | pullRequests, | |
| 24 | repositories, | |
| 25 | users, | |
| 26 | } from "../db/schema"; | |
| 27 | import { getRepoPath } from "../git/repository"; | |
| 28 | import { triagePullRequest, type PrTriage } from "./ai-generators"; | |
| 29 | import { isAiAvailable } from "./ai-client"; | |
| 30 | ||
| 31 | export const PR_TRIAGE_MARKER = "<!-- gluecron-pr-triage:summary -->"; | |
| 32 | ||
| 33 | const DIFF_BYTE_CAP = 8_000; | |
| 34 | ||
| 0316dbb | 35 | export 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 | ||
| cb6fd59 | 47 | async 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 | ||
| 66 | async 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 | */ | |
| 84 | async 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 | */ | |
| 139 | async 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 | */ | |
| 174 | export 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 | ||
| 0316dbb | 199 | export async function triggerPrTriage(input: PrTriageInput): Promise<void> { |
| cb6fd59 | 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 | |
| 0316dbb | 229 | ); |
| cb6fd59 | 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 | }) | |
| 976d7f7 | 241 | .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 | }); | |
| cb6fd59 | 247 | } catch (err) { |
| 248 | if (process.env.DEBUG_PR_TRIAGE === "1") { | |
| 249 | console.error("[pr-triage] crashed:", err); | |
| 250 | } | |
| 0316dbb | 251 | } |
| 252 | } | |
| cb6fd59 | 253 | |
| 254 | /** Test-only export of internals so DB-less tests can reach the helpers. */ | |
| 255 | export const __test = { | |
| 256 | alreadyTriaged, | |
| 257 | loadAvailableLabels, | |
| 258 | loadCandidateReviewers, | |
| 259 | buildDiffSummary, | |
| 260 | renderTriageComment, | |
| 261 | }; |