Skip to content

Commit d1cb4e5

Browse files
authored
feat(review): add finding acceptance rate calculation and related types (#1967) (#5127)
Introduces the FindingAcceptanceAggregate interface to track the acceptance rate of PRs flagged with blocking findings (hold/close). Implements aggregateFindingAcceptance function to compute the acceptance metrics from flagged PR outcomes. Updates computeStats to include finding acceptance data and adds corresponding tests to validate the new functionality.
1 parent f799c2c commit d1cb4e5

2 files changed

Lines changed: 192 additions & 5 deletions

File tree

src/review/stats.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,28 @@ export const EMPTY_CYCLE_TIME: CycleTimeAggregate = {
195195
sampleSize: 0,
196196
};
197197

198+
/** Finding acceptance rate (#1967): of PRs whose gate raised a BLOCKING finding (a `gate_decision` of `hold`
199+
* or `close` — the gate did NOT clear the PR to merge), how often the contributor acted on it and the PR
200+
* shipped anyway. The realized `pr_outcome` (merged vs closed) is the answer key, so acceptance is measurable
201+
* with zero manual labeling — the maintainer-ROI "our review feedback gets acted on X% of the time" signal. */
202+
export interface FindingAcceptanceAggregate {
203+
/** PRs whose gate raised a blocking finding (hold|close) that reached a realized outcome in the window. */
204+
flagged: number;
205+
/** Of `flagged`, those later merged — the contributor acted on the finding and the PR shipped. */
206+
addressed: number;
207+
/** Of `flagged`, those closed without merging — the finding stood; the PR did not ship. */
208+
unaddressed: number;
209+
/** addressed / flagged (3 dp); null when `flagged` is 0. */
210+
acceptanceRate: number | null;
211+
}
212+
213+
export const EMPTY_FINDING_ACCEPTANCE: FindingAcceptanceAggregate = {
214+
flagged: 0,
215+
addressed: 0,
216+
unaddressed: 0,
217+
acceptanceRate: null,
218+
};
219+
198220
export interface StatsPayload {
199221
generatedAt: string;
200222
window: { fromIso: string; days: number; bucket: string };
@@ -216,6 +238,9 @@ export interface StatsPayload {
216238
gateParity: GateParityReport & { cutoverReady: Array<{ project: string; ready: boolean }> };
217239
/** PR review cycle-time percentiles (gate decision → outcome) from review_audit (#2194). */
218240
cycleTime: CycleTimeAggregate;
241+
/** Finding acceptance rate (#1967): of PRs the gate flagged with a blocking finding (hold|close), the
242+
* fraction later merged — i.e. the contributor acted on the finding and the PR shipped. */
243+
findingAcceptance: FindingAcceptanceAggregate;
219244
}
220245

221246
/** ms between the gate decision and the resolution; null if implausible (NaN or negative). */
@@ -296,6 +321,61 @@ export async function computeCycleTimeAggregate(
296321
}
297322
}
298323

324+
/** Pure fold: flagged-PR outcome samples → the acceptance aggregate (#1967). Each sample is a PR whose gate
325+
* raised a blocking finding (hold|close); `merged` is its realized pr_outcome (true = merged, false = closed). */
326+
export function aggregateFindingAcceptance(
327+
samples: ReadonlyArray<{ merged: boolean }>,
328+
): FindingAcceptanceAggregate {
329+
const flagged = samples.length;
330+
if (flagged === 0) return EMPTY_FINDING_ACCEPTANCE;
331+
const addressed = samples.filter((sample) => sample.merged).length;
332+
return {
333+
flagged,
334+
addressed,
335+
unaddressed: flagged - addressed,
336+
acceptanceRate: Number((addressed / flagged).toFixed(3)),
337+
};
338+
}
339+
340+
// A PR is "flagged" if it EVER received a hold/close gate decision in the window (DISTINCT target_id), so a
341+
// PR that was held, fixed, then merge-cleared still counts as flagged-then-addressed. Joined to the LATEST
342+
// pr_outcome per target (rn = 1) so a reopened+reclosed PR keeps its final realized state.
343+
const FINDING_ACCEPTANCE_SQL = `WITH flagged AS (
344+
SELECT DISTINCT target_id
345+
FROM review_audit
346+
WHERE event_type = 'gate_decision' AND decision IN ('hold', 'close') AND created_at >= ?
347+
),
348+
po AS (
349+
SELECT target_id, decision AS truth,
350+
ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY created_at DESC) AS rn
351+
FROM review_audit
352+
WHERE event_type = 'pr_outcome' AND decision IS NOT NULL AND created_at >= ?
353+
)
354+
SELECT po.truth AS truth
355+
FROM flagged
356+
JOIN po ON flagged.target_id = po.target_id
357+
WHERE po.rn = 1`;
358+
359+
/** Load flagged-PR (blocking gate finding) → realized-outcome samples for the window and fold them into the
360+
* acceptance rate. Fail-safe → empty aggregate (mirrors computeCycleTimeAggregate). (#1967) */
361+
export async function computeFindingAcceptance(
362+
env: Env,
363+
opts: { days: number; nowMs: number },
364+
): Promise<FindingAcceptanceAggregate> {
365+
const days = Number.isFinite(opts.days) && opts.days > 0 ? Math.min(opts.days, 730) : 90;
366+
const fromIso = new Date(opts.nowMs - days * 86_400_000).toISOString().slice(0, 10);
367+
try {
368+
const rows = await storage(env)
369+
.prepare(FINDING_ACCEPTANCE_SQL)
370+
.bind(fromIso, fromIso)
371+
.all<{ truth: string }>();
372+
const samples = (rows.results ?? []).map((row) => ({ merged: row.truth === "merged" }));
373+
return aggregateFindingAcceptance(samples);
374+
} catch {
375+
return EMPTY_FINDING_ACCEPTANCE;
376+
}
377+
}
378+
299379
/** Fold per-PR persisted minutes into the maintainer aggregate (avg band + total minutes). */
300380
export function aggregateReviewEffort(perPrMinutes: number[]): ReviewEffortAggregate {
301381
if (perPrMinutes.length === 0) {
@@ -324,7 +404,7 @@ export async function computeStats(
324404
const bucketExpr = BUCKET_SQL[bucket] ?? BUCKET_SQL.day;
325405
const fromIso = new Date(opts.nowMs - days * 86_400_000).toISOString().slice(0, 10); // YYYY-MM-DD
326406

327-
const [decisionRows, reversalRows, effortRows, cycleTime] = await Promise.all([
407+
const [decisionRows, reversalRows, effortRows, cycleTime, findingAcceptance] = await Promise.all([
328408
storage(env).prepare(
329409
`SELECT ${bucketExpr} AS bucket, project, COALESCE(verdict, status) AS verdict, COUNT(*) AS n
330410
FROM review_targets
@@ -359,6 +439,7 @@ export async function computeStats(
359439
).bind(fromIso).all<{ minutes: number }>()
360440
.catch(() => ({ results: [] as Array<{ minutes: number }> })),
361441
computeCycleTimeAggregate(env, { days, nowMs: opts.nowMs }),
442+
computeFindingAcceptance(env, { days, nowMs: opts.nowMs }),
362443
]);
363444

364445
// Non-content gate decisions (incl. SHADOW would-actions) — recorded as `gate_decision` audit rows with
@@ -396,6 +477,7 @@ export async function computeStats(
396477
recommendations,
397478
gateParity: { ...parity, cutoverReady: parity.rows.map((r) => ({ project: r.project, ready: isParityCutoverReady(r) })) },
398479
cycleTime,
480+
findingAcceptance,
399481
};
400482
}
401483

test/unit/stats.test.ts

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ import { afterEach, describe, expect, it, vi } from "vitest";
22
import { createTestEnv } from "../helpers/d1";
33
import {
44
aggregateCycleTimePercentiles,
5+
aggregateFindingAcceptance,
56
aggregateReviewEffort,
67
buildCycleTimeDistribution,
8+
computeFindingAcceptance,
79
computeStats,
810
cycleTimeMs,
911
EMPTY_CYCLE_TIME,
12+
EMPTY_FINDING_ACCEPTANCE,
1013
handleParity,
1114
handleStats,
1215
isParityCutoverReady,
@@ -37,6 +40,8 @@ function stubEnv(extra: Record<string, unknown> = {}): Env {
3740
{ decided_at: "2026-06-01T10:00:00Z", outcome_at: "2026-06-01T10:05:00Z" },
3841
{ decided_at: "2026-06-01T11:00:00Z", outcome_at: "2026-06-01T11:30:00Z" },
3942
];
43+
// flagged-PR (hold|close) realized outcomes → 2 merged (addressed) + 1 closed (unaddressed).
44+
const acceptanceRows = [{ truth: "merged" }, { truth: "merged" }, { truth: "closed" }];
4045
let lastSql = "";
4146
return {
4247
...extra,
@@ -47,16 +52,18 @@ function stubEnv(extra: Record<string, unknown> = {}): Env {
4752
bind: () => ({
4853
all: async () => ({
4954
// review-effort read → effortMinutes; cycle-time pairs → cyclePairs; gate action counts → gateActions;
50-
// other review_audit → reversals; everything else → decision rows.
55+
// finding-acceptance read → acceptanceRows; other review_audit → reversals; everything else → decisions.
5156
results: lastSql.includes("reviewEffortMinutes")
5257
? effortMinutes
5358
: lastSql.includes("decided_at") && lastSql.includes("outcome_at")
5459
? cyclePairs
5560
: lastSql.includes("decision AS action")
5661
? gateActions
57-
: lastSql.includes("review_audit")
58-
? reversals
59-
: decisions,
62+
: lastSql.includes("flagged")
63+
? acceptanceRows
64+
: lastSql.includes("review_audit")
65+
? reversals
66+
: decisions,
6067
}),
6168
}),
6269
};
@@ -86,6 +93,7 @@ describe("computeStats — D1 aggregate for the dashboard", () => {
8693
expect(out.cycleTime.sampleSize).toBe(2);
8794
expect(out.cycleTime.p50Ms).toBe(300_000);
8895
expect(out.cycleTime.distribution.length).toBeGreaterThan(0);
96+
expect(out.findingAcceptance).toEqual({ flagged: 3, addressed: 2, unaddressed: 1, acceptanceRate: 0.667 });
8997
});
9098

9199
it("clamps an absurd window and falls back to a safe bucket", async () => {
@@ -423,6 +431,7 @@ describe("computeStats — NaN window + null D1 results (the ?? [] fallbacks)",
423431
expect(out.verdicts).toEqual([]);
424432
expect(out.reviewEffort).toEqual({ avgBand: null, totalEstimatedMinutes: 0 });
425433
expect(out.cycleTime).toEqual(EMPTY_CYCLE_TIME);
434+
expect(out.findingAcceptance).toEqual(EMPTY_FINDING_ACCEPTANCE);
426435
});
427436
});
428437

@@ -561,6 +570,102 @@ describe("cycle-time aggregation (#2194)", () => {
561570
});
562571
});
563572

573+
describe("finding acceptance rate (#1967)", () => {
574+
it("aggregateFindingAcceptance folds flagged-PR outcomes into the acceptance rate", () => {
575+
const agg = aggregateFindingAcceptance([{ merged: true }, { merged: true }, { merged: false }]);
576+
expect(agg).toEqual({ flagged: 3, addressed: 2, unaddressed: 1, acceptanceRate: 0.667 });
577+
});
578+
579+
it("aggregateFindingAcceptance reports 1 / 0 acceptance at the extremes (both filter branches)", () => {
580+
expect(aggregateFindingAcceptance([{ merged: true }, { merged: true }])).toEqual({
581+
flagged: 2,
582+
addressed: 2,
583+
unaddressed: 0,
584+
acceptanceRate: 1,
585+
});
586+
expect(aggregateFindingAcceptance([{ merged: false }, { merged: false }])).toEqual({
587+
flagged: 2,
588+
addressed: 0,
589+
unaddressed: 2,
590+
acceptanceRate: 0,
591+
});
592+
});
593+
594+
it("aggregateFindingAcceptance returns EMPTY_FINDING_ACCEPTANCE for no samples", () => {
595+
expect(aggregateFindingAcceptance([])).toEqual(EMPTY_FINDING_ACCEPTANCE);
596+
});
597+
598+
it("computeFindingAcceptance joins EVER-flagged (hold|close) PRs to their latest outcome from real D1", async () => {
599+
const env = createTestEnv();
600+
await env.DB.prepare(
601+
`INSERT INTO review_audit (id, project, target_id, event_type, decision, source, created_at) VALUES
602+
('gd1', 'owner/repo', 'owner/repo#1', 'gate_decision', 'close', 'test', '2026-06-10T10:00:00Z'),
603+
('po1', 'owner/repo', 'owner/repo#1', 'pr_outcome', 'merged', 'test', '2026-06-10T12:00:00Z'),
604+
('gd2', 'owner/repo', 'owner/repo#2', 'gate_decision', 'hold', 'test', '2026-06-11T10:00:00Z'),
605+
('po2', 'owner/repo', 'owner/repo#2', 'pr_outcome', 'closed', 'test', '2026-06-11T12:00:00Z'),
606+
('gd3a', 'owner/repo', 'owner/repo#3', 'gate_decision', 'hold', 'test', '2026-06-12T09:00:00Z'),
607+
('gd3b', 'owner/repo', 'owner/repo#3', 'gate_decision', 'hold', 'test', '2026-06-12T10:00:00Z'),
608+
('po3a', 'owner/repo', 'owner/repo#3', 'pr_outcome', 'closed', 'test', '2026-06-12T11:00:00Z'),
609+
('po3b', 'owner/repo', 'owner/repo#3', 'pr_outcome', 'merged', 'test', '2026-06-12T12:00:00Z'),
610+
('gd4', 'owner/repo', 'owner/repo#4', 'gate_decision', 'merge', 'test', '2026-06-13T10:00:00Z'),
611+
('po4', 'owner/repo', 'owner/repo#4', 'pr_outcome', 'merged', 'test', '2026-06-13T12:00:00Z'),
612+
('gd5', 'owner/repo', 'owner/repo#5', 'gate_decision', 'close', 'test', '2026-06-13T10:00:00Z')`,
613+
).run();
614+
const agg = await computeFindingAcceptance(env, { days: 90, nowMs: NOW });
615+
// #1 close→merged (addressed) and #3 hold→(closed then MERGED, rn=1 latest) (addressed); #2 hold→closed
616+
// (unaddressed); #4 merge→merged is a CLEAN merge (not flagged); #5 close has no outcome yet (not counted).
617+
expect(agg).toEqual({ flagged: 3, addressed: 2, unaddressed: 1, acceptanceRate: 0.667 });
618+
});
619+
620+
it("computeFindingAcceptance fails safe to EMPTY_FINDING_ACCEPTANCE when the query rejects", async () => {
621+
const env = {
622+
DB: {
623+
prepare: () => ({
624+
bind: () => ({
625+
all: async () => {
626+
throw new Error("d1 down");
627+
},
628+
}),
629+
}),
630+
},
631+
} as unknown as Env;
632+
expect(await computeFindingAcceptance(env, { days: 30, nowMs: NOW })).toEqual(EMPTY_FINDING_ACCEPTANCE);
633+
});
634+
635+
it("computeFindingAcceptance tolerates missing D1 results (the ?? [] fallback)", async () => {
636+
const env = {
637+
DB: {
638+
prepare: () => ({
639+
bind: () => ({
640+
all: async () => ({ results: undefined }),
641+
}),
642+
}),
643+
},
644+
} as unknown as Env;
645+
expect(await computeFindingAcceptance(env, { days: 30, nowMs: NOW })).toEqual(EMPTY_FINDING_ACCEPTANCE);
646+
});
647+
648+
it("computeFindingAcceptance defaults a non-finite / non-positive window to 90 days and clamps to 730", async () => {
649+
let boundFrom: string | undefined;
650+
const env = {
651+
DB: {
652+
prepare: () => ({
653+
bind: (fromIso: string) => {
654+
boundFrom = fromIso;
655+
return { all: async () => ({ results: [] as Array<{ truth: string }> }) };
656+
},
657+
}),
658+
},
659+
} as unknown as Env;
660+
await computeFindingAcceptance(env, { days: Number.NaN, nowMs: NOW });
661+
expect(boundFrom).toBe(new Date(NOW - 90 * 86_400_000).toISOString().slice(0, 10));
662+
await computeFindingAcceptance(env, { days: 0, nowMs: NOW });
663+
expect(boundFrom).toBe(new Date(NOW - 90 * 86_400_000).toISOString().slice(0, 10));
664+
await computeFindingAcceptance(env, { days: 99_999, nowMs: NOW });
665+
expect(boundFrom).toBe(new Date(NOW - 730 * 86_400_000).toISOString().slice(0, 10));
666+
});
667+
});
668+
564669
describe("isParityCutoverReady — every gate condition", () => {
565670
const base: GateParityRow = {
566671
project: "p",

0 commit comments

Comments
 (0)