-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(ci): auto-open a deflake fix issue for confirmed flaky tests #7231
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -15,6 +15,9 @@ const DEFAULT_ACTIVE_DAYS = 7; | |||||||||||||||||||||
| const DEFAULT_MAX_CANDIDATES = 5; | ||||||||||||||||||||||
| const MAX_ACTIONS = 3; | ||||||||||||||||||||||
| const ACTIONS = new Set(['rerun', 'comment', 'no_action']); | ||||||||||||||||||||||
| const DEFLAKE_MARKER = 'qwen-deflake'; | ||||||||||||||||||||||
| const DEFLAKE_LABELS = ['status/ready-for-agent', 'autofix/approved']; | ||||||||||||||||||||||
| const DEFLAKE_MAX_CHARS = 200; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| function timeMs(value) { | ||||||||||||||||||||||
| const ms = Date.parse(value ?? ''); | ||||||||||||||||||||||
|
|
@@ -306,6 +309,88 @@ function validDecision(target, decision) { | |||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| function wellFormedFlakyTest(ft) { | ||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
| !!ft && | ||||||||||||||||||||||
| typeof ft === 'object' && | ||||||||||||||||||||||
| typeof ft.file === 'string' && | ||||||||||||||||||||||
| ft.file.trim().length > 0 && | ||||||||||||||||||||||
| ft.file.length <= DEFLAKE_MAX_CHARS && | ||||||||||||||||||||||
| typeof ft.name === 'string' && | ||||||||||||||||||||||
| ft.name.trim().length > 0 && | ||||||||||||||||||||||
| ft.name.length <= DEFLAKE_MAX_CHARS | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // A backtick cannot be escaped INSIDE an inline code span — it closes the span | ||||||||||||||||||||||
| // and lets the remainder render as live Markdown (mention/markup injection via | ||||||||||||||||||||||
| // a test path or name). Drop backticks so the value stays inert in its span. | ||||||||||||||||||||||
| function codeSpanSafe(value) { | ||||||||||||||||||||||
| return String(value).replaceAll('`', "'"); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export function deflakeKey(flakyTest) { | ||||||||||||||||||||||
| return createHash('sha256') | ||||||||||||||||||||||
| .update(`${flakyTest.file}\u0000${flakyTest.name}`) | ||||||||||||||||||||||
| .digest('hex') | ||||||||||||||||||||||
| .slice(0, 16); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export function deflakeMarker(flakyTest) { | ||||||||||||||||||||||
| return `<!-- ${DEFLAKE_MARKER} key=${deflakeKey(flakyTest)} -->`; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export function deflakeIssueBody(flakyTest, decision, target, repo) { | ||||||||||||||||||||||
| const marker = deflakeMarker(flakyTest); | ||||||||||||||||||||||
| const runUrl = `https://github.com/${ | ||||||||||||||||||||||
| repo ?? target?.repo ?? 'QwenLM/qwen-code' | ||||||||||||||||||||||
| }/actions/runs/${target?.runId ?? ''}`; | ||||||||||||||||||||||
| const file = codeSpanSafe(flakyTest.file); | ||||||||||||||||||||||
| const name = codeSpanSafe(flakyTest.name); | ||||||||||||||||||||||
| return [ | ||||||||||||||||||||||
| `CI Failure Patrol flagged this test as flaky and triggered a rerun on the same commit. If it passes on rerun it is nondeterministic; if it fails again deterministically it is a real bug, not flakiness — in that case do NOT stabilize it.`, | ||||||||||||||||||||||
| ``, | ||||||||||||||||||||||
| `- **Test file:** \`${file}\``, | ||||||||||||||||||||||
| `- **Test name:** \`${name}\``, | ||||||||||||||||||||||
| `- **Observed on:** #${target?.prNumber ?? '?'} (${runUrl})`, | ||||||||||||||||||||||
| `- **Signature:** ${safeReason(decision?.reason_en ?? 'flaky test')}`, | ||||||||||||||||||||||
| ``, | ||||||||||||||||||||||
| `Fix it with \`.qwen/skills/deflake/SKILL.md\` — a minimal, assertion-preserving stabilization. Never weaken, skip, or delete the assertion.`, | ||||||||||||||||||||||
| ``, | ||||||||||||||||||||||
| `<details>`, | ||||||||||||||||||||||
| `<summary>中文说明</summary>`, | ||||||||||||||||||||||
| ``, | ||||||||||||||||||||||
| `CI Failure Patrol 判定此测试疑似 flaky 并在同一 commit 上触发了重跑。重跑通过则为非确定性;若确定性再次失败则是真 bug 而非 flaky —— 那种情况不要稳化它。`, | ||||||||||||||||||||||
| ``, | ||||||||||||||||||||||
| `- **测试文件:** \`${file}\``, | ||||||||||||||||||||||
| `- **用例名:** \`${name}\``, | ||||||||||||||||||||||
| `- **出现于:** #${target?.prNumber ?? '?'}(${runUrl})`, | ||||||||||||||||||||||
| `- **签名:** ${safeReason(decision?.reason_zh ?? 'flaky 测试')}`, | ||||||||||||||||||||||
| ``, | ||||||||||||||||||||||
| `请依据 \`.qwen/skills/deflake/SKILL.md\` 做最小、保留断言的稳化修复,绝不弱化、跳过或删除断言。`, | ||||||||||||||||||||||
| ``, | ||||||||||||||||||||||
| `</details>`, | ||||||||||||||||||||||
| ``, | ||||||||||||||||||||||
| marker, | ||||||||||||||||||||||
| ].join('\n'); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export async function ensureDeflakeIssue(client, target, decision) { | ||||||||||||||||||||||
| if (!wellFormedFlakyTest(decision?.flakyTest)) return; | ||||||||||||||||||||||
| const marker = deflakeMarker(decision.flakyTest); | ||||||||||||||||||||||
| if (await client.hasOpenIssueWithMarker(marker)) return; | ||||||||||||||||||||||
| const title = | ||||||||||||||||||||||
| `deflake: ${decision.flakyTest.file} \u203a ${decision.flakyTest.name}`.slice( | ||||||||||||||||||||||
| 0, | ||||||||||||||||||||||
| 240, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
|
Comment on lines
+382
to
+386
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The title is truncated with UTF-16 code-unit
Suggested change
— qwen3.8-max-preview via Qwen Code /review |
||||||||||||||||||||||
| await client.createIssue({ | ||||||||||||||||||||||
| title, | ||||||||||||||||||||||
| body: deflakeIssueBody(decision.flakyTest, decision, target, client?.repo), | ||||||||||||||||||||||
| labels: DEFLAKE_LABELS, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| function currentFailure(run, target) { | ||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
| run.status === 'completed' && | ||||||||||||||||||||||
|
|
@@ -387,6 +472,18 @@ export async function actOnDecision(client, target, decision) { | |||||||||||||||||||||
| if (decision.action === 'rerun') { | ||||||||||||||||||||||
| await client.rerunFailedJobs(target.runId); | ||||||||||||||||||||||
| await client.comment(target.prNumber, marker); | ||||||||||||||||||||||
| // A flaky TEST (not infra) also gets a deflake issue so the autofix loop | ||||||||||||||||||||||
| // can stabilize it. Best-effort: the rerun + marker already succeeded, so a | ||||||||||||||||||||||
| // transient issue-creation failure must not propagate (it would surface as | ||||||||||||||||||||||
| // a misleading "skipping PR" and, with the marker already posted, | ||||||||||||||||||||||
| // permanently suppress the deflake). Retried on the next flaky occurrence. | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| await ensureDeflakeIssue(client, target, decision); | ||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||
| console.error( | ||||||||||||||||||||||
| `::warning::deflake issue creation failed for #${target.prNumber}; the rerun stands and it retries on the next flaky occurrence: ${error?.message ?? error}`, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } else if (decision.action === 'comment') { | ||||||||||||||||||||||
| await client.comment( | ||||||||||||||||||||||
| target.prNumber, | ||||||||||||||||||||||
|
|
@@ -574,6 +671,39 @@ export class GhClient { | |||||||||||||||||||||
| async jobLog(jobId) { | ||||||||||||||||||||||
| return this.gh(['api', `repos/${this.repo}/actions/jobs/${jobId}/logs`]); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| async hasOpenIssueWithMarker(marker) { | ||||||||||||||||||||||
| const output = await this.gh([ | ||||||||||||||||||||||
| 'issue', | ||||||||||||||||||||||
| 'list', | ||||||||||||||||||||||
| '--repo', | ||||||||||||||||||||||
| this.repo, | ||||||||||||||||||||||
| '--state', | ||||||||||||||||||||||
| 'open', | ||||||||||||||||||||||
| '--search', | ||||||||||||||||||||||
| `${marker} in:body`, | ||||||||||||||||||||||
|
Comment on lines
+683
to
+684
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] — qwen3.8-max-preview via Qwen Code /review |
||||||||||||||||||||||
| '--json', | ||||||||||||||||||||||
| 'number', | ||||||||||||||||||||||
| '--limit', | ||||||||||||||||||||||
| '1', | ||||||||||||||||||||||
| ]); | ||||||||||||||||||||||
| return JSON.parse(output).length > 0; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| async createIssue({ title, body, labels }) { | ||||||||||||||||||||||
| await this.gh([ | ||||||||||||||||||||||
| 'issue', | ||||||||||||||||||||||
| 'create', | ||||||||||||||||||||||
| '--repo', | ||||||||||||||||||||||
| this.repo, | ||||||||||||||||||||||
| '--title', | ||||||||||||||||||||||
| title, | ||||||||||||||||||||||
| '--body', | ||||||||||||||||||||||
| body, | ||||||||||||||||||||||
| '--label', | ||||||||||||||||||||||
| labels.join(','), | ||||||||||||||||||||||
|
Comment on lines
+703
to
+704
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The two new const api = new GhClient('QwenLM/qwen-code');
let args;
api.gh = async (a) => { args = a; return '[]'; };
await api.hasOpenIssueWithMarker('<!-- qwen-deflake key=x -->');
expect(args[args.indexOf('--search') + 1]).toBe('<!-- qwen-deflake key=x --> in:body');
// plus a createIssue case asserting args has '--label' then 'status/ready-for-agent,autofix/approved'— qwen3.8-max-preview via Qwen Code /review |
||||||||||||||||||||||
| ]); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export function argsMap(argv) { | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| --- | ||
| name: deflake | ||
| description: Stabilize a flaky test with a minimal, assertion-preserving fix — never by weakening or deleting the check. | ||
| --- | ||
|
|
||
| # Deflake a flaky test | ||
|
|
||
| A `deflake:` issue names ONE test that has been observed failing and then | ||
| passing on a rerun of the same commit — the definitive flaky signature. Your | ||
| job is to make that test deterministic **without changing what it verifies**. | ||
|
|
||
| The issue body carries the test identity (file + name) and the observed failure | ||
| signature (e.g. `Test timed out in 5000ms`, `Timed out waiting for …`, an | ||
| order-dependent assertion, a wall-clock/random-dependent value). Read the test, | ||
| reproduce the mechanism in your head, and apply the SMALLEST fix from the | ||
| allowed set below that removes the nondeterminism. | ||
|
|
||
| ## The only allowed fixes | ||
|
|
||
| 1. **Raise a timeout / poll budget.** A test that blows vitest's default under | ||
| CI contention (fully-mocked or I/O-bound, not a perf test) gets a generous | ||
| per-test `testTimeout` (3rd arg to `it`), or its internal poll loop is given | ||
| a real wall-clock budget instead of a fixed iteration count (a fixed count of | ||
| `setImmediate` turns elapses in milliseconds and races real I/O). | ||
| 2. **Stabilize timing / waiting.** Replace a bare `setTimeout`/fixed `sleep` | ||
| with an explicit `await` of the real condition (`vi.waitFor`, a resolved | ||
| promise, an event). Pre-warm a lazy load (e.g. a WASM runtime) in | ||
| `beforeAll` so per-test time doesn't include first-load cost. | ||
| 3. **Make randomness / time deterministic.** Seed the RNG, `vi.useFakeTimers()` | ||
| / mock `Date.now`, or pin the input so a value that depends on the real clock | ||
| or `Math.random` can't drift. | ||
| 4. **Isolate / serialize interference.** Give tests that collide on a shared | ||
| resource (a same-named tempdir, a fixed port, a global singleton) unique | ||
| per-test resources, or serialize them. | ||
|
|
||
| ## Hard rules | ||
|
|
||
| - **Never** delete the test, `skip`/`todo` it, loosen an assertion, widen an | ||
| expected range, add a blanket `try/catch`, or add a retry wrapper around the | ||
| assertion. Those hide the flake instead of fixing it — and could hide a real | ||
| bug. If none of the four fixes applies, or the failure looks like a REAL | ||
| intermittent product bug (not test nondeterminism), write | ||
| `<workdir>/failure.md` explaining what you found and stop. A human deflakes it. | ||
| - Keep the diff minimal and local to the named test (and its file's helpers). | ||
| Do not refactor unrelated code. | ||
| - Preserve every assertion and every input exactly. A timeout bump changes only | ||
| the ceiling; a determinism fix changes only the source of nondeterminism. | ||
| - Prefer a per-test or per-file change over a global config change unless the | ||
| same class demonstrably spans the whole package (then a `testTimeout` in that | ||
| package's `vitest.config.ts` is acceptable, as it only raises the ceiling and | ||
| weakens no assertion). | ||
|
|
||
| ## Verify | ||
|
|
||
| Run the named test's file several times (`npx vitest run <file>` in the right | ||
| package, repeated) — it must pass every time. Then run the standard verify gate | ||
| (build / typecheck / lint / the changed test). If you cannot make it pass | ||
| deterministically, write `<workdir>/failure.md` and stop. | ||
|
|
||
| Then follow `.qwen/skills/prepare-pr/SKILL.md` for the PR body and write the | ||
| bilingual `<workdir>/e2e-report.md` (per the Shared Rules) stating: the flaky | ||
| mechanism, which of the four fixes you applied and why, and the repeated-run | ||
| evidence that it is now deterministic. |
Uh oh!
There was an error while loading. Please reload this page.