Skip to content

Commit 0dbeaa5

Browse files
feat(flaky-unit-test-analysis): enhance historical failure analysis for Jest tests
- Updated the flaky-history-analysis script to count failures of modified Jest test files over the last 365 days, bucketed into 7d, 30d, 90d, and 365d windows. - Adjusted the output structure to include failure counts per window and the number of runs sampled for each window. - Enhanced the sticky comment generation to reflect the new windowed failure data, improving the representation of flaky test information. - Updated the workflow documentation to reflect changes in the analysis process and the new failure counting mechanism.
1 parent bed12d3 commit 0dbeaa5

3 files changed

Lines changed: 167 additions & 75 deletions

File tree

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

Lines changed: 117 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
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.
2626
const 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.
3451
const 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');
4158
interface WorkflowRun {
4259
id: number;
4360
conclusion: string | null;
61+
createdAt: string;
4462
}
4563

4664
interface 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+
6598
const 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

Comments
 (0)