feat(audit-p6-m909): deterministic disarm guard for /testing-audit (miss #909)

This commit is contained in:
Francisco Solla 2026-08-25 10:27:46 -03:00
parent b7d0dba650
commit e562af76d8
7 changed files with 383 additions and 23 deletions

View file

@ -95,8 +95,8 @@ Evidência por passo no relatório: timestamp, hash curto, linhas-resumo antes/d
2. Escrever `docs/changelog/<data>-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=<PAT> node scripts/github-pr.mjs --head <branch> --title "<data> — testing-audit" --body-file <relatório>`), 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=<PAT> node scripts/testing-audit-disarm.mjs --head <branch> --title "<data> — testing-audit" --body-file <relatório> --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 <N> --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 <N> --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 <N> --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 <N> --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=<PAT> node scripts/testing-audit-disarm.mjs --pr <N> --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.

View file

@ -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=<slug>` 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=<slug>` 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)

View file

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

View file

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

View file

@ -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=<PAT> node scripts/testing-audit-disarm.mjs \
* --head <branch> --title "<título>" --body-file <relatório> --draft-on-failure
* GITHUB_TOKEN=<PAT> node scripts/testing-audit-disarm.mjs --pr <N> --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 <branch> --title <t> --body-file <relatório> [--draft-on-failure]',
)
console.error(' ou: node scripts/testing-audit-disarm.mjs --pr <N> [--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))
}

View file

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

View file

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