diff --git a/docs/changelog/2026-09-19-flip-remove-labels-404.md b/docs/changelog/2026-09-19-flip-remove-labels-404.md new file mode 100644 index 00000000..9cff86a2 --- /dev/null +++ b/docs/changelog/2026-09-19-flip-remove-labels-404.md @@ -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`. diff --git a/docs/postmortems/2026-09-19-flip-remove-labels-404.md b/docs/postmortems/2026-09-19-flip-remove-labels-404.md new file mode 100644 index 00000000..6f47312a --- /dev/null +++ b/docs/postmortems/2026-09-19-flip-remove-labels-404.md @@ -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//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. diff --git a/scripts/lib/github-api.mjs b/scripts/lib/github-api.mjs index 48b332ad..2cd4acdd 100644 --- a/scripts/lib/github-api.mjs +++ b/scripts/lib/github-api.mjs @@ -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, }, ) } diff --git a/tests/unit/githubApi.unit.spec.ts b/tests/unit/githubApi.unit.spec.ts index a1de182c..0df809b8 100644 --- a/tests/unit/githubApi.unit.spec.ts +++ b/tests/unit/githubApi.unit.spec.ts @@ -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({