From 22bf8dcfd1d860c73c3d33790d776c010f64be2b Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 25 Aug 2026 02:26:32 -0300 Subject: [PATCH] =?UTF-8?q?refactor(OPS97):=20fixes=20do=20/simplify=20?= =?UTF-8?q?=E2=80=94=20fail-closed=20em=20PR=20inexistente,=20errata=20do?= =?UTF-8?q?=20plano,=20token=20neutro=20na=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/react-audit/SKILL.md | 2 +- docs/changelog/2026-08-25-ops97.md | 4 ++-- docs/plans/ops97-react-audit-skill-impl.md | 1 + scripts/lib/github-api.mjs | 17 ++++++++++++----- tests/unit/githubApi.unit.spec.ts | 12 ++++++++++-- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/.agents/skills/react-audit/SKILL.md b/.agents/skills/react-audit/SKILL.md index e6b96580..dad48afd 100644 --- a/.agents/skills/react-audit/SKILL.md +++ b/.agents/skills/react-audit/SKILL.md @@ -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/-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`. diff --git a/docs/changelog/2026-08-25-ops97.md b/docs/changelog/2026-08-25-ops97.md index 96d79c05..e6efb0f7 100644 --- a/docs/changelog/2026-08-25-ops97.md +++ b/docs/changelog/2026-08-25-ops97.md @@ -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). diff --git a/docs/plans/ops97-react-audit-skill-impl.md b/docs/plans/ops97-react-audit-skill-impl.md index 9b4ba0ac..d3e849db 100644 --- a/docs/plans/ops97-react-audit-skill-impl.md +++ b/docs/plans/ops97-react-audit-skill-impl.md @@ -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; ``/`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) diff --git a/scripts/lib/github-api.mjs b/scripts/lib/github-api.mjs index 8525c1f3..9447b50d 100644 --- a/scripts/lib/github-api.mjs +++ b/scripts/lib/github-api.mjs @@ -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`, ) diff --git a/tests/unit/githubApi.unit.spec.ts b/tests/unit/githubApi.unit.spec.ts index 42e064c5..f0ab82d6 100644 --- a/tests/unit/githubApi.unit.spec.ts +++ b/tests/unit/githubApi.unit.spec.ts @@ -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