33 *
44 * Identifies Jest unit test files modified in the PR and counts how often each
55 * 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).
6+ * bucketed into 7d/30d windows. Writes a machine-readable JSON artifact
7+ * 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.
1111 *
1212 * Failure modes are always downgraded to warnings + empty results so the
1313 * workflow stays informational and never blocks a PR.
14+ *
15+ * Design note — why we rely on history + AI patterns only, and deliberately do
16+ * NOT execute the PR's changed tests or inspect the PR's own ci.yml unit-test
17+ * run to decide flakiness:
18+ *
19+ * - New test with a flaky pattern: a single PR unit-test run almost never
20+ * reproduces the flake, so a run-based signal would usually miss it. AI
21+ * pattern detection on the diff is what actually catches this case.
22+ * - Existing test changed by the PR: AI detects the pattern AND the run
23+ * history on main provides an independent signal, so both fire.
24+ * - A modified test with an AI-detected pattern but zero historical failures
25+ * is still worth flagging: passing so far may just be luck or ordering, and
26+ * it can start failing under a different test order or in edge cases.
27+ *
28+ * Flagging on pattern rather than on an observed failure is intentional — the
29+ * output is informational and exists to prompt the author to review, not to
30+ * assert the test has already failed. Running or waiting on the PR's tests
31+ * would add cost and latency without meaningfully improving these outcomes.
1432 */
1533import * as core from '@actions/core' ;
1634import { getOctokit } from '@actions/github' ;
@@ -20,6 +38,12 @@ import { dirname, join } from 'path';
2038
2139type Octokit = ReturnType < typeof getOctokit > ;
2240
41+ // Prefix of the hidden state block embedded in the sticky comment body.
42+ // Stage 1 reads this to determine which files need re-analysis.
43+ const STATE_MARKER = '<!-- metamask-flaky-test-detection:state' ;
44+ // Marker used by Stage 3 to identify the sticky comment (must stay in sync).
45+ const COMMENT_MARKER = '<!-- metamask-flaky-test-detection -->' ;
46+
2347const WORKFLOW = 'ci.yml' ;
2448// JOB_NAME is written into the output artifact as metadata only — it is not
2549// used to filter the sampled runs. Failure-file intersection is done via FAIL
@@ -56,6 +80,22 @@ const FLAKY_THRESHOLD_PERCENT = 20;
5680// the script can also be run locally from any directory.
5781const WORKSPACE_ROOT = process . env . GITHUB_WORKSPACE ?? process . cwd ( ) ;
5882const OUTPUT_PATH = join ( WORKSPACE_ROOT , '.ai-pr-analyzer/flaky-history.json' ) ;
83+ // Prior per-file state written here for Stage 3 to merge with fresh findings.
84+ const PRIOR_STATE_PATH = join ( WORKSPACE_ROOT , '.ai-pr-analyzer/flaky-prior-state.json' ) ;
85+
86+ // Per-file state persisted inside the sticky comment body. The findings field
87+ // is typed as unknown[] here because Stage 1 only passes it through — Stage 3
88+ // owns the Finding type and casts accordingly.
89+ interface PerFileState {
90+ analyzedSha : string ;
91+ findings : unknown [ ] ;
92+ }
93+
94+ interface CommentState {
95+ version : number ;
96+ windows : number [ ] ;
97+ files : Record < string , PerFileState > ;
98+ }
5999
60100interface WorkflowRun {
61101 id : number ;
@@ -81,6 +121,12 @@ interface HistoryResult {
81121 // countable runs happened in each window, independent of the file).
82122 runsSampled : WindowCounts ;
83123 threshold : number ;
124+ // Files re-analyzed in this run (subset of files). Stage 3 uses this to
125+ // decide which entries get fresh AI findings vs. prior findings.
126+ analyzedFiles : string [ ] ;
127+ // HEAD SHA at which this analysis was run. Embedded per-file so Stage 3
128+ // can record when each file was last analyzed.
129+ headSha : string ;
84130 files : HistoryFile [ ] ;
85131}
86132
@@ -102,6 +148,8 @@ const env = {
102148 repo : process . env . GITHUB_REPOSITORY ?? '' ,
103149 serverUrl : process . env . GITHUB_SERVER_URL ?? 'https://github.com' ,
104150 token : process . env . GH_TOKEN ?? process . env . GITHUB_TOKEN ?? '' ,
151+ prNumber : Number ( process . env . PR_NUMBER ?? '0' ) ,
152+ headSha : process . env . HEAD_SHA ?? '' ,
105153} ;
106154
107155// The diff filter is TS-only because repo policy requires all new code to be
@@ -117,6 +165,103 @@ function sh(cmd: string, args: string[]): string {
117165 return execFileSync ( cmd , args , { encoding : 'utf8' } ) . trim ( ) ;
118166}
119167
168+ // Extracts the per-file state JSON block embedded in a sticky comment body.
169+ function parseStateFromComment ( body : string ) : CommentState | null {
170+ const idx = body . indexOf ( STATE_MARKER ) ;
171+ if ( idx === - 1 ) return null ;
172+ const after = body . slice ( idx + STATE_MARKER . length ) . trimStart ( ) ;
173+ const closeIdx = after . indexOf ( ' -->' ) ;
174+ if ( closeIdx === - 1 ) return null ;
175+ try {
176+ return JSON . parse ( after . slice ( 0 , closeIdx ) ) as CommentState ;
177+ } catch {
178+ return null ;
179+ }
180+ }
181+
182+ // Fetches the existing sticky comment and parses its embedded per-file state.
183+ // Returns null when there is no comment yet or the state block is missing/invalid
184+ // (e.g. comments created before this feature shipped).
185+ async function fetchPriorState (
186+ octokit : Octokit ,
187+ owner : string ,
188+ repo : string ,
189+ ) : Promise < CommentState | null > {
190+ if ( ! env . prNumber ) return null ;
191+ try {
192+ const comments = await octokit . paginate ( octokit . rest . issues . listComments , {
193+ owner,
194+ repo,
195+ issue_number : env . prNumber ,
196+ per_page : 100 ,
197+ } ) ;
198+ const sticky = comments . find ( ( c ) => c . body ?. startsWith ( COMMENT_MARKER ) ) ;
199+ if ( ! sticky ?. body ) return null ;
200+ return parseStateFromComment ( sticky . body ) ;
201+ } catch ( error ) {
202+ core . warning ( `fetchPriorState failed: ${ ( error as Error ) . message } ` ) ;
203+ return null ;
204+ }
205+ }
206+
207+ // Returns the subset of modifiedFiles that changed since their last-analyzed SHA.
208+ // Files with no prior state always need analysis. Files whose prior SHA is no
209+ // longer reachable (force-push/rebase) are also treated as changed.
210+ function computeNeedsAnalysis (
211+ modifiedFiles : string [ ] ,
212+ priorState : CommentState | null ,
213+ ) : string [ ] {
214+ if ( ! priorState ) return [ ...modifiedFiles ] ;
215+
216+ // Group by prior analyzedSha to batch git diff calls per unique SHA.
217+ const byAnalyzedSha = new Map < string , string [ ] > ( ) ;
218+ const nopriorFiles : string [ ] = [ ] ;
219+
220+ for ( const file of modifiedFiles ) {
221+ const prior = priorState . files [ file ] ;
222+ if ( ! prior ?. analyzedSha ) {
223+ nopriorFiles . push ( file ) ;
224+ } else {
225+ const list = byAnalyzedSha . get ( prior . analyzedSha ) ?? [ ] ;
226+ list . push ( file ) ;
227+ byAnalyzedSha . set ( prior . analyzedSha , list ) ;
228+ }
229+ }
230+
231+ const changedFiles = new Set < string > ( nopriorFiles ) ;
232+
233+ for ( const [ analyzedSha , files ] of byAnalyzedSha ) {
234+ let changedInDiff : Set < string > ;
235+ try {
236+ const diffOutput = sh ( 'git' , [
237+ 'diff' ,
238+ '--name-only' ,
239+ analyzedSha ,
240+ env . headSha || 'HEAD' ,
241+ ] ) ;
242+ changedInDiff = new Set (
243+ diffOutput
244+ . split ( '\n' )
245+ . map ( ( f ) => f . trim ( ) )
246+ . filter ( Boolean ) ,
247+ ) ;
248+ } catch ( error ) {
249+ // Force-push may have orphaned the SHA; treat all files in this group as
250+ // changed so we re-analyze rather than silently using stale findings.
251+ core . warning (
252+ `git diff ${ analyzedSha } ..HEAD failed — re-analyzing group: ${ ( error as Error ) . message } ` ,
253+ ) ;
254+ files . forEach ( ( f ) => changedFiles . add ( f ) ) ;
255+ continue ;
256+ }
257+ for ( const file of files ) {
258+ if ( changedInDiff . has ( file ) ) changedFiles . add ( file ) ;
259+ }
260+ }
261+
262+ return modifiedFiles . filter ( ( f ) => changedFiles . has ( f ) ) ;
263+ }
264+
120265function getModifiedUnitTestFiles ( ) : string [ ] {
121266 let diffOutput : string ;
122267 try {
@@ -270,7 +415,12 @@ async function buildHistory(
270415 return { files, runsSampled } ;
271416}
272417
273- function writeHistoryFile ( files : HistoryFile [ ] , runsSampled : WindowCounts ) : HistoryResult {
418+ function writeHistoryFile (
419+ files : HistoryFile [ ] ,
420+ runsSampled : WindowCounts ,
421+ analyzedFiles : string [ ] ,
422+ headSha : string ,
423+ ) : HistoryResult {
274424 mkdirSync ( dirname ( OUTPUT_PATH ) , { recursive : true } ) ;
275425 const result : HistoryResult = {
276426 generatedAt : new Date ( ) . toISOString ( ) ,
@@ -281,12 +431,20 @@ function writeHistoryFile(files: HistoryFile[], runsSampled: WindowCounts): Hist
281431 windows : [ ...WINDOWS_DAYS ] ,
282432 runsSampled,
283433 threshold : FLAKY_THRESHOLD_PERCENT ,
434+ analyzedFiles,
435+ headSha,
284436 files,
285437 } ;
286438 writeFileSync ( OUTPUT_PATH , JSON . stringify ( result , null , 2 ) ) ;
287439 return result ;
288440}
289441
442+ function writePriorStateFile ( state : CommentState | null ) : void {
443+ mkdirSync ( dirname ( PRIOR_STATE_PATH ) , { recursive : true } ) ;
444+ const empty : CommentState = { version : 1 , windows : [ ...WINDOWS_DAYS ] , files : { } } ;
445+ writeFileSync ( PRIOR_STATE_PATH , JSON . stringify ( state ?? empty , null , 2 ) ) ;
446+ }
447+
290448async function main ( ) : Promise < void > {
291449 const modifiedFiles = getModifiedUnitTestFiles ( ) ;
292450 console . log (
@@ -302,13 +460,17 @@ async function main(): Promise<void> {
302460 core . setOutput ( 'files' , modifiedFiles . join ( ' ' ) ) ;
303461
304462 if ( modifiedFiles . length === 0 ) {
305- writeHistoryFile ( [ ] , emptyWindowCounts ( ) ) ;
463+ writeHistoryFile ( [ ] , emptyWindowCounts ( ) , [ ] , env . headSha ) ;
464+ writePriorStateFile ( null ) ;
306465 console . log ( '💡 No modified unit test files — skipping history sampling' ) ;
307466 return ;
308467 }
309468
310469 if ( ! env . token ) {
311470 core . warning ( 'No GitHub token — skipping history sampling' ) ;
471+ // Cannot fetch prior state without a token; re-analyze everything.
472+ core . setOutput ( 'should_analyze' , 'true' ) ;
473+ core . setOutput ( 'files_to_analyze' , modifiedFiles . join ( ' ' ) ) ;
312474 writeHistoryFile (
313475 modifiedFiles . map ( ( path ) => ( {
314476 path,
@@ -317,20 +479,47 @@ async function main(): Promise<void> {
317479 runHistoryUrl : `${ env . serverUrl } /${ env . repo } /actions/workflows/${ WORKFLOW } ?query=branch%3Amain` ,
318480 } ) ) ,
319481 emptyWindowCounts ( ) ,
482+ modifiedFiles ,
483+ env . headSha ,
320484 ) ;
485+ writePriorStateFile ( null ) ;
321486 return ;
322487 }
323488
324489 const [ owner , repo ] = env . repo . split ( '/' ) ;
325490 const octokit = getOctokit ( env . token ) ;
491+
492+ // Fetch the prior per-file state embedded in the existing sticky comment.
493+ // This determines which modified test files actually changed since they were
494+ // last analyzed — only those files trigger a re-run of history sampling and
495+ // AI analysis, leaving the comment untouched for unrelated pushes.
496+ const priorState = await fetchPriorState ( octokit , owner , repo ) ;
497+ const needsAnalysis = computeNeedsAnalysis ( modifiedFiles , priorState ) ;
498+
499+ if ( needsAnalysis . length === 0 && priorState !== null ) {
500+ // No modified test file changed since it was last analyzed.
501+ // Skip all expensive work — history sampling, AI, and comment update —
502+ // so an unrelated push leaves the existing sticky comment exactly as-is.
503+ console . log ( '⏭️ No modified test files changed since last analysis — skipping' ) ;
504+ core . setOutput ( 'should_analyze' , 'false' ) ;
505+ core . setOutput ( 'files_to_analyze' , '' ) ;
506+ return ;
507+ }
508+
509+ core . setOutput ( 'should_analyze' , 'true' ) ;
510+ core . setOutput ( 'files_to_analyze' , needsAnalysis . join ( ' ' ) ) ;
511+
512+ // Persist prior state for Stage 3 to merge findings for untouched files.
513+ writePriorStateFile ( priorState ) ;
514+
326515 const runs = await getCompletedRunsInLookback ( octokit ) ;
327516 const { files, runsSampled } = await buildHistory ( octokit , owner , repo , modifiedFiles , runs ) ;
328517 console . log (
329518 `🔍 Sampled ${ runs . length } completed ci.yml run(s) on main over the last ${ LOOKBACK_DAYS } d ` +
330519 `(${ runsSampled [ '30d' ] } failure/success within window)` ,
331520 ) ;
332521
333- const result = writeHistoryFile ( files , runsSampled ) ;
522+ const result = writeHistoryFile ( files , runsSampled , needsAnalysis , env . headSha ) ;
334523
335524 const flakyCount = files . filter ( ( f ) => f . flaky ) . length ;
336525 console . log (
0 commit comments