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"; | |
| 479dcd9 | 30 | import { |
| 31 | getAutomationSettings, | |
| 32 | type AutomationSettingsLoader, | |
| 33 | } from "./automation-settings"; | |
| cb6fd59 | 34 | |
| 35 | export const PR_TRIAGE_MARKER = "<!-- gluecron-pr-triage:summary -->"; | |
| 36 | ||
| 37 | const DIFF_BYTE_CAP = 8_000; | |
| 38 | ||
| 0316dbb | 39 | export 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 | ||
| cb6fd59 | 51 | async 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 | ||
| 70 | async 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 | */ | |
| 88 | async 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 | */ | |
| 143 | async 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 | */ | |
| 178 | export 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 | ||
| 479dcd9 | 203 | export async function triggerPrTriage( |
| 204 | input: PrTriageInput, | |
| 205 | options: { loadSettings?: AutomationSettingsLoader } = {} | |
| 206 | ): Promise<void> { | |
| cb6fd59 | 207 | 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; | |
| 479dcd9 | 217 | // 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; | |
| cb6fd59 | 223 | 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 | |
| 0316dbb | 242 | ); |
| cb6fd59 | 243 | |
| 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 | }) | |
| 976d7f7 | 254 | .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 | }); | |
| cb6fd59 | 260 | } catch (err) { |
| 261 | if (process.env.DEBUG_PR_TRIAGE === "1") { | |
| 262 | console.error("[pr-triage] crashed:", err); | |
| 263 | } | |
| 0316dbb | 264 | } |
| 265 | } | |
| cb6fd59 | 266 | |
| 267 | /** Test-only export of internals so DB-less tests can reach the helpers. */ | |
| 268 | export const __test = { | |
| 269 | alreadyTriaged, | |
| 270 | loadAvailableLabels, | |
| 271 | loadCandidateReviewers, | |
| 272 | buildDiffSummary, | |
| 273 | renderTriageComment, | |
| 274 | }; |