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.
| 534f04a | 1 | /** |
| 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 | |
| a5705cf | 8 | * ratio). The LLM (Sonnet 4.6) only writes the one-paragraph |
| 534f04a | 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. | |
| a5705cf | 15 | * 2. `generatePrRiskSummary(args)` — calls Sonnet for prose. Never |
| 534f04a | 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 | ||
| 28 | import { and, desc, eq } from "drizzle-orm"; | |
| 29 | import { db } from "../db"; | |
| 30 | import { | |
| 31 | codeOwners, | |
| 32 | prRiskScores, | |
| 33 | pullRequests, | |
| 34 | repoDependencies, | |
| 35 | repositories, | |
| 36 | users, | |
| 37 | type PrRiskScoreRow, | |
| 38 | } from "../db/schema"; | |
| 39 | import { getDiff, getRepoPath, resolveRef } from "../git/repository"; | |
| 40 | import { | |
| 41 | getAnthropic, | |
| 42 | isAiAvailable, | |
| 43 | MODEL_HAIKU, | |
| b271465 | 44 | MODEL_SONNET, |
| 534f04a | 45 | extractText, |
| 46 | } from "./ai-client"; | |
| 47 | import { ownersForPath, parseCodeowners, type OwnerRule } from "./codeowners"; | |
| 48 | ||
| 49 | // --------------------------------------------------------------------------- | |
| 50 | // Public types | |
| 51 | // --------------------------------------------------------------------------- | |
| 52 | ||
| 53 | export type PrRiskBand = "low" | "medium" | "high" | "critical"; | |
| 54 | ||
| 55 | export 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 | ||
| 71 | export 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 | */ | |
| 108 | export 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 | ||
| 142 | function clamp01(n: number): number { | |
| 143 | if (!Number.isFinite(n)) return 0; | |
| 144 | return Math.max(0, Math.min(1, n)); | |
| 145 | } | |
| 146 | ||
| 147 | function 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 | */ | |
| 165 | export 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({ | |
| a5705cf | 178 | model: MODEL_SONNET, |
| 534f04a | 179 | 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 | ||
| 185 | PR title: ${args.title} | |
| 186 | Base branch: ${args.baseBranch} | |
| 187 | Head branch: ${args.headBranch} | |
| 188 | ||
| 189 | Signals (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 | ||
| 201 | Write ONE to THREE plain sentences describing the riskiest aspects of this PR. | |
| 202 | Tone: blunt, factual, no marketing. Do not include a numeric score (the reviewer | |
| 203 | already 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 | */ | |
| 219 | export 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 | ||
| 238 | function 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 | ||
| 249 | const SCHEMA_MIGRATION_PATTERNS = [ | |
| 250 | /^drizzle\//i, | |
| 251 | /^db\/migrations\//i, | |
| 252 | /^migrations\//i, | |
| 253 | /\/migrations\//i, | |
| 254 | /^src\/db\/schema\.ts$/i, | |
| 255 | ]; | |
| 256 | ||
| 257 | const LOCKED_PATH_PATTERNS = [ | |
| 258 | /^sensitive\//i, | |
| 259 | /^secrets\//i, | |
| 260 | /^infra\/secrets\//i, | |
| 261 | /\.pem$/i, | |
| 262 | /\.key$/i, | |
| 263 | ]; | |
| 264 | ||
| 265 | const TEST_PATH_PATTERNS = [ | |
| 266 | /(^|\/)__tests__\//i, | |
| 267 | /\.test\.[tj]sx?$/i, | |
| 268 | /\.spec\.[tj]sx?$/i, | |
| 269 | /(^|\/)tests?\//i, | |
| 270 | ]; | |
| 271 | ||
| 272 | function matchesAny(path: string, patterns: RegExp[]): boolean { | |
| 273 | for (const re of patterns) if (re.test(path)) return true; | |
| 274 | return false; | |
| 275 | } | |
| 276 | ||
| 277 | function isSchemaMigrationPath(path: string): boolean { | |
| 278 | return matchesAny(path, SCHEMA_MIGRATION_PATTERNS); | |
| 279 | } | |
| 280 | ||
| 281 | function isLockedPath(path: string): boolean { | |
| 282 | return matchesAny(path, LOCKED_PATH_PATTERNS); | |
| 283 | } | |
| 284 | ||
| 285 | function 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. */ | |
| 290 | export 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 | */ | |
| 301 | export 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 | */ | |
| 374 | export 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 | ||
| 384 | function 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 | ||
| 397 | interface PrRow { | |
| 398 | id: string; | |
| 399 | title: string; | |
| 400 | baseBranch: string; | |
| 401 | headBranch: string; | |
| 402 | repositoryId: string; | |
| 403 | } | |
| 404 | ||
| 405 | interface RepoRow { | |
| 406 | id: string; | |
| 407 | name: string; | |
| 408 | ownerUsername: string; | |
| 409 | } | |
| 410 | ||
| 411 | async 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 | ||
| 451 | async 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 | ||
| 469 | async 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 | */ | |
| 498 | async 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 | */ | |
| 548 | export 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 | */ | |
| 637 | export 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 | */ | |
| 675 | export 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 | ||
| 692 | function 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 | ||
| 707 | export const __test = { | |
| 708 | bandFor, | |
| 709 | clamp01, | |
| 710 | deterministicSummary, | |
| 711 | isMajorBump, | |
| 712 | leadingMajor, | |
| 713 | isSchemaMigrationPath, | |
| 714 | isLockedPath, | |
| 715 | isTestPath, | |
| 716 | rowToPrRiskScore, | |
| 717 | }; |