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