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.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.

pr-risk.test.tsBlame513 lines · 1 contributor
534f04aClaude1/**
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
19import {
20 describe,
21 expect,
22 it,
23 mock,
24 afterAll,
25 beforeEach,
26} from "bun:test";
27
28import {
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
41function 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
56describe("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
167describe("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
219describe("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
283describe("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
354describe("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
376describe("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().
426const _real_db = await import("../db");
427
428let _nextPrJoinRow: any = null;
429
430const _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
443const _insertChain = () => ({
444 values: () => ({
445 then: (resolve: (v: any) => void) => resolve(undefined),
446 returning: async () => [],
447 }),
448});
449
450const _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
460mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
461
462afterAll(() => {
463 // Restore the real DB so downstream files see the original module.
464 mock.module("../db", () => _real_db);
465});
466
467beforeEach(() => {
468 _nextPrJoinRow = null;
469});
470
471describe("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
487describe("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});