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-risk.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-risk.tsBlame716 lines · 1 contributor
534f04aClaude1/**
2 * Block M3 — AI pre-merge risk score.
3 *
4 * For every open PR we compute a transparent, auditable 0-10 risk score
5 * the reviewer can see at a glance before clicking Merge. The score is a
6 * pure function over a small set of signals (file count, line count,
7 * teams affected, schema migrations touched, dependency churn, test
8 * ratio). The LLM (Haiku — fast + cheap) only writes the one-paragraph
9 * prose summary; it never influences the numeric score.
10 *
11 * Architecture:
12 *
13 * 1. `computePrRiskScore(signals)` — pure helper. Same input always
14 * yields the same score. Documented inline; auditable.
15 * 2. `generatePrRiskSummary(args)` — calls Haiku for prose. Never
16 * throws — falls back to a deterministic sentence when no API key
17 * is set or the call fails.
18 * 3. `computePrRiskForPullRequest(prId)` — DB-backed orchestrator.
19 * Resolves the PR + head SHA, gathers signals, computes the score,
20 * persists it to `pr_risk_scores`, returns the full row.
21 * 4. `getCachedPrRisk(prId)` — cheap lookup for the current head SHA.
22 *
23 * Cache key: `(pull_request_id, commit_sha)`. When the head branch is
24 * force-pushed (new SHA), the cache miss naturally re-triggers a score
25 * computation.
26 */
27
28import { and, desc, eq } from "drizzle-orm";
29import { db } from "../db";
30import {
31 codeOwners,
32 prRiskScores,
33 pullRequests,
34 repoDependencies,
35 repositories,
36 users,
37 type PrRiskScoreRow,
38} from "../db/schema";
39import { getDiff, getRepoPath, resolveRef } from "../git/repository";
40import {
41 getAnthropic,
42 isAiAvailable,
43 MODEL_HAIKU,
44 extractText,
45} from "./ai-client";
46import { ownersForPath, parseCodeowners, type OwnerRule } from "./codeowners";
47
48// ---------------------------------------------------------------------------
49// Public types
50// ---------------------------------------------------------------------------
51
52export type PrRiskBand = "low" | "medium" | "high" | "critical";
53
54export interface PrRiskSignals {
55 filesChanged: number;
56 linesAdded: number;
57 linesRemoved: number;
58 /** Distinct CODEOWNERS owners touched (count). */
59 teamsAffected: number;
60 schemaMigrationTouched: boolean;
61 /** Paths under sensitive/ etc. */
62 lockedPathTouched: boolean;
63 addsNewDependency: boolean;
64 bumpsMajorDependency: boolean;
65 testsAddedForNewCode: boolean;
66 /** 0..1, 0 = lots of tests, 1 = no tests. */
67 diffMinusTestRatio: number;
68}
69
70export interface PrRiskScore {
71 score: number;
72 band: PrRiskBand;
73 signals: PrRiskSignals;
74 aiSummary: string;
75 generatedAt: Date;
76 commitSha: string;
77}
78
79// ---------------------------------------------------------------------------
80// Pure score formula
81// ---------------------------------------------------------------------------
82
83/**
84 * Transparent risk formula (intentionally simple, intentionally not LLM):
85 *
86 * + 0.3 per file changed (cap at 3.0)
87 * + 0.005 per (linesAdded + linesRemoved) (cap at 2.0)
88 * + 0.8 per team beyond the first (cap at 2.0)
89 * + 1.5 if schemaMigrationTouched
90 * + 1.0 if lockedPathTouched
91 * + 0.5 if addsNewDependency
92 * + 1.2 if bumpsMajorDependency
93 * + 1.0 * (testsAddedForNewCode ? 0 : diffMinusTestRatio)
94 *
95 * Clamp to [0, 10] and round to nearest integer.
96 *
97 * Band thresholds:
98 * 0–2 → low
99 * 3–4 → medium
100 * 5–7 → high
101 * 8–10 → critical
102 *
103 * Monotonicity: every weight is non-negative, so flipping any boolean
104 * signal from false to true (or increasing any counter) can only raise
105 * the score (until the cap). Verified by tests.
106 */
107export function computePrRiskScore(
108 signals: PrRiskSignals
109): { score: number; band: PrRiskBand } {
110 let raw = 0;
111
112 // File count contribution (cap at 3.0).
113 raw += Math.min(3.0, Math.max(0, signals.filesChanged) * 0.3);
114
115 // Line count contribution (cap at 2.0).
116 const totalLines =
117 Math.max(0, signals.linesAdded) + Math.max(0, signals.linesRemoved);
118 raw += Math.min(2.0, totalLines * 0.005);
119
120 // Teams-beyond-the-first contribution (cap at 2.0). teamsAffected=0 or 1
121 // contributes nothing; each additional team adds 0.8.
122 const extraTeams = Math.max(0, signals.teamsAffected - 1);
123 raw += Math.min(2.0, extraTeams * 0.8);
124
125 // Boolean signals.
126 if (signals.schemaMigrationTouched) raw += 1.5;
127 if (signals.lockedPathTouched) raw += 1.0;
128 if (signals.addsNewDependency) raw += 0.5;
129 if (signals.bumpsMajorDependency) raw += 1.2;
130
131 // Test-coverage penalty.
132 if (!signals.testsAddedForNewCode) {
133 const ratio = clamp01(signals.diffMinusTestRatio);
134 raw += 1.0 * ratio;
135 }
136
137 const score = Math.max(0, Math.min(10, Math.round(raw)));
138 return { score, band: bandFor(score) };
139}
140
141function clamp01(n: number): number {
142 if (!Number.isFinite(n)) return 0;
143 return Math.max(0, Math.min(1, n));
144}
145
146function bandFor(score: number): PrRiskBand {
147 if (score <= 2) return "low";
148 if (score <= 4) return "medium";
149 if (score <= 7) return "high";
150 return "critical";
151}
152
153// ---------------------------------------------------------------------------
154// AI prose summary — Haiku call with deterministic fallback
155// ---------------------------------------------------------------------------
156
157/**
158 * Generate a 1-3 sentence prose summary of the risk profile. Calls Haiku
159 * for fluency; on any failure (no key, API error, malformed response)
160 * falls back to a deterministic sentence built from the signal map.
161 *
162 * Never throws. Always returns a non-empty string.
163 */
164export async function generatePrRiskSummary(args: {
165 signals: PrRiskSignals;
166 title: string;
167 baseBranch: string;
168 headBranch: string;
169}): Promise<string> {
170 const fallback = deterministicSummary(args.signals);
171
172 if (!isAiAvailable()) return fallback;
173
174 try {
175 const client = getAnthropic();
176 const message = await client.messages.create({
177 model: MODEL_HAIKU,
178 max_tokens: 200,
179 messages: [
180 {
181 role: "user",
182 content: `You are summarising the risk profile of a pull request for a reviewer who is about to click Merge.
183
184PR title: ${args.title}
185Base branch: ${args.baseBranch}
186Head branch: ${args.headBranch}
187
188Signals (these are the FACTS — do not invent any others):
189- files changed: ${args.signals.filesChanged}
190- lines added: ${args.signals.linesAdded}
191- lines removed: ${args.signals.linesRemoved}
192- distinct CODEOWNERS owners touched: ${args.signals.teamsAffected}
193- schema migration touched: ${args.signals.schemaMigrationTouched}
194- locked / sensitive path touched: ${args.signals.lockedPathTouched}
195- adds a new dependency: ${args.signals.addsNewDependency}
196- bumps a major dependency: ${args.signals.bumpsMajorDependency}
197- tests added for new code: ${args.signals.testsAddedForNewCode}
198- diff-minus-test ratio (0=lots of tests, 1=no tests): ${args.signals.diffMinusTestRatio.toFixed(2)}
199
200Write ONE to THREE plain sentences describing the riskiest aspects of this PR.
201Tone: blunt, factual, no marketing. Do not include a numeric score (the reviewer
202already sees that). Do not use bullet points. Output the prose only.`,
203 },
204 ],
205 });
206 const text = extractText(message).trim();
207 if (!text) return fallback;
208 return text.slice(0, 600);
209 } catch {
210 return fallback;
211 }
212}
213
214/**
215 * Deterministic prose composed from the signal map. Used as the always-
216 * available fallback. Public-internal — exported for tests.
217 */
218export function deterministicSummary(signals: PrRiskSignals): string {
219 const parts: string[] = [];
220 parts.push(
221 `Touches ${signals.filesChanged} file(s) with ${signals.linesAdded} added and ${signals.linesRemoved} removed across ${signals.teamsAffected} owner(s).`
222 );
223 const hotspots: string[] = [];
224 if (signals.schemaMigrationTouched) hotspots.push("a schema migration");
225 if (signals.lockedPathTouched) hotspots.push("a locked / sensitive path");
226 if (signals.bumpsMajorDependency) hotspots.push("a major dependency bump");
227 else if (signals.addsNewDependency) hotspots.push("a new dependency");
228 if (hotspots.length > 0) {
229 parts.push(`Includes ${joinList(hotspots)}.`);
230 }
231 if (!signals.testsAddedForNewCode && signals.diffMinusTestRatio > 0.5) {
232 parts.push("No tests were added for the new code.");
233 }
234 return parts.join(" ");
235}
236
237function joinList(items: string[]): string {
238 if (items.length === 0) return "";
239 if (items.length === 1) return items[0];
240 if (items.length === 2) return `${items[0]} and ${items[1]}`;
241 return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
242}
243
244// ---------------------------------------------------------------------------
245// Signal-gathering helpers (DB + git)
246// ---------------------------------------------------------------------------
247
248const SCHEMA_MIGRATION_PATTERNS = [
249 /^drizzle\//i,
250 /^db\/migrations\//i,
251 /^migrations\//i,
252 /\/migrations\//i,
253 /^src\/db\/schema\.ts$/i,
254];
255
256const LOCKED_PATH_PATTERNS = [
257 /^sensitive\//i,
258 /^secrets\//i,
259 /^infra\/secrets\//i,
260 /\.pem$/i,
261 /\.key$/i,
262];
263
264const TEST_PATH_PATTERNS = [
265 /(^|\/)__tests__\//i,
266 /\.test\.[tj]sx?$/i,
267 /\.spec\.[tj]sx?$/i,
268 /(^|\/)tests?\//i,
269];
270
271function matchesAny(path: string, patterns: RegExp[]): boolean {
272 for (const re of patterns) if (re.test(path)) return true;
273 return false;
274}
275
276function isSchemaMigrationPath(path: string): boolean {
277 return matchesAny(path, SCHEMA_MIGRATION_PATTERNS);
278}
279
280function isLockedPath(path: string): boolean {
281 return matchesAny(path, LOCKED_PATH_PATTERNS);
282}
283
284function isTestPath(path: string): boolean {
285 return matchesAny(path, TEST_PATH_PATTERNS);
286}
287
288/** Diff-file shape we accept — keeps signal gather DI-friendly for tests. */
289export interface DiffFileInput {
290 path: string;
291 additions: number;
292 deletions: number;
293}
294
295/**
296 * Compute the raw signal map from a parsed diff plus owner rules and the
297 * pre/post dependency sets. Pure helper — no DB, no git, no LLM. Exported
298 * so it can be exercised directly by tests with synthetic inputs.
299 */
300export function buildSignalsFromDiff(args: {
301 files: DiffFileInput[];
302 raw: string;
303 ownerRules: OwnerRule[];
304 /** Existing dep set on the base side, keyed `ecosystem:name`. */
305 baseDeps: Map<string, string | null>;
306 /** Dep set after the PR's changes (best-effort), same key shape. */
307 headDeps: Map<string, string | null>;
308}): PrRiskSignals {
309 let linesAdded = 0;
310 let linesRemoved = 0;
311 let schemaMigrationTouched = false;
312 let lockedPathTouched = false;
313 let testFiles = 0;
314 let testLines = 0;
315 const ownerSet = new Set<string>();
316
317 for (const f of args.files) {
318 linesAdded += Math.max(0, f.additions || 0);
319 linesRemoved += Math.max(0, f.deletions || 0);
320 if (isSchemaMigrationPath(f.path)) schemaMigrationTouched = true;
321 if (isLockedPath(f.path)) lockedPathTouched = true;
322 if (isTestPath(f.path)) {
323 testFiles += 1;
324 testLines += Math.max(0, (f.additions || 0) + (f.deletions || 0));
325 }
326 if (args.ownerRules.length > 0) {
327 for (const o of ownersForPath(f.path, args.ownerRules)) {
328 ownerSet.add(o);
329 }
330 }
331 }
332
333 const filesChanged = args.files.length;
334 const teamsAffected = ownerSet.size;
335 const testsAddedForNewCode = testFiles > 0 && linesAdded > 0;
336 const totalLines = linesAdded + linesRemoved;
337 const diffMinusTestRatio =
338 totalLines === 0 ? 0 : 1 - Math.min(1, testLines / totalLines);
339
340 // Dependency comparison — adds vs bumps.
341 let addsNewDependency = false;
342 let bumpsMajorDependency = false;
343 for (const [key, headVersion] of args.headDeps) {
344 if (!args.baseDeps.has(key)) {
345 addsNewDependency = true;
346 continue;
347 }
348 const baseVersion = args.baseDeps.get(key) ?? null;
349 if (isMajorBump(baseVersion, headVersion)) {
350 bumpsMajorDependency = true;
351 }
352 }
353
354 return {
355 filesChanged,
356 linesAdded,
357 linesRemoved,
358 teamsAffected,
359 schemaMigrationTouched,
360 lockedPathTouched,
361 addsNewDependency,
362 bumpsMajorDependency,
363 testsAddedForNewCode,
364 diffMinusTestRatio,
365 };
366}
367
368/**
369 * Heuristic semver-major detector. Strips leading ^ ~ = v and compares
370 * the leading integer chunks. Anything we cannot parse returns false so
371 * we err toward "no false positive bump".
372 */
373export function isMajorBump(
374 before: string | null | undefined,
375 after: string | null | undefined
376): boolean {
377 const a = leadingMajor(before);
378 const b = leadingMajor(after);
379 if (a === null || b === null) return false;
380 return b > a;
381}
382
383function leadingMajor(spec: string | null | undefined): number | null {
384 if (!spec) return null;
385 const stripped = spec.trim().replace(/^[~^=v]+/, "");
386 const match = stripped.match(/^(\d+)/);
387 if (!match) return null;
388 const n = parseInt(match[1], 10);
389 return Number.isFinite(n) ? n : null;
390}
391
392// ---------------------------------------------------------------------------
393// DB-touching orchestrator
394// ---------------------------------------------------------------------------
395
396interface PrRow {
397 id: string;
398 title: string;
399 baseBranch: string;
400 headBranch: string;
401 repositoryId: string;
402}
403
404interface RepoRow {
405 id: string;
406 name: string;
407 ownerUsername: string;
408}
409
410async function loadPrRow(prId: string): Promise<{
411 pr: PrRow;
412 repo: RepoRow;
413} | null> {
414 try {
415 const [row] = await db
416 .select({
417 prId: pullRequests.id,
418 title: pullRequests.title,
419 baseBranch: pullRequests.baseBranch,
420 headBranch: pullRequests.headBranch,
421 repositoryId: pullRequests.repositoryId,
422 repoName: repositories.name,
423 ownerUsername: users.username,
424 })
425 .from(pullRequests)
426 .innerJoin(repositories, eq(repositories.id, pullRequests.repositoryId))
427 .innerJoin(users, eq(users.id, repositories.ownerId))
428 .where(eq(pullRequests.id, prId))
429 .limit(1);
430 if (!row) return null;
431 return {
432 pr: {
433 id: row.prId,
434 title: row.title,
435 baseBranch: row.baseBranch,
436 headBranch: row.headBranch,
437 repositoryId: row.repositoryId,
438 },
439 repo: {
440 id: row.repositoryId,
441 name: row.repoName,
442 ownerUsername: row.ownerUsername,
443 },
444 };
445 } catch {
446 return null;
447 }
448}
449
450async function loadOwnerRules(repoId: string): Promise<OwnerRule[]> {
451 try {
452 const rows = await db
453 .select({
454 pattern: codeOwners.pathPattern,
455 owners: codeOwners.ownerUsernames,
456 })
457 .from(codeOwners)
458 .where(eq(codeOwners.repositoryId, repoId));
459 return rows.map((r) => ({
460 pattern: r.pattern,
461 owners: (r.owners || "").split(",").filter(Boolean),
462 }));
463 } catch {
464 return [];
465 }
466}
467
468async function loadCurrentDeps(
469 repoId: string
470): Promise<Map<string, string | null>> {
471 try {
472 const rows = await db
473 .select({
474 ecosystem: repoDependencies.ecosystem,
475 name: repoDependencies.name,
476 versionSpec: repoDependencies.versionSpec,
477 })
478 .from(repoDependencies)
479 .where(eq(repoDependencies.repositoryId, repoId));
480 const out = new Map<string, string | null>();
481 for (const r of rows) {
482 out.set(`${r.ecosystem}:${r.name}`, r.versionSpec);
483 }
484 return out;
485 } catch {
486 return new Map();
487 }
488}
489
490/**
491 * Parse the dependency state shown by the PR's head commit. We re-use
492 * the indexed base-side `repo_dependencies` rows as a baseline and look
493 * for additions/changes by parsing a head-side `package.json` blob via
494 * `git show`. If git access fails we return null which the caller treats
495 * as "no signal" (addsNewDependency = bumpsMajorDependency = false).
496 */
497async function loadHeadDeps(
498 ownerName: string,
499 repoName: string,
500 baseBranch: string,
501 headBranch: string
502): Promise<Map<string, string | null>> {
503 const out = new Map<string, string | null>();
504 // We only sniff package.json on the head side (the most common manifest);
505 // the base-side counts come from the indexed snapshot already. This is a
506 // best-effort signal — false negatives are fine, false positives would be
507 // worse.
508 try {
509 const cwd = getRepoPath(ownerName, repoName);
510 const proc = Bun.spawn(
511 ["git", "show", `${headBranch}:package.json`],
512 { cwd, stdout: "pipe", stderr: "pipe" }
513 );
514 const text = await new Response(proc.stdout).text();
515 const exit = await proc.exited;
516 if (exit !== 0 || !text.trim()) return out;
517 const parsed = JSON.parse(text) as Record<string, unknown>;
518 for (const key of [
519 "dependencies",
520 "devDependencies",
521 "peerDependencies",
522 "optionalDependencies",
523 ]) {
524 const block = parsed[key];
525 if (!block || typeof block !== "object") continue;
526 for (const [name, spec] of Object.entries(block as Record<string, unknown>)) {
527 out.set(
528 `npm:${name}`,
529 typeof spec === "string" ? spec : null
530 );
531 }
532 }
533 // Mark baseBranch silently used so the args object stays meaningful
534 // even if a future refactor adds a base-side diff.
535 void baseBranch;
536 } catch {
537 /* swallow — return whatever we have */
538 }
539 return out;
540}
541
542/**
543 * Compute + persist the risk score for one PR. Returns null when the PR
544 * cannot be found or the git head SHA cannot be resolved. Idempotent on
545 * the unique (pull_request_id, commit_sha) constraint.
546 */
547export async function computePrRiskForPullRequest(
548 pullRequestId: string,
549 opts: { now?: Date } = {}
550): Promise<PrRiskScore | null> {
551 const loaded = await loadPrRow(pullRequestId);
552 if (!loaded) return null;
553
554 const { pr, repo } = loaded;
555 const headSha = await resolveRef(
556 repo.ownerUsername,
557 repo.name,
558 pr.headBranch
559 ).catch(() => null);
560 if (!headSha) return null;
561
562 let diffFiles: DiffFileInput[] = [];
563 let diffRaw = "";
564 try {
565 const d = await getDiff(repo.ownerUsername, repo.name, headSha);
566 diffFiles = d.files.map((f) => ({
567 path: f.path,
568 additions: f.additions,
569 deletions: f.deletions,
570 }));
571 diffRaw = d.raw;
572 } catch {
573 /* swallow — buildSignalsFromDiff handles empty arrays */
574 }
575
576 const ownerRules = await loadOwnerRules(repo.id);
577 const baseDeps = await loadCurrentDeps(repo.id);
578 const headDeps = await loadHeadDeps(
579 repo.ownerUsername,
580 repo.name,
581 pr.baseBranch,
582 pr.headBranch
583 );
584
585 const signals = buildSignalsFromDiff({
586 files: diffFiles,
587 raw: diffRaw,
588 ownerRules,
589 baseDeps,
590 headDeps,
591 });
592
593 const { score, band } = computePrRiskScore(signals);
594
595 const aiSummary = await generatePrRiskSummary({
596 signals,
597 title: pr.title,
598 baseBranch: pr.baseBranch,
599 headBranch: pr.headBranch,
600 });
601
602 const generatedAt = opts.now ?? new Date();
603
604 // Best-effort upsert: try insert, fall back to existing row on the
605 // unique-constraint hit.
606 try {
607 await db.insert(prRiskScores).values({
608 pullRequestId,
609 commitSha: headSha,
610 score,
611 band,
612 signals,
613 aiSummary,
614 generatedAt,
615 });
616 } catch {
617 // Already cached for this SHA — that's fine, the caller's view
618 // converges on the same shape via getCachedPrRisk below. No throw.
619 }
620
621 return {
622 score,
623 band,
624 signals,
625 aiSummary,
626 generatedAt,
627 commitSha: headSha,
628 };
629}
630
631/**
632 * Return the cached risk score for the current head SHA of a PR, if any.
633 * Returns null when no row matches (cache miss) — the caller is expected
634 * to kick off `computePrRiskForPullRequest` async and show a placeholder.
635 */
636export async function getCachedPrRisk(
637 pullRequestId: string
638): Promise<PrRiskScore | null> {
639 try {
640 const loaded = await loadPrRow(pullRequestId);
641 if (!loaded) return null;
642 const headSha = await resolveRef(
643 loaded.repo.ownerUsername,
644 loaded.repo.name,
645 loaded.pr.headBranch
646 ).catch(() => null);
647 if (!headSha) return null;
648
649 const [row] = await db
650 .select()
651 .from(prRiskScores)
652 .where(
653 and(
654 eq(prRiskScores.pullRequestId, pullRequestId),
655 eq(prRiskScores.commitSha, headSha)
656 )
657 )
658 .orderBy(desc(prRiskScores.generatedAt))
659 .limit(1);
660
661 if (!row) return null;
662 return rowToPrRiskScore(row);
663 } catch {
664 return null;
665 }
666}
667
668/**
669 * Lookup variant that doesn't need git access — returns the most-recent
670 * cached score for a PR regardless of which SHA it was pinned to. Used
671 * by the MCP merge tool where we want the score even if the SHA has
672 * since moved on.
673 */
674export async function getLatestCachedPrRisk(
675 pullRequestId: string
676): Promise<PrRiskScore | null> {
677 try {
678 const [row] = await db
679 .select()
680 .from(prRiskScores)
681 .where(eq(prRiskScores.pullRequestId, pullRequestId))
682 .orderBy(desc(prRiskScores.generatedAt))
683 .limit(1);
684 if (!row) return null;
685 return rowToPrRiskScore(row);
686 } catch {
687 return null;
688 }
689}
690
691function rowToPrRiskScore(row: PrRiskScoreRow): PrRiskScore {
692 return {
693 score: row.score,
694 band: row.band as PrRiskBand,
695 signals: (row.signals as unknown) as PrRiskSignals,
696 aiSummary: row.aiSummary ?? "",
697 generatedAt: row.generatedAt,
698 commitSha: row.commitSha,
699 };
700}
701
702// ---------------------------------------------------------------------------
703// Test-only surface
704// ---------------------------------------------------------------------------
705
706export const __test = {
707 bandFor,
708 clamp01,
709 deterministicSummary,
710 isMajorBump,
711 leadingMajor,
712 isSchemaMigrationPath,
713 isLockedPath,
714 isTestPath,
715 rowToPrRiskScore,
716};