From eec6fd3d49a7d110ebacccc820ec83c348a8d535 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 25 Aug 2026 02:07:24 -0300 Subject: [PATCH] fix(OPS97): desarme do auto-merge via GraphQL disablePullRequestAutoMerge com laco verificado (licao do dogfood #905) --- .agents/skills/react-audit/SKILL.md | 19 +++++---- docs/changelog/2026-08-25-ops97.md | 10 +++-- scripts/lib/github-api.mjs | 52 ++++++++++++++++++------ tests/unit/githubApi.unit.spec.ts | 62 +++++++++++++++++++++++------ 4 files changed, 108 insertions(+), 35 deletions(-) diff --git a/.agents/skills/react-audit/SKILL.md b/.agents/skills/react-audit/SKILL.md index e52d68b4..e6b96580 100644 --- a/.agents/skills/react-audit/SKILL.md +++ b/.agents/skills/react-audit/SKILL.md @@ -127,20 +127,25 @@ O PR nasce UMA vez, já estabilizado localmente, e fica SEM auto-merge. 1. `GITHUB_TOKEN= node scripts/github-pr.mjs --head \ --title "react-audit : fixes" --base main --body-file ` — nunca draft; `Refs #N` apenas; jamais `Closes` de Issue alheia. -2. Imediatamente após abrir, DESARME o auto-merge (o safety net - `agent-pr-ready-automerge.yml` arma em TODO PR same-repo→main não-draft): +2. Imediatamente após abrir, DESARME O AUTO-MERGE COM VERIFICAÇÃO EM LAÇO — o safety net + `agent-pr-ready-automerge.yml` arma ASSINCRONAMENTE depois da abertura (na corrida real + capturada no dogfood #905, um desarme único perdeu: o armar aconteceu ENTRE o primeiro + GET e o DELETE; e o endpoint REST `DELETE /pulls/N/auto-merge` responde **404 mesmo + armado-via-GraphQL** — não serve). Use o helper do dono, que codifica essa lição: ```js // node --input-type=module import { githubApi } from './scripts/lib/github-api.mjs' - const pr = await githubApi.getPullRequest() // head.sha confere a branch - await githubApi.disableAutoMerge() // DELETE .../auto-merge; 404 tolerado - const after = await githubApi.getPullRequest() - if (after.autoMerge !== null) throw new Error('auto-merge ainda armado') + const pr = await githubApi.getPullRequest() // confere head.sha = branch + await githubApi.ensureAutoMergeDisabled() // mutation GraphQL disablePullRequestAutoMerge + // + re-poll até auto_merge null (lança se não convergir) ``` + `ensureAutoMergeDisabled` resolve `true` só quando um poll lê `auto_merge = null`; + lança erro nomeando o PR se esgotar as tentativas — nesse caso, trate como falha de Done. + 3. REGRA DURA: todo push pós-abertura (fix de CI vermelho, rebase) é seguido IMEDIATAMENTE - do desarme + verificação acima — cada `synchronize` RE-ARMA o auto-merge silenciosamente. + de `ensureAutoMergeDisabled()` — cada `synchronize` RE-ARMA o auto-merge silenciosamente. ## Passo 6 — Estabilizar até o fim (Done condition) diff --git a/docs/changelog/2026-08-25-ops97.md b/docs/changelog/2026-08-25-ops97.md index b8237a0d..96d79c05 100644 --- a/docs/changelog/2026-08-25-ops97.md +++ b/docs/changelog/2026-08-25-ops97.md @@ -5,7 +5,9 @@ React/Next conferidas contra next 15.4.11 / react ^19.2.4 + `context7-setup.md`) triage per-fix (3 selos: file:line, família, fonte oficial; cap ≤6) → implementação com gate completo bare e um commit por fix → PR único Ready **sem auto-merge** cuja descrição é o relatório consolidado, estabilizado até checks green ∧ mergeable ∧ auto_merge null — a skill para sem mergear (humano revisa). -Mecânica anti-auto-merge: PR nasce uma única vez já estabilizado e o desarme idempotente -(`githubApi.disableAutoMerge`, novo helper REST em `scripts/lib/github-api.mjs` com `auto_merge` -normalizado em `getPullRequest`) é obrigatório após a abertura E após cada push — o safety net -`agent-pr-ready-automerge.yml` re-arma a cada synchronize. +Mecânica anti-auto-merge: PR nasce uma única vez já estabilizado e o desarme verificado em laço +(`githubApi.ensureAutoMergeDisabled`, novo helper GraphQL `disablePullRequestAutoMerge` + +re-poll em `scripts/lib/github-api.mjs`, com `auto_merge` normalizado em `getPullRequest`) é +obrigatório após a abertura E após cada push — o safety net `agent-pr-ready-automerge.yml` +re-arma a cada synchronize, e o endpoint REST DELETE responde 404 mesmo armado-via-GraphQL +(corrida e 404 capturados ao vivo no dogfood #905). diff --git a/scripts/lib/github-api.mjs b/scripts/lib/github-api.mjs index c54e6c12..8525c1f3 100644 --- a/scripts/lib/github-api.mjs +++ b/scripts/lib/github-api.mjs @@ -390,20 +390,48 @@ export const createApi = ({ }, /** - * DELETE /pulls/{number}/auto-merge — disarms GitHub's native auto-merge. - * Tolerates 404 (nothing armed) — idempotent by contract; any other error - * rethrows. The react-audit skill calls this right after opening its PR - * and after every subsequent push (the safety net workflow re-arms on - * each `synchronize`). + * Disarms GitHub's native auto-merge via GraphQL — the same channel the + * `agent-pr-ready-automerge` safety net uses to arm it. The REST + * counterpart (`DELETE /pulls/{number}/auto-merge`) answers 404 even + * when auto-merge IS armed by GraphQL, so it is not usable here + * (found live in the OPS97 dogfood, PR #905). Takes the PR's `nodeId` + * (`getPullRequest(n).nodeId`), mirroring `enableAutoMerge`. */ - disableAutoMerge: async (number) => { - try { - await request(`/repos/${owner}/${name}/pulls/${number}/auto-merge`, { - method: 'DELETE', - }) - } catch (error) { - if (!/\b404\b/.test(String(error?.message ?? ''))) throw error + disableAutoMerge: async (nodeId) => { + await graphql( + ` + mutation DisablePullRequestAutoMerge($id: ID!) { + disablePullRequestAutoMerge(input: { pullRequestId: $id }) { + pullRequest { + number + } + } + } + `, + { id: nodeId }, + ) + }, + + /** + * Disarm-and-verify loop for react-audit's Done condition. The safety + * net arms auto-merge asynchronously AFTER the PR opens (the race is + * real: a single disarm right after opening lost it in the OPS97 + * dogfood), so a bare DELETE/mutation is not enough — poll until + * `auto_merge` reads null or attempts are exhausted. Resolves true + * when verified disarmed; throws naming the PR otherwise. + */ + ensureAutoMergeDisabled: async (number, { attempts = 5, delayMs = 2000 } = {}) => { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const pr = await api.getPullRequest(number) + if (!pr || pr.autoMerge === null) return true + await api.disableAutoMerge(pr.nodeId) + if (attempt < attempts) await sleepImpl(delayMs) } + const final = await api.getPullRequest(number) + if (!final || final.autoMerge === null) return true + throw new Error( + `auto-merge do PR #${number} segue armado após ${attempts} tentativas de desarme`, + ) }, /** diff --git a/tests/unit/githubApi.unit.spec.ts b/tests/unit/githubApi.unit.spec.ts index 601330c9..42e064c5 100644 --- a/tests/unit/githubApi.unit.spec.ts +++ b/tests/unit/githubApi.unit.spec.ts @@ -144,33 +144,71 @@ describe('github-api (issue tracker layer)', () => { expect(pr?.head).toEqual({ ref: '', sha: '' }) }) - it('disableAutoMerge DELETEs the auto-merge endpoint', async () => { - const calls: FetchCall[] = [] + it('disableAutoMerge sends the GraphQL disable mutation with the node id', async () => { + const calls: { url: string; init?: { method?: string; body?: string } }[] = [] const api = createApi({ token: 'tok', fetchImpl: async (url, init) => { calls.push({ url: String(url), init }) - return ok({}) + return ok({ data: { disablePullRequestAutoMerge: { pullRequest: { number: 7 } } } }) }, }) - await api.disableAutoMerge(7) - expect(calls[0].url).toContain('/repos/fsolla/teqo/pulls/7/auto-merge') - expect(calls[0].init?.method).toBe('DELETE') + await api.disableAutoMerge('PR_node7') + expect(calls[0].url).toContain('/graphql') + expect(calls[0].init?.method).toBe('POST') + const payload = JSON.parse(calls[0].init?.body ?? '{}') + expect(payload.query).toContain('disablePullRequestAutoMerge') + expect(payload.variables).toEqual({ id: 'PR_node7' }) }) - it('disableAutoMerge tolerates 404 when nothing is armed', async () => { + it('ensureAutoMergeDisabled converges once a poll sees null', async () => { + let poll = 0 + let disabledCalls = 0 + const sleeps: number[] = [] const api = createApi({ token: 'tok', - fetchImpl: async () => ok({ message: 'Auto-merge request is disabled for this pull request' }, 404), + fetchImpl: async (url, init) => { + if (String(url).includes('/graphql')) { + disabledCalls += 1 + return ok({ data: { disablePullRequestAutoMerge: { pullRequest: { number: 9 } } } }) + } + poll += 1 + // Armed on the first two polls, disarmed on the third. + return ok({ + number: 9, + state: 'open', + draft: false, + node_id: 'PR_node9', + ...(poll < 3 ? { auto_merge: { merge_method: 'rebase' } } : {}), + }) + }, + sleepImpl: async (ms) => { + sleeps.push(ms) + }, }) - await expect(api.disableAutoMerge(9)).resolves.toBeUndefined() + await expect(api.ensureAutoMergeDisabled(9)).resolves.toBe(true) + expect(disabledCalls).toBe(2) + expect(sleeps.length).toBe(2) }) - it('disableAutoMerge rethrows non-404 errors', async () => { + it('ensureAutoMergeDisabled throws after exhausting attempts while still armed', async () => { const api = createApi({ token: 'tok', - fetchImpl: async () => ok({ message: 'nope' }, 422), + fetchImpl: async (url) => { + if (String(url).includes('/graphql')) { + return ok({ data: { disablePullRequestAutoMerge: { pullRequest: { number: 11 } } } }) + } + return ok({ + number: 11, + state: 'open', + draft: false, + node_id: 'PR_node11', + auto_merge: { merge_method: 'rebase' }, + }) + }, + sleepImpl: async () => {}, + retries: 0, }) - await expect(api.disableAutoMerge(11)).rejects.toThrow(/422/) + await expect(api.ensureAutoMergeDisabled(11, { attempts: 3 })).rejects.toThrow(/auto-merge/i) }) })