Skip to content

Commit 23cb210

Browse files
feat(flaky-history-analysis): enhance flaky test detection with state management
1 parent 91faa15 commit 23cb210

3 files changed

Lines changed: 301 additions & 35 deletions

File tree

.github/scripts/flaky-history-analysis.ts

Lines changed: 194 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,32 @@
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
*/
1533
import * as core from '@actions/core';
1634
import { getOctokit } from '@actions/github';
@@ -20,6 +38,12 @@ import { dirname, join } from 'path';
2038

2139
type 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+
2347
const 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.
5781
const WORKSPACE_ROOT = process.env.GITHUB_WORKSPACE ?? process.cwd();
5882
const 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

60100
interface 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+
120265
function 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+
290448
async 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

Comments
 (0)