mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* feat(ci): make flaky-patrol rerun and no_action decisions visible The CI Failure Patrol posted only a hidden HTML marker when it re-ran a flaky failure or could not classify one, so maintainers saw a red PR with no explanation and clicked Update Branch — advancing the head, dismissing approvals, and re-running the whole CI for a failure that was already being handled on the same commit. Post a short bilingual notice alongside the marker for both decisions: the rerun notice states the transient cause and that no Update Branch is needed; the no_action notice asks for manual review before rerunning or updating the branch. The deterministic-failure comment is byte-identical, and the post-success reset marker stays hidden. Pin the visible notices, the marker round-trip through dedup and the per-head action count, and reason escaping for all three actions. * fix(ci): strip bidi overrides from flaky-patrol notice reasons An adversarial audit of the visible patrol notices found that bidi control characters (U+200E/U+200F, U+202A-U+202E, U+2066-U+2069) passed through the reason escaping untouched. The reason is classifier output derived from untrusted CI logs, and a right-to-left override can visually rewrite the very sentence maintainers trust when deciding whether to update a branch. Drop those characters in the shared escaping helper so all three visible decisions (and the deflake issue signature) are covered, and pin the stripping for each action. Removing the strip line fails the new test. * fix(ci): cover the Arabic letter mark in the patrol bidi strip An escalated audit round compared the strip class against the full Unicode Bidi_Control set and found U+061C (Arabic letter mark) missing; it can reorder adjacent Arabic-script text in a visible notice. Add it to the class and extend the per-action pin payload, so the class now matches the complete Bidi_Control set. Removing just U+061C fails the pin test. * fix(ci): collapse newlines in patrol reasons and pin the audit gaps The verification round found that a newline plus a line-start fence in a classifier reason opens a block-level code fence in the visible notice: the folded Chinese block and the tracking marker render as visible code. Collapse newlines and their surrounding whitespace to a single space in the shared escaping helper, keeping every reason single-line. Close the coverage gaps the same round measured: feed all twelve Bidi_Control code points individually, pin the English-before-fold and Chinese-inside-fold placement of the deterministic comment, and assert the fold's closing tag in all three notices. Witnesses: removing the collapse, narrowing the bidi class, dropping U+200F alone, swapping EN/ZH, or dropping the closing tag each fails the suite; a marked rendering probe shows the fence swallowing the fold before the fix and a clean render after; benign comment bodies remain byte-identical to base.
1004 lines
32 KiB
JavaScript
1004 lines
32 KiB
JavaScript
import { describe, expect, it } from 'vitest';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
|
||
import {
|
||
actOnDecision,
|
||
actOnDecisions,
|
||
alreadyHandled,
|
||
deflakeIssueBody,
|
||
deflakeKey,
|
||
deflakeMarker,
|
||
argsMap,
|
||
currentActionCount,
|
||
eligibleAttemptJob,
|
||
fileSha256,
|
||
fingerprint,
|
||
GhClient,
|
||
resetSuccessfulFailures,
|
||
selectCandidateTargets,
|
||
skillLog,
|
||
} from '../../.github/scripts/ci-flaky-rerun.mjs';
|
||
|
||
const NOW = new Date('2026-07-12T08:00:00.000Z');
|
||
|
||
function run(overrides = {}) {
|
||
return {
|
||
databaseId: 11,
|
||
name: 'E2E Tests',
|
||
workflowName: 'Qwen Code CI',
|
||
status: 'COMPLETED',
|
||
conclusion: 'FAILURE',
|
||
startedAt: '2026-07-12T07:10:00.000Z',
|
||
completedAt: '2026-07-12T07:20:00.000Z',
|
||
detailsUrl: 'https://github.com/QwenLM/qwen-code/actions/runs/123/job/1',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function pr(overrides = {}) {
|
||
return {
|
||
number: 42,
|
||
isDraft: false,
|
||
baseRefName: 'main',
|
||
headRefOid: 'abc123',
|
||
statusCheckRollup: [run()],
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function target(overrides = {}) {
|
||
return {
|
||
...selectCandidateTargets([pr()], { now: NOW })[0],
|
||
runAttempt: 2,
|
||
failureKey: 'check-0123456789abcdef',
|
||
actionCount: 0,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function decision(overrides = {}) {
|
||
const t = target();
|
||
return {
|
||
prNumber: t.prNumber,
|
||
headSha: t.headSha,
|
||
runId: t.runId,
|
||
runAttempt: t.runAttempt,
|
||
failureKey: t.failureKey,
|
||
action: 'rerun',
|
||
confidence: 'high',
|
||
reason_en: 'runner timeout',
|
||
reason_zh: 'runner 超时',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function markerComment(overrides = {}) {
|
||
return {
|
||
body: '<!-- qwen-ci-flaky-rerun v=5 pr=42 head=abc123 run=123 attempt=2 workflow=Qwen%20Code%20CI check=E2E%20Tests action=rerun key=check-0123456789abcdef count=2 -->',
|
||
createdAt: '2026-07-12T07:25:00.000Z',
|
||
author: { login: 'patrol-bot' },
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function client(overrides = {}) {
|
||
const calls = [];
|
||
return {
|
||
calls,
|
||
trustedMarkerLogin: 'patrol-bot',
|
||
async currentPr() {
|
||
return { ...pr(), state: 'OPEN' };
|
||
},
|
||
async comments() {
|
||
return [];
|
||
},
|
||
async run() {
|
||
return {
|
||
status: 'completed',
|
||
conclusion: 'failure',
|
||
head_sha: 'abc123',
|
||
run_attempt: 2,
|
||
};
|
||
},
|
||
async rerunFailedJobs(...args) {
|
||
calls.push(['rerunFailedJobs', ...args]);
|
||
},
|
||
async comment(...args) {
|
||
calls.push(['comment', ...args]);
|
||
},
|
||
async hasOpenIssueWithMarker(...args) {
|
||
calls.push(['hasOpenIssueWithMarker', ...args]);
|
||
return false;
|
||
},
|
||
async createIssue(...args) {
|
||
calls.push(['createIssue', ...args]);
|
||
},
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe('ci flaky rerun patrol', () => {
|
||
it('selects only recent stale current Qwen Code CI failures', () => {
|
||
const selected = selectCandidateTargets(
|
||
[
|
||
pr({
|
||
statusCheckRollup: [
|
||
run(),
|
||
run({
|
||
databaseId: 12,
|
||
status: 'IN_PROGRESS',
|
||
conclusion: null,
|
||
completedAt: null,
|
||
startedAt: '2026-07-12T07:40:00.000Z',
|
||
detailsUrl:
|
||
'https://github.com/QwenLM/qwen-code/actions/runs/124/job/2',
|
||
}),
|
||
],
|
||
}),
|
||
pr({
|
||
number: 43,
|
||
headRefOid: 'def456',
|
||
statusCheckRollup: [
|
||
run({
|
||
databaseId: 13,
|
||
conclusion: 'TIMED_OUT',
|
||
detailsUrl:
|
||
'https://github.com/QwenLM/qwen-code/actions/runs/125/job/3',
|
||
}),
|
||
],
|
||
}),
|
||
pr({
|
||
number: 44,
|
||
statusCheckRollup: [run({ completedAt: '2026-07-01T07:20:00.000Z' })],
|
||
}),
|
||
pr({ number: 45, isDraft: true }),
|
||
pr({ number: 46, baseRefName: 'release' }),
|
||
pr({
|
||
number: 47,
|
||
statusCheckRollup: [run({ workflowName: 'Review PR' })],
|
||
}),
|
||
],
|
||
{ now: NOW, staleMinutes: 30, activeDays: 7 },
|
||
);
|
||
|
||
expect(selected).toEqual([
|
||
expect.objectContaining({ prNumber: 43, runId: 125, jobId: 3 }),
|
||
]);
|
||
});
|
||
|
||
it('orders the newest eligible failures first', () => {
|
||
const selected = selectCandidateTargets(
|
||
[
|
||
pr({
|
||
number: 41,
|
||
statusCheckRollup: [
|
||
run({
|
||
completedAt: '2026-07-12T06:00:00.000Z',
|
||
detailsUrl:
|
||
'https://github.com/QwenLM/qwen-code/actions/runs/121/job/1',
|
||
}),
|
||
],
|
||
}),
|
||
pr(),
|
||
],
|
||
{ now: NOW },
|
||
);
|
||
expect(selected.map((item) => item.prNumber)).toEqual([42, 41]);
|
||
});
|
||
|
||
it('keeps distinct failures from the same PR available for scanning', () => {
|
||
const selected = selectCandidateTargets(
|
||
[
|
||
pr({
|
||
statusCheckRollup: [
|
||
run(),
|
||
run({
|
||
databaseId: 12,
|
||
name: 'Unit Tests',
|
||
completedAt: '2026-07-12T07:25:00.000Z',
|
||
detailsUrl:
|
||
'https://github.com/QwenLM/qwen-code/actions/runs/124/job/2',
|
||
}),
|
||
],
|
||
}),
|
||
],
|
||
{ now: NOW },
|
||
);
|
||
|
||
expect(selected.map((item) => item.checkName)).toEqual([
|
||
'Unit Tests',
|
||
'E2E Tests',
|
||
]);
|
||
});
|
||
|
||
it('binds job evidence to the exact live run attempt', () => {
|
||
const t = target();
|
||
const job = {
|
||
id: t.jobId,
|
||
run_id: t.runId,
|
||
run_attempt: t.runAttempt,
|
||
head_sha: t.headSha,
|
||
name: t.checkName,
|
||
status: 'completed',
|
||
conclusion: 'failure',
|
||
completed_at: '2026-07-12T07:20:00.000Z',
|
||
};
|
||
|
||
expect(eligibleAttemptJob(job, t, 2, { now: NOW })).toBe(true);
|
||
expect(
|
||
eligibleAttemptJob({ ...job, run_attempt: 1 }, t, 2, { now: NOW }),
|
||
).toBe(false);
|
||
expect(
|
||
eligibleAttemptJob(
|
||
{ ...job, completed_at: '2026-07-12T07:45:00.000Z' },
|
||
t,
|
||
2,
|
||
{ now: NOW },
|
||
),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('rejects a command-line flag without a value', () => {
|
||
expect(() => argsMap(['--repo'])).toThrow('missing value for --repo');
|
||
});
|
||
|
||
it('runs from an entry path containing spaces', () => {
|
||
const dir = mkdtempSync(join(tmpdir(), 'ci flaky rerun-'));
|
||
const script = join(dir, 'ci flaky rerun.mjs');
|
||
try {
|
||
copyFileSync(
|
||
join(process.cwd(), '.github/scripts/ci-flaky-rerun.mjs'),
|
||
script,
|
||
);
|
||
const result = spawnSync(process.execPath, [script, 'invalid'], {
|
||
encoding: 'utf8',
|
||
});
|
||
expect(result.status).toBe(1);
|
||
expect(result.stderr).toContain('command must be scan, act, or reset');
|
||
} finally {
|
||
rmSync(dir, { recursive: true });
|
||
}
|
||
});
|
||
|
||
it('ignores empty lines in paginated comment output', async () => {
|
||
const api = new GhClient('QwenLM/qwen-code');
|
||
api.gh = async () => '{"body":"first"}\n\n{"body":"second"}\n';
|
||
await expect(api.comments(42)).resolves.toEqual([
|
||
{ body: 'first' },
|
||
{ body: 'second' },
|
||
]);
|
||
});
|
||
|
||
it('keeps the status rollup out of the PR search query', async () => {
|
||
// With statusCheckRollup in the list query, the search is one GraphQL
|
||
// call costing matched PRs times their check runs. It began returning
|
||
// HTTP 504 above ~30 matches and failed 100 consecutive scheduled scans;
|
||
// the same search without the rollup answers in a second. Reading the
|
||
// rollup per PR keeps every call small.
|
||
const api = new GhClient('QwenLM/qwen-code');
|
||
const calls = [];
|
||
api.gh = async (args) => {
|
||
calls.push(args);
|
||
if (args[1] === 'list') {
|
||
return JSON.stringify([{ number: 7 }, { number: 8 }]);
|
||
}
|
||
if (args[2] === '8') {
|
||
throw new Error('HTTP 502');
|
||
}
|
||
return JSON.stringify({ statusCheckRollup: [run()] });
|
||
};
|
||
await expect(api.prList('status:failure')).resolves.toEqual([
|
||
{ number: 7, statusCheckRollup: [run()] },
|
||
]);
|
||
const listArgs = calls[0];
|
||
expect(listArgs[listArgs.indexOf('--json') + 1].split(',')).not.toContain(
|
||
'statusCheckRollup',
|
||
);
|
||
expect(calls).toHaveLength(3);
|
||
});
|
||
|
||
it('bounds how many PR detail reads run at once', async () => {
|
||
// The patrol job has a ten-minute budget and this cost grows with the
|
||
// open-PR count, so the fan-out is capped rather than unbounded.
|
||
const api = new GhClient('QwenLM/qwen-code');
|
||
let inFlight = 0;
|
||
let peak = 0;
|
||
api.gh = async (args) => {
|
||
if (args[1] === 'list') {
|
||
return JSON.stringify(
|
||
Array.from({ length: 12 }, (_, i) => ({ number: i + 1 })),
|
||
);
|
||
}
|
||
inFlight += 1;
|
||
peak = Math.max(peak, inFlight);
|
||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||
inFlight -= 1;
|
||
return JSON.stringify({ statusCheckRollup: [run()] });
|
||
};
|
||
const result = await api.prList('status:failure');
|
||
expect(result).toHaveLength(12);
|
||
// Order survives the fan-out: the caller pairs rollups with PR numbers.
|
||
expect(result.map((pr) => pr.number)).toEqual([
|
||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
|
||
]);
|
||
expect(peak).toBeGreaterThan(1);
|
||
expect(peak).toBeLessThanOrEqual(5);
|
||
});
|
||
|
||
it('requests the PR number when refreshing current PR state', async () => {
|
||
const api = new GhClient('QwenLM/qwen-code');
|
||
let args = [];
|
||
api.gh = async (nextArgs) => {
|
||
args = nextArgs;
|
||
return JSON.stringify({ ...pr(), state: 'OPEN' });
|
||
};
|
||
await api.currentPr(42);
|
||
expect(args[args.indexOf('--json') + 1].split(',')).toContain('number');
|
||
});
|
||
|
||
it('handles an exact run attempt once and allows a new attempt', () => {
|
||
const comments = [markerComment()];
|
||
expect(alreadyHandled(comments, target(), 'patrol-bot')).toBe(true);
|
||
expect(
|
||
alreadyHandled(comments, target({ runAttempt: 3 }), 'patrol-bot'),
|
||
).toBe(false);
|
||
expect(alreadyHandled(comments, target(), 'someone-else')).toBe(false);
|
||
const malformed = markerComment({
|
||
body: markerComment().body.replace(' count=2', ' malformed count=2'),
|
||
});
|
||
expect(alreadyHandled([malformed], target(), 'patrol-bot')).toBe(false);
|
||
});
|
||
|
||
it('counts actions per head and resets after the handled check succeeds', () => {
|
||
const comments = [markerComment()];
|
||
expect(currentActionCount(pr(), comments, 'patrol-bot')).toBe(2);
|
||
expect(
|
||
currentActionCount(
|
||
pr({
|
||
statusCheckRollup: [
|
||
run({
|
||
conclusion: 'SUCCESS',
|
||
completedAt: '2026-07-12T07:30:00.000Z',
|
||
}),
|
||
],
|
||
}),
|
||
comments,
|
||
'patrol-bot',
|
||
),
|
||
).toBe(0);
|
||
expect(
|
||
currentActionCount(
|
||
pr({
|
||
statusCheckRollup: [
|
||
run({
|
||
conclusion: 'SUCCESS',
|
||
completedAt: '2026-07-12T07:30:00.000Z',
|
||
detailsUrl:
|
||
'https://github.com/QwenLM/qwen-code/actions/runs/124/job/2',
|
||
}),
|
||
],
|
||
}),
|
||
comments,
|
||
'patrol-bot',
|
||
),
|
||
).toBe(2);
|
||
expect(
|
||
currentActionCount(
|
||
pr({ headRefOid: 'new-head' }),
|
||
comments,
|
||
'patrol-bot',
|
||
),
|
||
).toBe(0);
|
||
});
|
||
|
||
const flaky = () => ({
|
||
file: 'packages/core/src/utils/shell-ast-parser-lazy.test.ts',
|
||
name: 'shellAstParser lazy runtime › loads web-tree-sitter on first use',
|
||
});
|
||
|
||
it('keys deflake dedup on (file, name), stable and collision-free', () => {
|
||
expect(deflakeKey(flaky())).toBe(deflakeKey(flaky()));
|
||
expect(deflakeKey({ ...flaky(), name: 'other test' })).not.toBe(
|
||
deflakeKey(flaky()),
|
||
);
|
||
expect(deflakeKey({ ...flaky(), file: 'other.test.ts' })).not.toBe(
|
||
deflakeKey(flaky()),
|
||
);
|
||
expect(deflakeMarker(flaky())).toBe(
|
||
`<!-- qwen-deflake key=${deflakeKey(flaky())} -->`,
|
||
);
|
||
// The issue body carries the marker, the SKILL pointer, and the collapsed
|
||
// Chinese — everything the autofix issue flow needs to produce the fix.
|
||
const body = deflakeIssueBody(
|
||
flaky(),
|
||
{ reason_en: 'timed out', reason_zh: '超时' },
|
||
{ prNumber: 42, runId: 99, repo: 'QwenLM/qwen-code' },
|
||
);
|
||
expect(body).toContain(deflakeMarker(flaky()));
|
||
expect(body).toContain('.qwen/skills/deflake/SKILL.md');
|
||
expect(body).toContain(flaky().file);
|
||
expect(body).toContain('<summary>中文说明</summary>');
|
||
expect(body).toContain('/actions/runs/99');
|
||
});
|
||
|
||
it('opens ONE deflake issue for a confirmed flaky test, deduped', async () => {
|
||
const c = client();
|
||
await actOnDecision(c, target(), decision({ flakyTest: flaky() }));
|
||
// Reran, marked, checked for an existing issue, then created one.
|
||
expect(c.calls.map((call) => call[0])).toEqual([
|
||
'rerunFailedJobs',
|
||
'comment',
|
||
'hasOpenIssueWithMarker',
|
||
'createIssue',
|
||
]);
|
||
const created = c.calls.find((call) => call[0] === 'createIssue')[1];
|
||
expect(created.title).toBe(
|
||
'deflake: packages/core/src/utils/shell-ast-parser-lazy.test.ts › shellAstParser lazy runtime › loads web-tree-sitter on first use',
|
||
);
|
||
expect(created.labels).toEqual([
|
||
'status/ready-for-agent',
|
||
'autofix/approved',
|
||
]);
|
||
expect(created.body).toContain(deflakeMarker(flaky()));
|
||
expect(created.body).toContain('.qwen/skills/deflake/SKILL.md');
|
||
expect(created.body).toContain('<details>');
|
||
expect(created.body).toContain('中文说明');
|
||
// Idempotent: an already-open deflake issue for this test → no second issue.
|
||
const seen = client({
|
||
async hasOpenIssueWithMarker() {
|
||
return true;
|
||
},
|
||
});
|
||
await actOnDecision(seen, target(), decision({ flakyTest: flaky() }));
|
||
// Existing open issue → deduped: it reran, but created NO second issue.
|
||
expect(seen.calls.map((call) => call[0])).toEqual([
|
||
'rerunFailedJobs',
|
||
'comment',
|
||
]);
|
||
expect(seen.calls.map((call) => call[0])).not.toContain('createIssue');
|
||
});
|
||
|
||
it('never opens a deflake issue for an infra rerun (no flakyTest)', async () => {
|
||
const c = client();
|
||
await actOnDecision(c, target(), decision());
|
||
expect(c.calls.map((call) => call[0])).not.toContain('createIssue');
|
||
expect(c.calls.map((call) => call[0])).not.toContain(
|
||
'hasOpenIssueWithMarker',
|
||
);
|
||
});
|
||
|
||
it('never lets a malformed flakyTest drop the valid rerun', async () => {
|
||
// The rerun is valid independently of flakyTest, so a malformed one only
|
||
// skips the deflake issue — it must NEVER gate the primary action.
|
||
for (const bad of [
|
||
null,
|
||
{ file: '', name: 'x' },
|
||
{ file: 'a.test.ts' }, // missing name
|
||
{ file: 'a.test.ts', name: 42 },
|
||
{ file: 'a'.repeat(201), name: 'x' }, // over-length file
|
||
{ file: 'a.test.ts', name: 'y'.repeat(201) }, // over-length name
|
||
]) {
|
||
const c = client();
|
||
await actOnDecision(c, target(), decision({ flakyTest: bad }));
|
||
const kinds = c.calls.map((call) => call[0]);
|
||
expect(kinds).toContain('rerunFailedJobs');
|
||
expect(kinds).toContain('comment');
|
||
expect(kinds).not.toContain('createIssue');
|
||
}
|
||
});
|
||
|
||
it('truncates a very long deflake title to GitHub-safe length', async () => {
|
||
const c = client();
|
||
const longFlaky = { file: 'x'.repeat(150), name: 'y'.repeat(150) };
|
||
await actOnDecision(c, target(), decision({ flakyTest: longFlaky }));
|
||
const created = c.calls.find((call) => call[0] === 'createIssue')?.[1];
|
||
expect(created).toBeTruthy();
|
||
expect(created.title.length).toBeLessThanOrEqual(240);
|
||
});
|
||
|
||
it('neutralizes backticks in the test path/name so the issue body cannot inject markup', async () => {
|
||
// A backtick in a file path (valid on Linux) or name would break out of
|
||
// the inline code span and turn `@user` into a live mention. The body must
|
||
// strip them so file/name stay inert.
|
||
const evil = { file: 'x`@ghost`y.test.ts', name: 'boom `@owner` case' };
|
||
const body = deflakeIssueBody(
|
||
evil,
|
||
{ reason_en: 'x', reason_zh: 'x' },
|
||
{
|
||
prNumber: 1,
|
||
runId: 2,
|
||
},
|
||
);
|
||
expect(body).not.toContain('`@ghost`');
|
||
expect(body).not.toContain('`@owner`');
|
||
// The whole marker + SKILL pointer survive intact.
|
||
expect(body).toContain(deflakeMarker(evil));
|
||
expect(body).toContain('.qwen/skills/deflake/SKILL.md');
|
||
});
|
||
|
||
it('points the run link at the patrol repo, not a hardcoded upstream', async () => {
|
||
const body = deflakeIssueBody(
|
||
flaky(),
|
||
{ reason_en: 'x', reason_zh: 'x' },
|
||
{ prNumber: 7, runId: 42 },
|
||
'my-fork/qwen-code',
|
||
);
|
||
expect(body).toContain(
|
||
'https://github.com/my-fork/qwen-code/actions/runs/42',
|
||
);
|
||
expect(body).not.toContain('QwenLM/qwen-code/actions/runs/42');
|
||
});
|
||
|
||
it('keeps the rerun when deflake issue creation fails (best-effort)', async () => {
|
||
const c = client({
|
||
async createIssue() {
|
||
throw new Error('502 from GitHub');
|
||
},
|
||
});
|
||
// Must not throw: the rerun + marker already succeeded.
|
||
await actOnDecision(c, target(), decision({ flakyTest: flaky() }));
|
||
const kinds = c.calls.map((call) => call[0]);
|
||
expect(kinds).toContain('rerunFailedJobs');
|
||
expect(kinds).toContain('comment');
|
||
});
|
||
|
||
it('reruns before posting the visible rerun notice', async () => {
|
||
const c = client();
|
||
await actOnDecision(c, target(), decision());
|
||
expect(c.calls.map((call) => call[0])).toEqual([
|
||
'rerunFailedJobs',
|
||
'comment',
|
||
]);
|
||
const body = c.calls[1][2];
|
||
expect(body).toContain('runner timeout');
|
||
expect(body).toContain('re-run on the same commit');
|
||
expect(body).toContain('No Update Branch');
|
||
expect(body).toContain('<summary>中文说明</summary>');
|
||
expect(body).toContain('runner 超时');
|
||
expect(body.split('</details>').length - 1).toBe(1);
|
||
expect(body).toMatch(/<!-- .*action=rerun.* -->$/);
|
||
});
|
||
|
||
it('keeps markers parseable inside the visible notices', async () => {
|
||
// The visible text must not break dedup or the per-head action count:
|
||
// both read the marker back out of the posted comment body.
|
||
const c = client();
|
||
await actOnDecision(c, target(), decision());
|
||
const posted = {
|
||
body: c.calls.find((call) => call[0] === 'comment')[2],
|
||
createdAt: '2026-07-12T07:25:00.000Z',
|
||
author: { login: 'patrol-bot' },
|
||
};
|
||
expect(alreadyHandled([posted], target(), 'patrol-bot')).toBe(true);
|
||
expect(currentActionCount(pr(), [posted], 'patrol-bot')).toBe(1);
|
||
});
|
||
|
||
it('posts deterministic failures in English with folded Chinese', async () => {
|
||
const c = client();
|
||
await actOnDecision(
|
||
c,
|
||
target(),
|
||
decision({
|
||
action: 'comment',
|
||
reason_en: 'TypeScript cannot resolve the imported module.',
|
||
reason_zh: 'TypeScript 无法解析导入模块。',
|
||
}),
|
||
);
|
||
expect(c.calls).toHaveLength(1);
|
||
const body = c.calls[0][2];
|
||
expect(body).toContain('TypeScript cannot resolve');
|
||
expect(body).toContain('<details>');
|
||
expect(body).toContain('TypeScript 无法解析');
|
||
expect(body).toContain('action=comment');
|
||
// Pin the bilingual placement, not just the presence of both strings:
|
||
// English before the fold, Chinese inside it.
|
||
const enIndex = body.indexOf('TypeScript cannot resolve');
|
||
const foldIndex = body.indexOf('<summary>中文说明</summary>');
|
||
const zhIndex = body.indexOf('TypeScript 无法解析');
|
||
expect(enIndex).toBeLessThan(foldIndex);
|
||
expect(foldIndex).toBeLessThan(zhIndex);
|
||
expect(body.split('</details>').length - 1).toBe(1);
|
||
});
|
||
|
||
it('records no_action with a visible manual-review notice', async () => {
|
||
const c = client();
|
||
await actOnDecision(
|
||
c,
|
||
target(),
|
||
decision({ action: 'no_action', confidence: 'low' }),
|
||
);
|
||
expect(c.calls).toHaveLength(1);
|
||
const body = c.calls[0][2];
|
||
expect(body).toContain('could not be confidently classified');
|
||
expect(body).toContain('runner timeout');
|
||
expect(body).toContain('<summary>中文说明</summary>');
|
||
expect(body).toContain('未采取自动动作');
|
||
expect(body.split('</details>').length - 1).toBe(1);
|
||
expect(body).toMatch(/<!-- .*action=no_action.* -->$/);
|
||
});
|
||
|
||
it('rejects malformed, unsafe, or mismatched decisions', async () => {
|
||
for (const invalid of [
|
||
null,
|
||
{},
|
||
decision({ action: 'delete_branch' }),
|
||
decision({ confidence: 'low' }),
|
||
decision({ failureKey: 'wrong-key' }),
|
||
decision({ runAttempt: 3 }),
|
||
decision({ action: 'update_branch' }),
|
||
decision({ reason_en: 'x'.repeat(201) }),
|
||
decision({ reason_en: '' }),
|
||
decision({ reason_zh: ' ' }),
|
||
]) {
|
||
const c = client();
|
||
await actOnDecision(c, target(), invalid);
|
||
expect(c.calls).toEqual([]);
|
||
}
|
||
});
|
||
|
||
it('neutralizes mentions and markup copied into visible reasons', async () => {
|
||
// All three actions now surface the reason in a visible comment, so the
|
||
// escaping must hold for every action, not just `comment`.
|
||
for (const action of ['rerun', 'comment', 'no_action']) {
|
||
const c = client();
|
||
await actOnDecision(
|
||
c,
|
||
target(),
|
||
decision({
|
||
action,
|
||
confidence: action === 'no_action' ? 'low' : 'high',
|
||
reason_en: '@qwen-maintainers <script>alert(1)</script>',
|
||
reason_zh: '@全体成员 </details>',
|
||
}),
|
||
);
|
||
const body = c.calls.find((call) => call[0] === 'comment')[2];
|
||
expect(body).toContain('@\u200bqwen-maintainers');
|
||
expect(body).toContain('<script>');
|
||
expect(body).toContain('@\u200b全体成员 </details>');
|
||
}
|
||
});
|
||
|
||
it('strips bidi overrides that could visually rewrite a notice', async () => {
|
||
// The reason is classifier output derived from untrusted logs; a bidi
|
||
// override could make "no Update Branch needed" read as its opposite.
|
||
// Every member of the Unicode Bidi_Control set is also fed individually
|
||
// so narrowing the strip class cannot survive this pin.
|
||
const bidiControls = [
|
||
'\u061C',
|
||
'\u200E',
|
||
'\u200F',
|
||
'\u202A',
|
||
'\u202B',
|
||
'\u202C',
|
||
'\u202D',
|
||
'\u202E',
|
||
'\u2066',
|
||
'\u2067',
|
||
'\u2068',
|
||
'\u2069',
|
||
];
|
||
for (const action of ['rerun', 'comment', 'no_action']) {
|
||
const confidence = action === 'no_action' ? 'low' : 'high';
|
||
const c = client();
|
||
await actOnDecision(
|
||
c,
|
||
target(),
|
||
decision({
|
||
action,
|
||
confidence,
|
||
reason_en: 'safe \u202Ereversed\u202C text',
|
||
reason_zh: '安全 \u2066隔离\u2069 \u061C文本',
|
||
}),
|
||
);
|
||
const body = c.calls.find((call) => call[0] === 'comment')[2];
|
||
expect(body).not.toMatch(
|
||
/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/,
|
||
);
|
||
expect(body).toContain('safe reversed text');
|
||
expect(body).toContain('安全 隔离 文本');
|
||
for (const ch of bidiControls) {
|
||
const cc = client();
|
||
await actOnDecision(
|
||
cc,
|
||
target(),
|
||
decision({
|
||
action,
|
||
confidence,
|
||
reason_en: `a${ch}b`,
|
||
reason_zh: `a${ch}b`,
|
||
}),
|
||
);
|
||
const single = cc.calls.find((call) => call[0] === 'comment')[2];
|
||
expect(single).not.toContain(ch);
|
||
expect(single).toContain('ab');
|
||
}
|
||
}
|
||
});
|
||
|
||
it('collapses newlines so a reason cannot open a block-level fence', async () => {
|
||
// A line-start fence in a reason swallows the folded Chinese block and
|
||
// renders the tracking marker as visible code; keep reasons single-line.
|
||
for (const action of ['rerun', 'comment', 'no_action']) {
|
||
const c = client();
|
||
await actOnDecision(
|
||
c,
|
||
target(),
|
||
decision({
|
||
action,
|
||
confidence: action === 'no_action' ? 'low' : 'high',
|
||
reason_en: 'npm error:\n```\nnpm error code ETIMEDOUT',
|
||
reason_zh: 'npm 错误:\n```\nnpm error code ETIMEDOUT',
|
||
}),
|
||
);
|
||
const body = c.calls.find((call) => call[0] === 'comment')[2];
|
||
expect(body).not.toMatch(/^```/m);
|
||
expect(body).toContain('npm error: ``` npm error code ETIMEDOUT');
|
||
expect(body.split('</details>').length - 1).toBe(1);
|
||
const posted = {
|
||
body,
|
||
createdAt: '2026-07-12T07:25:00.000Z',
|
||
author: { login: 'patrol-bot' },
|
||
};
|
||
expect(alreadyHandled([posted], target(), 'patrol-bot')).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('stops after three actions on the same head', async () => {
|
||
const c = client({
|
||
async comments() {
|
||
return [
|
||
markerComment({
|
||
body: markerComment()
|
||
.body.replace('attempt=2', 'attempt=1')
|
||
.replace('count=2', 'count=3'),
|
||
}),
|
||
];
|
||
},
|
||
});
|
||
await actOnDecision(c, target(), decision());
|
||
expect(c.calls).toEqual([]);
|
||
});
|
||
|
||
it('does nothing when the PR or failure is no longer current', async () => {
|
||
const changedPr = (overrides) =>
|
||
client({
|
||
async currentPr() {
|
||
return { ...pr(), state: 'OPEN', ...overrides };
|
||
},
|
||
});
|
||
const cases = [
|
||
client({
|
||
async currentPr() {
|
||
return { ...pr(), state: 'CLOSED' };
|
||
},
|
||
}),
|
||
changedPr({ headRefOid: 'new-head' }),
|
||
changedPr({ isDraft: true }),
|
||
changedPr({ baseRefName: 'release' }),
|
||
changedPr({
|
||
statusCheckRollup: [
|
||
run({
|
||
detailsUrl:
|
||
'https://github.com/QwenLM/qwen-code/actions/runs/123/job/2',
|
||
}),
|
||
],
|
||
}),
|
||
client({
|
||
async run() {
|
||
return {
|
||
status: 'completed',
|
||
conclusion: 'success',
|
||
head_sha: 'abc123',
|
||
run_attempt: 2,
|
||
};
|
||
},
|
||
}),
|
||
];
|
||
for (const c of cases) {
|
||
await actOnDecision(c, target(), decision());
|
||
expect(c.calls).toEqual([]);
|
||
}
|
||
});
|
||
|
||
it('applies a bounded batch independently and deduplicates decisions', async () => {
|
||
const c = client({
|
||
async rerunFailedJobs(runId) {
|
||
c.calls.push(['rerunFailedJobs', runId]);
|
||
if (runId === 123) throw new Error('temporary API failure');
|
||
},
|
||
async currentPr(prNumber) {
|
||
return {
|
||
...pr({
|
||
number: prNumber,
|
||
headRefOid: prNumber === 42 ? 'abc123' : 'def456',
|
||
statusCheckRollup: [
|
||
run({
|
||
detailsUrl: `https://github.com/QwenLM/qwen-code/actions/runs/${prNumber === 42 ? 123 : 124}/job/1`,
|
||
}),
|
||
],
|
||
}),
|
||
state: 'OPEN',
|
||
};
|
||
},
|
||
async run(runId) {
|
||
return {
|
||
status: 'completed',
|
||
conclusion: 'failure',
|
||
head_sha: runId === 123 ? 'abc123' : 'def456',
|
||
run_attempt: 2,
|
||
};
|
||
},
|
||
});
|
||
const second = target({ prNumber: 43, headSha: 'def456', runId: 124 });
|
||
const secondDecision = decision({
|
||
prNumber: 43,
|
||
headSha: 'def456',
|
||
runId: 124,
|
||
});
|
||
await actOnDecisions(
|
||
c,
|
||
[target(), second],
|
||
[decision(), decision(), secondDecision],
|
||
);
|
||
expect(c.calls.filter((call) => call[0] === 'rerunFailedJobs')).toEqual([
|
||
['rerunFailedJobs', 123],
|
||
['rerunFailedJobs', 124],
|
||
]);
|
||
expect(c.calls.filter((call) => call[0] === 'comment')).toHaveLength(1);
|
||
});
|
||
|
||
it('persists a zero-count reset marker after success', async () => {
|
||
const c = client({
|
||
async comments() {
|
||
return [markerComment()];
|
||
},
|
||
});
|
||
await resetSuccessfulFailures(c, [
|
||
pr({
|
||
statusCheckRollup: [
|
||
run({
|
||
conclusion: 'SUCCESS',
|
||
completedAt: '2026-07-12T07:30:00.000Z',
|
||
}),
|
||
],
|
||
}),
|
||
]);
|
||
expect(c.calls).toEqual([
|
||
[
|
||
'comment',
|
||
42,
|
||
expect.stringMatching(/^<!-- .*action=reset.*count=0.* -->$/),
|
||
],
|
||
]);
|
||
});
|
||
|
||
it('hashes classifier inputs and fingerprints failures deterministically', () => {
|
||
const dir = mkdtempSync(join(tmpdir(), 'ci-flaky-rerun-'));
|
||
const path = join(dir, 'input.json');
|
||
try {
|
||
writeFileSync(path, '{"candidates":[]}\n');
|
||
const before = fileSha256(path);
|
||
writeFileSync(path, '{"candidates":[1]}\n');
|
||
expect(fileSha256(path)).not.toBe(before);
|
||
} finally {
|
||
rmSync(dir, { recursive: true });
|
||
}
|
||
expect(fingerprint(target(), 'Error 123 at deadbeef')).toBe(
|
||
fingerprint(target(), 'error 456 at cafebabe'),
|
||
);
|
||
expect(fingerprint(target(), 'Error 12345678')).toBe(
|
||
fingerprint(target(), 'Error 456'),
|
||
);
|
||
expect(fingerprint(target(), 'timeout')).not.toBe(
|
||
fingerprint(target({ checkName: 'Lint' }), 'timeout'),
|
||
);
|
||
});
|
||
|
||
it('bounds and redacts untrusted classifier evidence', () => {
|
||
const evidence = skillLog(
|
||
[
|
||
'-----BEGIN PRIVATE KEY-----',
|
||
'private-material',
|
||
'-----END PRIVATE KEY-----',
|
||
'Authorization: Bearer bearer-secret',
|
||
'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signaturevalue',
|
||
'ghp_abcdef1234567890',
|
||
'TypeError: ensureTool is not a function',
|
||
...Array.from({ length: 220 }, (_, index) => `line-${index}`),
|
||
'x'.repeat(600),
|
||
'AKIAABCDEFGHIJKLMNOP',
|
||
'npm_abcdefghijklmnopqrst',
|
||
'sk-abcdefghijklmnopqrst',
|
||
'Cookie: session=cookie-secret',
|
||
'https://user:[email protected]/path',
|
||
'API_SECRET=env-secret',
|
||
'"db_password": "quoted-secret"',
|
||
].join('\n'),
|
||
);
|
||
const lines = evidence.split('\n');
|
||
expect(lines.length).toBeLessThanOrEqual(120);
|
||
expect(Math.max(...lines.map((line) => line.length))).toBeLessThanOrEqual(
|
||
300,
|
||
);
|
||
expect(evidence).not.toContain('private-material');
|
||
expect(evidence).not.toContain('bearer-secret');
|
||
expect(evidence).not.toContain('eyJhbGci');
|
||
expect(evidence).not.toContain('abcdef1234567890');
|
||
for (const secret of [
|
||
'ABCDEFGHIJKLMNOP',
|
||
'abcdefghijklmnopqrst',
|
||
'cookie-secret',
|
||
'url-secret',
|
||
'env-secret',
|
||
'quoted-secret',
|
||
]) {
|
||
expect(evidence).not.toContain(secret);
|
||
}
|
||
expect(evidence).toContain('TypeError: ensureTool is not a function');
|
||
});
|
||
|
||
it('keeps fallback errors', () => {
|
||
expect(skillLog('Error: connect ECONNREFUSED')).toContain(
|
||
'Error: connect ECONNREFUSED',
|
||
);
|
||
});
|
||
|
||
it('rejects tampered classifier input before GitHub access', () => {
|
||
const dir = mkdtempSync(join(tmpdir(), 'ci-flaky-rerun-act-'));
|
||
try {
|
||
writeFileSync(join(dir, 'ci-flaky-input.json'), '{"candidates":[]}\n');
|
||
const result = spawnSync(
|
||
process.execPath,
|
||
[
|
||
join(process.cwd(), '.github/scripts/ci-flaky-rerun.mjs'),
|
||
'act',
|
||
'--workdir',
|
||
dir,
|
||
'--input-sha',
|
||
'tampered',
|
||
],
|
||
{ encoding: 'utf8' },
|
||
);
|
||
expect(result.status).toBe(1);
|
||
expect(result.stderr).toContain('integrity check failed');
|
||
} finally {
|
||
rmSync(dir, { recursive: true });
|
||
}
|
||
});
|
||
|
||
it('keeps the failure summary when later tests log expected errors', () => {
|
||
const evidence = skillLog(
|
||
[
|
||
'Failed Tests 1',
|
||
'FAIL toolFormatting.test.ts > translates every tool',
|
||
"AssertionError: expected ['deferred_tool_call'] to deeply equal []",
|
||
...Array.from(
|
||
{ length: 200 },
|
||
() => 'TypeError: fetch failed (expected by this passing test)',
|
||
),
|
||
'Cleaning up orphan processes',
|
||
].join('\n'),
|
||
);
|
||
expect(evidence).toContain("expected ['deferred_tool_call']");
|
||
});
|
||
|
||
it('keeps the primary failure when later summary lines fill the limit', () => {
|
||
const evidence = skillLog(
|
||
[
|
||
'Failed Tests 1',
|
||
'FAIL toolFormatting.test.ts > translates every tool',
|
||
"AssertionError: expected ['deferred_tool_call'] to deeply equal []",
|
||
...Array.from(
|
||
{ length: 200 },
|
||
(_, index) => `npm error cleanup noise ${index}`,
|
||
),
|
||
].join('\n'),
|
||
);
|
||
expect(evidence.split('\n')).toHaveLength(120);
|
||
expect(evidence).toContain("expected ['deferred_tool_call']");
|
||
});
|
||
});
|