Skip to content

Commit bf7f90e

Browse files
joshuarliclaude
andauthored
feat(ci): report backend test fails (#109543)
Adds a PR comment summarising backend test failures across all shards as they happen. Each shard's job runs a `github-script` step immediately after its tests finish. The step reads `.artifacts/pytest.json` directly from the runner filesystem (no artifact upload/download needed) and appends any new failures to the PR comment. The comment body itself is the shared accumulator — `extractNodeids()` parses existing `<code>` tags to skip already-reported tests, so re-runs and concurrent shards are idempotent. Each failure links to its shard's job log via `listJobsForWorkflowRun`. The step runs with `continue-on-error: true` so a GitHub API hiccup never fails CI. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 690cd30 commit bf7f90e

File tree

3 files changed

+714
-1
lines changed

3 files changed

+714
-1
lines changed

.github/workflows/backend.yml

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,8 @@ jobs:
246246
permissions:
247247
contents: read
248248
id-token: write
249-
actions: read # used for DIM metadata
249+
actions: read # used for DIM metadata and job URL lookup
250+
pull-requests: write # used to post failure comments
250251
strategy:
251252
# This helps not having to run multiple jobs because one fails, thus, reducing resource usage
252253
# and reducing the risk that one of many runs would turn red again (read: intermittent tests)
@@ -289,6 +290,18 @@ jobs:
289290
run: |
290291
make test-python-ci
291292
293+
- name: Report failures
294+
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
295+
continue-on-error: true
296+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
297+
env:
298+
PYTEST_JSON_PATH: ${{ github.workspace }}/.artifacts/pytest.json
299+
PYTEST_ARTIFACT_DIR: pytest-results-backend-${{ github.run_id }}-${{ matrix.instance }}
300+
with:
301+
script: |
302+
const { reportShard } = await import(`${process.env.GITHUB_WORKSPACE}/.github/workflows/scripts/report-backend-test-failures.js`);
303+
await reportShard({ github, context, core });
304+
292305
- name: Inspect failure
293306
if: failure()
294307
run: |
@@ -313,6 +326,10 @@ jobs:
313326
name: backend migration tests
314327
runs-on: ubuntu-24.04
315328
timeout-minutes: 30
329+
permissions:
330+
contents: read
331+
actions: read
332+
pull-requests: write
316333

317334
steps:
318335
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
@@ -327,6 +344,18 @@ jobs:
327344
run: |
328345
PYTEST_ADDOPTS="$PYTEST_ADDOPTS -m migrations --migrations --reruns 0 --fail-slow=120s" make test-python-ci
329346
347+
- name: Report failures
348+
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
349+
continue-on-error: true
350+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
351+
env:
352+
PYTEST_JSON_PATH: ${{ github.workspace }}/.artifacts/pytest.json
353+
PYTEST_ARTIFACT_DIR: pytest-results-migration-${{ github.run_id }}
354+
with:
355+
script: |
356+
const { reportShard } = await import(`${process.env.GITHUB_WORKSPACE}/.github/workflows/scripts/report-backend-test-failures.js`);
357+
await reportShard({ github, context, core });
358+
330359
- name: Inspect failure
331360
if: failure()
332361
run: |
@@ -474,6 +503,8 @@ jobs:
474503
permissions:
475504
contents: read
476505
id-token: write
506+
actions: read
507+
pull-requests: write
477508
steps:
478509
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
479510

@@ -487,6 +518,18 @@ jobs:
487518
run: |
488519
make test-monolith-dbs
489520
521+
- name: Report failures
522+
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
523+
continue-on-error: true
524+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
525+
env:
526+
PYTEST_JSON_PATH: ${{ github.workspace }}/.artifacts/pytest.monolith-dbs.json
527+
PYTEST_ARTIFACT_DIR: pytest-results-monolith-dbs-${{ github.run_id }}
528+
with:
529+
script: |
530+
const { reportShard } = await import(`${process.env.GITHUB_WORKSPACE}/.github/workflows/scripts/report-backend-test-failures.js`);
531+
await reportShard({ github, context, core });
532+
490533
- name: Inspect failure
491534
if: failure()
492535
run: |
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import {readFileSync} from 'node:fs';
2+
import {basename, dirname} from 'node:path';
3+
4+
const MAX_FAILURES = 30;
5+
const MAX_TRACEBACK_LINES = 50;
6+
const MAX_BODY_LENGTH = 65_000;
7+
export const COMMENT_MARKER = '<!-- BACKEND_TEST_FAILURES -->';
8+
9+
export const commitMarker = sha => `<!-- BACKEND_TEST_FAILURES_COMMIT:${sha} -->`;
10+
11+
export function parseFailures(files, core) {
12+
return files.flatMap(file => {
13+
let data;
14+
try {
15+
data = JSON.parse(readFileSync(file, 'utf8'));
16+
} catch (e) {
17+
core.warning(`Skipping ${file}: ${e.message}`);
18+
return [];
19+
}
20+
if (!Array.isArray(data.tests)) return [];
21+
22+
const artifactDir = basename(dirname(file));
23+
return data.tests
24+
.filter(t => t.outcome === 'failed')
25+
.map(t => ({
26+
nodeid: t.nodeid ?? 'unknown',
27+
longrepr: (t.call ?? t.setup ?? t.teardown ?? {}).longrepr ?? '',
28+
artifactDir,
29+
}));
30+
});
31+
}
32+
33+
// Maps artifact directory names to GitHub Actions job log URLs.
34+
const JOB_MATCHERS = [
35+
{
36+
// pytest-results-backend-{runId}-{N} → "backend test (N)"
37+
dir: /^pytest-results-backend-\d+-(?<shard>\d+)$/,
38+
job: (jobs, {shard}) =>
39+
jobs.find(j => j.name.match(/^backend test \((\d+)\)$/)?.[1] === shard),
40+
},
41+
{
42+
// pytest-results-migration-{runId} → "backend migration tests"
43+
dir: /^pytest-results-migration-\d+$/,
44+
job: jobs => jobs.find(j => j.name.includes('backend migration tests')),
45+
},
46+
{
47+
// pytest-results-monolith-dbs-{runId} → "monolith-dbs test"
48+
dir: /^pytest-results-monolith-dbs-\d+$/,
49+
job: jobs => jobs.find(j => j.name.includes('monolith-dbs test')),
50+
},
51+
];
52+
53+
async function getJobUrls(failures, github, context) {
54+
const uniqueDirs = [...new Set(failures.map(f => f.artifactDir))];
55+
if (uniqueDirs.length === 0) return {};
56+
57+
const {owner, repo} = context.repo;
58+
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
59+
owner,
60+
repo,
61+
run_id: context.runId,
62+
});
63+
64+
const findJobUrl = dir => {
65+
for (const {dir: pattern, job: findJob} of JOB_MATCHERS) {
66+
const m = dir.match(pattern);
67+
if (m) return findJob(jobs, m.groups ?? {})?.html_url;
68+
}
69+
return undefined;
70+
};
71+
72+
return Object.fromEntries(
73+
uniqueDirs.map(dir => [dir, findJobUrl(dir)]).filter(([, url]) => url)
74+
);
75+
}
76+
77+
// Returns the set of test nodeids already present in a comment body.
78+
export function extractNodeids(body) {
79+
if (!body) return new Set();
80+
// Iterator.prototype.map() — available since Node 22.
81+
return new Set(body.matchAll(/<code>([^<]+)<\/code>/g).map(m => m[1]));
82+
}
83+
84+
function truncateBody(body) {
85+
if (body.length <= MAX_BODY_LENGTH) return body;
86+
return (
87+
body.slice(0, MAX_BODY_LENGTH - 100) +
88+
'\n\n... (truncated due to GitHub comment size limit)\n'
89+
);
90+
}
91+
92+
// Renders <details> blocks for each failure (no header).
93+
export function buildFailureBlocks(failures) {
94+
return failures
95+
.map(({nodeid, longrepr, jobUrl}) => {
96+
let tb = longrepr;
97+
if (tb) {
98+
const lines = tb.split('\n');
99+
if (lines.length > MAX_TRACEBACK_LINES) {
100+
tb =
101+
lines.slice(0, MAX_TRACEBACK_LINES).join('\n') +
102+
`\n... (${lines.length - MAX_TRACEBACK_LINES} more lines)`;
103+
}
104+
}
105+
const logLink = jobUrl ? ` — <a href="${jobUrl}">log</a>` : '';
106+
return (
107+
`<details><summary><code>${nodeid}</code>${logLink}</summary>\n\n` +
108+
`\`\`\`\n${tb || 'No traceback available'}\n\`\`\`\n\n</details>\n\n`
109+
);
110+
})
111+
.join('');
112+
}
113+
114+
// Builds a full comment body (header + blocks). Used when creating a new comment.
115+
export function buildCommentBody(failures, {runUrl, sha, repoUrl}) {
116+
const capped = failures.slice(0, MAX_FAILURES);
117+
const shortSha = sha.slice(0, 7);
118+
const commitUrl = `${repoUrl}/commit/${sha}`;
119+
120+
let body = `${COMMENT_MARKER}\n${commitMarker(sha)}\n## Backend Test Failures\n\nFailures on [\`${shortSha}\`](${commitUrl}) in [this run](${runUrl}):\n\n`;
121+
body += buildFailureBlocks(capped);
122+
123+
if (failures.length > MAX_FAILURES) {
124+
body += `... and ${failures.length - MAX_FAILURES} more failures.\n`;
125+
}
126+
127+
return truncateBody(body);
128+
}
129+
130+
// Called from within each test shard. Reads the shard's own pytest.json from the
131+
// filesystem, appends any new failures to the PR comment, creating it if needed.
132+
export async function reportShard({github, context, core}) {
133+
const jsonPath = process.env.PYTEST_JSON_PATH;
134+
const artifactDir = process.env.PYTEST_ARTIFACT_DIR;
135+
136+
if (!jsonPath) {
137+
core.warning('PYTEST_JSON_PATH not set — skipping.');
138+
return;
139+
}
140+
141+
const rawFailures = parseFailures([jsonPath], core);
142+
if (rawFailures.length === 0) {
143+
core.info('No failures in this shard — skipping.');
144+
return;
145+
}
146+
147+
const shardFailures = artifactDir
148+
? rawFailures.map(f => ({...f, artifactDir}))
149+
: rawFailures;
150+
151+
let jobUrls = {};
152+
try {
153+
jobUrls = await getJobUrls(shardFailures, github, context);
154+
} catch (e) {
155+
core.warning(`Could not fetch job URLs: ${e.message}`);
156+
}
157+
const failures = shardFailures.map(f => ({...f, jobUrl: jobUrls[f.artifactDir]}));
158+
159+
const {owner, repo} = context.repo;
160+
const prNumber = context.payload.pull_request.number;
161+
const {sha} = context;
162+
const marker = commitMarker(sha);
163+
164+
const comments = await github.paginate(github.rest.issues.listComments, {
165+
owner,
166+
repo,
167+
issue_number: prNumber,
168+
});
169+
// Only match comments for the same commit — a new push gets a fresh comment.
170+
const existing = comments.find(c => c.body?.includes(marker));
171+
172+
// Append-only: skip failures whose nodeid is already in the comment.
173+
const seen = extractNodeids(existing?.body);
174+
const newFailures = failures.filter(f => !seen.has(f.nodeid)).slice(0, MAX_FAILURES);
175+
176+
if (newFailures.length === 0) {
177+
core.info('All failures already reported — skipping.');
178+
return;
179+
}
180+
181+
if (existing) {
182+
await github.rest.issues.updateComment({
183+
owner,
184+
repo,
185+
comment_id: existing.id,
186+
body: truncateBody(existing.body + buildFailureBlocks(newFailures)),
187+
});
188+
core.info(`Appended ${newFailures.length} failure(s) to comment.`);
189+
} else {
190+
const repoUrl = `https://github.com/${owner}/${repo}`;
191+
const runUrl = `${repoUrl}/actions/runs/${context.runId}`;
192+
await github.rest.issues.createComment({
193+
owner,
194+
repo,
195+
issue_number: prNumber,
196+
body: buildCommentBody(failures, {runUrl, sha, repoUrl}),
197+
});
198+
core.info(
199+
`Created failure comment with ${Math.min(failures.length, MAX_FAILURES)} failure(s).`
200+
);
201+
}
202+
}

0 commit comments

Comments
 (0)