fix(flip): removeLabels idempotente — restaura o ignoreNotFound revertido pelo C140 (descoberto no PR #1202)

This commit is contained in:
Francisco Solla 2026-09-19 10:54:13 -03:00
parent d7d4258cd7
commit 8a73eb2154
4 changed files with 100 additions and 2 deletions

View file

@ -0,0 +1 @@
**FIX — flip pós-merge abortava com 404 ao remover label ausente; idempotência de `removeLabels` restaurada e pinada com 2 testes (2026-09-19):** o job `issue-done-on-main-merge` falhava `GitHub API DELETE .../labels/in-progress → 404 {"message":"Label does not exist"}` quando a Issue citada não tinha o label (keyword-close, re-run do dispatch de recuperação ou Issue que nunca entrou em `in-progress`), abortando o lote no primeiro item — evidenciado no merge do PR #1202 (#1176 fechada sem `done`/`in-prod`; #1177/#1184 não processadas) e antes em #1155/#1169 (runs 35416333242/35416350778). Causa-raiz: o `ignoreNotFound` de `scripts/lib/github-api.mjs` existia desde `4486c05b` (idempotência, OPS46-S1) e foi revertido por `9467e0f2` (C140 — commit de 70+ arquivos levou o blob antigo de `github-api.mjs`, artefato de rebase); sem teste, o revert passou ~4 semanas silencioso. Fix: restauração exata (`request` tolera 404 com `ignoreNotFound`; `removeLabels` o usa) + 2 pins em `tests/unit/githubApi.unit.spec.ts` (404 tolerado; não-404 ainda lança) — RED pré-fix, GREEN pós (31/31). Sem migration/schema/Consent; sem deploy de aplicação. Post-mortem: `docs/postmortems/2026-09-19-flip-remove-labels-404.md`.

View file

@ -0,0 +1,68 @@
# Post-mortem: flip pós-merge abortava com 404 ao remover label ausente — fix idempotente revertido sem teste
> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente.
## Registro
| Campo | Valor |
| ------------------- | ------------------------------------------------------------------------------------------------------------ |
| Data do post-mortem | 2026-09-19 |
| Severidade | alta (flip pós-merge aborta no primeiro item; labels de status mentem e as demais Issues do lote não fecham) |
| Ambiente | CI (GitHub Actions) + tracker GitHub |
| Issue(s) | sem Issue (descoberto no `/bug-fix` do ready-automerge; evidência viva em #1155/#1169 e #1176/#1177/#1184) |
| PR do fix | (este PR) |
| Detectado por | log (run 35425762455, flip do PR #1202) + investigação do `/bug-fix` |
## Timeline
| Momento | Data/hora | Evento |
| --------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Introdução | 2026-08-23 | `4486c05b` adiciona `ignoreNotFound` ao `request` e o usa em `removeLabels` (idempotência do flip). Horas depois, `9467e0f2` (C140 — 70+ arquivos) reverte `scripts/lib/github-api.mjs` ao blob pré-fix (`7be10d1e` → `2ae14a50`) sem que nada pinasse o comportamento |
| Falhas vivas | 2026-09-19T02:40Z | Runs 35416333242 (PR #1183 → #1155) e 35416350778 (PR #1175 → #1169) falham `DELETE .../labels/in-progress → 404` — inclusive o flip do PR que corrigiu o 401 (post-mortem irmão) |
| Detecção | 2026-09-19T06:07:43Z | Flip do PR #1202 (run 35425762455): #1176 fecha só pelo keyword do GitHub (sem `done`/`in-prod`), o run falha no 404 e #1177/#1184 ficam abertas — o defeito aparece na verificação do fix do ready-automerge |
| Correção | (este PR) | Restaura `4486c05b` + 2 pins unitários (o fix original não tinha teste) |
| Verificação e2e | a confirmar | Recovery `workflow_dispatch` do `issue-done-on-main-merge.yml` com `pr=1202` após o merge; esperado: #1176/#1177/#1184 fechadas com `done`/`in-prod` |
| Deploy | não se aplica | Mudança em scripts de CI/tracker; nenhum deploy de aplicação |
## O bug
O job `issue-done-on-main-merge` (flip pós-merge) falhava com `GitHub API DELETE /repos/fsolla/teqo/issues/<n>/labels/in-progress → 404: {"message":"Label does not exist"}` sempre que a Issue citada não tinha o label `in-progress` — caso comum: Issue já fechada por keyword do GitHub, re-run do dispatch de recuperação, ou Issue que nunca entrou em `in-progress`. O `issue-transition-on-merge.mjs` processa as Issues citadas em ordem e aborta no primeiro erro: as seguintes não são processadas, os labels de status ficam mentirosos e o job fica vermelho (fail-closed, OPS61). Evidência viva: #1176 (fechada sem `done`/`in-prod`) e #1177/#1184 (abertas) no merge do PR #1202; antes, #1155 e #1169.
## Causa-raiz
5-whys:
1. O GitHub devolve 404 ao remover um label que a Issue não tem (estado final desejado, não erro).
2. `removeLabels` (`scripts/lib/github-api.mjs`) não pedia tolerância a 404 — o `request` só tolera 404 em GET.
3. A tolerância existia (`ignoreNotFound`, `4486c05b`) e foi **revertida** por `9467e0f2` (C140): o commit grande levou o blob antigo de `github-api.mjs` (`git show 9467e0f2 -- scripts/lib/github-api.mjs`) — artefato de rebase que resolve conflito escolhendo o lado velho.
4. Nenhum teste pinava a idempotência — o fix original entrou sem teste e o revert passou silencioso por ~4 semanas.
5. O flip depende da idempotência para re-run/recovery (dispatch manual com label já removido) e aborta o lote no primeiro erro: uma regressão pequena derruba o flip inteiro.
**Evidência:** `git show 4486c05b` e `git show 9467e0f2 -- scripts/lib/github-api.mjs` (blob `7be10d1e` → `2ae14a50`); runs 35416333242, 35416350778 e 35425762455.
## Correção
Restauração exata de `4486c05b` em `scripts/lib/github-api.mjs`: `ignoreNotFound` no `request` (404 → `null`) e `removeLabels` passando `ignoreNotFound: true`, com o comentário de intenção. Mais 2 testes unitários em `tests/unit/githubApi.unit.spec.ts`: 404 tolerado (idempotência do re-run) e não-404 ainda lança (não engolir erro real). O pin é o que faltava no fix original.
## Verificação
- Teste de regressão: `removeLabels tolerates 404 on an already-absent label` — RED pré-fix (reproduz o erro exato de `github-api.mjs:138`), GREEN pós-fix; 31/31 na spec
- Suíte: `pnpm gate:ci` (a confirmar)
- CI: a confirmar no PR (este PR)
- Live: recovery dispatch do flip do PR #1202 após o merge (a confirmar; #1176/#1177/#1184 fechadas com labels)
- Prod: não se aplica (script de CI/tracker)
## Prevenção
| Estratégia | Custo | Estado |
| -------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------- |
| 2 pins unitários da idempotência de `removeLabels` (o fix original não tinha) | barata | implementada agora (este PR) |
| Guard contra "revert silencioso" de blob em commits grandes (ou Merge Queue / branch protection estrita) | cara | documentada — não implementada (candidata a Issue) |
**Estratégia implementada:** os dois pinos unitários — reverter o `ignoreNotFound` de novo agora quebra o CI.
**Estratégia documentada (cara):** detecção automática de blob regredido em arquivos não relacionados de um commit (caro e ruidoso); a rede barata é manter fix pequeno com teste na mesma entrega. O guard de conflito semântico de rebase (branch protection estrita/Merge Queue) segue candidato registrado no post-mortem irmão de 2026-09-18.
## Lições
Um fix de uma linha sem teste é um fix que um commit vizinho pode desfazer sem ninguém notar — e foi o que aconteceu: `9467e0f2` (C140) reverteu `github-api.mjs` inteiro para o blob de horas antes, e o único sinal vivo foi um 404 no flip, quatro semanas depois. O design fail-closed do flip está certo (nunca mentir), mas ele processa o lote em ordem e aborta no primeiro erro: idempotência é pré-requisito do recovery, não luxo. Trilha: `4486c05b`, `9467e0f2`, runs 35416333242/35416350778/35425762455, PR #1202 e #1175.

View file

@ -91,7 +91,7 @@ export const createApi = ({
}
}
const request = async (path, { method = 'GET', body, query } = {}) => {
const request = async (path, { method = 'GET', body, query, ignoreNotFound = false } = {}) => {
const qs = query
? '?' +
Object.entries(query)
@ -134,6 +134,7 @@ export const createApi = ({
}
if (response.status === 404 && method === 'GET') return null
if (!response.ok) {
if (ignoreNotFound && response.status === 404) return null
const hint = response.status === 401 ? `${AUTH_401_HINT} ` : ''
throw new Error(
`GitHub API ${method} ${path} → ${response.status}: ${hint}${text.slice(0, 400)}`,
@ -250,13 +251,19 @@ export const createApi = ({
body: { labels },
}),
/** Removes labels by NAME (GitHub DELETE is by label name). */
/**
* Removes labels by NAME (GitHub DELETE is by label name). Idempotent:
* removing an already-absent label (404) is the desired end state — a
* re-run of the flip/recovery path must not fail (live finding OPS46-S1,
* dispatch re-run after a manual flip 404'd on `in-progress`).
*/
removeLabels: async (number, labels) => {
for (const label of labels) {
await request(
`/repos/${owner}/${name}/issues/${number}/labels/${encodeURIComponent(label)}`,
{
method: 'DELETE',
ignoreNotFound: true,
},
)
}

View file

@ -91,6 +91,28 @@ describe('github-api (issue tracker layer)', () => {
expect(JSON.parse(calls[1].init?.body ?? '{}')).toEqual({ labels: ['done'] })
})
it('removeLabels tolerates 404 on an already-absent label (idempotent flip re-run)', async () => {
const calls: FetchCall[] = []
const api = createApi({
token: 'tok',
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init })
return ok({ message: 'Label does not exist' }, 404)
},
})
await expect(api.removeLabels(11, ['in-progress'])).resolves.toBeUndefined()
expect(calls[0].init?.method).toBe('DELETE')
expect(calls[0].url).toContain('/issues/11/labels/in-progress')
})
it('removeLabels still throws on non-404 failures (does not swallow real errors)', async () => {
const api = createApi({
token: 'tok',
fetchImpl: async () => ok({ message: 'boom' }, 500),
})
await expect(api.removeLabels(11, ['in-progress'])).rejects.toThrow(/→ 500/)
})
it('closeIssue PATCHes state=closed', async () => {
const calls: FetchCall[] = []
const api = createApi({