Skip to content

Commit ad39c2b

Browse files
committed
Refactor test failure detector to group issues by test file
One issue per test file with a checklist of failing tests. Each test case gets its own comment with failure history. Signed-off-by: Hanxi Zhang <hanxizh@amazon.com>
1 parent 13fe8fc commit ad39c2b

1 file changed

Lines changed: 169 additions & 59 deletions

File tree

.github/workflows/test-failure-detector.yml

Lines changed: 169 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@ jobs:
106106
107107
fs.writeFileSync('job-urls.json', JSON.stringify(jobUrlMap, null, 2));
108108
109-
# Step 3: Parse and merge failures
110-
- name: Merge failures
109+
# Step 3: Group failures by test file
110+
- name: Group failures by file
111111
if: steps.download.outputs.found == 'true'
112112
id: merge
113113
run: |
@@ -120,46 +120,59 @@ jobs:
120120
with open("all-test-failures.json") as f:
121121
all_failures = json.load(f)
122122
123-
grouped = {}
123+
# Group by test file
124+
by_file = {}
124125
125126
for job_name, suites in all_failures.items():
126127
for suite_name, entries in suites.items():
127128
for entry in entries:
128-
name = f"{entry['test_name']} in {entry['test_file']}"
129+
test_file = entry["test_file"]
130+
test_name = entry["test_name"]
129131
130-
if name not in grouped:
131-
grouped[name] = {
132-
"name": name,
133-
"test_name": entry["test_name"],
134-
"test_file": entry["test_file"],
132+
if test_file not in by_file:
133+
by_file[test_file] = {}
134+
135+
if test_name not in by_file[test_file]:
136+
by_file[test_file][test_name] = {
137+
"test_name": test_name,
138+
"test_file": test_file,
135139
"error": entry.get("error", ""),
136140
"jobs": []
137141
}
138-
grouped[name]["jobs"].append({
142+
143+
by_file[test_file][test_name]["jobs"].append({
139144
"job": job_name,
140145
"suite": suite_name,
141146
"url": job_urls.get(job_name, "")
142147
})
143-
print(f"{name} in {job_name}/{suite_name}")
148+
print(f"{test_name} in {test_file} ({job_name}/{suite_name})")
149+
150+
# Convert to list format
151+
result = []
152+
for test_file, tests in by_file.items():
153+
result.append({
154+
"test_file": test_file,
155+
"tests": list(tests.values())
156+
})
144157
145-
unique_failures = list(grouped.values())
146-
print(f"\nTotal unique failures: {len(unique_failures)}")
158+
print(f"\nTotal files with failures: {len(result)}")
159+
print(f"Total unique test failures: {sum(len(f['tests']) for f in result)}")
147160
148161
with open("failures.json", "w") as f:
149-
json.dump(unique_failures, f, indent=2)
162+
json.dump(result, f, indent=2)
150163
151164
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
152-
f.write(f"has_failures={'true' if unique_failures else 'false'}\n")
165+
f.write(f"has_failures={'true' if result else 'false'}\n")
153166
EOF
154167
155-
# Step 4: Create or update issues
168+
# Step 4: Create or update issues (one per test file, one comment per test case)
156169
- name: Create or update issues
157170
if: steps.merge.outputs.has_failures == 'true'
158171
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
159172
with:
160173
script: |
161174
const fs = require('fs');
162-
const failures = JSON.parse(fs.readFileSync('failures.json', 'utf8'));
175+
const fileFailures = JSON.parse(fs.readFileSync('failures.json', 'utf8'));
163176
const label = 'test-failure';
164177
165178
// Ensure label exists
@@ -194,31 +207,59 @@ jobs:
194207
195208
const today = new Date().toISOString().split('T')[0];
196209
197-
for (const failure of failures) {
198-
const title = `[TEST-FAILURE] ${failure.test_name} in ${failure.test_file}`;
199-
const envList = failure.jobs.map(j => j.job);
200-
const ciLinks = failure.jobs.map(j => `- \`${j.job}\`: [CI link](${j.url})`).join('\n');
201-
const envLine = `**Environments:** ${envList.map(e => '`' + e + '`').join(', ')}`;
210+
for (const fileEntry of fileFailures) {
211+
const testFile = fileEntry.test_file;
212+
const tests = fileEntry.tests;
213+
const title = `[TEST-FAILURE] ${testFile}`;
214+
215+
// Collect all environments across all tests in this file
216+
const allEnvs = [...new Set(tests.flatMap(t => t.jobs.map(j => j.job)))];
217+
const envLine = `**Environments:** ${allEnvs.map(e => '`' + e + '`').join(', ')}`;
218+
219+
// Build checklist of failing tests
220+
const checklist = tests.map(t => `- [ ] ${t.test_name}`).join('\n');
202221
203222
const existing = existingIssues.find(i => i.title === title);
204223
205224
if (existing) {
206-
console.log(`Found existing issue #${existing.number} for ${failure.name}`);
225+
console.log(`Found existing issue #${existing.number} for ${testFile}`);
226+
227+
// Update checklist in issue body — add new test names
228+
const bodyLines = existing.body.split('\n');
229+
const existingTests = bodyLines
230+
.filter(l => l.match(/^- \[[ x]\] /))
231+
.map(l => l.replace(/^- \[[ x]\] /, ''));
232+
233+
const newTests = tests.map(t => t.test_name).filter(n => !existingTests.includes(n));
234+
if (newTests.length > 0) {
235+
console.log(` New failing tests: ${newTests.join(', ')}`);
236+
const newChecklistItems = newTests.map(n => `- [ ] ${n}`).join('\n');
237+
const updatedBody = existing.body.replace(
238+
/(---\n\*Auto-created)/,
239+
`${newChecklistItems}\n\n$1`
240+
);
241+
await github.rest.issues.update({
242+
owner: context.repo.owner,
243+
repo: context.repo.repo,
244+
issue_number: existing.number,
245+
body: updatedBody,
246+
});
247+
}
207248
249+
// Update environments
208250
const envMatch = existing.body.match(/\*\*Environments:\*\*\s*(.+)/);
209251
const envInner = envMatch ? envMatch[1].match(/`([^`]+)`/g) : null;
210-
const existingEnvs = envInner
211-
? envInner.map(e => e.replace(/`/g, ''))
212-
: [];
213-
214-
const newEnvs = envList.filter(e => !existingEnvs.includes(e));
215-
216-
if (newEnvs.length > 0) {
217-
console.log(` New environments: ${newEnvs.join(', ')}`);
218-
const allEnvs = [...existingEnvs, ...newEnvs];
219-
const newEnvLine = `**Environments:** ${allEnvs.map(e => '`' + e + '`').join(', ')}`;
220-
const updatedBody = existing.body.replace(/\*\*Environments:\*\*\s*.+/, newEnvLine);
221-
252+
const existingEnvs = envInner ? envInner.map(e => e.replace(/`/g, '')) : [];
253+
const brandNewEnvs = allEnvs.filter(e => !existingEnvs.includes(e));
254+
if (brandNewEnvs.length > 0) {
255+
const mergedEnvs = [...existingEnvs, ...brandNewEnvs];
256+
const newEnvLine = `**Environments:** ${mergedEnvs.map(e => '`' + e + '`').join(', ')}`;
257+
const currentBody = (await github.rest.issues.get({
258+
owner: context.repo.owner,
259+
repo: context.repo.repo,
260+
issue_number: existing.number,
261+
})).data.body;
262+
const updatedBody = currentBody.replace(/\*\*Environments:\*\*\s*.+/, newEnvLine);
222263
await github.rest.issues.update({
223264
owner: context.repo.owner,
224265
repo: context.repo.repo,
@@ -227,45 +268,114 @@ jobs:
227268
});
228269
}
229270
230-
await github.rest.issues.createComment({
231-
owner: context.repo.owner,
232-
repo: context.repo.repo,
233-
issue_number: existing.number,
234-
body: `Test failed again on ${today}.\n\n**Failed in:**\n${ciLinks}`,
235-
});
271+
// Get existing comments to find per-test comments
272+
const comments = await github.paginate(
273+
github.rest.issues.listComments,
274+
{
275+
owner: context.repo.owner,
276+
repo: context.repo.repo,
277+
issue_number: existing.number,
278+
},
279+
(response) => response.data
280+
);
281+
282+
// For each failing test, create or update a comment
283+
for (const test of tests) {
284+
const commentMarker = `<!-- test-failure: ${test.test_name} -->`;
285+
const ciLinks = test.jobs.map(j => ` - \`${j.job}\`: [CI link](${j.url})`).join('\n');
286+
const historyEntry = `- ${today}:\n${ciLinks}`;
287+
288+
const existingComment = comments.find(c => c.body.includes(commentMarker));
289+
290+
if (existingComment) {
291+
// Append to failure history
292+
const updatedComment = existingComment.body.replace(
293+
/(\n---\n\*Auto-tracked)/,
294+
`\n${historyEntry}$1`
295+
);
296+
await github.rest.issues.updateComment({
297+
owner: context.repo.owner,
298+
repo: context.repo.repo,
299+
comment_id: existingComment.id,
300+
body: updatedComment,
301+
});
302+
console.log(` Updated comment for test: ${test.test_name}`);
303+
} else {
304+
// Create new comment for this test
305+
await github.rest.issues.createComment({
306+
owner: context.repo.owner,
307+
repo: context.repo.repo,
308+
issue_number: existing.number,
309+
body: [
310+
commentMarker,
311+
`**Test:** \`${test.test_name}\``,
312+
``,
313+
`**Error:**`,
314+
'```',
315+
test.error || 'N/A',
316+
'```',
317+
``,
318+
`**Failure history:**`,
319+
historyEntry,
320+
``,
321+
`---`,
322+
`*Auto-tracked by Test Failure Detector*`,
323+
].join('\n'),
324+
});
325+
console.log(` Created comment for test: ${test.test_name}`);
326+
}
327+
}
236328
237329
} else {
238-
console.log(`Creating issue for ${failure.name}`);
239-
await github.rest.issues.create({
330+
// Create new issue for this test file
331+
console.log(`Creating issue for ${testFile}`);
332+
const issue = await github.rest.issues.create({
240333
owner: context.repo.owner,
241334
repo: context.repo.repo,
242335
title: title,
243336
labels: [label],
244337
body: [
245-
`**Summary**`,
246-
``,
247-
`\`${failure.test_name}\` in \`${failure.test_file}\` is failing in CI.`,
248-
``,
249-
`**Failing test(s)**`,
338+
`**Test file:** \`${testFile}\``,
250339
``,
251-
`- Test name: \`${failure.test_name}\``,
252-
`- Test file: \`${failure.test_file}\``,
253-
`- CI link(s):`,
254-
ciLinks,
255-
``,
256-
`**Error stack trace**`,
257-
``,
258-
'```',
259-
failure.error || 'N/A',
260-
'```',
340+
`**Failing tests:**`,
341+
checklist,
261342
``,
262343
envLine,
263344
``,
264345
`---`,
265346
`*Auto-created by Test Failure Detector*`,
266347
].join('\n'),
267348
});
349+
350+
// Create one comment per failing test
351+
for (const test of tests) {
352+
const commentMarker = `<!-- test-failure: ${test.test_name} -->`;
353+
const ciLinks = test.jobs.map(j => ` - \`${j.job}\`: [CI link](${j.url})`).join('\n');
354+
355+
await github.rest.issues.createComment({
356+
owner: context.repo.owner,
357+
repo: context.repo.repo,
358+
issue_number: issue.data.number,
359+
body: [
360+
commentMarker,
361+
`**Test:** \`${test.test_name}\``,
362+
``,
363+
`**Error:**`,
364+
'```',
365+
test.error || 'N/A',
366+
'```',
367+
``,
368+
`**Failure history:**`,
369+
`- ${today}:`,
370+
ciLinks,
371+
``,
372+
`---`,
373+
`*Auto-tracked by Test Failure Detector*`,
374+
].join('\n'),
375+
});
376+
console.log(` Created comment for test: ${test.test_name}`);
377+
}
268378
}
269379
}
270380
271-
console.log(`Done. Processed ${failures.length} failure(s).`);
381+
console.log(`Done. Processed ${fileFailures.length} file(s).`);

0 commit comments

Comments
 (0)