diff --git a/scripts/merge-candidate.mjs b/scripts/merge-candidate.mjs index 87ae3373..ce3c98a0 100755 --- a/scripts/merge-candidate.mjs +++ b/scripts/merge-candidate.mjs @@ -101,9 +101,9 @@ const sh = (cmd, args, opts = {}) => { return { status: r.status ?? 1, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() }; }; -export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now }) { +export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, exec = sh }) { const pushUrl = `https://x-access-token:${token}@github.com/${repo}`; - const git = (...args) => sh('git', args); + const git = (...args) => exec('git', args); const must = (r, what) => { if (r.status !== 0) throw new Error(`${what}: ${r.stderr || r.stdout}`); return r.stdout; }; return { fetch: (...refs) => must(git('fetch', '-q', 'origin', ...refs), 'git fetch'), @@ -131,14 +131,14 @@ export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) = // новое дерево, поэтому слияние запускает Validate с мутантами само и ждёт // именно этот dispatch-прогон; push-прогон на том же SHA их не содержит. dispatchValidate: (ref) => { - const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']); + const r = exec('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']); if (r.status !== 0) throw new Error(`gh workflow run ${workflow}: ${r.stderr || r.stdout}`); }, waitValidate: async (sha, { event = 'workflow_dispatch' } = {}) => { const started = now(); let runId = null; while (now() - started < VALIDATE_TOTAL_MS) { - const r = sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url,event', '--limit', '10']); + const r = exec('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url,event', '--limit', '10']); const all = r.status === 0 && r.stdout ? JSON.parse(r.stdout) : []; // Отменённый прогон ничего не доказывает (#511): его заменил следующий // dispatch на той же ветке — ждём его, а не красим кандидата. diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 53acc115..3d23e4f5 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -8158,6 +8158,17 @@ const MUTANT_DEFINITIONS = [ replace: " if (false) { // mutant: cancelled counts as red", }], }, + { + id: 'merge-trusts-cancelled-dispatch', + guard: 'node --test test/merge-candidate.test.mjs', + because: 'the real waitValidate must skip a dispatch cancelled by its replacement; reading it as red ' + + 'fails the merge candidate for nothing (#510 review r2 M1, #511)', + patches: [{ + file: 'scripts/merge-candidate.mjs', + find: " const runs = all.filter((x) => (!event || x.event === event) && x.conclusion !== 'cancelled');", + replace: " const runs = all.filter((x) => (!event || x.event === event)); // mutant: cancelled is red", + }], + }, { id: 'review-trusts-push-run-without-mutants', guard: 'node --test test/validate-gate.test.mjs', diff --git a/test/merge-candidate.test.mjs b/test/merge-candidate.test.mjs index f34ed895..942feb6a 100755 --- a/test/merge-candidate.test.mjs +++ b/test/merge-candidate.test.mjs @@ -226,3 +226,42 @@ test('на настоящем git: чистый ребейз с равным pat rmSync(dir, { recursive: true, force: true }); } }); + +// ---------- #510 code review r2 M1: the real waitValidate, not a fake ---------- + +/** gh scripted by call: each `gh run list` answer is the next snapshot. */ +function scriptedExec(snapshots) { + let calls = 0; + return { + exec: (cmd, args) => { + if (cmd === 'gh' && args[0] === 'run' && args[1] === 'list') { + const s = snapshots[Math.min(calls, snapshots.length - 1)]; + calls += 1; + return { status: 0, stdout: JSON.stringify(s), stderr: '' }; + } + throw new Error(`unexpected ${cmd} ${args.join(' ')}`); + }, + calls: () => calls, + }; +} + +test('#510 r2 M1: realOps.waitValidate ignores a cancelled dispatch and follows its replacement', async () => { + const cancelled = { databaseId: 1, status: 'completed', conclusion: 'cancelled', url: 'https://run/1', event: 'workflow_dispatch' }; + const push = { databaseId: 2, status: 'completed', conclusion: 'success', url: 'https://run/2', event: 'push' }; + const replacement = { databaseId: 3, status: 'completed', conclusion: 'success', url: 'https://run/3', event: 'workflow_dispatch' }; + const gh = scriptedExec([[cancelled, push], [cancelled, push], [replacement, cancelled, push]]); + let clock = 0; + const ops = realOps({ repo: 'x/y', token: 'none', exec: gh.exec, sleep: async (ms) => { clock += ms; }, now: () => clock }); + const r = await ops.waitValidate('c'.repeat(40), { event: 'workflow_dispatch' }); + assert.deepEqual(r, { result: 'green', url: 'https://run/3' }); + assert.equal(gh.calls(), 3, 'kept polling past the cancelled run instead of returning red on the first answer'); +}); + +test('#510 r2 M1: realOps.waitValidate with only a cancelled dispatch reports missing after the appear window, never red', async () => { + const cancelled = { databaseId: 1, status: 'completed', conclusion: 'cancelled', url: 'https://run/1', event: 'workflow_dispatch' }; + const gh = scriptedExec([[cancelled]]); + let clock = 0; + const ops = realOps({ repo: 'x/y', token: 'none', exec: gh.exec, sleep: async (ms) => { clock += ms; }, now: () => clock }); + const r = await ops.waitValidate('c'.repeat(40), { event: 'workflow_dispatch' }); + assert.equal(r.result, 'missing'); +});