diff --git a/.agents/skills/testing-audit/SKILL.md b/.agents/skills/testing-audit/SKILL.md index dd060adf..e71f1aad 100644 --- a/.agents/skills/testing-audit/SKILL.md +++ b/.agents/skills/testing-audit/SKILL.md @@ -95,8 +95,8 @@ Evidência por passo no relatório: timestamp, hash curto, linhas-resumo antes/d 2. Escrever `docs/changelog/-ops96-testing-audit.md` (entrada append-only curta; o agregado é gitignored — nunca commitar `docs/CHANGELOG-AGENTS.md`). 3. Gate completo no SHA final, bare: `pnpm lint`, `pnpm format:check`, `pnpm typecheck` (`tsc --noEmit`), `pnpm exec knip`, `pnpm check:cycles`, `pnpm test:unit`, `pnpm test:int`. 4. Push ÚNICO via `pnpm push -u origin HEAD` (o hook roda `gate:push`; minimiza rearms do automerge). -5. Abrir PR **ready** base `main` (`GITHUB_TOKEN= node scripts/github-pr.mjs --head --title " — testing-audit" --body-file `), body = relatório, com `Closes #898` se a Issue desta entrega ainda estiver aberta. -6. Aguardar CI green no SHA final: flake aparente → 1 rerun (`gh run rerun --failed`); quebra real → fix-forward ≤2 ciclos (cada fix é novo push e REARMA o automerge → repetir Fase 5 depois). Esgotado → CI pendente registrado como pendência conhecida (main não corre risco: automerge desarmado na Fase 5). +5. Abrir PR **ready** base `main` e desarmar o auto-merge num ÚNICO passo atômico: `GITHUB_TOKEN= node scripts/testing-audit-disarm.mjs --head --title " — testing-audit" --body-file --draft-on-failure` (cria Ready → desarma → verifica `autoMergeRequest === null`; em falha converte a draft e sai 1 — fallback fail-closed estrutural). Body = relatório, com `Closes #898` se a Issue desta entrega ainda estiver aberta. +6. Aguardar CI green no SHA final: flake aparente → 1 rerun (`gh run rerun --failed`); quebra real → fix-forward ≤2 ciclos (cada fix é novo push e REARMA o automerge → desarmar de novo com `scripts/testing-audit-disarm.mjs --pr --draft-on-failure` depois). Esgotado → CI pendente registrado como pendência conhecida (main não corre risco: automerge desarmado na Fase 5). ### Template do relatório (seções fixas) @@ -130,16 +130,15 @@ Evidência por passo no relatório: timestamp, hash curto, linhas-resumo antes/d (CI, mergeable, automerge desarmado às HH:MM) ``` -## Fase 5 — Desarme do auto-merge (imediatamente após CADA push, nunca depois do green) +## Fase 5 — Desarme do auto-merge (na criação e imediatamente após CADA push, nunca depois do green) -O safety-net arma o automerge no evento de abertura e REARMA a cada push. **Errata OPS96 (falha real da 1ª noite):** desarmar DEPOIS do CI green é tarde — o GitHub mergea em segundos quando o required check fica verde; o PR da 1ª noite mergeou sozinho nesse intervalo (~40s). Sequência correta: +O safety-net arma o automerge no evento de abertura e REARMA a cada push. **Errata OPS96 (falha real da 1ª noite):** desarmar DEPOIS do CI green é tarde — o GitHub mergea em segundos quando o required check fica verde; o PR da 1ª noite mergeou sozinho nesse intervalo (~40s). Desde o Pass 6 o desarme é determinístico — `scripts/testing-audit-disarm.mjs` cria→desarma→verifica e falha fechado; nada depende de lembrar a ordem: -1. Abrir o PR ready LOGO após o push — o safety-net arma no evento `opened`. -2. **Desarmar imediatamente**, com o CI ainda pendente: `gh pr merge --disable-auto`. Armado + checks pendentes não mergeia nada; o desarme cedo elimina a condição de corrida (a janela de CI ~13min é o seu tempo folga). -3. Cada push novo REARMA (evento `synchronize`) → repetir o desarme logo após cada push, sempre enquanto o CI estiver pendente. **Nunca "aguardar o workflow/arquivo de checks terminar" para então desarmar** — isso chega exatamente no momento do merge. -4. Verificação final no fecho da noite (último passo): `gh pr view --json isDraft,autoMergeRequest,mergeable,mergeStateStatus` → esperado `{isDraft: false, autoMergeRequest: null, mergeable: MERGEABLE}`. -5. **Fallback fail-closed:** desarme falhou ou `autoMergeRequest` persistiu não-nulo → converter para draft (`gh pr ready --undo`; draft fora de `cursor/*` é veto estrutural do safety-net) e registrar no relatório que o humano vira ready pela manhã. Draft protegido é preferível a main mergeado sem querer. -6. Gravar no relatório (seção estado final): hora do desarme + saída da verificação. +1. A criação do PR na Fase 4 JÁ desarma e verifica (um único comando com `--draft-on-failure`). +2. Cada push novo REARMA (evento `synchronize`) → repetir o desarme logo após cada push, sempre enquanto o CI estiver pendente: `GITHUB_TOKEN= node scripts/testing-audit-disarm.mjs --pr --draft-on-failure`. **Nunca "aguardar o workflow/arquivo de checks terminar" para então desarmar** — isso chega exatamente no momento do merge. +3. Verificação final no fecho da noite (último passo): o script imprime o JSON de status — esperado `"autoMergeRequest": null, "isDraft": false` com exit 0 (exit 0 SÓ com desarmado+Ready). Qualquer outra saída é falha do passo 4. +4. **Fallback fail-closed:** verificação falhou e o `--draft-on-failure` converteu a PR para draft (draft é veto estrutural do safety-net) — registrar no relatório que o humano vira ready pela manhã. Draft protegido é preferível a main mergeado sem querer. +5. Gravar no relatório (seção estado final): hora do desarme + JSON de status da verificação. Depois da verificação final: **nenhum push, rebase ou merge** — tudo isso rearma. diff --git a/docs/GUARDRAILS.md b/docs/GUARDRAILS.md index b6222732..7ec85867 100644 --- a/docs/GUARDRAILS.md +++ b/docs/GUARDRAILS.md @@ -6,19 +6,20 @@ Ledger dos guardrails determinísticos colhidos de `kind:agent-miss` (fluxo `pnp ## Ativos -| Guarda | Origem | Classe | Mecanismo | Status | -| ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -| `agent:ship` exige stamp de /simplify casado com HEAD; PR Ready + auto-merge + watch até MERGED | [#83](https://github.com/fsolla/teqo/issues/83) | 3 (script + hooks) | `scripts/agent-ship.mjs`, `.cursor/hooks/guard-pr-create.mjs` (bloqueia `gh pr create` manual), `.cursor/hooks/pr-merge-watchdog.mjs` (re-engata com PR aberto) | vivo desde 2026-07-31 (PR #89); miss fechada no Pass 4 | -| Alocador de municípios em specs — sem slug fixo + mutação; sem `municipio=` em e2e; regex de nome de catálogo ancorada | [#73](https://github.com/fsolla/teqo/issues/73) | 3 | `tests/unit/testMunicipalityAllocatorConventions.unit.spec.ts` (3 regras; mutações = assign/touch/create\*/`payload.update` municipality) | PR [#93](https://github.com/fsolla/teqo/pull/93) (Pass 4) | -| Sheet compartilhada por lista — chip cells exigem `CampaignListSheetProvider`; invariante dev no overlay | [#52](https://github.com/fsolla/teqo/issues/52) | 3 + 5 | `tests/unit/sharedSheetHostConventions.unit.spec.ts`; `console.error` development-only em `CampaignCellEditOverlay` | PR [#94](https://github.com/fsolla/teqo/pull/94) (Pass 4) | -| Navegação e2e assentada — goto→goto sem settle falha o build | [#54](https://github.com/fsolla/teqo/issues/54) | 3 | `tests/unit/e2eNavigationConventions.unit.spec.ts` (settle = await expect/waitForURL/click/reload/login/catch) | PR [#95](https://github.com/fsolla/teqo/pull/95) (Pass 4) | -| Readiness de autenticador virtual antes da cerimônia WebAuthn | [#53](https://github.com/fsolla/teqo/issues/53) | 3 | mesma spec (spec com `WebAuthn.addVirtualAuthenticator` deve chamar `expectCampaignBiometricsReady(`) | PR [#95](https://github.com/fsolla/teqo/pull/95) (Pass 4) | -| Convenção de navegação e2e (goto = cold load/contrato; in-shell = continuidade) | [#54](https://github.com/fsolla/teqo/issues/54) | 6 (**judgment-only**) | parágrafo em `docs/TESTING.md` | PR [#95](https://github.com/fsolla/teqo/pull/95) (Pass 4) | -| Update de globals públicos nunca "qualquer autenticado" | P0 do Pass 4 (sem issue) | 3 | `tests/unit/globalAccessConventions.unit.spec.ts` ban `Boolean(user)` em `src/globals` | PR [#91](https://github.com/fsolla/teqo/pull/91) (Pass 4) | -| Loaders de notificação tipados pelo ator (recipientID arbitrário irrepresentável) | P0 adjacente do Pass 4 | 1 | assinatura `(payload, user: CampaignUser)` + `overrideAccess: false` sob `canReadOwnNotifications` | PR [#91](https://github.com/fsolla/teqo/pull/91) (Pass 4) | -| Die-helpers da CLI importados onde usados; `DAY_MS` single-source | P1 do Pass 4 (sem issue) | 3 | `tests/unit/scriptCliConventions.unit.spec.ts` | PR [#92](https://github.com/fsolla/teqo/pull/92) (Pass 4) | -| `src/lib` não type-importa `@/utilities/**` (direção única também para tipos) | dodge do Pass 3 (suggestionCatalog) | 2 | ESLint `no-restricted-imports` `allowTypeImports: false` na zona `src/lib` | PR [#92](https://github.com/fsolla/teqo/pull/92) (Pass 4) | -| Local API com `user` sempre declara `overrideAccess`; import dinâmico de módulos server banido em utilities | guarda ausente (medida baseline 59/0) | 3 | `tests/unit/localApiOverrideAccessConventions.unit.spec.ts` | PR [#92](https://github.com/fsolla/teqo/pull/92) (Pass 4) | +| Guarda | Origem | Classe | Mecanismo | Status | +| ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| `agent:ship` exige stamp de /simplify casado com HEAD; PR Ready + auto-merge + watch até MERGED | [#83](https://github.com/fsolla/teqo/issues/83) | 3 (script + hooks) | `scripts/agent-ship.mjs`, `.cursor/hooks/guard-pr-create.mjs` (bloqueia `gh pr create` manual), `.cursor/hooks/pr-merge-watchdog.mjs` (re-engata com PR aberto) | vivo desde 2026-07-31 (PR #89); miss fechada no Pass 4 | +| Alocador de municípios em specs — sem slug fixo + mutação; sem `municipio=` em e2e; regex de nome de catálogo ancorada | [#73](https://github.com/fsolla/teqo/issues/73) | 3 | `tests/unit/testMunicipalityAllocatorConventions.unit.spec.ts` (3 regras; mutações = assign/touch/create\*/`payload.update` municipality) | PR [#93](https://github.com/fsolla/teqo/pull/93) (Pass 4) | +| Sheet compartilhada por lista — chip cells exigem `CampaignListSheetProvider`; invariante dev no overlay | [#52](https://github.com/fsolla/teqo/issues/52) | 3 + 5 | `tests/unit/sharedSheetHostConventions.unit.spec.ts`; `console.error` development-only em `CampaignCellEditOverlay` | PR [#94](https://github.com/fsolla/teqo/pull/94) (Pass 4) | +| Navegação e2e assentada — goto→goto sem settle falha o build | [#54](https://github.com/fsolla/teqo/issues/54) | 3 | `tests/unit/e2eNavigationConventions.unit.spec.ts` (settle = await expect/waitForURL/click/reload/login/catch) | PR [#95](https://github.com/fsolla/teqo/pull/95) (Pass 4) | +| Readiness de autenticador virtual antes da cerimônia WebAuthn | [#53](https://github.com/fsolla/teqo/issues/53) | 3 | mesma spec (spec com `WebAuthn.addVirtualAuthenticator` deve chamar `expectCampaignBiometricsReady(`) | PR [#95](https://github.com/fsolla/teqo/pull/95) (Pass 4) | +| Convenção de navegação e2e (goto = cold load/contrato; in-shell = continuidade) | [#54](https://github.com/fsolla/teqo/issues/54) | 6 (**judgment-only**) | parágrafo em `docs/TESTING.md` | PR [#95](https://github.com/fsolla/teqo/pull/95) (Pass 4) | +| Update de globals públicos nunca "qualquer autenticado" | P0 do Pass 4 (sem issue) | 3 | `tests/unit/globalAccessConventions.unit.spec.ts` ban `Boolean(user)` em `src/globals` | PR [#91](https://github.com/fsolla/teqo/pull/91) (Pass 4) | +| Loaders de notificação tipados pelo ator (recipientID arbitrário irrepresentável) | P0 adjacente do Pass 4 | 1 | assinatura `(payload, user: CampaignUser)` + `overrideAccess: false` sob `canReadOwnNotifications` | PR [#91](https://github.com/fsolla/teqo/pull/91) (Pass 4) | +| Die-helpers da CLI importados onde usados; `DAY_MS` single-source | P1 do Pass 4 (sem issue) | 3 | `tests/unit/scriptCliConventions.unit.spec.ts` | PR [#92](https://github.com/fsolla/teqo/pull/92) (Pass 4) | +| `src/lib` não type-importa `@/utilities/**` (direção única também para tipos) | dodge do Pass 3 (suggestionCatalog) | 2 | ESLint `no-restricted-imports` `allowTypeImports: false` na zona `src/lib` | PR [#92](https://github.com/fsolla/teqo/pull/92) (Pass 4) | +| Local API com `user` sempre declara `overrideAccess`; import dinâmico de módulos server banido em utilities | guarda ausente (medida baseline 59/0) | 3 | `tests/unit/localApiOverrideAccessConventions.unit.spec.ts` | PR [#92](https://github.com/fsolla/teqo/pull/92) (Pass 4) | +| Desarme determinístico do auto-merge da /testing-audit (cria→desarma→verifica atômico; draft fail-closed) | [#909](https://github.com/fsolla/teqo/issues/909) | 3 (script + spec) | `scripts/testing-audit-disarm.mjs` (create/`--pr` + `--draft-on-failure`); pin `tests/unit/testingAuditDisarmConventions.unit.spec.ts` (skill /testing-audit deve referenciar o script) | vivo desde 2026-08-25 (Pass 6) | ## Precedentes vivos (anteriores ao primeiro harvest) diff --git a/docs/changelog/2026-08-25-audit-pass6-m909.md b/docs/changelog/2026-08-25-audit-pass6-m909.md new file mode 100644 index 00000000..0f1d8d71 --- /dev/null +++ b/docs/changelog/2026-08-25-audit-pass6-m909.md @@ -0,0 +1 @@ +**Audit Pass 6 — P6-M909 (#909) — desarme determinístico do auto-merge da /testing-audit:** miss colhida no Pass 6 (o desarme dependia do agente lembrar a ordem; a 1ª noite mergeou sozinha — OPS96). Novo `scripts/testing-audit-disarm.mjs` (plain Node, shape de `github-pr.mjs`): `--head/--title/--body-file [--draft-on-failure]` cria Ready → desarma → verifica (atômico) e `--pr N` cobre o rearm pós-`synchronize`; exit 0 só com `autoMergeRequest === null && !isDraft`, JSON de status impresso para o relatório, `--draft-on-failure` converte a draft (veto estrutural) e sai 1, e `GITHUB_TOKEN` ausente morre antes de qualquer chamada (fail-closed por construção). Dono estendido: `github-api.mjs` ganha `getPullRequestAutoMergeStatus` (GraphQL: `autoMergeRequest`/`isDraft`/`mergeable`/`mergeStateStatus` + node `id`) e `convertPullRequestToDraft` (mutation), reusando `ensureAutoMergeDisabled`/`createPullRequest`; pins novos em `githubApi.unit.spec.ts` (status, draft, idempotência read-first). Fiação classe 3: Fase 4/5 da skill /testing-audit reescrita para um único comando no passo de criação (a sequência manual `gh pr merge --disable-auto` saiu do caminho canônico) + pin `testingAuditDisarmConventions.unit.spec.ts` que falha o build se a skill parar de referenciar o script; ledger em `docs/GUARDRAILS.md`. diff --git a/scripts/lib/github-api.mjs b/scripts/lib/github-api.mjs index 9447b50d..e6e29ef6 100644 --- a/scripts/lib/github-api.mjs +++ b/scripts/lib/github-api.mjs @@ -441,6 +441,79 @@ export const createApi = ({ ) }, + /** + * GraphQL status read of a PR's auto-merge — the verification arm of the + * testing-audit disarm guard (P6-M909, miss #909). One query carries the + * `autoMergeRequest` (GraphQL-only), `isDraft`, `mergeable` and + * `mergeStateStatus` plus the node `id` (needed by the draft conversion + * fallback). Fail-closed: an unknown PR throws — a wrong number must not + * read as "disarmed". + * @param {number} number + */ + getPullRequestAutoMergeStatus: async (number) => { + const data = await graphql( + ` + query PullRequestAutoMergeStatus($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + id + isDraft + mergeable + mergeStateStatus + autoMergeRequest { + mergeMethod + enabledBy { + login + } + } + } + } + } + `, + { owner, name, number }, + ) + const pr = data?.repository?.pullRequest + if (!pr) { + throw new Error(`PR #${number} não encontrado — status de auto-merge não verificado`) + } + return { + number, + nodeId: pr.id, + isDraft: Boolean(pr.isDraft), + mergeable: pr.mergeable, + mergeStateStatus: pr.mergeStateStatus, + autoMergeRequest: pr.autoMergeRequest + ? { + mergeMethod: pr.autoMergeRequest.mergeMethod, + enabledBy: pr.autoMergeRequest.enabledBy?.login ?? '', + } + : null, + } + }, + + /** + * GraphQL mutation `convertPullRequestToDraft` — the fail-closed fallback + * of the testing-audit disarm guard: when the disarm verification fails, + * a draft is the structural veto the safety net respects (a draft never + * auto-merges). Takes the PR's node `id` + * (`getPullRequestAutoMergeStatus(n).nodeId`). + */ + convertPullRequestToDraft: async (nodeId) => { + await graphql( + ` + mutation ConvertPullRequestToDraft($id: ID!) { + convertPullRequestToDraft(input: { pullRequestId: $id }) { + pullRequest { + number + isDraft + } + } + } + `, + { id: nodeId }, + ) + }, + /** * GET /repos/{owner}/{repo}/branches/{branch}/protection — normalized for * drift comparison; `null` when the branch has no protection rule (404). diff --git a/scripts/testing-audit-disarm.mjs b/scripts/testing-audit-disarm.mjs new file mode 100644 index 00000000..cdc02801 --- /dev/null +++ b/scripts/testing-audit-disarm.mjs @@ -0,0 +1,151 @@ +/** + * Deterministic disarm guard for the /testing-audit skill (Pass 6 P6-M909, + * miss #909). Plain-Node CLI (no pnpm) — same runtime contract as + * `github-pr.mjs`. + * + * The repo safety net (`agent-pr-ready-automerge.yml`) arms GitHub's native + * auto-merge on EVERY ready PR against `main` and REARMS on every push + * (`synchronize`). The /testing-audit contract forbids auto-merge (the human + * decides the merge in the morning), so the disarm used to depend on the + * agent remembering the order — one misordered step was the whole protection. + * This CLI makes the disarm part of the creation step, atomic from the + * agent's point of view: + * + * GITHUB_TOKEN= node scripts/testing-audit-disarm.mjs \ + * --head --title "" --body-file --draft-on-failure + * GITHUB_TOKEN= node scripts/testing-audit-disarm.mjs --pr --draft-on-failure + * + * - First form: creates the PR ready → disarms → verifies (the disarm loop + * polls, because the safety net arms asynchronously AFTER `opened`). + * - Second form: disarms an existing PR — the rearm after each subsequent + * push (`synchronize`). Read-first: an already-disarmed PR gets no + * mutation. + * - Exit 0 ONLY when the final GraphQL status reads + * `autoMergeRequest === null && isDraft === false`. The status JSON is + * printed for the report. + * - Without `--draft-on-failure` a failed verification exits 1 leaving the + * PR as-is; with it, the PR is converted to draft (structural veto) before + * exiting 1. A missing `GITHUB_TOKEN` dies BEFORE any API call, so nothing + * gets created-and-left-armed. + */ + +import { readFileSync } from 'node:fs' + +import { dieWithLabel } from './lib/cli.mjs' +import { createApi } from './lib/github-api.mjs' + +const die = dieWithLabel('testing-audit-disarm') + +const parseArgs = (argv) => { + const flags = {} + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index] + if (arg.startsWith('--')) { + const name = arg.slice(2) + const next = argv[index + 1] + flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true + if (typeof next === 'string' && !next.startsWith('--')) index += 1 + } + } + return flags +} + +const flags = parseArgs(process.argv) + +if (!process.env.GITHUB_TOKEN) { + die( + 'GITHUB_TOKEN ausente — aborte antes de qualquer chamada à API (fail-closed: nada criado, nada armado).', + ) +} + +const api = createApi({}) + +const printStatus = (number, status) => { + console.log(`[testing-audit-disarm] status do PR #${number}:`) + console.log(JSON.stringify(status, null, 2)) +} + +const disarmAndVerify = async (number, { afterCreate = false } = {}) => { + // Read-first: a disarmed PR gets no mutation (idempotent rearm handling). + // After CREATE the disarm loop always runs — the safety net arms + // asynchronously right after `opened`, so the first read can be null while + // the arm is still in flight (the race found live in OPS97, PR #905). + const initial = await api.getPullRequestAutoMergeStatus(number) + if (afterCreate || initial.autoMergeRequest !== null) { + await api.ensureAutoMergeDisabled(number) + } + return api.getPullRequestAutoMergeStatus(number) +} + +const main = async () => { + const draftOnFailure = Boolean(flags['draft-on-failure']) + const base = flags.base ?? 'main' + let number + let afterCreate = false + + if (flags.pr) { + number = Number(flags.pr) + if (!Number.isInteger(number) || number <= 0) { + die(`--pr inválido: "${flags.pr}" — use o número do PR`) + } + } else { + const { head, title } = flags + if (!head || !title || !flags['body-file']) { + console.error( + 'Usage: node scripts/testing-audit-disarm.mjs --head --title --body-file [--draft-on-failure]', + ) + console.error(' ou: node scripts/testing-audit-disarm.mjs --pr [--draft-on-failure]') + process.exit(1) + } + let body + try { + body = readFileSync(flags['body-file'], 'utf8') + } catch (error) { + die(`não consegui ler --body-file: ${error.message}`) + } + if (typeof body !== 'string' || body.length === 0) { + die('body vazio — o relatório precisa de conteúdo') + } + const pr = await api.createPullRequest({ head, base, title, body }) + number = pr.number + afterCreate = true + console.log(`[testing-audit-disarm] PR #${number} criado (Ready): ${pr.htmlUrl}`) + } + + try { + const status = await disarmAndVerify(number, { afterCreate }) + const disarmed = status.autoMergeRequest === null && !status.isDraft + printStatus(number, status) + if (disarmed) { + console.log(`[testing-audit-disarm] PR #${number} desarmado e Ready — verificado.`) + return + } + const reason = status.autoMergeRequest !== null ? 'auto-merge segue armado' : 'PR virou draft' + throw new Error(`${reason} no PR #${number}`) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (!draftOnFailure) { + die(`verificação falhou: ${message} — sem --draft-on-failure, nada foi alterado.`) + } + try { + const status = await api.getPullRequestAutoMergeStatus(number) + await api.convertPullRequestToDraft(status.nodeId) + const after = await api.getPullRequestAutoMergeStatus(number) + printStatus(number, after) + die( + `verificação falhou: ${message} — PR #${number} convertida para draft (fail-closed; humano vira ready pela manhã).`, + ) + } catch (draftError) { + const draftMessage = draftError instanceof Error ? draftError.message : String(draftError) + die( + `verificação falhou (${message}) E a conversão para draft falhou: ${draftMessage} — intervenção humana obrigatória.`, + ) + } + } +} + +try { + await main() +} catch (error) { + die(error instanceof Error ? error.message : String(error)) +} diff --git a/tests/unit/githubApi.unit.spec.ts b/tests/unit/githubApi.unit.spec.ts index 74d80a70..4b047a2a 100644 --- a/tests/unit/githubApi.unit.spec.ts +++ b/tests/unit/githubApi.unit.spec.ts @@ -219,4 +219,110 @@ describe('github-api (issue tracker layer)', () => { }) await expect(api.ensureAutoMergeDisabled(11, { attempts: 3 })).rejects.toThrow(/auto-merge/i) }) + + it('ensureAutoMergeDisabled is idempotent — a null auto_merge gets no mutation', async () => { + const calls: FetchCall[] = [] + const api = createApi({ + token: 'tok', + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init }) + return ok({ number: 21, state: 'open', draft: false }) + }, + }) + await expect(api.ensureAutoMergeDisabled(21)).resolves.toBe(true) + expect(calls).toHaveLength(1) + expect(calls[0].url).not.toContain('/graphql') + }) + + it('getPullRequestAutoMergeStatus reads the GraphQL status and normalizes it', async () => { + const calls: FetchCall[] = [] + const api = createApi({ + token: 'tok', + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init }) + return ok({ + data: { + repository: { + pullRequest: { + id: 'PR_node33', + isDraft: false, + mergeable: 'MERGEABLE', + mergeStateStatus: 'CLEAN', + autoMergeRequest: { + mergeMethod: 'REBASE', + enabledBy: { login: 'github-actions[bot]' }, + }, + }, + }, + }, + }) + }, + }) + const status = await api.getPullRequestAutoMergeStatus(33) + expect(status).toEqual({ + number: 33, + nodeId: 'PR_node33', + isDraft: false, + mergeable: 'MERGEABLE', + mergeStateStatus: 'CLEAN', + autoMergeRequest: { mergeMethod: 'REBASE', enabledBy: 'github-actions[bot]' }, + }) + 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('query PullRequestAutoMergeStatus') + expect(payload.query).toContain('autoMergeRequest') + expect(payload.variables).toEqual({ owner: 'fsolla', name: 'teqo', number: 33 }) + }) + + it('getPullRequestAutoMergeStatus normalizes a disarmed PR to autoMergeRequest null', async () => { + const api = createApi({ + token: 'tok', + fetchImpl: async () => + ok({ + data: { + repository: { + pullRequest: { + id: 'PR_node34', + isDraft: false, + mergeable: 'MERGEABLE', + mergeStateStatus: 'BLOCKED', + autoMergeRequest: null, + }, + }, + }, + }), + }) + const status = await api.getPullRequestAutoMergeStatus(34) + expect(status.autoMergeRequest).toBeNull() + expect(status.isDraft).toBe(false) + }) + + it('getPullRequestAutoMergeStatus throws fail-closed on an unknown PR', async () => { + const api = createApi({ + token: 'tok', + fetchImpl: async () => ok({ data: { repository: { pullRequest: null } } }), + retries: 0, + }) + await expect(api.getPullRequestAutoMergeStatus(99)).rejects.toThrow(/não encontrado/) + }) + + it('convertPullRequestToDraft sends the GraphQL draft mutation with the node id', async () => { + const calls: FetchCall[] = [] + const api = createApi({ + token: 'tok', + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init }) + return ok({ + data: { convertPullRequestToDraft: { pullRequest: { number: 41, isDraft: true } } }, + }) + }, + }) + await api.convertPullRequestToDraft('PR_node41') + 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('convertPullRequestToDraft') + expect(payload.variables).toEqual({ id: 'PR_node41' }) + }) }) diff --git a/tests/unit/testingAuditDisarmConventions.unit.spec.ts b/tests/unit/testingAuditDisarmConventions.unit.spec.ts new file mode 100644 index 00000000..64cb5477 --- /dev/null +++ b/tests/unit/testingAuditDisarmConventions.unit.spec.ts @@ -0,0 +1,29 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Pass 6 (2026-08-25, miss #909): the /testing-audit skill used to disarm the +// safety-net auto-merge with a manual `gh pr merge --disable-auto` sequence +// whose order depended on the agent remembering it — the 1st night merged its +// PR without the human (OPS96 errata). The disarm is now deterministic via +// `scripts/testing-audit-disarm.mjs` (create → disarm → verify-null, atomic). +// This spec is the class-3 guard of that wiring: if the skill stops pointing +// at the script, the build fails and the disarm goes back to judgment-only. + +const repoRoot = process.cwd() +const skillPath = resolve(repoRoot, '.agents/skills/testing-audit/SKILL.md') +const scriptPath = resolve(repoRoot, 'scripts/testing-audit-disarm.mjs') + +describe('testing-audit disarm wiring (miss #909)', () => { + it('keeps the /testing-audit skill wired to the deterministic disarm script', () => { + const skill = readFileSync(skillPath, 'utf8') + expect( + skill, + 'Fase 4/5 da skill /testing-audit deve invocar scripts/testing-audit-disarm.mjs (o desarme manual por gh é o miss #909)', + ).toContain('scripts/testing-audit-disarm.mjs') + }) + + it('keeps the disarm script present in the repo', () => { + expect(existsSync(scriptPath), 'scripts/testing-audit-disarm.mjs deve existir').toBe(true) + }) +})