Skip to content

Commit 7fc3676

Browse files
committed
fix(ci): retain disabled workflow sources
1 parent 94ddc2e commit 7fc3676

20 files changed

Lines changed: 4530 additions & 9 deletions
Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Turn the logs of a failed main-branch CI run into a stable failure signature.
4+
*
5+
* `main-ci-failure-issue.yml` used to dedupe on the commit SHA, so a standing
6+
* red opened one fresh issue per merged commit (six duplicates for a single
7+
* broken E2E test on 2026-07-26). Deduping on *what broke* collapses those into
8+
* one issue that records each recurrence instead.
9+
*
10+
* Every failing test gets its own `qwen-main-ci-failure-test:<key>` marker in
11+
* the issue body, so an issue is matched when the current failure set overlaps
12+
* the recorded one at all — `[A]` then `[A, B]` updates the issue that already
13+
* tracks A rather than opening a second one.
14+
*/
15+
import { createHash } from 'node:crypto';
16+
import { readFileSync } from 'node:fs';
17+
import { pathToFileURL } from 'node:url';
18+
19+
export const TEST_MARKER_PREFIX = 'qwen-main-ci-failure-test:';
20+
/** Pre-dedupe marker, still used for runs whose failing tests are unknown. */
21+
export const LEGACY_MARKER_PREFIX = 'qwen-main-ci-failure:';
22+
export const SIGNATURE_MARKER_PREFIX = 'qwen-main-ci-failure-sig:';
23+
export const OCCURRENCE_MARKER = '<!-- qwen-main-ci-failure-occurrences -->';
24+
export const MAX_OCCURRENCES = 10;
25+
26+
/** Markers to search issues by. GitHub search is a cost per query, and a run
27+
* with dozens of failures is an infra break, not a per-test regression. */
28+
export const MAX_SEARCH_MARKERS = 5;
29+
30+
/** Failing tests listed in the issue body. A total-suite failure (expired
31+
* provider key, model outage) can fail every test at once; the body must stay
32+
* under GitHub's 65,536-character limit or `gh issue create` hard-fails. */
33+
export const MAX_BODY_TESTS = 20;
34+
35+
// Vitest and pytest colourise their output and Actions stores the escapes
36+
// verbatim, so failure lines arrive wrapped in SGR sequences.
37+
// eslint-disable-next-line no-control-regex -- matches the ESC that opens one
38+
const ANSI_PATTERN = /\u001B\[[0-9;?]*[A-Za-z]/g;
39+
// Actions prefixes every log line with an RFC3339 timestamp.
40+
const LOG_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s?/;
41+
const VITEST_FAIL_PATTERN = /^FAIL\s+(.+)$/;
42+
// Anchoring on ` - ` rather than the first space keeps parametrized node ids
43+
// whose parameters contain spaces (`test_x[case one]`).
44+
const PYTEST_FAIL_PATTERN = /^FAILED\s+(.+?)(?:\s+-\s.*)?$/;
45+
const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?\b|\.py\b/;
46+
47+
function cleanLine(line) {
48+
return line
49+
.replace(ANSI_PATTERN, '')
50+
.replace(LOG_TIMESTAMP_PATTERN, '')
51+
.replace(/\s+/g, ' ')
52+
.trim();
53+
}
54+
55+
/**
56+
* Collect the failing test identifiers a runner reported, first-seen order.
57+
* Both runners print their failures more than once (inline plus summary), and a
58+
* matrix leg repeats them per job, so identifiers are deduped.
59+
*/
60+
export function extractFailingTests(logText) {
61+
const seen = new Set();
62+
for (const rawLine of String(logText ?? '').split('\n')) {
63+
const line = cleanLine(rawLine);
64+
const vitest = VITEST_FAIL_PATTERN.exec(line);
65+
const pytest = PYTEST_FAIL_PATTERN.exec(line);
66+
if (!vitest && !pytest) continue;
67+
68+
// pytest -q appends ` - <error message>`; the message varies run to run and
69+
// would defeat deduping, so keep only the `file::test` node id.
70+
const id = vitest ? vitest[1].trim() : pytest[1].trim();
71+
72+
// Guard against the phrase appearing in a test's own captured stdout: a
73+
// real failure line names a test file, or a vitest `file > suite > case`.
74+
if (!TEST_FILE_PATTERN.test(id) && !id.includes(' > ')) continue;
75+
seen.add(id);
76+
}
77+
return [...seen];
78+
}
79+
80+
export function testKey(testId) {
81+
return createHash('sha256')
82+
.update(String(testId).replace(/\s+/g, ' ').trim())
83+
.digest('hex')
84+
.slice(0, 12);
85+
}
86+
87+
/**
88+
* A signature over the whole failure set, recorded in the body for humans
89+
* comparing two issues. Matching is done with the per-test markers, which
90+
* tolerate a failure set that grows or shrinks between runs.
91+
*/
92+
export function failureSignature(workflowName, testIds) {
93+
const keys = testIds.map(testKey).sort();
94+
return createHash('sha256')
95+
.update(`${workflowName}\n${keys.join('\n')}`)
96+
.digest('hex')
97+
.slice(0, 12);
98+
}
99+
100+
/**
101+
* Titles are read in issue lists, so keep the two parts that identify the
102+
* failure — the file and the test case — and collapse the suite chain between
103+
* them (`file > Suite > nested > case` is routinely over 140 characters).
104+
*/
105+
export function shortenForTitle(testId, limit = 110) {
106+
const segments = testId.replace(/\s+/g, ' ').trim().split(' > ');
107+
const collapsed =
108+
segments.length > 2
109+
? [segments[0], '…', segments.at(-1)].join(' > ')
110+
: segments.join(' > ');
111+
return collapsed.length <= limit
112+
? collapsed
113+
: `${collapsed.slice(0, limit - 1)}…`;
114+
}
115+
116+
export function analyzeLogs(workflowName, logTexts) {
117+
const tests = [];
118+
for (const logText of logTexts) {
119+
for (const id of extractFailingTests(logText)) {
120+
if (!tests.some((test) => test.id === id))
121+
tests.push({ id, key: testKey(id) });
122+
}
123+
}
124+
125+
const extra = tests.length > 1 ? ` (+${tests.length - 1} more)` : '';
126+
return {
127+
workflow: workflowName,
128+
tests,
129+
signature: tests.length
130+
? failureSignature(
131+
workflowName,
132+
tests.map((t) => t.id),
133+
)
134+
: '',
135+
markers: tests.map((test) => `${TEST_MARKER_PREFIX}${test.key}`),
136+
searchMarkers: tests
137+
.slice(0, MAX_SEARCH_MARKERS)
138+
.map((test) => `${TEST_MARKER_PREFIX}${test.key}`),
139+
title: tests.length
140+
? `Main CI failed: ${workflowName}${shortenForTitle(tests[0].id)}${extra}`
141+
: '',
142+
};
143+
}
144+
145+
function occurrenceLine({ sha, runUrl, runId, at }) {
146+
const shortSha = String(sha ?? '').slice(0, 12);
147+
return `- \`${shortSha}\` · ${at} · [run ${runId}](${runUrl})`;
148+
}
149+
150+
const TRIMMED_NOTE = '_Older recurrences trimmed._';
151+
const RECURRENCE_HEADING = '## Recurrences';
152+
const ALSO_FAILING_HEADING = '## Also failing';
153+
// The "## Also failing" list is machine-owned and rebuilt from the current
154+
// failure set on every merge, so the previous one is stripped first. The block
155+
// is the heading plus its contiguous bullet list — nothing else is ever written
156+
// under it.
157+
const ALSO_FAILING_BLOCK = /\n*##\s+Also failing\s*\n+(?:- [^\n]*\n?)+/;
158+
159+
function splitOccurrenceBlock(body) {
160+
const index = body.indexOf(OCCURRENCE_MARKER);
161+
if (index === -1) return { head: body.trimEnd(), lines: [], tail: '' };
162+
163+
const head = body.slice(0, index).trimEnd();
164+
const rest = body.slice(index + OCCURRENCE_MARKER.length).split('\n');
165+
166+
// Occurrence lines always open with the short SHA in backticks, so the
167+
// trimmed-note line never re-enters the list and accumulates. Anything else
168+
// was written by a human or the autofix agent below the block: it is kept
169+
// verbatim as `tail` and re-emitted above the refreshed block.
170+
const lines = [];
171+
let cursor = 0;
172+
for (; cursor < rest.length; cursor += 1) {
173+
const line = rest[cursor].trim();
174+
if (!line || line === TRIMMED_NOTE) continue;
175+
if (!line.startsWith('- `')) break;
176+
lines.push(line);
177+
}
178+
179+
return { head, lines, tail: rest.slice(cursor).join('\n').trim() };
180+
}
181+
182+
/**
183+
* A run that failed before any test result was reported — an install or build
184+
* break — has nothing to dedupe on, so it keeps the original per-commit marker
185+
* and title.
186+
*/
187+
function renderPerCommitBody({ analysis, occurrence }) {
188+
return [
189+
`<!-- ${LEGACY_MARKER_PREFIX}${occurrence.sha} -->`,
190+
'',
191+
'A main-branch CI run failed on `main` before any test result was',
192+
'reported, so this issue is tracked per commit.',
193+
'',
194+
`- Workflow: ${analysis.workflow}`,
195+
`- Run: ${occurrence.runUrl}`,
196+
`- Run ID: ${occurrence.runId}`,
197+
`- Commit: ${occurrence.sha}`,
198+
'',
199+
'This issue is labeled for autofix so the existing agent can create a repair PR.',
200+
'',
201+
].join('\n');
202+
}
203+
204+
export function renderIssueTitle({ analysis, occurrence }) {
205+
if (!analysis.tests.length) {
206+
return `Main CI failed: ${analysis.workflow} on ${String(occurrence.sha).slice(0, 12)}`;
207+
}
208+
return analysis.title;
209+
}
210+
211+
function cappedTestLines(tests) {
212+
const lines = tests
213+
.slice(0, MAX_BODY_TESTS)
214+
.map((test) => `- \`${test.id}\``);
215+
if (tests.length > MAX_BODY_TESTS)
216+
lines.push(`- …and ${tests.length - MAX_BODY_TESTS} more`);
217+
return lines;
218+
}
219+
220+
/**
221+
* Build the issue body: the create path when `existingBody` is empty, otherwise
222+
* a merge that keeps the existing prose (an agent's or a human's notes live
223+
* there) and only refreshes the machine-owned trailer.
224+
*/
225+
export function renderIssueBody({
226+
analysis,
227+
occurrence,
228+
maxOccurrences = MAX_OCCURRENCES,
229+
existingBody = '',
230+
}) {
231+
if (!analysis.tests.length) {
232+
// Nothing to merge into: the per-commit path opens one issue per commit and
233+
// an existing body means the same commit was already filed.
234+
return existingBody.trim()
235+
? existingBody
236+
: renderPerCommitBody({ analysis, occurrence });
237+
}
238+
239+
// Search only ever uses the first MAX_SEARCH_MARKERS markers, so the body
240+
// need not carry more — a total-suite failure can fail every test at once and
241+
// an unbounded body crosses GitHub's 65,536-character limit.
242+
const bodyMarkers = analysis.markers.slice(0, MAX_SEARCH_MARKERS);
243+
const testLines = cappedTestLines(analysis.tests);
244+
245+
if (!existingBody.trim()) {
246+
const head = [
247+
`<!-- ${SIGNATURE_MARKER_PREFIX}${analysis.signature} -->`,
248+
...bodyMarkers.map((marker) => `<!-- ${marker} -->`),
249+
'',
250+
`A main-branch \`${analysis.workflow}\` run failed on \`main\`.`,
251+
'',
252+
'## Failing tests',
253+
'',
254+
...testLines,
255+
'',
256+
'This issue is labeled for autofix so the existing agent can create a repair PR.',
257+
'It is deduped by failing test, so every later commit that hits the same',
258+
'failure is appended below instead of opening another issue.',
259+
].join('\n');
260+
return [
261+
head,
262+
'',
263+
RECURRENCE_HEADING,
264+
'',
265+
OCCURRENCE_MARKER,
266+
occurrenceLine(occurrence),
267+
'',
268+
].join('\n');
269+
}
270+
271+
const { head, lines, tail } = splitOccurrenceBlock(existingBody);
272+
// The heading belongs to the machine block and is re-emitted with it, so kept
273+
// prose can never end up between the heading and its list.
274+
const withoutHeading = head.replace(/\n*##\s+Recurrences\s*$/, '');
275+
const prose = tail ? `${withoutHeading}\n\n${tail}` : withoutHeading;
276+
277+
// The "## Also failing" list is rebuilt from the current failure set below,
278+
// so strip the previous one first: a test that has since been fixed must
279+
// disappear instead of being listed forever.
280+
const strippedProse = prose.replace(ALSO_FAILING_BLOCK, '').trimEnd();
281+
282+
// Record markers for tests that joined the failure set after the issue was
283+
// opened, so the next run still matches this issue on either test.
284+
const missingMarkers = bodyMarkers.filter(
285+
(marker) => !strippedProse.includes(marker),
286+
);
287+
const missingTests = testLines.filter(
288+
(line) => line.startsWith('- `') && !strippedProse.includes(line),
289+
);
290+
const withMarkers = missingMarkers.length
291+
? `${missingMarkers.map((marker) => `<!-- ${marker} -->`).join('\n')}\n${strippedProse}`
292+
: strippedProse;
293+
const withTests = missingTests.length
294+
? `${withMarkers}\n\n${ALSO_FAILING_HEADING}\n\n${missingTests.join('\n')}`
295+
: withMarkers;
296+
297+
// A re-run of the same run must not add a second line for it. Match the
298+
// `[run <id>]` link text, not the run URL: `/301` is a substring of `/3010`,
299+
// so a URL match would silently delete an unrelated run's line.
300+
const kept = lines.filter(
301+
(line) => !line.includes(`[run ${occurrence.runId}]`),
302+
);
303+
const combined = [occurrenceLine(occurrence), ...kept];
304+
const nextLines = combined.slice(0, maxOccurrences);
305+
const footer = combined.length > nextLines.length ? ['', TRIMMED_NOTE] : [];
306+
307+
return [
308+
withTests,
309+
'',
310+
RECURRENCE_HEADING,
311+
'',
312+
OCCURRENCE_MARKER,
313+
...nextLines,
314+
...footer,
315+
'',
316+
].join('\n');
317+
}
318+
319+
function parseArgs(argv) {
320+
const options = {};
321+
const positional = [];
322+
for (let index = 0; index < argv.length; index += 1) {
323+
const arg = argv[index];
324+
if (arg.startsWith('--')) {
325+
options[arg.slice(2)] = argv[index + 1];
326+
index += 1;
327+
} else {
328+
positional.push(arg);
329+
}
330+
}
331+
return { options, positional };
332+
}
333+
334+
export function runCli(argv) {
335+
const [command, ...rest] = argv;
336+
const { options, positional } = parseArgs(rest);
337+
338+
if (command === 'analyze') {
339+
const logTexts = positional.map((file) => readFileSync(file, 'utf8'));
340+
process.stdout.write(
341+
`${JSON.stringify(analyzeLogs(options.workflow ?? '', logTexts))}\n`,
342+
);
343+
return;
344+
}
345+
346+
// The title and body are emitted together so the privileged job that writes
347+
// the issue needs nothing but these two strings — it never reads the repo.
348+
if (command === 'plan') {
349+
const analysis = JSON.parse(readFileSync(options.analysis, 'utf8'));
350+
const existingBody = options.existing
351+
? readFileSync(options.existing, 'utf8')
352+
: '';
353+
const occurrence = {
354+
sha: options.sha,
355+
runUrl: options['run-url'],
356+
runId: options['run-id'],
357+
at: options.at,
358+
};
359+
process.stdout.write(
360+
`${JSON.stringify({
361+
title: renderIssueTitle({ analysis, occurrence }),
362+
body: renderIssueBody({ analysis, existingBody, occurrence }),
363+
searchMarkers: analysis.tests.length
364+
? analysis.searchMarkers
365+
: [`${LEGACY_MARKER_PREFIX}${occurrence.sha}`],
366+
})}\n`,
367+
);
368+
return;
369+
}
370+
371+
throw new Error(`Unknown command: ${command ?? '(none)'}`);
372+
}
373+
374+
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
375+
runCli(process.argv.slice(2));
376+
}

0 commit comments

Comments
 (0)