fix(OPS97): desarme do auto-merge via GraphQL disablePullRequestAutoMerge com laco verificado (licao do dogfood #905)

This commit is contained in:
Francisco Solla 2026-08-25 02:07:24 -03:00
parent 3cfbfeac0e
commit eec6fd3d49
4 changed files with 108 additions and 35 deletions

View file

@ -127,20 +127,25 @@ O PR nasce UMA vez, já estabilizado localmente, e fica SEM auto-merge.
1. `GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs --head <branch> \
--title "react-audit <data>: <N> fixes" --base main --body-file <relatorio.md>`
— 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(<N>) // head.sha confere a branch
await githubApi.disableAutoMerge(<N>) // DELETE .../auto-merge; 404 tolerado
const after = await githubApi.getPullRequest(<N>)
if (after.autoMerge !== null) throw new Error('auto-merge ainda armado')
const pr = await githubApi.getPullRequest(<N>) // confere head.sha = branch
await githubApi.ensureAutoMergeDisabled(<N>) // 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(<N>)` — cada `synchronize` RE-ARMA o auto-merge silenciosamente.
## Passo 6 — Estabilizar até o fim (Done condition)

View file

@ -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).

View file

@ -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`,
)
},
/**

View file

@ -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)
})
})