11/**
22 * Stage 1 — Deterministic historical analysis (MCWP-474).
33 *
4- * Identifies Jest unit test files modified in the PR and checks their
5- * historical failure rate over the last RUNS_TO_SAMPLE completed ci.yml
6- * runs on main . Writes a machine-readable JSON artifact consumed by Stage 2
7- * (AI analyzer) and Stage 3 (sticky PR comment).
4+ * Identifies Jest unit test files modified in the PR and counts how often each
5+ * one failed on main over the last LOOKBACK_DAYS of completed ci.yml runs,
6+ * bucketed into 7d/30d/90d/365d windows . Writes a machine-readable JSON
7+ * artifact consumed by Stage 2 (AI analyzer) and Stage 3 (sticky PR comment).
88 *
99 * Historical failure is a HINT, not proof — a file can be flagged here with
1010 * zero AI findings, or have AI findings with no historical signal.
@@ -24,13 +24,30 @@ const WORKFLOW = 'ci.yml';
2424// lines in the raw job logs, which covers all unit-test shards regardless of
2525// the exact job name.
2626const JOB_NAME = 'Unit tests' ;
27- // 10 runs is enough to compute a meaningful rate at the 20% threshold (2/10)
28- // while keeping the total `gh run view --log-failed` cost bounded (~10 API
29- // calls per PR). Bumping this raises API cost linearly.
30- const RUNS_TO_SAMPLE = 10 ;
31- // 20% comes from the Jira acceptance criteria. Rounded down to the nearest
32- // integer failure count against RUNS_TO_SAMPLE, so at 10 runs this means
33- // "flagged when >= 2 out of 10 recent runs on main failed for this file."
27+ // How far back the historical window extends. Failures are bucketed into the
28+ // nested windows below, so a failure 5 days ago counts in all four windows and
29+ // one 200 days ago counts only in the 365d bucket.
30+ const LOOKBACK_DAYS = 365 ;
31+ const WINDOWS_DAYS = [ 7 , 30 , 90 , 365 ] as const ;
32+ type WindowKey = `${( typeof WINDOWS_DAYS ) [ number ] } d`;
33+ const WINDOW_KEYS = WINDOWS_DAYS . map ( ( d ) => `${ d } d` as WindowKey ) ;
34+ type WindowCounts = Record < WindowKey , number > ;
35+ // Listing runs is cheap (metadata only, 100 per page); the created>= filter is
36+ // the real bound. This cap is just a safety valve against an unbounded page
37+ // walk on an extremely busy repo.
38+ const MAX_RUNS_LISTED = 3000 ;
39+ // Downloading a run's failed-step log is the expensive part (one
40+ // `gh run view --log-failed` per failed run). Bound the total and process
41+ // failed runs newest-first so the short windows (7d/30d) stay accurate even if
42+ // an unusually red year exceeds the cap — only the oldest 365d tail is then
43+ // undercounted.
44+ const MAX_FAILED_LOG_FETCHES = 200 ;
45+ // A window needs at least this many countable runs before its failure rate is
46+ // trusted for the flaky flag, so a single early failure in a nearly-empty
47+ // window cannot flag the file on its own.
48+ const MIN_RUNS_FOR_RATE = 5 ;
49+ // 20% comes from the Jira acceptance criteria: a file is flagged flaky when its
50+ // failure rate reaches this threshold in any window with enough sampled runs.
3451const FLAKY_THRESHOLD_PERCENT = 20 ;
3552
3653// GITHUB_WORKSPACE is always set in Actions; fall back to process.cwd() so
@@ -41,13 +58,12 @@ const OUTPUT_PATH = join(WORKSPACE_ROOT, '.ai-pr-analyzer/flaky-history.json');
4158interface WorkflowRun {
4259 id : number ;
4360 conclusion : string | null ;
61+ createdAt : string ;
4462}
4563
4664interface HistoryFile {
4765 path : string ;
48- failures : number ;
49- runsSampled : number ;
50- failureRatePercent : number ;
66+ failures : WindowCounts ;
5167 flaky : boolean ;
5268 runHistoryUrl : string ;
5369}
@@ -57,11 +73,28 @@ interface HistoryResult {
5773 workflow : string ;
5874 job : string ;
5975 branch : string ;
60- runsSampled : number ;
76+ lookbackDays : number ;
77+ windows : number [ ] ;
78+ // Denominator per window (identical across files: it only counts how many
79+ // countable runs happened in each window, independent of the file).
80+ runsSampled : WindowCounts ;
6181 threshold : number ;
6282 files : HistoryFile [ ] ;
6383}
6484
85+ function emptyWindowCounts ( ) : WindowCounts {
86+ return WINDOW_KEYS . reduce ( ( acc , key ) => {
87+ acc [ key ] = 0 ;
88+ return acc ;
89+ } , { } as WindowCounts ) ;
90+ }
91+
92+ // The windows a run of the given age (in days) contributes to — every window
93+ // at least as wide as the run's age.
94+ function windowKeysForAge ( ageDays : number ) : WindowKey [ ] {
95+ return WINDOWS_DAYS . filter ( ( d ) => ageDays <= d ) . map ( ( d ) => `${ d } d` as WindowKey ) ;
96+ }
97+
6598const env = {
6699 baseRef : process . env . BASE_REF ?? 'main' ,
67100 repo : process . env . GITHUB_REPOSITORY ?? '' ,
@@ -99,24 +132,36 @@ function getModifiedUnitTestFiles(): string[] {
99132 . filter ( ( f ) => ! EXCLUDE_PATTERNS . some ( ( pattern ) => pattern . test ( f ) ) ) ;
100133}
101134
102- async function getRecentCompletedRuns (
135+ async function getCompletedRunsInLookback (
103136 octokit : ReturnType < typeof getOctokit > ,
104137) : Promise < WorkflowRun [ ] > {
105138 const [ owner , repo ] = env . repo . split ( '/' ) ;
139+ const since = new Date ( Date . now ( ) - LOOKBACK_DAYS * 24 * 60 * 60 * 1000 )
140+ . toISOString ( )
141+ . slice ( 0 , 10 ) ;
142+ const runs : WorkflowRun [ ] = [ ] ;
106143 try {
107- const { data } = await octokit . rest . actions . listWorkflowRuns ( {
144+ // paginate.iterator walks the Link header page-by-page so we can stop at
145+ // MAX_RUNS_LISTED instead of buffering the whole (potentially huge) year.
146+ const iterator = octokit . paginate . iterator ( octokit . rest . actions . listWorkflowRuns , {
108147 owner,
109148 repo,
110149 workflow_id : WORKFLOW ,
111150 branch : 'main' ,
112151 status : 'completed' ,
113- per_page : RUNS_TO_SAMPLE ,
152+ created : `>=${ since } ` ,
153+ per_page : 100 ,
114154 } ) ;
115- return data . workflow_runs . map ( ( r ) => ( { id : r . id , conclusion : r . conclusion } ) ) ;
155+ for await ( const { data } of iterator ) {
156+ for ( const r of data ) {
157+ runs . push ( { id : r . id , conclusion : r . conclusion , createdAt : r . created_at } ) ;
158+ if ( runs . length >= MAX_RUNS_LISTED ) return runs ;
159+ }
160+ }
116161 } catch ( error ) {
117162 core . warning ( `listWorkflowRuns failed: ${ ( error as Error ) . message } ` ) ;
118- return [ ] ;
119163 }
164+ return runs ;
120165}
121166
122167// Extracts every `FAIL <path>` line Jest emits at the top of a failed test
@@ -140,49 +185,70 @@ function getFailedTestFilesForRun(runId: number): string[] {
140185}
141186
142187// Intersects failed-file names from the sampled runs with the PR's modified
143- // files. The denominator is failure+success runs only — cancelled/timed_out
144- // runs don't tell us whether the test suite passed, so counting them inflates
145- // the sample size and deflates the failure rate.
146- function buildHistory ( modifiedFiles : string [ ] , runs : WorkflowRun [ ] ) : HistoryFile [ ] {
188+ // files, bucketing each failure into every window it falls in. The denominator
189+ // is failure+success runs only — cancelled/timed_out runs don't tell us whether
190+ // the suite passed, so counting them inflates the sample and deflates the rate.
191+ function buildHistory (
192+ modifiedFiles : string [ ] ,
193+ runs : WorkflowRun [ ] ,
194+ ) : { files : HistoryFile [ ] ; runsSampled : WindowCounts } {
195+ const now = Date . now ( ) ;
196+ const ageInDays = ( createdAt : string ) =>
197+ ( now - new Date ( createdAt ) . getTime ( ) ) / ( 24 * 60 * 60 * 1000 ) ;
198+
147199 const countableRuns = runs . filter (
148200 ( r ) => r . conclusion === 'failure' || r . conclusion === 'success' ,
149201 ) ;
150- const failedRuns = countableRuns . filter ( ( r ) => r . conclusion === 'failure' ) ;
151- const failureCounts = new Map < string , number > ( ) ;
152202
203+ const runsSampled = emptyWindowCounts ( ) ;
204+ for ( const run of countableRuns ) {
205+ for ( const key of windowKeysForAge ( ageInDays ( run . createdAt ) ) ) runsSampled [ key ] ++ ;
206+ }
207+
208+ // Newest-first so the failed-log budget is spent on recent runs, keeping the
209+ // short windows accurate if the cap is ever hit.
210+ const failedRuns = countableRuns
211+ . filter ( ( r ) => r . conclusion === 'failure' )
212+ . sort ( ( a , b ) => new Date ( b . createdAt ) . getTime ( ) - new Date ( a . createdAt ) . getTime ( ) )
213+ . slice ( 0 , MAX_FAILED_LOG_FETCHES ) ;
214+
215+ const failuresByFile = new Map < string , WindowCounts > ( ) ;
153216 for ( const run of failedRuns ) {
154- const failedFiles = getFailedTestFilesForRun ( run . id ) ;
155- for ( const file of failedFiles ) {
217+ const keys = windowKeysForAge ( ageInDays ( run . createdAt ) ) ;
218+ for ( const file of getFailedTestFilesForRun ( run . id ) ) {
156219 if ( ! modifiedFiles . includes ( file ) ) continue ;
157- failureCounts . set ( file , ( failureCounts . get ( file ) ?? 0 ) + 1 ) ;
220+ if ( ! failuresByFile . has ( file ) ) failuresByFile . set ( file , emptyWindowCounts ( ) ) ;
221+ const counts = failuresByFile . get ( file ) as WindowCounts ;
222+ for ( const key of keys ) counts [ key ] ++ ;
158223 }
159224 }
160225
161226 const runHistoryUrl = `${ env . serverUrl } /${ env . repo } /actions/workflows/${ WORKFLOW } ?query=branch%3Amain` ;
162227
163- return modifiedFiles . map ( ( path ) => {
164- const failures = failureCounts . get ( path ) ?? 0 ;
165- const runsSampled = countableRuns . length ;
166- const failureRatePercent =
167- runsSampled > 0 ? Math . round ( ( failures / runsSampled ) * 100 ) : 0 ;
168- return {
169- path,
170- failures,
171- runsSampled,
172- failureRatePercent,
173- flaky : failureRatePercent >= FLAKY_THRESHOLD_PERCENT ,
174- runHistoryUrl,
175- } ;
228+ const files = modifiedFiles . map ( ( path ) => {
229+ const failures = failuresByFile . get ( path ) ?? emptyWindowCounts ( ) ;
230+ const flaky = WINDOW_KEYS . some ( ( key ) => {
231+ const denominator = runsSampled [ key ] ;
232+ return (
233+ denominator >= MIN_RUNS_FOR_RATE &&
234+ ( failures [ key ] / denominator ) * 100 >= FLAKY_THRESHOLD_PERCENT
235+ ) ;
236+ } ) ;
237+ return { path, failures, flaky, runHistoryUrl } ;
176238 } ) ;
239+
240+ return { files, runsSampled } ;
177241}
178242
179- function writeHistoryFile ( files : HistoryFile [ ] , runsSampled : number ) : HistoryResult {
243+ function writeHistoryFile ( files : HistoryFile [ ] , runsSampled : WindowCounts ) : HistoryResult {
180244 mkdirSync ( dirname ( OUTPUT_PATH ) , { recursive : true } ) ;
181245 const result : HistoryResult = {
182246 generatedAt : new Date ( ) . toISOString ( ) ,
183247 workflow : WORKFLOW ,
184248 job : JOB_NAME ,
185249 branch : 'main' ,
250+ lookbackDays : LOOKBACK_DAYS ,
251+ windows : [ ...WINDOWS_DAYS ] ,
186252 runsSampled,
187253 threshold : FLAKY_THRESHOLD_PERCENT ,
188254 files,
@@ -206,7 +272,7 @@ async function main(): Promise<void> {
206272 core . setOutput ( 'files' , modifiedFiles . join ( ' ' ) ) ;
207273
208274 if ( modifiedFiles . length === 0 ) {
209- writeHistoryFile ( [ ] , 0 ) ;
275+ writeHistoryFile ( [ ] , emptyWindowCounts ( ) ) ;
210276 console . log ( '💡 No modified unit test files — skipping history sampling' ) ;
211277 return ;
212278 }
@@ -216,28 +282,24 @@ async function main(): Promise<void> {
216282 writeHistoryFile (
217283 modifiedFiles . map ( ( path ) => ( {
218284 path,
219- failures : 0 ,
220- runsSampled : 0 ,
221- failureRatePercent : 0 ,
285+ failures : emptyWindowCounts ( ) ,
222286 flaky : false ,
223287 runHistoryUrl : `${ env . serverUrl } /${ env . repo } /actions/workflows/${ WORKFLOW } ?query=branch%3Amain` ,
224288 } ) ) ,
225- 0 ,
289+ emptyWindowCounts ( ) ,
226290 ) ;
227291 return ;
228292 }
229293
230294 const octokit = getOctokit ( env . token ) ;
231- const runs = await getRecentCompletedRuns ( octokit ) ;
232- const countableRuns = runs . filter (
233- ( r ) => r . conclusion === 'failure' || r . conclusion === 'success' ,
234- ) ;
295+ const runs = await getCompletedRunsInLookback ( octokit ) ;
296+ const { files, runsSampled } = buildHistory ( modifiedFiles , runs ) ;
235297 console . log (
236- `🔍 Sampled ${ runs . length } completed ci.yml run(s) on main (${ countableRuns . length } failure/success)` ,
298+ `🔍 Sampled ${ runs . length } completed ci.yml run(s) on main over the last ${ LOOKBACK_DAYS } d ` +
299+ `(${ runsSampled [ '365d' ] } failure/success within window)` ,
237300 ) ;
238301
239- const files = buildHistory ( modifiedFiles , runs ) ;
240- const result = writeHistoryFile ( files , countableRuns . length ) ;
302+ const result = writeHistoryFile ( files , runsSampled ) ;
241303
242304 const flakyCount = files . filter ( ( f ) => f . flaky ) . length ;
243305 console . log (
0 commit comments