@@ -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