refactor(OPS97): fixes do /simplify — fail-closed em PR inexistente, errata do plano, token neutro na skill

This commit is contained in:
Francisco Solla 2026-08-25 02:26:32 -03:00
parent 50cb313a8e
commit 22bf8dcfd1
5 changed files with 26 additions and 10 deletions

View file

@ -50,7 +50,7 @@ Tudo aqui é leitura. Falhou um item → pare com o remédio nomeado, não impro
3. Branch corrente ≠ `main`. Se estiver em main/detached, crie `agent/<N>-react-audit`
a partir de `origin/main` — nunca commitar fix em main.
4. `GITHUB_TOKEN` presente e validado com chamada barata ANTES do trabalho:
`node -e "import('./scripts/lib/github-api.mjs').then(m=>m.githubApi.getIssue(901)).then(i=>console.log(i?.state))"`.
`node -e "import('./scripts/lib/github-api.mjs').then(m=>m.githubApi.listIssues({limit:1})).then(i=>console.log(i.length>=0))"`.
5. Baseline bare dos gates estáticos — vermelhos pré-existentes são BASELINE (política
"dono do PR, dono do CI", `docs/AGENT-OPS.md`), anote-os:
`pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm exec knip`, `pnpm check:cycles`.

View file

@ -6,8 +6,8 @@ triage per-fix (3 selos: file:line, família, fonte oficial; cap ≤6) → imple
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 verificado em laço
(`githubApi.ensureAutoMergeDisabled`, novo helper GraphQL `disablePullRequestAutoMerge` +
re-poll em `scripts/lib/github-api.mjs`, com `auto_merge` normalizado em `getPullRequest`) é
(`githubApi.ensureAutoMergeDisabled`, novo par GraphQL `disableAutoMerge`/`enableAutoMerge` 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

@ -81,6 +81,7 @@ Alternativas rejeitadas: B porque cria segunda identidade para o mesmo trabalho
- **`.agents/skills/react-audit/anti-patterns.md`** (novo): catálogo FIXO, uma seção por família com `Sintoma/heurística de busca`, `Norma no repo` (âncora nos .mdc), `Fonte oficial` (URL react.dev/learn, react.dev/reference, nextjs.org/docs, nextjs.org/learn — conferidas contra next 15.4.11 / react ^19.2.4), `Receita de fix`, `Precedente` (B14 ~21 kB sidebar, B34 closures/fallbacks obsoletos, B32+ live-region, C139/C140 reset de form React 19 em `docs/plans/react-19-form-reset-campanha.md`, P4-F). Famílias fechadas (9): (1) client boundary/server-first (`'use client'` desnecessário, import de server module por VALOR, estado elevado além dos consumidores, view models não selecionados); (2) RSC payload bloat (doc Payload inteiro no fio); (3) effects fazendo derived-state / stale closures (B34); (4) forms & server actions (reset `action={submitAction}`, transição ausente, optimistic sem `revalidatePath`); (5) caching ladder (live data sem invalidação, auth dentro do core cacheado); (6) URL serializers chegando ao browser (B14); (7) live-region mistakes (B32+); (8) loading/streaming feedback (dim o resultado, não o controle; `<Suspense>`/`loading.tsx`); (9) next/image + fonts + a11y de componentes.
- **`.agents/skills/react-audit/context7-setup.md`** (novo): passo MANUAL do dono — declarar o MCP globalmente FORA do git em `~/.config/opencode/opencode.jsonc` (bloco `mcp` remote `https://mcp.context7.com/mcp`), validar com `opencode debug config`; deixado claro que a skill funciona sem (fallback = links oficiais embutidos no catálogo); o executor valida o shape vigente do bloco ao escrever o doc.
- **`scripts/lib/github-api.mjs`** (editar o dono — é o dono do acesso REST GitHub): adicionar `disablePullRequestAutoMerge(number)` (DELETE `/repos/{owner}/{repo}/pulls/{number}/auto-merge`, tolerando 404) e expor `auto_merge` no retorno de `getPullRequest`; reuso integral do que já existe (`createIssue`/`listIssues` para follow-ups, `mergeable` para estabilização). Único código novo da entrega; sem twin em curl/scripts avulsos.
- **Errata de execução (2026-08-25):** o mecanismo acima foi superseded pelo dogfood #905 — o endpoint REST DELETE responde **404 mesmo com auto-merge armado via GraphQL**, e o armar do safety net é assíncrono (um desarme único perdeu a corrida). Entregue no lugar: `disableAutoMerge(nodeId)` por mutation GraphQL `disablePullRequestAutoMerge` (simétrica ao `enableAutoMerge`) + `ensureAutoMergeDisabled(number)` com laço desarme→re-poll até `auto_merge = null`, fail-closed em PR inexistente.
- **Migration:** sem migration. **Access/Consent:** N/A (nenhuma collection/global tocada; fixes de produto continuam sujeitos às regras normais quando tocarem essas áreas — aí viram follow-up, não fix noturno). **UI:** Impeccable A — N/A sem UI.
### Dados → forma (se aplicável)

View file

@ -176,7 +176,10 @@ export const createApi = ({
draft: Boolean(pr.draft),
mergeable: pr.mergeable ?? null,
autoMerge: pr.auto_merge
? { mergeMethod: pr.auto_merge.merge_method ?? '', enabledBy: pr.auto_merge.enabled_by?.login ?? '' }
? {
mergeMethod: pr.auto_merge.merge_method ?? '',
enabledBy: pr.auto_merge.enabled_by?.login ?? '',
}
: null,
nodeId: pr.node_id ?? '',
head: { ref: pr.head?.ref ?? '', sha: pr.head?.sha ?? '' },
@ -417,18 +420,22 @@ export const createApi = ({
* 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.
* `auto_merge` reads null or attempts are exhausted. Fail-closed: an
* unknown PR throws (a wrong number must not read as success); the
* final GET runs without a preceding sleep, so eventual consistency
* may raise a false "still armed" — that direction is safe.
*/
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
if (!pr) throw new Error(`PR #${number} não encontrado — desarme não verificado`)
if (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
if (!final) throw new Error(`PR #${number} não encontrado — desarme não verificado`)
if (final.autoMerge === null) return true
throw new Error(
`auto-merge do PR #${number} segue armado após ${attempts} tentativas de desarme`,
)

View file

@ -141,11 +141,10 @@ describe('github-api (issue tracker layer)', () => {
})
const pr = await api.getPullRequest(5)
expect(pr?.autoMerge?.mergeMethod).toBe('rebase')
expect(pr?.head).toEqual({ ref: '', sha: '' })
})
it('disableAutoMerge sends the GraphQL disable mutation with the node id', async () => {
const calls: { url: string; init?: { method?: string; body?: string } }[] = []
const calls: FetchCall[] = []
const api = createApi({
token: 'tok',
fetchImpl: async (url, init) => {
@ -161,6 +160,15 @@ describe('github-api (issue tracker layer)', () => {
expect(payload.variables).toEqual({ id: 'PR_node7' })
})
it('ensureAutoMergeDisabled throws fail-closed on an unknown PR', async () => {
const api = createApi({
token: 'tok',
fetchImpl: async () => ok({ message: 'Not Found' }, 404),
retries: 0,
})
await expect(api.ensureAutoMergeDisabled(13)).rejects.toThrow(/não encontrado/)
})
it('ensureAutoMergeDisabled converges once a poll sees null', async () => {
let poll = 0
let disabledCalls = 0