qwen-code/.github/scripts/ci-flaky-rerun.mjs
Shaojin Wen 076427650d
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
feat(ci): auto-open a deflake fix issue for confirmed flaky tests (#7231)
* feat(ci): auto-open a deflake fix issue for confirmed flaky tests

The CI Failure Patrol reruns flaky failures but never fixes them, so
the same tests flake forever on a rerun treadmill. This closes the
loop: when the patrol classifies a rerun as a nondeterministic TEST
(not infra), it now also opens ONE deflake issue that the existing
autofix issue pipeline develops into a reviewable stabilization PR.

- ci-flaky-patrol SKILL: a rerun decision whose cause is a specific
  named flaky test carries an optional flakyTest {file, name}; infra
  reruns (ENOSPC, network, runner death) never do.
- ci-flaky-rerun.mjs: validates flakyTest (malformed → the whole
  decision is rejected, so a bad classification can't open a bogus
  issue); after a rerun, ensureDeflakeIssue upserts a deflake issue
  deduped by a stable (file, name) marker — one open issue per flaky
  test across all PRs — labeled status/ready-for-agent + autofix/
  approved so the scheduled autofix scan picks it up.
- .qwen/skills/deflake/SKILL.md: constrains the fix to four
  assertion-preserving patterns (raise timeout/poll budget, stabilize
  timing/waiting, make randomness/time deterministic, isolate
  interference) and forbids skipping/deleting/loosening the check;
  write failure.md if none applies or the failure looks like a real
  bug. The produced PR is reviewable, never auto-merged.

Tests: deflakeKey stability/collision-freedom, the bilingual issue
body, one-issue-per-test dedup, no issue for infra reruns, and
malformed-flakyTest rejection. 34/34 across both patrol suites.

* fix(ci): deflake review hardening — rerun survives bad metadata, no markup injection

Addresses the two Criticals + suggestions on #7231:

- **Critical: a malformed/over-length flakyTest no longer kills the
  rerun.** flakyTest validation is removed from validDecision (which
  gated the PRIMARY action on secondary metadata — a >200-char nested
  test name or a null silently dropped a valid rerun). Well-formedness
  is now checked in ensureDeflakeIssue, which simply skips the deflake
  issue when the metadata is bad; the rerun always stands.
- **Critical: markup/mention injection via the test path/name.** file
  and name are code-span-stripped of backticks (which cannot be escaped
  inside a span and would break out into live Markdown, turning
   into a mention in a bot-created issue) and both now sit in
  code spans. safeReason alone did not close this (it does not touch
  backticks).
- Best-effort deflake: ensureDeflakeIssue is wrapped in try/catch so a
  transient createIssue failure — after the marker is already posted —
  no longer surfaces as a misleading "skipping PR" and permanently
  suppresses the deflake; it retries on the next flaky occurrence.
- Run link uses the patrol's own repo (client.repo) instead of the dead
  target.repo, so deflake issues on a fork don't 404.
- Body reworded: it no longer claims the rerun already passed (it runs
  right after the rerun is triggered) — it says a real deterministic
  failure is NOT flakiness and must not be stabilized.
- SKILL: bound file/name to 200 chars, and note a malformed one is
  ignored (never drops the rerun).

Tests: malformed flakyTest keeps rerun (no createIssue); long title
truncates ≤240; backtick path/name cannot inject; run link honors the
repo; a throwing createIssue leaves the rerun intact. 38/38.

---------

Co-authored-by: wenshao <wenshao@example.com>
2026-07-19 16:49:29 +00:00

884 lines
27 KiB
JavaScript

#!/usr/bin/env node
import { execFile as execFileCallback } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
const execFile = promisify(execFileCallback);
const TARGET_WORKFLOW = 'Qwen Code CI';
const MARKER = 'qwen-ci-flaky-rerun';
const DEFAULT_STALE_MINUTES = 30;
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 ?? '');
return Number.isFinite(ms) ? ms : 0;
}
function runIdFromUrl(url) {
const match = String(url ?? '').match(/\/actions\/runs\/(\d+)/);
return match ? Number(match[1]) : null;
}
function jobIdFromUrl(url) {
const match = String(url ?? '').match(/\/jobs?\/(\d+)/);
return match ? Number(match[1]) : null;
}
function checkId(check) {
return (
Number(check.databaseId) ||
jobIdFromUrl(check.detailsUrl) ||
runIdFromUrl(check.detailsUrl) ||
0
);
}
function isNewerCheck(check, current) {
const id = checkId(check);
const currentId = checkId(current);
if (id && currentId && id !== currentId) return id > currentId;
return (
Math.max(timeMs(check.completedAt), timeMs(check.startedAt)) >
Math.max(timeMs(current.completedAt), timeMs(current.startedAt))
);
}
function latestChecks(pr) {
const checks = new Map();
for (const check of pr.statusCheckRollup ?? []) {
const key = `${check.workflowName}/${check.name}`;
const current = checks.get(key);
if (!current || isNewerCheck(check, current)) checks.set(key, check);
}
return [...checks.values()];
}
function toTarget(pr, check) {
const runId = runIdFromUrl(check.detailsUrl);
if (runId === null) return null;
return {
prNumber: pr.number,
headSha: pr.headRefOid,
runId,
jobId: jobIdFromUrl(check.detailsUrl),
workflowName: check.workflowName,
checkName: check.name,
completedAt: check.completedAt,
};
}
function isEligibleFailure(check, now, staleMinutes, activeDays) {
const age = timeMs(now) - timeMs(check.completedAt);
return (
check.workflowName === TARGET_WORKFLOW &&
check.status === 'COMPLETED' &&
['FAILURE', 'TIMED_OUT'].includes(check.conclusion) &&
age >= staleMinutes * 60_000 &&
age <= activeDays * 86_400_000
);
}
export function selectCandidateTargets(prs, options = {}) {
const now = options.now ?? new Date();
const staleMinutes = options.staleMinutes ?? DEFAULT_STALE_MINUTES;
const activeDays = options.activeDays ?? DEFAULT_ACTIVE_DAYS;
const targets = [];
for (const pr of prs) {
if (pr.isDraft || pr.baseRefName !== 'main') continue;
for (const check of latestChecks(pr)) {
if (!isEligibleFailure(check, now, staleMinutes, activeDays)) continue;
const target = toTarget(pr, check);
if (target) targets.push(target);
}
}
return targets.sort((a, b) => timeMs(b.completedAt) - timeMs(a.completedAt));
}
function markerFor(target, action, count) {
return `<!-- ${MARKER} v=5 pr=${target.prNumber} head=${target.headSha} run=${target.runId} attempt=${target.runAttempt} workflow=${encodeURIComponent(target.workflowName)} check=${encodeURIComponent(target.checkName)} action=${action} key=${target.failureKey} count=${count} -->`;
}
function parseMarker(comment) {
try {
const match = String(comment.body ?? '').match(
/<!--\s*qwen-ci-flaky-rerun\s+v=5\s+([^]*?)\s*-->/,
);
if (!match) return null;
const tokens = match[1].split(/\s+/);
if (tokens.some((field) => field.indexOf('=') <= 0)) return null;
const fields = Object.fromEntries(
tokens.map((field) => {
const separator = field.indexOf('=');
return [field.slice(0, separator), field.slice(separator + 1)];
}),
);
const state = {
prNumber: Number(fields.pr),
headSha: fields.head,
runId: Number(fields.run),
runAttempt: Number(fields.attempt),
workflowName: decodeURIComponent(fields.workflow ?? ''),
checkName: decodeURIComponent(fields.check ?? ''),
action: fields.action,
failureKey: fields.key,
count: Number(fields.count),
createdAt: comment.createdAt,
};
return Number.isInteger(state.prNumber) &&
Number.isInteger(state.runId) &&
Number.isInteger(state.runAttempt) &&
Number.isInteger(state.count) &&
state.count >= 0 &&
state.headSha &&
state.workflowName &&
state.checkName &&
state.failureKey
? state
: null;
} catch {
return null;
}
}
function trustedStates(comments, trustedMarkerLogin) {
return comments
.filter((comment) => comment.author?.login === trustedMarkerLogin)
.map(parseMarker)
.filter(Boolean)
.sort((a, b) => timeMs(a.createdAt) - timeMs(b.createdAt));
}
export function alreadyHandled(comments, target, trustedMarkerLogin) {
return trustedStates(comments, trustedMarkerLogin).some(
(state) =>
state.prNumber === target.prNumber &&
state.headSha === target.headSha &&
state.runId === target.runId &&
state.runAttempt === target.runAttempt &&
state.workflowName === target.workflowName &&
state.checkName === target.checkName &&
state.failureKey === target.failureKey,
);
}
function succeededAfter(pr, state) {
return (pr.statusCheckRollup ?? []).some(
(check) =>
check.workflowName === state.workflowName &&
check.name === state.checkName &&
runIdFromUrl(check.detailsUrl) === state.runId &&
check.status === 'COMPLETED' &&
check.conclusion === 'SUCCESS' &&
timeMs(check.completedAt) > timeMs(state.createdAt),
);
}
export function currentActionCount(pr, comments, trustedMarkerLogin) {
const states = trustedStates(comments, trustedMarkerLogin).filter(
(state) => state.prNumber === pr.number && state.headSha === pr.headRefOid,
);
const latest = states.at(-1);
if (!latest || latest.count === 0 || succeededAfter(pr, latest)) return 0;
return latest.count;
}
function redactLog(log) {
return log
.replace(
/-----BEGIN [^-\r\n]+-----[\s\S]*?-----END [^-\r\n]+-----/g,
'[redacted private key]',
)
.replace(
/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g,
'[redacted jwt]',
)
.replace(/((?:Set-)?Cookie:\s*)[^\r\n]*/gi, '$1[redacted]')
.replace(/(Authorization:\s*)[^\r\n]*/gi, '$1[redacted]')
.replace(/(Bearer\s+)\S+/gi, '$1[redacted]')
.replace(
/(?:gh[pousr]_|github_pat_|glpat-|xox[bp]-)[A-Za-z0-9_-]{8,}/g,
'[redacted]',
)
.replace(/\bsk-(?:proj-)?[A-Za-z0-9-]{20,}/g, 'sk-[redacted]')
.replace(/(?:AKIA|ASIA)[A-Z0-9]{16}/g, '[redacted]')
.replace(/\bnpm_[A-Za-z0-9]{20,}/g, '[redacted]')
.replace(/\b([a-z][a-z0-9+.-]*:\/\/)(?:[^/\s]+@)+/gi, '$1[redacted]@')
.replace(
/(["']?)([A-Za-z_][A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|AUTH|CREDENTIAL)[A-Za-z0-9_]*)(\1\s*[:=]\s*)["']?[^"',\s}]+["']?/gi,
'$1$2$3[redacted]',
);
}
export function skillLog(log) {
const lines = redactLog(log).split('\n');
const selected = new Set();
const primary = new Set();
const summary =
/##\[error\]|failed tests|\sFAIL\s|AssertionError|error TS\d{4}|❌|npm error|lifecycle script .* failed/i;
let matches = lines.flatMap((line, index) =>
summary.test(line) ? [index] : [],
);
if (matches.length === 0) {
const fallback = /(?:Type|Reference|Syntax)?Error:|fatal|timed? ?out/i;
matches = lines.flatMap((line, index) =>
fallback.test(line) ? [index] : [],
);
}
for (const [matchIndex, index] of matches.entries()) {
const before = lines[index].includes('##[error]') ? 20 : 3;
for (let context = index - before; context <= index + 3; context += 1) {
if (context < 0 || context >= lines.length) continue;
selected.add(context);
if (matchIndex === 0) primary.add(context);
}
}
for (
let index = Math.max(0, lines.length - 20);
index < lines.length;
index += 1
) {
selected.add(index);
}
const remaining = [...selected]
.filter((index) => !primary.has(index))
.sort((a, b) => a - b)
.slice(-(120 - primary.size));
return [...primary, ...remaining]
.sort((a, b) => a - b)
.map((index) => lines[index].slice(0, 300))
.join('\n');
}
export function fingerprint(target, log) {
const normalized = log
.toLowerCase()
.replace(/\b\d+\b/g, '<n>')
.replace(/\b(?:0x)?[a-f0-9]{7,40}\b/g, '<hex>')
.replace(/\s+/g, ' ')
.trim();
return `check-${createHash('sha256')
.update(`${target.workflowName}\n${target.checkName}\n${normalized}`)
.digest('hex')
.slice(0, 16)}`;
}
function latestMatchingTarget(pr, target) {
const latest = latestChecks(pr).find(
(check) =>
check.workflowName === target.workflowName &&
check.name === target.checkName,
);
return latest ? toTarget(pr, latest) : null;
}
function validDecision(target, decision) {
if (!decision || typeof decision !== 'object') return false;
if (!ACTIONS.has(decision.action)) return false;
if (!['high', 'low'].includes(decision.confidence)) return false;
if (decision.action !== 'no_action' && decision.confidence !== 'high') {
return false;
}
if (
typeof decision.reason_en !== 'string' ||
!decision.reason_en.trim() ||
decision.reason_en.length > 200 ||
typeof decision.reason_zh !== 'string' ||
!decision.reason_zh.trim() ||
decision.reason_zh.length > 200
) {
return false;
}
return (
decision.prNumber === target.prNumber &&
decision.headSha === target.headSha &&
decision.runId === target.runId &&
decision.runAttempt === target.runAttempt &&
decision.failureKey === target.failureKey
);
}
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,
);
await client.createIssue({
title,
body: deflakeIssueBody(decision.flakyTest, decision, target, client?.repo),
labels: DEFLAKE_LABELS,
});
}
function currentFailure(run, target) {
return (
run.status === 'completed' &&
['failure', 'timed_out'].includes(run.conclusion) &&
run.head_sha === target.headSha &&
run.run_attempt === target.runAttempt
);
}
export function eligibleAttemptJob(job, target, runAttempt, options = {}) {
const now = options.now ?? new Date();
const staleMinutes = options.staleMinutes ?? DEFAULT_STALE_MINUTES;
const activeDays = options.activeDays ?? DEFAULT_ACTIVE_DAYS;
return (
Number(job?.id) === target.jobId &&
Number(job?.run_id) === target.runId &&
Number(job?.run_attempt) === runAttempt &&
job?.head_sha === target.headSha &&
job?.name === target.checkName &&
isEligibleFailure(
{
workflowName: target.workflowName,
status: String(job?.status ?? '').toUpperCase(),
conclusion: String(job?.conclusion ?? '').toUpperCase(),
completedAt: job?.completed_at,
},
now,
staleMinutes,
activeDays,
)
);
}
function safeReason(reason) {
return reason
.trim()
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('@', '@\u200b');
}
export async function actOnDecision(client, target, decision) {
if (!target || !validDecision(target, decision)) return;
const isCurrentTarget = async () => {
const pr = await client.currentPr(target.prNumber);
const current = latestMatchingTarget(pr, target);
return {
current:
pr.state === 'OPEN' &&
!pr.isDraft &&
pr.baseRefName === 'main' &&
pr.headRefOid === target.headSha &&
current?.runId === target.runId &&
current?.jobId === target.jobId,
pr,
};
};
let state = await isCurrentTarget();
if (!state.current) return;
const comments = await client.comments(target.prNumber);
if (
alreadyHandled(comments, target, client.trustedMarkerLogin) ||
currentActionCount(state.pr, comments, client.trustedMarkerLogin) >=
MAX_ACTIONS
) {
return;
}
state = await isCurrentTarget();
if (!state.current) return;
if (!currentFailure(await client.run(target.runId), target)) return;
const count =
currentActionCount(state.pr, comments, client.trustedMarkerLogin) + 1;
const marker = markerFor(target, decision.action, count);
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,
`CI is failing because: ${safeReason(decision.reason_en)}\n\n<details>\n<summary>中文说明</summary>\n\n${safeReason(decision.reason_zh)}\n\n</details>\n\n${marker}`,
);
} else {
await client.comment(target.prNumber, marker);
}
}
function decisionKey(value) {
return [
value?.prNumber,
value?.headSha,
value?.runId,
value?.runAttempt,
value?.failureKey,
].join(':');
}
export async function actOnDecisions(client, targets, decisions) {
if (!Array.isArray(targets) || !Array.isArray(decisions)) return;
const byKey = new Map(targets.map((target) => [decisionKey(target), target]));
const handled = new Set();
for (const decision of decisions) {
const key = decisionKey(decision);
if (handled.has(key)) continue;
handled.add(key);
try {
const target = byKey.get(key);
if (!target) {
process.stderr.write(
`act: no candidate for decision on PR ${decision?.prNumber ?? 'unknown'}\n`,
);
continue;
}
await actOnDecision(client, target, decision);
} catch (error) {
process.stderr.write(
`act: skipping PR ${decision?.prNumber ?? 'unknown'}: ${error.message}${error.stderr ? `\n${String(error.stderr).trim()}` : ''}\n`,
);
}
}
}
export async function resetSuccessfulFailures(client, prs) {
for (const pr of prs) {
try {
const comments = await client.comments(pr.number);
const state = trustedStates(comments, client.trustedMarkerLogin)
.filter(
(item) =>
item.prNumber === pr.number && item.headSha === pr.headRefOid,
)
.at(-1);
if (!state || state.count === 0 || !succeededAfter(pr, state)) continue;
await client.comment(pr.number, markerFor(state, 'reset', 0));
} catch (error) {
process.stderr.write(
`reset: skipping PR ${pr.number}: ${error.message}\n`,
);
}
}
}
export function fileSha256(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex');
}
export class GhClient {
constructor(repo, trustedMarkerLogin) {
this.repo = repo;
this.trustedMarkerLogin = trustedMarkerLogin;
}
async gh(args) {
const { stdout } = await execFile('gh', args, {
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
timeout: 60_000,
});
return stdout;
}
async currentLogin() {
return (await this.gh(['api', 'user', '--jq', '.login'])).trim();
}
async prs(activeDays = DEFAULT_ACTIVE_DAYS) {
const since = new Date(Date.now() - activeDays * 86_400_000)
.toISOString()
.slice(0, 10);
return this.prList(`status:failure updated:>=${since}`);
}
async prsWithMarkers() {
return this.prList(`in:comments ${MARKER}`);
}
async prList(search) {
return JSON.parse(
await this.gh([
'pr',
'list',
'--repo',
this.repo,
'--base',
'main',
'--state',
'open',
'--search',
search,
'--json',
'number,isDraft,baseRefName,headRefOid,statusCheckRollup',
'--limit',
'1000',
]),
);
}
async comments(prNumber) {
const output = await this.gh([
'api',
'--paginate',
`repos/${this.repo}/issues/${prNumber}/comments`,
'--jq',
'.[] | {body, createdAt: .created_at, author: {login: .user.login}}',
]);
return output.trim()
? output
.trim()
.split('\n')
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line))
: [];
}
async currentPr(prNumber) {
return JSON.parse(
await this.gh([
'pr',
'view',
String(prNumber),
'--repo',
this.repo,
'--json',
'number,headRefOid,state,isDraft,baseRefName,statusCheckRollup,files',
]),
);
}
async run(runId) {
return JSON.parse(
await this.gh(['api', `repos/${this.repo}/actions/runs/${runId}`]),
);
}
async job(jobId) {
return JSON.parse(
await this.gh(['api', `repos/${this.repo}/actions/jobs/${jobId}`]),
);
}
async rerunFailedJobs(runId) {
await this.gh([
'api',
'-X',
'POST',
`repos/${this.repo}/actions/runs/${runId}/rerun-failed-jobs`,
]);
}
async comment(prNumber, body) {
await this.gh([
'pr',
'comment',
String(prNumber),
'--repo',
this.repo,
'--body',
body,
]);
}
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`,
'--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(','),
]);
}
}
export function argsMap(argv) {
const args = new Map();
for (let index = 0; index < argv.length; index += 2) {
if (!argv[index]?.startsWith('--')) {
throw new Error(`unexpected argument: ${argv[index] ?? ''}`);
}
if (argv[index + 1] === undefined) {
throw new Error(`missing value for ${argv[index]}`);
}
args.set(argv[index].replace(/^--/, ''), argv[index + 1]);
}
return args;
}
function requiredArg(args, name) {
const value = args.get(name);
if (!value) throw new Error(`--${name} is required`);
return value;
}
function numberArg(args, name, fallback) {
const value = Number(args.get(name) ?? fallback);
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`--${name} must be a positive number`);
}
return value;
}
function writeJson(workdir, name, value) {
mkdirSync(workdir, { recursive: true });
const path = resolve(workdir, name);
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
return path;
}
function runStillCurrent(run, target, runAttempt) {
return (
run.status === 'completed' &&
['failure', 'timed_out'].includes(run.conclusion) &&
run.head_sha === target.headSha &&
run.run_attempt === runAttempt
);
}
async function scan(args) {
const workdir = requiredArg(args, 'workdir');
const repo = requiredArg(args, 'repo');
const activeDays = numberArg(args, 'active-days', DEFAULT_ACTIVE_DAYS);
const staleMinutes = numberArg(args, 'stale-minutes', DEFAULT_STALE_MINUTES);
const maxCandidates = numberArg(
args,
'max-candidates',
DEFAULT_MAX_CANDIDATES,
);
const client = new GhClient(repo);
const trustedLogin =
args.get('trusted-marker-login') ?? (await client.currentLogin());
client.trustedMarkerLogin = trustedLogin;
const targets = selectCandidateTargets(await client.prs(activeDays), {
activeDays,
staleMinutes,
});
const candidates = [];
const selectedPrs = new Set();
for (const target of targets) {
if (candidates.length >= maxCandidates) break;
if (selectedPrs.has(target.prNumber)) continue;
try {
if (target.jobId === null) continue;
const before = await client.run(target.runId);
if (!runStillCurrent(before, target, before.run_attempt)) continue;
const job = await client.job(target.jobId);
if (
!eligibleAttemptJob(job, target, before.run_attempt, {
activeDays,
staleMinutes,
})
) {
continue;
}
const log = skillLog(await client.jobLog(target.jobId));
const candidate = {
...target,
completedAt: job.completed_at,
runAttempt: before.run_attempt,
failureKey: fingerprint(target, log),
};
const comments = await client.comments(target.prNumber);
const pr = await client.currentPr(target.prNumber);
const current = latestMatchingTarget(pr, target);
const after = await client.run(target.runId);
if (
pr.state !== 'OPEN' ||
pr.isDraft ||
pr.baseRefName !== 'main' ||
pr.headRefOid !== target.headSha ||
current?.runId !== target.runId ||
current?.jobId !== target.jobId ||
!runStillCurrent(after, target, before.run_attempt) ||
alreadyHandled(comments, candidate, trustedLogin)
) {
continue;
}
const actionCount = currentActionCount(pr, comments, trustedLogin);
if (actionCount >= MAX_ACTIONS) continue;
candidates.push({
...candidate,
actionCount,
changedFiles: (pr.files ?? []).map((file) => file.path).slice(0, 100),
log,
});
selectedPrs.add(target.prNumber);
} catch (error) {
const stderr = error.stderr ? `\n${String(error.stderr).trim()}` : '';
process.stderr.write(
`scan: skipping PR ${target.prNumber}: ${error.message}${stderr}\n`,
);
}
}
const path = writeJson(workdir, 'ci-flaky-input.json', { candidates });
process.stdout.write(`target_found=${candidates.length > 0}\n`);
process.stdout.write(`input_sha=${fileSha256(path)}\n`);
}
async function act(args) {
const workdir = requiredArg(args, 'workdir');
const inputPath = resolve(workdir, 'ci-flaky-input.json');
if (fileSha256(inputPath) !== requiredArg(args, 'input-sha')) {
throw new Error('ci-flaky-input.json integrity check failed');
}
const repo = requiredArg(args, 'repo');
const client = new GhClient(
repo,
args.get('trusted-marker-login') ?? undefined,
);
if (!client.trustedMarkerLogin) {
client.trustedMarkerLogin = await client.currentLogin();
}
const { candidates } = JSON.parse(readFileSync(inputPath, 'utf8'));
const { decisions } = JSON.parse(
readFileSync(resolve(workdir, 'ci-flaky-decisions.json'), 'utf8'),
);
await actOnDecisions(client, candidates, decisions);
}
async function reset(args) {
const repo = requiredArg(args, 'repo');
const client = new GhClient(
repo,
args.get('trusted-marker-login') ?? undefined,
);
if (!client.trustedMarkerLogin) {
client.trustedMarkerLogin = await client.currentLogin();
}
await resetSuccessfulFailures(client, await client.prsWithMarkers());
}
async function main() {
const [command, ...rest] = process.argv.slice(2);
const args = argsMap(rest);
if (command === 'scan') return scan(args);
if (command === 'act') return act(args);
if (command === 'reset') return reset(args);
throw new Error('command must be scan, act, or reset');
}
if (import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
main().catch((error) => {
console.error(
error.stderr ? `${error.message}\n${error.stderr}` : error.message,
);
process.exit(1);
});
}