mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(audit-p6-m909): deterministic disarm guard for /testing-audit (miss #909)
This commit is contained in:
parent
b7d0dba650
commit
e562af76d8
7 changed files with 383 additions and 23 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
1
docs/changelog/2026-08-25-audit-pass6-m909.md
Normal file
1
docs/changelog/2026-08-25-audit-pass6-m909.md
Normal 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`.
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
151
scripts/testing-audit-disarm.mjs
Normal file
151
scripts/testing-audit-disarm.mjs
Normal 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))
|
||||
}
|
||||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
29
tests/unit/testingAuditDisarmConventions.unit.spec.ts
Normal file
29
tests/unit/testingAuditDisarmConventions.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue