@@ -2,11 +2,14 @@ import { afterEach, describe, expect, it, vi } from "vitest";
22import { createTestEnv } from "../helpers/d1" ;
33import {
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+
564669describe ( "isParityCutoverReady — every gate condition" , ( ) => {
565670 const base : GateParityRow = {
566671 project : "p" ,
0 commit comments