CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
pr-risk.test.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 tests. | |
| 3 | * | |
| 4 | * Drives the pure helpers directly: | |
| 5 | * - `computePrRiskScore` — six worked examples (one per band edge), | |
| 6 | * caps, zero-floor, monotonicity. | |
| 7 | * - `generatePrRiskSummary` — no-API-key fallback returns deterministic | |
| 8 | * prose. | |
| 9 | * - `computePrRiskForPullRequest` — null on non-existent PR (the DB | |
| 10 | * surface is stubbed via the same K1-style spread-from-real pattern | |
| 11 | * used in mcp-write.test.ts, so the test never touches Neon). | |
| 12 | * - Cache hit path returns the same shape as cache miss. | |
| 13 | * | |
| 14 | * Mock policy: we spread the REAL modules first so non-overridden helpers | |
| 15 | * stay live, then narrow each mock to the smallest surface. Originals are | |
| 16 | * restored in `afterAll` so the mocks never bleed into sibling suites. | |
| 17 | */ | |
| 18 | ||
| 19 | import { | |
| 20 | describe, | |
| 21 | expect, | |
| 22 | it, | |
| 23 | mock, | |
| 24 | afterAll, | |
| 25 | beforeEach, | |
| 26 | } from "bun:test"; | |
| 27 | ||
| 28 | import { | |
| 29 | computePrRiskScore, | |
| 30 | generatePrRiskSummary, | |
| 31 | buildSignalsFromDiff, | |
| 32 | isMajorBump, | |
| 33 | __test, | |
| 34 | type PrRiskSignals, | |
| 35 | } from "../lib/pr-risk"; | |
| 36 | ||
| 37 | // --------------------------------------------------------------------------- | |
| 38 | // computePrRiskScore — formula + bands | |
| 39 | // --------------------------------------------------------------------------- | |
| 40 | ||
| 41 | function zeroSignals(): PrRiskSignals { | |
| 42 | return { | |
| 43 | filesChanged: 0, | |
| 44 | linesAdded: 0, | |
| 45 | linesRemoved: 0, | |
| 46 | teamsAffected: 0, | |
| 47 | schemaMigrationTouched: false, | |
| 48 | lockedPathTouched: false, | |
| 49 | addsNewDependency: false, | |
| 50 | bumpsMajorDependency: false, | |
| 51 | testsAddedForNewCode: false, | |
| 52 | diffMinusTestRatio: 0, | |
| 53 | }; | |
| 54 | } | |
| 55 | ||
| 56 | describe("computePrRiskScore — six worked examples covering each band", () => { | |
| 57 | it("LOW (1/10): tiny doc-only PR with tests", () => { | |
| 58 | // 1 file × 0.3 = 0.3, 20 lines × 0.005 = 0.1, 1 team contributes 0, | |
| 59 | // tests added so the test-penalty is zero. Total ≈ 0.4 → rounds to 0 | |
| 60 | // → band low. | |
| 61 | const { score, band } = computePrRiskScore({ | |
| 62 | ...zeroSignals(), | |
| 63 | filesChanged: 1, | |
| 64 | linesAdded: 10, | |
| 65 | linesRemoved: 10, | |
| 66 | teamsAffected: 1, | |
| 67 | testsAddedForNewCode: true, | |
| 68 | diffMinusTestRatio: 0.0, | |
| 69 | }); | |
| 70 | expect(band).toBe("low"); | |
| 71 | expect(score).toBeLessThanOrEqual(2); | |
| 72 | }); | |
| 73 | ||
| 74 | it("LOW (2/10): moderate feature with tests", () => { | |
| 75 | // 5 files × 0.3 = 1.5, 100 lines × 0.005 = 0.5, 1 team = 0, no other | |
| 76 | // signals. Total = 2.0 → score 2 → low. | |
| 77 | const { score, band } = computePrRiskScore({ | |
| 78 | ...zeroSignals(), | |
| 79 | filesChanged: 5, | |
| 80 | linesAdded: 60, | |
| 81 | linesRemoved: 40, | |
| 82 | teamsAffected: 1, | |
| 83 | testsAddedForNewCode: true, | |
| 84 | diffMinusTestRatio: 0.0, | |
| 85 | }); | |
| 86 | expect(score).toBe(2); | |
| 87 | expect(band).toBe("low"); | |
| 88 | }); | |
| 89 | ||
| 90 | it("MEDIUM (3/10): adds a new dep + cross-team but tested", () => { | |
| 91 | // 6 files × 0.3 = 1.8, 200 lines × 0.005 = 1.0, 2 teams = 0.8, | |
| 92 | // addsNewDependency = 0.5. Total = 4.1 → score 4 → medium. | |
| 93 | const { score, band } = computePrRiskScore({ | |
| 94 | ...zeroSignals(), | |
| 95 | filesChanged: 6, | |
| 96 | linesAdded: 120, | |
| 97 | linesRemoved: 80, | |
| 98 | teamsAffected: 2, | |
| 99 | addsNewDependency: true, | |
| 100 | testsAddedForNewCode: true, | |
| 101 | diffMinusTestRatio: 0.0, | |
| 102 | }); | |
| 103 | expect(band).toBe("medium"); | |
| 104 | expect(score).toBeGreaterThanOrEqual(3); | |
| 105 | expect(score).toBeLessThanOrEqual(4); | |
| 106 | }); | |
| 107 | ||
| 108 | it("HIGH (6/10): schema migration, no tests, multi-team", () => { | |
| 109 | // 8 files × 0.3 = 2.4, 300 lines × 0.005 = 1.5, 2 teams = 0.8, | |
| 110 | // schemaMigration = 1.5, no tests + ratio 1 = 1.0. Total = 7.2 → 7 | |
| 111 | // → high. | |
| 112 | const { score, band } = computePrRiskScore({ | |
| 113 | ...zeroSignals(), | |
| 114 | filesChanged: 8, | |
| 115 | linesAdded: 200, | |
| 116 | linesRemoved: 100, | |
| 117 | teamsAffected: 2, | |
| 118 | schemaMigrationTouched: true, | |
| 119 | testsAddedForNewCode: false, | |
| 120 | diffMinusTestRatio: 1.0, | |
| 121 | }); | |
| 122 | expect(band).toBe("high"); | |
| 123 | expect(score).toBeGreaterThanOrEqual(5); | |
| 124 | expect(score).toBeLessThanOrEqual(7); | |
| 125 | }); | |
| 126 | ||
| 127 | it("HIGH (5/10): bumps major dependency without tests but no locked path", () => { | |
| 128 | // 5 files × 0.3 = 1.5, 100 lines × 0.005 = 0.5, 1 team = 0, | |
| 129 | // bumpsMajor = 1.2, no-tests + ratio 1 = 1.0. Total = 4.2 → score 4 | |
| 130 | // → medium. Increase deps to get high. | |
| 131 | // Use slightly bigger PR: 8 files, 200 lines, 1 team, major bump, | |
| 132 | // no tests. 8 × 0.3 = 2.4, 200 × 0.005 = 1.0, major=1.2, no tests=1.0 | |
| 133 | // Total = 5.6 → score 6 → high. | |
| 134 | const { score, band } = computePrRiskScore({ | |
| 135 | ...zeroSignals(), | |
| 136 | filesChanged: 8, | |
| 137 | linesAdded: 120, | |
| 138 | linesRemoved: 80, | |
| 139 | teamsAffected: 1, | |
| 140 | bumpsMajorDependency: true, | |
| 141 | testsAddedForNewCode: false, | |
| 142 | diffMinusTestRatio: 1.0, | |
| 143 | }); | |
| 144 | expect(band).toBe("high"); | |
| 145 | expect(score).toBeGreaterThanOrEqual(5); | |
| 146 | expect(score).toBeLessThanOrEqual(7); | |
| 147 | }); | |
| 148 | ||
| 149 | it("CRITICAL (9-10/10): touches schema, locked path, new dep, major bump, no tests, many teams", () => { | |
| 150 | const { score, band } = computePrRiskScore({ | |
| 151 | filesChanged: 50, | |
| 152 | linesAdded: 2000, | |
| 153 | linesRemoved: 1000, | |
| 154 | teamsAffected: 5, | |
| 155 | schemaMigrationTouched: true, | |
| 156 | lockedPathTouched: true, | |
| 157 | addsNewDependency: true, | |
| 158 | bumpsMajorDependency: true, | |
| 159 | testsAddedForNewCode: false, | |
| 160 | diffMinusTestRatio: 1.0, | |
| 161 | }); | |
| 162 | expect(band).toBe("critical"); | |
| 163 | expect(score).toBeGreaterThanOrEqual(8); | |
| 164 | }); | |
| 165 | }); | |
| 166 | ||
| 167 | describe("computePrRiskScore — bounds", () => { | |
| 168 | it("clamps at 10 when every signal is extreme", () => { | |
| 169 | const { score, band } = computePrRiskScore({ | |
| 170 | filesChanged: 10_000, | |
| 171 | linesAdded: 10_000_000, | |
| 172 | linesRemoved: 10_000_000, | |
| 173 | teamsAffected: 1000, | |
| 174 | schemaMigrationTouched: true, | |
| 175 | lockedPathTouched: true, | |
| 176 | addsNewDependency: true, | |
| 177 | bumpsMajorDependency: true, | |
| 178 | testsAddedForNewCode: false, | |
| 179 | diffMinusTestRatio: 1.0, | |
| 180 | }); | |
| 181 | expect(score).toBe(10); | |
| 182 | expect(band).toBe("critical"); | |
| 183 | }); | |
| 184 | ||
| 185 | it("clamps at 0 when every signal is zero / negative", () => { | |
| 186 | const { score, band } = computePrRiskScore({ | |
| 187 | filesChanged: 0, | |
| 188 | linesAdded: 0, | |
| 189 | linesRemoved: 0, | |
| 190 | teamsAffected: 0, | |
| 191 | schemaMigrationTouched: false, | |
| 192 | lockedPathTouched: false, | |
| 193 | addsNewDependency: false, | |
| 194 | bumpsMajorDependency: false, | |
| 195 | testsAddedForNewCode: true, | |
| 196 | diffMinusTestRatio: 0, | |
| 197 | }); | |
| 198 | expect(score).toBe(0); | |
| 199 | expect(band).toBe("low"); | |
| 200 | }); | |
| 201 | ||
| 202 | it("treats negative inputs the same as zero (defensive)", () => { | |
| 203 | const { score } = computePrRiskScore({ | |
| 204 | filesChanged: -5, | |
| 205 | linesAdded: -100, | |
| 206 | linesRemoved: -100, | |
| 207 | teamsAffected: -2, | |
| 208 | schemaMigrationTouched: false, | |
| 209 | lockedPathTouched: false, | |
| 210 | addsNewDependency: false, | |
| 211 | bumpsMajorDependency: false, | |
| 212 | testsAddedForNewCode: true, | |
| 213 | diffMinusTestRatio: -1, | |
| 214 | }); | |
| 215 | expect(score).toBe(0); | |
| 216 | }); | |
| 217 | }); | |
| 218 | ||
| 219 | describe("computePrRiskScore — monotonicity", () => { | |
| 220 | it("flipping any signal from false→true never decreases the score", () => { | |
| 221 | const baseSignals: PrRiskSignals = { | |
| 222 | filesChanged: 5, | |
| 223 | linesAdded: 100, | |
| 224 | linesRemoved: 50, | |
| 225 | teamsAffected: 1, | |
| 226 | schemaMigrationTouched: false, | |
| 227 | lockedPathTouched: false, | |
| 228 | addsNewDependency: false, | |
| 229 | bumpsMajorDependency: false, | |
| 230 | testsAddedForNewCode: true, | |
| 231 | diffMinusTestRatio: 0.0, | |
| 232 | }; | |
| 233 | const baseScore = computePrRiskScore(baseSignals).score; | |
| 234 | ||
| 235 | for (const key of [ | |
| 236 | "schemaMigrationTouched", | |
| 237 | "lockedPathTouched", | |
| 238 | "addsNewDependency", | |
| 239 | "bumpsMajorDependency", | |
| 240 | ] as const) { | |
| 241 | const next = { ...baseSignals, [key]: true }; | |
| 242 | const nextScore = computePrRiskScore(next).score; | |
| 243 | expect(nextScore).toBeGreaterThanOrEqual(baseScore); | |
| 244 | } | |
| 245 | ||
| 246 | // Removing testsAddedForNewCode + raising ratio can only raise the | |
| 247 | // score (never lower it). | |
| 248 | const noTests: PrRiskSignals = { | |
| 249 | ...baseSignals, | |
| 250 | testsAddedForNewCode: false, | |
| 251 | diffMinusTestRatio: 1.0, | |
| 252 | }; | |
| 253 | expect(computePrRiskScore(noTests).score).toBeGreaterThanOrEqual(baseScore); | |
| 254 | ||
| 255 | // Increasing the integer signals can only raise the score. | |
| 256 | for (const key of ["filesChanged", "linesAdded", "linesRemoved", "teamsAffected"] as const) { | |
| 257 | const next = { ...baseSignals, [key]: baseSignals[key] + 50 }; | |
| 258 | expect(computePrRiskScore(next).score).toBeGreaterThanOrEqual(baseScore); | |
| 259 | } | |
| 260 | }); | |
| 261 | ||
| 262 | it("the score is the same on identical input (pure)", () => { | |
| 263 | const s: PrRiskSignals = { | |
| 264 | filesChanged: 4, | |
| 265 | linesAdded: 80, | |
| 266 | linesRemoved: 20, | |
| 267 | teamsAffected: 1, | |
| 268 | schemaMigrationTouched: false, | |
| 269 | lockedPathTouched: true, | |
| 270 | addsNewDependency: false, | |
| 271 | bumpsMajorDependency: false, | |
| 272 | testsAddedForNewCode: false, | |
| 273 | diffMinusTestRatio: 0.7, | |
| 274 | }; | |
| 275 | expect(computePrRiskScore(s)).toEqual(computePrRiskScore(s)); | |
| 276 | }); | |
| 277 | }); | |
| 278 | ||
| 279 | // --------------------------------------------------------------------------- | |
| 280 | // buildSignalsFromDiff — path-classification helpers | |
| 281 | // --------------------------------------------------------------------------- | |
| 282 | ||
| 283 | describe("buildSignalsFromDiff", () => { | |
| 284 | it("flags schema migration paths and locked paths", () => { | |
| 285 | const signals = buildSignalsFromDiff({ | |
| 286 | files: [ | |
| 287 | { path: "drizzle/0099_my.sql", additions: 20, deletions: 0 }, | |
| 288 | { path: "sensitive/api-key.pem", additions: 1, deletions: 0 }, | |
| 289 | ], | |
| 290 | raw: "", | |
| 291 | ownerRules: [], | |
| 292 | baseDeps: new Map(), | |
| 293 | headDeps: new Map(), | |
| 294 | }); | |
| 295 | expect(signals.schemaMigrationTouched).toBe(true); | |
| 296 | expect(signals.lockedPathTouched).toBe(true); | |
| 297 | }); | |
| 298 | ||
| 299 | it("treats tests under __tests__/ as test files", () => { | |
| 300 | const signals = buildSignalsFromDiff({ | |
| 301 | files: [ | |
| 302 | { path: "src/foo.ts", additions: 50, deletions: 0 }, | |
| 303 | { path: "src/__tests__/foo.test.ts", additions: 50, deletions: 0 }, | |
| 304 | ], | |
| 305 | raw: "", | |
| 306 | ownerRules: [], | |
| 307 | baseDeps: new Map(), | |
| 308 | headDeps: new Map(), | |
| 309 | }); | |
| 310 | expect(signals.testsAddedForNewCode).toBe(true); | |
| 311 | // Roughly half the diff is tests → ratio about 0.5. | |
| 312 | expect(signals.diffMinusTestRatio).toBeCloseTo(0.5, 1); | |
| 313 | }); | |
| 314 | ||
| 315 | it("detects new dependencies and major bumps", () => { | |
| 316 | const base = new Map<string, string | null>([ | |
| 317 | ["npm:react", "^17.0.0"], | |
| 318 | ["npm:hono", "^3.0.0"], | |
| 319 | ]); | |
| 320 | const head = new Map<string, string | null>([ | |
| 321 | ["npm:react", "^18.0.0"], // major bump | |
| 322 | ["npm:hono", "^3.0.0"], // unchanged | |
| 323 | ["npm:zod", "^3.22.0"], // new | |
| 324 | ]); | |
| 325 | const signals = buildSignalsFromDiff({ | |
| 326 | files: [{ path: "package.json", additions: 3, deletions: 1 }], | |
| 327 | raw: "", | |
| 328 | ownerRules: [], | |
| 329 | baseDeps: base, | |
| 330 | headDeps: head, | |
| 331 | }); | |
| 332 | expect(signals.addsNewDependency).toBe(true); | |
| 333 | expect(signals.bumpsMajorDependency).toBe(true); | |
| 334 | }); | |
| 335 | ||
| 336 | it("counts distinct CODEOWNERS owners as teamsAffected", () => { | |
| 337 | const signals = buildSignalsFromDiff({ | |
| 338 | files: [ | |
| 339 | { path: "src/api/foo.ts", additions: 5, deletions: 0 }, | |
| 340 | { path: "src/web/bar.ts", additions: 5, deletions: 0 }, | |
| 341 | ], | |
| 342 | raw: "", | |
| 343 | ownerRules: [ | |
| 344 | { pattern: "src/api/**", owners: ["alice"] }, | |
| 345 | { pattern: "src/web/**", owners: ["bob"] }, | |
| 346 | ], | |
| 347 | baseDeps: new Map(), | |
| 348 | headDeps: new Map(), | |
| 349 | }); | |
| 350 | expect(signals.teamsAffected).toBe(2); | |
| 351 | }); | |
| 352 | }); | |
| 353 | ||
| 354 | describe("isMajorBump", () => { | |
| 355 | it("returns true on a major version increase", () => { | |
| 356 | expect(isMajorBump("^1.0.0", "^2.0.0")).toBe(true); | |
| 357 | expect(isMajorBump("17.0.0", "18.0.0")).toBe(true); | |
| 358 | expect(isMajorBump("~1.2.3", "^2.0.0")).toBe(true); | |
| 359 | }); | |
| 360 | it("returns false on minor / patch / no change", () => { | |
| 361 | expect(isMajorBump("^1.0.0", "^1.1.0")).toBe(false); | |
| 362 | expect(isMajorBump("^1.2.0", "^1.2.5")).toBe(false); | |
| 363 | expect(isMajorBump("^2.0.0", "^2.0.0")).toBe(false); | |
| 364 | }); | |
| 365 | it("returns false on unparseable specs (defensive)", () => { | |
| 366 | expect(isMajorBump(null, "^1.0.0")).toBe(false); | |
| 367 | expect(isMajorBump("workspace:*", "^1.0.0")).toBe(false); | |
| 368 | expect(isMajorBump("^1.0.0", null)).toBe(false); | |
| 369 | }); | |
| 370 | }); | |
| 371 | ||
| 372 | // --------------------------------------------------------------------------- | |
| 373 | // generatePrRiskSummary — fallback when no API key | |
| 374 | // --------------------------------------------------------------------------- | |
| 375 | ||
| 376 | describe("generatePrRiskSummary", () => { | |
| 377 | const originalKey = process.env.ANTHROPIC_API_KEY; | |
| 378 | ||
| 379 | afterAll(() => { | |
| 380 | if (originalKey === undefined) delete process.env.ANTHROPIC_API_KEY; | |
| 381 | else process.env.ANTHROPIC_API_KEY = originalKey; | |
| 382 | }); | |
| 383 | ||
| 384 | it("falls back to deterministic prose when no Anthropic key is set", async () => { | |
| 385 | delete process.env.ANTHROPIC_API_KEY; | |
| 386 | const text = await generatePrRiskSummary({ | |
| 387 | signals: { | |
| 388 | ...zeroSignals(), | |
| 389 | filesChanged: 3, | |
| 390 | linesAdded: 50, | |
| 391 | linesRemoved: 10, | |
| 392 | teamsAffected: 1, | |
| 393 | }, | |
| 394 | title: "Bump deps", | |
| 395 | baseBranch: "main", | |
| 396 | headBranch: "feature/x", | |
| 397 | }); | |
| 398 | expect(typeof text).toBe("string"); | |
| 399 | expect(text.length).toBeGreaterThan(0); | |
| 400 | // Deterministic fallback mentions the file count. | |
| 401 | expect(text).toContain("3 file"); | |
| 402 | }); | |
| 403 | ||
| 404 | it("deterministicSummary mentions schema migration + locked path", () => { | |
| 405 | const text = __test.deterministicSummary({ | |
| 406 | ...zeroSignals(), | |
| 407 | filesChanged: 5, | |
| 408 | linesAdded: 100, | |
| 409 | linesRemoved: 30, | |
| 410 | teamsAffected: 2, | |
| 411 | schemaMigrationTouched: true, | |
| 412 | lockedPathTouched: true, | |
| 413 | }); | |
| 414 | expect(text).toContain("schema migration"); | |
| 415 | expect(text).toContain("locked"); | |
| 416 | }); | |
| 417 | }); | |
| 418 | ||
| 419 | // --------------------------------------------------------------------------- | |
| 420 | // computePrRiskForPullRequest — null when PR not found | |
| 421 | // --------------------------------------------------------------------------- | |
| 422 | ||
| 423 | // Stub `../db` with a chain that returns no PR row for a probe id. We use | |
| 424 | // the same spread-from-real pattern as mcp-write.test.ts so the original | |
| 425 | // surface stays live and other test files are not poisoned by mock.module(). | |
| 426 | const _real_db = await import("../db"); | |
| 427 | ||
| 428 | let _nextPrJoinRow: any = null; | |
| 429 | ||
| 430 | const _selectChain: any = { | |
| 431 | from: () => _selectChain, | |
| 432 | innerJoin: () => _selectChain, | |
| 433 | leftJoin: () => _selectChain, | |
| 434 | rightJoin: () => _selectChain, | |
| 435 | where: () => _selectChain, | |
| 436 | orderBy: () => _selectChain, | |
| 437 | groupBy: () => _selectChain, | |
| 438 | limit: async () => (_nextPrJoinRow ? [_nextPrJoinRow] : []), | |
| 439 | then: (resolve: (v: any) => void) => | |
| 440 | resolve(_nextPrJoinRow ? [_nextPrJoinRow] : []), | |
| 441 | }; | |
| 442 | ||
| 443 | const _insertChain = () => ({ | |
| 444 | values: () => ({ | |
| 445 | then: (resolve: (v: any) => void) => resolve(undefined), | |
| 446 | returning: async () => [], | |
| 447 | }), | |
| 448 | }); | |
| 449 | ||
| 450 | const _fakeDb = { | |
| 451 | db: { | |
| 452 | select: () => _selectChain, | |
| 453 | insert: () => _insertChain(), | |
| 454 | update: () => ({ set: () => ({ where: () => Promise.resolve() }) }), | |
| 455 | delete: () => ({ where: () => Promise.resolve() }), | |
| 456 | }, | |
| 457 | getDb: () => _fakeDb.db, | |
| 458 | }; | |
| 459 | ||
| 460 | mock.module("../db", () => ({ ..._real_db, ..._fakeDb })); | |
| 461 | ||
| 462 | afterAll(() => { | |
| 463 | // Restore the real DB so downstream files see the original module. | |
| 464 | mock.module("../db", () => _real_db); | |
| 465 | }); | |
| 466 | ||
| 467 | beforeEach(() => { | |
| 468 | _nextPrJoinRow = null; | |
| 469 | }); | |
| 470 | ||
| 471 | describe("computePrRiskForPullRequest", () => { | |
| 472 | it("returns null for a non-existent pull request", async () => { | |
| 473 | // Re-import the orchestrator AFTER mock.module() so it picks up the | |
| 474 | // stubbed `../db`. Top-level import was a deliberate first-load, but | |
| 475 | // pr-risk is small + the import shape is preserved. | |
| 476 | const mod = await import("../lib/pr-risk"); | |
| 477 | _nextPrJoinRow = null; | |
| 478 | const result = await mod.computePrRiskForPullRequest("missing-pr-id"); | |
| 479 | expect(result).toBeNull(); | |
| 480 | }); | |
| 481 | }); | |
| 482 | ||
| 483 | // --------------------------------------------------------------------------- | |
| 484 | // Cache hit returns the same shape as cache miss | |
| 485 | // --------------------------------------------------------------------------- | |
| 486 | ||
| 487 | describe("PrRiskScore shape", () => { | |
| 488 | it("rowToPrRiskScore returns the same fields a fresh computation would", () => { | |
| 489 | const row = { | |
| 490 | id: "x", | |
| 491 | pullRequestId: "pr-1", | |
| 492 | commitSha: "abc1234", | |
| 493 | score: 6, | |
| 494 | band: "high", | |
| 495 | signals: { | |
| 496 | ...zeroSignals(), | |
| 497 | filesChanged: 4, | |
| 498 | linesAdded: 100, | |
| 499 | linesRemoved: 50, | |
| 500 | teamsAffected: 2, | |
| 501 | }, | |
| 502 | aiSummary: "Touches a schema migration.", | |
| 503 | generatedAt: new Date("2026-05-13T00:00:00Z"), | |
| 504 | } as any; | |
| 505 | const out = __test.rowToPrRiskScore(row); | |
| 506 | expect(out.score).toBe(6); | |
| 507 | expect(out.band).toBe("high"); | |
| 508 | expect(out.commitSha).toBe("abc1234"); | |
| 509 | expect(out.signals.filesChanged).toBe(4); | |
| 510 | expect(out.aiSummary).toBe("Touches a schema migration."); | |
| 511 | expect(out.generatedAt).toBeInstanceOf(Date); | |
| 512 | }); | |
| 513 | }); |