mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:25:20 +00:00
fix(OPS97): desarme do auto-merge via GraphQL disablePullRequestAutoMerge com laco verificado (licao do dogfood #905)
This commit is contained in:
parent
3cfbfeac0e
commit
eec6fd3d49
4 changed files with 108 additions and 35 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue