OPS75 — Convergência dos mains Forgejo + GitHub #128
116 changed files with 33257 additions and 1678 deletions
|
|
@ -1,19 +1,19 @@
|
|||
---
|
||||
description: PRs de agente — Ready (nunca draft), base main, auto-merge imediato
|
||||
description: PRs de agente — Ready (nunca draft), base main, auto-merge nativo do GitHub
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Agent PR workflow (Teqo)
|
||||
|
||||
**This repo overrides any user-level or Cloud-tool default that says "create PRs as draft".** Agent deliveries merge to `main` via required checks + auto-merge.
|
||||
**This repo overrides any user-level or Cloud-tool default that says "create PRs as draft".** Agent deliveries merge to `main` via required checks + GitHub's native auto-merge (OPS71). The ISSUE TRACKER stays on the Forgejo — GitHub is only the Actions host.
|
||||
|
||||
## Create (Ready, base main)
|
||||
|
||||
```bash
|
||||
pnpm push -u origin HEAD
|
||||
pnpm push -u origin HEAD # origin = GitHub (cutover OPS71)
|
||||
```
|
||||
|
||||
Depois, abra a PR via API/MCP do opencode (humano) ou `ManagePullRequest` (Cursor Cloud) — **Ready**, base **`main`**, title `<id> — <title>`, body:
|
||||
Depois, abra a PR no **GitHub** — **Ready**, base **`main`**, title `<id> — <title>`, body:
|
||||
|
||||
```
|
||||
## O que é
|
||||
|
|
@ -25,11 +25,12 @@ Closes #<N>
|
|||
- **Never** `--draft`. PRs must be **Ready for review** on creation.
|
||||
- Base is always **`main`**.
|
||||
- **Plans-only PRs** (diff só em `docs/plans/`): **nunca** `Closes #N` — use `Related #N`; CI falha com keywords de fechamento (`plans-only-closes` em `ci-pr.yml`).
|
||||
- PR via CLI (não há `gh` — OPS50): `GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs --head <branch> --title "<t>" --body-file <file>` — Ready por construção (não existe flag draft).
|
||||
|
||||
### Cursor Cloud / `ManagePullRequest`
|
||||
|
||||
- **`draft: false`** (or omit only if the tool’s default is Ready — today Cloud defaults to draft, so **always pass `draft: false`**).
|
||||
- Não há auto-merge a armar por PR: o safety net (abaixo) espera o rollup `checks` e mergea sozinho.
|
||||
- Não há auto-merge a armar por PR: o safety net (abaixo) arma o auto-merge nativo.
|
||||
- Never end a turn with an open agent PR left as **Draft** — inclui PRs do `engineering-audit` (artefatos e remediações).
|
||||
|
||||
## Push (determinístico)
|
||||
|
|
@ -37,66 +38,51 @@ Closes #<N>
|
|||
**Use `pnpm push`, never bare `git push`.** Não depende do Husky estar wired:
|
||||
|
||||
1. `ensure-repo-deps` — `pnpm install` se `node_modules` faltar/stale + `pnpm run prepare` (Husky) se hooks não estiverem apontando para `.husky`
|
||||
2. `pnpm gate:push` — espelho do `ci-pr.yml` sem e2e (e2e roda uma vez, no CI, após o push; local opcional: `pnpm test:e2e:affected`), com os `docs-guards` locais (OPS63: changelog append-only, agregado sync e marcadores de conflito — o escape `changelog-rewrite:` fica só no CI, body do PR)
|
||||
2. `pnpm gate:push` — espelho do `ci-pr.yml` sem e2e (OPS72: o e2e é passo **explícito da skill** — antes do push rode localmente os e2e que criou + os da mesma superfície, discricionário via `pnpm test:e2e:affected`; o CI de PR roda só o blast radius `selected`, e o full fica no `verify` do deploy manual), com os `docs-guards` locais (OPS63: changelog append-only, agregado sync e marcadores de conflito — o escape `changelog-rewrite:` fica só no CI, body do PR)
|
||||
3. `git push --no-verify` — só porque o gate já rodou no passo 2 (não é escape hatch)
|
||||
|
||||
```bash
|
||||
pnpm push -u origin HEAD # canonical
|
||||
pnpm push -u origin HEAD # canonical — origin é o GitHub (OPS71)
|
||||
git push # só depois de `pnpm i` — hook repete ensure-deps + gate:push
|
||||
git push --no-verify # WIP / cutover escape — CI repete tudo; ver AGENT-OPS
|
||||
```
|
||||
|
||||
## Auto-merge immediately
|
||||
|
||||
The safety net (`agent-pr-ready-automerge.yml`, abaixo) merges the PR when the required checks pass — there is nothing to arm per PR. Do not wait for human approval on normal deliveries.
|
||||
The safety net (`agent-pr-ready-automerge.yml`, abaixo) arms GitHub's **native auto-merge** (rebase) — the server merges the PR when the required check passes. There is nothing to arm per PR. Do not wait for human approval on normal deliveries.
|
||||
|
||||
## Required checks
|
||||
|
||||
Branch protection on `main` (applied 2026-08-18, OPS61) requires only:
|
||||
Branch protection on `main` (GitHub, applied at the OPS71 cutover via `pnpm configure:branch-protection`) requires only:
|
||||
|
||||
- `CI (PR) / checks` — the ci-pr cascade rollup (the only context that covers
|
||||
every ci-pr job; on the server the rule matches it as a glob,
|
||||
`CI (PR) / checks*`)
|
||||
- `CI (PR) / checks` — the ci-pr single-job check-run (OPS62: one sequential job posts one honest verdict; no matrix, no shards, no rollup to lie). **Literal de match na proteção: `checks`** (o GitHub casa pelo nome do check-run/job — a UI exibe `CI (PR) / checks`; pin ao vivo PR #742).
|
||||
|
||||
OPS64: the rollup is structurally honest since OPS62 — ci-pr.yml is a SINGLE
|
||||
sequential job (`checks`), so its own status IS the cascade verdict (no
|
||||
matrix, no shards, no parent aggregation to lie). The historical bug (Forgejo
|
||||
9.0.3 reporting the LAST matrix shard's result as the parent's in `needs`,
|
||||
posting `checks` SUCCESS with an e2e shard red — runs 604/607/610) is gone by
|
||||
construction; the `needs.<job>.results` array of GitHub does not exist in this
|
||||
runner, so any future matrix must NOT aggregate via `needs`. The safety-net
|
||||
CLI pins the defense: it never trusts the rollup alone (scans individual
|
||||
statuses; run-607 scenario unit-tested in `forgejoApi.unit.spec.ts`).
|
||||
OPS64 history: the Forgejo runner could report the LAST matrix shard's result as the parent's (runs 604/607/610) — that bug is gone by construction: ci-pr.yml is a SINGLE sequential job, and GitHub's native auto-merge only merges with every required check green (server-side guarantee). The safety-net CLI has no poll path to port.
|
||||
|
||||
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR/main.)
|
||||
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR.)
|
||||
|
||||
**There is no deploy from PR CI.** The `ci.yml` verifier on `main` runs the full suite in its fixed 30-minute window and, on a production change, the `deploy` job publishes to the homeserver (`jorgesolla1313.com.br`, self-hosted) — migrations applied before the rollout. Do not add a Vercel/Git-integration deploy gate.
|
||||
**There is no deploy from PR CI.** Publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`): `verify` runs the full suite (hosted) and only then the `deploy` job (self-hosted runner on the homeserver) publishes `jorgesolla1313.com.br` — migrations applied before the rollout. Do not add a Vercel/Git-integration deploy gate.
|
||||
|
||||
When watching CI, use the PR page (or the API) — the safety net is what waits on checks; `gh` does not exist in this repo (OPS50).
|
||||
When watching CI, use the PR page (or the API) — the safety net arms the server-side auto-merge; `gh` does not exist in this repo (OPS50).
|
||||
|
||||
Use the rollup context `CI (PR) / checks` — without it, you are not covering the cascade.
|
||||
Use the required check `CI (PR) / checks` — without it, you are not covering the cascade.
|
||||
|
||||
## Promote
|
||||
|
||||
There is no stage→main promote. Merging to `main` is the delivery; `ci.yml` verifies the full suite and, on a production change, deploys to the homeserver (OPS53/OPS65; runbook: `docs/ops/teqo-1313-deploy.md`).
|
||||
There is no stage→main promote. Merging to `main` is the delivery; publishing is a deliberate manual dispatch with full verification (runbook: `docs/ops/teqo-1313-deploy.md`).
|
||||
|
||||
## Safety net (workflow)
|
||||
|
||||
`agent-pr-ready-automerge.yml` — PRs same-repo → `main` (todas as branches).
|
||||
O workflow roda `scripts/forgejo-pr-automerge.mjs` (plain Node, zero-dep): se
|
||||
draft `cursor/*` → marca Ready; espera os checks e mergea por rebase (o
|
||||
equivalente Forgejo do auto-merge — sem `gh`). O wait exige o rollup
|
||||
`CI (PR) / checks` POSTADO e verde (OPS61) — o único contexto que só existe
|
||||
quando a cascata inteira do ci-pr assentou (jobs ainda não agendados não
|
||||
postam status; o próprio status do job do safety net é `pending` durante o run
|
||||
e **nunca** entra no veredito). O CLI nunca confia no rollup sozinho: varre
|
||||
todos os statuses individuais e falha em qualquer `failure`/`error` — um shard
|
||||
de e2e vermelho bloqueia o merge mesmo com o rollup verde (OPS64; run 607).
|
||||
A regra de branch protection no servidor
|
||||
bloqueia o merge POST (405) enquanto o contexto exigido não estiver satisfeito
|
||||
— inclusive merges manuais via API: foi um merge manual que colocou a PR #52
|
||||
em main com o CI vermelho (o CLI nunca mergeou sozinho antes do OPS61; todo
|
||||
merge Forgejo era manual).
|
||||
O workflow roda `scripts/github-pr-automerge.mjs` (plain Node, zero-dep,
|
||||
REST+GraphQL): se draft `cursor/*` → marca Ready (PATCH `draft: false`); arm
|
||||
`enablePullRequestAutoMerge` (GraphQL, mergeMethod REBASE) no `node_id` do PR
|
||||
— o **servidor** espera o required check `CI (PR) / checks` POSTADO e verde
|
||||
antes de mergear; o próprio check-run do safety net fica `pending` durante o
|
||||
run e não interfere. A regra de branch protection no servidor
|
||||
(`enforce_admins: true`) bloqueia qualquer caminho de merge — inclusive API
|
||||
manual — enquanto o required check não estiver satisfeito (foi um merge
|
||||
manual que colocou a PR #52 em main com o CI vermelho na era Forgejo).
|
||||
Cobre o caso em que o
|
||||
agente (ou o default draft do Cloud) esqueceu e PRs humanas/worktree com merge
|
||||
falho silencioso (incidente OPS52). **Draft de branch não-`cursor/*` nunca é
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Established 2026-07-23 by the engineering-standards hardening (tracker: `docs/IM
|
|||
|
||||
1. **Dead code dies immediately.** After your edits, check what your change orphaned and delete it in the same delivery: run `pnpm exec knip` (files/dependencies are CI-blocking errors; unused exports are warnings being driven to zero). Never leave commented-out code, unused exports, superseded components, or one-shot scripts behind — git history is the archive.
|
||||
2. **Zero warnings.** `pnpm lint` runs with `--max-warnings=0`. Deliberate placeholders use a `_` prefix. Never silence a rule without an inline justification comment.
|
||||
3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (iteração durante a sessão): `pnpm gate:fast` (lint + typecheck + test:unit). **Push canônico:** `pnpm push` — `ensure-repo-deps` (install + Husky em worktrees) + `git push`; o hook pre-push roda `gate:push` → `gate:ci` (espelho serial do `ci-pr.yml` **sem e2e** — OPS59: o e2e roda **uma vez, no CI**, em PR e na main; local opcional para feedback antecipado: `pnpm test:e2e:affected`; **com os `docs-guards` locais** — OPS63: changelog append-only, agregado sync e marcadores de conflito, mesmos scripts do CI; o escape `changelog-rewrite:` fica só no CI, body do PR). Não use `git push` nu. Fonte única: scripts `gate:*` e `scripts/git-push.mjs` em `package.json`; escape para WIP: `git push --no-verify`. Migrations NÃO são serializadas entre PRs: a cadeia é validada por `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation`) em todo PR e na main; rebase antes de `migrate:create` continua obrigatório (o antigo `migration-lock` foi removido em 2026-08-12 — deadlock de CI). Entrega = merge em `main`; deploy gated pelo verificador `ci.yml`. **CI vermelho no teu PR é teu, mesmo pré-existente ou de infra** — "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`).
|
||||
3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (iteração durante a sessão): `pnpm gate:fast` (lint + typecheck + test:unit). **Push canônico:** `pnpm push` — `ensure-repo-deps` (install + Husky em worktrees) + `git push`; o hook pre-push roda `gate:push` → `gate:ci` (espelho serial do `ci-pr.yml` **sem e2e** — OPS72: o e2e é passo **explícito da skill** (`work-issue`/`agent-work-issue`): antes do push rode localmente os e2e que criou + os da mesma superfície, **discricionário** via `pnpm test:e2e:affected`; o CI de PR roda só o blast radius `selected` — nunca `full` — e o full fica no `verify` do deploy manual; **com os `docs-guards` locais** — OPS63: changelog append-only, agregado sync e marcadores de conflito, mesmos scripts do CI; o escape `changelog-rewrite:` fica só no CI, body do PR). Não use `git push` nu. Fonte única: scripts `gate:*` e `scripts/git-push.mjs` em `package.json`; escape para WIP: `git push --no-verify`. Migrations NÃO são serializadas entre PRs: a cadeia é validada por `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation`) em todo PR; rebase antes de `migrate:create` continua obrigatório (o antigo `migration-lock` foi removido em 2026-08-12 — deadlock de CI). Entrega = merge em `main`; publish gated pelo `verify` do `deploy.yml` (dispatch manual — OPS71; não há `ci.yml` verificador de main). **CI vermelho no teu PR é teu, mesmo pré-existente ou de infra** — "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`).
|
||||
4. **Run gate commands bare, never piped** (`pnpm test | tail` swallows the exit code). Never run `knip --fix` blind — verify each removal with `git grep -w <symbol>` first (knip still cannot load `payload.config.ts`, so its graph is incomplete; ledgered as P3).
|
||||
|
||||
## Type honesty
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ Conduz **uma** Issue já claimada do plano de implementação ao merge em `main`
|
|||
- [ ] 1. Sessão: rename_chat + open_resource na intenção
|
||||
- [ ] 2. Modelo: spawn do pool já fixou (`model:` da Issue) — sem comparação
|
||||
- [ ] 3. Plan mode → plano de implementação (`*-impl.md`)
|
||||
- [ ] 4. Executar o impl plan (schema/server → UI → gates)
|
||||
- [ ] 4. Executar o impl plan (schema/server → UI → e2e local afetado → gates)
|
||||
- [ ] 5. /simplify → fixes; capture-review-debts (modo autônomo)
|
||||
- [ ] 6. PR Ready `--base main` + auto-merge + checks --required
|
||||
- [ ] 6. PR Ready `--base main` (GitHub) + auto-merge nativo — `pnpm push -u origin HEAD` → `node scripts/github-pr.mjs` (ou `ManagePullRequest` `draft: false` no Cloud)
|
||||
```
|
||||
|
||||
## Passo 0 — Prep
|
||||
|
|
@ -79,13 +79,20 @@ O spawn do pool já fixou o modelo (`model:` da Issue — ver `model-selection`)
|
|||
(executar → simplify → fechar em main), com os deltas do ator pool:
|
||||
|
||||
- **Branch:** `agent/<id>-<slug>` (worktrees Cursor podem já ter criado).
|
||||
- **E2E local afetado (OPS72):** mesmo passo discricionário do ator humano
|
||||
(rodar os e2e criados + da mesma superfície — ver `execution-pipeline.md`);
|
||||
**worktree com browsers Playwright** → rode; **Cursor Cloud sem browsers** →
|
||||
registre a justificativa no fechamento e siga — o CI de PR roda o blast
|
||||
radius quando o diff classifica `selected`; em diff high-risk (`full`) o PR
|
||||
não roda e2e e a rede final é o `verify` do deploy.
|
||||
- **UI:** shape → craft → critique → polish (harden/optimize só sob gatilho).
|
||||
- **`capture-review-debts`:** **autônomo** — só `expensive_lock` com score ≥4
|
||||
(Issues novas com `depends: [<id-pai>]` se necessário); score ≤3 /
|
||||
cheap_polish / defer_trigger → defer com gatilho no `*-impl.md` ou descarte —
|
||||
**não** polua a fila.
|
||||
- **Cloud:** PR via `ManagePullRequest` com `draft: false`; Prep Cloud no
|
||||
Passo 0.
|
||||
- **Cloud:** PR via `ManagePullRequest` com `draft: false` no **GitHub**
|
||||
(repo/PR agora no GitHub; o tracker de Issues segue no Forgejo — OPS71);
|
||||
Prep Cloud no Passo 0.
|
||||
|
||||
## Resumo final
|
||||
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ Para cada lote com destino **registrar** ou **absorver**:
|
|||
|
||||
Não implemente as fases aqui. Execução é via `work-issue` / `agent-work-issue`, só se o usuário (ou o pipeline autônomo) seguir.
|
||||
|
||||
**Próximo no fluxo de entrega:** após a triage (confirmada ou modo autônomo), o fechamento segue o Passo 6 de `agent-work-issue` / `work-issue` (`pnpm push` → PR `--base main` com `Closes #N` → auto-merge → CI).
|
||||
**Próximo no fluxo de entrega:** após a triage (confirmada ou modo autônomo), o fechamento segue o Passo 6 de `agent-work-issue` / `work-issue` (`pnpm push` → PR no GitHub `--base main` com `Closes #N` → auto-merge nativo → CI).
|
||||
|
||||
## Modo autônomo (`work-issue` pós-plano / `agent-work-issue` / pool)
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ pular a pausa do impl plan; Draft / sem auto-merge.
|
|||
- [ ] 1. Contexto: Issue do prompt/`$ARGUMENTS` da sessão (ausente → UMA pergunta com validação; nunca claim)
|
||||
- [ ] 2. Abrir o plano de intenção do body da Issue (`Plano: docs/plans/...`; sem link → body é spec)
|
||||
- [ ] 3. Plan mode → escrever `docs/plans/<slug>-impl.md` → **PARAR e confirmar com o humano**
|
||||
- [ ] 4. Após “ok”: Passo 4 (execução → /simplify → débitos autônomos → PR, via `execution-pipeline.md`)
|
||||
- [ ] 4. Após “ok”: Passo 4 (execução → e2e local afetado → /simplify → débitos autônomos → PR, via `execution-pipeline.md`)
|
||||
```
|
||||
|
||||
## Passo 1 — Contexto da sessão
|
||||
|
|
@ -96,6 +96,12 @@ nunca invente produto novo.
|
|||
fechar em main), com os deltas do ator humano:
|
||||
|
||||
- **Branch:** `<Code>-<slug>` do worktree — nunca crie branch nova na sessão.
|
||||
- **E2E local afetado (OPS72, discricionário):** antes do push, rode
|
||||
localmente os e2e que você **criou** + os da **mesma superfície afetada** —
|
||||
você decide quais (`pnpm test:e2e:affected` ou specs diretas). Não está no
|
||||
`gate:push`; ver a seção "E2E local afetado" do `execution-pipeline.md`
|
||||
(incl. a limitação da #72: `--no-deps` + projetos paralelos → `--workers=1`
|
||||
ou a cadeia padrão de projetos).
|
||||
- **UI:** shape → craft → critique → polish.
|
||||
- **`capture-review-debts`:** **autônomo** — colha, dedupe, pontue e **decida
|
||||
você mesmo o destino** (registrar/absorver/deferir/descartar) pela triage
|
||||
|
|
|
|||
|
|
@ -12,12 +12,42 @@ Ordem:
|
|||
testes de domínio. Invariantes do engineering-brief.
|
||||
2. **UI** — se Impeccable B/C/D: shape → craft → critique → polish. Tokens
|
||||
`data-theme='campaign'`; shells existentes.
|
||||
3. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push` (não
|
||||
3. **E2E local afetado (OPS72, discricionário)** — antes do push, rode
|
||||
localmente os e2e que você **criou** + os da **mesma superfície afetada**
|
||||
(você decide quais). Ver §E2E local afetado.
|
||||
4. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push` (não
|
||||
`git push` nu).
|
||||
|
||||
Tracer bullet cedo se o item for grande. Inclua o `*-impl.md` no commit da
|
||||
entrega.
|
||||
|
||||
## E2E local afetado (OPS72)
|
||||
|
||||
Política de e2e: **local = discricionário, CI/PR = blast radius, main = full
|
||||
antes do deploy**.
|
||||
|
||||
- **O que rodar:** os specs e2e que o diff **criou** (novos `.e2e.spec.ts`) +
|
||||
os da mesma superfície trabalhada (o `E2E_AFFECTED_MANIFEST` mapeia
|
||||
`src/` → specs; a mecânica do CI é a mesma). **É discricionário: você
|
||||
decide quais rodar** — não é um gate mecânico e não está no `gate:push`.
|
||||
- **Ferramenta:** `pnpm test:e2e:affected` (espelho local do classifier:
|
||||
modo `none`/`selected`/`full` conforme o diff; roda `migrate` +
|
||||
`db:seed:minimal` antes). Alternativa para specs diretas:
|
||||
`pnpm test:e2e --no-deps -- tests/e2e/<spec>.e2e.spec.ts` (ou
|
||||
`--no-deps --project=<família>`).
|
||||
- **CI/PR:** roda só o **blast radius** detectado (`ci-scope.mjs`, modo
|
||||
`selected`) — nunca `full`. Diff high-risk (schema/lockfile/harness)
|
||||
classifica `full` → o PR não roda e2e: o full fica para o `verify` do
|
||||
deploy manual (antes de publicar) e, nesse mesmo diff, o espelho local
|
||||
roda full.
|
||||
- **Limitação da #72 (S3-FOLLOWUP):** e2e local com `--no-deps` + projetos
|
||||
paralelos (`--project=a --project=b`) colide no `seedTestUser` (delete +
|
||||
create concorrentes nos `beforeAll` → `ValidationError: email` e falhas
|
||||
fantasma). Use `--workers=1` ou a cadeia padrão de projetos. Só afeta o
|
||||
local: no CI/prod mode a cadeia de deps dos projects é descartada (as
|
||||
famílias rodam em paralelo) e os runs selecionados filtram por arquivo —
|
||||
quase sempre uma família — então o seed não colide.
|
||||
|
||||
## /simplify + débitos
|
||||
|
||||
1. Rode o comando `/simplify` completo (3 reviewers paralelos via Task,
|
||||
|
|
@ -34,10 +64,15 @@ entrega.
|
|||
e rode `pnpm changelog:build` para regenerar `docs/CHANGELOG-AGENTS.md`
|
||||
(insert-only: entradas históricas nunca mudam; o diff será só a entrada
|
||||
nova + o agregado). Rode `pnpm changelog:check` para confirmar.
|
||||
3. **`pnpm push -u origin HEAD`**
|
||||
4. PR via API/MCP (ou `ManagePullRequest` no Cloud) — **Ready** (nunca draft), base `main`, com `Closes #<N>`
|
||||
5. O safety net `agent-pr-ready-automerge.yml` espera o rollup `CI (PR) / checks` e mergea por rebase — nada a armar
|
||||
6. CI flipa `done`/`in-prod` no merge. Comente na Issue o desfecho em uma linha.
|
||||
3. **`pnpm push -u origin HEAD`** — origin é o **GitHub** (OPS71; o tracker de
|
||||
Issues continua no Forgejo por API).
|
||||
4. PR no **GitHub** via `GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs --head <branch> --title "<id> — <título>" --body-file <arquivo>` — **Ready** (nunca draft; o script não tem flag draft), base `main`, body com `Closes #<N>` (ou `Related #N` em plans-only). Em Cursor Cloud: `ManagePullRequest` com `draft: false`.
|
||||
5. O safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo do
|
||||
GitHub** (`enablePullRequestAutoMerge`, rebase) — o servidor mergea quando
|
||||
o required check `CI (PR) / checks` fica verde; nada a armar.
|
||||
6. No merge, o workflow `issue-done-on-main-merge.yml` flipa `done`/`in-prod`
|
||||
**no Forgejo** (lê o body do PR via API do GitHub, escreve no tracker por
|
||||
`FORGEJO_API_TOKEN`). Comente na Issue o desfecho em uma linha.
|
||||
|
||||
## Deltas por ator
|
||||
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
# Safety net: every same-repo PR to main must be Ready + auto-merge.
|
||||
# Covers Cloud ManagePullRequest defaulting to draft, agents that forget to
|
||||
# arm the merge, and human/worktree PRs (the OPS52 incident was a human PR
|
||||
# whose merge failed silently). Draft PRs from non-`cursor/*` branches are
|
||||
# never touched — the draft flag is the actor's veto (the CLI skips them).
|
||||
# Fork PRs are excluded (the repo is public; a merged fork PR would deploy).
|
||||
#
|
||||
# Forgejo port (OPS50): Forgejo's API has no auto-merge scheduling, so the
|
||||
# workflow runs scripts/forgejo-pr-automerge.mjs (plain Node): marks draft →
|
||||
# ready, waits for the commit statuses (the branch-protection `checks` gate),
|
||||
# then merges by rebase. Same semantics as `gh pr merge --auto --rebase`,
|
||||
# implemented as poll + merge. GitHub's cancel-workflow-on-failure action was
|
||||
# dropped (no GITHUB_RUN_ID plumbing on Forgejo).
|
||||
#
|
||||
# OPS61: the job injects FORGEJO_API_TOKEN. The merge POST performs a git push
|
||||
# of the rebased branch to main; Forgejo's pre-receive hook rejects that push
|
||||
# when the caller's user has no repo write — and the native GITHUB_TOKEN acts
|
||||
# as the Actions system user (no write, and the same 403 that broke issue
|
||||
# label flips). Every pre-OPS61 "auto-merge" that ended success had actually
|
||||
# found the PR already merged manually; the CLI's own merge attempts died on
|
||||
# 409 "User permission denied for writing".
|
||||
name: PR Ready + auto-merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
ready-automerge:
|
||||
# Same-repo only. This MUST stay in the `if:` (pre-checkout): for a fork
|
||||
# PR, actions/checkout checks out the fork's head, so a same-repo guard
|
||||
# inside the CLI would already be running the fork's own script — RCE with
|
||||
# the Actions token. Fork PRs are excluded here because the repo is public
|
||||
# and a merged fork PR would deploy attacker code to production. The draft
|
||||
# policy lives in the CLI (skips non-`cursor/*` drafts) because there the
|
||||
# PR state is re-read from the API, not from webhook payload fields.
|
||||
if: >-
|
||||
github.event.pull_request.state == 'open' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
# OPS61: the CLI now waits for the full CI (PR) cascade (the `checks`
|
||||
# rollup posts only after every ci-pr job, incl. e2e shards) — 55 min keeps
|
||||
# 10 min of slack over the CLI's 45 min timeout.
|
||||
timeout-minutes: 55
|
||||
env:
|
||||
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
|
||||
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Mark Ready and merge when checks pass
|
||||
run: node scripts/forgejo-pr-automerge.mjs --pr "${{ github.event.pull_request.number }}"
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
# Post-merge verifier on `main`, in a fixed 30-minute window (OPS65): the
|
||||
# workflow no longer fires on every push. The `schedule` trigger polls every
|
||||
# 30 min and a cheap gate (`gate`, runs-on host, no pnpm) classifies whether
|
||||
# anything that would enter the docker build context changed since the
|
||||
# revision running on the homeserver (the `org.opencontainers.image.revision`
|
||||
# label of the running container, read over ssh).
|
||||
# - Deployed == HEAD → suite + deploy skip; the run stays green.
|
||||
# - Anything else (production change, unknown deployed state, dispatch) →
|
||||
# full suite; deploy in the same window.
|
||||
# - `workflow_dispatch` always runs the full pipeline (explicit operator
|
||||
# intent); the deploy is idempotent via the "already deployed" guard in
|
||||
# scripts/deploy-homeserver.sh.
|
||||
#
|
||||
# Fail-open: an unreadable/unknown deployed revision makes the gate report
|
||||
# production=true, so the suite ALWAYS verifies main and a quiet day only
|
||||
# skips when we KNOW deployed == HEAD. A gate that cannot classify at all
|
||||
# (git/.dockerignore error) exits non-zero and turns the run RED — never a
|
||||
# silent green blackout. The deploy job only runs when the gate succeeded
|
||||
# AND production changed AND checks are green — never deploy blind.
|
||||
#
|
||||
# When the suite is green, the `deploy` job publishes teqo-1313 on the
|
||||
# homeserver (OPS53): it streams scripts/deploy-homeserver.sh to the homeserver
|
||||
# over SSH; the remote script builds the standalone image at the pushed SHA,
|
||||
# pushes it to the local registry (localhost:5000), applies pending migrations
|
||||
# through the compose maintenance service and rolls the container out.
|
||||
#
|
||||
# SINGLE sequential job (OPS62, X1): one container, one checkout, one pnpm
|
||||
# install, one runner slot. Fail-fast is STRUCTURAL — a failed step skips
|
||||
# every step after it, the job fails, the run fails and the slot is released
|
||||
# immediately. No cancel-run API (Forgejo 9.0.3 has none), no watchdog job,
|
||||
# no upgrades. `deploy` (needs: [checks]) never starts on a red run.
|
||||
#
|
||||
# Forgejo port (OPS50): Postgres services live on the job network and are
|
||||
# reached by NAME (postgres-int / postgres-build on 5432) — no host publish,
|
||||
# so concurrent runs never collide on host ports (OPS62 X1). No
|
||||
# `permissions:` (Forgejo issues one native GITHUB_TOKEN) and no
|
||||
# `concurrency:` (unsupported). `gh workflow run` requeues became
|
||||
# `node scripts/forgejo-dispatch.mjs`.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/30 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
PAYLOAD_SECRET: test-only-secret-not-used-in-production
|
||||
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: gate production change
|
||||
# Host runner: needs `ssh homeserver` (same environment as the deploy
|
||||
# job — the key lives on the workstation host) and a full clone to diff
|
||||
# <deployed-rev>..HEAD. Main + dispatch only: a branch dispatch must not
|
||||
# run that branch's code on the host — the skipped gate makes the suite
|
||||
# run fail-open on ubuntu containers instead (test-only secrets).
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: host
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
production: ${{ steps.classify.outputs.production }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Read deployed revision and classify production change
|
||||
id: classify
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Truth = the RUNNING container's revision label (the compose file
|
||||
# can lie after a failed rollback — it is swapped before the
|
||||
# rollout). Empty/malformed = unknown deployed state → the
|
||||
# classifier reports production=true (full run, green).
|
||||
deployed_sha="$(ssh homeserver "docker inspect -f '{{index .Config.Labels \"org.opencontainers.image.revision\"}}' teqo-1313 2>/dev/null" || true)"
|
||||
if ! printf '%s' "$deployed_sha" | grep -Eq '^[0-9a-f]{40}$'; then
|
||||
deployed_sha=""
|
||||
fi
|
||||
echo "deployed_sha: ${deployed_sha:-<none>}"
|
||||
out="$(node scripts/ci-classify-production.mjs --deployed "$deployed_sha")"
|
||||
printf '%s\n' "$out"
|
||||
echo "production=$(printf '%s' "$out" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).production')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 50
|
||||
needs: [gate]
|
||||
# Fail-open: an errored gate never skips the verifier; only a gate that
|
||||
# succeeded AND found no production change skips the suite.
|
||||
if: needs.gate.result != 'success' || needs.gate.outputs.production == 'true'
|
||||
services:
|
||||
# Services are reached by NAME on the job network (no host publish —
|
||||
# OPS62 X1): concurrent runs never collide on host ports, which the
|
||||
# single long-lived job would hold for the whole run. Two isolated
|
||||
# services — int and build+e2e — keep e2e from inheriting int fixture
|
||||
# residue. Forgejo 9 does not expand expressions in `services.ports`,
|
||||
# so a per-run port scheme was dropped in favour of the job-network DNS.
|
||||
postgres-int:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
postgres-build:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check test file locations
|
||||
run: node scripts/check-test-locations.mjs
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
- name: Format check
|
||||
run: pnpm format:check
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
- name: Knip
|
||||
run: pnpm knip
|
||||
- name: Check cycles
|
||||
run: pnpm check:cycles
|
||||
- name: Unit tests (full suite)
|
||||
run: pnpm test:unit
|
||||
|
||||
- name: Prepare database URL (int service)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int:5432/teqo_test" >> "$GITHUB_ENV"
|
||||
# vitest re-loads .env.test over the process env, so the service URL
|
||||
# must win through the .env.test.local layer (same mechanism
|
||||
# `pnpm worktree next` uses). OPS50.
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int:5432/teqo_test" > .env.test.local
|
||||
- name: Migrate (int database)
|
||||
run: pnpm migrate
|
||||
- name: Seed minimal (int database)
|
||||
run: pnpm db:seed:minimal
|
||||
- name: Integration tests (full suite)
|
||||
env:
|
||||
# Self-hosted runner has 8 CPUs; vitest forks ≈ CPUs and each fork
|
||||
# holds a Payload pool (max 10) → overflows the service Postgres' 100
|
||||
# clients ("too many clients already"). 4 forks stay well under. The
|
||||
# unit step above stays CPU-relative (no DB).
|
||||
VITEST_MAX_WORKERS: 4
|
||||
run: pnpm test:int
|
||||
|
||||
- name: Prepare database URL (build/e2e service)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" >> "$GITHUB_ENV"
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" > .env.test.local
|
||||
- name: Migrate (build/e2e database)
|
||||
run: pnpm migrate
|
||||
- name: Seed minimal (build/e2e database)
|
||||
run: pnpm db:seed:minimal
|
||||
|
||||
- name: Build (against the service database)
|
||||
run: pnpm build
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
playwright-${{ runner.os }}-
|
||||
- name: Install Playwright chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Build for e2e
|
||||
# Same dir the Playwright webServer serves (playwright.config.ts
|
||||
# falls back to '.next-e2e'; gate-ci.mjs builds there too).
|
||||
run: pnpm build
|
||||
env:
|
||||
NEXT_DIST_DIR: .next-e2e
|
||||
- name: E2E tests (full suite, single job, 4 workers)
|
||||
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
|
||||
# 4 workers in one process replace the former 2 shards × 2 workers.
|
||||
# No `--` separator: pnpm would forward it verbatim to playwright and
|
||||
# the flag would be parsed as a file pattern ("No tests found").
|
||||
env:
|
||||
PLAYWRIGHT_WORKERS: 4
|
||||
run: pnpm test:e2e
|
||||
|
||||
deploy:
|
||||
name: deploy homeserver (teqo-1313)
|
||||
# OPS53. Runs directly on the runner HOST (not a job container): the
|
||||
# deploy needs the workstation's SSH key to reach the homeserver
|
||||
# (`ssh homeserver`) and streams the whole remote build/migrate/rollout
|
||||
# to this job's logs. Deliberately wired ONLY here — main window +
|
||||
# workflow_dispatch — never in ci-pr.yml: a host job is code execution as
|
||||
# `fsolla` on the workstation, and main only ever moves through a merged
|
||||
# PR whose full suite was already green.
|
||||
# OPS65: double-gated — the verifier must be green AND the gate must have
|
||||
# found a production change. The explicit `if` prevents both "deploy
|
||||
# blind on a failed gate" and "deploy with a red suite" (a bare output
|
||||
# condition would run despite failed needs).
|
||||
needs: [checks, gate]
|
||||
if: needs.checks.result == 'success' && needs.gate.outputs.production == 'true'
|
||||
runs-on: host
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Deploy teqo-1313 on the homeserver
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh homeserver "bash -s -- $GITHUB_SHA" < scripts/deploy-homeserver.sh
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
# Main merge flips claimed issues to done + in-prod.
|
||||
# Forgejo port (OPS50): the gh loop became scripts/forgejo-issue-transition.mjs
|
||||
# (plain Node, reads the PR body from the API itself).
|
||||
# OPS61: the job injects FORGEJO_API_TOKEN — the native GITHUB_TOKEN 403s on
|
||||
# issue label writes, and the flip was silently skipped (script logged the 403
|
||||
# and the job still ended success). The fork guard is the same class as
|
||||
# agent-pr-ready-automerge.yml: on a merged fork PR the checkout pulls the
|
||||
# fork's own script, which would run with the repo PAT (RCE).
|
||||
name: Main merge flips claimed issues to done + in-prod
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
issue-done:
|
||||
if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
|
||||
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Flip Closes/Fixes #N issues to done and in-prod
|
||||
run: node scripts/forgejo-issue-transition.mjs --pr "${{ github.event.pull_request.number }}"
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
# Safety net OPS18: when a PR merges to main with `Related #N`, promote Issues
|
||||
# that are still awaiting their intention plan (`blocked` + docs/plans/ link)
|
||||
# to `ready`. Idempotent with `pnpm agent:ready` (agent happy path).
|
||||
#
|
||||
# Does NOT run on open PRs — only after merge — so the claim race stays closed.
|
||||
# Job is gated on Related # in the body so non-plan merges skip checkout/Node.
|
||||
# Forgejo port (OPS50): script reads the PR via the Forgejo API (plain Node).
|
||||
# OPS61: the job injects FORGEJO_API_TOKEN — the native GITHUB_TOKEN 403s on
|
||||
# issue label writes (same silent-skip failure as issue-done-on-main-merge).
|
||||
# Fork guard: on a merged fork PR the checkout pulls the fork's own script,
|
||||
# which would run with the repo PAT (RCE — same class as
|
||||
# agent-pr-ready-automerge.yml).
|
||||
name: Promote plan-issue Related on main merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
plan-issue-ready:
|
||||
if: >-
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
(
|
||||
contains(github.event.pull_request.body, 'Related #') ||
|
||||
contains(github.event.pull_request.body, 'related #') ||
|
||||
contains(github.event.pull_request.body, 'RELATED #')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script is plain Node (stdlib) — no pnpm. Disable
|
||||
# package-manager-cache: setup-node@v5 defaults to true and looks for
|
||||
# pnpm (packageManager in package.json) before pnpm/action-setup.
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Promote Related #N awaiting plan → ready
|
||||
run: node scripts/agent-promote-related-on-merge.mjs --pr "${{ github.event.pull_request.number }}"
|
||||
64
.github/workflows/agent-pr-ready-automerge.yml
vendored
Normal file
64
.github/workflows/agent-pr-ready-automerge.yml
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Safety net (OPS71 — GitHub): every same-repo PR to main must be Ready +
|
||||
# auto-merge armed. Covers Cloud ManagePullRequest defaulting to draft,
|
||||
# agents that forget to arm the merge, and human/worktree PRs (the OPS52
|
||||
# incident was a human PR whose merge failed silently). Draft PRs from
|
||||
# non-`cursor/*` branches are never touched — the draft flag is the actor's
|
||||
# veto (the CLI skips them). Fork PRs are excluded (the repo is public; a
|
||||
# merged fork PR would deploy) — same RCE-guard class as the other workflows.
|
||||
#
|
||||
# GitHub's NATIVE auto-merge replaces the Forgejo-era poll CLI (removed in
|
||||
# OPS71 Fase 2): `enablePullRequestAutoMerge` (GraphQL, REBASE —
|
||||
# the repo's canonical merge style) is server-side — GitHub only merges when
|
||||
# every required check (the `CI (PR) / checks` rollup of ci-pr.yml, OPS62) is
|
||||
# green and the PR is mergeable. The OPS64 "rollup can lie" defense is
|
||||
# structural here: a single sequential job posts one honest check-run and the
|
||||
# server enforces it — nothing to poll, nothing to trust.
|
||||
#
|
||||
# Permissions: contents+pull-requests write — enough for PATCH draft:false
|
||||
# and the GraphQL auto-merge mutation with the built-in GITHUB_TOKEN.
|
||||
name: PR Ready + auto-merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
ready-automerge:
|
||||
# Same-repo only. This MUST stay in the `if:` (pre-checkout): for a fork
|
||||
# PR, actions/checkout checks out the fork's head, so a same-repo guard
|
||||
# inside the CLI would already be running the fork's own script — RCE
|
||||
# with the Actions token. The draft policy lives in the CLI (skips
|
||||
# non-`cursor/*` drafts) because there the PR state is re-read from the
|
||||
# API, not from webhook payload fields.
|
||||
if: >-
|
||||
github.event.pull_request.state == 'open' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
|
||||
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Mark Ready and arm auto-merge
|
||||
env:
|
||||
# Explicit `github.token` — the built-in GITHUB_TOKEN env is not
|
||||
# guaranteed to be injected on every event path; passing it via the
|
||||
# documented context is the robust form.
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# OPS71-FLIP: arming with the built-in GITHUB_TOKEN makes the merge
|
||||
# happen as github-actions[bot], and GitHub's anti-recursion never
|
||||
# creates workflow runs for events triggered by GITHUB_TOKEN actions
|
||||
# — the post-merge `closed` flips would silently never run (PR #746).
|
||||
# AUTOMERGE_PAT (real user PAT) attributes the merge to a user, so
|
||||
# the `closed` event creates runs again. Fail-closed in the CLI.
|
||||
AUTOMERGE_PAT: ${{ secrets.AUTOMERGE_PAT }}
|
||||
run: node scripts/github-pr-automerge.mjs --pr "${{ github.event.pull_request.number }}"
|
||||
|
|
@ -11,6 +11,8 @@ on:
|
|||
description: Cursor run id (run-…) optional cancel first
|
||||
required: false
|
||||
type: string
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
archive:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -1,53 +1,71 @@
|
|||
# PR gate: feature → main. SINGLE sequential job (OPS62, X1): one container,
|
||||
# one checkout, one pnpm install, one runner slot. Fail-fast is STRUCTURAL —
|
||||
# a failed step skips every step after it, the job fails, the run fails and
|
||||
# the slot is released immediately. No cancel-run API (Forgejo 9.0.3 has
|
||||
# none), no watchdog job, no upgrades: with one job there are no siblings to
|
||||
# cancel. Steps keep their own logs, so diagnosis is intact after a failure.
|
||||
# The job id `checks` preserves the `CI (PR) / checks` status context the
|
||||
# automerger waitForChecks and branch protection rely on (OPS61).
|
||||
# PR gate: feature → main (OPS71 — GitHub Actions). SINGLE sequential job
|
||||
# (OPS62, X1): one container, one checkout, one pnpm install, one runner slot.
|
||||
# Fail-fast is STRUCTURAL — a failed step skips every step after it, the job
|
||||
# fails and the run fails. GitHub-native `concurrency` cancels the in-flight
|
||||
# run when the PR gets a new push (the Forgejo era had no cancel API; here it
|
||||
# is free and matches the OPS62 "one slot per PR" spirit).
|
||||
#
|
||||
# The job id `checks` preserves the `CI (PR) / checks` check-run that the
|
||||
# branch protection rule and the native auto-merge rely on (OPS71): GitHub
|
||||
# names it <workflow name> / <job name> — keep both literals frozen. GitHub's
|
||||
# auto-merge is server-side: it only merges with every required check green,
|
||||
# so the OPS64 "rollup can lie" defense is structural here (single job posts
|
||||
# one honest check-run; no matrix, no shards).
|
||||
#
|
||||
# Job-level skips come from the `scope` classifier (scripts/ci-scope.mjs) run
|
||||
# as a step; its outputs drive `if:` on the heavy steps (same modes as the
|
||||
# former per-job cascade: none/changed/full).
|
||||
# as a step; its outputs drive `if:` on the heavy steps (code/build/test:
|
||||
# none/changed/full; e2e: none/selected/full). e2e policy (OPS72): the PR
|
||||
# runs ONLY the blast radius — the specs the classifier detected as touched
|
||||
# (mode `selected`), never `full`. A high-risk diff (schema/lockfile/test
|
||||
# harness) classifies as `full` → the PR simply runs no e2e: the local agent
|
||||
# mirror (`pnpm test:e2e:affected`) runs full in that same case, and the
|
||||
# deploy `verify` job runs the full suite before publishing.
|
||||
#
|
||||
# Schema migrations are NOT serialized across PRs (migration-lock removed
|
||||
# 2026-08-12): a stale chain is caught by `pnpm migrate` + `test:int` on every
|
||||
# PR and on main (ci.yml), incl. campaignMigrationReconciliation — the check's
|
||||
# hard fail deadlocked every open schema PR the moment two coexisted.
|
||||
# PR, incl. campaignMigrationReconciliation.
|
||||
#
|
||||
# Forgejo port (OPS50): Postgres services live on the job network and are
|
||||
# reached by NAME (postgres-int / postgres-build on 5432) — no host publish,
|
||||
# so concurrent runs never collide on host ports (OPS62 X1). Two isolated
|
||||
# services keep e2e from inheriting int fixture residue.
|
||||
# Fork-PRs are excluded at job level (`if:` same-repo): the repo is public and
|
||||
# a fork PR would burn hosted minutes running arbitrary code. Same guard class
|
||||
# as the RCE guard on the post-merge workflows.
|
||||
#
|
||||
# Postgres services (OPS71 — GitHub hosted): jobs run directly on the runner
|
||||
# VM (NOT inside the job-container network of the Forgejo era), so services
|
||||
# are reached at localhost on PUBLISHED host ports. Each hosted run is its own
|
||||
# VM, so fixed ports never collide across runs. Two isolated services — int
|
||||
# on 5432 and build+e2e on 5433 — keep e2e from inheriting int fixture
|
||||
# residue.
|
||||
name: CI (PR)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-pr-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PAYLOAD_SECRET: test-only-secret-not-used-in-production
|
||||
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 50
|
||||
services:
|
||||
# Services are reached by NAME on the job network (no host publish —
|
||||
# OPS62 X1): concurrent runs never collide on host ports, which the
|
||||
# single long-lived job would hold for the whole run. Two isolated
|
||||
# services — int and build+e2e — keep e2e from inheriting int fixture
|
||||
# residue (same isolation the former jobs had). Forgejo 9 does not
|
||||
# expand expressions in `services.ports`, so a per-run port scheme was
|
||||
# dropped in favour of the job-network DNS.
|
||||
postgres-int:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
|
|
@ -59,6 +77,8 @@ jobs:
|
|||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
ports:
|
||||
- 5433:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
|
|
@ -133,11 +153,11 @@ jobs:
|
|||
- name: Prepare database URL (int service)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int:5432/teqo_test" >> "$GITHUB_ENV"
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" >> "$GITHUB_ENV"
|
||||
# vitest re-loads .env.test over the process env, so the service URL
|
||||
# must win through the .env.test.local layer (same mechanism
|
||||
# `pnpm worktree next` uses). OPS50.
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-int:5432/teqo_test" > .env.test.local
|
||||
# `pnpm worktree next` uses).
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" > .env.test.local
|
||||
- name: Migrate (int database)
|
||||
run: pnpm migrate
|
||||
- name: Seed minimal (int database)
|
||||
|
|
@ -146,10 +166,10 @@ jobs:
|
|||
- name: Integration tests (full suite)
|
||||
if: steps.scope.outputs.test_mode == 'full'
|
||||
env:
|
||||
# Self-hosted runner has 8 CPUs; vitest forks ≈ CPUs and each fork
|
||||
# holds a Payload pool (max 10) → overflows the service Postgres' 100
|
||||
# clients ("too many clients already", CI run 180). 4 forks stay
|
||||
# well under. The unit step above stays CPU-relative (no DB).
|
||||
# vitest forks ≈ CPUs and each fork holds a Payload pool (max 10) →
|
||||
# overflows the service Postgres' 100 clients ("too many clients
|
||||
# already"). 4 forks stay well under. The unit step above stays
|
||||
# CPU-relative (no DB).
|
||||
VITEST_MAX_WORKERS: 4
|
||||
run: pnpm test:int
|
||||
- name: Integration tests (changed since PR base)
|
||||
|
|
@ -159,16 +179,16 @@ jobs:
|
|||
run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests
|
||||
|
||||
- name: Prepare database URL (build/e2e service)
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode != 'none'
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" >> "$GITHUB_ENV"
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@postgres-build:5432/teqo_test" > .env.test.local
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" >> "$GITHUB_ENV"
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" > .env.test.local
|
||||
- name: Migrate (build/e2e database)
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode != 'none'
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'
|
||||
run: pnpm migrate
|
||||
- name: Seed minimal (build/e2e database)
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode != 'none'
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'
|
||||
run: pnpm db:seed:minimal
|
||||
|
||||
- name: Build (against the local service database)
|
||||
|
|
@ -176,7 +196,7 @@ jobs:
|
|||
run: pnpm build
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
if: steps.scope.outputs.e2e_mode != 'none'
|
||||
if: steps.scope.outputs.e2e_mode == 'selected'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
|
@ -184,26 +204,23 @@ jobs:
|
|||
restore-keys: |
|
||||
playwright-${{ runner.os }}-
|
||||
- name: Install Playwright chromium
|
||||
if: steps.scope.outputs.e2e_mode != 'none'
|
||||
if: steps.scope.outputs.e2e_mode == 'selected'
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Build for e2e
|
||||
# Selected mode needs the build too (the Playwright webServer serves
|
||||
# .next-e2e; the fallback `.next-e2e` dir is what gate-ci.mjs builds).
|
||||
if: steps.scope.outputs.e2e_mode != 'none'
|
||||
# the `.next-e2e` production build).
|
||||
if: steps.scope.outputs.e2e_mode == 'selected'
|
||||
run: pnpm build
|
||||
env:
|
||||
NEXT_DIST_DIR: .next-e2e
|
||||
- name: E2E tests (full suite, single job, 4 workers)
|
||||
if: steps.scope.outputs.e2e_mode == 'full'
|
||||
- name: E2E tests (blast radius — manifest-selected specs)
|
||||
if: steps.scope.outputs.e2e_mode == 'selected'
|
||||
# OPS72: the PR never runs e2e full — mode `full` (high-risk diff)
|
||||
# skips this step by design (local mirror + deploy verify cover it).
|
||||
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
|
||||
# 4 workers in one process replace the former 2 shards × 2 workers.
|
||||
# No `--` separator: pnpm would forward it verbatim to playwright and
|
||||
# the flag would be parsed as a file pattern ("No tests found").
|
||||
env:
|
||||
PLAYWRIGHT_WORKERS: 4
|
||||
run: pnpm test:e2e
|
||||
- name: E2E tests (manifest-selected specs)
|
||||
if: steps.scope.outputs.e2e_mode == 'selected'
|
||||
env:
|
||||
PLAYWRIGHT_WORKERS: 4
|
||||
# The `--` separates pnpm from the scope paths: pnpm consumes it and
|
||||
# playwright receives the bare spec paths (no flag parsing drift).
|
||||
run: pnpm test:e2e -- ${{ steps.scope.outputs.e2e_specs }}
|
||||
170
.github/workflows/deploy.yml
vendored
Normal file
170
.github/workflows/deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
# Manual production deploy (OPS71 — GitHub Actions). `workflow_dispatch`
|
||||
# only: there is NO automatic main verifier anymore (ci.yml eliminated in the
|
||||
# gate 2026-08-19) — publishing is a deliberate operator act, and this
|
||||
# workflow runs the FULL suite (no scope skips, e2e included) before the
|
||||
# deploy job touches the homeserver.
|
||||
#
|
||||
# TWO jobs, same workflow:
|
||||
# verify — ubuntu-latest (hosted): full cascade (guards for PR contracts are
|
||||
# left out — they gate PRs, not production).
|
||||
# deploy — needs: [verify], runs-on [self-hosted, homeserver]: the GitHub
|
||||
# self-hosted runner ON the homeserver executes
|
||||
# scripts/deploy-homeserver.sh LOCALLY (no SSH; outbound-only
|
||||
# runner, works behind the Cloudflare tunnel). The hosted job
|
||||
# never reaches the homeserver; the self-hosted job never counts
|
||||
# hosted minutes.
|
||||
#
|
||||
# The production build CANNOT leave the homeserver (migrate pre-build on the
|
||||
# prod DB — OPS66 — and the compose network / local registry localhost:5000),
|
||||
# which is exactly why the hosted job only verifies and the self-hosted job
|
||||
# only builds + deploys. Deploy is idempotent ("already deployed" guard in
|
||||
# deploy-homeserver.sh) and serialized by flock on the homeserver.
|
||||
#
|
||||
# The `deploy` job fetches the repo from github.com (public) — TEQO_REPO_URL
|
||||
# in deploy-homeserver.sh now defaults there; the script's HEAD guard only
|
||||
# deploys when the dispatched SHA is main's HEAD (a stale dispatch skips).
|
||||
|
||||
name: Deploy (manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
PAYLOAD_SECRET: test-only-secret-not-used-in-production
|
||||
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 50
|
||||
services:
|
||||
# OPS71 — GitHub hosted: jobs run on the runner VM, so services are
|
||||
# reached at localhost on PUBLISHED host ports (fixed — each run is its
|
||||
# own VM). Two isolated services — int on 5432, build+e2e on 5433 —
|
||||
# keep e2e from inheriting int fixture residue.
|
||||
postgres-int:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
postgres-build:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
POSTGRES_PASSWORD: teqo
|
||||
POSTGRES_DB: teqo_test
|
||||
ports:
|
||||
- 5433:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U teqo"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check test file locations
|
||||
run: node scripts/check-test-locations.mjs
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
- name: Format check
|
||||
run: pnpm format:check
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
- name: Knip
|
||||
run: pnpm knip
|
||||
- name: Check cycles
|
||||
run: pnpm check:cycles
|
||||
- name: Unit tests (full suite)
|
||||
run: pnpm test:unit
|
||||
|
||||
- name: Prepare database URL (int service)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" >> "$GITHUB_ENV"
|
||||
# vitest re-loads .env.test over the process env, so the service URL
|
||||
# must win through the .env.test.local layer (same mechanism
|
||||
# `pnpm worktree next` uses).
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" > .env.test.local
|
||||
- name: Migrate (int database)
|
||||
run: pnpm migrate
|
||||
- name: Seed minimal (int database)
|
||||
run: pnpm db:seed:minimal
|
||||
- name: Integration tests (full suite)
|
||||
env:
|
||||
# vitest forks ≈ CPUs and each fork holds a Payload pool (max 10) →
|
||||
# overflows the service Postgres' 100 clients. 4 forks stay well
|
||||
# under. The unit step above stays CPU-relative (no DB).
|
||||
VITEST_MAX_WORKERS: 4
|
||||
run: pnpm test:int
|
||||
|
||||
- name: Prepare database URL (build/e2e service)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" >> "$GITHUB_ENV"
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" > .env.test.local
|
||||
- name: Migrate (build/e2e database)
|
||||
run: pnpm migrate
|
||||
- name: Seed minimal (build/e2e database)
|
||||
run: pnpm db:seed:minimal
|
||||
- name: Build (against the service database)
|
||||
run: pnpm build
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
playwright-${{ runner.os }}-
|
||||
- name: Install Playwright chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Build for e2e
|
||||
# Same dir the Playwright webServer serves (playwright.config.ts
|
||||
# falls back to '.next-e2e'; gate-ci.mjs builds there too).
|
||||
run: pnpm build
|
||||
env:
|
||||
NEXT_DIST_DIR: .next-e2e
|
||||
- name: E2E tests (full suite, single job, 4 workers)
|
||||
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
|
||||
# No `--` separator: pnpm would forward it verbatim to playwright and
|
||||
# the flag would be parsed as a file pattern ("No tests found").
|
||||
env:
|
||||
PLAYWRIGHT_WORKERS: 4
|
||||
run: pnpm test:e2e
|
||||
|
||||
deploy:
|
||||
name: deploy homeserver (teqo-1313)
|
||||
# OPS53 + OPS71. Runs on the GitHub self-hosted runner ON the homeserver
|
||||
# (labels self-hosted + homeserver): the script executes locally — no SSH
|
||||
# hop, no workstation involvement. Deliberately wired ONLY here (manual
|
||||
# dispatch), never in ci-pr.yml: a self-hosted job is code execution on
|
||||
# the production machine, and only an explicit dispatch with the full
|
||||
# verify suite green can reach it.
|
||||
needs: [verify]
|
||||
if: needs.verify.result == 'success'
|
||||
runs-on: [self-hosted, homeserver]
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Deploy teqo-1313 on the homeserver
|
||||
run: bash scripts/deploy-homeserver.sh "$GITHUB_SHA"
|
||||
80
.github/workflows/issue-done-on-main-merge.yml
vendored
Normal file
80
.github/workflows/issue-done-on-main-merge.yml
vendored
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# Main merge flips claimed issues to done + in-prod.
|
||||
# OPS71: the PR lives on GitHub; the ISSUES stay on the Forgejo tracker. The
|
||||
# job passes the PR body via PR_BODY (GitHub event payload) and
|
||||
# scripts/forgejo-issue-transition.mjs flips labels/comments and closes the
|
||||
# issue on the Forgejo API with the FORGEJO_API_TOKEN PAT (the built-in
|
||||
# GITHUB_TOKEN cannot touch Forgejo issues — OPS61). The fork guard is the
|
||||
# same class as agent-pr-ready-automerge.yml: on a merged fork PR the checkout
|
||||
# pulls the fork's own script, which would run with the repo PAT (RCE).
|
||||
#
|
||||
# OPS71-FLIP: `workflow_dispatch` is the MANUAL recovery path. The `closed`
|
||||
# event does NOT create a run when the merge was performed by
|
||||
# github-actions[bot] (auto-merge armed with the built-in GITHUB_TOKEN —
|
||||
# GitHub's anti-recursion; live finding PR #746). Dispatch with the PR number:
|
||||
# the job fetches the PR via `gh` (same contract: merged + base main +
|
||||
# same-repo) and runs the same flip.
|
||||
name: Main merge flips claimed issues to done + in-prod
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
description: 'PR number (manual recovery when the closed event did not create a run)'
|
||||
required: true
|
||||
type: number
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
issue-done:
|
||||
if: >-
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.pr != '')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
|
||||
# Explicit base: in the GitHub Actions env, GITHUB_SERVER_URL points at
|
||||
# github.com and forgejo-api.mjs would resolve the wrong base (410).
|
||||
FORGEJO_API_URL: https://git.solla.dev/api/v1
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
|
||||
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Fetch PR metadata (manual recovery — bot auto-merges skip the closed event)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
id: pr
|
||||
run: |
|
||||
PR="${{ inputs.pr }}"
|
||||
STATE="$(gh pr view "$PR" --json state -q .state)"
|
||||
if [ "$STATE" != "MERGED" ]; then
|
||||
echo "PR #$PR não mergeada (state=$STATE) — skip"
|
||||
exit 0
|
||||
fi
|
||||
BASE="$(gh pr view "$PR" --json baseRefName -q .baseRefName)"
|
||||
REPO="$(gh pr view "$PR" --json headRepository -q .headRepository.nameWithOwner)"
|
||||
if [ "$BASE" != "main" ] || [ "$REPO" != "${{ github.repository }}" ]; then
|
||||
echo "PR #$PR fora do contrato (base=$BASE repo=$REPO — precisa main + same-repo) — skip"
|
||||
exit 0
|
||||
fi
|
||||
BODY="$(gh pr view "$PR" --json body -q .body)"
|
||||
{
|
||||
echo "valid=true"
|
||||
echo "pr_number=$PR"
|
||||
echo "pr_body<<EOF"
|
||||
echo "$BODY"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Flip Closes/Fixes #N issues to done and in-prod
|
||||
if: steps.pr.outputs.valid == 'true' || github.event_name == 'pull_request'
|
||||
env:
|
||||
PR_BODY: ${{ steps.pr.outputs.pr_body || github.event.pull_request.body }}
|
||||
run: node scripts/forgejo-issue-transition.mjs --pr "${{ steps.pr.outputs.pr_number || github.event.pull_request.number }}"
|
||||
88
.github/workflows/plan-issue-ready-on-main-merge.yml
vendored
Normal file
88
.github/workflows/plan-issue-ready-on-main-merge.yml
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Safety net OPS18: when a PR merges to main with `Related #N`, promote Issues
|
||||
# that are still awaiting their intention plan (`blocked` + docs/plans/ link)
|
||||
# to `ready`. Idempotent with `pnpm agent:ready` (agent happy path). OPS71:
|
||||
# the PR lives on GitHub, the issues on the Forgejo tracker — the job passes
|
||||
# the PR body via PR_BODY and scripts/agent-promote-related-on-merge.mjs flips
|
||||
# labels on the Forgejo API (FORGEJO_API_TOKEN; the built-in GITHUB_TOKEN
|
||||
# cannot touch Forgejo issues — OPS61).
|
||||
#
|
||||
# Does NOT run on open PRs — only after merge — so the claim race stays closed.
|
||||
# Job is gated on Related # in the body so non-plan merges skip checkout/Node.
|
||||
# Fork guard: on a merged fork PR the checkout pulls the fork's own script,
|
||||
# which would run with the repo PAT (RCE — same class as
|
||||
# agent-pr-ready-automerge.yml).
|
||||
#
|
||||
# OPS71-FLIP: `workflow_dispatch` is the MANUAL recovery path — the `closed`
|
||||
# event does NOT create a run when the merge was performed by
|
||||
# github-actions[bot] (auto-merge armed with the built-in GITHUB_TOKEN;
|
||||
# GitHub's anti-recursion, live finding PR #746). Dispatch with the PR number:
|
||||
# the job fetches the PR via `gh` (same contract: merged + base main +
|
||||
# same-repo) and runs the same promotion.
|
||||
name: Promote plan-issue Related on main merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
description: 'PR number (manual recovery when the closed event did not create a run)'
|
||||
required: true
|
||||
type: number
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
plan-issue-ready:
|
||||
if: >-
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository && (contains(github.event.pull_request.body, 'Related #') || contains(github.event.pull_request.body, 'related #') || contains(github.event.pull_request.body, 'RELATED #'))) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.pr != '')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
|
||||
# Explicit base: in the GitHub Actions env, GITHUB_SERVER_URL points at
|
||||
# github.com and forgejo-api.mjs would resolve the wrong base (410).
|
||||
FORGEJO_API_URL: https://git.solla.dev/api/v1
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script is plain Node (stdlib) — no pnpm. Disable
|
||||
# package-manager-cache: setup-node@v5 defaults to true and looks for
|
||||
# pnpm (packageManager in package.json) before pnpm/action-setup.
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Fetch PR metadata (manual recovery — bot auto-merges skip the closed event)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
id: pr
|
||||
run: |
|
||||
PR="${{ inputs.pr }}"
|
||||
STATE="$(gh pr view "$PR" --json state -q .state)"
|
||||
if [ "$STATE" != "MERGED" ]; then
|
||||
echo "PR #$PR não mergeada (state=$STATE) — skip"
|
||||
exit 0
|
||||
fi
|
||||
BASE="$(gh pr view "$PR" --json baseRefName -q .baseRefName)"
|
||||
REPO="$(gh pr view "$PR" --json headRepository -q .headRepository.nameWithOwner)"
|
||||
if [ "$BASE" != "main" ] || [ "$REPO" != "${{ github.repository }}" ]; then
|
||||
echo "PR #$PR fora do contrato (base=$BASE repo=$REPO — precisa main + same-repo) — skip"
|
||||
exit 0
|
||||
fi
|
||||
BODY="$(gh pr view "$PR" --json body -q .body)"
|
||||
{
|
||||
echo "valid=true"
|
||||
echo "pr_number=$PR"
|
||||
echo "pr_body<<EOF"
|
||||
echo "$BODY"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Promote Related #N awaiting plan → ready
|
||||
if: steps.pr.outputs.valid == 'true' || github.event_name == 'pull_request'
|
||||
env:
|
||||
PR_BODY: ${{ steps.pr.outputs.pr_body || github.event.pull_request.body }}
|
||||
run: node scripts/agent-promote-related-on-merge.mjs --pr "${{ steps.pr.outputs.pr_number || github.event.pull_request.number }}"
|
||||
File diff suppressed because one or more lines are too long
12
README.md
12
README.md
|
|
@ -6,16 +6,16 @@
|
|||
|
||||
> **Cheatsheet de operação agentic (humanos)** — o fluxo em 5 linhas:
|
||||
>
|
||||
> 1. O agente roda `pnpm agent:claim` (ou `pnpm worktree next --issue N`) e pega a próxima Issue `ready` por prioridade (`prio:P0..P3`).
|
||||
> 1. O agente roda `pnpm agent:claim` (ou `pnpm worktree next --issue N`) e pega a próxima Issue `ready` por prioridade (`prio:P0..P3`). O **tracker de Issues vive no Forgejo** (`git.solla.dev/fsolla/teqo`); o código/PR/CI vive no **GitHub**.
|
||||
> 2. Ele implementa, roda o fast gate (`lint + typecheck + unit`) e `pnpm push -u origin HEAD` — abre a PR para `main` (Ready, `Closes #N`).
|
||||
> 3. CI verde → o safety net `agent-pr-ready-automerge.yml` mergea em `main` por rebase; o verificador `ci.yml` do main roda a suíte completa na janela fixa de 30 min.
|
||||
> 4. Com mudança de produção → o job `deploy` publica no homeserver (`jorgesolla1313.com.br`, self-hosted) — migrações aplicadas antes do rollout.
|
||||
> 5. Secrets humanos (uma vez): `FORGEJO_API_TOKEN` no Forgejo; envs de prod em `~/stack/teqo-1313.env` no homeserver. Branch protection de `main` já aplicada (reaplicar: `pnpm configure:branch-protection`).
|
||||
> 3. CI verde (`CI (PR) / checks`) → o safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo** (rebase) — o servidor só mergea com o required check verde.
|
||||
> 4. Publicar é **manual**: dispatch de `deploy.yml` roda a suíte full (`verify`) e o job `deploy` publica no homeserver (`jorgesolla1313.com.br`, runner self-hosted) — migrações aplicadas antes do rollout. Nada é automático pós-merge.
|
||||
> 5. Secrets humanos (uma vez): `FORGEJO_API_TOKEN` no GitHub (flips pós-merge no Forgejo); envs de prod em `~/stack/teqo-1313.env` no homeserver. Branch protection de `main` já aplicada (reaplicar: `pnpm configure:branch-protection`).
|
||||
>
|
||||
> Comandos: `pnpm agent:claim | agent:register | agent:prioritize | agent:file-miss | worktree next` e `pnpm db:seed:minimal`.
|
||||
> Labels: estado `ready|in-progress|blocked|done|in-prod`, `prio:P0..P3`, `kind:*`, `needs:migration|consent`, `requirements-changed`.
|
||||
> **Agente faz sozinho:** claim → implementar → PR → main. **Só humano:** envs do homeserver, branch protection, runbook de rollback.
|
||||
> Tudo em detalhe: [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md) · CI: `.forgejo/workflows/ci-pr.yml` + `ci.yml`.
|
||||
> **Agente faz sozinho:** claim → implementar → PR → main. **Só humano:** deploy (dispatch), envs do homeserver, branch protection, runbook de rollback.
|
||||
> Tudo em detalhe: [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md) · CI: `.github/workflows/ci-pr.yml` · Deploy: `.github/workflows/deploy.yml`.
|
||||
|
||||
Teqo starts as the official digital platform for **deputado Jorge Solla** and evolves into a **white-label civic engagement platform** for politicians in Brazil.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
# AGENT-OPS — operação do paradigma de agentes paralelos
|
||||
|
||||
Uma página. Norte: simplicidade (só Forgejo + Cursor), agente entrega em `main`, deploy de produção só após o verificador full-suite verde.
|
||||
Uma página. Norte: simplicidade (Forgejo = tracker, GitHub = Actions), agente entrega em `main`, deploy de produção é **manual** com verificação full (OPS71).
|
||||
|
||||
## Ambientes de dados (test ladder — nunca misturar)
|
||||
|
||||
| Ambiente | Conteúdo | Quem usa |
|
||||
| ------------------------------------------- | ------------------------------------------------------------ | -------------------------------- |
|
||||
| **Mínimo** (`teqo_test` local / service CI) | schema migrado + `pnpm db:seed:minimal` (sintético, sem PII) | agentes, Cursor Cloud, CI |
|
||||
| **Prod** (`teqo_1313`, homeserver) | real | job `deploy` do `ci.yml` (OPS53) |
|
||||
| Ambiente | Conteúdo | Quem usa |
|
||||
| ------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------- |
|
||||
| **Mínimo** (`teqo_test` local / service CI) | schema migrado + `pnpm db:seed:minimal` (sintético, sem PII) | agentes, Cursor Cloud, CI |
|
||||
| **Prod** (`teqo_1313`, homeserver) | real | job `deploy` do `deploy.yml` (OPS53 + OPS71) |
|
||||
|
||||
Agentes **nunca** recebem `DATABASE_URL` de prod e nunca setam `ALLOW_REMOTE_DB`. Não há mais smoke `stage` / `ALLOW_STAGE_TEST_DB` / `ci-stage.yml`.
|
||||
|
||||
## Fluxo
|
||||
|
||||
```text
|
||||
claim → feature branch → PR --base main → CI PR green (cascade + skips) → auto-merge em main
|
||||
main → ci.yml (janela de 30 min, OPS65) full suite verde → job `deploy` (homeserver, OPS53 — merge com mudança de produção == site atualizado em ~30 min + suite/deploy)
|
||||
claim → feature branch → PR --base main (GitHub) → CI PR green (cascade + skips) → auto-merge nativo em main (rebase)
|
||||
main → (opcional, deliberado) workflow_dispatch deploy.yml → verify full suite verde → job deploy no homeserver (OPS53)
|
||||
```
|
||||
|
||||
**Skills:** `plan-issue` (intenção + Issues) → `work-issue` (humano: Issue já claimada → impl plan → confirmação → execução) ou `agent-work-issue` (pool: já claimada → impl plan → execução sem pausa) → `/simplify` → `capture-review-debts` → PR `--base main` → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = Issues do Forgejo (`git.solla.dev/fsolla/teqo`).
|
||||
**Skills:** `plan-issue` (intenção + Issues) → `work-issue` (humano: Issue já claimada → impl plan → confirmação → execução) ou `agent-work-issue` (pool: já claimada → impl plan → execução sem pausa) → `/simplify` → `capture-review-debts` → PR `--base main` (GitHub) → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = Issues do Forgejo (`git.solla.dev/fsolla/teqo`).
|
||||
|
||||
- **Agente faz sozinho:** claim (pool-supervisor no pool; humano via `pnpm agent:claim` fora da sessão — OPS33: `worktree next` claima) → implementa → **`pnpm push`** → PR via API/MCP **Ready** (nunca draft) com `Closes #N` → o safety net `agent-pr-ready-automerge.yml` espera o rollup `CI (PR) / checks` e mergea por rebase. Regra always-on: `.agents/rules/agent-pr-workflow.mdc`. Em Cursor Cloud: `ManagePullRequest` com `draft: false` (o default draft da tool **não** vale neste repo) — o merge continua pelo safety net.
|
||||
- **Só humano:** envs de produção no homeserver (`~/stack/teqo-1313.env`, fora do repo — o deploy as lê via BuildKit secrets), `pnpm configure:branch-protection` (idempotente; reaplicar se o drift voltar), runbook manual/rollback (`docs/ops/teqo-1313-deploy.md`).
|
||||
- **Agente faz sozinho:** claim (pool-supervisor no pool; humano via `pnpm agent:claim` fora da sessão — OPS33: `worktree next` claima) → implementa → **e2e local afetado** (OPS72, discricionário: roda os e2e que criou + os da mesma superfície — `pnpm test:e2e:affected`; ver skill) → **`pnpm push`** (origin = GitHub) → PR via `node scripts/github-pr.mjs` **Ready** (nunca draft) com `Closes #N` → o safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo do GitHub** (`enablePullRequestAutoMerge`, rebase) — o servidor mergea quando o required check `CI (PR) / checks` fica verde. Regra always-on: `.agents/rules/agent-pr-workflow.mdc`. Em Cursor Cloud: `ManagePullRequest` com `draft: false` (o default draft da tool **não** vale neste repo) — o merge continua pelo safety net.
|
||||
- **Só humano:** envs de produção no homeserver (`~/stack/teqo-1313.env`, fora do repo — o deploy as lê via BuildKit secrets), `GITHUB_TOKEN` local (PAT) para os scripts GitHub, secrets no repo GitHub, `pnpm configure:branch-protection` (idempotente; reaplicar se o drift voltar), runbook manual/rollback (`docs/ops/teqo-1313-deploy.md`).
|
||||
|
||||
### Dono do PR, dono do CI
|
||||
|
||||
|
|
@ -36,28 +36,29 @@ main → ci.yml (janela de 30 min, OPS65) full suite verde → job `deploy` (hom
|
|||
|
||||
| Comando | Faz |
|
||||
| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief |
|
||||
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief (Forgejo) |
|
||||
| `pnpm worktree next [--issue N] [--stay]` | Claim determinístico (mesma fila/lock do claim) → worktree + provisionamento |
|
||||
| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
|
||||
| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue no Forgejo (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
|
||||
| `pnpm agent:ready -- --issue N[,N…]` | Pós-merge do plano: `blocked`→`ready` (só Issues com link `docs/plans/`) |
|
||||
| `pnpm agent:status` | Overview / fila / mermaid |
|
||||
| `pnpm agent:prioritize -- <issue> <P0..P3>` | Troca `prio:*` |
|
||||
| `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` |
|
||||
| `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Pool Cloud **dormente (OPS65)** — o workflow `agent-pool.yml` foi removido; dispatch falha 404; scripts/skill ficam para histórico |
|
||||
| `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push |
|
||||
| `pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main`: required status check `CI (PR) / checks` (rollup do ci-pr; idempotente — cria/atualiza/no-op) |
|
||||
| `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push para o **GitHub** (origin) |
|
||||
| `GITHUB_TOKEN=… node scripts/github-pr.mjs --head <b> --title <t> --body-file <f>` | Abre PR no **GitHub** (Ready, base `main`, nunca draft) |
|
||||
| `GITHUB_TOKEN=… pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main` no **GitHub**: required check `CI (PR) / checks` (idempotente — cria/atualiza/no-op) |
|
||||
| `pnpm db:seed:minimal` | DB mínimo sintético |
|
||||
|
||||
Labels: `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
|
||||
Labels (Forgejo): `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
|
||||
|
||||
**plan-issue (OPS17 + OPS18):** gate pós-overview fecha o lote antes de qualquer Issue/PR; register com `--plan` nasce `blocked` (não entra na fila do claim/pool); após o PR de planos mergear em `main` (`Related #N`), promote dual idempotente: (A) `pnpm agent:ready` na sessão; (B) Action `plan-issue-ready-on-main-merge.yml` lê `Related #N` e promove Issues ainda “aguardando plano” (`blocked` + link `docs/plans/`). Chores sem plano continuam nascendo `ready`.
|
||||
|
||||
## Contrato de PR
|
||||
|
||||
1. Schema → atualizar `db:seed:minimal` no mesmo PR (`needs:migration`).
|
||||
2. Migrations NÃO são serializadas entre PRs: o CI (migrate + int, incl. `campaignMigrationReconciliation`) valida a cadeia em todo PR e na main; rebase antes de `migrate:create` continua obrigatório.
|
||||
3. Ready + auto-merge: `pnpm push -u origin HEAD` → PR via API/MCP (Ready, base `main`, `Closes #N`) → o safety net `agent-pr-ready-automerge.yml` (`scripts/forgejo-pr-automerge.mjs`) espera o rollup `CI (PR) / checks` e mergea por rebase — não há `gh` em nenhuma máquina (OPS50), o safety net é quem espera os checks. **Merge é por rebase** (nunca merge commit): preserva a linha histórica limpa do `main` e é o modo que o CI guarda contra conflitos latentes.
|
||||
4. **Changelog da entrega (OPS44):** toda entrega escreve `docs/changelog/<data>-<id>.md` (ex. `2026-08-13-ops44.md`) e roda `pnpm changelog:build` antes do push — o agregado `docs/CHANGELOG-AGENTS.md` é **insert-only** (nunca remove/reescreve entradas; entradas históricas não são migradas). Guard de CI `docs-guards`: (a) agregado é append-only (multiset sobre blobs — perda de linha falha); (b) `docs/changelog/` é additions-only (M/D falham); (c) agregado está up to date com `docs/changelog/` (`changelog:check`); (d) nenhum marcador de conflito em diffs de markdown (docs/, AGENTS.md, .agents/ — cobre PRs docs-only, onde a suíte unit não roda). **Desde OPS63 os mesmos três checks rodam também no pre-push local** (`gate:push` → `gate:ci`, fase 1 — mesmos scripts, default `origin/main`). Restauração legítima (header muda, restauração estilo D8) escapa escrevendo **`changelog-rewrite: <motivo>` como linha própria** no body do PR — definição canônica aqui; o texto do checkbox do PR template nunca ativa (linha-ancorado). **O escape é CI-only (body do PR):** no pre-push local a restauração legítima falha com a mesma mensagem do CI — bypass documentado é `git push --no-verify` direto (o CI, com o escape no body, continua sendo a via da restauração). A própria troca `--merge` → `--rebase` deste doc usou o escape — header do agregado mudou.
|
||||
2. Migrations NÃO são serializadas entre PRs: o CI (migrate + int, incl. `campaignMigrationReconciliation`) valida a cadeia em todo PR; rebase antes de `migrate:create` continua obrigatório.
|
||||
3. Ready + auto-merge: `pnpm push -u origin HEAD` (GitHub) → PR via `node scripts/github-pr.mjs` (Ready, base `main`, `Closes #N`) → o safety net `agent-pr-ready-automerge.yml` (`scripts/github-pr-automerge.mjs`) arma o **auto-merge nativo do GitHub por rebase** — o servidor espera o required check `CI (PR) / checks` e mergea; nada a armar por PR, não há `gh` (OPS50) e não há poll (OPS71). **Merge é por rebase** (nunca merge commit): preserva a linha histórica limpa do `main` e é o modo do auto-merge.
|
||||
4. **Changelog da entrega (OPS44):** toda entrega escreve `docs/changelog/<data>-<id>.md` (ex. `2026-08-13-ops44.md`) e roda `pnpm changelog:build` antes do push — o agregado `docs/CHANGELOG-AGENTS.md` é **insert-only** (nunca remove/reescreve entradas; entradas históricas não são migradas). Guard de CI `docs-guards`: (a) agregado é append-only (multiset sobre blobs — perda de linha falha); (b) `docs/changelog/` é additions-only (M/D falham); (c) agregado está up to date com `docs/changelog/` (`changelog:check`); (d) nenhum marcador de conflito em diffs de markdown (docs/, AGENTS.md, .agents/ — cobre PRs docs-only, onde a suíte unit não roda). **Desde OPS63 os mesmos três checks rodam também no pre-push local** (`gate:push` → `gate:ci`, fase 1 — mesmos scripts, default `origin/main`). Restauração legítima (header muda, restauração estilo D8) escapa escrevendo **`changelog-rewrite: <motivo>` como linha própria** no body do PR — definição canônica aqui; o texto do checkbox do PR template nunca ativa (linha-ancorado). **O escape é CI-only (body do PR):** no pre-push local a restauração legítima falha com a mesma mensagem do CI — bypass documentado é `git push --no-verify` direto (o CI, com o escape no body, continua sendo a via da restauração).
|
||||
|
||||
## Registries compartilhados (serialização por arquivo, não por PR)
|
||||
|
||||
|
|
@ -67,39 +68,46 @@ Arquivos de "lista" que N agentes escrevem em paralelo (changelog, plans, migra
|
|||
- **`serializes` no plano de intenção** nomeia o registro exato que a Issue toca (ex. `serializes: 'docs/plans/'`), para o executor agendar com clareza — é dica de ordem, não lock (rebase continua obrigatório).
|
||||
- O rebase (Contrato de PR item 3) é o mecanismo real de conciliação: conflitos de append resolvem com `git checkout --ours/--theirs` por lado.
|
||||
|
||||
## CI por alvo
|
||||
## CI por alvo (GitHub Actions, OPS71)
|
||||
|
||||
| Workflow | Trigger | Banco | Passos |
|
||||
| ------------------------------------ | ----------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ci-pr.yml` | PR → `main` | 2× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha e o slot é liberado; e2e 1 processo com `PLAYWRIGHT_WORKERS=4`; 2 services por nome na rede do job (`postgres-int`/`postgres-build`, sem publish host) |
|
||||
| `ci.yml` | schedule `*/30` / dispatch `main` | 2× service Postgres 17 | Janela fixa (OPS65): gate `gate` (host, main-only, sem pnpm) classifica mudança de produção desde a **revision do container rodando** no homeserver (`.dockerignore` = fonte da verdade; fail-open; incapaz de classificar → run **vermelho**); sem mudança → suite + deploy skipped, run verde; com mudança → job único `checks` sequencial (mesma mecânica X1) + job `deploy` (runs-on `host`) publica no homeserver via `scripts/deploy-homeserver.sh` (OPS53, guard "already deployed") |
|
||||
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Closes`/`Fixes` → `done` + `in-prod` (PAT `FORGEJO_API_TOKEN`; `GITHUB_TOKEN` 403 em Issues) |
|
||||
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip; mesmo PAT) |
|
||||
| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `forgejo-pr-automerge.mjs` (plain Node): espera o rollup `checks` e mergea por rebase com `FORGEJO_API_TOKEN` (o `GITHUB_TOKEN` nativo não tem write no repo — o push do merge é rejeitado com 409); draft `cursor/*` → Ready; draft não-`cursor/*` = veto, skip |
|
||||
| `archive-cursor-agent.yml` | dispatch manual (ops) | — | Helper: cancela run + arquiva um agente Cursor Cloud (`CURSOR_API_KEY`) — **dormente (OPS65)** como o pool |
|
||||
| Workflow | Trigger | Banco | Passos |
|
||||
| ------------------------------------ | --------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `ci-pr.yml` | PR → `main` (same-repo) | 2× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha; `concurrency` nativo cancela o run em push novo; e2e **apenas blast radius** (`selected`, nunca `full` — OPS72) em 1 processo com `PLAYWRIGHT_WORKERS=4`; 2 services por nome na rede do job (`postgres-int`/`postgres-build`, sem publish host); fork-PR excluída no `if:` do job (repo público — não queima minutes) |
|
||||
| `deploy.yml` | **`workflow_dispatch` manual** | 2× service Postgres 17 | **Dois jobs:** `verify` (hosted, suíte **full** sem skips — lint→format→typecheck→knip→cycles→unit→int→build→e2e full) → `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh <sha>` **localmente** no homeserver (sem SSH; idempotente; HEAD guard; flock; migrator→migrate→runner→rollout→smoke) |
|
||||
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Closes`/`Fixes` → `done` + `in-prod` + **close** **no Forgejo** (PAT `FORGEJO_API_TOKEN`; body do PR via `PR_BODY` do evento GitHub — no dispatch, busca via `gh` com o mesmo contrato: merged + main + same-repo) |
|
||||
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Related #N` aguardando plano → `ready` **no Forgejo** (OPS18; soft-skip; mesmo PAT) |
|
||||
| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `github-pr-automerge.mjs` (plain Node, REST+GraphQL): draft `cursor/*` → Ready (PATCH); arm `enablePullRequestAutoMerge` (rebase) **com o `AUTOMERGE_PAT`** (fail-closed — nunca o `GITHUB_TOKEN` nativo, cujo merge como `github-actions[bot]` não cria runs no `closed`; OPS71-FLIP) — o **servidor** espera o required check `CI (PR) / checks` (o OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto); draft não-`cursor/*` = veto, skip |
|
||||
| `archive-cursor-agent.yml` | dispatch manual (ops) | — | Helper: cancela run + arquiva um agente Cursor Cloud (`CURSOR_API_KEY`) — **dormente (OPS65)** como o pool |
|
||||
|
||||
`agent-pool.yml` foi **removido no OPS65** (pool dormente; o tick `*/10` custava 144 `pnpm install`/dia na workstation).
|
||||
`agent-pool.yml` foi **removido no OPS65** (pool dormente; o tick `*/10` custava 144 `pnpm install`/dia na workstation). O verificador de main (`ci.yml`) foi **eliminado no OPS71** (gate 2026-08-19): só o `verify` do dispatch manual roda a suíte full.
|
||||
|
||||
Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5`, `pnpm/action-setup@v6` e `styfle/cancel-workflow-action@0.13.1` usam Node 24 nativo — sem `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`.
|
||||
Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5`, `pnpm/action-setup@v6` e `actions/cache@v4`.
|
||||
|
||||
Fast gate: `pnpm gate:fast`. Push: `pnpm push`.
|
||||
|
||||
### Secrets (humano, uma vez)
|
||||
### Secrets (humano, uma vez — repo GitHub)
|
||||
|
||||
| Secret | Uso |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `FORGEJO_API_TOKEN` | Supervisor do pool e scripts Forgejo (`forgejo-api.mjs`); também nos workflows pós-merge (`issue-done`, `plan-issue-ready`) e no safety net — o `GITHUB_TOKEN` nativo 403 em Issues e não tem write no repo (OPS61) |
|
||||
| `CURSOR_API_KEY` | Supervisor do pool (Cursor Cloud) — **dormente (OPS65)**; só o helper `archive-cursor-agent.yml` a usa hoje |
|
||||
| Secret | Uso |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `FORGEJO_API_TOKEN` | Scripts Forgejo (`forgejo-api.mjs`) e flips pós-merge (`issue-done`, `plan-issue-ready`) — o `GITHUB_TOKEN` nativo do GitHub não toca Issues do Forgejo (OPS61) |
|
||||
| `AUTOMERGE_PAT` | **Arm do auto-merge nativo** (`agent-pr-ready-automerge.yml`) — PAT de usuário real (fsolla) com `pull-requests: write` + `contents: write` no repo. **Sem ele o job falha fail-closed**: o `GITHUB_TOKEN` nativo armado faria o merge como `github-actions[bot]` e o `closed` do PR não criaria runs (anti-recursão do GitHub) — os flips pós-merge ficariam silenciosamente mortos (OPS71-FLIP, PR #746) |
|
||||
| `CURSOR_API_KEY` | Supervisor do pool (Cursor Cloud) — **dormente (OPS65)**; só o helper `archive-cursor-agent.yml` a usa hoje |
|
||||
|
||||
`POOL_GITHUB_TOKEN` é **legado** (era da transição GitHub): o `scripts/agent-pool.mjs` o ignora quando presente; não criar no Forgejo. Envs de produção (DB, S3, VAPID, `REVALIDATE_SECRET`, `NEXT_PUBLIC_SITE_URL`) vivem em `~/stack/teqo-1313.env` no homeserver — nunca como secrets do Forgejo.
|
||||
Local: `GITHUB_TOKEN` (PAT com escopo `repo`) para `node scripts/github-pr.mjs`, `pnpm configure:branch-protection` e qualquer script GitHub — o push SSH usa a chave `~/.ssh/id_ed25519` registrada no GitHub. Envs de produção (DB, S3, VAPID, `REVALIDATE_SECRET`, `NEXT_PUBLIC_SITE_URL`) vivem em `~/stack/teqo-1313.env` no homeserver — nunca como secrets do GitHub. `POOL_GITHUB_TOKEN` é **legado** (era da transição GitHub pré-OPS50).
|
||||
|
||||
Branch protection de `main` (aplicada em 2026-08-18, OPS61): required status check `CI (PR) / checks` (o rollup do `ci-pr.yml` — único contexto que cobre a cascata inteira), `strict=false`, 0 reviews. O `waitForChecks` do safety net espera esse mesmo rollup antes de mergear (o próprio status do job é `pending` durante o run e nunca entra no veredito); a regra no servidor é o gate real — o Forgejo bloqueia o merge POST com 405 se o contexto exigido não estiver verde, inclusive merges manuais via API (a PR #52 entrou em main com CI vermelho por merge manual). **OPS64:** o CLI nunca confia no rollup sozinho — varre os statuses individuais e falha em qualquer `failure`/`error` (pin em `forgejoApi.unit.spec.ts`, cenário run 607: rollup verde com shard de e2e vermelho → throw). Com o desenho de job único do OPS62 (sem matrix, sem shards) o status do job `checks` já é o veredito honesto da cascata — o bug de agregação do parent de matrix deixou de existir estruturalmente. Reaplicar/consertar drift: `pnpm configure:branch-protection` (idempotente).
|
||||
### Flips pós-merge (OPS71-FLIP): o evento `closed` e a recuperação manual
|
||||
|
||||
Deploy (OPS53 + OPS65): o `ci.yml` do main roda em **janela fixa de 30 min** (`schedule */30` + `workflow_dispatch` — não dispara mais a cada push). O job `gate` (host, sem pnpm) lê a **revision do container rodando** no homeserver (label `org.opencontainers.image.revision` — o compose pode mentir após rollback falho) e classifica "mudança de produção" pelos padrões do `.dockerignore` (fonte da verdade do artefato; `docs/`, `.agents/`, `.env*` viram skip; `scripts/`, `tests/`, `.forgejo/`, `AGENTS.md` contam como produção — conservador). Deployed == HEAD → suite e deploy skipped (run verde); estado desconhecido (sem revision) → suite roda fail-open; gate incapaz de classificar → suite roda e o run fica **vermelho** (nunca blackout). Com mudança → suite full verde → job `deploy` (runs-on `host`) streama `scripts/deploy-homeserver.sh` para o homeserver via SSH — build no homeserver (secrets de `~/stack/teqo-1313.env`), registry `localhost:5000`, migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout, smoke pós-deploy; o script é idempotente (revision do container já no SHA → skip verde "already deployed"). Nenhum secret novo no Forgejo. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel (`vercel-promote.yml`, scripts, `vercel.json`) foi removido no OPS50.
|
||||
O flip `issue-done`/`plan-issue-ready` roda no `pull_request: closed` do PR no GitHub — mas **o merge feito pelo auto-merge armado com o `GITHUB_TOKEN` nativo é atribuído a `github-actions[bot]` e NÃO cria workflow runs** (anti-recursão documentada do GitHub: eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline do PR com 0 runs — achado ao vivo no PR #746). Isso só não acontece porque o arm agora usa o `AUTOMERGE_PAT` (merge atribuído a usuário real → runs criados, comportamento do PR #742).
|
||||
|
||||
**Se mesmo assim o flip não rodou** (evento perdido, run falhou, PR mergeado por bot antes do fix): o workflow tem **`workflow_dispatch` de recuperação** — dispatch de `issue-done-on-main-merge.yml` (ou `plan-issue-ready-on-main-merge.yml`) com o input `pr` = número do PR; o job busca o PR via `gh` e aplica o mesmo contrato do evento (merged + base main + same-repo, senão skip). Fallback local (sem GitHub): `PR_BODY='Closes #N' node scripts/forgejo-issue-transition.mjs --pr <N>` com o `GITHUB_TOKEN` do ambiente (PAT). O flip agora também **fecha** a Issue (`PATCH state=closed` — o merge no GitHub não fecha Issues do Forgejo como o merge nativo da era Forgejo fazia). Falha em qualquer passo = job vermelho (OPS61, nunca mentir); o `forgejo-api.mjs` retenta 410/5xx transitórios (OPS67 + OPS71-FLIP: 410 do proxy retenta em qualquer método — a origem nunca viu o request).
|
||||
|
||||
Branch protection de `main` no **GitHub** (aplicada no cutover do OPS71, via `pnpm configure:branch-protection`): required check `CI (PR) / checks` (a UI exibe workflow/job; o **literal de match é `checks`** — o GitHub casa pelo nome do check-run — pin ao vivo PR #742), `strict=false`, 0 reviews, `enforce_admins: true` (nem admin mergea com CI vermelho — mesmo invariante do rule-based Forgejo). O auto-merge nativo espera esse mesmo check antes de mergear; a regra no servidor é o gate real de qualquer caminho de merge, inclusive API manual. Reaplicar/consertar drift: `pnpm configure:branch-protection` (idempotente).
|
||||
|
||||
Deploy (OPS53 + OPS71): **manual** via `workflow_dispatch` no `deploy.yml` — o job `verify` (hosted, suíte full incl. e2e full) roda **antes** do job `deploy` (`needs: [verify]`), que executa `scripts/deploy-homeserver.sh <sha>` no runner self-hosted do GitHub **no homeserver** (conecta outbound ao GitHub — funciona atrás do Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não conta minutos hosted). O script: HEAD guard (`TEQO_REPO_URL` — default `https://github.com/fsolla/teqo.git`, público) → flock → guard "already deployed" (revision do container) → build do migrator (BuildKit, secrets de `~/stack/teqo-1313.env`) → push/tag em `localhost:5000` → swap dos tags no compose (backup) → **migrations** (`teqo-1313-migrate` — antes do build do runner, OPS66) → build do runner (contra o banco JÁ migrado) → rollout → healthcheck → smoke. Falha = job vermelho; nada é publicado pela metade. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel foi removido no OPS50; o verificador automático de main foi eliminado no OPS71 — se um dia o merge em main quiser rede de regressão, o `ci.yml` volta com 1 linha de trigger (sem deploy).
|
||||
|
||||
## Cursor Cloud
|
||||
|
||||
`.cursor/environment.json` instala deps + Postgres nativo + `db:seed:minimal`. Sem secrets de prod. Preferir `pnpm push`; escape `git push --no-verify` só no cutover documentado acima.
|
||||
`.cursor/environment.json` instala deps + Postgres nativo + `db:seed:minimal`. Sem secrets de prod. Preferir `pnpm push`; escape `git push --no-verify` só no cutover documentado acima. PR via `ManagePullRequest` (draft: false) — o merge continua pelo safety net do GitHub.
|
||||
|
||||
## Agent pool
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,28 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
**C141 — perfis de permissão por assessor: Visão × Edição configuráveis (2026-08-19):** o coordenador/candidato define por assessor, em `/campanha/assessores` (coluna "Permissão" com badge + popover, seção no detalhe e no draft de novo assessor), o que ele **vê** (`carteira|tudo`) e o que ele **edita** (`carteira|tudo|somente_leitura`) — contas novas e existentes seguem no padrão Carteira·Edita carteira até configurarem. Enforcement fail-closed no servidor: a leitura alarga com Visão "Tudo" via prólogo novo `resolveProfileScopedRead` (municípios, lideranças, updates, pledges, atividades, contatos derivados), a escrita segue o eixo de Edição (`advisorEditingAccess`) nos `canUpdate*`/`canCreate*` de todos os domínios, no check do giro e no create individual de atividade (ambos contra `getWritableMunicipalityIds`), e a ficha de contatos passou a ser autorizada pelo escopo de escrita (`assertPersonContactWritable` — antes era "você edita o que vê", que a Visão "Tudo" alargaria em escrita de PII). Caps mantidos: demandas seguem a regra própria (C143), apoiadores (PII) e telefones de contas ficam na carteira mesmo com Tudo, e escritas de coordenação (contas, `municipality.advisors`, nível de envolvimento E14, `allocationDecision`) continuam restritas. Combinação incoerente (Edição "Tudo" sem Visão "Tudo") é rejeitada no zod, na action e no hook `enforceCoherentAdvisorProfile` (REST/admin incluídos). Migration `20260819_214755_add_campaign_user_advisor_permission_profile` (duas colunas select com default `'carteira'`, sem backfill).
|
||||
|
||||
**OPS73 — deploy do fix do admin em produção (2026-08-20):** o `/admin` do Payload (jorgesolla1313.com.br) voltou a renderizar o formulário de login em produção — o bug de "tela branca" que travava o CMS do time editorial/campanha está resolvido e verificado. Durante o diagnóstico descobriu-se que a premissa do plano ("produção nunca recebeu o fix OPS69") estava errada: o deploy das 23:09 de 2026-08-19 (revision `8c734ba`) já continha o fix do importMap **no histórico**, mas o commit S9 (`8d2352c0`, captura de novidades na home) tinha regenerado o importMap **sem** as envs `S3_*`, removendo o handler `@payloadcms/storage-s3/client#S3ClientUploadHandler` e reintroduzindo o erro `getFromImportMap: PayloadComponent not found in importMap` — o admin abria em branco de novo ("funcionou e no deploy seguinte parou"). Verificado também que o **main do GitHub havia divergido do Forgejo** (pós-cutover OPS71): o GitHub tinha o fix + OPS71, mas não as features S8 (seção "Nossa história") e S9 (captura de novidades) que produção já rodava — deployar o main como estava regrediria as duas. A entrega: PR #751 re-homou no GitHub main as features S8+S9 (cherry-pick dos commits aprovados no Forgejo `8d2352c0` + `488dcf7d`), mantendo o handler no importMap (prova: `generate:importmap` com envs `S3_*` dummy → diff vazio vs main); CI verde; aguardando o merge. Deploy pós-merge via o pipeline canônico do OPS71 (dispatch manual do `deploy.yml`: `verify` full → job `deploy` no runner self-hosted recém-instalado no homeserver — 2 tentativas "stale run" por merges paralelos no meio do verify, terceira aplicou `dba75ff8`); migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: container na revision `dba75ff8`, healthy, **log sem nenhum `getFromImportMap`**, `/admin` renderizando o formulário de login (2 inputs email/senha + botão Login, título "Login - Payload", 0 erros de console), e a home pública mantendo "Nossa história" (S8) e o form de novidades (S9). Evidência: `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`. Lições registradas: a regeneração do importMap sem envs `S3_*` re-órfão o handler (classe exata que o guard #87 deve impedir no CI); divergência de plataformas pós-cutover — workspace do homeserver ainda apontava para o Forgejo local (`localhost:3000`) e o S10 (pixel) segue pendente de re-home no GitHub.
|
||||
|
||||
**OPS73 — deploy do fix do admin em produção (2026-08-20):** o `/admin` do Payload (jorgesolla1313.com.br) voltou a renderizar o formulário de login em produção — o bug de "tela branca" que travava o CMS do time editorial/campanha está resolvido e verificado. O diagnóstico mostrou que a premissa do plano (fix nunca tinha chegado a prod) estava errada em dois pontos: (1) o deploy das 23:09 de 2026-08-19 (revision `8c734ba`) já continha o fix do importMap no histórico, mas o commit S9 (`8d2352c0`) regenerou o importMap **sem** as envs `S3_*`, removendo o handler `@payloadcms/storage-s3/client#S3ClientUploadHandler` e reintroduzindo `getFromImportMap: PayloadComponent not found` (admin em branco de novo); (2) o **main do GitHub havia divergido do Forgejo** (pós-cutover OPS71) — o GitHub tinha o fix + OPS71, mas não as features S8 ("Nossa história") e S9 (captura de novidades) que produção já rodava. A entrega (PR #751): re-home do S8+S9 no GitHub main (cherry-pick `8d2352c0`+`488dcf7d`), mantendo o handler no importMap (prova: `generate:importmap` com envs `S3_*` dummy → diff vazio vs main); deploy via `deploy.yml` no runner self-hosted do homeserver (`dba75ff8`), após 2 "stale run" por merges paralelos no verify; migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: revision `dba75ff8`, healthy, **log sem `getFromImportMap`**, `/admin` renderizando o login (2 inputs + botão, título "Login - Payload", 0 erros de console), home mantendo S8/S9. Evidência: `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`. Lições: regeneração do importMap sem envs `S3_*` re-órfão o handler (classe do guard OPS70/#87); divergência de plataformas pós-cutover (workspace homeserver ainda aponta Forgejo local; S10 pixel pendente de re-home).
|
||||
|
||||
**S11** — o feed do Instagram configurado **sem aparecer na home** fica diagnosticável: fim do fail-closed silencioso da S3. A assessoria vê, na mesma global onde preenche o token, o estado da sincronização (`instagramSyncStatus` jsonb hidden na `social-feed-settings`) e o **porquê** de os posts não aparecerem — o painel `InstagramSyncStatusPanel` (campo `ui` irmão do picker) mostra 5 estados fiéis ao rascunho aprovado (desativado / ainda não configurado / falha com motivo em linguagem de produto + "Tentar sincronizar de novo" / sincronizado · há X min · N posts / aguardando 1ª sync). A causa da ocorrência de produção (board vazio com token+ID preenchidos) é agora confirmável em 1 clique após o deploy. Engenharia: `loadInstagramFeed` passou a parsear o body de erro da Graph API e lançar `InstagramApiError` (`status` + `{message,type}`); `describeInstagramError` mapeia para copy pt-BR com a correção — OAuthException/expired → "token inválido/expirado ou emitido via Facebook Login — gere pelo Instagram Login" (o refresh endpoint da Graph API só renova tokens de Instagram Login), "Invalid user id" → "confira o ID da conta Business/Creator", 5xx → indisponível, `TypeError` → rede. Estado escrito por 3 caminhos: render da home (`getInstagramFeed`, dentro do `unstable_cache`, SQL direto sem hooks — padrão anti-`revalidateTag`-no-cache da S2), `afterChange` da global (sync **awaited** quando a credencial IG muda — `instagramCredentialsChanged(doc, previousDoc)`, gate que pula save de exclusões/YouTube; o sync é importado lazy no hook para o `payload.config.ts` não puxar o módulo do feed no load, que é o que todo spec int importa) e a rota `POST /api/social-feed/sync` (botão; `payload.auth` sessão admin exigindo `users` + `isSameOriginRequest` CSRF — allowlistada no spec de convenção de POST routes com razão, é auth de admin ≠ envelope `/campanha`). **Bug real descoberto e corrigido na entrega:** o `afterChange` roda DENTRO da transação do save (a row lock fica presa até os hooks retornarem), então escrever o status/snapshot via pool numa segunda conexão **deadlockava no lock da própria linha** (~80-230s por save, transações-zumbi segurando o lock). A correção roteia os persists do sync-hook pela **mesma transação** (`getPostgresTransactionDatabase` — o helper transacional da C6) com um `req` opcional no `syncInstagramFeed`; o render e a rota sem transação aberta seguem no pool. Diagnóstico reproduzido em `pg_stat_activity` (UPDATE `transactionid` Lock vs `idle in transaction`) e fix verificado (45ms vs 232s). Stub IG ganha estado `invalid-token` (400 OAuthException) para o e2e da cena de falha; runbook `docs/ops/instagram-feed-token-runbook.md` (o que o painel mostra, validação via curl `/me` e `refresh_access_token`, geração pelo Instagram Login, o que NÃO fazer). Migration `20260820_003426_add_instagram_sync_status` (ADD COLUMN jsonb nullable, não destrutiva). **/simplify (3 reviewers):** absorvi a correção P1 de diagnóstico — o mapeamento de ID exigia só "user" (regex frouxo) e mensagens reais de token OAuthException ("the **user** must be logged in", "user changed password") caíam na branch de ID, mostrando a razão errada; passei a exigir "user" E "id" juntos (`isInvalidUserIdMessage`) e adicionei o mapeamento de `AbortError` (timeout → "demorou demais"). Painel: gate passou a respeitar o master `enabled` (master off → "Instagram desativado", nunca "Sincronizado no board"), rótulo do botão no estado de primeira sync ("Tentar sincronizar" vs "de novo"), `outcome` transiente limpo ao editar credencial e `aria-live` nos boxes interativos. Testes: caminho transacional do `syncInstagramFeed` (persist na mesma transação) + fallback pool cobertos por unit; e2e do painel ganhou as cenas fail-500 e "desativado"; cleanup do stub global virou `afterEach`. Débitos registrados: #124 (janela de lock: fetch de rede no sync-hook segura a row lock até 10s, acoplando a home ao save) e #125 (polish P3 — tipo de persist duplicado, rota 200 vs 400, last-writer-wins stale, `5 posts` hardcoded). Verificação: unit 2463 + int 791 verdes; lint/format/tsc/cycles verdes; `pnpm build` ok; e2e local — smoke admin 5/5 (painel ok → falha token → retry → falha 500 → desativado → não configurado) + describe serial frontend 19/19.
|
||||
|
||||
**C143** — demandas visíveis apenas para responsáveis explícitos + candidato/coordenador. Novo campo `campaignDemand.responsibles` (hasMany → `campaignUser`, staff elegível via `eligibleCampaignStaffWhere` com hook `beforeValidate` que descarta ids não-staff, fail-closed) e regra de leitura/atualização por membership: assessor só vê demandas onde é responsável (`{ responsibles: { contains } }`), candidato/coordenador/admin sempre, leader nunca — a regra substitui o escopo municipal de demandas em `src/utilities/access/demands.ts` e todas as superfícies (lista, URL, busca global, demandas de atividade, C90) herdam via `overrideAccess: false`; criação sem responsáveis fica visível só a unrestricted (fail-closed). Criador entra automaticamente como responsável no hook `enforceDemandWorkflow` (cobre form, wizard e rascunhos C90); migration `20260819_233503_add_campaign_demand_responsibles` adiciona `campaign_user_id` a `campaign_demand_rels` (FK cascade) e faz backfill de `created_by_id` → responsável para demandas pré-C143. UI: picker multi-select `DemandResponsibleMultiSelect` (chips com avatar, chip do criador rotulado "(criador)" e travado na criação, sugestões = assessores do município) na criação (`/demandas/nova` em 2 colunas + wizard "Registrar pedido" empilhado) e card de gestão `DemandResponsiblesCard` no detalhe (add/remove com save full-replace + advisory lock, rótulo "Visível para: Responsáveis · Candidato · Coordenador"); busca por `searchDemandResponsibleOptions` (query curta → assessores do município; senão → staff por nome). Testes: int reescritos para a regra nova (workflow/homeSearch) + 4 casos novos (membership, fail-closed sem responsáveis, gestão add/remove/self-removal, responsável fora da carteira, descarte de id não-staff); e2e novo `campaignDemandVisibility.e2e.spec.ts` (assessor do mesmo município não vê lista/URL; hand-off de acesso add/revoke pela UI); fixture e2e ganhou `createCampaignDemand`. Verificação: unit 2444 + int 795 verdes, e2e 5/5 local (visibility + wizard + demanda do municipalidades), lint/format/knip/cycles/tsc/build limpos; importMap regenerado com `S3_*` dummy (OPS69). Nota de deploy: conferir na primeira aplicação em prod `SELECT count(*) FROM campaign_demand_rels WHERE path='responsibles'` = nº de demandas com `created_by_id`. Plano de implementação: `docs/plans/demandas-responsaveis-impl.md`.
|
||||
|
||||
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs de S3 (classe OPS69 — o guard do OPS70/#87 cobre; restaurado na entrega com envs dummy).
|
||||
|
||||
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs de S3 (classe OPS69 — o guard do #77 cobre; restaurado na entrega com envs dummy).
|
||||
|
||||
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs envs de S3 (classe OPS69 — o guard do #77 cobre; restaurado na entrega com envs dummy).
|
||||
|
||||
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, HIGH*RISK → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs `S3*\*` (classe OPS69 — o guard do #77 cobre; restaurado na entrega com envs dummy).
|
||||
|
||||
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, HIGH_RISK → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`.
|
||||
|
||||
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc/unit verdes; `test:e2e:affected` com modo `none` (diff sem blast radius e2e); CI do PR valida (e2e não roda — docs/skills/workflow não são superfície e2e). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`.
|
||||
|
||||
**OPS71-INFRA (2026-08-19):** runner **self-hosted do GitHub instalado no homeserver** (Issue #113) — o job `deploy` do `deploy.yml` (dispatch manual) agora roda localmente no homeserver, sem SSH. Detalhes: `~/actions-runner` v2.336.0 linux-x64 (sha256 conferido contra a API de releases), nome `teqo-1313-runner`, labels `self-hosted` + `homeserver`, registrado com o PAT do OPS71 (registration token TTL 1h gerado e aplicado na sequência). Roda como **systemd user** `actions.runner.fsolla-teqo.teqo-1313-runner.service` (linger já ativo; `svc.sh install` exige root — unit user criado manualmente do template oficial), usuário `fsolla` (docker + `~/stack` envs), workdir `_work`, outbound-only (funciona atrás do Cloudflare tunnel). Verificado `online` na API do GitHub com as labels corretas. Registrado em infra-solla `STATE.md` (seção Teqo: runner + fluxo deploy manual atualizado; linha do repo/CI corrigida para o pós-cutover GitHub). Workspace `~/teqo-deploy` re-aponta para o GitHub no primeiro deploy via `set-url` idempotente do script. Pendência registrada: secret `CURSOR_API_KEY` no GitHub fica dormente (helper `archive-cursor-agent.yml`; a key não existe no ambiente — pool morto OPS65) até o usuário pedir a reativação.
|
||||
|
||||
**S9 — captura de novidades da campanha na home pública (2026-08-19):** a home de campanha (`jorgesolla1313.com.br`) ganhou a seção "Receba as novidades da campanha" logo acima do rodapé — nome + WhatsApp obrigatórios, e-mail/estado/cidade/comentário opcionais, toggle pré-selecionado "Quero fazer parte do time" (frequência + grupos de WhatsApp vs. comunicações esporádicas) e confirmação in-place com atalho para as bandeiras. O CTA secundário do hero virou o atalho da seção (âncora `#novidades`, scroll suave no container da home; "Conhecer bandeiras" sai, as bandeiras seguem no rodapé e no estado de sucesso). O registro é `Contact` + `Subscription` em transação com consentimento NOVO por chave estável `campanha-novidades` (fail-closed: sem o texto aprovado no admin, o form recusa a captura — nada é gravado); a escolha do toggle sobrevive como `subscription.campaignLevel` (`time|esporadico`, coluna própria na lista do admin). Migração `20260819_192605_add_campaign_newsletter_capture` (DROP NOT NULL em `contact.state` — estado opcional no form, ficha staff via zod segue exigindo — + coluna `campaign_level` com enum). Precedente `submitWhatsapp` preservado; action nova usa `withPayloadTransaction` (o dono do concern de transação, não o hand-roll gêmeo — `submitWhatsapp`/`submitPetitionSignature` ficam como débito de alinhamento). S10 (pixel Meta) dispara `Lead` na costura do sucesso do form. Chave de consentimento pendente de aprovação jurídica antes da ativação.
|
||||
|
||||
**S7** — polimento dos cards da seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br), look editorial e direto no lugar do "card de app": cantos retos (**sem `rounded-xl` no card e `rounded-lg` na capa**), capa de **borda a borda** (o `p-3` saiu do contêiner do card e foi para o bloco de texto — título/subtítulo/meta continuam respirando `p-3` das bordas; `mt-auto` da meta e `overflow-hidden` do zoom hover intactos) e, por decisão do humano posterior ao gate, **sem borda (`border --campaign-line`) e sem sombra** (`shadow-sm`/`hover:shadow-md`) — o card branco lê contra a banda do S5 só pela forma. **Chips de badge removidos de todos os cards** (decisão B do gate): nem os tipos de post do site (`POST_TYPE_BADGE_LABELS` deletado de `src/utilities/posts.ts`, junto com o describe no unit test) nem as origens externas YouTube/Instagram (sinalização externa segue por `target="_blank"`/`rel="noopener"`); `badgeLabel` saiu do tipo `CampaignContentCardData`. Superfície única no componente dono (`CampaignContentCard.tsx`) — bento e carrossel herdam; `ContentShareButton` (S4), banda do S5, carrossel e fail-closed da seção intocados. Zero migration/schema/Consent. e2e do describe da seção atualizados para o novo estado: loop de badges → ausência (`toHaveCount(0)` para os 4 tipos + YouTube/Instagram), pins CSS do card (`border-radius`/`border-width`/`box-shadow` + raio da capa) e pin geométrico da capa de borda a borda (boundingBox do card × capa). Verificação: gate:fast verde, unit 2415 + int 791 verdes, e2e frontend 19/19 local, format/knip/cycles limpos. Plano de implementação: `docs/plans/cards-acompanhe-de-perto-polimento-impl.md`.
|
||||
|
|
@ -42,10 +64,16 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
**PUB5** — slogan da campanha "Mais saúde mais futuro" no hero e no rodapé da home (jorgesolla1313.com.br), no lugar de "Um mandato do tamanho da Bahia" (discurso do mandato vigente; o novo é da campanha 2026 — saúde é o tema-âncora do candidato). Troca de copy cirúrgica, sem redesenho nem migration: h1 do hero em duas linhas uppercase "MAIS SAÚDE / MAIS FUTURO" com `aria-label` acompanhando ("MAIS SAÚDE MAIS FUTURO"), segunda linha já em `font-black` (anatomia existente), e o rodapé em sentence case com ponto ("Mais saúde mais futuro."). Testes que pinavam a string antiga atualizados (`campaignHero.unit`, `campaignHome.unit`, `frontend.e2e`). Fora de escopo decidido no gate: cabeçalho de artigos ("O mandato do tamanho da Bahia") e docs históricos. Verificação: unit 2269 + int 791 verdes, e2e frontend 8/8 em prod mode (`E2E_PROD=1`, `.next-e2e`), lint/format/typecheck/cycles/knip/build verdes; render conferido por screenshot + DOM (hero duas linhas, rodapé com ponto). `/simplify` 3 reviewers: sem P0/P1; P2 corrigidos no impl plan (redação + line refs); débitos descartados por serem convenções pré-existentes ou decisões do gate.
|
||||
|
||||
**Recently resolved (2026-08-20):** **OPS71-FLIP** — flip pós-merge falhou/silencioso no fluxo GitHub, validado e endurecido (Issue #119, achado ao vivo no merge do PR #746). **Investigação (GitHub API pública):** o `closed` do PR #746 (merge por `github-actions[bot]`, auto-merge armado com o `GITHUB_TOKEN` nativo) **não criou run nenhum** — anti-recursão documentada do GitHub (eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline com 0 runs; comparação com o #742, mergeado por `fsolla`, cujos runs nasceram 3s após o merge). O 410 do run do #742 é falha separada (API do Forgejo respondeu 410 Gone nginx, transitório). **Endurecimento:** (1) **fix de raiz** — o arm do auto-merge (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`) agora usa o secret novo **`AUTOMERGE_PAT`** (PAT real do fsolla; decisão pura `automergeArmingToken` em `github-pr-flow.mjs` com fail-closed — sem o secret o job sai 1, nunca fallback pro bot token; o merge passa a ser atribuído a usuário real e o `closed` volta a gerar runs); (2) **recuperação manual** — `workflow_dispatch` com input `pr` nos dois flips (`issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`): o job busca o PR via `gh` com o mesmo contrato do evento (merged + base main + same-repo, senão skip) e repassa o body como `PR_BODY`; (3) **close da Issue** — `forgejo-issue-transition.mjs` agora `PATCH state=closed` via novo `closeIssue` do `forgejo-api.mjs` (era-Forgejo o merge fechava nativo; era-GitHub o PR não toca o tracker — antes, toda Issue mergeada ficava `OPEN` com `done`+`in-prod`); (4) **410 no retry** — `forgejo-api.mjs` retenta **410 em qualquer método** (proxy respondeu antes da origem — sem efeito duplicado; 5xx segue fail-closed em escrita) e corpo nginx vira marker compacto `corpo não-JSON (proxy/nginx…)` no erro. **Testes:** 8 specs novas (retry 410 GET/write, marker após 410 esgotado, `closeIssue` PATCH pin, `automergeArmingToken` ok/fail-closed/vazio) — 50 unit verdes nas specs tocadas. **Docs:** `AGENTS.md` (frase do auto-merge com o PAT + porquê), `docs/AGENT-OPS.md` (tabelas de workflows/secrets + runbook de recuperação manual com fallback local `PR_BODY`). Sem migration/access/Consent/UI. Impl: [`ops71-flip-pos-merge-falhou-silencioso-impl.md`](plans/ops71-flip-pos-merge-falhou-silencioso-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-19):** **OPS66** — deadlock de migrations em caminho estático: o deploy buildava o runner **antes** do migrate e, se uma migration criasse tabela lida em geração estática (caso S2, 2026-08-18: `social_feed_settings` na home), o `next build` falhava para sempre (`relation ... does not exist`) e o migrate nunca rodava — 4 deploys seguidos vermelhos até o migrator manual. Fix em `scripts/deploy-homeserver.sh` (reordenação de fases, sem custo novo — migrate e build do migrator já rodavam em todo deploy): **build do migrator** (o estágio não roda `next build` — builda contra o schema antigo) → push/tag do migrator → swap do compose (backup + trap na mesma posição) → **migrate** via `docker compose run teqo-1313-migrate` (caminho canônico do serviço de maintenance, agora com a imagem do SHA novo) → **build do runner** contra o banco JÁ migrado → push/tag → rollout → healthcheck → smoke. Alternativas rejeitadas: `payload migrate` dentro do build do runner (efeito colateral de escrita em prod a partir de um processo de build; contraria o desenho deliberado do Dockerfile) e `docker run` direto do migrator com env-file (duplicaria a definição do serviço de maintenance — caminho paralelo ao canônico). Caveat do rollback ajustado no runbook: migrate antes do build do runner deixa o banco à frente do código se o build falhar depois (mesma classe do caveat atual — migrations append-only e revisadas). Testes: `tests/unit/deployScript.unit.spec.ts` com pin de ordem completo (migrator build < swap < migrate < runner build < rollout) + invariante novo do Dockerfile (estágio migrator nunca roda `next build`). Runbook `docs/ops/teqo-1313-deploy.md` atualizado (fluxo, caveat, falha conhecida nova). Dockerfile/AGENTS.md com a precisão build-after-migrate.
|
||||
|
||||
**Recently resolved (2026-08-19):** **OPS68** — fechamento da parte 2 do OPS50: reescrita dos docs canônicos operacionais que ainda descreviam a era GitHub/Vercel. **12 arquivos, 78+/82−, edições linha-a-linha:** `docs/AGENT-OPS.md` (norte "só Forgejo + Cursor"; fonte canônica = Issues do Forgejo; fluxo do agente e Contrato de PR sem `gh` — criação via API/MCP + merge pelo safety net `agent-pr-ready-automerge.yml`, o único que espera o rollup `CI (PR) / checks`; "Só humano" = envs de `~/stack/teqo-1313.env` + branch protection + runbook; tabela de secrets sem `POOL_GITHUB_TOKEN` (legado — o script o ignora) e com nota de pool dormente; linha `archive-cursor-agent.yml` na tabela CI; "smoke Neon stage" → sem smoke stage), `README.md` (cheatsheet de 5 linhas reescrito: claim → push → PR via API/MCP → safety net mergea → ci.yml em janela de 30 min + deploy homeserver; Neon → homeserver Postgres; `PROD_DATABASE_URL` = `DATABASE_URL` de `~/stack/teqo-1313.env`; migrações no deploy via `teqo-1313-migrate`; "GitHub mirror" → Forgejo Actions; papéis `coordinator/advisor/leader/candidate`; "electoral nuclei" → municípios; Issues → link Forgejo), `docs/roadmap.md` (só o ponteiro da fonte canônica — o stub continua congelado), `AGENTS.md` (refs Neon → homeserver `teqo_1313`; job do gate `check`→`gate`, key real do YAML; "Vercel preview" → host genérico na rationale do WebAuthn), `.agents/rules/agent-pr-workflow.mdc` (comandos `gh pr create/merge/checks` morrem — `gh` não existe em nenhuma máquina (OPS50); regra descreve API/MCP + safety net; "There is no deploy from CI" corrigido — OPS53 colocou o deploy no `ci.yml`), e as skills `work-issue` (SKILL + `execution-pipeline`), `plan-issue` (frontmatter, gate "nada no Forgejo", dedup via `pnpm issue all`), `project-status`, `capture-review-debts`, `engineering-audit` (`gh issue list` → `pnpm issue all`/API; `issue-done-on-stage-merge.yml`→`on-main`; merge `stage`→`main`), `worktree-next-issue` ("resto do Forgejo") e `agent-pool` (descrição Forgejo Actions; workflow marcado removido OPS65; estado em `pool-state.json`, não mais variables). **Mantido como registro histórico** (exclusões explícitas do plano): menções de Vercel/Neon/POOL_GITHUB que documentam remoções (AGENTS.md L15/L105/L167, AGENT-OPS L94/L98, "Vercel/Git-integration deploy gate" como guardrail no rule). Fonte canônica única: `git.solla.dev/fsolla/teqo`. Sem migration/access/Consent/UI. Gate: grep de saída zero nas classes `gh pr`/`gh issue`/`github.com/fsolla`/`GitHub Issues`/`GitHub mirror`/`POOL_GITHUB`/Neon-em-uso; docs-only → CI roda os docs-guards.
|
||||
|
||||
**Recently resolved (2026-08-19):** **OPS71** — CI/PR/deploy do Forgejo Actions de volta para o **GitHub Actions**, com o **tracker de Issues permanecendo no Forgejo** (labels, claims, `pnpm issue`/`agent:*` e flips pós-merge continuam via API do Forgejo; o GitHub é só o host do Actions). O PR CI (`ci-pr.yml`, job único `checks` — OPS62) roda no GitHub com a mesma cascata (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado via `ci-scope.mjs`), com `concurrency` nativo cancelando o run em push novo e `if:` same-repo (repo público — fork não queima minutes). Seu check-run `CI (PR) / checks` é o required check do `main` e o gate do **auto-merge nativo** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`: draft `cursor/*` → Ready via PATCH, arm `enablePullRequestAutoMerge` GraphQL com REBASE — o servidor espera o required check verde; sem poll; o pin OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto). **`ci.yml` (verificador de main) eliminado** — publicar é **dispatch manual** (`deploy.yml`): `verify` (hosted, suíte full incl. e2e full) → `deploy` (`needs`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh <sha>` **localmente** no runner do GitHub instalado no homeserver (sem SSH; outbound-only; o hosted nunca toca o homeserver); o script re-aponta o workspace `~/teqo-deploy` para `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, público). `pnpm push` passa a empurrar para `origin` = **GitHub** (cutover manual de remote documentado; o repo Forgejo congela em main, tracker vivo por API). Novos helpers plain-Node zero-dep: `scripts/lib/github-api.mjs` (REST+GraphQL, retry OPS67), `scripts/lib/github-pr-flow.mjs` (decisão draft/veto/skip, pura), `scripts/lib/github-branch-protection.mjs` (regra desejada + drift, `enforce_admins: true`), `scripts/github-pr.mjs` (criação de PR Ready — nunca draft), `scripts/configure-branch-protection.mjs` reescrito para a REST do GitHub; `forgejo-issue-transition.mjs` aceita `PR_BODY` (body do PR via evento GitHub; flip continua no Forgejo por `FORGEJO_API_TOKEN`). **Cutover em duas fases (gate):** nada do Forgejo foi apagado (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs` preservados como rollback) — a remoção é entrega sucessora pós-validação. Testes: `githubApi` (10), `githubBranchProtection` (10), `githubPrFlow` (9) unit novos. Docs: `AGENTS.md`, `docs/AGENT-OPS.md`, `.agents/rules/agent-pr-workflow.mdc`, `work-issue/execution-pipeline.md`, `agent-work-issue`, `capture-review-debts`, runbook `docs/ops/teqo-1313-deploy.md` (dispatch manual, runner no homeserver, cutover com ordem, rollback).
|
||||
|
||||
**Recently resolved (2026-08-19):** **OPS71 Fase 2** — remoção dos arquivos do fluxo Forgejo após validação ao vivo do GitHub Actions (PRs #742/#743: CI verde, auto-merge nativo, flips via API). **14 arquivos deletados:** `.forgejo/workflows/` (6 — ci-pr.yml, ci.yml, agent-pr-ready-automerge.yml, issue-done-on-main-merge.yml, plan-issue-ready-on-main-merge.yml, archive-cursor-agent.yml), `scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-dispatch.mjs`, `scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`, `scripts/lib/branch-protection.mjs` e as specs órfãs `ciClassifyProduction`/`dockerignoreMatch`/`branchProtection` `.unit.spec.ts`. **Consumidores funcionais achados pela varredura (fora do escopo literal do body) e ajustados na mesma mudança:** `scripts/lib/test-affected-core.mjs` perdeu as 2 entries de `HIGH_RISK_EXACT` dos scripts mortos; `tests/unit/ciSkipInvariants.unit.spec.ts` tirou os 2 `.forgejo/workflows/*` do loop `.next-e2e`, os 2 paths do pin de high-risk e **reescreveu o teste da janela de 30 min (OPS65) para o invariante vivo da era GitHub**: `deploy.yml` é `workflow_dispatch`-only (sem `push:`/`schedule:` — deploy nunca automático) e `ci-pr.yml` não referencia o gate morto. **Docs:** `AGENTS.md` (frase "preservados até Fase 2" → removidos; rollback = revert do PR ou clone do main congelado do Forgejo), runbook `docs/ops/teqo-1313-deploy.md` (Rollback do cutover idem), `README.md` (cheatsheet de 5 linhas era-Forgejo com janela de 30 min + deploy automático → era-GitHub: auto-merge nativo + deploy manual dispatch), comentários com referência morta em `scripts/gate-ci.mjs`, `scripts/github-pr-automerge.mjs` e `.github/workflows/agent-pr-ready-automerge.yml`. **Intocados (anti-goal tracker):** `forgejo-api.mjs`, `agent-*.mjs`, `issue.mjs`, `forgejo-issue-transition.mjs`, `agent-promote-related-on-merge.mjs`, `.forgejo/worktree.env` (consumido pelo provisionamento de worktree), `.dockerignore` (o arquivo — `docker build` do deploy o consome; só o lib de parse morreu). `docs/AGENT-OPS.md` já era era-GitHub (zero referências). Histórico (plans/changelogs antigos) congelado. Sem migration/access/Consent/UI. Gate: tsc/lint/format/knip/cycles/unit+int/build verdes; grep de saneamento `forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore.mjs|branch-protection.mjs` → só histórico.
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS52** — a media saiu do Vercel Blob para o Garage S3 do homeserver (bucket `teqo-media` + key `teqo-key`, já existentes no Garage v2.1.0). O plugin `@payloadcms/storage-vercel-blob` (e o `@vercel/blob` do seed) foi trocado por `@payloadcms/storage-s3` na config, no padrão R2 da doc do Payload (`endpoint` + `forcePathStyle: true`, region `garage`). **Decisão de arquitetura (revisão A→B durante a execução, gate com o humano):** o Garage v2.1.0 **não suporta acesso anônimo na API S3** (sem `--public-read`; source: "when we support anonymous access in the future"; testado: `anonymous`/`anon` → `NoSuchAccessKey`), então o bucket fica **privado** e a app **serve a media por proxy** — sem `disablePayloadAccessControl`, o `staticHandler` do plugin streama o objeto do Garage em `/api/media/file/<filename>` (range + etag/304 nativos) — o **mesmo contrato de URL relativa que as 40 rows pré-existentes já têm** (a recuperação dos arquivos é a Issue #10/OPS52-media). **Fail-closed:** `resolveS3StorageEnv` (`src/utilities/mediaStorage.ts`, unit-testado) — envs `S3_*` parciais → throw no boot com a lista exata de faltantes; nenhuma → storage local (dev/test). Envs novas: `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION` (default `garage`), `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (`.env.example`, `.env.test`, worktree.mjs com cópia all-or-nothing). Seed de posts: pre-delete do Blob removido (o S3 sobrescreve o key determinístico nativamente — idempotência intacta). Prova de contrato real: PUT/GET/DELETE via SDK contra o Garage (tailnet `http://100.119.220.31:3900`), depois media criada pelo Payload local com S3 ativo → `media.url` relativa + `GET /api/media/file/...` → 200 image/png; bucket devolvido a 0 objects e `.env.local` do worktree limpo (sem credenciais de prod). Sem migration (config-only; importMap regenerado sem `VercelBlobClientUploadHandler`). Runbook de cutover registrado no impl plan: setar as 5 `S3_*` no `~/stack/teqo-1313.env` (endpoint tailnet) antes do próximo deploy (OPS53). Repo: `docs/plans/ops52-blob-vercel-para-garage-impl.md`; infra-solla `STATE.md` atualizado à parte.
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS52-media** — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa `/api/media/file/<filename>` mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com `pnpm media:recover` (`scripts/recover-media.mjs`, novo): lê as rows de `media` + `post.coverImage` (mapeamento exato row→slug; fallback pelo filename determinístico `<slug>.<ext>`), resolve a capa com a MESMA lógica do seed (featured media do REST `_embed`, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz `PutObject` no bucket das envs `S3_*` com key = `filename` da row, **sem nunca escrever no DB** (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para `scripts/lib/wpArticles.mjs` o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 `S3_*` completas (`resolveS3StorageEnv` reusado; recuperação nunca vai pro storage local), `ALLOW_REMOTE_DB=true` explícito para DB remoto + echo do alvo (DB/bucket/endpoint), e timeouts curtos no SDK (`@smithy/node-http-handler`, 5s/15s) — endpoint inalcançável falha em segundos em vez de pendurar (a rota pública de media de prod pendurava >25s; evidência de possível falta de rota container→Garage, diagnosticada no runbook do impl plan). Modos: reconcile (padrão, upload idempotente por overwrite), `--dry-run` (plano + `headObject`), `--verify` (HEAD da URL pública por filename — o aceite da Issue, sem credencial). Runbook de execução no homeserver: `docs/plans/ops52-media-recuperar-arquivos-impl.md`.
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ Three layers, all local-database-only (see `local-database` skill; tests refuse
|
|||
- **Integration** (`tests/int/**/*.int.spec.ts`, 71 specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Since the miss #73 guardrail (Pass 4), specs that mutate municipalities must use the allocator (no pinned slugs) — enforced by `testMunicipalityAllocatorConventions`. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned.
|
||||
- **E2E** (`tests/e2e`, Playwright, 17 specs / 51 cases (Pass 5, 2026-08-03; was 48 at Pass 4)): smoke-level; most campaign behavior is pinned at the int layer.
|
||||
|
||||
CI (`ci-pr.yml`) is a **single sequential job `checks`** (OPS62 X1): guards → lint (zero warnings) → format → typecheck → knip → cycles → unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths) → build → e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; production build + `list`/`github` reporters in CI) — against two isolated Postgres 17 services (int / build+e2e) with **`db:seed:minimal`**. Fail-fast is structural: a failed step stops the run; e2e runs with `PLAYWRIGHT_WORKERS=4`. Push to `main` (`ci.yml`) always runs the full net including e2e.
|
||||
CI (`ci-pr.yml`) is a **single sequential job `checks`** (OPS62 X1): guards → lint (zero warnings) → format → typecheck → knip → cycles → unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths) → build → e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; **only in mode `selected` — OPS72: the PR never runs full e2e**; production build + `list`/`github` reporters in CI) — against two isolated Postgres 17 services (int / build+e2e) with **`db:seed:minimal`**. Fail-fast is structural: a failed step stops the run; e2e runs with `PLAYWRIGHT_WORKERS=4`. There is no main verifier (`ci.yml` eliminated in OPS71): the full net including e2e runs in the deploy `verify` job before publishing (manual dispatch).
|
||||
|
||||
**Local e2e before push (same blast radius as PR CI):** `pnpm test:e2e:affected` — runs `e2e-affected.mjs` against `origin/stage`, migrates/seeds, then `pnpm test:e2e` with the selected specs (or the full suite on high-risk diffs). Forward extra Playwright args after `--` for a narrowed repro. Opt into production mode (after fixing the known municipalities-list prod client bug) with `E2E_PROD=1 pnpm test:e2e:affected`.
|
||||
**Local e2e (OPS72 — skill step, discretionary):** before push, run the e2e specs you created + the same affected surface — `pnpm test:e2e:affected` runs `e2e-affected.mjs` against `origin/main`, migrates/seeds, then `pnpm test:e2e` with the selected specs (full suite on high-risk diffs). High-risk diffs diverge from PR CI by design: the PR runs no e2e (selected only, never full) and the local mirror runs the full suite. Forward extra Playwright args after `--` for a narrowed repro. Opt into production mode with `E2E_PROD=1 pnpm test:e2e:affected` (CI and prod mode serve the production build via `pnpm start`; plain local runs use `pnpm dev` — dev-mode flakes like cold-compile timeouts and dev-only console warnings do not reproduce in prod mode).
|
||||
|
||||
**Navigation discipline in e2e (miss #54, 2026-07-30):** `page.goto` is for cold loads and URL contracts (deep links, redirects, legacy params); when the scenario is _continuity inside the authenticated shell_ — especially after a heavy list route — navigate in-shell instead (sidebar links, buttons, form submits), the way the user does. Never fire two `page.goto` back-to-back with nothing settling the first navigation (an `await expect(...)`, `waitForURL`, or interaction): the second aborts the first's in-flight RSC fetch (`net::ERR_ABORTED`, measured in `campaignSavedFilters`). The `e2eNavigationConventions` unit guard fails the build on unsettled goto pairs. Two sibling rules of the same family: wait for **interactive state** before clicking dynamic controls — `toBeVisible` proves SSR paint, not hydration (pre-hydration clicks no-op silently; `checkRadixWhenHydrated` is the precedent, P3-C class) — and gate WebAuthn ceremonies on `expectCampaignBiometricsReady` before the island's one-shot probe runs (miss #53). **Dev-mode caveat (closed 2026-08-10, P4-L/OPS30):** `campaignLeaderships`' chip-cell spec was failing deterministically under `pnpm dev` before B34 rewrote it; with the OPS30 prewarm/login hardening it ran ×3 green in dev + ×1 `E2E_PROD=1` (evidence in TECH-DEBT).
|
||||
|
||||
|
|
|
|||
1
docs/changelog/2026-08-19-c143.md
Normal file
1
docs/changelog/2026-08-19-c143.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C143** — demandas visíveis apenas para responsáveis explícitos + candidato/coordenador. Novo campo `campaignDemand.responsibles` (hasMany → `campaignUser`, staff elegível via `eligibleCampaignStaffWhere` com hook `beforeValidate` que descarta ids não-staff, fail-closed) e regra de leitura/atualização por membership: assessor só vê demandas onde é responsável (`{ responsibles: { contains } }`), candidato/coordenador/admin sempre, leader nunca — a regra substitui o escopo municipal de demandas em `src/utilities/access/demands.ts` e todas as superfícies (lista, URL, busca global, demandas de atividade, C90) herdam via `overrideAccess: false`; criação sem responsáveis fica visível só a unrestricted (fail-closed). Criador entra automaticamente como responsável no hook `enforceDemandWorkflow` (cobre form, wizard e rascunhos C90); migration `20260819_233503_add_campaign_demand_responsibles` adiciona `campaign_user_id` a `campaign_demand_rels` (FK cascade) e faz backfill de `created_by_id` → responsável para demandas pré-C143. UI: picker multi-select `DemandResponsibleMultiSelect` (chips com avatar, chip do criador rotulado "(criador)" e travado na criação, sugestões = assessores do município) na criação (`/demandas/nova` em 2 colunas + wizard "Registrar pedido" empilhado) e card de gestão `DemandResponsiblesCard` no detalhe (add/remove com save full-replace + advisory lock, rótulo "Visível para: Responsáveis · Candidato · Coordenador"); busca por `searchDemandResponsibleOptions` (query curta → assessores do município; senão → staff por nome). Testes: int reescritos para a regra nova (workflow/homeSearch) + 4 casos novos (membership, fail-closed sem responsáveis, gestão add/remove/self-removal, responsável fora da carteira, descarte de id não-staff); e2e novo `campaignDemandVisibility.e2e.spec.ts` (assessor do mesmo município não vê lista/URL; hand-off de acesso add/revoke pela UI); fixture e2e ganhou `createCampaignDemand`. Verificação: unit 2444 + int 795 verdes, e2e 5/5 local (visibility + wizard + demanda do municipalidades), lint/format/knip/cycles/tsc/build limpos; importMap regenerado com `S3_*` dummy (OPS69). Nota de deploy: conferir na primeira aplicação em prod `SELECT count(*) FROM campaign_demand_rels WHERE path='responsibles'` = nº de demandas com `created_by_id`. Plano de implementação: `docs/plans/demandas-responsaveis-impl.md`.
|
||||
1
docs/changelog/2026-08-19-ops71-fase2.md
Normal file
1
docs/changelog/2026-08-19-ops71-fase2.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**Recently resolved (2026-08-19):** **OPS71 Fase 2** — remoção dos arquivos do fluxo Forgejo após validação ao vivo do GitHub Actions (PRs #742/#743: CI verde, auto-merge nativo, flips via API). **14 arquivos deletados:** `.forgejo/workflows/` (6 — ci-pr.yml, ci.yml, agent-pr-ready-automerge.yml, issue-done-on-main-merge.yml, plan-issue-ready-on-main-merge.yml, archive-cursor-agent.yml), `scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-dispatch.mjs`, `scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`, `scripts/lib/branch-protection.mjs` e as specs órfãs `ciClassifyProduction`/`dockerignoreMatch`/`branchProtection` `.unit.spec.ts`. **Consumidores funcionais achados pela varredura (fora do escopo literal do body) e ajustados na mesma mudança:** `scripts/lib/test-affected-core.mjs` perdeu as 2 entries de `HIGH_RISK_EXACT` dos scripts mortos; `tests/unit/ciSkipInvariants.unit.spec.ts` tirou os 2 `.forgejo/workflows/*` do loop `.next-e2e`, os 2 paths do pin de high-risk e **reescreveu o teste da janela de 30 min (OPS65) para o invariante vivo da era GitHub**: `deploy.yml` é `workflow_dispatch`-only (sem `push:`/`schedule:` — deploy nunca automático) e `ci-pr.yml` não referencia o gate morto. **Docs:** `AGENTS.md` (frase "preservados até Fase 2" → removidos; rollback = revert do PR ou clone do main congelado do Forgejo), runbook `docs/ops/teqo-1313-deploy.md` (Rollback do cutover idem), `README.md` (cheatsheet de 5 linhas era-Forgejo com janela de 30 min + deploy automático → era-GitHub: auto-merge nativo + deploy manual dispatch), comentários com referência morta em `scripts/gate-ci.mjs`, `scripts/github-pr-automerge.mjs` e `.github/workflows/agent-pr-ready-automerge.yml`. **Intocados (anti-goal tracker):** `forgejo-api.mjs`, `agent-*.mjs`, `issue.mjs`, `forgejo-issue-transition.mjs`, `agent-promote-related-on-merge.mjs`, `.forgejo/worktree.env` (consumido pelo provisionamento de worktree), `.dockerignore` (o arquivo — `docker build` do deploy o consome; só o lib de parse morreu). `docs/AGENT-OPS.md` já era era-GitHub (zero referências). Histórico (plans/changelogs antigos) congelado. Sem migration/access/Consent/UI. Gate: tsc/lint/format/knip/cycles/unit+int/build verdes; grep de saneamento `forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore.mjs|branch-protection.mjs` → só histórico.
|
||||
1
docs/changelog/2026-08-19-ops71.md
Normal file
1
docs/changelog/2026-08-19-ops71.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**Recently resolved (2026-08-19):** **OPS71** — CI/PR/deploy do Forgejo Actions de volta para o **GitHub Actions**, com o **tracker de Issues permanecendo no Forgejo** (labels, claims, `pnpm issue`/`agent:*` e flips pós-merge continuam via API do Forgejo; o GitHub é só o host do Actions). O PR CI (`ci-pr.yml`, job único `checks` — OPS62) roda no GitHub com a mesma cascata (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado via `ci-scope.mjs`), com `concurrency` nativo cancelando o run em push novo e `if:` same-repo (repo público — fork não queima minutes). Seu check-run `CI (PR) / checks` é o required check do `main` e o gate do **auto-merge nativo** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`: draft `cursor/*` → Ready via PATCH, arm `enablePullRequestAutoMerge` GraphQL com REBASE — o servidor espera o required check verde; sem poll; o pin OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto). **`ci.yml` (verificador de main) eliminado** — publicar é **dispatch manual** (`deploy.yml`): `verify` (hosted, suíte full incl. e2e full) → `deploy` (`needs`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh <sha>` **localmente** no runner do GitHub instalado no homeserver (sem SSH; outbound-only; o hosted nunca toca o homeserver); o script re-aponta o workspace `~/teqo-deploy` para `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, público). `pnpm push` passa a empurrar para `origin` = **GitHub** (cutover manual de remote documentado; o repo Forgejo congela em main, tracker vivo por API). Novos helpers plain-Node zero-dep: `scripts/lib/github-api.mjs` (REST+GraphQL, retry OPS67), `scripts/lib/github-pr-flow.mjs` (decisão draft/veto/skip, pura), `scripts/lib/github-branch-protection.mjs` (regra desejada + drift, `enforce_admins: true`), `scripts/github-pr.mjs` (criação de PR Ready — nunca draft), `scripts/configure-branch-protection.mjs` reescrito para a REST do GitHub; `forgejo-issue-transition.mjs` aceita `PR_BODY` (body do PR via evento GitHub; flip continua no Forgejo por `FORGEJO_API_TOKEN`). **Cutover em duas fases (gate):** nada do Forgejo foi apagado (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs` preservados como rollback) — a remoção é entrega sucessora pós-validação. Testes: `githubApi` (10), `githubBranchProtection` (10), `githubPrFlow` (9) unit novos. Docs: `AGENTS.md`, `docs/AGENT-OPS.md`, `.agents/rules/agent-pr-workflow.mdc`, `work-issue/execution-pipeline.md`, `agent-work-issue`, `capture-review-debts`, runbook `docs/ops/teqo-1313-deploy.md` (dispatch manual, runner no homeserver, cutover com ordem, rollback).
|
||||
1
docs/changelog/2026-08-19-ops72.md
Normal file
1
docs/changelog/2026-08-19-ops72.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs de S3 (classe OPS69 — o guard do OPS70/#87 cobre; restaurado na entrega com envs dummy).
|
||||
1
docs/changelog/2026-08-19-s11.md
Normal file
1
docs/changelog/2026-08-19-s11.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**S11** — o feed do Instagram configurado **sem aparecer na home** fica diagnosticável: fim do fail-closed silencioso da S3. A assessoria vê, na mesma global onde preenche o token, o estado da sincronização (`instagramSyncStatus` jsonb hidden na `social-feed-settings`) e o **porquê** de os posts não aparecerem — o painel `InstagramSyncStatusPanel` (campo `ui` irmão do picker) mostra 5 estados fiéis ao rascunho aprovado (desativado / ainda não configurado / falha com motivo em linguagem de produto + "Tentar sincronizar de novo" / sincronizado · há X min · N posts / aguardando 1ª sync). A causa da ocorrência de produção (board vazio com token+ID preenchidos) é agora confirmável em 1 clique após o deploy. Engenharia: `loadInstagramFeed` passou a parsear o body de erro da Graph API e lançar `InstagramApiError` (`status` + `{message,type}`); `describeInstagramError` mapeia para copy pt-BR com a correção — OAuthException/expired → "token inválido/expirado ou emitido via Facebook Login — gere pelo Instagram Login" (o refresh endpoint da Graph API só renova tokens de Instagram Login), "Invalid user id" → "confira o ID da conta Business/Creator", 5xx → indisponível, `TypeError` → rede. Estado escrito por 3 caminhos: render da home (`getInstagramFeed`, dentro do `unstable_cache`, SQL direto sem hooks — padrão anti-`revalidateTag`-no-cache da S2), `afterChange` da global (sync **awaited** quando a credencial IG muda — `instagramCredentialsChanged(doc, previousDoc)`, gate que pula save de exclusões/YouTube; o sync é importado lazy no hook para o `payload.config.ts` não puxar o módulo do feed no load, que é o que todo spec int importa) e a rota `POST /api/social-feed/sync` (botão; `payload.auth` sessão admin exigindo `users` + `isSameOriginRequest` CSRF — allowlistada no spec de convenção de POST routes com razão, é auth de admin ≠ envelope `/campanha`). **Bug real descoberto e corrigido na entrega:** o `afterChange` roda DENTRO da transação do save (a row lock fica presa até os hooks retornarem), então escrever o status/snapshot via pool numa segunda conexão **deadlockava no lock da própria linha** (~80-230s por save, transações-zumbi segurando o lock). A correção roteia os persists do sync-hook pela **mesma transação** (`getPostgresTransactionDatabase` — o helper transacional da C6) com um `req` opcional no `syncInstagramFeed`; o render e a rota sem transação aberta seguem no pool. Diagnóstico reproduzido em `pg_stat_activity` (UPDATE `transactionid` Lock vs `idle in transaction`) e fix verificado (45ms vs 232s). Stub IG ganha estado `invalid-token` (400 OAuthException) para o e2e da cena de falha; runbook `docs/ops/instagram-feed-token-runbook.md` (o que o painel mostra, validação via curl `/me` e `refresh_access_token`, geração pelo Instagram Login, o que NÃO fazer). Migration `20260820_003426_add_instagram_sync_status` (ADD COLUMN jsonb nullable, não destrutiva). **/simplify (3 reviewers):** absorvi a correção P1 de diagnóstico — o mapeamento de ID exigia só "user" (regex frouxo) e mensagens reais de token OAuthException ("the **user** must be logged in", "user changed password") caíam na branch de ID, mostrando a razão errada; passei a exigir "user" E "id" juntos (`isInvalidUserIdMessage`) e adicionei o mapeamento de `AbortError` (timeout → "demorou demais"). Painel: gate passou a respeitar o master `enabled` (master off → "Instagram desativado", nunca "Sincronizado no board"), rótulo do botão no estado de primeira sync ("Tentar sincronizar" vs "de novo"), `outcome` transiente limpo ao editar credencial e `aria-live` nos boxes interativos. Testes: caminho transacional do `syncInstagramFeed` (persist na mesma transação) + fallback pool cobertos por unit; e2e do painel ganhou as cenas fail-500 e "desativado"; cleanup do stub global virou `afterEach`. Débitos registrados: #124 (janela de lock: fetch de rede no sync-hook segura a row lock até 10s, acoplando a home ao save) e #125 (polish P3 — tipo de persist duplicado, rota 200 vs 400, last-writer-wins stale, `5 posts` hardcoded). Verificação: unit 2463 + int 791 verdes; lint/format/tsc/cycles verdes; `pnpm build` ok; e2e local — smoke admin 5/5 (painel ok → falha token → retry → falha 500 → desativado → não configurado) + describe serial frontend 19/19.
|
||||
1
docs/changelog/2026-08-20-ops71-flip.md
Normal file
1
docs/changelog/2026-08-20-ops71-flip.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**Recently resolved (2026-08-20):** **OPS71-FLIP** — flip pós-merge falhou/silencioso no fluxo GitHub, validado e endurecido (Issue #119, achado ao vivo no merge do PR #746). **Investigação (GitHub API pública):** o `closed` do PR #746 (merge por `github-actions[bot]`, auto-merge armado com o `GITHUB_TOKEN` nativo) **não criou run nenhum** — anti-recursão documentada do GitHub (eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline com 0 runs; comparação com o #742, mergeado por `fsolla`, cujos runs nasceram 3s após o merge). O 410 do run do #742 é falha separada (API do Forgejo respondeu 410 Gone nginx, transitório). **Endurecimento:** (1) **fix de raiz** — o arm do auto-merge (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`) agora usa o secret novo **`AUTOMERGE_PAT`** (PAT real do fsolla; decisão pura `automergeArmingToken` em `github-pr-flow.mjs` com fail-closed — sem o secret o job sai 1, nunca fallback pro bot token; o merge passa a ser atribuído a usuário real e o `closed` volta a gerar runs); (2) **recuperação manual** — `workflow_dispatch` com input `pr` nos dois flips (`issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`): o job busca o PR via `gh` com o mesmo contrato do evento (merged + base main + same-repo, senão skip) e repassa o body como `PR_BODY`; (3) **close da Issue** — `forgejo-issue-transition.mjs` agora `PATCH state=closed` via novo `closeIssue` do `forgejo-api.mjs` (era-Forgejo o merge fechava nativo; era-GitHub o PR não toca o tracker — antes, toda Issue mergeada ficava `OPEN` com `done`+`in-prod`); (4) **410 no retry** — `forgejo-api.mjs` retenta **410 em qualquer método** (proxy respondeu antes da origem — sem efeito duplicado; 5xx segue fail-closed em escrita) e corpo nginx vira marker compacto `corpo não-JSON (proxy/nginx…)` no erro. **Testes:** 8 specs novas (retry 410 GET/write, marker após 410 esgotado, `closeIssue` PATCH pin, `automergeArmingToken` ok/fail-closed/vazio) — 50 unit verdes nas specs tocadas. **Docs:** `AGENTS.md` (frase do auto-merge com o PAT + porquê), `docs/AGENT-OPS.md` (tabelas de workflows/secrets + runbook de recuperação manual com fallback local `PR_BODY`). Sem migration/access/Consent/UI. Impl: [`ops71-flip-pos-merge-falhou-silencioso-impl.md`](plans/ops71-flip-pos-merge-falhou-silencioso-impl.md).
|
||||
1
docs/changelog/2026-08-20-ops71-infra.md
Normal file
1
docs/changelog/2026-08-20-ops71-infra.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**OPS71-INFRA (2026-08-19):** runner **self-hosted do GitHub instalado no homeserver** (Issue #113) — o job `deploy` do `deploy.yml` (dispatch manual) agora roda localmente no homeserver, sem SSH. Detalhes: `~/actions-runner` v2.336.0 linux-x64 (sha256 conferido contra a API de releases), nome `teqo-1313-runner`, labels `self-hosted` + `homeserver`, registrado com o PAT do OPS71 (registration token TTL 1h gerado e aplicado na sequência). Roda como **systemd user** `actions.runner.fsolla-teqo.teqo-1313-runner.service` (linger já ativo; `svc.sh install` exige root — unit user criado manualmente do template oficial), usuário `fsolla` (docker + `~/stack` envs), workdir `_work`, outbound-only (funciona atrás do Cloudflare tunnel). Verificado `online` na API do GitHub com as labels corretas. Registrado em infra-solla `STATE.md` (seção Teqo: runner + fluxo deploy manual atualizado; linha do repo/CI corrigida para o pós-cutover GitHub). Workspace `~/teqo-deploy` re-aponta para o GitHub no primeiro deploy via `set-url` idempotente do script. Pendência registrada: secret `CURSOR_API_KEY` no GitHub fica dormente (helper `archive-cursor-agent.yml`; a key não existe no ambiente — pool morto OPS65) até o usuário pedir a reativação.
|
||||
1
docs/changelog/2026-08-20-ops73.md
Normal file
1
docs/changelog/2026-08-20-ops73.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**OPS73 — deploy do fix do admin em produção (2026-08-20):** o `/admin` do Payload (jorgesolla1313.com.br) voltou a renderizar o formulário de login em produção — o bug de "tela branca" que travava o CMS do time editorial/campanha está resolvido e verificado. Durante o diagnóstico descobriu-se que a premissa do plano ("produção nunca recebeu o fix OPS69") estava errada: o deploy das 23:09 de 2026-08-19 (revision `8c734ba`) já continha o fix do importMap **no histórico**, mas o commit S9 (`8d2352c0`, captura de novidades na home) tinha regenerado o importMap **sem** as envs `S3_*`, removendo o handler `@payloadcms/storage-s3/client#S3ClientUploadHandler` e reintroduzindo o erro `getFromImportMap: PayloadComponent not found in importMap` — o admin abria em branco de novo ("funcionou e no deploy seguinte parou"). Verificado também que o **main do GitHub havia divergido do Forgejo** (pós-cutover OPS71): o GitHub tinha o fix + OPS71, mas não as features S8 (seção "Nossa história") e S9 (captura de novidades) que produção já rodava — deployar o main como estava regrediria as duas. A entrega: PR #751 re-homou no GitHub main as features S8+S9 (cherry-pick dos commits aprovados no Forgejo `8d2352c0` + `488dcf7d`), mantendo o handler no importMap (prova: `generate:importmap` com envs `S3_*` dummy → diff vazio vs main); CI verde; aguardando o merge. Deploy pós-merge via o pipeline canônico do OPS71 (dispatch manual do `deploy.yml`: `verify` full → job `deploy` no runner self-hosted recém-instalado no homeserver — 2 tentativas "stale run" por merges paralelos no meio do verify, terceira aplicou `dba75ff8`); migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: container na revision `dba75ff8`, healthy, **log sem nenhum `getFromImportMap`**, `/admin` renderizando o formulário de login (2 inputs email/senha + botão Login, título "Login - Payload", 0 erros de console), e a home pública mantendo "Nossa história" (S8) e o form de novidades (S9). Evidência: `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`. Lições registradas: a regeneração do importMap sem envs `S3_*` re-órfão o handler (classe exata que o guard #87 deve impedir no CI); divergência de plataformas pós-cutover — workspace do homeserver ainda apontava para o Forgejo local (`localhost:3000`) e o S10 (pixel) segue pendente de re-home no GitHub.
|
||||
71
docs/ops/instagram-feed-token-runbook.md
Normal file
71
docs/ops/instagram-feed-token-runbook.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Runbook — Feed do Instagram: validar/gerar o token (S11)
|
||||
|
||||
Issue: #115 · Entrega: S11 (painel de status da sincronização no admin)
|
||||
|
||||
## O que o painel mostra
|
||||
|
||||
Na global **Configurações → Feed de redes sociais**, o bloco "Instagram — estado
|
||||
da sincronização" diz o estado da última tentativa:
|
||||
|
||||
| Estado | Significado | Ação |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| **Sincronizado · há X min · N posts** | Credenciais aceitas; posts no board "Acompanhe de perto" | Nenhuma |
|
||||
| **Falha na última sincronização** | A Graph API recusou a última tentativa; o texto diz o motivo e a correção | Corrigir conforme o motivo e clicar **Tentar sincronizar de novo** |
|
||||
| **Instagram ainda não configurado** | Falta token ou ID | Preencher os dois campos |
|
||||
| **Aguardando a primeira sincronização** | Configurado, nenhuma tentativa ainda | Clicar **Tentar sincronizar de novo** |
|
||||
|
||||
O estado é atualizado no salvar (quando as credenciais mudam), em cada render da
|
||||
home (cache de 5 min) e no clique do botão. A home pública nunca quebra quando a
|
||||
API falha — o board segue com artigos + YouTube.
|
||||
|
||||
## Diagnóstico da ocorrência de 2026-08-19 (causa provável)
|
||||
|
||||
Produção mostrava picker vazio e nenhum card IG com token + ID preenchidos — o
|
||||
fail-closed silencioso da S3 não dizia o porquê. A causa mais provável, na ordem:
|
||||
|
||||
1. **Token emitido via Facebook Login** (page token) — a `graph.instagram.com`
|
||||
recusa; o refresh automático não renova (o endpoint `refresh_access_token`
|
||||
só aceita tokens do Instagram Login).
|
||||
2. **Token expirado/revogado**.
|
||||
3. **ID do usuário errado** (não é o da conta Business/Creator vinculada).
|
||||
|
||||
**Confirmação em 1 clique:** no admin, **Tentar sincronizar de novo** — o painel
|
||||
mostra o motivo exato devolvido pela API. Se a causa for a 1, aparece a mensagem
|
||||
de token recusado com a correção; se for a 3, a mensagem de ID não reconhecido.
|
||||
|
||||
## Validar um token sem o admin
|
||||
|
||||
```bash
|
||||
# O endpoint /me responde com o username e o tipo de conta se o token é válido
|
||||
# para a Graph API do Instagram (Business/Creator):
|
||||
curl -s "https://graph.instagram.com/me?fields=user_id,username&access_token=SEU_TOKEN"
|
||||
|
||||
# Teste o refresh (só tokens emitidos pelo Instagram Login passam):
|
||||
curl -s "https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=SEU_TOKEN"
|
||||
```
|
||||
|
||||
- `/me` com `OAuthException` → token inválido/expirado ou emitido via
|
||||
**Facebook Login** (a Graph API do Instagram só aceita tokens do Instagram
|
||||
Login).
|
||||
- `/me` ok mas o ID configurado não bate com `user_id` → corrigir o campo
|
||||
"ID do usuário".
|
||||
|
||||
## Gerar um token de longa duração (Instagram Login)
|
||||
|
||||
1. Acessar `developers.facebook.com` com a conta que administra a página →
|
||||
criar/usar um app do tipo **Business** (qualquer tipo serve para o fluxo de
|
||||
negócios, mas o app precisa estar em modo Live para uso fora do sandbox).
|
||||
2. Produto **Instagram** → **Instagram Login with Basic Display** → **Gerar
|
||||
token** com a conta Business/Creator do @depjorgesolla.
|
||||
3. Trocar o token de curta duração pelo de longa duração usando o
|
||||
**Long-Lived Token Exchange** da documentação do Instagram Basic Display
|
||||
(ou usar o endpoint `refresh_access_token` do passo anterior após a troca).
|
||||
4. Colar o token e o ID numérico da conta no admin e clicar
|
||||
**Tentar sincronizar de novo**.
|
||||
|
||||
## O que NÃO fazer
|
||||
|
||||
- Não usar token da API Graph do Facebook (página) — o app recusa.
|
||||
- Não colar o token em arquivos, Issues ou chat — o campo é admin-only e deve
|
||||
continuar.
|
||||
- Não zerar o token para "testar" — o painel de status dispensa tentativa-e-erro.
|
||||
|
|
@ -1,40 +1,30 @@
|
|||
# Runbook: deploy do site 1313 no homeserver (OPS53, janela OPS65)
|
||||
# Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 — dispatch manual)
|
||||
|
||||
O deploy de produção é uma etapa do CI (`ci.yml` no Forgejo): mergir em `main`
|
||||
com a suite verde publica o site em `jorgesolla1313.com.br` — sem passo manual.
|
||||
Merge **com mudança de produção** = site atualizado em ~30 min (janela) + tempo
|
||||
da suite/deploy (~15–25 min cada; tipicamente < 1 h no total).
|
||||
O deploy de produção é uma **action manual** (`workflow_dispatch` no GitHub
|
||||
Actions, `.github/workflows/deploy.yml`): mergir em `main` **não** publica
|
||||
nada. O operador dispara o deploy quando decide publicar; antes de tocar o
|
||||
homeserver, o job `verify` (hosted) roda a suíte **full** (incl. e2e full).
|
||||
|
||||
## Gatilho e fluxo
|
||||
|
||||
1. `git push` para `main` (via PR merge) — o `ci.yml` **não dispara mais a cada
|
||||
push**: roda por **janela fixa de 30 min** (`schedule */30`) ou via
|
||||
`workflow_dispatch` (deploy manual, imediato, sempre full suite).
|
||||
2. Job `gate` (barato, host, sem pnpm): lê a **revision do container rodando**
|
||||
no homeserver (`docker inspect` → label `org.opencontainers.image.revision`
|
||||
do `teqo-1313` — a fonte de verdade; o compose pode mentir após rollback
|
||||
falho, pois é trocado antes do rollout) e classifica "mudança de produção"
|
||||
com o `.dockerignore` como fonte da verdade (o que não entra na imagem não
|
||||
justifica build novo; `docs/`, `.agents/`, `.env*` viram skip;
|
||||
`scripts/`, `tests/`, `.forgejo/`, `AGENTS.md` contam como produção —
|
||||
conservador, qualquer dúvida roda a suite).
|
||||
- Deployed == HEAD → suite e deploy **skipped**; run verde.
|
||||
- Sem revision conhecida (label ausente, container sem label) → fail-open:
|
||||
suite + deploy rodam na janela (run verde; comportamento conservador).
|
||||
- Gate incapaz de classificar (erro de git/`.dockerignore`) → suite roda e
|
||||
o run fica **vermelho** — nunca blackout silencioso.
|
||||
3. Com mudança de produção: suite completa verde (static/int/build/e2e/checks).
|
||||
4. Job `deploy` (runs-on `host`, na **workstation**) roda
|
||||
`ssh homeserver "bash -s -- <sha>" < scripts/deploy-homeserver.sh`.
|
||||
O script é **idempotente**: se o container rodando já tem a revision do SHA
|
||||
(ex.: dispatch duplicado), sai verde "already deployed" sem rebuild.
|
||||
5. O script, no **homeserver**: guarda de HEAD (`main` remoto == SHA do job,
|
||||
senão skip) → `flock` (serializa) → guard "already deployed" (revision do
|
||||
container) → **build do migrator** (o estágio migrator não roda `next
|
||||
build` — builda mesmo contra o schema antigo; BuildKit, secrets do
|
||||
`~/stack/teqo-1313.env`) → push/tag do migrator em `localhost:5000` →
|
||||
swap dos tags de imagem no `~/stack/docker-compose.yml` (backup antes) →
|
||||
**migrations**
|
||||
1. GitHub → Actions → **Deploy (manual)** → Run workflow (ref: `main`).
|
||||
2. Job `verify` (hosted `ubuntu-latest`, ~50 min): suíte full sem skips —
|
||||
check-test-locations → lint → format → typecheck → knip → cycles → unit →
|
||||
int (migrate+seed nos services) → build → e2e full.
|
||||
3. Se `verify` verde, o job `deploy` (`needs: [verify]`,
|
||||
`runs-on: [self-hosted, homeserver]`) roda no **runner do GitHub instalado
|
||||
no homeserver** e executa `scripts/deploy-homeserver.sh <sha>` **localmente**
|
||||
(sem SSH; o runner conecta outbound ao GitHub — funciona atrás do
|
||||
Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não
|
||||
conta minutos hosted).
|
||||
4. O script, no **homeserver**: guarda de HEAD (`git ls-remote` de
|
||||
`TEQO_REPO_URL` — default `https://github.com/fsolla/teqo.git`, público —
|
||||
== SHA do job, senão "stale run" skip) → `flock` (serializa) → guard
|
||||
"already deployed" (revision do container rodando) → **build do migrator**
|
||||
(o estágio migrator não roda `next build` — builda mesmo contra o schema
|
||||
antigo; BuildKit, secrets do `~/stack/teqo-1313.env`) → push/tag do
|
||||
migrator em `localhost:5000` → swap dos tags de imagem no
|
||||
`~/stack/docker-compose.yml` (backup antes) → **migrations**
|
||||
(`docker compose --profile maintenance run --rm teqo-1313-migrate </dev/null`,
|
||||
já com a imagem do SHA novo) → **build do runner** (contra o banco JÁ
|
||||
migrado — o `next build` da geração estática lê o schema novo, OPS66;
|
||||
|
|
@ -43,27 +33,63 @@ build` — builda mesmo contra o schema antigo; BuildKit, secrets do
|
|||
→ push/tag do runner → `docker compose up -d teqo-1313` → healthcheck →
|
||||
smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn
|
||||
login-options, `api/revalidate` com o secret real).
|
||||
6. Falha = job vermelho no commit; nada é publicado pela metade.
|
||||
5. Falha = job vermelho; nada é publicado pela metade (rollback automático
|
||||
após o swap).
|
||||
|
||||
**Primeira janela (verificação ao vivo):** após o merge do OPS65, acompanhe um
|
||||
run agendado — confira no log do job `gate` o `deployed_sha` lido do container e
|
||||
o veredito da classificação, e que um dispatch manual roda suite + deploy
|
||||
(idempotência: segundo dispatch no mesmo SHA sai "already deployed"). Isso
|
||||
prova ao vivo a paridade Forgejo de `needs.<gate>.outputs` com gate falho
|
||||
(suite roda; deploy pula; run vermelho).
|
||||
**Primeiro deploy (verificação ao vivo):** depois do cutover OPS71, dispare o
|
||||
deploy manual e confira no log do job `deploy` o `set-url` do workspace
|
||||
(origem Forgejo → GitHub), o `deployed_sha` lido do container e o guard
|
||||
"already deployed" num segundo dispatch do mesmo SHA.
|
||||
|
||||
## Onde roda cada coisa
|
||||
|
||||
| Máquina | Papel |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **workstation** (16c/61GB) | runner Forgejo (`workstation-runner`); job `deploy` roda no host (chave SSH `~/.ssh/id_ed25519` → `ssh homeserver`) |
|
||||
| **homeserver** (8c/16GB) | todo o stack (`~/stack/docker-compose.yml`): postgres `teqo_1313`, forgejo, registry `localhost:5000`, cloudflared, `teqo-1313` na 127.0.0.1:1313; workspace `~/teqo-deploy` (clone do Forgejo local) |
|
||||
| Máquina | Papel |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **GitHub hosted** (ubuntu) | CI de PR (`ci-pr.yml`, job `checks`) e job `verify` do `deploy.yml` (suíte full). Nunca toca o homeserver. |
|
||||
| **homeserver** (8c/16GB) | todo o stack (`~/stack/docker-compose.yml`): postgres `teqo_1313`, forgejo, registry `localhost:5000`, cloudflared, `teqo-1313` na 127.0.0.1:1313; **runner self-hosted do GitHub** (labels `self-hosted`, `homeserver`; instalado 2026-08-19 — Issue #113 OPS71-INFRA; `~/actions-runner` v2.336.0, systemd user `actions.runner.fsolla-teqo.teqo-1313-runner.service` — `systemctl --user status/restart`); workspace `~/teqo-deploy` (clone de `github.com/fsolla/teqo`, público) |
|
||||
| **workstation** | dev/agentes apenas — o runner do Forgejo foi **desligado** no cutover (o schedule do `ci.yml` antigo não tem mais razão; religar é reversível) |
|
||||
|
||||
Segredos **não** ficam no Forgejo: o script sourceia `~/stack/teqo-1313.env` e
|
||||
`~/stack/.env` (chmod 600) no próprio homeserver. Nenhum secret novo em
|
||||
Settings → Actions.
|
||||
Segredos **não** ficam no GitHub: o script sourceia `~/stack/teqo-1313.env` e
|
||||
`~/stack/.env` (chmod 600) no próprio homeserver. O repo GitHub é público — o
|
||||
clone do workspace não precisa de credencial. No GitHub ficam apenas os
|
||||
secrets de integração: `FORGEJO_API_TOKEN` (flips pós-merge) e
|
||||
`CURSOR_API_KEY` (archive helper, dormente).
|
||||
|
||||
## Rollback
|
||||
## Cutover OPS71 (passos manuais, ordem)
|
||||
|
||||
1. Remotes locais (config compartilhada pelos worktrees):
|
||||
`git remote set-url origin git@github.com:fsolla/teqo.git` e
|
||||
`git remote add forgejo ssh://git@192.168.15.142:2222/fsolla/teqo.git`
|
||||
(ou `https://git.solla.dev/fsolla/teqo.git`) — o repo Forgejo congela em
|
||||
main; o tracker (Issues) segue vivo por API.
|
||||
2. GitHub repo: secrets `FORGEJO_API_TOKEN` + `CURSOR_API_KEY`
|
||||
(Settings → Secrets and variables → Actions) e **`Allow auto-merge`**
|
||||
(Settings → General → Pull Requests; ou `PATCH /repos/...` com
|
||||
`allow_auto_merge: true`). Repo é público: em Settings → Actions →
|
||||
General, defina fork PR workflows como não executar (ou exigir aprovação).
|
||||
3. PAT local: criar token GitHub com escopo `repo` e exportar `GITHUB_TOKEN`
|
||||
(para `node scripts/github-pr.mjs` e `pnpm configure:branch-protection`).
|
||||
4. `GITHUB_TOKEN=… pnpm configure:branch-protection` — required check
|
||||
`CI (PR) / checks` (literal de match: `checks` — o GitHub casa pelo nome do
|
||||
check-run; a UI exibe workflow/job; pin PR #742), 0 reviews,
|
||||
`enforce_admins: true`. **Antes do primeiro push** (senão o PR mergearia
|
||||
sem required check).
|
||||
5. **Desligar o runner do Forgejo** na workstation (ex.:
|
||||
`systemctl --user stop forgejo-runner`) — após o primeiro PR GitHub
|
||||
validado; para o schedule do `ci.yml` antigo.
|
||||
6. Instalar o runner self-hosted do GitHub no homeserver (labels
|
||||
`self-hosted` + `homeserver`; usuário com docker + acesso a `~/stack`;
|
||||
registrar em infra-solla: `STATE.md`).
|
||||
7. Push do PR do OPS71 → CI verde → auto-merge → flip da Issue no Forgejo →
|
||||
deploy manual de validação.
|
||||
|
||||
Rollback do cutover (se o GitHub falhar cedo): religar o runner do Forgejo e
|
||||
abrir PRs no Forgejo a partir do main congelado — os workflows de
|
||||
`.forgejo/workflows/` foram removidos na OPS71 Fase 2, mas restauram do git
|
||||
history (`git revert` do PR da remoção, ou clone do main congelado do Forgejo)
|
||||
— remotes: `git remote set-url origin ssh://git@192.168.15.142:2222/fsolla/teqo.git`.
|
||||
|
||||
## Rollback (do deploy)
|
||||
|
||||
A imagem anterior **não** fica mais local após a limpeza pós-deploy (INF3/F2:
|
||||
`docker builder prune` + remoção das tags locais antigas, exceto o SHA em uso)
|
||||
|
|
@ -91,39 +117,40 @@ roda após migrator build+push+swap ok; rollout só após runner build ok). Desd
|
|||
o OPS66 o migrate roda **antes** do build do runner (o build precisa do schema
|
||||
novo): um build do runner que falhe depois do migrate deixa o banco à frente
|
||||
do código — migrations são append-only e revisadas antes do deploy (checklist);
|
||||
a correção se re-mergeia e a próxima janela completa o deploy.
|
||||
a correção se re-mergeia e o próximo deploy completo publica.
|
||||
|
||||
## Falhas conhecidas
|
||||
|
||||
| Sintoma | Causa | Tratamento |
|
||||
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Job verde sem deploy ("stale run") | Push mais novo venceu; o deploy antigo saiu da fila | Nada a fazer — o run do SHA novo deploya |
|
||||
| Job verde sem deploy ("already deployed") | O container rodando já tem a revision do SHA do job (ex.: `workflow_dispatch` duplicado da mesma HEAD) | Nada a fazer — o site já roda esse SHA. Forçar rebuild da MESMA HEAD não é suportado via dispatch: edite o tag da imagem no compose (`image: teqo-1313:<sha>-rebuild`) e `docker compose up -d`, ou espere um commit novo |
|
||||
| Gate `gate` vermelho (erro de git/`.dockerignore`) | SHA deployado ilegível para o git (ex.: história reescrita) ou `.dockerignore` corrompido no checkout | Fail-open: suite roda; deploy pula (nunca deploy às cegas). Corrigir a causa e esperar a próxima janela (auto-cura). Se `ssh homeserver` estiver fora, o gate lê revision vazia e roda tudo (verde) — checar tailnet se a suite roda sem deploy recorrente |
|
||||
| Sem revision no container (label ausente no compose) | O compose do homeserver não define `org.opencontainers.image.revision` (deploy pré-OPS53 ou edição manual) | Degradação conservadora: toda janela roda suite + rebuild (verde). Corrigir adicionando o label ao serviço `teqo-1313` no compose e re-deployando |
|
||||
| Todo job docker quebra com `exec: "node": executable file not found in $PATH` | `runner.envs.PATH` do `~/.forgejo-runner/config.yaml` (workstation) com paths só de host (nvm/bun) — o container perde o node do toolcache (`/opt/acttoolcache`); edição do config exige o toolcache primeiro (ver comentário no próprio config) | Corrigir o PATH no config (toolcache primeiro — ver comentário no próprio arquivo) + `systemctl --user restart forgejo-runner` (incidente 2026-08-17) |
|
||||
| Deploy para logo após o migrate ("Done." e nada mais, EXIT=0) | `docker compose run` anexa stdin por padrão — o container consome o resto do script que vai no pipe do `bash -s`; o bash chega a EOF e termina sem rodar o rollout | O script já usa `< /dev/null` no `run --rm` do migrate (não remover); sintoma visto 2026-08-17 no primeiro deploy |
|
||||
| Build falha: `network mode "stack_default" not supported by buildkit` | BuildKit (drivers docker e docker-container) recusa rede bridge custom no `--network` | O script builda com `--network host` + proxy socat `teqo-1313-build-proxy` (na `stack_default`, publicado em 127.0.0.1:5433, criado idempotentemente com `restart: unless-stopped`) + `DATABASE_URL` reescrita para o loopback |
|
||||
| Build OOM no homeserver | Laptop 8c/16GB com o stack ativo (~12GB livres medidos) | Re-dispatch; se recorrente, item futuro: build na workstation com túnel |
|
||||
| Build do runner falha: `relation "..." does not exist` no `next build` | Migration nova criou tabela lida em geração estática. Pré-OPS66 a ordem era build→migrate e o deploy morria aqui para sempre (incidente 2026-08-18, S2) | Pós-OPS66 não deve ocorrer: migrate roda antes do build do runner. Se reaparecer, confira no log se o passo migrate rodou; recovery manual: `docker build --target migrator` + `docker run --rm --network stack_default --env-file ~/stack/teqo-1313.env localhost:5000/teqo-1313-migrator:<sha>` e re-dispatch do workflow |
|
||||
| `checkout@v5` falha no job `host` | Label host do act_runner | Fallback: baixar o script direto do Forgejo (`curl -s https://git.solla.dev/fsolla/teqo/raw/branch/main/scripts/deploy-homeserver.sh`) |
|
||||
| Migrate falha | Drift/erro de schema | Job vermelho; site segue no container antigo; corrigir e re-mergear |
|
||||
| Smoke falha pós-up | Regressão de runtime | Rollback automático (restore + `up -d`) + job vermelho; investigar |
|
||||
| Sintoma | Causa | Tratamento |
|
||||
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Job verde sem deploy ("stale run") | Push mais novo venceu; o dispatch apontava um SHA que não é mais o HEAD do main do GitHub | Nada a fazer — dispatch novo no main atual |
|
||||
| Job verde sem deploy ("already deployed") | O container rodando já tem a revision do SHA do job (ex.: `workflow_dispatch` duplicado da mesma HEAD) | Nada a fazer — o site já roda esse SHA. Forçar rebuild da MESMA HEAD não é suportado via dispatch: edite o tag da imagem no compose (`image: teqo-1313:<sha>-rebuild`) e `docker compose up -d`, ou espere um commit novo |
|
||||
| Job `deploy` nunca roda / runner offline | Runner self-hosted do homeserver não instalado ou parado (passo manual do cutover OPS71) | Instalar/religar o runner (labels `self-hosted`, `homeserver`); os PRs e o `verify` não dependem dele — só o deploy |
|
||||
| Deploy dispara mas o job `verify` falha | Regressão real na suíte full | Corrigir e re-dispatchar — o homeserver nunca é tocado |
|
||||
| Deploy para logo após o migrate ("Done." e nada mais, EXIT=0) | `docker compose run` anexa stdin por padrão — o container consome o resto do script; o bash chega a EOF e termina sem rodar o rollout | O script já usa `< /dev/null` no `run --rm` do migrate (não remover); sintoma visto 2026-08-17 no primeiro deploy |
|
||||
| Build falha: `network mode "stack_default" not supported by buildkit` | BuildKit (drivers docker e docker-container) recusa rede bridge custom no `--network` | O script builda com `--network host` + proxy socat `teqo-1313-build-proxy` (na `stack_default`, publicado em 127.0.0.1:5433, criado idempotentemente com `restart: unless-stopped`) + `DATABASE_URL` reescrita para o loopback |
|
||||
| Build OOM no homeserver | Laptop 8c/16GB com o stack ativo (~12GB livres medidos) | Re-dispatch; se recorrente, item futuro: build na workstation com túnel |
|
||||
| Build do runner falha: `relation "..." does not exist` no `next build` | Migration nova criou tabela lida em geração estática. Pré-OPS66 a ordem era build→migrate e o deploy morria aqui para sempre (incidente 2026-08-18, S2) | Pós-OPS66 não deve ocorrer: migrate roda antes do build do runner. Se reaparecer, confira no log se o passo migrate rodou; recovery manual: `docker build --target migrator` + `docker run --rm --network stack_default --env-file ~/stack/teqo-1313.env localhost:5000/teqo-1313-migrator:<sha>` e re-dispatch do workflow |
|
||||
| Workspace clone sem o SHA novo | O workspace antigo apontava para o Forgejo local (pré-OPS71) e o fetch não achou o SHA | O script agora re-aponta `origin` para `TEQO_REPO_URL` (idempotente) antes do fetch — se ainda falhar, `git -C ~/teqo-deploy remote -v` e corrigir manualmente |
|
||||
| Migrate falha | Drift/erro de schema | Job vermelho; site segue no container antigo; corrigir e re-mergear |
|
||||
| Smoke falha pós-up | Regressão de runtime | Rollback automático (restore + `up -d`) + job vermelho; investigar |
|
||||
|
||||
## Segurança (decisão deliberada)
|
||||
|
||||
- O job `deploy` usa `runs-on: host` — executa como `fsolla` na workstation.
|
||||
Está **apenas** no `ci.yml` (janela `*/30` em `main` + dispatch), nunca no
|
||||
`ci-pr.yml`. O job `gate` tem o mesmo perfil e é restrito a
|
||||
`github.ref == 'refs/heads/main'` (dispatch em branch não roda código do
|
||||
branch no host — a suite roda fail-open em containers com secrets de teste).
|
||||
`main` só anda por PR mergeado com suite verde. Fork-PRs não disparam
|
||||
workflows no Forgejo (default off — verificado).
|
||||
- O job `deploy` usa `runs-on: [self-hosted, homeserver]` — executa como o
|
||||
usuário do runner no homeserver. Está **apenas** no `deploy.yml`
|
||||
(dispatch manual), nunca no `ci-pr.yml`; o hosted (`verify`) não tem acesso
|
||||
ao homeserver e o self-hosted não recebe secrets de produção (as envs vivem
|
||||
no próprio homeserver). `main` só anda por PR mergeado com CI verde; o
|
||||
deploy só roda com `verify` full verde. Fork-PRs não rodam CI (same-repo
|
||||
gate + setting do repo).
|
||||
- Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` /
|
||||
build-secrets; envs só no homeserver.
|
||||
|
||||
## Referências
|
||||
|
||||
- `scripts/deploy-homeserver.sh` — o script remoto (fonte da verdade do fluxo)
|
||||
- `docs/plans/ops53-ci-deploy-homeserver*.md` — intenção e decisões
|
||||
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo)
|
||||
- `.github/workflows/deploy.yml` — verify → deploy
|
||||
- `docs/plans/ops53-ci-deploy-homeserver*.md` — intenção e decisões (era Forgejo)
|
||||
- `docs/plans/ops71-ci-github-actions-tracker-forgejo*.md` — o cutover
|
||||
- infra-solla: `STATE.md`, `plano-infra-final.md` §"Arquitetura de deploy"
|
||||
|
|
|
|||
89
docs/plans/demandas-responsaveis-impl.md
Normal file
89
docs/plans/demandas-responsaveis-impl.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# Impl: Demandas visíveis apenas para responsáveis explícitos + candidato/coordenador
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #106
|
||||
Intenção: docs/plans/demandas-responsaveis.md
|
||||
Appetite restante: herdado (~1–1,5 dias eng; tracer primeiro, polish depois)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** a demanda tem um conjunto de **responsáveis** (staff da campanha); candidato e coordenador sempre a veem; assessor do município relacionado que não é responsável **não vê** — nem lista, nem URL, nem busca. Criador entra automaticamente como responsável. Quem atualiza a demanda gerencia os responsáveis. Leader lockdown intacto; custo/comprovantes seguem a visibilidade da demanda.
|
||||
- **O que NÃO negociar:** visibilidade **fail-closed** por vínculo explícito (nada de herança do município); regra nos dados (access), não escondida no cliente; leader nunca vê demandas; escalada continua candidato/coordenador.
|
||||
- **O que reavaliar:** a hipótese da intenção era "`canReadCampaignDemand` passa a ser unrestricted ou responsável". Confirma-se no código: hoje o escopo vive **só** em `src/utilities/access/demands.ts:33` (`resolveActorScopedRead` com `municipality`), e **todos** os consumidores (lista, detalhe, busca global do dashboard, demandas vinculadas da atividade, C90, omnibox) herdam via Local API `overrideAccess: false` — não há REST nem client query. O corte é cirúrgico: regra nova + campo + migration + UI de gestão.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
F[CampaignDemand.responsibles<br/>hasMany → campaignUser] --> A[canRead/UpdateCampaignDemand<br/>advisor → responsibles contains id]
|
||||
H[enforceDemandWorkflow<br/>criador vira responsável na criação] --> F
|
||||
M[Migration: campaign_demand_rels<br/>+campaign_user_id + backfill createdBy] --> F
|
||||
S[updateCampaignDemandResponsibles<br/>tx + advisory lock] --> A
|
||||
U[DemandResponsibleMultiSelect<br/>nova + wizard + detalhe] --> S
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A — Campo `responsibles` hasMany → `campaignUser` + regra `where` de membership** (espelho de `municipality.advisors` / `{ advisors: { contains } }`).
|
||||
- **B — Relação polimórfica `responsible` (union campaignUser/leadership/stateDeputy) como a `activity`.**
|
||||
- **C — `or: [responsibles contains, createdBy equals]`** (criador visível mesmo se removido dos responsáveis).
|
||||
|
||||
**Recomendação:** **A** — single collection, sem union desnecessária (responsável é sempre staff da campanha — `eligibleCampaignStaffWhere`), query escalar `{ responsibles: { contains: id } }` (mesmo mecanismo do `advisors`), e a join table `campaign_demand_rels` **já existe** (hoje usada por `receipts`/media) — a migration só adiciona a coluna `campaign_user_id` + FK + índice.
|
||||
|
||||
**Rejeitadas:** **B** porque responsável é sempre `campaignUser` (não há liderança/dobradinha como responsável de demanda — a intenção fala de "pessoas da campanha"/staff; union polimórfica traria o custo do `equals { relationTo, value }` e catalog triplo sem ganho). **C** porque fura o modelo de responsável explícito: remover o criador da lista não o tiraria da visibilidade, e a intenção fixou "criador entra **como responsável**" (vínculo explícito, único source of truth). O criador entra na lista por hook **na criação** + **backfill** dos existentes.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
1. **Regra de leitura/atualização** — `canReadCampaignDemand` deixa de usar `resolveActorScopedRead` (que é o prólogo municipal) e vira função própria:
|
||||
`admin → true; leader → false; unrestricted → true; advisor → { responsibles: { contains: id } }; resto → false`. `canUpdateCampaignDemand = canReadCampaignDemand` (regra única "quem vê gerencia" — aceite: "quem pode atualizar pode gerenciar os responsáveis"). `canCreateCampaignDemand` **inalterado** (staff no município da carteira). `canDeleteCampaignDemand` admin-only inalterado. `canReadCampaignStaffField`/`canManageCampaignStaffField` inalterados.
|
||||
2. **Criador auto-responsável** — no branch `create` do hook `enforceDemandWorkflow` (`CampaignDemand.ts:129-143`), quando `actor` é campaignUser: `data.responsibles = union(data.responsibles, [actor.id])`. Cobre **todos** os caminhos de criação: form `/demandas/nova`, wizard (A5), rascunhos C90 (activity.ts passa `user: currentActor`), admin UI (payload admin → actor null → sem responsáveis → fail-closed: invisível a assessores, o que é aceitável — quem criou via admin vê tudo). Sem auto-add no update.
|
||||
3. **Backfill dos existentes** — na própria migration (padrão C90 `20260808_184113_remodel_activity_responsible.ts`, `RAISE NOTICE` + SQL no up): `INSERT INTO campaign_demand_rels (order, parent_id, path, campaign_user_id) SELECT 0, id, 'responsibles', created_by_id FROM campaign_demand WHERE created_by_id IS NOT NULL`. Preserva "a criação não some do criador" para as demandas pré-c143. Campo novo → nenhuma linha tem responsável → sem dedup.
|
||||
4. **Gestão (server)** — `updateCampaignDemandResponsiblesRecord` em `actions/demand.ts`, mesmo shape dos irmãos: schema zod novo (`{ id, responsibles: array de positiveRelationshipId }`), `withPayloadTransaction`, `reloadCampaignActor`, staff check, advisory lock `campaign-demand:{id}`, `findByID` scoped (`overrideAccess: false` — a row access autoriza antes do write), `payload.update` com a lista **completa** (replace). Replace é a forma mais simples e segura: o estado do client já é a lista inteira e o advisory lock serializa editores concorrentes (add/remove atômico seria 2 formas de escrita para o mesmo dado).
|
||||
5. **Busca de candidatos (UI)** — server action `searchDemandResponsibleOptions(query, municipalityId)` (padrão `searchDemandActivityOptions`): query < 2 chars → sugestões = assessores do município da demanda (read scoped de `municipality.advisors`); senão → busca por nome em `campaignUser` com `eligibleCampaignStaffWhere` (reusa o padrão de `loadEligibleAdvisorOptions`), `overrideAccess: false` (campaign users leem `campaignUser` — `canReadCampaignUsers`). Sugestão NÃO é herança: é atalho de preenchimento, a visibilidade vem só do campo salvo.
|
||||
6. **Componente de multi-select** — `DemandResponsibleMultiSelect` novo em `src/components/campaign/demand/` (domain, não shared): single collection, chips removíveis, chip do criador rotulado "Você (criador)", sugestões no diálogo quando query curta, hidden inputs repetidos `responsibles` (contrato de `RelationMultiSelect`, não o JSON de `ResponsibleMultiSelect`). **Não reusar `ResponsibleMultiSelect` (C90)**: é polimórfico (typeLabel/relationTo), tem cap hardcoded `MAX_ACTIVITY_RESPONSIBLES=20`, e o contrato de FormData é JSON tipado — forçar modo single-collection seria 4 props novas + branch, um módulo raso; dois componentes com contratos distintos é honesto (mesmo critério que separou `RelationMultiSelect` de `ResponsibleMultiSelect`).
|
||||
7. **Detalhe** — card `DemandResponsiblesCard` (client) com a lista (nome + avatar via `loadCampaignUserDisplayByIds`, helper existente), add por combobox, remove por chip, e o rótulo estático "Visível para: Responsáveis · Candidato · Coordenador". Layout do rascunho: grade `lg:grid-cols-3` — coluna 1–2 com os blocos atuais (descrição, decisão, workflow, controle interno), coluna 3 com o card. Todos os readers são updaters (update access = read access), então o card de gestão aparece para qualquer staff que abra a demanda — sem flag.
|
||||
8. **Criação** — `DemandFields` ganha o campo responsáveis (opcional via prop `responsiblesMunicipalityId`/`currentUser`): `/demandas/nova` e wizard compartilham o mesmo parse (`parseCampaignDemandCreateFormData` ganha `responsibles: formData.getAll('responsibles')`), schema de create ganha `responsibles: z.array(positiveRelationshipId)` (sem cap — como `municipality.advisors`; o hook faz o union, dedup implícito). Draft: `/demandas/nova` em grade 2 colunas (form | responsáveis); wizard empilhado (o wizard herda via `DemandFields`).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`CampaignDemand.responsibles`** (`src/collections/CampaignDemand.ts`): `relationship hasMany → campaignUser`, label "Responsáveis", index; sem field access (row access já gateia; staff nativo não abre `/admin`).
|
||||
- **`enforceDemandWorkflow`** (`CampaignDemand.ts`): union do criador no create (campaignUser only).
|
||||
- **`canReadCampaignDemand`/`canUpdateCampaignDemand`** (`src/utilities/access/demands.ts`): regra de membership; remove `resolveActorScopedRead`/`getAccessibleMunicipalityIds` deste módulo.
|
||||
- **Migration:** `add_campaign_demand_responsibles` (drizzle): `ALTER TABLE campaign_demand_rels ADD COLUMN campaign_user_id` + FK (`ON DELETE cascade`, padrão `state_deputy_rels`) + índice; + backfill no up. Gerar com `pnpm migrate:create`.
|
||||
- **Access / Consent:** sem Consent (dado interno staff). Fail-closed no where da access.
|
||||
- **Server actions:** `updateCampaignDemandResponsiblesRecord` + `setDemandResponsiblesFormAction` (`[slug]/formActions.ts`, `revalidatePath`); `searchDemandResponsibleOptions` (`demandas/responsibleSearchActions.ts`).
|
||||
- **UI:** `DemandResponsibleMultiSelect` + `DemandResponsiblesCard` em `src/components/campaign/demand/`; `DemandFields`/`DemandForm`/`WizardRegisterDemandStep` ganham o campo; `[slug]/page.tsx` ganha o card + grade lg. Impeccable B — seguir o rascunho (`demandas-responsaveis-ui-draft-*.png`): chips, placeholder "Buscar assessor…"/"Adicionar responsável…", textos informativos do rascunho.
|
||||
- **View-model:** `DemandDetailViewModel.responsibles: Array<{ id, name, avatarUrl }>` via `loadCampaignUserDisplayByIds`; `nova/page.tsx` carrega o `currentUser` (id/nome) para seed do picker.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- Sem dados apresentados ao usuário além de nomes/avatares de responsáveis — forma já existente (chips com nome + avatar, padrão do código). Lista da demanda **não** ganha coluna de responsáveis (fora do aceite — apenas visibilidade muda).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Schema + server (tracer)** — migration + campo + hook do criador + access rule; `pnpm generate:types`; int tests de access reescritos/adicionados (`campaignDemandWorkflow.int.spec.ts`, `homeSearchDemands.int.spec.ts`); backfill verificado no local.
|
||||
2. **Ações + busca** — `updateCampaignDemandResponsiblesRecord` + zod + `searchDemandResponsibleOptions`; int de gestão (add/remove/self-removal, advisor não-responsável não lê).
|
||||
3. **UI** — multi-select na criação (nova + wizard) e card no detalhe; shape → critique → polish contra o rascunho; e2e: assessor não-responsável não vê (lista vazia + URL → 404); ajustar e2e existentes se o aceite mudou o comportamento (criador auto-responsável preserva os fluxos atuais).
|
||||
4. **Gates** — `tsc`, `lint`, `format:check`, `knip`, `check:cycles`, unit+int, `build` local; e2e afetado no CI. Aikido nos arquivos tocados.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Notificação ao ser marcado responsável** (fora de escopo da intenção).
|
||||
- **Coluna de responsáveis na lista / omnibox** — visibilidade herda a access, sem mudança de UI de lista.
|
||||
- **C141 "Visão Tudo"** — não existe ainda; a regra nova já falha fechado para assessor em qualquer perfil futuro.
|
||||
- **Generalizar `ResponsibleMultiSelect`/`RelationMultiSelect`** — rejeitado (contratos distintos, módulo raso).
|
||||
- **Overflow "+N" dos chips no detalhe** — o padrão do código é wrap; o "+2" do rascunho é affordance de wireframe, não spec.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Regra nova muda o que assessores veem em todos os pontos de demanda** (lista, detalhe, busca home, activity detail, C90). Mitigação: tudo herda por `overrideAccess: false` — um único ponto de corte; int tests cobrem cada superfície; C90 cria com `user: currentActor` → criador auto-responsável → a atividade continua listando as próprias demandas.
|
||||
- **Self-removal deixa a demanda sem responsável** → visível só a candidato/coordenador (fail-closed, aceitável e coerente com o modelo explícito — gestão é dos responsáveis).
|
||||
- **Join table já existente** (`campaign_demand_rels` com `path='media'`) — a migration adiciona coluna/FK; conferir o SQL gerado (não regenerar a tabela).
|
||||
- **`codebaseConventions.unit.spec.ts`** — o guard anti-fragmento municipal não é violado (a regra nova não re-escreve `{ municipality: { in } }`); rodar o guard no gate.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (visibilidade fail-closed por responsáveis explícitos; criador automático; gestão por quem atualiza; leader lockdown; escalada inalterada)
|
||||
- [x] Invariantes AGENTS/engineering-standards (Local API com `user`/`overrideAccess: false`; transação + advisory lock nas escritas multi-step; pt-BR em labels, identificadores em inglês)
|
||||
- [x] Testes de domínio previstos (int: access rule, gestão de responsáveis, backfill; e2e: invisibilidade para não-responsável; adjust: workflow/homeSearch/listData)
|
||||
123
docs/plans/feed-instagram-nao-aparece-home-impl.md
Normal file
123
docs/plans/feed-instagram-nao-aparece-home-impl.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# Impl: Feed do Instagram configurado não aparece na home pública
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #115
|
||||
Intenção: docs/plans/feed-instagram-nao-aparece-home.md
|
||||
Appetite restante: ~1 dia eng
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** a assessoria vê, na própria global "Feed de redes sociais", o estado da sincronização do Instagram — "Sincronizado · há X min · N posts", "Falha na última sincronização" com o motivo em linguagem de produto e a correção esperada, ou "Instagram ainda não configurado" — e tem um botão "Tentar sincronizar de novo" para feedback imediato. O board público permanece fail-closed como hoje (artigos + YouTube quando o IG falha); a home pública não muda em nada.
|
||||
- **O que NÃO negociar:** painel de status por plataforma na própria global, só estado atual + motivo (sem histórico, sem alertas, sem dashboard); sem wizard OAuth; sem embeds no site público; token nunca exposto fora do admin (`read` admin-only preservado); copy em pt; identificadores em inglês; a home pública nunca quebra nem vaza erro.
|
||||
- **O que reavaliar:** a hipótese da intenção apontava `InstagramPostExclusionPicker` como superfície do status — o picker é a lista de exclusão (S3) e **fica intacto**; o painel de status é um **irmão** (`InstagramSyncStatusPanel`), mesma família de componente `admin.components`, mesmo padrão do rascunho (caixa de status separada da lista). `revalidateRequest.ts` realmente não muda (a tag `social-feed` já cobre). O diagnóstico da ocorrência em produção é impossível deste worktree (sem acesso ao DB de prod — `PROD_DATABASE_URL` não existe localmente): o próprio painel vira o instrumento de diagnóstico (1 clique após o deploy confirma a causa), e o runbook documenta validação/geração de token.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Global SocialFeedSettings<br/>+ instagramSyncStatus jsonb] --> H[afterChange<br/>revalidate + sync se credencial mudou]
|
||||
H --> S[syncInstagramFeed<br/>load + snapshot + status]
|
||||
B[Botão Tentar sincronizar] --> R[POST /api/social-feed/sync<br/>auth admin + same-origin]
|
||||
R --> S
|
||||
C[Render home<br/>getInstagramFeed · unstable_cache] -->|sucesso| P[persist snapshot + status<br/>SQL cru, sem hooks]
|
||||
C -->|falha| Q[persist status erro +<br/>fallback snapshot]
|
||||
S --> P
|
||||
S --> Q
|
||||
P --> G[(social_feed_settings)]
|
||||
Q --> G
|
||||
G --> PANEL[InstagramSyncStatusPanel<br/>ui field · 5 estados]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C
|
||||
**Recomendação:** A — estado da sincronização **persistido** numa coluna `jsonb` da própria global (escrito por render, hook e botão, todos pelo mesmo módulo `instagramSync.ts`); painel admin como campo `ui` irmão do picker; rota `POST /api/social-feed/sync` (auth de sessão admin + `isSameOriginRequest`) para o botão; erro da API mapeado para copy de produto (`InstagramApiError` + `describeInstagramError`). O caminho de render ganha apenas a persistência do status dentro do try/catch existente — zero mudança visual na home, fail-closed intacto.
|
||||
**Rejeitadas:** B) estado derivado em runtime (o painel admin não renderiza a home — sem estado não há como mostrar nada; e a intenção exige o estado da **última** sincronização, que precisa sobreviver a restart) | C) server action no botão (primeiro uso sem precedente no shell do admin do Payload; rota HTTP é previsível, testável e segue o precedente de `/api/revalidate`).
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
1. **Estado persistido como `instagramSyncStatus` (jsonb, campo hidden) — uma coluna, shape `{ lastSyncAt?, postCount?, error?, errorAt? }`.**
|
||||
Opções: A) uma coluna jsonb (padrão do `instagramFeedSnapshot`) | B) 3 colunas (`lastSyncAt`, `postCount`, `lastError`) | C) estado em memória/cache.
|
||||
Recomendação: A — o snapshot já é jsonb escrito por SQL cru; uma coluna nova espelha o padrão; o shape é autodescritivo e muda sem migration futura. `lastSyncAt`/`postCount` são da última sincronização **bem-sucedida**; `error`/`errorAt` da última **falha** (falha após sucesso preserva os campos de sucesso? **não** — o painel em falha mostra só o erro, cena 1 do rascunho; overwrite é mais simples e sem leitura-modificação-escrita).
|
||||
Rejeitadas: B 3 colunas = 3 DDLs para o mesmo dado; C não sobrevive a restart e o painel admin não compartilha o render.
|
||||
→ **Migration:** `pnpm migrate:create add_instagram_sync_status` (ADD COLUMN jsonb nullable, não destrutiva; revisar o DDL gerado — precedente S2/S3 de drift em migrações hand-written).
|
||||
|
||||
2. **`InstagramApiError` (status + apiMessage) no `loadInstagramFeed` + `describeInstagramError` para copy de produto.**
|
||||
Opções: A) o fetch parseia o body de erro da Graph API (`{ error: { message, type } }`) e lança erro tipado; `describeInstagramError` mapeia para pt-BR com a correção | B) mensagem genérica "falhou com status N" (status quo) | C) mapa estático por status.
|
||||
Recomendação: A — o produto exige o "porquê" (token recusado vs ID inválido vs rede); o body da Graph API distingue OAuthException (token inválido/expirado/emitido via Facebook Login — copy do draft) de "Invalid user id"; status 5xx = API indisponível; `TypeError` de fetch = rede. O mesmo `describeInstagramError` serve render, hook e rota (uma spelling).
|
||||
Rejeitadas: B é o bug atual (assessoria cega); C não lê o body e não cobre casos reais.
|
||||
- Mapeamentos: `400|401|403` + OAuthException/`expired|invalid|revoked` → "O token do Instagram foi recusado pela Graph API — está inválido/expirado ou foi emitido via Facebook Login (a Graph API só aceita tokens gerados pelo Instagram Login). Gere um novo token de longa duração pelo Instagram Login e atualize o campo acima."; `400` + menção a user/id → "O ID do usuário não foi reconhecido pela Graph API. Confira se é o ID numérico da conta Business/Creator."; `5xx`/outros → "A Graph API está indisponível no momento (status N). Tente novamente em alguns minutos."; `TypeError` de fetch → "Não foi possível falar com a API do Instagram (rede indisponível)."; desconhecido → "Erro inesperado ao sincronizar o Instagram."
|
||||
|
||||
3. **Rota `POST /api/social-feed/sync` para o botão — auth de sessão admin + CSRF same-origin.**
|
||||
Opções: A) route handler em `(frontend)/api/social-feed/sync` com `payload.auth({ headers })` exigindo `collection === 'users'` + `isSameOriginRequest` (helper já existente) | B) server action | C) rota sem auth (confia no botão).
|
||||
Recomendação: A — o precedente `sameOriginRequest.ts` é literalmente para "cookie-authenticated JSON route handlers"; `payload.auth` lê o cookie `payload-token` da sessão admin (precedente `campaignAuth`); a rota vive ao lado de `/api/revalidate` (grupo `(frontend)` — route handlers não passam por layouts; o URL é `/api/social-feed/sync`). Retorna `{ ok, status }`; status `null`/400 quando IG não configurado (o botão nem renderiza nesse estado — defesa).
|
||||
Rejeitadas: B sem precedente no shell admin (risco de incompatibilidade com o RSC do Payload, custo de spike); C permite abuso do rate da Graph API.
|
||||
→ Depois do sync, a rota chama `revalidateTag('social-feed')` (fora de cache — permitido).
|
||||
|
||||
4. **Sync no `afterChange` da global quando a credencial IG muda (awaited, bounded, à prova de erro).**
|
||||
Opções: A) hook `afterChange` [revalidateFeed, syncInstagramAfterChange] — sync **awaited** quando `doc` ≠ `previousDoc` em `instagramAccessToken`/`instagramUserId`/`instagramEnabled` (false→true); `AbortSignal.timeout(10_000)` no fetch; try/catch total — erro de sync nunca quebra o save | B) sync em todo save | C) sem hook — só botão e render.
|
||||
Recomendação: A — o fluxo da intenção é "preenche token + ID e salva → o admin mostra o estado": com o sync awaited, o reload da global após o salvar já mostra o resultado **com as credenciais novas** (o estado antigo mostraria a falha da credencial antiga — confuso e o problema do status quo). Gate por mudança: save de exclusões/YouTube não gasta chamada de API nem adiciona latência. Timeout: Graph API responde em <1s mesmo em erro; 10s cap em API pendurada.
|
||||
Rejeitadas: B latência e custo em todo save sem ganho; C deixa o fluxo principal da intenção em dois cliques e o estado inicialmente mentiroso.
|
||||
→ O hook usa o MESMO `syncInstagramFeed` da rota (uma implementação, dois call sites).
|
||||
|
||||
5. **Painel `InstagramSyncStatusPanel` (campo `ui`, irmão do picker) — 5 estados.**
|
||||
Opções: A) componente client próprio lendo os campos do form via `useAllFormFields` + estado local pós-botão | B) estender o picker | C) campo `json` não-hidden renderizado cru.
|
||||
Recomendação: A — o picker prova o padrão (campo `ui` + componente client + `useAllFormFields`); o painel lê `instagramSyncStatus` (inicial), `instagramAccessToken`/`instagramUserId`/`instagramEnabled` e mantém estado local só para o pós-clique (o status já está persistido no servidor — o form não precisa ser modificado). Estados: (1) `instagramEnabled === false` → "Instagram desativado — os posts não aparecem no board."; (2) sem token ou sem ID → "Instagram ainda não configurado" + explicação (cena 3); (3) `error` → "Falha na última sincronização" + mensagem + botão "Tentar sincronizar de novo" (cena 1); (4) `lastSyncAt` → "Sincronizado · há X min · N posts" + "Próxima atualização automática em ~5 min. Os N posts mais recentes estão no board 'Acompanhe de perto' da home." (cena 2, sem botão — fiel ao rascunho); (5) configurado sem status → "Aguardando a primeira sincronização" + botão. Pós-clique: `pending` ("Sincronizando…") → sucesso cena 2 / falha cena 1 com o motivo retornado. Tempo relativo com `Intl.RelativeTimeFormat('pt-BR')` local (o painel é client; `formatRelativePostDate` é server-only — `posts.ts` abre com `import 'server-only'`).
|
||||
Rejeitadas: B mistura duas responsabilidades no picker (o picker é lista de exclusão; o status é estado operacional — o rascunho os desenha como caixas separadas); C expõe JSON cru (anti-produto).
|
||||
→ **UI:** Impeccable C — encaixe na linguagem do admin (caixas zinc/red do draft, `text-sm`, bordas `rounded-lg`), shape → craft → critique → polish no navegador (admin, 3 cenas + estados).
|
||||
|
||||
6. **`loadInstagramFeed` ganha `signal?: AbortSignal` (repasse ao `fetchImpl`).**
|
||||
O hook precisa do deadline; o `LoadInstagramFeedArgs` aceita `init` no fetchImpl — o signal flui sem mudar a assinatura do fetch. Unit-testado.
|
||||
|
||||
7. **Stub IG ganha estado `invalid-token` (400 com body `{ error: { type: 'OAuthException', ... } }`).**
|
||||
A cena 1 do draft é literalmente esse erro; o e2e do painel precisa exercitá-lo (o estado `fail` atual responde 500 genérico). O painel de falha com a copy do token é o aceite central do item.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`InstagramApiError` + `parseInstagramErrorBody`** (`src/utilities/socialFeed/instagramFeed.ts`, estender): parseia body de erro em resposta não-2xx, lança `InstagramApiError` (`status` + `apiMessage`); `loadInstagramFeed` aceita `signal` opcional repassado ao `fetchImpl` (fetch do usuário, media e refresh).
|
||||
- **`src/utilities/socialFeed/instagramSync.ts`** (novo, `server-only`, dentro do subfolder `socialFeed/` — a pin de top-level da `codebaseConventions.unit.spec.ts` não se aplica a subfolders, o comentário já cobre o domínio):
|
||||
- `InstagramSyncStatus` (`{ lastSyncAt?: string; postCount?: number; error?: string; errorAt?: string }`).
|
||||
- `describeInstagramError(cause: unknown): string` — mapeamentos da Decisão 2 (puro, exportado p/ unit).
|
||||
- `successInstagramSyncStatus(postCount)` / `failedInstagramSyncStatus(error)` — builders puros.
|
||||
- `persistInstagramSyncStatus(payload, status)` — SQL cru UPDATE+INSERT (mesmo padrão/precondição do snapshot; rodado DENTRO de `unstable_cache` no render — sem hooks).
|
||||
- `syncInstagramFeed(payload, { signal? }): Promise<{ ok: true; status } | { ok: false; status }>` — lê a global, retorna "não configurado" sem tocar na API quando `enabled === false`/`instagramEnabled === false`/sem token/ID; sucesso → `persistInstagramSnapshot` + persist token renovado (reuso dos privados? **não** — os `persist*` atuais são module-private do `instagramFeed.ts`; exportar `persistInstagramSnapshot`/`persistInstagramAccessToken` do `instagramFeed.ts` para o sync module — o dono do persist é o feed, o sync é o orquestrador) + status de sucesso; falha → status de erro via `describeInstagramError`; nunca lança (status é o resultado).
|
||||
- **`getInstagramFeed`** (`instagramFeed.ts`, estender): no try (sucesso) persiste também o status de sucesso; no catch persiste o status de erro ANTES do fallback snapshot (a ordem não importa para o board; importa para o painel). Nada mais muda no caminho público.
|
||||
- **`SocialFeedSettings`** (`src/globals/SocialFeedSettings.ts`, estender): campo `instagramSyncStatus` (json, `admin.hidden: true` — o painel renderiza, o dado não aparece cru); campo `ui` `instagramSyncStatusPanel` (após `instagramEnabled`, antes do token — status no topo do bloco IG, fiel ao rascunho; `admin.components.Field` → painel); `afterChange` = `[revalidateFeed, syncInstagramAfterChange]` (gate da Decisão 4, try/catch total). Copy do campo token/`admin.description`: ajuste de 1 linha para apontar o painel de status ("veja o estado da sincronização abaixo/ao lado") — opcional, não bloquear.
|
||||
- **`src/app/(frontend)/api/social-feed/sync/route.ts`** (novo): `POST`; `isSameOriginRequest` (401 se cross-origin); `payload.auth({ headers })` exigindo `user.collection === 'users'` (401 sem sessão admin); lê a global, IG desconfigurado → `400 { ok: false, error }`; `syncInstagramFeed` com `AbortSignal.timeout(10_000)`; `revalidateTag(REVALIDATE_SOCIAL_FEED_TAG)`; `200 { ok, status }`. Erro interno → `500` (sem stack).
|
||||
- **`src/components/admin/InstagramSyncStatusPanel.tsx`** (novo, `'use client'`): estados da Decisão 5; fetch para a rota no clique; `aria-busy` no pending; sem escrita no form (status é persistido no servidor).
|
||||
- **`tests/e2e/instagram-stub.mjs`** (estender): estado `'invalid-token'` → `400 { error: { type: 'OAuthException', message: 'Error validating access token' } }` em `/user` e `/media` (e refresh → 400); `POST /__stub/state` aceita o novo estado.
|
||||
- **Migration:** `add_instagram_sync_status` (jsonb nullable). **Types/importmap:** `pnpm generate:types` + `pnpm generate:importmap` (com envs `S3_*` dummy setadas — OPS69). **Access/Consent:** sem consent (estado operacional interno, não PII); global segue admin-only; a rota exige sessão admin. **UI:** Impeccable C (encaixe no admin; shape → craft → critique → polish).
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- O painel mostra **estado operacional**, não métrica: contagem de posts sincronizados (cru, da API), tempo desde a última boa, motivo da última falha. Nada de histórico, alertas ou engajamento (corte da intenção). Forma: caixas do rascunho (verde/zinc neutro sucesso, red falha, zinc neutro não-configurado) — contraste e semântica visuais primeiro, sem cor inventada.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Schema + servidor** (~metade do appetite): migration + `generate:types`; `InstagramApiError` + parse do body + `signal` no `loadInstagramFeed`; `instagramSync.ts` (status builders + `describeInstagramError` + persist SQL + `syncInstagramFeed`); persistência de status no `getInstagramFeed`; rota `/api/social-feed/sync`. Unit tests: `describeInstagramError` (OAuthException→token, invalid-user→ID, 5xx→indisponível, TypeError→rede, unknown→inesperado), `loadInstagramFeed` com fetchImpl 400+body de erro lançando `InstagramApiError`, repasse de `signal`, builders de status, gate `instagramCredentialsChanged` (doc/previousDoc: token mudou, ID mudou, enabled false→true, exclusão não dispara). Gates parciais: tsc + `pnpm test`.
|
||||
2. **Painel + hook + runbook** (restante): `InstagramSyncStatusPanel` + campo `ui` + hook `afterChange` com sync awaited; stub `invalid-token`; runbook `docs/ops/instagram-feed-token-runbook.md` (o que o painel mostra; validação do token via refresh/me com curl; geração pelo Instagram Login vs Facebook Login; a provável causa da ocorrência atual e como o painel confirma em 1 clique pós-deploy); alinhar a copy do vazio do picker com o rascunho ("Nenhum post sincronizado. Após uma sincronização bem-sucedida, os últimos posts aparecem aqui…"); `generate:importmap` + AGENTS.md (1 linha: painel de status + rota de sync na seção do `social-feed`). Polish no navegador (admin, 3 cenas).
|
||||
3. **e2e + gates finais**: admin smoke — seed com token+ID → painel "Aguardando a primeira sincronização" → clique → "Sincronizado" com contagem + assert do `instagramSyncStatus` via REST; stub `invalid-token` → falha com copy do token + botão; stub `fail` → falha 500; reset do helper `resetSocialFeedSettings` ganha `instagramSyncStatus: null` (determinismo do serial); smoke do picker existente deve continuar verde (o hook passa a sincronizar no seed com credenciais — stub ok, determinístico). Gates: `pnpm gate:fast`, `pnpm test`, `pnpm build` local, e2e no CI; changelog `docs/changelog/2026-08-19-s11.md` + `pnpm changelog:build`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não sincronizar no salvar de exclusões/YouTube (gate por credencial — Decisão 4).
|
||||
- Não fazer histórico/alerta/dashboard de saúde; não tocar no board público nem nos cards.
|
||||
- Não mexer no fluxo de refresh de token existente (o `loadInstagramFeed` já refresca e persiste; o sync só reporta).
|
||||
- Não paginar o feed, não buscar stories/métricas.
|
||||
- Não backfill de status retroativo (a primeira sincronização após o deploy escreve o estado).
|
||||
- Não acesso ao DB de prod para o diagnóstico (fora do alcance do worktree); o painel + runbook cobrem o aceite.
|
||||
- Não mudar a pin de `codebaseConventions` (o módulo novo fica no subfolder `socialFeed/` já documentado).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Rota auth no admin:** `payload.auth({ headers })` com cookie `payload-token` — precedente `campaignAuth`; sem sessão → 401 silencioso (o painel mostra o erro como falha de sincronização? **não** — o painel trata 401 como "sessão expirada": recarrega a página para o admin re-autenticar; copy honesta). e2e cobre.
|
||||
- **Hook sync com latência no save:** cap `AbortSignal.timeout(10_000)`; sync nunca lança (try/catch); save de credenciais é raro (assessoria configura uma vez).
|
||||
- **`revalidateTag` dentro de cache:** a persistência de status no render usa SQL cru (sem hooks) — mesma restrição documentada do snapshot (Next 15.4 lança `revalidateTag` dentro de `unstable_cache`); a rota/hook revalidam fora do cache (permitido).
|
||||
- **e2e serial com hook novo:** todo seed de credenciais via REST dispara o sync (stub determinístico, ok/fail por estado); resets incluem `instagramSyncStatus: null`; orçamento de polling do serial mantido.
|
||||
- **Formato do body de erro da Graph API:** documentado (`{ error: { message, type } }`); parse defensivo — body inesperado → mensagem genérica por status (nunca quebra o fluxo).
|
||||
- **Painel com estado stale após save:** o sync awaited no hook resolve (status escrito antes do reload); botão cobre o resto; "há X min" é honesto (última boa real).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: painel de status na global (sincronizado/falha com motivo+não configurado), botão "Tentar sincronizar de novo", home pública inalterada e fail-closed, runbook curto com validação/geração de token, token nunca fora do admin
|
||||
- [ ] Invariantes AGENTS/engineering-standards: migration não destrutiva; sem consent novo; identificadores em inglês; copy/admin em pt; access admin-only preservado; rota com auth de sessão + CSRF same-origin; tag `social-feed` já cobre revalidação; SQL cru fora de cache
|
||||
- [ ] Testes de domínio previstos: unit (mapeamentos de erro, `InstagramApiError`, signal, builders, gate do hook) + e2e (painel ok/falha token/falha 500, status persistido via REST, serial determinístico)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
docs/plans/feed-instagram-nao-aparece-home-ui-draft-admin-ok.png
Normal file
BIN
docs/plans/feed-instagram-nao-aparece-home-ui-draft-admin-ok.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
315
docs/plans/feed-instagram-nao-aparece-home-ui-draft.html
Normal file
315
docs/plans/feed-instagram-nao-aparece-home-ui-draft.html
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<title>UI draft — Feed do Instagram configurado não aparece na home</title>
|
||||
</head>
|
||||
<body class="bg-zinc-950 text-zinc-100 antialiased">
|
||||
<main class="mx-auto max-w-6xl p-8">
|
||||
<p class="text-sm text-zinc-400">
|
||||
Assessoria de comunicação configura o feed do Instagram no admin (global "Feed de redes
|
||||
sociais") e precisa ver, no mesmo lugar, por que os posts não aparecem na home pública.
|
||||
</p>
|
||||
|
||||
<!-- Cena: falha na sincronização (o caso real em produção) -->
|
||||
<section data-shot="admin-falha-token" class="mt-10">
|
||||
<p class="mb-3 text-sm font-medium text-zinc-300">Cena 1 — token recusado pela Graph API</p>
|
||||
<div class="rounded-xl border border-zinc-700 bg-zinc-100 text-zinc-900">
|
||||
<div class="flex items-center justify-between border-b border-zinc-300 px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded bg-zinc-900 px-2 py-0.5 text-xs font-semibold text-zinc-100">
|
||||
Configurações
|
||||
</span>
|
||||
<span class="text-sm text-zinc-600">/ Feed de redes sociais</span>
|
||||
</div>
|
||||
<span class="rounded border border-zinc-400 bg-white px-3 py-1 text-xs text-zinc-600">
|
||||
Draft · Salvar · Cancelar
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-5">
|
||||
<h1 class="text-lg font-bold">Feed de redes sociais</h1>
|
||||
<p class="mt-1 max-w-2xl text-sm text-zinc-600">
|
||||
Fonte automática do board "Acompanhe de perto" da home de campanha. Sem chave
|
||||
configurada, os cards da plataforma não aparecem.
|
||||
</p>
|
||||
|
||||
<div class="mt-5 rounded-lg border border-red-300 bg-red-50 p-4">
|
||||
<p class="text-sm font-bold text-red-800">Falha na última sincronização</p>
|
||||
<p class="mt-1 max-w-2xl text-sm text-red-700">
|
||||
A Graph API recusou o token configurado (token inválido/expirado ou emitido via
|
||||
Facebook Login — a Graph API só aceita tokens gerados pelo Instagram Login).
|
||||
Enquanto isso, nenhum post do Instagram aparece na home.
|
||||
</p>
|
||||
<p class="mt-1 max-w-2xl text-sm text-red-700">
|
||||
<strong>Correção:</strong> gere um token de longa duração pelo Instagram Login
|
||||
(conta Business/Creator vinculada à página) e atualize o campo abaixo.
|
||||
</p>
|
||||
<button
|
||||
class="mt-3 rounded-md bg-red-600 px-3 py-1.5 text-sm font-semibold text-white"
|
||||
>
|
||||
Tentar sincronizar de novo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid max-w-2xl grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-zinc-700"
|
||||
>Token de acesso (Instagram Graph API)</label
|
||||
>
|
||||
<div
|
||||
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
|
||||
>
|
||||
IGAA...abc123
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-zinc-700"
|
||||
>ID do usuário (conta Business/Creator)</label
|
||||
>
|
||||
<div
|
||||
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
|
||||
>
|
||||
17841400000000000
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-zinc-700">Máximo de posts</label>
|
||||
<div
|
||||
class="mt-1 w-24 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 max-w-2xl rounded-lg border border-zinc-300 bg-white p-4">
|
||||
<p class="text-sm font-bold text-zinc-800">Instagram — posts recentes</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
Nenhum post sincronizado. Após uma sincronização bem-sucedida, os últimos posts
|
||||
aparecem aqui para marcar os que não devem ir para a home.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-2 border-t border-zinc-300 pt-4">
|
||||
<button
|
||||
class="rounded-md border border-zinc-400 bg-white px-4 py-1.5 text-sm font-semibold text-zinc-700"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button class="rounded-md bg-red-600 px-4 py-1.5 text-sm font-semibold text-white">
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cena: sincronizado, picker com posts -->
|
||||
<section data-shot="admin-ok" class="mt-12">
|
||||
<p class="mb-3 text-sm font-medium text-zinc-300">Cena 2 — sincronizado com sucesso</p>
|
||||
<div class="rounded-xl border border-zinc-700 bg-zinc-100 text-zinc-900">
|
||||
<div class="flex items-center justify-between border-b border-zinc-300 px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded bg-zinc-900 px-2 py-0.5 text-xs font-semibold text-zinc-100">
|
||||
Configurações
|
||||
</span>
|
||||
<span class="text-sm text-zinc-600">/ Feed de redes sociais</span>
|
||||
</div>
|
||||
<span class="rounded border border-zinc-400 bg-white px-3 py-1 text-xs text-zinc-600">
|
||||
Draft · Salvar · Cancelar
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-5">
|
||||
<h1 class="text-lg font-bold">Feed de redes sociais</h1>
|
||||
|
||||
<div class="mt-5 rounded-lg border border-zinc-300 bg-white p-4">
|
||||
<p class="text-sm font-bold text-zinc-800">Sincronizado · há 2 min · 4 posts</p>
|
||||
<p class="mt-1 max-w-2xl text-sm text-zinc-500">
|
||||
Próxima atualização automática em ~5 min. Os 4 posts mais recentes estão no board
|
||||
"Acompanhe de perto" da home.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid max-w-2xl grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-zinc-700"
|
||||
>Token de acesso (Instagram Graph API)</label
|
||||
>
|
||||
<div
|
||||
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
|
||||
>
|
||||
IGAA...abc123
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-zinc-700"
|
||||
>ID do usuário (conta Business/Creator)</label
|
||||
>
|
||||
<div
|
||||
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
|
||||
>
|
||||
17841400000000000
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-zinc-700">Máximo de posts</label>
|
||||
<div
|
||||
class="mt-1 w-24 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 max-w-2xl rounded-lg border border-zinc-300 bg-white p-4">
|
||||
<p class="text-sm font-bold text-zinc-800">Instagram — posts recentes</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
Marque "Não exibir" para tirar um post do board (ex.: posts de grade). O board pula
|
||||
para o próximo elegível.
|
||||
</p>
|
||||
<ul class="mt-3 divide-y divide-zinc-200">
|
||||
<li class="flex items-center gap-3 py-2.5">
|
||||
<span class="h-10 w-10 shrink-0 rounded bg-zinc-300"></span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-medium text-zinc-800">
|
||||
Caravana pelo interior: chegada em Vitória da Conquista
|
||||
</span>
|
||||
<span class="block text-xs text-zinc-500"
|
||||
>há 2 h · ID 17841400000000000_001</span
|
||||
>
|
||||
</span>
|
||||
<span class="rounded bg-zinc-200 px-2 py-1 text-xs font-semibold text-zinc-700">
|
||||
Exibir
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-3 py-2.5">
|
||||
<span class="h-10 w-10 shrink-0 rounded bg-zinc-300"></span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-medium text-zinc-800">
|
||||
Foto do plenário: votação da pauta da saúde
|
||||
</span>
|
||||
<span class="block text-xs text-zinc-500"
|
||||
>ontem · ID 17841400000000000_002</span
|
||||
>
|
||||
</span>
|
||||
<span class="rounded bg-zinc-200 px-2 py-1 text-xs font-semibold text-zinc-700">
|
||||
Exibir
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-3 py-2.5">
|
||||
<span class="h-10 w-10 shrink-0 rounded bg-zinc-300"></span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-medium text-zinc-800">
|
||||
Post de grade (sem legenda) — para marcar "Não exibir"
|
||||
</span>
|
||||
<span class="block text-xs text-zinc-500"
|
||||
>ontem · ID 17841400000000000_003</span
|
||||
>
|
||||
</span>
|
||||
<span class="rounded bg-red-100 px-2 py-1 text-xs font-semibold text-red-700">
|
||||
Não exibir
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-3 py-2.5">
|
||||
<span class="h-10 w-10 shrink-0 rounded bg-zinc-300"></span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-medium text-zinc-800">
|
||||
Agenda da semana: agendas abertas em Salvador
|
||||
</span>
|
||||
<span class="block text-xs text-zinc-500"
|
||||
>há 2 dias · ID 17841400000000000_004</span
|
||||
>
|
||||
</span>
|
||||
<span class="rounded bg-zinc-200 px-2 py-1 text-xs font-semibold text-zinc-700">
|
||||
Exibir
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-2 border-t border-zinc-300 pt-4">
|
||||
<button
|
||||
class="rounded-md border border-zinc-400 bg-white px-4 py-1.5 text-sm font-semibold text-zinc-700"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button class="rounded-md bg-red-600 px-4 py-1.5 text-sm font-semibold text-white">
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cena: ainda não configurado (primeiro acesso) -->
|
||||
<section data-shot="admin-nao-configurado" class="mt-12">
|
||||
<p class="mb-3 text-sm font-medium text-zinc-300">
|
||||
Cena 3 — sem token nem ID (primeiro acesso)
|
||||
</p>
|
||||
<div class="rounded-xl border border-zinc-700 bg-zinc-100 text-zinc-900">
|
||||
<div class="flex items-center justify-between border-b border-zinc-300 px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded bg-zinc-900 px-2 py-0.5 text-xs font-semibold text-zinc-100">
|
||||
Configurações
|
||||
</span>
|
||||
<span class="text-sm text-zinc-600">/ Feed de redes sociais</span>
|
||||
</div>
|
||||
<span class="rounded border border-zinc-400 bg-white px-3 py-1 text-xs text-zinc-600">
|
||||
Draft · Salvar · Cancelar
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-5">
|
||||
<h1 class="text-lg font-bold">Feed de redes sociais</h1>
|
||||
|
||||
<div class="mt-5 max-w-2xl rounded-lg border border-zinc-300 bg-white p-4">
|
||||
<p class="text-sm font-bold text-zinc-800">Instagram ainda não configurado</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
Informe o token de acesso e o ID da conta para publicar posts do Instagram na home.
|
||||
Sem configuração, o board funciona com artigos e YouTube.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid max-w-2xl grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-zinc-700"
|
||||
>Token de acesso (Instagram Graph API)</label
|
||||
>
|
||||
<div
|
||||
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-400"
|
||||
>
|
||||
(vazio)
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-zinc-700"
|
||||
>ID do usuário (conta Business/Creator)</label
|
||||
>
|
||||
<div
|
||||
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-400"
|
||||
>
|
||||
(vazio)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-2 border-t border-zinc-300 pt-4">
|
||||
<button
|
||||
class="rounded-md border border-zinc-400 bg-white px-4 py-1.5 text-sm font-semibold text-zinc-700"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button class="rounded-md bg-red-600 px-4 py-1.5 text-sm font-semibold text-white">
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
93
docs/plans/feed-instagram-nao-aparece-home.md
Normal file
93
docs/plans/feed-instagram-nao-aparece-home.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Feed do Instagram configurado não aparece na home pública
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #115
|
||||
Priority: P1
|
||||
Model: cursor-grok-4.5-medium
|
||||
Impeccable: C — encaixe na global do admin (painel de status de sincronização)
|
||||
Rascunho UI: docs/plans/feed-instagram-nao-aparece-home-ui-draft.html + PNGs embutidos abaixo
|
||||
Appetite: ~1 dia eng + diagnóstico da causa em produção
|
||||
Responsável: —
|
||||
|
||||
## Intenção
|
||||
|
||||
A assessoria preencheu o token e o ID do Instagram na configuração do feed (global "Feed de redes sociais", entregue na S3) e os posts **não aparecem** na seção "Acompanhe de perto" da home pública — em produção, o board mostra YouTube e artigos, nenhum card do Instagram e nenhum link "Seguir no Instagram →" (verificado em 2026-08-19 em jorgesolla1313.com.br). O desenho da S3 é fail-closed **silencioso**: qualquer falha da API (token recusado, ID inválido, rede) degrada para "sem cards" sem deixar rastro — o único indício hoje é o picker do admin vazio ("Nenhum post recente"), que não diz **por quê**. A assessoria fica num ciclo cego de tentativa-e-erro de credenciais.
|
||||
|
||||
## Persona e fluxo
|
||||
|
||||
- **Persona / contexto:** assessoria de comunicação, configurando a global no admin do Payload (grupo "Configurações"), sem acesso ao código nem a logs.
|
||||
- **Job principal:** saber, no mesmo lugar onde configurou o feed, se a sincronização do Instagram funcionou — e, se não, **por quê** e **o que fazer**.
|
||||
- **Fluxo desejado:**
|
||||
1. A assessoria preenche token + ID e salva a global.
|
||||
2. O admin mostra o estado da sincronização: "Sincronizado · há X min · N posts" (e os posts aparecem no picker, prontos para exclusão), ou "Falha na última sincronização" com o motivo e a correção esperada (ex.: token inválido/expirado ou emitido via Facebook Login — gerar pelo Instagram Login).
|
||||
3. Com sincronização OK, os posts aparecem na home pública em minutos (cache revalidado no salvar).
|
||||
4. A causa da ocorrência atual fica diagnosticada e documentada num runbook curto para a assessoria não repetir o ciclo.
|
||||
- **Anti-goals de produto:** nada de wizard OAuth "conectar com Facebook" dentro do admin; nada de dashboard de saúde de feeds; nada de embeds/scripts do Instagram no site público; nada de métricas de engajamento.
|
||||
|
||||
### Esboço de fluxo (B/C/D)
|
||||
|
||||
```text
|
||||
[admin: global Feed de redes sociais]
|
||||
→ salvar token+ID → [status: Sincronizado · N posts] → [home pública: cards IG em minutos]
|
||||
→ [status: Falha + motivo + correção] → [assessoria corrige credencial e salva] → [status OK]
|
||||
```
|
||||
|
||||
### Rascunho UI (B/C/D)
|
||||
|
||||
Painel de status de sincronização por plataforma na global do admin, embutido junto aos campos do Instagram (e reaproveitável para YouTube depois, se a paridade for pedida).
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- Com credenciais válidas (token gerado pelo Instagram Login + ID da conta Business/Creator), os posts do Instagram aparecem na seção "Acompanhe de perto" da home em até alguns minutos após salvar a configuração — badge "Instagram", legenda, data relativa, clique abre na plataforma. Sem ação manual além da config.
|
||||
- Quando a Graph API recusa as credenciais ou a sincronização falha, o admin mostra o motivo em linguagem de produto e a correção esperada (ex.: "token inválido/expirado ou emitido via Facebook Login — gere pelo Instagram Login"). A assessoria deixa de ficar cega.
|
||||
- A home pública continua fail-closed: API fora ou credenciais inválidas nunca quebram a página nem vazam erro — o board segue com artigos + YouTube (comportamento atual preservado).
|
||||
- A causa da ocorrência em produção fica diagnosticada e registrada em runbook curto (passos para gerar/validar o token), para o ciclo de tentativa-e-erro não se repetir.
|
||||
- Guardrails: token nunca exposto fora do admin (read da global admin-only, como hoje); zero embeds/scripts do Instagram no site público (LCP); identificadores em inglês, copy em pt.
|
||||
|
||||
## Dados (intenção)
|
||||
|
||||
- **Vou apresentar dados?** Não — o painel mostra estado operacional (última sincronização, nº de posts, motivo de erro), não métricas. Nenhuma decisão de análise desbloqueada.
|
||||
- **Forma:** adiada ao plano de implementação — sem contadores, sem engajamento.
|
||||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- **Áreas prováveis:** `src/utilities/socialFeed/instagramFeed.ts` (onde o erro é engolido no fail-closed — fonte do "porquê"), `src/globals/SocialFeedSettings.ts` (superfície do status), `src/components/admin/InstagramPostExclusionPicker.tsx` (pickers/superfície admin já existente, mesma família de componente), `src/utilities/revalidateRequest.ts` (tag `social-feed` — o salvar já revalida; não espera-se tocar).
|
||||
- **Precedente a olhar:** plano/impl da S3 (`docs/plans/secao-conteudos-home-instagram*.md`), irmão `youtubeFeed.ts`, e2e do describe serial da seção (stub IG) e smoke do picker no admin.
|
||||
- **Risco de acoplamento:** o fetch roda dentro de `unstable_cache` durante render — qualquer persistência de status precisa respeitar a restrição de não chamar `revalidateTag` dentro de cache (precedente documentado da S2/S3). A home pública é ISR; revalidação via tag já cobre o salvar do admin.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **S3** (pai, `done`/`in-prod` — imutável; este é item **sucessor**).
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Setup OAuth / "conectar com Facebook" dentro do admin (o fluxo Meta fica com a assessoria; runbook documenta).
|
||||
- Redesenho do board público ou dos cards (superfície aprovada na S3 não muda).
|
||||
- Paridade de status para o YouTube (pode virar followup se a plataforma pedir; o desenho do painel é por plataforma).
|
||||
- Backfill retroativo de snapshot.
|
||||
|
||||
## Rabbit holes de produto
|
||||
|
||||
- **Dashboard de saúde de feeds.** Se alguém "só completar" a ideia de status, vira painel de monitoramento com histórico e alertas. **Corte neste item:** um painel de status por plataforma na própria global, só estado atual + motivo; sem histórico, sem alertas.
|
||||
- **Wizard OAuth no admin.** Completar o desejo com o fluxo completo da Meta explode o escopo (app Meta, permissões, redirects). **Corte neste item:** credenciais continuam preenchidas manualmente; o valor é diagnóstico e correção, não aquisição de token.
|
||||
- **Investigação infinita da causa em produção.** A causa pode ser credencial da assessoria (troca fora do nosso controle). **Corte neste item:** diagnóstico com prazo — o que não for descoberto num período curto vira instrução de validação no runbook, não bloqueio do item.
|
||||
|
||||
## Questões em aberto (produto)
|
||||
|
||||
- **Onde a assessoria vê o "porquê"?** **Opções:** A) painel de status na própria global do feed (ao lado dos campos do Instagram) — o lugar onde a assessoria já está quando configura | B) só logs/exceção no servidor (exige acesso técnico — não resolve para a persona) | C) nada além do picker vazio (status quo). **Recomendação:** A — o fracasso acontece onde a configuração acontece; o sinal pertence a esse mesmo lugar. _(assumido — validar com produto)_
|
||||
- **O item cobre a ocorrência atual em produção?** **Opções:** A) diagnóstico da causa atual + painel de status (a correção de credencial fica com a assessoria, documentada) | B) só painel de status; a causa atual fica para followup | C) só runbook de credenciais. **Recomendação:** A — sem o diagnóstico, não sabemos se há também um defeito de código por trás do sintoma; sem o painel, o sintoma se repete cegamente. _(assumido — validar com produto)_
|
||||
- **Paridade de status para YouTube agora?** **Opções:** A) só Instagram nesta fatia (o YouTube funciona em produção; o painel nasce desenhado por plataforma) | B) YouTube e Instagram juntos. **Recomendação:** A — fatia mínima útil; paridade vira followup barato se pedida. _(assumido — validar com produto)_
|
||||
|
||||
## Referências
|
||||
|
||||
- Issue pai S3 (#20, `done`/`in-prod`) + `docs/plans/secao-conteudos-home-instagram.md` e `-impl.md`
|
||||
- Rascunho UI (gate): `docs/plans/feed-instagram-nao-aparece-home-ui-draft.html` + PNGs acima
|
||||
- Primeiras pistas para o executor: `src/utilities/socialFeed/instagramFeed.ts` (caminho de falha silenciosa), `src/globals/SocialFeedSettings.ts`, `src/components/admin/InstagramPostExclusionPicker.tsx`, `tests/e2e/frontend.e2e.spec.ts` (describe serial da seção)
|
||||
- `docs/AGENT-OPS.md` (serializes: nenhum recurso compartilhado — `serializes: []`)
|
||||
130
docs/plans/ops71-ci-github-actions-tracker-forgejo-impl.md
Normal file
130
docs/plans/ops71-ci-github-actions-tracker-forgejo-impl.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# Impl: CI/PR do Forgejo Actions de volta para o GitHub Actions — tracker de Issues permanece no Forgejo
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #97
|
||||
Intenção: docs/plans/ops71-ci-github-actions-tracker-forgejo.md
|
||||
Appetite restante: herdado (~2–3 dias eng; este impl é o item inteiro — OPS72 roda depois com `depends`)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** código/PR/CI no GitHub Actions (mesma cascata de verificações,
|
||||
job único `checks`), tracker de Issues no Forgejo por API, deploy **manual**
|
||||
(`workflow_dispatch`) com dois jobs (`verify` hosted full → `deploy`
|
||||
self-hosted no homeserver), rollup `CI (PR) / checks` como required check do
|
||||
GitHub, workstation livre da suíte.
|
||||
- **O que NÃO negociar:** tracker (Issues/labels/claims) permanece no Forgejo;
|
||||
estrutura de job único (OPS62) preservada — nada de matrix; deploy NÃO volta
|
||||
a ser automático; sem migrar Issues; sem segundo deploy (nada de Vercel);
|
||||
nunca merge com CI vermelho; `pnpm push` continua sendo o caminho canônico.
|
||||
- **O que reavaliar:** a intenção sugere "auto-merge nativo do GitHub ou
|
||||
safety net equivalente" e "`gh workflow run` (ou API GitHub)". Reavaliado:
|
||||
`gh` **não existe** em nenhuma máquina (OPS50) e a filosofia do repo é
|
||||
plain-Node stdlib — os helpers novos são scripts Node zero-dep sobre a REST
|
||||
do GitHub, no mesmo molde de `forgejo-api.mjs`. O auto-merge é o **nativo do
|
||||
GitHub** (GraphQL `enablePullRequestAutoMerge`, rebase) — o servidor espera o
|
||||
required check; o OPS64 pin (varrer statuses individuais) era defesa contra o
|
||||
rollup mentiroso do runner Forgejo, que **não existe** no GitHub (o check-run
|
||||
`CI (PR) / checks` de um job único é o veredito honesto, e o auto-merge nativo
|
||||
só mergeia com required checks verdes — garantia do servidor).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
W[worktree local] -->|pnpm push -u origin HEAD| GH[github.com/fsolla/teqo]
|
||||
GH -->|PR Ready base main Closes #N| CI["ci-pr.yml — job único checks (mesma cascata OPS62)"]
|
||||
CI -->|required check CI (PR) / checks| AM["agent-pr-ready-automerge.yml — auto-merge nativo (GraphQL, rebase)"]
|
||||
AM -->|merge| MAIN[main no GitHub]
|
||||
MAIN -->|PR closed merged| FLIP["issue-done / plan-issue-ready — flip no Forgejo via FORGEJO_API_TOKEN"]
|
||||
MAIN -->|dispatch manual| V[deploy.yml verify hosted full suite]
|
||||
V -->|needs| D["deploy runs-on self-hosted homeserver — deploy-homeserver.sh local"]
|
||||
D --> P[(teqo-1313 produção)]
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A — origin vira GitHub; Forgejo como remote secundário congelado; TEQO_REPO_URL → GitHub (repo público, sem auth).** `origin/main` é o base dos gates locais (ci-scope/gate-ci) — precisa apontar para o repo que recebe os merges, senão o diff local compara contra um main defasado. O repo GitHub é público (verificado: `private: false`) → o homeserver clona sem credencial.
|
||||
- **B — auto-merge via GraphQL nativo.** GitHub espera o required check no servidor; nenhum poll; sem semáforo portado. Draft `cursor/*` → Ready via PATCH; draft não-`cursor/*` = veto (OPS57, preservado).
|
||||
- **C — deploy.yml com `verify` (hosted, suíte full sem skips) → `deploy` (needs, self-hosted homeserver) executando `deploy-homeserver.sh <sha>` localmente (sem SSH).**
|
||||
- **D — `forgejo-issue-transition.mjs` / `agent-promote-related-on-merge.mjs` ganham `PR_BODY` env** (mecanismo que `agent-promote-related` já usa): o body do PR vem do evento GitHub; o script flipa no Forgejo por API sem ler o PR do Forgejo (PR lá não existe mais).
|
||||
- **E — `configure-branch-protection.mjs` reescrito para a REST do GitHub** com lib pura `github-branch-protection.mjs` (regra desejada + drift), no mesmo molde de `branch-protection.mjs`; `enforce_admins: true` preserva o invariante "nunca merge com CI vermelho" inclusive para admin (o rule-based do Forgejo valia para todos).
|
||||
|
||||
**Recomendação:** A+B+C+D+E — porque cada peça reusa o molde existente (plain Node stdlib, libs puras unit-testadas, drift idempotente) e o aceite da intenção exige exatamente esse desenho; nada disso exige binário novo (`gh`) nem semáforo reimplementado.
|
||||
|
||||
**Rejeitadas:**
|
||||
|
||||
- **Instalar `gh` e usar `gh pr create/merge/run`** — binário novo em todas as máquinas + workflows, contra a convenção zero-dep do repo; a REST cobre tudo.
|
||||
- **Poll-and-merge portado (`waitForChecks` do Forgejo) como mecanismo único** — reimplementa o semáforo que a intenção manda cortar; o OPS64 pin era contra o bug do rollup do Forgejo, inexistente no GitHub com job único + auto-merge nativo.
|
||||
- **Mirror ativo do Forgejo (`pnpm push` para dois remotes / sync pós-merge)** — nada lê o git do Forgejo em runtime (deploy usa TEQO_REPO_URL=GitHub; flips são API); o repo Forgejo congela em main no ponto do cutover (tracker intacto por API, anti-goal "nunca fecha o tracker" preservado).
|
||||
- **Deletar os arquivos do Forgejo nesta entrega** — decisão do gate (2026-08-19): o sistema GitHub Actions nasce PRIMEIRO e é validado ao vivo (PR real com CI verde → auto-merge → flips → deploy manual); a remoção de `.forgejo/workflows/` + scripts mortos é **entrega sucessora**, só depois de tudo funcionando. O repo Forgejo congelado no main pré-cutover mantém os workflows como rollback (reversível: religar runner + PRs lá); apagar na primeira entrega queimaria a via de volta.
|
||||
- **Manter `ci-classify-production.mjs` como check informativo no dispatch** — o hosted não alcança o homeserver (sem SSH) e o `verify` roda a suíte full sem skips; a classificação não muda nada → removido só na entrega de remoção.
|
||||
- **`TEQO_REPO_URL` → Forgejo local com mirror** — dependência extra de sync; o repo GitHub é público, clone direto sem auth.
|
||||
- **Fork-PR no CI** — repo público: fork PR rodaria a suíte full em minutes hosted de graça; `if:` same-repo no job `checks` (mesma guarda de RCE dos outros workflows).
|
||||
|
||||
## Cutover em duas fases (gate 2026-08-19)
|
||||
|
||||
O GitHub é **só** o host do Actions; o Forgejo permanece o **tracker principal** (Issues, labels, claims, flips — tudo via API, intacto). A remoção dos arquivos do Forgejo só acontece após validação ao vivo do fluxo GitHub:
|
||||
|
||||
1. **Fase 1 (esta entrega — OPS71):** cria todo o sistema GitHub Actions (workflows, scripts, libs, docs). **Nada do Forgejo é apagado** — `.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs` + specs permanecem no repo (dormentes: o repo Forgejo não recebe mais push).
|
||||
2. **Cutover manual (humano, nesta ordem — (a)–(c) ANTES do push do OPS71, senão o PR mergearia sem required check):** (a) remotes locais (`origin` → GitHub, `forgejo` secundário); (b) PAT `GITHUB_TOKEN` local + secrets no GitHub (`FORGEJO_API_TOKEN`, `CURSOR_API_KEY`) + `allow_auto_merge: true`; (c) `pnpm configure:branch-protection` (GitHub); (d) **desligar o runner do Forgejo** (reversível — é o que para o schedule do `ci.yml` antigo, que não tem mais sentido e disputaria a workstation; pode ser logo após o primeiro PR GitHub validado); (e) runner self-hosted no homeserver (deploy; não bloqueia PRs — pode ficar para depois); (f) push do OPS71 → PR no GitHub → CI verde → auto-merge → flip da #97 (valida os flips no merge do próprio OPS71) → deploy manual dispatch quando o runner do homeserver estiver pronto.
|
||||
3. **Fase 2 (entrega sucessora, registrada no fechamento desta Issue):** remove `.forgejo/workflows/*`, scripts mortos e docs residuais do fluxo antigo. Só depois do fluxo GitHub comprovado de ponta a ponta.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`.github/workflows/ci-pr.yml`** (novo): port do `.forgejo/workflows/ci-pr.yml` — job único `checks`, mesmos steps/guards/services (`postgres-int`/`postgres-build` por nome na rede do job), skips via `ci-scope.mjs` (env `GITHUB_BASE_REF` idêntico). Deltas GitHub: `concurrency: { group: ci-pr-${{ github.head_ref }}, cancel-in-progress: true }` (fail-fast nativo em push novo — o OPS62 não cancelava só porque o Forgejo não tem API; aqui é grátis), `if:` same-repo no job (fork não queima minutes), `permissions: contents: read` (default), e2e selected preservado (OPS72 ajusta a política depois — `depends`).
|
||||
- **`.github/workflows/deploy.yml`** (novo): `workflow_dispatch` (sem inputs — ref default main). `verify` (ubuntu-latest, timeout 50): `check-test-locations` → lint → format → typecheck → knip → cycles → unit → int (migrate+seed nos services) → build → e2e **full** (4 workers; guardas de contrato de PR ficam de fora — não são verificação de produção). `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`, timeout 60, `permissions: contents: read`): checkout → `bash scripts/deploy-homeserver.sh "$GITHUB_SHA"`.
|
||||
- **`scripts/deploy-homeserver.sh`**: `TEQO_REPO_URL` default → `https://github.com/fsolla/teqo.git`; no workspace existente, `git remote set-url origin "$TEQO_REPO_URL"` idempotente antes do fetch (o clone velho aponta para o Forgejo local); doc de invocação: execução local no runner (não mais pipe via ssh) — o `</dev/null` no `run --rm` do migrate permanece. Fluxo interno intacto (HEAD guard, flock, already-deployed, migrator→migrate→runner→rollout, smoke).
|
||||
- **`.github/workflows/agent-pr-ready-automerge.yml`** (novo) + **`scripts/github-pr-automerge.mjs`** (novo, plain Node) + **`scripts/lib/github-api.mjs`** (novo, REST+GraphQL zero-dep): lê PR (REST); base != main → skip; draft `cursor/*` → `PATCH draft:false`; draft outro → skip (veto OPS57); senão GraphQL `enablePullRequestAutoMerge(mergeMethod: REBASE)` no `node_id` do PR. Erro do GraphQL → exit 1 (job vermelho, visível). `permissions: { contents: write, pull-requests: write }`.
|
||||
- **`scripts/github-pr.mjs`** (novo, plain Node): criação de PR para agentes/humano — `GITHUB_TOKEN` env; `--head <branch> --title <t> --body-file <f>` (Ready, base `main` — sem opção draft, regra do repo). Substitui o `forgejo_create_pull_request` do MCP no fluxo de entrega.
|
||||
- **`scripts/lib/github-branch-protection.mjs`** (novo, puro): `DESIRED_RULE` GitHub (`required_status_checks.checks = [{ context: 'CI (PR) / checks' }]`, strict=false, 0 reviews, `enforce_admins: true`, sem restrictions) + `ruleMatches`/`planBranchProtectionRule` (mesmo contrato do atual).
|
||||
- **`scripts/configure-branch-protection.mjs`**: reescrito para a REST do GitHub (`PUT /repos/{owner}/{repo}/branches/main/protection`), idempotente (read → plan → apply → verify), `--dry-run` preservado.
|
||||
- **`scripts/forgejo-issue-transition.mjs`**: aceita `PR_BODY` env (body do PR vem do evento GitHub); sem PR_BODY mantém a leitura via API do Forgejo (fallback — os workflows do Forgejo ainda estão vivos até a Fase 2); comentário do flip atualizado ("deploy manual via dispatch, ver AGENT-OPS").
|
||||
- **`.github/workflows/issue-done-on-main-merge.yml` / `plan-issue-ready-on-main-merge.yml`** (novos): `pull_request: closed` + `if:` merged && base main && **same-repo** (guarda RCE: checkout de fork + PAT = RCE — preservada); `PR_BODY: ${{ github.event.pull_request.body }}`; `FORGEJO_API_TOKEN` secret; sem `GITHUB_TOKEN` de Issues do GitHub (flip é no Forgejo).
|
||||
- **`.github/workflows/archive-cursor-agent.yml`** (novo): port direto do `.forgejo/workflows/` (CURSOR_API_KEY).
|
||||
- **`docs/AGENT-OPS.md`, `.agents/rules/agent-pr-workflow.mdc`, `.agents/skills/work-issue/` (SKILL + execution-pipeline), `.agents/skills/agent-work-issue/SKILL.md`, `.agents/skills/capture-review-debts/SKILL.md` (linha do fechamento), `AGENTS.md`, `docs/ops/teqo-1313-deploy.md`**: fluxo de PR/CI/merge/deploy reescrito para GitHub (canônico); seção de transição nomeando a Fase 2; tabela de secrets (GitHub agora: `FORGEJO_API_TOKEN`, `CURSOR_API_KEY`, local `GITHUB_TOKEN` PAT); passos manuais do cutover na ordem (incl. desligar o runner do Forgejo e por quê — o schedule do `ci.yml` antigo pararia de ter sentido).
|
||||
- **Preservados nesta entrega (Fase 2 remove):** `.forgejo/workflows/*` (6 arquivos), `scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-dispatch.mjs`, `scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`, `scripts/lib/branch-protection.mjs` + specs (`ciClassifyProduction`, `dockerignoreMatch`, `branchProtection`) — dormentes, documentados como rollback e alvo da entrega de remoção.
|
||||
- **Testes novos:** `tests/unit/githubApi.unit.spec.ts` (normalização de shapes + endpoints REST/GraphQL, molde do `forgejoApi.unit.spec.ts`), `tests/unit/githubBranchProtection.unit.spec.ts` (regra desejada + drift, molde do `branchProtection.unit.spec.ts`), e pure-functions da política de draft/skip do automerger (`scripts/lib/github-pr-flow.mjs` + spec — o pin OPS57/OPS64 do jeito GitHub).
|
||||
- **Migration:** sem migration (nenhum schema).
|
||||
- **Access / Consent:** nenhum.
|
||||
- **UI:** Impeccable N/A.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Libs + scripts base** — `github-api.mjs`, `github-pr-flow.mjs` (puro), `github-branch-protection.mjs` (puro), `github-pr.mjs`, `github-pr-automerge.mjs`, `forgejo-issue-transition.mjs` (PR_BODY), `configure-branch-protection.mjs` (GitHub) + specs unit. Gate: `pnpm test:unit`.
|
||||
2. **Workflows** — `.github/workflows/*` (6). Nada do `.forgejo/workflows/` é tocado nesta fase (Fase 2 do cutover). Gate: `pnpm gate:fast` + `pnpm check:cycles` + knip.
|
||||
3. **Docs/skills + changelog** — AGENTS.md, AGENT-OPS, agent-pr-workflow.mdc, skills, runbook (com seção de transição), `docs/changelog/2026-08-19-ops71.md` + `pnpm changelog:build`.
|
||||
4. **Gates finais + entrega** — `pnpm gate:ci` completo (unit+int+build), format, knip, cycles; `pnpm push -u origin HEAD` → PR no GitHub via `node scripts/github-pr.mjs` → CI verde no GitHub → auto-merge → flip da #97. **Passos manuais do cutover listados para o humano** (Fase 1 do cutover: remotes, secrets, branch protection, runner homeserver, shutdown runner Forgejo) — o PR em si não depende deles (CI roda no GitHub com secrets de teste), mas a ordem recomendada é: remotes → secrets → configure → push → PR.
|
||||
|
||||
## Fase 2 — entrega sucessora (pós-validação)
|
||||
|
||||
Registrada no fechamento desta Issue (capture-review-debts): remove `.forgejo/workflows/*`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs`, `dockerignore.mjs` (+ specs órfãs) e qualquer doc residual do fluxo Forgejo. Critério de entrada: um ciclo completo validado no GitHub (PR → CI → auto-merge → flip → deploy manual).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Portar `waitForChecks`/statuses para o GitHub — não há; auto-merge nativo.
|
||||
- Re-engenharia do `deploy-homeserver.sh` (fluxo docker/compose/migrate) — só a origem do clone e o modo de invocação mudam.
|
||||
- Sincronizar o repo Forgejo com main — congelado (decidido, anti-goal do tracker preservado).
|
||||
- Migrar Issues/labels/claims — anti-goal explícito.
|
||||
- OPS72 (e2e local afetado) — item sucessor com `depends: [OPS71]`.
|
||||
- Pool/`agent-pool*` — dormente (OPS65); o texto `gh pr` em `agent-pool-prompt.mjs` é de feature morta — deixo como está (fora do serializes).
|
||||
- Secrets de GitHub (`FORGEJO_API_TOKEN`, `CURSOR_API_KEY`, `GITHUB_TOKEN` PAT local) — passo manual documentado, não código.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Nome do check-run `CI (PR) / checks`:** o workflow `name: CI (PR)` + job `checks` geram exatamente esse check-run no GitHub; o required check usa o mesmo literal — pin no spec da branch protection e verificação ao vivo no primeiro PR (se o nome divergir, o configure aponta o drift e o PR não mergea — fail-closed, nunca merge sem verificação).
|
||||
- **Auto-merge GraphQL com `GITHUB_TOKEN`:** permissões declaradas (`contents`+`pull-requests: write`) cobrem PATCH draft e a mutation; se o GraphQL recusar (ex.: auto-merge desabilitado no repo), o job fica vermelho com a mensagem — mitigação: passo manual `allow_auto_merge: true` no repo (Settings ou API) listado no cutover.
|
||||
- **PR com conflito:** auto-merge fica armado e o PR espera — igual ao hoje (waitForChecks lançava "não mergeável"); nenhum merge errado é possível (servidor).
|
||||
- **Repo público → fork PR:** `if:` same-repo no job `checks` + setting de fork workflows no repo (passo manual) — fork não queima minutes nem acessa secrets.
|
||||
- **Runner do homeserver ainda não instalado:** o `deploy` fica indisponível até o passo manual; PRs seguem mergeando (CI hosted não depende do runner). O PR deste item não publica nada sozinho — o deploy é dispatch manual.
|
||||
- **Schedule do `ci.yml` antigo rodando na workstation durante a transição:** com o repo Forgejo congelado, o gate do Forgejo lerá um deployed SHA inexistente no clone → fail-open vermelho → suíte full a cada 30 min na workstation. Mitigado: o cutover desliga o runner do Forgejo logo após o merge (passo (d), reversível) — antes do primeiro deploy via GitHub.
|
||||
- **`gate:ci` local vs `origin/main`:** o remote muda para GitHub; sem o passo manual dos remotes, o gate local compara contra o Forgejo defasado → risco de diff falso. Mitigado: o passo de remotes é o PRIMEIRO do cutover e o runbook avisa; o `*-impl.md` documenta o comando exato.
|
||||
- **TEQO_REPO_URL no homeserver:** clone velho com origin Forgejo → `set-url` idempotente no script cobre; repo público → sem credencial.
|
||||
- **Knip com os scripts do Forgejo preservados:** nada órfão é criado nesta entrega (todos os scripts seguem referenciados pelos YAMLs do `.forgejo/workflows/`, que permanecem) — sem risco de knip flagrar a deleção adiada.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (CI/PR GitHub, tracker Forgejo, deploy manual verify→self-hosted, rollup required check)
|
||||
- [x] Cutover em duas fases (gate 2026-08-19): sistema GitHub criado e validado primeiro; arquivos do Forgejo preservados nesta entrega, remoção em entrega sucessora pós-validação
|
||||
- [x] Invariantes AGENTS/engineering-standards (sem migration, sem schema; plain Node stdlib nos helpers de CI; zero-dep nos workflows)
|
||||
- [x] Testes de domínio previstos: github-api (normalização/endpoints), github-branch-protection (drift), github-pr-flow (draft policy/skips — pin OPS57/OPS64 no jeito GitHub)
|
||||
83
docs/plans/ops71-fase2-remover-arquivos-forgejo-impl.md
Normal file
83
docs/plans/ops71-fase2-remover-arquivos-forgejo-impl.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Impl: OPS71 Fase 2 — remover arquivos do Forgejo (workflows/scripts) após validação do GitHub Actions
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #111
|
||||
Intenção: docs/plans/ops71-ci-github-actions-tracker-forgejo.md (§"Fase 2 — entrega sucessora", linha 99-101 do impl da Fase 1)
|
||||
Appetite restante: pequeno — remoção + fixups de consumidores + docs (1 sessão)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** o fluxo GitHub Actions (OPS71 Fase 1, validado ao vivo: PRs #742/#743 — CI verde, auto-merge nativo, flips via API) é a única via de CI/PR/deploy no repo; os arquivos do fluxo Forgejo saem do tree; docs residuais atualizados; nada do tracker é tocado.
|
||||
- **O que NÃO negociar:** tracker no Forgejo (Issues/labels/claims/flips via API) — `forgejo-api.mjs`, `agent-*.mjs`, `issue.mjs`, `forgejo-issue-transition.mjs`, `agent-promote-related-on-merge.mjs` **ficam**; `.forgejo/worktree.env` fica (fora do escopo `workflows/*`, consumido pelo provisionamento de worktree); desligar o runner do Forgejo é passo manual do runbook (fora do código); `ci.yml` de main não volta (deploy = dispatch manual).
|
||||
- **O que reavaliar:** a hipótese da intenção dizia "grep `forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore` como saída" — a varredura achou **dois consumidores funcionais** além de docs, não listados no escopo literal: `scripts/lib/test-affected-core.mjs` (HIGH_RISK_EXACT com os 2 paths mortos) e `tests/unit/ciSkipInvariants.unit.spec.ts` (lê `.forgejo/workflows/ci.yml`+`ci-pr.yml` e pina o gate OPS65). Ambos **precisam ser editados** — são o contrato que quebraria se os arquivos sumissem. Também achado: `README.md` ainda descreve o fluxo Forgejo pré-Fase 1 (janela de 30 min, deploy automático, linha de CI apontando `.forgejo/workflows/`).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
D[14 arquivos deletados] --> C1[test-affected-core: -2 entries HIGH_RISK]
|
||||
D --> C2[ciSkipInvariants: -2 paths .next-e2e, -2 high-risk, teste OPS65 reescrito para deploy.yml dispatch-only]
|
||||
D --> C3[gate-ci: comentário .github/workflows/ci-pr.yml]
|
||||
D --> C4[github-pr-automerge + agent-pr-ready-automerge: comentário sem referência morta]
|
||||
D --> DOC[AGENTS.md + runbook + README — era GitHub só]
|
||||
DOC --> CL[changelog ops71-fase2 + changelog:build]
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A — Remoção seca + fixups mínimos dos consumidores** (o escopo do body + os 2 consumidores funcionais + 3 docs). Nada de novo é criado; invariantes de CI re-ancorados no `.github/workflows/`.
|
||||
- **B — Reescrever `ciSkipInvariants` para pinar "`.forgejo/` não existe"** — redundante: a deleção é o próprio diff do PR; e o teste que importa é o invariante _vivo_ (deploy nunca automático), que re-pino no `deploy.yml`.
|
||||
- **C — Deletar o teste OPS65 inteiro** (remover o teste da janela de 30 min e pronto) — perderia o pin "nunca deploy automático" sem reposição; o GitHub manteve o espírito (dispatch manual), o teste deve continuar existindo apontando para o arquivo certo.
|
||||
|
||||
**Recomendação:** A — porque os consumidores são pequenos e bem delimitados, a substituição do pin OPS65 pelo pin equivalente da era GitHub (`deploy.yml` sem trigger `push`/`schedule`; `ci-pr.yml` sem o gate morto) preserva a invariante de deploy manual, e nada além do escopo declarado é tocado.
|
||||
|
||||
**Rejeitadas:**
|
||||
|
||||
- **Manter os arquivos como rollback inerte** — o propósito da Issue é a remoção; rollback passa a ser git history (o main congelado do Forgejo é uma cópia íntegra da era Forgejo; o PR é um commit reversível).
|
||||
- **Deletar `forgejo-issue-transition.mjs`/`agent-promote-related-on-merge.mjs`/`forgejo-api.mjs`/`agent-*.mjs`** — anti-goal explícito (tracker vivo).
|
||||
- **Deletar `.forgejo/worktree.env`** — fora do escopo (`workflows/*`); consumido pelo provisionamento de worktree.
|
||||
- **Editar `docs/plans/*`, `docs/changelog/*`, `docs/CHANGELOG-AGENTS.md`** — história congelada/insert-only; a narrativa de "preservados até Fase 2" vive nos changelogs antigos e permanece.
|
||||
- **Tocar `docs/AGENT-OPS.md`** — já é era-GitHub (grep não acha referência a nenhum arquivo removido).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **Deletados (14):** `.forgejo/workflows/` (6: ci-pr.yml, ci.yml, agent-pr-ready-automerge.yml, issue-done-on-main-merge.yml, plan-issue-ready-on-main-merge.yml, archive-cursor-agent.yml); `scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-dispatch.mjs`, `scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`, `scripts/lib/branch-protection.mjs`; specs `tests/unit/ciClassifyProduction.unit.spec.ts`, `tests/unit/dockerignoreMatch.unit.spec.ts`, `tests/unit/branchProtection.unit.spec.ts`. Nenhuma importação viva aponta para eles (grep: só `test-affected-core` + `ciSkipInvariants` + docs).
|
||||
- **`scripts/lib/test-affected-core.mjs`**: remove `scripts/ci-classify-production.mjs` e `scripts/lib/dockerignore.mjs` de `HIGH_RISK_EXACT` (paths mortos nunca aparecem em diff; listas devem espelhar o tree).
|
||||
- **`tests/unit/ciSkipInvariants.unit.spec.ts`**: (1) tira `ci.yml`+`ci-pr.yml` do loop `.next-e2e`; (2) tira os 2 paths do pin de high-risk; (3) reescreve o teste "main CI 30-min window (OPS65)" para a era GitHub: lê `.github/workflows/deploy.yml` e pina `workflow_dispatch:` presente, `push:`/`schedule:` ausentes (deploy nunca automático) e `.github/workflows/ci-pr.yml` sem `ci-classify-production.mjs` (gate morto).
|
||||
- **`scripts/gate-ci.mjs`** (comentário do header): "Local mirror of `.forgejo/workflows/ci-pr.yml`" → `.github/workflows/ci-pr.yml`.
|
||||
- **`scripts/github-pr-automerge.mjs` + `.github/workflows/agent-pr-ready-automerge.yml`**: comentários com o parêntese "(forgejo-pr-automerge.mjs)" → referência genérica à era Forgejo (arquivo não existe mais).
|
||||
- **`AGENTS.md`**: frase "The Forgejo-era files (...) are preserved as rollback until the OPS71 Fase 2 removal" → removidos na Fase 2; rollback via git history (main congelado do Forgejo / revert do PR).
|
||||
- **`docs/ops/teqo-1313-deploy.md`** (§"Rollback do cutover"): "os workflows de `.forgejo/workflows/` seguem no repo até a Fase 2" → arquivos removidos; restauração via git history do main congelado do Forgejo.
|
||||
- **`README.md`**: bloco de fluxo stale (janela 30 min, deploy automático, linha `CI: .forgejo/workflows/ci-pr.yml + ci.yml`) → fluxo era-GitHub (PR → `CI (PR) / checks` → auto-merge nativo → deploy manual dispatch).
|
||||
- **Changelog:** `docs/changelog/2026-08-19-ops71-fase2.md` + `pnpm changelog:build`.
|
||||
- **Migration:** sem migration (nenhum schema).
|
||||
- **Access / Consent / UI:** N/A.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Deleção + fixups de código** — `git rm` dos 14 arquivos; edita `test-affected-core.mjs`, `ciSkipInvariants.unit.spec.ts`, comentários de `gate-ci.mjs`/`github-pr-automerge.mjs`/`agent-pr-ready-automerge.yml`. Gate: `pnpm test:unit` (ciSkipInvariants/testAffected/github\*).
|
||||
2. **Docs + changelog** — AGENTS.md, runbook, README, `docs/changelog/2026-08-19-ops71-fase2.md` + `pnpm changelog:build`.
|
||||
3. **Gates finais** — `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test` (unit+int), `pnpm build`; grep de saneamento (`forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore.mjs|branch-protection.mjs` → só histórico/docs/changelogs); push → PR GitHub → CI (PR) / checks → auto-merge.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Re-engenharia do `ciSkipInvariants` além do re-pino — não; só o que o arquivo lê muda.
|
||||
- `pnpm configure:branch-protection`/github libs — intocados (era GitHub).
|
||||
- Pool/`agent-pool*` dormente (OPS65) — intocado.
|
||||
- Desligar o runner do Forgejo — passo manual no runbook, não código.
|
||||
- `.dockerignore` (o arquivo) — **permanece**: o `docker build` do `deploy-homeserver.sh` o consome; só o lib `dockerignore.mjs` (parse de regras, gate OPS65) morre.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **`ciSkipInvariants` quebrar por leitura de arquivo removido** — os 3 pontos são editados na mesma fase da deleção; gate local `pnpm test:unit` antes de qualquer push.
|
||||
- **Pin perdido do "deploy manual"** — mitigado pelo teste reescrito (dispatch-only, sem `push:`/`schedule:`); se um dia o `ci.yml` voltar com trigger, o teste acende.
|
||||
- **Referência morta residual fora do grep** — saneamento final com o grep do escopo + knip (dead exports/deps é CI-blocking); docs históricos (plans/changelogs antigos) são allowlist consciente.
|
||||
- **Knip acusar `branch-protection.mjs`/`dockerignore.mjs` como órfãos na Fase 1→Fase 2** — a deleção é atômica com a remoção das referências (mesma fase); nada fica órfão no diff.
|
||||
- **PR de plano vs `check-plans-only-pr-closes`** — este PR tem código (não é plans-only); guard não se aplica.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (remoção completa + docs; tracker intacto)
|
||||
- [x] Invariantes AGENTS/engineering-standards (sem migration/schema; nada do tracker; docs históricos intocados; changelog insert-only)
|
||||
- [x] Testes de domínio previstos: ciSkipInvariants re-ancorado na era GitHub (deploy dispatch-only); unit suíte verde
|
||||
97
docs/plans/ops71-flip-pos-merge-falhou-silencioso-impl.md
Normal file
97
docs/plans/ops71-flip-pos-merge-falhou-silencioso-impl.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# Impl: OPS71-FLIP — Flip pós-merge falhou/silencioso no fluxo GitHub — validar e endurecer issue-done-on-main-merge
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-20
|
||||
Issue: #119
|
||||
Intenção: body da Issue (sem plano linkado — o body é a spec)
|
||||
Appetite restante: herdado (chore P2, sem appetite declarado — escopo = as 4 ações sugeridas + fix de raiz)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** todo PR mergeado em `main` com `Closes #N` deve flipar a Issue no Forgejo para `done`+`in-prod` + comentário + **close** — de forma **não silenciosa** (falha visível) e com **caminho de recuperação manual** quando o evento do GitHub não criar run.
|
||||
- **O que NÃO negociar:** tracker continua no Forgejo (flips via `FORGEJO_API_TOKEN`); fork guard (RCE) preservado; flips nunca mentem (OPS61: job vermelho se qualquer flip falhar); CI green é o único gate de merge.
|
||||
- **O que reavaliar:** a hipótese da Issue ("tipos de evento / entrega do GitHub") estava certa só pela metade — a causa real é a **anti-recursão do GITHUB_TOKEN** (merge atribuído a `github-actions[bot]` não cria runs); a hipótese "410 é transitório, só pin/retry" é insuficiente — o 410 quebrou o run do #742 **depois** do evento ter criado o run; os dois problemas são independentes.
|
||||
|
||||
## Investigação (root cause — evidência ao vivo, GitHub API pública)
|
||||
|
||||
Comparação dos dois merges (timeline `issues/{n}/timeline` + `actions/runs`):
|
||||
|
||||
| | PR #742 (Fase 1) | PR #746 (Fase 2) |
|
||||
| ------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `auto_rebase_enabled` | `github-actions[bot]` 21:20:38Z | `github-actions[bot]` 00:02:32Z |
|
||||
| `merged` / `closed` | **`fsolla`** 22:17:20Z | **`github-actions[bot]`** 00:20:11Z |
|
||||
| Runs criados no `closed` | 32308114529 (falhou 410) + 32308114530 (skipped), 3s após o merge | **0 runs** (nenhum evento, nenhum head — janela 00:00–00:45 verificada) |
|
||||
|
||||
**Causa raiz:** o auto-merge nativo armado via `enablePullRequestAutoMerge` (GraphQL) com o **GITHUB_TOKEN** do workflow executa o merge como `github-actions[bot]`. O GitHub **documenta** ("Automatic token authentication", anti-recursão): eventos acionados por ações feitas com o `GITHUB_TOKEN` **não criam workflow runs** (exceção: `workflow_dispatch`, `repository_dispatch`). O `closed` fica no timeline do PR, mas o Actions não gera run nenhum — nem `pull_request`, nem `push` (0 runs na janela do merge). Toda auto-merge por bot vai pular os dois flips pós-merge **em silêncio** (run nem é criado — nunca vai aparecer vermelho).
|
||||
|
||||
O 410 é falha **separada**: o run do #742 foi criado normalmente (merge por humano) e o `GET /issues/97/labels` respondeu 410 Gone (nginx) — transitório, endpoint ok às 00:3x. O retry do OPS67 cobre fetch-rejection + 5xx-no-GET; 410 (4xx) nunca retenta; o corpo nginx HTML entra no erro só como slice cru de 400 chars (imprestável para log).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
M["PR mergea em main"] -->|"auto-merge armado com PAT real (AUTOMERGE_PAT)"| E["closed event → runs criados<br/>(merge atribuído ao dono do PAT)"]
|
||||
M -. "legado: bot merge (GITHUB_TOKEN)" .-> X["anti-recursão: 0 runs<br/>(causa raiz #746)"]
|
||||
E --> F["issue-done / plan-issue-ready flipam no Forgejo"]
|
||||
F --> C["labels done+in-prod + comentário + close<br/>(forgejo-issue-transition.mjs)"]
|
||||
H["humano: dispatch manual (recovery)"] --> G["gh pr view → PR_BODY → mesmo script"]
|
||||
C -->|"410/5xx transitório"| R["retry 410 no forgejo-api<br/>(proxy respondeu — origem intocada)"]
|
||||
```
|
||||
|
||||
**Opções consideradas (fix de raiz):** A | B | C
|
||||
**Recomendação:** **A + recuperação manual + close + retry 410** — a trinca atende os 4 pontos da Issue e conserta a causa que a torna _silenciosa_.
|
||||
**Rejeitadas:**
|
||||
|
||||
- **B — manter arming com GITHUB_TOKEN e só adicionar dispatch manual:** o flip continua quebrado em todo merge de bot (o "silencioso" do título); o humano vira o flip de todo PR. Rejeitada porque a Issue é exatamente sobre o silêncio.
|
||||
- **C — poller (schedule) que varre PRs mergeados:** disparo a cada N minutos com API lendo PRs fechados = latência, races, e um "verificador" novo que o OPS71 eliminou de propósito; PAT + evento nativo é mais simples e imediato.
|
||||
|
||||
**Opções consideradas (recuperação manual):** A | B
|
||||
**Recomendação:** **A — `workflow_dispatch` com input `pr`** (o script já aceita `PR_BODY`; o step de dispatch busca body+merged via `gh` e repassa). B (rodar o script local com `PR_BODY`) é o fallback final do runbook, sem YAML novo.
|
||||
**Rejeitadas:** C — GitHub App só para isso (peso de registrar app; o repo já usa PATs).
|
||||
|
||||
**Opções consideradas (410):** A | B
|
||||
**Recomendação:** **A — 410 entra no retry do `forgejo-api.mjs` para QUALQUER método** (não só GET): 410 vem do proxy (nginx/Cloudflare) — a origem **nunca viu** o request, então retentar escrita não duplica efeito colateral (diferente de 5xx, onde a origem pode ter aplicado o write — esse continua fail-closed). Corpo não-JSON vira marker compacto no erro.
|
||||
**Rejeitadas:** B — 410 só no GET (o flip faz `removeLabels` GET → `addLabels` POST → `addComment` POST; um 410 no POST continuaria quebrando o flip apesar da classificação "transitório conhecido").
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`.github/workflows/agent-pr-ready-automerge.yml`**: passa `AUTOMERGE_PAT: ${{ secrets.AUTOMERGE_PAT }}` ao step; comentário do porquê (anti-recursão).
|
||||
- **`scripts/github-pr-automerge.mjs`**: **fail-closed** — se `AUTOMERGE_PAT` ausente, `exit 1` com mensagem nomeando o secret e o motivo (nunca fallback silencioso para GITHUB_TOKEN); `enableAutoMerge` passa a usar `createApi({ token: AUTOMERGE_PAT })` (o `markPullRequestReady` continua com o token do run — PATCH draft:false não precisa gerar evento: nada consome `ready_for_review` além do próprio workflow, e o CI corre no `synchronize` do push do agente).
|
||||
- **`.github/workflows/issue-done-on-main-merge.yml`** + **`plan-issue-ready-on-main-merge.yml`**: `on: workflow_dispatch` com input `pr` (required); job `if:` = evento (`closed` + merged + main + same-repo) **ou** dispatch com input; permission `pull-requests: read` (gh); step de dispatch: `gh pr view` → valida `merged == true` + `baseRefName == main` + `headRepository.nameWithOwner == github.repository` (mesmo contrato do evento — um dispatch num PR de fork/aberto é skip) → `PR_BODY` via `GITHUB_OUTPUT`; step do node usa `steps.pr.outputs.* || github.event.pull_request.*` (um caminho só, sem override vazio). O script já no-ops sem `Closes`/`Related`.
|
||||
- **`scripts/forgejo-issue-transition.mjs`**: no loop do flip, após labels+comentário → **`api.closeIssue(number)`** (era-Forgejo o close era nativo do merge do PR; era-GitHub o PR não toca o tracker — sem o PATCH toda Issue mergeada fica `OPEN` com `done`+`in-prod`). Falha de close = `flipFailed` (OPS61: vermelho, nunca mentir). Comentário ganha menção ao close.
|
||||
- **`scripts/lib/forgejo-api.mjs`**: `RETRYABLE_BEFORE_ORIGIN = new Set([410])` retentado em qualquer método (comentário: proxy respondeu — origem intocada, sem risco de efeito duplicado; 5xx segue GET-only); erro `!response.ok` com corpo HTML → marker compacto `corpo não-JSON (proxy/nginx — HTTP <status>): <120 chars>` em vez do slice cru.
|
||||
- **`scripts/lib/github-api.mjs`**: **intocado** (o 410 observado é do proxy do Forgejo; github-api não recebe o mesmo tratamento por escopo — política documentada nos headers de ambos).
|
||||
- **`docs/AGENT-OPS.md`**: tabela de workflows (rows dos dois flips ganham "dispatch manual de recuperação"), tabela de secrets (`AUTOMERGE_PAT`), parágrafo do flip com o porquê bot-merge não cria run + runbook de recuperação (dispatch com número do PR; fallback local `PR_BODY`).
|
||||
- **`AGENTS.md`**: frase curta no parágrafo do OPS71 (auto-merge armado com `AUTOMERGE_PAT` — merge por bot não cria runs).
|
||||
- **Migration:** sem migration. **Access/Consent:** n/a. **UI:** n/a.
|
||||
- **Changelog:** `docs/changelog/2026-08-20-ops71-flip.md` + `pnpm changelog:build`.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Lib + script do flip** — `forgejo-api.mjs` (410 + closeIssue + marker HTML), `forgejo-issue-transition.mjs` (close). Unit specs novas em `tests/unit/forgejoApi.unit.spec.ts` (410 GET retry, 410 write retry, 410 esgotado → marker HTML, closeIssue PATCH pin). Verificação: `pnpm test` (spec), `pnpm exec tsc --noEmit`, lint/format.
|
||||
2. **Auto-merge com PAT** — `github-pr-automerge.mjs` fail-closed + workflow env. Verificação: tsc/lint; comportamento real no próprio PR desta entrega (CI verde → auto-merge armado com o PAT).
|
||||
3. **Workflows de recuperação** — `workflow_dispatch` nos dois flips. Verificação: YAML válido (CI do PR), gate do humano.
|
||||
4. **Gates + docs + changelog** — `pnpm gate:fast`; `pnpm changelog:build`; AGENT-OPS/AGENTS.
|
||||
5. **Pós-merge (validação ao vivo, humano):** criar o secret `AUTOMERGE_PAT` **antes** do merge deste PR (senão o auto-merge do próprio PR falha fail-closed); conferir o flip da #119 (run criado pelo `closed` do merge — agora atribuído ao humano) e, como recovery proof, disparar o dispatch manual do `issue-done` uma vez.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Retry 410 no `github-api.mjs` (simetria): sem evidência de 410 no GitHub API; política documentada, gatilho = ocorrência real.
|
||||
- GitHub App dedicado para o merge (B do fix de raiz): rejeitado no gate — PAT fine-grained basta.
|
||||
- `agent-promote-related-on-merge.mjs` (`setLabels` do `agent-forgejo.mjs`): intocado — só o workflow dele ganha o dispatch.
|
||||
- Poller/`schedule` de verificação de merges (C): rejeitado — o evento nativo + dispatch cobre.
|
||||
- Drift do `ci-pr.yml` / branch protection: fora de escopo.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Secret `AUTOMERGE_PAT` ainda não existe no merge deste PR** → o fail-closed derruba o auto-merge de todo PR até o humano criar. Mitigação: passo 5 da fase pós-merge declara criar o secret **primeiro**; sem o secret, merges manuais continuam funcionando (o bot não arma, o humano mergea — `closed` por humano gera runs, comportamento #742).
|
||||
- **Fine-grained PAT sem permissão de merge** → `enablePullRequestAutoMerge` GraphQL falha → job vermelho (visível, não silencioso). Mitigação: runbook documenta escopos (`pull-requests: write` + `contents: write` no repo fsolla/teqo).
|
||||
- **Dispatch manual num PR não mergeado / fork** → skip (validação `merged` + `baseRefName` + `headRepository` no step), mesma semântica do evento.
|
||||
- **`gh` no runner hosted**: pré-instalado no `ubuntu-latest`; `GITHUB_TOKEN` injetado automaticamente; sem dependência nova.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (flip sempre acontece; falha é visível; recovery existe)
|
||||
- [x] Invariantes AGENTS/engineering-standards (fork guard nos dois workflows, OPS61 exit-1, zero-dep plain Node, DRY <3 call sites)
|
||||
- [x] Testes de domínio previstos (unit: retry 410 GET/write, marker HTML, closeIssue, fail-closed PAT)
|
||||
- [x] Self-score decision-quality: 5/5 (decisões caras com rejeitadas; abordagem no appetite; rabbit holes nomeados; depth check ok — reusa createApi/PR_BODY; intenção preservada)
|
||||
135
docs/plans/ops71-runner-self-hosted-github-homeserver-impl.md
Normal file
135
docs/plans/ops71-runner-self-hosted-github-homeserver-impl.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# Impl: Runner self-hosted do GitHub no homeserver (deploy do dispatch manual) + CURSOR_API_KEY
|
||||
|
||||
Status: aguardando aprovação
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #113
|
||||
Intenção: o body da Issue #113 (OPS71-INFRA) é a spec — sem plano de intenção separado
|
||||
Appetite: horas de operação (não é Issue de código — nada de schema/UI/access)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** o job `deploy` do `.github/workflows/deploy.yml` (dispatch manual)
|
||||
roda no **runner self-hosted do GitHub instalado no homeserver** (labels
|
||||
`self-hosted` + `homeserver`), executando `scripts/deploy-homeserver.sh <sha>`
|
||||
localmente (sem SSH). O hosted (`verify`) nunca toca o homeserver.
|
||||
- **O que NÃO negociar:** runner só no `deploy.yml` (nunca no `ci-pr.yml`);
|
||||
usuário com docker group + leitura de `~/stack/`; outbound-only (funciona
|
||||
atrás do Cloudflare tunnel); registro do runner em infra-solla `STATE.md`.
|
||||
- **Já validado na sessão (pre-flight):**
|
||||
- SSH ao homeserver OK (LAN `192.168.15.142`, user `fsolla`).
|
||||
- `fsolla` tem `docker` no groups + `sudo`; lê `~/stack/teqo-1313.env` e
|
||||
`~/stack/.env` (modo 600, dono = fsolla). Arch `x86_64` → tarball linux-x64.
|
||||
- **Runner ainda NÃO existe** no homeserver (sem `~/actions-runner`, sem unit
|
||||
systemd do runner; o `runner-watch.*` é outra coisa — WoL/shutdown da
|
||||
workstation).
|
||||
- **PAT do OPS71 funciona** (local, `~/.bashrc`): `GET /repos/fsolla/teqo` 200
|
||||
e `POST …/actions/runners/registration-token` 201 → admin no repo OK.
|
||||
- Versão estável atual do runner: **v2.336.0** linux-x64, 226 MB,
|
||||
sha256 `04cf0be1aff4c3ec3554466c39124ca250e3effd8873bb7e8d68535aa9505d5d`
|
||||
(digest da API de releases do GitHub).
|
||||
- **Linger do homeserver já ativo** (`loginctl show-user fsolla` →
|
||||
`Linger=yes`) → systemd **user** funciona sem sudo (o `runner-watch` já é
|
||||
user service persistente lá — prova do padrão).
|
||||
- Workspace `~/teqo-deploy` existe mas ainda aponta origin → Forgejo
|
||||
(`http://localhost:3000/…`): o `set-url` idempotente do
|
||||
`deploy-homeserver.sh` re-aponta para `TEQO_REPO_URL` (GitHub) no primeiro
|
||||
deploy — nada a fazer, mas é o que o log do primeiro deploy deve mostrar.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
Sequência **operacional no homeserver** (nenhum commit no repo teqo — o código
|
||||
do OPS71 já foi mergeado; o único artefato commitado desta Issue é o registro no
|
||||
`STATE.md` do infra-solla, repo separado):
|
||||
|
||||
1. **Download** (homeserver, `~/actions-runner`): tarball v2.336.0 linux-x64 da
|
||||
release oficial, **sha256 conferido** contra o digest da API de releases.
|
||||
2. **Registration token** (workstation, PAT local): `POST
|
||||
/repos/fsolla/teqo/actions/runners/registration-token` (TTL 1 h — gerar e
|
||||
configar na sequência, um único passo encadeado).
|
||||
3. **config.sh** (homeserver, user fsolla):
|
||||
`./config.sh --url https://github.com/fsolla/teqo --token <token> --labels
|
||||
self-hosted,homeserver --name teqo-1313-runner --work _work`.
|
||||
4. **svc.sh como systemd USER** (`./svc.sh install` sem root → unit
|
||||
`~/.config/systemd/user/actions.runner.fsolla-teqo-1313-runner.service`) +
|
||||
`systemctl --user enable --now …` (start). Linger já ativo → persiste sem
|
||||
login; roda como fsolla (docker + `~/stack` envs).
|
||||
5. **Verificação do runner**: `systemctl --user status …` ativo; runner listado
|
||||
em `GET /repos/fsolla/teqo/actions/runners` como online.
|
||||
6. **Registro em infra-solla** (`~/Code/infra-solla/STATE.md`): entrada na
|
||||
seção do Teqo + nota na tabela de topologia (runner GitHub no homeserver,
|
||||
labels, dir, systemd user) — e no runbook `docs/ops/teqo-1313-deploy.md` do
|
||||
teqo (a tabela "Onde roda cada coisa" já o menciona prospectivamente; só
|
||||
confirmar o texto com o estado real). Changelog `docs/changelog/2026-08-20-ops71-infra.md` + `pnpm changelog:build`.
|
||||
7. **Aceite (verificação ao vivo)**: dispatch manual do `deploy.yml` (ref main)
|
||||
→ `verify` full verde → job `deploy` roda no runner do homeserver → log com
|
||||
o `set-url` do workspace (Forgejo → GitHub) + smoke ok → **segundo dispatch
|
||||
do mesmo SHA** → guard "already deployed".
|
||||
|
||||
**Decisões:**
|
||||
|
||||
- **systemd user + linger (já ativo) vs. `svc.sh install` via root:** user — sem
|
||||
senha sudo interativa, mesmo padrão do `runner-watch` (persistência provada no
|
||||
próprio homeserver); o unit system-level não adicionaria nada (o unit user
|
||||
roda com `User=fsolla` de qualquer forma).
|
||||
- **`~/actions-runner` + workdir `_work`:** convenção oficial; `~/teqo-deploy`
|
||||
permanece como workspace do deploy (o script seta `TEQO_REPO_URL` nele).
|
||||
- **Sem mudanças em código/workflows:** `runs-on: [self-hosted, homeserver]`
|
||||
já está no `deploy.yml`; o `deploy-homeserver.sh` já tem o `set-url`
|
||||
idempotente. O runner vem do GitHub com o token de registro — sem secrets
|
||||
novos no repo (o repo é público; as envs de produção ficam no homeserver).
|
||||
|
||||
**Rejeitadas:**
|
||||
|
||||
- **`svc.sh install` como root** — exigiria sudo interativo; nada ganho sobre o
|
||||
unit user (mesmo usuário efetivo, linger já garante auto-start no boot).
|
||||
- **Rodar o runner em container/docker** — complexidade extra; o padrão oficial
|
||||
do GitHub é o binário com svc; o runner-watch local prova o user service.
|
||||
- **Instalar agora o helper `archive-cursor-agent` / criar o secret
|
||||
`CURSOR_API_KEY` no GitHub** — item secundário da Issue, dormente (pool morto,
|
||||
OPS65): a key não existe no ambiente; fica **registrado como pendência** no
|
||||
fechamento (pedir a key ao humano quando quiser reativar o helper), sem ação
|
||||
agora.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. Runner baixado + sha256 conferido.
|
||||
2. Runner configurado (`.runner` + `.credentials` no `~/actions-runner`),
|
||||
labels corretas, nome `teqo-1313-runner`.
|
||||
3. Unit systemd user ativo e **persistente** (`systemctl --user is-enabled …`),
|
||||
runner online na API do GitHub.
|
||||
4. `STATE.md` (infra-solla) + runbook + changelog registrados.
|
||||
5. Dispatch manual de validação (verify → deploy → already-deployed no segundo
|
||||
dispatch) — **aceite final, exige o humano** (dispara no GitHub UI).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não re-engenharia do `deploy-homeserver.sh` (fluxo compose/migrate intacto).
|
||||
- Não mexer no stack/registry/env do homeserver.
|
||||
- Não criar secret `CURSOR_API_KEY` (dormente — só com a key do usuário).
|
||||
- Não editar o `ci-pr.yml` nem a branch protection.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Token de registro expira em 1 h** → o `config.sh` roda na mesma sequência
|
||||
do POST (script encadeado da workstation → homeserver).
|
||||
- **`svc.sh install` gerar unit system em vez de user** (se detectar root):
|
||||
rodar sem sudo garante o user unit; se algo criar system unit, desfazer com
|
||||
`svc.sh uninstall` e repetir como user.
|
||||
- **Runner offline no dispatch** → falha conhecida já documentada no runbook
|
||||
("job deploy nunca roda / runner offline"): checar `systemctl --user status`,
|
||||
o runner-watch local não cuida deste runner (não é o forgejo-runner).
|
||||
- **verify full ~50 min hosted antes do deploy** → aceite da Issue é um ciclo
|
||||
completo; nada do homeserver é tocado até `verify` verde.
|
||||
- **Rollback**: `~/actions-runner/./svc.sh uninstall` + `rm -rf ~/actions-runner`
|
||||
(desregistra no GitHub); o deploy volta a ficar indisponível até o runner ser
|
||||
religado — comportamento conhecido, documentado no runbook.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto (deploy self-hosted no homeserver,
|
||||
verify hosted nunca toca o homeserver)
|
||||
- [ ] Runner instalado como fsolla (docker + `~/stack`), systemd user com
|
||||
linger, outbound-only
|
||||
- [ ] Registrado em infra-solla `STATE.md` + runbook + changelog
|
||||
- [ ] Validação ao vivo: dispatch manual → deploy no runner → "already
|
||||
deployed" no segundo dispatch
|
||||
76
docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md
Normal file
76
docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Impl: E2E local apenas para os testes afetados — CI de PR sem e2e full
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #98
|
||||
Intenção: docs/plans/ops72-e2e-local-afetado-ci-sem-full.md
|
||||
Appetite restante: ~0,5–1 dia eng (herdado)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** (1) skill `work-issue` (e irmãs do fluxo) exige que o agente rode localmente **os e2e que criou + os da mesma superfície afetada** — **discricionário**: o agente decide quais; `pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; (2) CI de PR roda o **blast-radius** (e2e que o classifier detecta como tocados — modo `selected`, nunca `full`); (3) e2e **full** roda na **main, antes do deploy** (`verify` do deploy manual — rede de segurança pré-publicação); (4) limitação da #72 documentada na skill.
|
||||
- **O que NÃO negociar:** full no deploy/verify de main; gate local (`gate:push`) sem e2e (passo explícito da skill, não hook); mecânica do classifier/manifest intocada; CI selected (blast radius) mantido como rede — o local é discricionário por design, o CI não.
|
||||
- **O que reavaliar:** hipótese de "`.github/workflows/ci.yml` (verificador de main)" — **não existe mais**: OPS71 eliminou o `ci.yml`; a suíte full vive só no job `verify` do `deploy.yml` (confirmado). A hipótese do ci-pr.yml já ter os dois steps e2e (`full` + `selected`) dirigidos por `ci-scope.mjs` se confirma — o trabalho é **remover o full** e reorientar o texto.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Diff do PR] --> B[ci-scope.mjs / e2e-affected.mjs]
|
||||
B -->|selected| C[CI PR: E2E blast-radius manifest-selected specs]
|
||||
B -->|full| D[CI PR: sem e2e - full vive no verify do deploy]
|
||||
B -->|none| E[CI PR: sem e2e - sem blast radius]
|
||||
F[Agente antes do push] --> G[e2e local dos specs criados + mesma superfície - discricionário]
|
||||
G -->|decisão do agente| H[pnpm test:e2e:affected ou specs diretas]
|
||||
I[main antes do deploy] --> J[verify: e2e full]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C
|
||||
**Recomendação:** A — remover o step e2e `full` do `ci-pr.yml`; quando o classifier emitir `full` (diff high-risk: schema/lockfile/harness), o CI de PR simplesmente não roda e2e (o step `selected` tem `if: e2e_mode == 'selected'` e é pulado; build-for-e2e segue rodando). O espelho local (`run-e2e-affected.mjs`) roda **full localmente** nesse mesmo caso — mesma mecânica, sem gap: o único caminho para "full" é diff high-risk, e aí o agente viu o full na máquina dele e o `verify` do deploy roda full antes de publicar. Ciência: um diff high-risk sem e2e no CI é um risco deliberado (rabbit hole "full só no deploy cria gap de regressão" já aceito no gate).
|
||||
**Rejeitadas:** B — manter o step `full` no CI (status quo): viola o gate "nunca full por PR" e devolve o custo 40+ min por PR de infra. C — mapear `full` → falha/aviso no CI: criaria bloqueio artificial em PRs legítimos de infra/schema (o diff high-risk é exatamente o que o local roda full); o verify do deploy já é o gate honesto.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`.github/workflows/ci-pr.yml`**: remover o step `E2E tests (full suite, single job, 4 workers)`; atualizar o comentário do header ("e2e policy: selected (affected) specs on PRs — never full; full lives in deploy verify — OPS72"); o step `E2E tests (manifest-selected specs)` fica funcionalmente intacto.
|
||||
- **`scripts/gate-ci.mjs`**: mensagens informativas do escopo e2e (texto-only, ~linhas 153–162) — "e2e verified in CI" vira "e2e selected no CI (PR); full só no `verify` do deploy; local: `pnpm test:e2e:affected` (passo da skill)". Comportamento intocado (e2e fora do gate — aceite). Mensagens não são pinadas por unit tests (verificado).
|
||||
- **`.agents/skills/work-issue/execution-pipeline.md`** (corpo compartilhado das duas skills): seção "Executar" item 3 (Gates) ganha o passo **e2e local (discricionário)** antes do `pnpm push`; nova seção curta "E2E local afetado" documentando: o agente roda **os specs e2e que criou + os da mesma superfície afetada**, decidindo quais (discricionário); a ferramenta (`pnpm test:e2e:affected` — espelho do classifier; ou specs diretas via `--no-deps`), e a **limitação da #72** (`--no-deps` + projetos paralelos colidem no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos; só conveniência local, CI é sequencial por construção).
|
||||
- **`.agents/skills/work-issue/SKILL.md`**: checklist item 4 e Passo 4 ganham o passo (humano): "rode localmente os e2e que você criou + os da mesma superfície afetada — você decide quais (`pnpm test:e2e:affected`)".
|
||||
- **`.agents/skills/agent-work-issue/SKILL.md`**: delta pool — mesmo passo **quando o worktree tiver browsers Playwright**; Cursor Cloud sem browser → registra justificativa e segue (CI selected é a rede) — opção B da intenção com registro, não silêncio.
|
||||
- **`AGENTS.md`**: reescrever o parágrafo "O e2e não roda mais no gate local (OPS59)" → política OPS72: e2e afetado local = passo obrigatório da skill; CI de PR = selected; full = `verify` do deploy manual.
|
||||
- **`docs/AGENT-OPS.md`**: item "Agente faz sozinho" ganha o passo e2e local afetado; linha do `ci-pr.yml` na tabela CI passa a "e2e selected (nunca full — OPS72)".
|
||||
- **`.agents/rules/agent-pr-workflow.mdc`**: linha 41 (`pnpm gate:push` descrição) reflete a política nova.
|
||||
- **Migration:** sem migration. **Access / Consent:** n/a. **UI:** Impeccable A — sem UI.
|
||||
- **Changelog (OPS44):** `docs/changelog/2026-08-19-ops72.md` + `pnpm changelog:build` + `changelog:check`.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- N/a — não apresenta dados; apenas política/CI/skills.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **CI** — `.github/workflows/ci-pr.yml`: step full removido + comentários; `scripts/gate-ci.mjs`: mensagens (texto-only). Verificação: `pnpm exec prettier --check` nos arquivos; diff limpo.
|
||||
2. **Skills** — `execution-pipeline.md` (passo + seção #72), `work-issue/SKILL.md`, `agent-work-issue/SKILL.md`.
|
||||
3. **Docs** — `AGENTS.md`, `AGENT-OPS.md`, `agent-pr-workflow.mdc`.
|
||||
4. **Changelog** — entrada OPS72 + build + check.
|
||||
5. **Gates** — `pnpm gate:fast`; `pnpm format:check`; `pnpm test:unit --changed` (nenhum pin quebra — mensagens de console não são pinadas); e2e local afetado: o diff toca `scripts/gate-ci.mjs` (HIGH_RISK) → modo `full` → suíte full rodada (154 passed em dev; 8 flakes de ambiente dev triados — passam em `E2E_PROD=1`, o modo do CI); CI do PR não roda e2e (full por design desta política); `pnpm push`; PR Ready `Closes #98`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não tocar `ci-scope.mjs`, `e2e-affected.mjs`, `e2e-affected-manifest.mjs`, `test-affected-core.mjs`, `run-e2e-affected.mjs` — mecânica funciona; só muda onde/em que modo roda (fora de escopo da intenção).
|
||||
- Não implementar o fix da #72 (fica na fila; só documentação).
|
||||
- Não adicionar e2e ao `gate:push`/`gate:ci` (aceite explícito: passo da skill, não hook).
|
||||
- Não mexer no `deploy.yml` (já roda full no `verify`).
|
||||
- Não criar script novo — `pnpm test:e2e:affected` já existe.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Diff high-risk no CI sem e2e** (ex.: PR de schema): o PR não roda e2e no CI. Mitigação: espelho local roda full no mesmo diff; `verify` do deploy roda full antes de publicar; documentado no workflow e no gate-ci.
|
||||
- **Agente pula o e2e local**: discricionário por design (decisão do usuário) — o agente decide quais specs rodar e pode decidir não rodar. Mitigação: CI selected (blast radius) pega a superfície quando o diff é selecionável; full no `verify` do deploy fecha o ciclo antes de publicar.
|
||||
- **Mensagem do gate-ci desatualizada vs CI**: texto ajustado na mesma entrega; nada pinado.
|
||||
- **Serialização**: a intenção serializa `ci-pr.yml` e `.agents/skills/work-issue/`; OPS71 já mergeou — sem concorrência pendente (OPS72 é o primeiro a tocar no ci-pr.yml novo pós-OPS71).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (e2e local discricionário documentado na skill; CI/PR = blast-radius selected, nunca full; full na main antes do deploy; #72 documentada; gate local intocado)
|
||||
- [x] Invariantes AGENTS/engineering-standards (edita o dono — execution-pipeline é o corpo compartilhado; nada duplicado)
|
||||
- [x] Testes de domínio previstos: nenhum pin quebra (mensagens de console não pinadas); `test:e2e:affected` com modo `none` valida o fluxo novo
|
||||
BIN
docs/plans/ops73-deploy-admin-prod-evidence.png
Normal file
BIN
docs/plans/ops73-deploy-admin-prod-evidence.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
docs/plans/ops73-deploy-admin-prod-fixed-evidence.png
Normal file
BIN
docs/plans/ops73-deploy-admin-prod-fixed-evidence.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
77
docs/plans/ops73-deploy-admin-prod-impl.md
Normal file
77
docs/plans/ops73-deploy-admin-prod-impl.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# Impl: Admin do Payload segue em branco em produção — re-home S8/S9 no GitHub + fix importMap + deploy + verificação
|
||||
|
||||
Status: entregue (deploy `dba75ff8` em prod + verificação pós-deploy completa)
|
||||
Atualizado em: 2026-08-20
|
||||
Issue: #114
|
||||
Intenção: docs/plans/ops73-deploy-admin-prod.md
|
||||
Appetite restante: herdado (~0,5 dia); ajuste: +1 PR de re-home (premissa da intenção se provou falsa)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** `/admin` de produção renderiza o formulário de login (tela preta some); editor loga e abre uma coleção; log do container sem `getFromImportMap: PayloadComponent not found in importMap` para `@payloadcms/storage-s3/client#S3ClientUploadHandler`.
|
||||
- **O que NÃO negociar:** nada de schema/migration/Consent/UI nova nesta entrega; não reabrir o diagnóstico OPS69 (#77); não introduzir features novas; o deploy é do que já está aprovado.
|
||||
- **O que reavaliar (premissa FALSA):** a intenção afirma que "o fix OPS69 está em main desde 7162c65e e produção nunca o recebeu — basta deployar o main". **Verificado ao vivo (2026-08-19 23:56–2026-08-20 00:40), a premissa está errada em dois pontos:**
|
||||
1. **Produção JÁ recebeu um deploy às 23:09 UTC** (revision `8c734ba`, "docs(S8)", build 23:09:47, container up 23:09:48) — mas essa revision **não tem o handler no importMap**: o commit S9 (`8d2352c0`, captura de novidades na home) regenerou o importMap **sem** as envs `S3_*` e removeu as 2 linhas do fix OPS69. O erro `getFromImportMap` persiste no log desde o start do container e `/admin` continua em branco (0 inputs/botões/links; shell HTML ok, título "Painel de Controle - Payload"). A revision ANTERIOR (`fbfbfb1`) **tinha** o handler (2 matches) — confirma o relato "funcionou e no deploy seguinte parou de funcionar de novo".
|
||||
2. **O main do GitHub diverge do main do Forgejo (pós-cutover OPS71):** GitHub main = `3ea16243` (OPS69 fix com handler ✓, OPS71 infra ✓, plans S11/OPS73 ✓) mas **NÃO contém as features S8 (seção "Nossa história") e S9 (captura de novidades/newsletter)** que produção roda desde o deploy das 23:09 (árvore de 8c734ba tem `CampaignNewsletterForm.tsx`, `CampaignStorySection.tsx`, migration `20260819_192605_add_campaign_newsletter_capture`, e o GitHub main não — 404 via API; `git diff 8c734ba 3ea16243` mostra os arquivos como `D`). O Forgejo main congelou em `e339ceec` (S8/S9/S10); o workspace do homeserver ainda aponta `origin = http://localhost:3000/fsolla/teqo.git` (o Forgejo local) — por isso sua visão de "main" era o e339ceec. **Deployar o main do GitHub como está REGREDIRIA as features S8/S9 em produção** (some a seção "Nossa história" e o form de newsletter).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[cherry-pick S9 8d2352c0 + S8 488dcf7d sobre main 3ea16243] --> B[resolver conflito importMap: manter handler]
|
||||
B --> C[regen importMap com S3_* dummy: diff vazio = prova de consistencia]
|
||||
C --> D[gates locais: tsc/lint/format/knip/cycles/unit/int/build]
|
||||
D --> E[PR OPS73 em main com auto-merge]
|
||||
E --> F[confirmar no main: handler + S8/S9 presentes]
|
||||
F --> G[deploy via ssh homeserver: deploy-homeserver.sh sha]
|
||||
G --> H[verificar: revision label + log + /admin renderiza + home S8/S9 + editor loga]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C
|
||||
**Recomendação:** **A — PR único OPS73** que re-homa no GitHub main as features S8+S9 já aprovadas e mergeadas no Forgejo (cherry-pick dos commits de feature `8d2352c0` + `488dcf7d`), resolve o conflito do importMap **mantendo o handler** e prova consistência rodando `generate:importmap` com envs `S3_*` dummy (diff deve ser vazio) — porque: (1) o aceite "admin no ar" exige o handler no build; (2) o aceite "não regredir prod" exige S8/S9 no main antes do deploy; (3) o main do GitHub é a fonte da verdade do deploy (guard de HEAD + `TEQO_REPO_URL`); (4) cherry-pick do código já aprovado ≠ feature nova (anti-goal preservado: nada de design/schema novo).
|
||||
**Rejeitadas:**
|
||||
|
||||
- **B — PRs separados (re-home S8, re-home S9, depois fix/deploy):** ownership nominalmente mais limpo, mas 3 PRs em fila, admin fora do ar por mais tempo e o deploy continua dependendo do último; sem ganho de risco.
|
||||
- **C — deploy do main atual aceitando a regressão S8/S9:** regressão pública (seção "Nossa história" + form de novidades somem) — viola o aceite "o time editorial e de campanha volta a trabalhar" e reverteria features `in-prod` no tracker.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/app/(payload)/admin/importMap.js`** (gerado, commitado): estado final = com `@payloadcms/storage-s3/client#S3ClientUploadHandler` (idêntico ao de 3ea16243 — o conflito do cherry-pick do S9 resolve mantendo o handler; a regen com envs `S3_*` dummy fecha a prova de consistência com diff vazio).
|
||||
- **S9 (cherry-pick `8d2352c0`):** `CampaignNewsletterSection.tsx`, `submitCampaignNewsletter.ts`, `CampaignNewsletterForm.tsx`, `campaignConsentKeys.ts`, `schemas/campaignNewsletter.ts`, mudanças em `Contact.ts`/`Subscription.ts`/`CampaignHero.tsx`/`CitySelect.tsx`/`StateSelect.tsx`/`page.tsx`/`styles.css` + migration `20260819_192605_add_campaign_newsletter_capture` + docs/changelog. **ImportMap do commit NÃO entra (remover as -2 linhas é o que quebrou; manter o handler).**
|
||||
- **S8 (cherry-pick `488dcf7d`):** `CampaignStorySection.tsx`, `page.tsx` (+2), e2e `frontend.e2e.spec.ts`/`fixtures/e2eTest.ts`, `.gitignore`, plans/changelog.
|
||||
- **Migration:** o cherry-pick traz a do S9; o deploy aplica **zero migrations novas** vs prod (8c734ba já a tem aplicada — `20260819_192605` rodou no deploy das 23:09). Sem migration nova neste PR.
|
||||
- **Access / Consent:** não se aplica (S8/S9 já aprovados; `campaignConsentKeys.ts` é parte do S9 já mergeado no Forgejo).
|
||||
- **UI:** Impeccable A — nenhuma UI nova; features S8/S9 já aprovadas (copy aprovado nos respectivos PRs).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Cherry-picks + conflito** — `git cherry-pick 8d2352c0` (resolver importMap: manter o handler) e `488dcf7d` sobre `origin/main` (3ea16243); conferir que o diff final vs 3ea16243 contém S8/S9 completos (sem truncamentos) e que o importMap fica com handler. (Quota: ~30 min)
|
||||
2. **Prova de consistência do importMap** — `S3_BUCKET=teqo-media S3_ENDPOINT=http://localhost:3900 S3_ACCESS_KEY_ID=test S3_SECRET_ACCESS_KEY=test pnpm generate:importmap` → `git diff` do importMap deve ficar **vazio** (estado já consistente). Se aparecer drift não compreendido: parar e investigar (guard do OPS69).
|
||||
3. **Gates** — `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test` (unit+int), `pnpm build` (DB local). E2e: no CI do PR (frontend S8/S9 specs + admin).
|
||||
4. **PR → merge** — push da branch, PR para main, auto-merge (required check `CI (PR) / checks`); conferir no main pós-merge: `importMap.js` com handler, `CampaignNewsletterForm.tsx`/`CampaignStorySection.tsx` presentes, migration do S9 presente.
|
||||
5. **Deploy (caminho B — runner #113 ainda não instalado)** — `ssh homeserver 'bash ~/teqo-deploy/scripts/deploy-homeserver.sh <sha-do-main>'` (o script re-pointa o origin do workspace para o GitHub, guard de HEAD, flock, build migrator → migrate (no-op) → build runner → up → healthcheck → smoke). Executado pelo agente via SSH (acesso validado: `ssh homeserver` ok, docker ok, envs em `~/stack/teqo-1313.env`), com o humano acompanhando; alternativa: humano roda o mesmo comando. ~15–20 min.
|
||||
6. **Verificação pós-deploy** — (a) `docker inspect` revision label == sha deployado; (b) `docker logs teqo-1313 --since <start>` sem `getFromImportMap` para o handler (aceite #3 da intenção); (c) `/admin` de prod renderiza o formulário de login no browser (Playwright + screenshot → `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`); (d) home pública mantém seção "Nossa história" (S8) e form de novidades (S9); (e) **editor loga e abre uma coleção — passo manual do humano** (credenciais de prod não ficam com o agente).
|
||||
7. **Fechamento** — changelog `docs/changelog/2026-08-20-ops73.md` + `pnpm changelog:build`; comentários na Issue #114 com evidência; reforçar no AGENTS.md a lição do S9 (regen sem envs re-órfão o handler) e a necessidade do guard #87; registrar débito da divergência de plataformas (workspace homeserver aponta para Forgejo local; S10 re-home pendente — S10 NÃO está em prod nem no GitHub main, e sua Issue está in-prod no tracker).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Arqueologia do rewrite do main (S10 force-push/rebase sobre o Forgejo):** a divergência das plataformas está documentada acima; reconstituir quem-mergeou-onde não muda o plano. Corte: verificação por conteúdo (API + árvore), não por linhagem.
|
||||
- **Re-home do S10 (pixel):** S10 não está em prod (8c734ba só tem o `facebookPixel.ts` antigo do petition pages, `be1106f8`) nem no GitHub main — é PR próprio, fora deste escopo; registrar como débito.
|
||||
- **Instalar o runner (#113)/deploy automático:** fora de escopo (a intenção já corta; caminho B é o que desbloqueia hoje).
|
||||
- **Guard de CI do importMap (#87):** segue pendente; este incidente (regen do S9) é a segunda ocorrência da mesma classe — reforça a prioridade, não muda o escopo.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Main do GitHub anda durante o trabalho (agentes paralelos S10/S11):** re-verificar `git ls-remote origin main` antes do cherry-pick, antes do push e antes do deploy (o guard do script re-checa). Se o main mudar, rebase e re-valida.
|
||||
- **Conflito no cherry-pick além do importMap (page.tsx, Contact.ts, etc.):** base do S8/S9 = 693d2e48 (comum aos dois mains); o delta GitHub (OPS69/OPS71/plans) não toca os arquivos do S9 exceto importMap — conflitos esperados só no importMap; resolver mantendo o handler.
|
||||
- **Deploy falhar no meio:** rollback automático do script (restore do compose + up) + runbook `docs/ops/teqo-1313-deploy.md`; zero migrations novas → janela de rollback limpa.
|
||||
- **Admin continuar branco pós-deploy (fix insuficiente):** verificação por browser na fase 6 é o gate; se persistir, o erro no log apontará o próximo passo (o importMap está correto no main — cenário improvável, mas o log decide).
|
||||
- **Screenshot de evidência sem ferramenta de visão:** usar Playwright (DOM: inputs/buttons > 0) + `design-vision` para descrever a imagem.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: `/admin` renderiza login em prod; log sem o erro do handler; editor loga e abre coleção (humano)
|
||||
- [ ] Sem regressão: S8/S9 presentes no main do GitHub e servidos em prod (história + newsletter na home)
|
||||
- [ ] Invariantes AGENTS/engineering-standards: nada de schema/Consent/UI novo; importMap é arquivo gerado e commitado; gates verdes
|
||||
- [ ] Prova de consistência do importMap: regen com envs `S3_*` dummy → diff vazio
|
||||
- [ ] Changelog + Issue #114 documentada com evidência (antes/depois); débitos registrados (S10 re-home, guard #87 reforçado, workspace homeserver origin)
|
||||
74
docs/plans/ops73-deploy-admin-prod.md
Normal file
74
docs/plans/ops73-deploy-admin-prod.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# Admin do Payload segue em branco em produção — deploy do fix OPS69 + verificação pós-deploy
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #114
|
||||
Priority: P1
|
||||
Model: composer-2.5
|
||||
Impeccable: A — sem mudança de UI (deploy do que já está em main)
|
||||
Rascunho UI: N/A
|
||||
Appetite: ~0,5 dia — deploy + verificação funcional; um outcome verificável
|
||||
Responsável: —
|
||||
|
||||
## Intenção
|
||||
|
||||
O time editorial e de campanha está **sem CMS**: `/admin` em produção (jorgesolla1313.com.br) abre em branco (tela preta) — a página carrega, o shell hidrata, mas o formulário de login nunca renderiza. O bug raiz já foi diagnosticado e corrigido em `main` pelo **OPS69** (Issue #77, fechada): o `importMap` commitado ficou sem a entrada `@payloadcms/storage-s3/client#S3ClientUploadHandler` e o admin não monta as views quando o plugin `s3Storage` está ativo (envs `S3_*` de produção). O fix está em main desde `7162c65e` (2026-08-19).
|
||||
|
||||
**Mas produção nunca recebeu o fix.** O container roda um build de 2026-08-18 21:49 (`e6bd1d26`), anterior ao merge: o deploy automático antigo morreu (#85 OPS70 — runs agendados skipped) e o caminho novo (dispatch manual do `deploy.yml` no GitHub Actions) depende do runner self-hosted no homeserver, que ainda não está instalado (#113 OPS71-INFRA). A verificação pós-deploy do OPS69 (aceite do próprio plano) nunca rodou.
|
||||
|
||||
Verificado ao vivo em 2026-08-19: `/admin` de produção renderiza o shell vazio — **0 inputs, 0 botões, body sem texto** — mesmo sintoma do diagnóstico OPS69. `main` no GitHub hoje (`f5862c9b`) contém o fix.
|
||||
|
||||
## Persona e fluxo
|
||||
|
||||
- **Persona / contexto:** editor de conteúdo do mandato/campanha (staff que alimenta notícias e campanhas pelo Painel de Controle), bloqueado há ~1 dia.
|
||||
- **Job principal:** abrir o admin, logar e voltar a editar publicações — sem depender de infra nova.
|
||||
- **Fluxo desejado:** abre `jorgesolla1313.com.br/admin` → formulário de login aparece (não mais tela preta) → credenciais → dashboard com as coleções → consegue abrir uma coleção.
|
||||
- **Anti-goals de produto:** não rediagnosticar o bug (OPS69 já fechou o diagnóstico com evidência); não reabrir a Issue #77; não entregar nenhuma mudança de código nova — só publicar o que já está aprovado em main.
|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- `/admin` em produção renderiza o formulário de login para quem tem credencial (tela preta some).
|
||||
- O container de produção roda uma revisão que contém o fix do importMap (`7162c65e` ou posterior — HEAD atual `f5862c9b`).
|
||||
- O log do container de produção não re-emite `getFromImportMap: PayloadComponent not found in importMap` para o handler do storage.
|
||||
- Um editor loga e abre uma coleção sem editar nada (smoke mínimo de leitura).
|
||||
- Guardrail: nada de schema/migration/Consent/UI nesta entrega — é o deploy do que já está em `main`.
|
||||
|
||||
## Dados (intenção)
|
||||
|
||||
- **Vou apresentar dados?** Não — a verificação é funcional (render do login + log do container), nenhum número novo.
|
||||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- **Áreas prováveis:** `scripts/deploy-homeserver.sh` (o script de deploy — fonte da verdade do fluxo, roda no homeserver), `.github/workflows/deploy.yml` (dispatch manual `verify` → `deploy`), runbook `docs/ops/teqo-1313-deploy.md`, `src/app/(payload)/admin/importMap.js` (o fix já commitado — conferir a revision do container, não o arquivo).
|
||||
- **Precedente a olhar:** OPS69 (`docs/plans/ops69-admin-branco-importmap-s3*.md`, #77) — aceite de verificação pós-deploy pendente; OPS71 (cutover do deploy) e #113 (runner); OPS66 (ordem migrate→build já resolvida no script).
|
||||
- **Risco de acoplamento:** o deploy de `main` leva junto outras mudanças já aprovadas (ex.: S7, cards da home) — esperado e desejado (deploy do HEAD); o smoke do script cobre `/`, `/campanha/login`, `/admin`; rollback = restore do compose (runbook).
|
||||
|
||||
## Dependências
|
||||
|
||||
- Nenhuma dura. #113 (runner self-hosted) é o habilitador do caminho A, não pré-requisito — o caminho B desbloqueia sem ele.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Rediagnóstico do bug e guard de CI do importMap — #87 OPS70 já na fila (P2).
|
||||
- Instalação do runner self-hosted do GitHub — #113 (Issue própria).
|
||||
- Deploy contínuo automático — decisão OPS71 é deploy manual; #85 OPS70 (pipeline antigo) parece obsoleto pós-cutover — sugestão de fechamento à parte, não escopo daqui.
|
||||
|
||||
## Rabbit holes de produto
|
||||
|
||||
- **"A tela preta voltou → reabrir o diagnóstico."** Se alguém só completar o desejo: regressar ao OPS69. **Corte neste item:** conferir a revision do container antes de qualquer diagnóstico — a evidência (build de 2026-08-18 sem o fix, main com o fix desde 2026-08-19) já explica o sintoma.
|
||||
- **"Já que vou mexer, instalo o runner/automatizo tudo."** **Corte:** #113 (runner) e #87 (guard) são Issues separadas; este item entrega admin no ar.
|
||||
|
||||
## Questões em aberto (produto)
|
||||
|
||||
- **Caminho do deploy?** **Opções:** A) dispatch manual do `deploy.yml` no GitHub Actions (verify full ~50 min → deploy no runner do homeserver — exige instalar o runner, #113, antes); B) executar `scripts/deploy-homeserver.sh <sha>` direto no homeserver (mesmo script idempotente que o runner rodaria; smoke + rollback documentados; não espera #113); C) instalar o runner primeiro e depois A. **Recomendação:** **B agora** — admin caído = CMS indisponível; o script é o mesmo e o runbook cobre smoke e rollback; depois, o primeiro dispatch via A valida o pipeline OPS71. _(assumido — validar com produto)_
|
||||
- **Prioridade?** **Opções:** P0 (mesma dor do incidente original) | P1 (o fix já existe em main; resta entrega + verificação). **Recomendação:** **P1** — claimável já, sem precisar de re-P0 da fila. _(assumido — validar com produto)_
|
||||
- **Quem toca o homeserver?** O agente não tem acesso ao homeserver nem ao GitHub para dispatch sem ação do operador. **Opções:** o executor prepara os comandos do runbook e o humano roda (2 passos, ~5 min) | o executor dispara via GitHub API (`GITHUB_TOKEN` disponível) quando o runner estiver em pé (#113). **Recomendação:** humana para o caminho B imediato; o agente executa o restante (conferir SHA, verificar `/admin` no browser, checar log) e registra a evidência. _(assumido — validar com produto)_
|
||||
|
||||
## Referências
|
||||
|
||||
- OPS69: `docs/plans/ops69-admin-branco-importmap-s3.md` + `-impl.md`, Issue #77 (fechada — fix `7162c65e` em main)
|
||||
- Runbook de deploy: `docs/ops/teqo-1313-deploy.md` (smoke, rollback, falhas conhecidas)
|
||||
- OPS71 cutover: `docs/plans/ops71-ci-github-actions-tracker-forgejo.md`; runner: #113; guard de importMap: #87; pipeline antigo morto: #85
|
||||
- Evidência (gate): `docs/plans/ops73-deploy-admin-prod-evidence.png` — `/admin` de produção em 2026-08-19 (shell vazio, sem login)
|
||||
|
||||

|
||||
42
docs/plans/ops73-followup-rehome-s10-infra.md
Normal file
42
docs/plans/ops73-followup-rehome-s10-infra.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Follow-up pós-OPS73 — re-home S10 no GitHub, origin do workspace homeserver + fix da race de seedUser do e2e
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-20
|
||||
Issue: OPS73-FOLLOWUP (depends #114)
|
||||
Intenção: Origem — débitos colhidos no fechamento do OPS73 (#114)
|
||||
Appetite: ~0,4 dia (fase 1 dominante)
|
||||
|
||||
## Contexto
|
||||
|
||||
O OPS73 revelou que o cutover OPS71 **divergiu as plataformas**: o Forgejo main congelou em `e339ceec` (com S8/S9/S10) e o GitHub main recebeu OPS69/OPS71/OPS74 — as features S8/S9 foram re-homadas no OPS73 (PR #751), mas o **S10 (pixel Meta) não**. Além disso, duas pendências de infra/teste apareceram no ciclo.
|
||||
|
||||
## Já resolvido (não reabrir)
|
||||
|
||||
- Re-home do S8 (488dcf7d) e S9 (8d2352c0) no GitHub main → **PR #751** (merged `624126da`/rebase `dba75ff8`), admin fix + deploy verificado.
|
||||
- ImportMap com handler S3 no main (regen com envs dummy — diff vazio).
|
||||
|
||||
## Fases (mesma família: infra/deploy pós-cutover)
|
||||
|
||||
### F1 — Re-home do S10 (pixel Meta) no GitHub main + deploy
|
||||
|
||||
O S10 está `done`+`in-prod` no tracker (#94, PR #109 merged Forgejo `e339ceec`), mas o código **não está no GitHub main nem em prod** (a revision deployada `dba75ff8` não tem o pixel; `8c734ba` só tem o `facebookPixel.ts` antigo do petition, `be1106f8`). Cherry-pick do commit S10 (`e339ceec`) sobre o main do GitHub (mesma mecânica do OPS73), revisar a migration `20260819_213947_add_site_settings_facebook_pixel_id` (aditiva), rodar gates + e2e e deployar. Verificar `/campanha` com `SiteSettings.facebookPixelId` configurado → `#meta-pixel-*` presente.
|
||||
|
||||
### F2 — Apontar o origin do workspace do homeserver para o GitHub
|
||||
|
||||
`~/teqo-deploy` ainda tem `origin = http://localhost:3000/fsolla/teqo.git` (Forgejo local). O `deploy-homeserver.sh` já re-aponta para `TEQO_REPO_URL` (https://github.com/fsolla/teqo.git) a cada deploy (idempotente), mas o estado depois de um deploy devia ficar GitHub. Ação: `git -C ~/teqo-deploy remote set-url origin https://github.com/fsolla/teqo.git`. Sem code change.
|
||||
|
||||
### F3 — Race de `seedTestUser` no e2e full do GitHub CI
|
||||
|
||||
`tests/helpers/seedUser.ts` faz delete-then-create de `dev@payloadcms.com`. Com a suíte full no CI do GitHub (4 workers paralelos; `frontend`, `admin` e `campaignNewsletter` specs chamam `seedTestUser`), dois workers podem intercalar `delete`/`create` → `duplicate key value violates unique constraint "users_email_idx"` reportado como `ValidationError: email`. Mitigar: `on conflict` na criação ou serializar o seed (blocking lock), ou uma chamada com retry. Impacto: é o 2º sinal do e2e full (novo no pipeline GitHub); o deploy `verify` roda full e pode flakear por isso.
|
||||
|
||||
## Rabit holes / Explicitamente fora
|
||||
|
||||
- **Não** reabrir o diagnóstico do admin (OPS73 fechou).
|
||||
- **Não** "re-homar" o re-home do S8/S9 de novo (já em main).
|
||||
- Guard #87 (importMap sem envs S3\_) é Issue própria (já absorve a lição do S9); não duplicar aqui.
|
||||
|
||||
## Aceite
|
||||
|
||||
- S10 rodando em prod (pixel presente) e no GitHub main.
|
||||
- `~/teqo-deploy` com origin = GitHub após a mudança.
|
||||
- e2e full do GitHub CI estável no `seedTestUser` (sem flake de duplicate email).
|
||||
|
|
@ -6,5 +6,30 @@
|
|||
"url": "https://design.penpot.app/mcp/stream?userToken={file:.opencode/secrets/penpot-token}",
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"provider": {
|
||||
"cheapest-inference": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Cheapest Inference",
|
||||
"options": {
|
||||
"baseURL": "https://api.cheapestinference.com/v1"
|
||||
},
|
||||
"models": {
|
||||
"deepseek-v4-flash": {
|
||||
"name": "DeepSeek V4 Flash",
|
||||
"limit": {
|
||||
"context": 1000000,
|
||||
"output": 384000
|
||||
}
|
||||
},
|
||||
"mimo-v2.5": {
|
||||
"name": "MiMo v2.5",
|
||||
"limit": {
|
||||
"context": 1000000,
|
||||
"output": 128000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,155 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* ci-classify-production — OPS65 production-change gate for the main CI
|
||||
* window. Classifies whether the merge head differs from the deployed
|
||||
* artifact in anything that would enter the docker build context.
|
||||
*
|
||||
* The `.dockerignore` is the source of truth (the Dockerfile does `COPY . .`):
|
||||
* a changed path is production when it is NOT excluded by the ignore
|
||||
* patterns. The gate is FAIL-OPEN toward production: dispatch (explicit
|
||||
* operator intent) and an unknown deployed SHA always run the full suite;
|
||||
* a classification ERROR (git/.dockerignore unreadable, no input) prints
|
||||
* production=true AND exits 1, so the workflow run turns red — the suite
|
||||
* still runs (fail-open) but the deploy never happens silently. A green run
|
||||
* only ever means "the gate could classify and there is nothing to do".
|
||||
*
|
||||
* Plain Node, stdlib only — the gate must stay cheap (no pnpm install).
|
||||
*
|
||||
* node scripts/ci-classify-production.mjs --deployed <sha> [--value]
|
||||
* node scripts/ci-classify-production.mjs --files-from <file> [--value]
|
||||
*
|
||||
* Output: JSON { production, reason, changed, kept, skipped } on stdout
|
||||
* (`--value` prints only `true`/`false`). Exit 0 = classified; exit 1 =
|
||||
* could not classify (production=true printed, run should go red).
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { isDockerignored, parseDockerignore } from './lib/dockerignore.mjs'
|
||||
|
||||
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).replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
||||
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)
|
||||
const deployed = flags.deployed
|
||||
const filesFrom = flags.filesFrom
|
||||
const valueOnly = flags.value === true
|
||||
|
||||
/**
|
||||
* @param {string[]} changed - repo-relative changed paths
|
||||
* @param {import('./lib/dockerignore.mjs').DockerignoreRule[]} patterns -
|
||||
* .dockerignore rules
|
||||
* @returns {{ production: boolean, kept: string[], skipped: string[] }}
|
||||
*/
|
||||
export function classifyProduction(changed, patterns) {
|
||||
const kept = []
|
||||
const skipped = []
|
||||
for (const path of changed) {
|
||||
if (path.length === 0) continue
|
||||
if (isDockerignored(path, patterns)) skipped.push(path)
|
||||
else kept.push(path)
|
||||
}
|
||||
return { production: kept.length > 0, kept, skipped }
|
||||
}
|
||||
|
||||
const report = (production, reason, extra = {}) => {
|
||||
const result = { production, reason, ...extra }
|
||||
if (valueOnly) {
|
||||
console.log(production)
|
||||
return
|
||||
}
|
||||
console.log(JSON.stringify(result))
|
||||
}
|
||||
|
||||
/**
|
||||
* Could not classify at all (git/.dockerignore unreadable, no input): run
|
||||
* the full pipeline AND make the run red — a gate that cannot classify must
|
||||
* never look like "nothing to do".
|
||||
*/
|
||||
const failOpenRed = (reason) => {
|
||||
report(true, reason, { changed: [], kept: [], skipped: [] })
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
// Import-safe: the module is unit-tested (classifyProduction), so the CLI
|
||||
// body only runs when executed as a script.
|
||||
const isCli =
|
||||
typeof process.argv[1] === 'string' && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
if (isCli) {
|
||||
main()
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (process.env.GITHUB_EVENT_NAME === 'workflow_dispatch') {
|
||||
report(true, 'workflow_dispatch (intenção explícita)')
|
||||
return
|
||||
}
|
||||
|
||||
let changed
|
||||
try {
|
||||
if (filesFrom) {
|
||||
changed = readFileSync(filesFrom, 'utf8').split('\n').filter(Boolean)
|
||||
} else if (deployed === '') {
|
||||
// The workflow always passes --deployed (possibly empty: no container
|
||||
// revision on the homeserver). An empty deployed SHA is a legitimate
|
||||
// "unknown deployed state" — run the full pipeline, keep the run green.
|
||||
report(true, 'sem SHA deployado (estado desconhecido)', {
|
||||
changed: [],
|
||||
kept: [],
|
||||
skipped: [],
|
||||
})
|
||||
return
|
||||
} else if (deployed === true) {
|
||||
failOpenRed('--deployed sem valor')
|
||||
return
|
||||
} else if (deployed) {
|
||||
changed = execFileSync('git', ['diff', '--name-only', deployed, 'HEAD', '--'], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
} else {
|
||||
failOpenRed('sem SHA deployado nem files-from')
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
failOpenRed(`erro ao obter o diff (${error instanceof Error ? error.message : error})`)
|
||||
return
|
||||
}
|
||||
|
||||
if (changed.length === 0) {
|
||||
report(false, 'diff vazio (nada a fazer)', {
|
||||
changed: [],
|
||||
kept: [],
|
||||
skipped: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let rules
|
||||
try {
|
||||
rules = parseDockerignore(readFileSync('.dockerignore', 'utf8'))
|
||||
} catch (error) {
|
||||
failOpenRed(`.dockerignore ilegível (${error instanceof Error ? error.message : error})`)
|
||||
return
|
||||
}
|
||||
|
||||
const classified = classifyProduction(changed, rules)
|
||||
report(
|
||||
classified.production,
|
||||
classified.production ? 'mudança de produção desde o último deploy' : 'sem mudança de produção',
|
||||
{ changed, kept: classified.kept, skipped: classified.skipped },
|
||||
)
|
||||
}
|
||||
|
|
@ -1,38 +1,45 @@
|
|||
/**
|
||||
* Drift repair for branch protection (main-only) — Forgejo. Idempotent: reads
|
||||
* the existing rules and creates / updates / no-ops (never duplicates).
|
||||
* Drift repair for `main` branch protection — GitHub (OPS71). Idempotent:
|
||||
* reads the existing rule and creates / updates / no-ops (never duplicates).
|
||||
*
|
||||
* pnpm configure:branch-protection # apply
|
||||
* pnpm configure:branch-protection -- --dry-run
|
||||
* GITHUB_TOKEN=<PAT> pnpm configure:branch-protection # apply
|
||||
* GITHUB_TOKEN=<PAT> pnpm configure:branch-protection -- --dry-run
|
||||
*
|
||||
* main: required status check `CI (PR) / checks*` (the ci-pr cascade rollup —
|
||||
* OPS61: Forgejo matches the contexts as globs against the real status
|
||||
* contexts like `CI (PR) / checks (pull_request)`, so the old literal `checks`
|
||||
* would match nothing), strict=false, 0 reviews. Feature PRs auto-merge when
|
||||
* that context is green; the server rule is the final defense against merges
|
||||
* before the cascade settles (the waitForChecks rollup gate is the client
|
||||
* half).
|
||||
* (migration-lock removed 2026-08-12 — see ci-pr.yml header.)
|
||||
* Desired rule: required check-run `CI (PR) / checks` (the ci-pr cascade —
|
||||
* OPS62), strict=false, 0 reviews, `enforce_admins: true` (nobody, admin
|
||||
* included, merges with a red required check). Feature PRs auto-merge when
|
||||
* that check is green; the server rule is the final defense against every
|
||||
* merge path, manual API merges included.
|
||||
*/
|
||||
|
||||
import { parseArgs } from './lib/agent-forgejo.mjs'
|
||||
import { DESIRED_RULE, planBranchProtectionRule } from './lib/branch-protection.mjs'
|
||||
import { createApi } from './lib/forgejo-api.mjs'
|
||||
import { createApi } from './lib/github-api.mjs'
|
||||
import { DESIRED_RULE, planBranchProtectionRule } from './lib/github-branch-protection.mjs'
|
||||
|
||||
const { flags } = parseArgs(process.argv.slice(2), new Set())
|
||||
const dryRun = flags['dry-run'] === true
|
||||
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).replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
||||
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)
|
||||
const dryRun = flags.dryRun === true
|
||||
const branch = 'main'
|
||||
|
||||
const api = createApi({})
|
||||
const repo = process.env.FORGEJO_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? 'fsolla/teqo'
|
||||
const ruleName = DESIRED_RULE.rule_name
|
||||
|
||||
const existingRules = await api.listBranchProtections()
|
||||
const existing =
|
||||
existingRules.find((rule) => rule.rule_name === ruleName || rule.branch_name === ruleName) ?? null
|
||||
const repo = process.env.GITHUB_REPOSITORY ?? 'fsolla/teqo'
|
||||
const existing = await api.getBranchProtection(branch)
|
||||
const { action } = planBranchProtectionRule(existing)
|
||||
|
||||
console.log(
|
||||
`[configure:branch-protection] repo ${repo}${dryRun ? ' (dry-run)' : ''} — regra "${ruleName}": ${
|
||||
`[configure:branch-protection] repo ${repo}${dryRun ? ' (dry-run)' : ''} — regra "${branch}": ${
|
||||
action === 'noop'
|
||||
? 'já conforme — nada a fazer'
|
||||
: action === 'update'
|
||||
|
|
@ -42,34 +49,26 @@ console.log(
|
|||
)
|
||||
|
||||
if (action === 'noop') {
|
||||
console.log(`[configure:branch-protection] no-op: ${JSON.stringify(DESIRED_RULE)}`)
|
||||
console.log(`[configure:branch-protection] no-op: ${JSON.stringify(existing)}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const payload = { ...DESIRED_RULE, branch_name: ruleName }
|
||||
const target = action === 'create' ? '/branch_protections' : `/branch_protections/${ruleName}`
|
||||
const method = action === 'create' ? 'POST' : 'PATCH'
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`[dry-run] ${method} /repos/${repo}${target} ${JSON.stringify(payload)}`)
|
||||
console.log(
|
||||
`[dry-run] PUT /repos/${repo}/branches/${branch}/protection ${JSON.stringify(DESIRED_RULE)}`,
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (action === 'create') {
|
||||
await api.updateBranchProtection(ruleName, payload)
|
||||
} else {
|
||||
await api.editBranchProtection(ruleName, payload)
|
||||
}
|
||||
await api.updateBranchProtection(DESIRED_RULE, branch)
|
||||
|
||||
const after = await api.listBranchProtections()
|
||||
const applied =
|
||||
after.find((rule) => rule.rule_name === ruleName || rule.branch_name === ruleName) ?? null
|
||||
if (!applied) {
|
||||
const after = await api.getBranchProtection(branch)
|
||||
if (!after) {
|
||||
console.error(
|
||||
`[configure:branch-protection] FALHOU: ${method} ${target} respondeu mas a regra "${ruleName}" não aparece no servidor.`,
|
||||
`[configure:branch-protection] FALHOU: PUT respondeu mas a regra "${branch}" não aparece no servidor.`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(
|
||||
`[configure:branch-protection] done — regra verificada no servidor: ${JSON.stringify(applied)}`,
|
||||
`[configure:branch-protection] done — regra verificada no servidor: ${JSON.stringify(after)}`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@
|
|||
#
|
||||
# Deploy teqo-1313 to the homeserver (OPS53).
|
||||
#
|
||||
# Runs ON the homeserver, piped by the CI deploy job:
|
||||
# ssh homeserver "bash -s -- <commit-sha>" < scripts/deploy-homeserver.sh
|
||||
# OPS71: runs ON the homeserver via the GitHub self-hosted runner (deploy job
|
||||
# of .github/workflows/deploy.yml):
|
||||
# bash scripts/deploy-homeserver.sh <commit-sha>
|
||||
# (The Forgejo-era invocation `ssh homeserver "bash -s -- <sha>" < script`
|
||||
# is gone — no SSH hop, no workstation involvement.)
|
||||
#
|
||||
# Flow: HEAD guard (only the current main HEAD deploys) -> flock
|
||||
# serialization -> workspace fetch at <sha> -> docker login (local registry)
|
||||
|
|
@ -17,14 +20,16 @@
|
|||
# the running site untouched and the job red.
|
||||
#
|
||||
# Environment defaults assume the homeserver layout: stack under
|
||||
# $HOME/stack, repo cloned from the local Forgejo. Secrets are sourced from
|
||||
# the chmod-600 env files and never echoed (no `set -x`, passwords only via
|
||||
# --password-stdin / build secrets).
|
||||
# $HOME/stack, repo cloned from github.com/fsolla/teqo (public — no
|
||||
# credentials; the pre-OPS71 clone pointed at the local Forgejo and is
|
||||
# re-pointed idempotently below). Secrets are sourced from the chmod-600 env
|
||||
# files and never echoed (no `set -x`, passwords only via --password-stdin /
|
||||
# build secrets).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SHA="${1:?usage: deploy-homeserver.sh <commit-sha>}"
|
||||
TEQO_REPO_URL="${TEQO_REPO_URL:-http://localhost:3000/fsolla/teqo.git}"
|
||||
TEQO_REPO_URL="${TEQO_REPO_URL:-https://github.com/fsolla/teqo.git}"
|
||||
STACK_DIR="${STACK_DIR:-$HOME/stack}"
|
||||
WORKSPACE_DIR="${WORKSPACE_DIR:-$HOME/teqo-deploy}"
|
||||
DEPLOY_LOCK="${DEPLOY_LOCK:-/tmp/teqo-1313-deploy.lock}"
|
||||
|
|
@ -88,6 +93,14 @@ if [ ! -d "$WORKSPACE_DIR/.git" ]; then
|
|||
say "cloning $TEQO_REPO_URL into $WORKSPACE_DIR"
|
||||
git clone "$TEQO_REPO_URL" "$WORKSPACE_DIR"
|
||||
else
|
||||
# OPS71: the pre-cutover clone pointed at the local Forgejo — re-point
|
||||
# idempotently so the HEAD guard and the fetch compare against the repo
|
||||
# that actually received the merge.
|
||||
current_url="$(git -C "$WORKSPACE_DIR" remote get-url origin 2>/dev/null || true)"
|
||||
if [ -n "$current_url" ] && [ "$current_url" != "$TEQO_REPO_URL" ]; then
|
||||
say "updating workspace origin: $current_url → $TEQO_REPO_URL"
|
||||
git -C "$WORKSPACE_DIR" remote set-url origin "$TEQO_REPO_URL"
|
||||
fi
|
||||
git -C "$WORKSPACE_DIR" fetch origin main
|
||||
fi
|
||||
git -C "$WORKSPACE_DIR" checkout --detach "$SHA" || fatal "checkout of $SHA failed"
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
/**
|
||||
* Plain-Node CLI (no pnpm) — dispatch a Forgejo workflow via workflow_dispatch.
|
||||
* Used by CI jobs (requeue) and humans.
|
||||
*
|
||||
* node scripts/forgejo-dispatch.mjs <workflow-file> <ref> [key=value …]
|
||||
*/
|
||||
|
||||
import { createApi } from './lib/forgejo-api.mjs'
|
||||
|
||||
const [, , workflowFile, ref = 'main', ...inputPairs] = process.argv
|
||||
if (!workflowFile) {
|
||||
console.error('Usage: node scripts/forgejo-dispatch.mjs <workflow-file> [ref] [key=value …]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const inputs = {}
|
||||
for (const pair of inputPairs) {
|
||||
const eq = pair.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
inputs[pair.slice(0, eq)] = pair.slice(eq + 1)
|
||||
}
|
||||
|
||||
const api = createApi({})
|
||||
try {
|
||||
await api.workflowDispatch(workflowFile, { ref, inputs })
|
||||
console.log(
|
||||
`[forgejo-dispatch] ${workflowFile}@${ref} enviado${Object.keys(inputs).length > 0 ? ` (${Object.keys(inputs).join(', ')})` : ''}`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`[forgejo-dispatch] falhou: ${error instanceof Error ? error.message : error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
@ -2,8 +2,12 @@
|
|||
* Plain-Node CLI (no pnpm) — on merge to main, flip Issues cited as
|
||||
* `Closes #N` / `Fixes #N` in the PR body to `done` + `in-prod` and comment.
|
||||
* Replacement for the `gh issue edit/comment` loop of issue-done-on-main-merge.
|
||||
* The PR body comes from the API (Forgejo's PR webhook payload is not relied
|
||||
* on), so the workflow only needs the PR number.
|
||||
*
|
||||
* OPS71: the PR now lives on GitHub. The workflow passes the PR body via the
|
||||
* `PR_BODY` env (from the GitHub event payload) and the script flips Issues
|
||||
* on the Forgejo tracker by API — no Forgejo PR read when PR_BODY is present.
|
||||
* Without PR_BODY the script falls back to reading the PR from the Forgejo
|
||||
* API (the Forgejo-era workflows stay alive until the OPS71 Fase 2 removal).
|
||||
*
|
||||
* OPS61: a failed flip exits 1 — the flip is the whole purpose of this
|
||||
* workflow, and the 403 era proved that a swallowed error ends the job
|
||||
|
|
@ -11,6 +15,7 @@
|
|||
* (the loop does not abort), but the job goes red so the breakage is visible.
|
||||
*
|
||||
* node scripts/forgejo-issue-transition.mjs --pr <N>
|
||||
* PR_BODY='Closes #97' node scripts/forgejo-issue-transition.mjs --pr <N>
|
||||
*/
|
||||
|
||||
import { createApi } from './lib/forgejo-api.mjs'
|
||||
|
|
@ -37,7 +42,11 @@ if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
|||
}
|
||||
|
||||
const api = createApi({})
|
||||
const pr = await api.getPullRequest(prNumber)
|
||||
const bodyFromEnv = process.env.PR_BODY
|
||||
const pr =
|
||||
typeof bodyFromEnv === 'string'
|
||||
? { merged: true, body: bodyFromEnv }
|
||||
: await api.getPullRequest(prNumber)
|
||||
if (!pr) {
|
||||
console.error(`[forgejo-issue-transition] PR #${prNumber} não encontrada`)
|
||||
process.exit(1)
|
||||
|
|
@ -62,9 +71,12 @@ for (const number of numbers) {
|
|||
await api.setLabels(number, { add: ['done', 'in-prod'], remove: ['in-progress'] })
|
||||
await api.addComment(
|
||||
number,
|
||||
`Merged em main via PR #${prNumber} — \`done\` + \`in-prod\`. Deploy de produção segue o verificador \`ci.yml\` (gated).`,
|
||||
`Merged em main via PR #${prNumber} — \`done\` + \`in-prod\` + Issue fechada. Deploy de produção é manual (workflow_dispatch no GitHub Actions) — ver docs/AGENT-OPS.md.`,
|
||||
)
|
||||
console.log(`[forgejo-issue-transition] #${number}: done + in-prod`)
|
||||
// OPS71-FLIP: o PR vive no GitHub e não fecha a Issue no Forgejo como o
|
||||
// merge nativo fazia na era Forgejo — o close é explícito aqui.
|
||||
await api.closeIssue(number)
|
||||
console.log(`[forgejo-issue-transition] #${number}: done + in-prod + fechada`)
|
||||
} catch (error) {
|
||||
flipFailed = true
|
||||
console.error(
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
/**
|
||||
* Plain-Node CLI (no pnpm) — PR safety net: wait for checks and merge by
|
||||
* rebase (the Forgejo equivalent of `gh pr merge --auto --rebase`, which the
|
||||
* Forgejo API does not offer as a scheduled operation). Idempotent: already
|
||||
* merged → no-op success. The verdict comes from re-reading the PR (Forgejo's
|
||||
* merge endpoint answers 200 + empty body on success, so the POST response
|
||||
* alone cannot prove the merge) — "já mergeado" is only printed for a PR the
|
||||
* server actually reports as merged; a failed merge exits 1 loudly.
|
||||
*
|
||||
* Draft policy (OPS57): only `cursor/*` heads get marked ready. A draft from
|
||||
* any other branch is the actor's veto — the CLI skips it (exit 0) so the
|
||||
* workflow never forces a human/plans PR to ready.
|
||||
*
|
||||
* node scripts/forgejo-pr-automerge.mjs --pr <N>
|
||||
*/
|
||||
|
||||
import { createApi } from './lib/forgejo-api.mjs'
|
||||
|
||||
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)
|
||||
const prNumber = Number(flags.pr)
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||
console.error('Usage: node scripts/forgejo-pr-automerge.mjs --pr <N>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const api = createApi({})
|
||||
try {
|
||||
const pr = await api.getPullRequest(prNumber)
|
||||
if (!pr || pr.state !== 'OPEN') {
|
||||
console.log(
|
||||
`[forgejo-pr-automerge] PR #${prNumber} não está aberto (${pr?.state ?? 'inexistente'}) — skip`,
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
if (pr.base.ref !== 'main') {
|
||||
console.log(`[forgejo-pr-automerge] PR #${prNumber} base é ${pr.base.ref} (não main) — skip`)
|
||||
process.exit(0)
|
||||
}
|
||||
if (pr.isDraft) {
|
||||
if (!pr.head.ref.startsWith('cursor/')) {
|
||||
console.log(
|
||||
`[forgejo-pr-automerge] PR #${prNumber} é draft fora de cursor/* — skip (veto do ator)`,
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
console.log(`[forgejo-pr-automerge] PR #${prNumber} é draft — marcando Ready`)
|
||||
await api.markPullRequestReady(prNumber)
|
||||
} else {
|
||||
console.log(`[forgejo-pr-automerge] PR #${prNumber} já Ready`)
|
||||
}
|
||||
const {
|
||||
attempted,
|
||||
merged,
|
||||
pr: after,
|
||||
} = await api.autoMerge(prNumber, {
|
||||
// OPS61: the wait now covers the whole CI (PR) cascade (the `checks`
|
||||
// rollup only posts after every ci-pr job), so the 30 min default was too
|
||||
// tight under load — e2e shards + int + build run before the rollup.
|
||||
timeoutMs: 45 * 60 * 1000,
|
||||
log: (line) => console.log(`[forgejo-pr-automerge] ${line}`),
|
||||
})
|
||||
if (merged) {
|
||||
console.log(
|
||||
attempted
|
||||
? `[forgejo-pr-automerge] PR #${prNumber} mergeado (rebase)`
|
||||
: `[forgejo-pr-automerge] PR #${prNumber} já mergeado`,
|
||||
)
|
||||
} else {
|
||||
console.log(
|
||||
after?.state === 'CLOSED'
|
||||
? `[forgejo-pr-automerge] PR #${prNumber} fechado sem merge — skip`
|
||||
: `[forgejo-pr-automerge] PR #${prNumber} voltou a draft — skip (veto do ator)`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[forgejo-pr-automerge] falhou: ${error instanceof Error ? error.message : error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Local mirror of `.forgejo/workflows/ci-pr.yml` (serial): phase-1 cheap checks,
|
||||
* Local mirror of `.github/workflows/ci-pr.yml` (serial): phase-1 cheap checks,
|
||||
* then phase-2 expensive (int/build). e2e is NOT part of the local mirror
|
||||
* (OPS59): it runs once, in CI (PR job `e2e` → `checks`; main `ci.yml` e2e →
|
||||
* `checks` → `deploy`). Local optional feedback: `pnpm test:e2e:affected`.
|
||||
* (OPS59 + OPS72): the PR CI runs only the e2e blast radius (mode `selected`,
|
||||
* never `full`), the deploy `verify` job runs the full suite before
|
||||
* publishing, and the local affected run is a skill step (discretionary):
|
||||
* `pnpm test:e2e:affected`.
|
||||
* Docs guards (OPS63): the `docs-guards` checks (changelog append-only,
|
||||
* aggregate sync, conflict markers) also run here — cheap git diffs, same
|
||||
* scripts the CI job runs (the `changelog-rewrite:` escape stays CI-only,
|
||||
|
|
@ -77,7 +79,7 @@ const main = async () => {
|
|||
const scope = readJsonScript('scripts/ci-scope.mjs')
|
||||
|
||||
console.log(
|
||||
`[gate:ci] scope base=${scope.base} code=${scope.code.mode} build=${scope.build.mode} test=${scope.test.mode} e2e=${scope.e2e.mode} (e2e runs in CI, not in this gate — OPS59)`,
|
||||
`[gate:ci] scope base=${scope.base} code=${scope.code.mode} build=${scope.build.mode} test=${scope.test.mode} e2e=${scope.e2e.mode} (e2e not in this gate — OPS72; local affected is a skill step)`,
|
||||
)
|
||||
|
||||
// --- Phase 1 (cheap) ---
|
||||
|
|
@ -152,14 +154,18 @@ const main = async () => {
|
|||
|
||||
if (scope.e2e.mode === 'none') {
|
||||
console.log('\n[gate:ci] ⊘ e2e: CI will skip (no e2e blast radius)')
|
||||
} else if (scope.e2e.mode === 'selected') {
|
||||
console.log(
|
||||
`\n[gate:ci] ▶ e2e: CI runs the blast radius (${scope.e2e.specs.join(', ')}) — not in this gate (OPS72); full lives in deploy verify`,
|
||||
)
|
||||
} else {
|
||||
const specs =
|
||||
scope.e2e.mode === 'full' ? 'full suite' : `selected: ${scope.e2e.specs.join(', ')}`
|
||||
console.log(`\n[gate:ci] ▶ e2e: runs in CI (job e2e, ${specs}) — not in this gate (OPS59)`)
|
||||
console.log(
|
||||
'\n[gate:ci] ▶ e2e: diff is high-risk (full mode) — no e2e in this PR by design (OPS72); run `pnpm test:e2e:affected` locally; deploy verify runs full before publishing',
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\n[gate:ci] ✓ all checks passed (ci-pr mirror: docs guards local — OPS63; e2e verified in CI)',
|
||||
'\n[gate:ci] ✓ all checks passed (ci-pr mirror: docs guards local — OPS63; e2e: local affected is a skill step — OPS72)',
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
86
scripts/github-pr-automerge.mjs
Normal file
86
scripts/github-pr-automerge.mjs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Plain-Node CLI (no pnpm) — GitHub PR safety net: mark `cursor/*` drafts
|
||||
* ready and arm GitHub's native auto-merge (rebase). Runs in
|
||||
* .github/workflows/agent-pr-ready-automerge.yml. The auto-merge arming uses
|
||||
* the AUTOMERGE_PAT (real user PAT) — never the built-in GITHUB_TOKEN, whose
|
||||
* actor (github-actions[bot]) would merge without creating workflow runs for
|
||||
* the `closed` event (OPS71-FLIP; anti-recursion — silent broken flips).
|
||||
* Mark-ready keeps the run token (nothing consumes its event).
|
||||
*
|
||||
* Unlike the Forgejo-era automerger (removed in OPS71 Fase 2), there is NO
|
||||
* poll loop: `enablePullRequestAutoMerge` is server-side — GitHub only merges
|
||||
* when every required check (incl. `CI (PR) / checks`) is green and the PR is
|
||||
* mergeable. The OPS64 "never trust the rollup" pin is structural here: a
|
||||
* single sequential job posts one honest check-run, and the server enforces
|
||||
* it. Draft policy (OPS57) preserved: only `cursor/*` heads are marked
|
||||
* ready — any other draft is the actor's veto (skip, exit 0).
|
||||
*
|
||||
* Idempotent: already merged / closed / non-main base → no-op success.
|
||||
* A GraphQL error (e.g. repo auto-merge disabled, PR in conflict) exits 1 so
|
||||
* the job goes red and the situation is visible.
|
||||
*
|
||||
* node scripts/github-pr-automerge.mjs --pr <N>
|
||||
*/
|
||||
|
||||
import { createApi } from './lib/github-api.mjs'
|
||||
import { automergeArmingToken, decideAutomergeAction } from './lib/github-pr-flow.mjs'
|
||||
|
||||
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)
|
||||
const prNumber = Number(flags.pr)
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||
console.error('Usage: node scripts/github-pr-automerge.mjs --pr <N>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const api = createApi({})
|
||||
// OPS71-FLIP: the native auto-merge MUST be armed with a real user PAT, not
|
||||
// the built-in GITHUB_TOKEN. A merge performed through the GITHUB_TOKEN actor
|
||||
// (github-actions[bot]) never creates workflow runs for its events (GitHub
|
||||
// anti-recursion — the `pull_request` `closed` is recorded in the timeline
|
||||
// but no run is created), so the post-merge flips would silently never run
|
||||
// (live finding: PR #746). With a PAT the merge is attributed to its owner (a
|
||||
// real user) and the `closed` event creates runs again (PR #742 behavior).
|
||||
// Fail-closed: no AUTOMERGE_PAT → exit 1 — never fall back to GITHUB_TOKEN.
|
||||
const arming = automergeArmingToken(process.env)
|
||||
if (!arming.ok) {
|
||||
console.error(`[github-pr-automerge] ${arming.reason}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const automergeApi = createApi({ token: arming.token })
|
||||
try {
|
||||
const pr = await api.getPullRequest(prNumber)
|
||||
const verdict = decideAutomergeAction(pr)
|
||||
|
||||
if (verdict.action === 'skip') {
|
||||
console.log(`[github-pr-automerge] PR #${prNumber}: skip (${verdict.reason})`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (verdict.action === 'mark-ready') {
|
||||
console.log(`[github-pr-automerge] PR #${prNumber} é draft cursor/* — marcando Ready`)
|
||||
await api.markPullRequestReady(prNumber)
|
||||
}
|
||||
|
||||
console.log(`[github-pr-automerge] PR #${prNumber}: armando auto-merge (rebase)`)
|
||||
await automergeApi.enableAutoMerge(pr.nodeId)
|
||||
console.log(
|
||||
`[github-pr-automerge] PR #${prNumber}: auto-merge armado — o GitHub mergea quando o required check "CI (PR) / checks" fica verde`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`[github-pr-automerge] falhou: ${error instanceof Error ? error.message : error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
64
scripts/github-pr.mjs
Normal file
64
scripts/github-pr.mjs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Plain-Node CLI (no pnpm) — create a GitHub PR (Ready, never draft) for
|
||||
* agent deliveries. Replaces the Forgejo MCP `create_pull_request` in the
|
||||
* delivery flow (OPS71: PRs live on GitHub; the tracker stays on Forgejo).
|
||||
*
|
||||
* GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs \
|
||||
* --head <branch> --title "<título>" --body-file <path>
|
||||
* GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs --head <b> --title <t> --body "Closes #N"
|
||||
*
|
||||
* The repo rule "PRs are Ready, never draft" is structural: there is no
|
||||
* `--draft` flag. Base defaults to `main`.
|
||||
*/
|
||||
|
||||
import { createApi } from './lib/github-api.mjs'
|
||||
|
||||
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)
|
||||
const head = flags.head
|
||||
const title = flags.title
|
||||
const base = flags.base ?? 'main'
|
||||
|
||||
if (!head || !title) {
|
||||
console.error(
|
||||
'Usage: node scripts/github-pr.mjs --head <branch> --title <t> [--base main] --body "<text>" | --body-file <path>',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let body = flags.body
|
||||
if (flags['body-file']) {
|
||||
try {
|
||||
const { readFileSync } = await import('node:fs')
|
||||
body = readFileSync(flags['body-file'], 'utf8')
|
||||
} catch (error) {
|
||||
console.error(`[github-pr] não consegui ler --body-file: ${error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
if (typeof body !== 'string' || body.length === 0) {
|
||||
console.error('[github-pr] body vazio — passe --body ou --body-file')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const api = createApi({})
|
||||
try {
|
||||
const pr = await api.createPullRequest({ head, base, title, body })
|
||||
console.log(`[github-pr] PR #${pr.number} criado (Ready): ${pr.htmlUrl}`)
|
||||
} catch (error) {
|
||||
console.error(`[github-pr] falhou: ${error instanceof Error ? error.message : error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
/**
|
||||
* Pure decision for the main branch protection rule (OPS61).
|
||||
*
|
||||
* The desired rule is the repo contract: main requires the CI (PR) cascade
|
||||
* rollup as the only status check (`strict=false`, 0 reviews). The context is
|
||||
* a glob on purpose — Forgejo matches `status_check_contexts` with gobwas/glob
|
||||
* against the real commit-status contexts (`CI (PR) / checks (pull_request)` —
|
||||
* the ` (event)` suffix is part of the context), so the literal `checks` would
|
||||
* match nothing and block every merge.
|
||||
*/
|
||||
|
||||
export const DESIRED_RULE = Object.freeze({
|
||||
rule_name: 'main',
|
||||
enable_status_check: true,
|
||||
status_check_contexts: ['CI (PR) / checks*'],
|
||||
enable_push: false,
|
||||
required_approvals: 0,
|
||||
dismiss_stale_approvals: false,
|
||||
})
|
||||
|
||||
const COMPARED_FIELDS = [
|
||||
'rule_name',
|
||||
'enable_status_check',
|
||||
'status_check_contexts',
|
||||
'enable_push',
|
||||
'required_approvals',
|
||||
'dismiss_stale_approvals',
|
||||
]
|
||||
|
||||
const sameStrings = (have, want) =>
|
||||
have.length === want.length &&
|
||||
want.every((value) => have.includes(value)) &&
|
||||
have.every((value) => want.includes(value))
|
||||
|
||||
/**
|
||||
* True when the existing rule (from the API, already normalized) matches the
|
||||
* desired rule on every compared field — array fields as sets.
|
||||
*/
|
||||
export const ruleMatches = (existing, desired = DESIRED_RULE) => {
|
||||
if (!existing) return false
|
||||
return COMPARED_FIELDS.every((field) => {
|
||||
const want = desired[field]
|
||||
const have = existing[field]
|
||||
if (Array.isArray(want)) return Array.isArray(have) && sameStrings(have, want)
|
||||
return have === want
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* What to do with the rule: `create` (no rule), `update` (rule exists with
|
||||
* drift), `noop` (already compliant). Idempotent re-runs land on `noop`.
|
||||
*
|
||||
* @param {object|null} existing - normalized rule from the API, or null
|
||||
* @param {object} [desired]
|
||||
* @returns {{ action: 'create' | 'update' | 'noop' }}
|
||||
*/
|
||||
export const planBranchProtectionRule = (existing, desired = DESIRED_RULE) => {
|
||||
if (ruleMatches(existing, desired)) return { action: 'noop' }
|
||||
return { action: existing ? 'update' : 'create' }
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
/**
|
||||
* dockerignore match — pure `.dockerignore` semantics (moby patternmatcher
|
||||
* subset) for the CI production-change gate (OPS65). No git, no fs, no deps.
|
||||
*
|
||||
* The gate classifies "production change" by asking: would this path enter the
|
||||
* docker build context? The `.dockerignore` is the source of truth for the
|
||||
* artifact (the Dockerfile does `COPY . .`), so a path is production when it
|
||||
* is NOT excluded by the ignore patterns.
|
||||
*
|
||||
* Implemented rules (moby semantics):
|
||||
* - blank lines and `#` comments are ignored
|
||||
* - `!` prefixes a negation (re-include); last match wins
|
||||
* - trailing `/` is a directory pattern (the dir itself and its tree)
|
||||
* - leading `/` anchors the pattern to the context root
|
||||
* - a pattern with no other `/` matches the basename at ANY depth
|
||||
* - any matched path also excludes everything under it (the builder prunes
|
||||
* at the matched directory, so `docs` excludes `docs/x.md`)
|
||||
* - `*` matches any run of non-separator chars, `?` a single one
|
||||
* - `**` matches any number of directories (globstar)
|
||||
*/
|
||||
|
||||
/** A compiled ignore rule: regex against the cleaned repo-relative path. */
|
||||
export class DockerignoreRule {
|
||||
/**
|
||||
* @param {RegExp} regex - compiled matcher
|
||||
* @param {boolean} negate - `!` prefix: re-include when matched
|
||||
* @param {string} source - original pattern text (diagnostics)
|
||||
*/
|
||||
constructor(regex, negate, source) {
|
||||
this.regex = regex
|
||||
this.negate = negate
|
||||
this.source = source
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pattern - one `.dockerignore` line (no trailing newline)
|
||||
* @returns {DockerignoreRule}
|
||||
*/
|
||||
export function compileDockerignoreRule(pattern) {
|
||||
const source = pattern
|
||||
let negate = false
|
||||
if (pattern.startsWith('!')) {
|
||||
negate = true
|
||||
pattern = pattern.slice(1)
|
||||
}
|
||||
if (pattern.endsWith('/')) pattern = pattern.slice(0, -1)
|
||||
const anchored = pattern.startsWith('/')
|
||||
if (anchored) pattern = pattern.slice(1)
|
||||
|
||||
let body = ''
|
||||
for (let index = 0; index < pattern.length; index += 1) {
|
||||
const char = pattern[index]
|
||||
if (char === '*') {
|
||||
if (pattern[index + 1] === '*') {
|
||||
index += 1
|
||||
if (pattern[index + 1] === '/') {
|
||||
body += '(?:.*/)?'
|
||||
index += 1
|
||||
} else {
|
||||
body += '.*'
|
||||
}
|
||||
} else {
|
||||
body += '[^/]*'
|
||||
}
|
||||
} else if (char === '?') {
|
||||
body += '[^/]'
|
||||
} else if ('\\^$.|+()[]{}'.includes(char)) {
|
||||
body += `\\${char}`
|
||||
} else {
|
||||
body += char
|
||||
}
|
||||
}
|
||||
|
||||
const anyDepth = !anchored && !pattern.includes('/') ? '(?:.*/)?' : ''
|
||||
const subtree = '(?:/.*)?'
|
||||
const regexSource = body.length > 0 ? `^${anyDepth}${body}${subtree}$` : '$^'
|
||||
return new DockerignoreRule(new RegExp(regexSource), negate, source)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text - full `.dockerignore` content
|
||||
* @returns {DockerignoreRule[]} rules in order (last match wins)
|
||||
*/
|
||||
export function parseDockerignore(text) {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith('#'))
|
||||
.map(compileDockerignoreRule)
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-match-wins exclusion test for one repo-relative path.
|
||||
*
|
||||
* @param {string} path - repo-relative, forward slashes (e.g. "src/app/x.ts")
|
||||
* @param {DockerignoreRule[]} rules - from parseDockerignore
|
||||
* @returns {boolean} true when the path is excluded from the build context
|
||||
*/
|
||||
export function isDockerignored(path, rules) {
|
||||
let ignored = false
|
||||
for (const rule of rules) {
|
||||
if (rule.regex.test(path)) ignored = !rule.negate
|
||||
}
|
||||
return ignored
|
||||
}
|
||||
|
|
@ -12,8 +12,12 @@
|
|||
* that rejects (network-level `fetch failed`, DNS/TCP/reset) retries on ANY
|
||||
* method; a 5xx (502/503/504 — Cloudflare blips) retries on GET only, so the
|
||||
* safety net's long `waitForChecks` poll survives, while write endpoints keep
|
||||
* failing closed instead of risking duplicated side effects. 4xx never
|
||||
* retries. Defaults: 3 retries, base 300 ms ×2, ±20% jitter.
|
||||
* failing closed instead of risking duplicated side effects. 410 Gone
|
||||
* (OPS71-FLIP): served by the reverse proxy (nginx/Cloudflare) BEFORE the
|
||||
* request reaches the app — the origin never saw it, so retrying is safe on
|
||||
* ANY method (unlike a 5xx, where the origin may have applied the write).
|
||||
* 4xx never retries (410 is the one documented proxy-level exception).
|
||||
* Defaults: 3 retries, base 300 ms ×2, ±20% jitter.
|
||||
*
|
||||
* Shapes are normalized to the GitHub-flavored contract the agent scripts and
|
||||
* their unit specs already use: `issue.state` ∈ OPEN|CLOSED,
|
||||
|
|
@ -24,6 +28,10 @@ const DEFAULT_BASE_URL = 'https://git.solla.dev/api/v1'
|
|||
const DEFAULT_REPOSITORY = 'fsolla/teqo'
|
||||
const USER_AGENT = 'teqo-agent-scripts/1.0 (forgejo)'
|
||||
const RETRYABLE_STATUSES = new Set([502, 503, 504])
|
||||
// 410 Gone from the reverse proxy (OPS71-FLIP live finding: nginx answered
|
||||
// `GET /repos/fsolla/teqo/issues/97/labels` with 410, transient). The origin
|
||||
// never received the request — retrying is side-effect-free on ANY method.
|
||||
const RETRYABLE_BEFORE_ORIGIN = new Set([410])
|
||||
|
||||
/**
|
||||
* @typedef {object} ForgejoApiOptions
|
||||
|
|
@ -124,6 +132,11 @@ export const createApi = ({
|
|||
await retryAfter(attempt, `HTTP ${response.status}`)
|
||||
continue
|
||||
}
|
||||
if (!isLastAttempt && RETRYABLE_BEFORE_ORIGIN.has(response.status)) {
|
||||
await response.body?.cancel()
|
||||
await retryAfter(attempt, `HTTP ${response.status} (proxy — origem intocada)`)
|
||||
continue
|
||||
}
|
||||
let text
|
||||
try {
|
||||
text = await response.text()
|
||||
|
|
@ -133,7 +146,14 @@ export const createApi = ({
|
|||
continue
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Forgejo API ${method} ${path} → ${response.status}: ${text.slice(0, 400)}`)
|
||||
const isProxyHtml = /^\s*</.test(text)
|
||||
const snippet = isProxyHtml
|
||||
? `corpo não-JSON (proxy/nginx — HTTP ${response.status}): ${text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 120)}`
|
||||
: text.slice(0, 400)
|
||||
throw new Error(`Forgejo API ${method} ${path} → ${response.status}: ${snippet}`)
|
||||
}
|
||||
if (!text) return null
|
||||
try {
|
||||
|
|
@ -214,6 +234,18 @@ export const createApi = ({
|
|||
body: { body },
|
||||
}),
|
||||
|
||||
/**
|
||||
* PATCH state=closed — closes the issue on the tracker. Forgejo-era: the
|
||||
* merge of the PR closed the issue natively. GitHub-era (OPS71): the PR
|
||||
* lives on GitHub and never touches the Forgejo tracker — the flip must
|
||||
* close explicitly, else every merged issue stays OPEN with done+in-prod.
|
||||
*/
|
||||
closeIssue: (number) =>
|
||||
request(`/repos/${owner}/${name}/issues/${number}`, {
|
||||
method: 'PATCH',
|
||||
body: { state: 'closed' },
|
||||
}),
|
||||
|
||||
listIssueComments: async (number) => {
|
||||
const comments = await request(`/repos/${owner}/${name}/issues/${number}/comments`)
|
||||
return (Array.isArray(comments) ? comments : []).map((comment) => ({
|
||||
|
|
|
|||
264
scripts/lib/github-api.mjs
Normal file
264
scripts/lib/github-api.mjs
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
/**
|
||||
* GitHub REST + GraphQL client for the CI/PR scripts (`github-pr*`, branch
|
||||
* protection). Zero dependencies — plain Node `fetch` — so it also runs
|
||||
* inside GitHub Actions jobs without `pnpm install`.
|
||||
*
|
||||
* Auth: `GITHUB_TOKEN` (PAT locally; the built-in Actions token in
|
||||
* workflows). Base URL: `GITHUB_API_URL`, else `https://api.github.com`.
|
||||
*
|
||||
* Retry (same policy as forgejo-api OPS67): transient failures retry with
|
||||
* exponential backoff — a fetch that rejects (network-level, DNS/TCP/reset)
|
||||
* retries on ANY method; a 5xx retries on GET only, so read polls survive
|
||||
* while write endpoints keep failing closed instead of risking duplicated
|
||||
* side effects. 4xx never retries. Defaults: 3 retries, base 300 ms ×2,
|
||||
* ±20% jitter.
|
||||
*
|
||||
* Shapes are normalized to the contract the CLI scripts use: `pr.state` ∈
|
||||
* OPEN|CLOSED, `pr.draft`, `pr.nodeId` (GraphQL auto-merge), branch
|
||||
* protection normalized for drift comparison.
|
||||
*/
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.github.com'
|
||||
const DEFAULT_GRAPHQL_URL = 'https://api.github.com/graphql'
|
||||
const DEFAULT_REPOSITORY = 'fsolla/teqo'
|
||||
const USER_AGENT = 'teqo-agent-scripts/1.0 (github)'
|
||||
const RETRYABLE_STATUSES = new Set([502, 503, 504])
|
||||
|
||||
/**
|
||||
* @typedef {object} GithubApiOptions
|
||||
* @property {string} [base]
|
||||
* @property {string} [graphqlBase]
|
||||
* @property {string} [token]
|
||||
* @property {string} [repository]
|
||||
* @property {(input: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: string }) => Promise<Response>} [fetchImpl]
|
||||
* @property {number} [retries] Additional attempts after the first (total = retries + 1). Default 3.
|
||||
* @property {number} [backoffMs] Base exponential delay. Default 300.
|
||||
* @property {boolean} [jitter] ±20% randomization on each delay. Default true.
|
||||
* @property {(ms: number) => Promise<void>} [sleepImpl] Test seam for the backoff delay.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {GithubApiOptions} [options]
|
||||
*/
|
||||
export const createApi = ({
|
||||
base,
|
||||
graphqlBase,
|
||||
token,
|
||||
repository,
|
||||
fetchImpl,
|
||||
retries = 3,
|
||||
backoffMs = 300,
|
||||
jitter = true,
|
||||
sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
} = {}) => {
|
||||
const baseUrl = (base ?? process.env.GITHUB_API_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, '')
|
||||
const graphqlUrl = (graphqlBase ?? process.env.GITHUB_GRAPHQL_URL ?? DEFAULT_GRAPHQL_URL).replace(
|
||||
/\/+$/,
|
||||
'',
|
||||
)
|
||||
const repo = repository ?? process.env.GITHUB_REPOSITORY ?? DEFAULT_REPOSITORY
|
||||
const fetcher = fetchImpl ?? fetch
|
||||
const [owner, name] = repo.split('/')
|
||||
if (retries < 0 || backoffMs < 0) {
|
||||
throw new Error(
|
||||
`Retry inválido: retries=${retries}, backoffMs=${backoffMs} — ambos devem ser >= 0`,
|
||||
)
|
||||
}
|
||||
const attemptCount = retries + 1
|
||||
|
||||
const backoffDelay = (attempt) => {
|
||||
const base = backoffMs * 2 ** (attempt - 1)
|
||||
return jitter ? Math.round(base * (0.8 + Math.random() * 0.4)) : base
|
||||
}
|
||||
|
||||
const warnRetry = (path, method, reason, attempt, delay) =>
|
||||
console.warn(
|
||||
`[github-api] ${method} ${path} falhou (tentativa ${attempt}/${attemptCount}): ${reason} — retry em ${delay}ms`,
|
||||
)
|
||||
|
||||
const headers = (withJson = true) => {
|
||||
const authToken = token ?? process.env.GITHUB_TOKEN
|
||||
if (!authToken) {
|
||||
throw new Error('Sem token para a API do GitHub — defina GITHUB_TOKEN.')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
'User-Agent': USER_AGENT,
|
||||
...(withJson ? { 'Content-Type': 'application/json' } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const request = async (path, { method = 'GET', body, query } = {}) => {
|
||||
const qs = query
|
||||
? '?' +
|
||||
Object.entries(query)
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||||
.join('&')
|
||||
: ''
|
||||
const url = `${baseUrl}${path}${qs}`
|
||||
const retryAfter = async (attempt, reason) => {
|
||||
const delay = backoffDelay(attempt)
|
||||
warnRetry(path, method, reason, attempt, delay)
|
||||
await sleepImpl(delay)
|
||||
}
|
||||
for (let attempt = 1; attempt <= attemptCount; attempt += 1) {
|
||||
const isLastAttempt = attempt === attemptCount
|
||||
let response
|
||||
try {
|
||||
response = await fetcher(url, {
|
||||
method,
|
||||
headers: headers(),
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
})
|
||||
} catch (error) {
|
||||
if (isLastAttempt) throw error
|
||||
await retryAfter(attempt, error.message)
|
||||
continue
|
||||
}
|
||||
if (method === 'GET' && !isLastAttempt && RETRYABLE_STATUSES.has(response.status)) {
|
||||
await response.body?.cancel()
|
||||
await retryAfter(attempt, `HTTP ${response.status}`)
|
||||
continue
|
||||
}
|
||||
let text
|
||||
try {
|
||||
text = await response.text()
|
||||
} catch (error) {
|
||||
if (isLastAttempt || method !== 'GET') throw error
|
||||
await retryAfter(attempt, error.message)
|
||||
continue
|
||||
}
|
||||
if (response.status === 404 && method === 'GET') return null
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API ${method} ${path} → ${response.status}: ${text.slice(0, 400)}`)
|
||||
}
|
||||
if (!text) return null
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(`GitHub API ${method} ${path}: resposta não-JSON: ${text.slice(0, 80)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const graphql = async (query, variables) => {
|
||||
let response
|
||||
try {
|
||||
response = await fetcher(graphqlUrl, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ query, variables }),
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`GitHub GraphQL falhou (rede): ${error.message}`)
|
||||
}
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub GraphQL → ${response.status}: ${text.slice(0, 400)}`)
|
||||
}
|
||||
const parsed = JSON.parse(text)
|
||||
const firstError = parsed?.errors?.[0]
|
||||
if (firstError) {
|
||||
throw new Error(`GitHub GraphQL: ${firstError.message}`)
|
||||
}
|
||||
return parsed.data ?? null
|
||||
}
|
||||
|
||||
const normalizeState = (state) => (String(state).toLowerCase() === 'closed' ? 'CLOSED' : 'OPEN')
|
||||
|
||||
const normalizePullRequest = (pr) => ({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
body: pr.body ?? '',
|
||||
state: normalizeState(pr.state),
|
||||
merged: Boolean(pr.merged_at || pr.merged),
|
||||
draft: Boolean(pr.draft),
|
||||
mergeable: pr.mergeable ?? null,
|
||||
nodeId: pr.node_id ?? '',
|
||||
head: { ref: pr.head?.ref ?? '', sha: pr.head?.sha ?? '' },
|
||||
base: { ref: pr.base?.ref ?? '' },
|
||||
})
|
||||
|
||||
const api = {
|
||||
/**
|
||||
* GET /repos/{owner}/{repo}/pulls/{number} — normalized.
|
||||
* @param {number} number
|
||||
*/
|
||||
getPullRequest: async (number) => {
|
||||
const pr = await request(`/repos/${owner}/${name}/pulls/${number}`)
|
||||
return pr ? normalizePullRequest(pr) : null
|
||||
},
|
||||
|
||||
/** PATCH draft:false — marks a draft PR ready for review. */
|
||||
markPullRequestReady: (number) =>
|
||||
request(`/repos/${owner}/${name}/pulls/${number}`, {
|
||||
method: 'PATCH',
|
||||
body: { draft: false },
|
||||
}),
|
||||
|
||||
/** POST /pulls — creates a PR (never draft). */
|
||||
createPullRequest: async ({ head, base = 'main', title, body }) => {
|
||||
const pr = await request(`/repos/${owner}/${name}/pulls`, {
|
||||
method: 'POST',
|
||||
body: { head, base, title, body },
|
||||
})
|
||||
return { number: pr.number, title: pr.title, htmlUrl: pr.html_url }
|
||||
},
|
||||
|
||||
/**
|
||||
* Enables GitHub's native auto-merge (GraphQL) with the repo's canonical
|
||||
* merge style (rebase). The server waits for the required checks — a PR
|
||||
* with a red `CI (PR) / checks` can never auto-merge.
|
||||
*/
|
||||
enableAutoMerge: async (nodeId, mergeMethod = 'REBASE') => {
|
||||
await graphql(
|
||||
`
|
||||
mutation EnablePullRequestAutoMerge($id: ID!, $mergeMethod: PullRequestMergeMethod!) {
|
||||
enablePullRequestAutoMerge(input: { pullRequestId: $id, mergeMethod: $mergeMethod }) {
|
||||
pullRequest {
|
||||
number
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id: nodeId, mergeMethod },
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /repos/{owner}/{repo}/branches/{branch}/protection — normalized for
|
||||
* drift comparison; `null` when the branch has no protection rule (404).
|
||||
*/
|
||||
getBranchProtection: async (branch = 'main') => {
|
||||
const rule = await request(`/repos/${owner}/${name}/branches/${branch}/protection`)
|
||||
if (!rule) return null
|
||||
return {
|
||||
required_status_checks: rule.required_status_checks
|
||||
? {
|
||||
strict: Boolean(rule.required_status_checks.strict),
|
||||
contexts: (rule.required_status_checks.checks ?? []).map((entry) => entry.context),
|
||||
}
|
||||
: null,
|
||||
enforce_admins: Boolean(rule.enforce_admins?.enabled ?? rule.enforce_admins),
|
||||
required_pull_request_reviews: rule.required_pull_request_reviews ?? null,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /repos/{owner}/{repo}/branches/{branch}/protection — replaces the
|
||||
* whole rule (GitHub replace semantics: omitted protections are removed).
|
||||
*/
|
||||
updateBranchProtection: (payload, branch = 'main') =>
|
||||
request(`/repos/${owner}/${name}/branches/${branch}/protection`, {
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
}),
|
||||
|
||||
/** PATCH /repos/{owner}/{repo} — repo settings (e.g. allow_auto_merge). */
|
||||
updateRepository: (payload) =>
|
||||
request(`/repos/${owner}/${name}`, { method: 'PATCH', body: payload }),
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
78
scripts/lib/github-branch-protection.mjs
Normal file
78
scripts/lib/github-branch-protection.mjs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Pure decision for the GitHub `main` branch protection rule (OPS71).
|
||||
*
|
||||
* The desired rule is the repo contract: main requires the ci-pr cascade
|
||||
* check-run (OPS62) as the only status check (strict=false, 0 reviews) and
|
||||
* `enforce_admins: true` so even repo admins cannot merge with a red required
|
||||
* check (the rule-based protection of the Forgejo era applied to everyone —
|
||||
* preserved).
|
||||
*
|
||||
* The auto-merge safety net never merges on red CI by construction (GitHub's
|
||||
* server-side guarantee), but the branch protection rule is the final defense
|
||||
* against every other merge path (manual API merges included).
|
||||
*
|
||||
* Context literal (verified live on PR #742, 2026-08-19): GitHub's
|
||||
* required-check matching uses the CHECK-RUN NAME — the bare JOB name
|
||||
* (`checks`), NOT the UI display `<workflow name> / <job name>`
|
||||
* (`CI (PR) / checks`). The latter never matches and leaves the PR
|
||||
* mergeable_state "blocked" forever. The UI still shows
|
||||
* `CI (PR) / checks`; the rule literal is `checks`.
|
||||
*/
|
||||
|
||||
export const REQUIRED_CHECK_CONTEXT = 'checks'
|
||||
|
||||
/**
|
||||
* GitHub PUT /branches/{branch}/protection replace-semantics payload. Only
|
||||
* `checks` (modern) is sent — `contexts` is legacy-derived and GitHub's API
|
||||
* rejects the two together (422 anyOf). The GET normalization reads the
|
||||
* check-run contexts from `checks`, so drift comparison is unaffected.
|
||||
*/
|
||||
export const DESIRED_RULE = Object.freeze({
|
||||
required_status_checks: {
|
||||
strict: false,
|
||||
checks: [{ context: REQUIRED_CHECK_CONTEXT }],
|
||||
},
|
||||
enforce_admins: true,
|
||||
required_pull_request_reviews: null,
|
||||
restrictions: null,
|
||||
required_linear_history: false,
|
||||
allow_force_pushes: false,
|
||||
allow_deletions: false,
|
||||
block_creations: false,
|
||||
})
|
||||
|
||||
const sameStrings = (have, want) =>
|
||||
have.length === want.length &&
|
||||
want.every((value) => have.includes(value)) &&
|
||||
have.every((value) => want.includes(value))
|
||||
|
||||
/**
|
||||
* True when the existing rule (from `getBranchProtection`, already
|
||||
* normalized) matches the desired rule on every compared field — array
|
||||
* fields as sets.
|
||||
*/
|
||||
export const ruleMatches = (existing) => {
|
||||
if (!existing) return false
|
||||
const wantStatus = DESIRED_RULE.required_status_checks
|
||||
if (existing.enforce_admins !== DESIRED_RULE.enforce_admins) return false
|
||||
if (existing.required_pull_request_reviews !== null) return false
|
||||
const haveStatus = existing.required_status_checks
|
||||
if (!haveStatus) return false
|
||||
if (haveStatus.strict !== wantStatus.strict) return false
|
||||
return sameStrings(
|
||||
haveStatus.contexts,
|
||||
wantStatus.checks.map((entry) => entry.context),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What to do with the rule: `create` (no rule), `update` (rule exists with
|
||||
* drift), `noop` (already compliant). Idempotent re-runs land on `noop`.
|
||||
*
|
||||
* @param {object|null} existing - normalized rule from the API, or null
|
||||
* @returns {{ action: 'create' | 'update' | 'noop' }}
|
||||
*/
|
||||
export const planBranchProtectionRule = (existing) => {
|
||||
if (ruleMatches(existing)) return { action: 'noop' }
|
||||
return { action: existing ? 'update' : 'create' }
|
||||
}
|
||||
52
scripts/lib/github-pr-flow.mjs
Normal file
52
scripts/lib/github-pr-flow.mjs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Pure decision logic for the GitHub PR auto-merge safety net
|
||||
* (scripts/github-pr-automerge.mjs). Unit-tested in
|
||||
* tests/unit/githubPrFlow.unit.spec.ts — the OPS57 (draft veto) / OPS64
|
||||
* (never merge on red CI) pins ported to the GitHub mechanism.
|
||||
*
|
||||
* GitHub's native auto-merge only fires with the required checks green, so
|
||||
* the safety net's only real decisions are: skip (non-main base, draft veto,
|
||||
* already merged) or mark-ready (cursor/* draft) before arming auto-merge.
|
||||
*/
|
||||
|
||||
export const CURSOR_HEAD_PREFIX = 'cursor/'
|
||||
|
||||
/** OPS57: only `cursor/*` drafts get force-marked ready — any other draft is the actor's veto. */
|
||||
export const isCursorHead = (ref) => typeof ref === 'string' && ref.startsWith(CURSOR_HEAD_PREFIX)
|
||||
|
||||
/**
|
||||
* OPS71-FLIP: the auto-merge MUST be armed with a real user PAT. Arming with
|
||||
* the built-in GITHUB_TOKEN makes the merge happen as `github-actions[bot]`,
|
||||
* and GitHub's anti-recursion never creates workflow runs for events
|
||||
* triggered by GITHUB_TOKEN actions — the post-merge `closed` flips would
|
||||
* silently never run (live finding PR #746). Fail-closed: no token → ok:false;
|
||||
* the CLI exits 1 instead of falling back to the run token.
|
||||
*
|
||||
* @param {{ AUTOMERGE_PAT?: string }} [env]
|
||||
* @returns {{ ok: true, token: string } | { ok: false, reason: string }}
|
||||
*/
|
||||
export const automergeArmingToken = (env = process.env) => {
|
||||
const token = env.AUTOMERGE_PAT
|
||||
if (typeof token === 'string' && token.length > 0) return { ok: true, token }
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
'AUTOMERGE_PAT ausente — sem ele o merge aconteceria como github-actions[bot] e o evento closed não criaria runs (flips pós-merge silenciosos). Defina o secret AUTOMERGE_PAT no GitHub (PAT do fsolla, escopos pull-requests: write + contents: write no repo) — ver docs/AGENT-OPS.md.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object|null} pr - normalized PR from github-api (or null)
|
||||
* @returns {{ action: 'skip' | 'mark-ready' | 'enable-auto-merge', reason: string }}
|
||||
*/
|
||||
export const decideAutomergeAction = (pr) => {
|
||||
if (!pr) return { action: 'skip', reason: 'pr-inexistente' }
|
||||
if (pr.merged) return { action: 'skip', reason: 'ja-mergeada' }
|
||||
if (pr.state !== 'OPEN') return { action: 'skip', reason: 'pr-nao-aberta' }
|
||||
if (pr.base?.ref !== 'main') return { action: 'skip', reason: 'base-nao-main' }
|
||||
if (pr.draft) {
|
||||
if (isCursorHead(pr.head?.ref)) return { action: 'mark-ready', reason: 'draft-cursor' }
|
||||
return { action: 'skip', reason: 'draft-veto' }
|
||||
}
|
||||
return { action: 'enable-auto-merge', reason: 'ready' }
|
||||
}
|
||||
|
|
@ -29,8 +29,6 @@ export const HIGH_RISK_EXACT = new Set([
|
|||
'scripts/e2e-affected.mjs',
|
||||
'scripts/run-e2e-affected.mjs',
|
||||
'scripts/ci-scope.mjs',
|
||||
'scripts/ci-classify-production.mjs',
|
||||
'scripts/lib/dockerignore.mjs',
|
||||
'scripts/check-test-locations.mjs',
|
||||
'scripts/check-plans-only-pr-closes.mjs',
|
||||
'scripts/lib/plansOnlyClosesGuard.mjs',
|
||||
|
|
|
|||
|
|
@ -199,6 +199,7 @@ export default async function CampaignActionWizardPage({
|
|||
municipalityName={municipality.name}
|
||||
municipalitySlug={municipality.slug}
|
||||
returnPath={returnPath}
|
||||
currentUser={{ id: user.id, name: user.name }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import { revalidatePath } from 'next/cache'
|
|||
import {
|
||||
attachCampaignDemandReceiptRecord,
|
||||
setCampaignDemandCost,
|
||||
setCampaignDemandResponsibles,
|
||||
transitionCampaignDemand,
|
||||
updateCampaignDemand,
|
||||
} from '@/app/(campaign)/campanha/actions/demand'
|
||||
import {
|
||||
FormDataBoundaryError,
|
||||
nullableFormText,
|
||||
repeatedRelationshipFormValues,
|
||||
requiredFormText,
|
||||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
|
|
@ -19,6 +21,7 @@ import {
|
|||
CAMPAIGN_DEMAND_EDIT_STAFF_MESSAGE,
|
||||
CAMPAIGN_DEMAND_INVALID_STATUS_MESSAGE,
|
||||
CAMPAIGN_DEMAND_RECEIPT_SAFE_MESSAGES,
|
||||
CAMPAIGN_DEMAND_RESPONSIBLES_STAFF_MESSAGE,
|
||||
CAMPAIGN_DEMAND_TRANSITION_SAFE_MESSAGES,
|
||||
campaignDemandStatuses,
|
||||
type CampaignDemandStatus,
|
||||
|
|
@ -100,6 +103,23 @@ export const setDemandCostFormAction = async (
|
|||
genericMessage: 'Não foi possível registrar o custo. Tente novamente.',
|
||||
})
|
||||
|
||||
export const setDemandResponsiblesFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await setCampaignDemandResponsibles({
|
||||
id: requiredRelationshipFormValue(formData, 'demandId'),
|
||||
responsibles: repeatedRelationshipFormValues(formData, 'responsibles'),
|
||||
})
|
||||
revalidatePath('/campanha/demandas/[slug]', 'page')
|
||||
return { message: 'Responsáveis atualizados.' }
|
||||
},
|
||||
safeMessages: [CAMPAIGN_DEMAND_RESPONSIBLES_STAFF_MESSAGE],
|
||||
genericMessage: 'Não foi possível atualizar os responsáveis. Tente novamente.',
|
||||
})
|
||||
|
||||
export const attachDemandReceiptFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { notFound } from 'next/navigation'
|
|||
import { getPayload } from 'payload'
|
||||
|
||||
import { DemandDescriptionEditor } from '@/components/campaign/demand/DemandDescriptionEditor'
|
||||
import { DemandResponsiblesCard } from '@/components/campaign/demand/DemandResponsiblesCard'
|
||||
import { DemandWorkflowCard } from '@/components/campaign/demand/DemandWorkflowCard'
|
||||
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
|
|
@ -22,6 +23,7 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
|||
import {
|
||||
attachDemandReceiptFormAction,
|
||||
setDemandCostFormAction,
|
||||
setDemandResponsiblesFormAction,
|
||||
transitionDemandFormAction,
|
||||
updateDemandFormAction,
|
||||
} from './formActions'
|
||||
|
|
@ -118,87 +120,106 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps
|
|||
{dateTimeFormatter.format(new Date(demand.createdAt))}
|
||||
</p>
|
||||
|
||||
<DemandDescriptionEditor
|
||||
demandId={demand.id}
|
||||
initialDescription={demand.description ?? ''}
|
||||
formAction={updateDemandFormAction}
|
||||
/>
|
||||
<div className="grid items-start gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<DemandDescriptionEditor
|
||||
demandId={demand.id}
|
||||
initialDescription={demand.description ?? ''}
|
||||
formAction={updateDemandFormAction}
|
||||
/>
|
||||
|
||||
{demand.decisionNote && (demand.status === 'aprovada' || demand.status === 'rejeitada') ? (
|
||||
<section aria-label="Decisão" className="rounded-xl border p-4">
|
||||
<h2 className="text-sm font-medium">Nota da decisão</h2>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{demand.decisionNote}
|
||||
</p>
|
||||
{demand.decidedAt ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Decidida em {dateTimeFormatter.format(new Date(demand.decidedAt))}
|
||||
</p>
|
||||
{demand.decisionNote &&
|
||||
(demand.status === 'aprovada' || demand.status === 'rejeitada') ? (
|
||||
<section aria-label="Decisão" className="rounded-xl border p-4">
|
||||
<h2 className="text-sm font-medium">Nota da decisão</h2>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{demand.decisionNote}
|
||||
</p>
|
||||
{demand.decidedAt ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Decidida em {dateTimeFormatter.format(new Date(demand.decidedAt))}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<DemandWorkflowCard
|
||||
demandID={demand.id}
|
||||
status={demand.status}
|
||||
canDecideEscalated={isCampaignUnrestricted(user)}
|
||||
currentCost={demand.cost}
|
||||
transitionFormAction={transitionDemandFormAction}
|
||||
costFormAction={setDemandCostFormAction}
|
||||
receiptFormAction={attachDemandReceiptFormAction}
|
||||
/>
|
||||
<DemandWorkflowCard
|
||||
demandID={demand.id}
|
||||
status={demand.status}
|
||||
canDecideEscalated={isCampaignUnrestricted(user)}
|
||||
currentCost={demand.cost}
|
||||
transitionFormAction={transitionDemandFormAction}
|
||||
costFormAction={setDemandCostFormAction}
|
||||
receiptFormAction={attachDemandReceiptFormAction}
|
||||
/>
|
||||
|
||||
<section
|
||||
aria-labelledby="demand-internal-title"
|
||||
className="flex flex-col gap-3 rounded-xl border p-4"
|
||||
>
|
||||
<h2 id="demand-internal-title" className="text-base font-medium">
|
||||
Controle interno
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Custo: {demand.cost != null ? currencyFormatter.format(demand.cost) : 'não registrado'}
|
||||
</p>
|
||||
{demand.receipts.length ? (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{demand.receipts.map((receipt) => (
|
||||
<li key={receipt.id} className="flex items-center gap-2 text-sm">
|
||||
<PaperclipIcon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
{receipt.url ? (
|
||||
<a
|
||||
href={receipt.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{receipt.filename ?? `Comprovante ${receipt.id}`}
|
||||
</a>
|
||||
) : (
|
||||
<span>{receipt.filename ?? `Comprovante ${receipt.id}`}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Nenhum comprovante anexado.</p>
|
||||
)}
|
||||
{demand.statusHistory.length ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-medium">Histórico</h3>
|
||||
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
|
||||
{demand.statusHistory.map((entry, index) => (
|
||||
<li key={`${entry.status}-${index}`}>
|
||||
{campaignDemandStatusLabels[entry.status]}
|
||||
{entry.authorName ? ` — ${entry.authorName}` : ''}
|
||||
{entry.createdAt
|
||||
? ` · ${dateTimeFormatter.format(new Date(entry.createdAt))}`
|
||||
: ''}
|
||||
{entry.note ? ` · ${entry.note}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<section
|
||||
aria-labelledby="demand-internal-title"
|
||||
className="flex flex-col gap-3 rounded-xl border p-4"
|
||||
>
|
||||
<h2 id="demand-internal-title" className="text-base font-medium">
|
||||
Controle interno
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Visível para: <span className="font-medium">Responsáveis</span> ·{' '}
|
||||
<span className="font-medium">Candidato</span> ·{' '}
|
||||
<span className="font-medium">Coordenador</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Custo:{' '}
|
||||
{demand.cost != null ? currencyFormatter.format(demand.cost) : 'não registrado'}
|
||||
</p>
|
||||
{demand.receipts.length ? (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{demand.receipts.map((receipt) => (
|
||||
<li key={receipt.id} className="flex items-center gap-2 text-sm">
|
||||
<PaperclipIcon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
{receipt.url ? (
|
||||
<a
|
||||
href={receipt.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{receipt.filename ?? `Comprovante ${receipt.id}`}
|
||||
</a>
|
||||
) : (
|
||||
<span>{receipt.filename ?? `Comprovante ${receipt.id}`}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Nenhum comprovante anexado.</p>
|
||||
)}
|
||||
{demand.statusHistory.length ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-medium">Histórico</h3>
|
||||
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
|
||||
{demand.statusHistory.map((entry, index) => (
|
||||
<li key={`${entry.status}-${index}`}>
|
||||
{campaignDemandStatusLabels[entry.status]}
|
||||
{entry.authorName ? ` — ${entry.authorName}` : ''}
|
||||
{entry.createdAt
|
||||
? ` · ${dateTimeFormatter.format(new Date(entry.createdAt))}`
|
||||
: ''}
|
||||
{entry.note ? ` · ${entry.note}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DemandResponsiblesCard
|
||||
demandId={demand.id}
|
||||
municipalityId={demand.municipalityId > 0 ? demand.municipalityId : null}
|
||||
creatorUserId={demand.createdBy}
|
||||
initialResponsibles={demand.responsibles}
|
||||
formAction={setDemandResponsiblesFormAction}
|
||||
/>
|
||||
</div>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export default async function NewDemandPage({ searchParams }: NewDemandPageProps
|
|||
municipalityOptions={municipalityOptions}
|
||||
initialActivity={initialActivity}
|
||||
initialMunicipalityId={initialMunicipalityId}
|
||||
currentUser={{ id: user.id, name: user.name }}
|
||||
searchActivities={searchDemandActivityOptions}
|
||||
formAction={createDemandFormAction}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
'use server'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { CAMPAIGN_AUTH_REQUIRED_MESSAGE } from '@/utilities/campaignFormActionError'
|
||||
import { loadCampaignUserSummaries } from '@/utilities/campaignRelationOptions'
|
||||
|
||||
const RESPONSIBLE_SEARCH_LIMIT = 20
|
||||
|
||||
/**
|
||||
* C143 — candidate catalog for the demand responsible picker. Short queries
|
||||
* (or empty) return the municipality's advisors as suggestions — a fill-in
|
||||
* shortcut, never an automatic responsibility. Longer queries search staff by
|
||||
* name. Both paths run access-scoped reads (`overrideAccess: false`), so the
|
||||
* catalog only ever offers what the actor may already read.
|
||||
*/
|
||||
export const searchDemandResponsibleOptions = async (
|
||||
query: string,
|
||||
municipalityId: number | null,
|
||||
): Promise<RelationOption[]> => {
|
||||
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
|
||||
if (!user) throw new Error(CAMPAIGN_AUTH_REQUIRED_MESSAGE)
|
||||
|
||||
if (query.trim().length < 2) {
|
||||
if (!municipalityId) return []
|
||||
|
||||
const municipality = await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { id: { equals: municipalityId } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
select: { advisors: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
const advisors = (municipality.docs[0]?.advisors ?? [])
|
||||
.map((advisor) => (typeof advisor === 'object' && advisor !== null ? advisor.id : advisor))
|
||||
.filter((id): id is number => Number.isInteger(id))
|
||||
return loadCampaignUserSummaries(payload, user, advisors)
|
||||
}
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: {
|
||||
and: [{ name: { contains: query.trim() } }, eligibleCampaignStaffWhere],
|
||||
},
|
||||
depth: 0,
|
||||
limit: RESPONSIBLE_SEARCH_LIMIT,
|
||||
pagination: false,
|
||||
sort: 'name',
|
||||
select: { name: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
return result.docs.map(({ id, name }) => ({ id, name }))
|
||||
}
|
||||
|
|
@ -11,15 +11,18 @@ import {
|
|||
CAMPAIGN_DEMAND_RECEIPT_SIZE_MESSAGE,
|
||||
CAMPAIGN_DEMAND_RECEIPT_STAFF_MESSAGE,
|
||||
CAMPAIGN_DEMAND_RECEIPT_TYPE_MESSAGE,
|
||||
CAMPAIGN_DEMAND_RESPONSIBLES_STAFF_MESSAGE,
|
||||
CAMPAIGN_DEMAND_TRANSITION_STAFF_MESSAGE,
|
||||
} from '@/lib/schemas/campaignDemand'
|
||||
import {
|
||||
campaignDemandCostSchema,
|
||||
campaignDemandCreateSchema,
|
||||
campaignDemandResponsiblesSchema,
|
||||
campaignDemandTransitionSchema,
|
||||
campaignDemandUpdateSchema,
|
||||
type CampaignDemandCostInput,
|
||||
type CampaignDemandCreateInput,
|
||||
type CampaignDemandResponsiblesInput,
|
||||
type CampaignDemandTransitionInput,
|
||||
type CampaignDemandUpdateInput,
|
||||
} from '@/lib/schemas/campaignDemandInput'
|
||||
|
|
@ -84,8 +87,9 @@ export const updateCampaignDemandRecord = async (
|
|||
throw new Error(CAMPAIGN_DEMAND_EDIT_STAFF_MESSAGE)
|
||||
}
|
||||
|
||||
// Access-scoped read (staff see only their municipalities). The AI call
|
||||
// stays before the advisory lock, so the lock is never held open by it.
|
||||
// Access-scoped read (C143: responsibles + unrestricted see the demand).
|
||||
// The AI call stays before the advisory lock, so the lock is never held
|
||||
// open by it.
|
||||
const demand = await payload.findByID({
|
||||
collection: 'campaignDemand',
|
||||
id,
|
||||
|
|
@ -256,6 +260,47 @@ export const attachCampaignDemandReceiptRecord = async (
|
|||
)
|
||||
}
|
||||
|
||||
export const setCampaignDemandResponsiblesRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: CampaignDemandResponsiblesInput,
|
||||
) => {
|
||||
const { id, responsibles } = campaignDemandResponsiblesSchema.parse(input)
|
||||
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await reloadCampaignActor(payload, actor, req)
|
||||
if (!isCampaignStaff(currentActor)) {
|
||||
throw new Error(CAMPAIGN_DEMAND_RESPONSIBLES_STAFF_MESSAGE)
|
||||
}
|
||||
await acquireTextAdvisoryLocks(payload, req, [`campaign-demand:${id}`])
|
||||
|
||||
// Access-scoped read (responsibles + unrestricted see it) before the
|
||||
// replace, so an advisor cannot manage a demand they cannot read.
|
||||
await payload.findByID({
|
||||
collection: 'campaignDemand',
|
||||
id,
|
||||
depth: 0,
|
||||
select: { title: true },
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
return payload.update({
|
||||
collection: 'campaignDemand',
|
||||
id,
|
||||
data: { responsibles: [...new Set(responsibles)] },
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível iniciar a atualização dos responsáveis.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const createCampaignDemand = async (input: CampaignDemandCreateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return createCampaignDemandRecord(payload, actor, input)
|
||||
|
|
@ -275,3 +320,8 @@ export const setCampaignDemandCost = async (input: CampaignDemandCostInput) => {
|
|||
const { payload, actor } = await getCampaignActionContext()
|
||||
return setCampaignDemandCostRecord(payload, actor, input)
|
||||
}
|
||||
|
||||
export const setCampaignDemandResponsibles = async (input: CampaignDemandResponsiblesInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return setCampaignDemandResponsiblesRecord(payload, actor, input)
|
||||
}
|
||||
|
|
|
|||
41
src/app/(frontend)/api/social-feed/sync/route.ts
Normal file
41
src/app/(frontend)/api/social-feed/sync/route.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest'
|
||||
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
|
||||
import { INSTAGRAM_SYNC_TIMEOUT_MS, syncInstagramFeed } from '@/utilities/socialFeed/instagramSync'
|
||||
import configPromise from '@payload-config'
|
||||
import { revalidateTag } from 'next/cache'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
/**
|
||||
* "Tentar sincronizar de novo" — the admin status panel's retry button
|
||||
* (`InstagramSyncStatusPanel`). Runs one Instagram sync from the persisted
|
||||
* global settings, writes the outcome status, busts the `social-feed` cache
|
||||
* so the public board reflects the new snapshot, and returns the status the
|
||||
* panel renders immediately.
|
||||
*
|
||||
* Auth: admin session (the `payload-token` cookie via `payload.auth`, `users`
|
||||
* collection only — same gate as the global's `read`) plus the same-origin
|
||||
* CSRF guard every cookie-authenticated JSON route uses. The token and the
|
||||
* Graph API are never reachable without an admin session.
|
||||
*/
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
if (!isSameOriginRequest(request)) {
|
||||
return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers: request.headers })
|
||||
if (!user || user.collection !== 'users') {
|
||||
return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const outcome = await syncInstagramFeed(payload, {
|
||||
signal: AbortSignal.timeout(INSTAGRAM_SYNC_TIMEOUT_MS),
|
||||
})
|
||||
revalidateTag(REVALIDATE_SOCIAL_FEED_TAG)
|
||||
return NextResponse.json({ ok: outcome.ok, status: outcome.status })
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: 'Falha interna ao sincronizar' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +34,9 @@ import { ExportPreview as ExportPreview_cdf7e044479f899a31f804427d568b36 } from
|
|||
import { ExportSaveButton as ExportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
|
||||
import { ImportPreview as ImportPreview_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
|
||||
import { ImportSaveButton as ImportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
|
||||
import { InstagramSyncStatusPanel as InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da } from '../../../components/admin/InstagramSyncStatusPanel'
|
||||
import { InstagramPostExclusionPicker as InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0 } from '../../../components/admin/InstagramPostExclusionPicker'
|
||||
import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client'
|
||||
import { ImportExportProvider as ImportExportProvider_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
|
|
@ -75,7 +77,9 @@ export const importMap = {
|
|||
"@payloadcms/plugin-import-export/rsc#ExportSaveButton": ExportSaveButton_cdf7e044479f899a31f804427d568b36,
|
||||
"@payloadcms/plugin-import-export/rsc#ImportPreview": ImportPreview_cdf7e044479f899a31f804427d568b36,
|
||||
"@payloadcms/plugin-import-export/rsc#ImportSaveButton": ImportSaveButton_cdf7e044479f899a31f804427d568b36,
|
||||
"./components/admin/InstagramSyncStatusPanel#InstagramSyncStatusPanel": InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da,
|
||||
"./components/admin/InstagramPostExclusionPicker#InstagramPostExclusionPicker": InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0,
|
||||
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
|
||||
"@payloadcms/plugin-import-export/rsc#ImportExportProvider": ImportExportProvider_cdf7e044479f899a31f804427d568b36,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
canReadCampaignStaffField,
|
||||
canSetCampaignSystemField,
|
||||
canUpdateCampaignDemand,
|
||||
eligibleCampaignStaffWhere,
|
||||
isCampaignUnrestricted,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import { systemStampedActorField } from '@/utilities/campaignAuditFields'
|
||||
|
|
@ -41,6 +42,40 @@ const DEMAND_STATUS_OPTIONS = campaignDemandStatuses.map((value) => ({
|
|||
label: campaignDemandStatusLabels[value],
|
||||
}))
|
||||
|
||||
/**
|
||||
* C143 — responsibles are always eligible staff (coordinator/advisor/candidate).
|
||||
* The picker catalog already filters, but the invariant belongs in the data
|
||||
* path, fail-closed: forged/non-eligible ids (a leader, a deleted account) are
|
||||
* dropped from any create/update. Intentional admin bypass — the hook must SEE
|
||||
* the non-eligible rows to drop them (same precedent as the slug probe).
|
||||
*/
|
||||
const restrictResponsiblesToStaff: CollectionBeforeValidateHook = async ({ data, req }) => {
|
||||
if (!data) return data
|
||||
const rawResponsibles = data.responsibles
|
||||
if (!Array.isArray(rawResponsibles) || rawResponsibles.length === 0) return data
|
||||
|
||||
const ids = rawResponsibles
|
||||
.map((entry) => relationshipId(entry))
|
||||
.filter((id): id is number => id !== null)
|
||||
if (ids.length === 0) return data
|
||||
|
||||
// Bypass (documented in the hook's comment): the eligibility probe must see
|
||||
// the non-eligible rows (leaders, deleted accounts) to drop them.
|
||||
const eligible = await req.payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: { and: [{ id: { in: ids } }, eligibleCampaignStaffWhere] },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { name: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const eligibleIDs = new Set(eligible.docs.map((doc) => doc.id))
|
||||
data.responsibles = [...new Set(ids.filter((id) => eligibleIDs.has(id)))]
|
||||
return data
|
||||
}
|
||||
|
||||
const setCanonicalDemandSlug: CollectionBeforeValidateHook = ({ data, operation, originalDoc }) => {
|
||||
if (!data) return data
|
||||
const title = trimmedText(data.title ?? originalDoc?.title)
|
||||
|
|
@ -127,7 +162,19 @@ const enforceDemandWorkflow: CollectionBeforeChangeHook = async ({
|
|||
const actor = req.user?.collection === 'campaignUser' ? req.user : null
|
||||
|
||||
if (operation === 'create') {
|
||||
if (actor) data.createdBy = actor.id
|
||||
if (actor) {
|
||||
data.createdBy = actor.id
|
||||
|
||||
// C143 — the creator is always a responsible (explicit-responsible
|
||||
// visibility): union with any client-provided list so a create can
|
||||
// never leave its author outside the demand's own visibility.
|
||||
const existingResponsibles = Array.isArray(data.responsibles)
|
||||
? data.responsibles
|
||||
.map((entry) => relationshipId(entry))
|
||||
.filter((id): id is number => id !== null)
|
||||
: []
|
||||
data.responsibles = [...new Set([...existingResponsibles, actor.id])]
|
||||
}
|
||||
|
||||
const initialStatus = isDemandStatus(data.status) ? data.status : 'aberta'
|
||||
data.status = initialStatus
|
||||
|
|
@ -255,6 +302,7 @@ export const CampaignDemand: CollectionConfig = {
|
|||
},
|
||||
hooks: {
|
||||
beforeValidate: [
|
||||
restrictResponsiblesToStaff,
|
||||
setCanonicalDemandSlug,
|
||||
ensureUniqueDemandSlug,
|
||||
validateDemandActivityMunicipality,
|
||||
|
|
@ -322,6 +370,19 @@ export const CampaignDemand: CollectionConfig = {
|
|||
label: 'Liderança solicitante',
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'responsibles',
|
||||
type: 'relationship',
|
||||
relationTo: 'campaignUser',
|
||||
label: 'Responsáveis',
|
||||
hasMany: true,
|
||||
index: true,
|
||||
filterOptions: eligibleCampaignStaffWhere,
|
||||
admin: {
|
||||
description:
|
||||
'Só responsáveis, candidato e coordenador veem a demanda. O criador entra automaticamente.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import {
|
|||
getPostCanonicalPath,
|
||||
getVisiblePosts,
|
||||
} from '@/utilities/posts'
|
||||
import { getInstagramFeed, type InstagramPost } from '@/utilities/socialFeed/instagramFeed'
|
||||
import type { InstagramPost } from '@/utilities/socialFeed/instagramFeed'
|
||||
import { getInstagramFeed } from '@/utilities/socialFeed/instagramFeedView'
|
||||
import {
|
||||
formatYouTubeViews,
|
||||
getYouTubeFeed,
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ export const InstagramPostExclusionPicker: FieldClientComponent = () => {
|
|||
if (posts.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-zinc-500">
|
||||
Nenhum post recente ainda. Com o token e o ID do usuário configurados, os posts aparecem
|
||||
aqui após a primeira sincronização (a cada 5 minutos).
|
||||
Nenhum post sincronizado. Após uma sincronização bem-sucedida, os últimos posts aparecem
|
||||
aqui para marcar os que não devem ir para a home.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
186
src/components/admin/InstagramSyncStatusPanel.tsx
Normal file
186
src/components/admin/InstagramSyncStatusPanel.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
'use client'
|
||||
|
||||
import { useAllFormFields } from '@payloadcms/ui'
|
||||
import type { FieldClientComponent } from 'payload'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type InstagramSyncStatusValue = {
|
||||
lastSyncAt?: string
|
||||
postCount?: number
|
||||
error?: string
|
||||
errorAt?: string
|
||||
}
|
||||
|
||||
type SyncResponse = {
|
||||
ok: boolean
|
||||
status?: InstagramSyncStatusValue
|
||||
error?: string
|
||||
}
|
||||
|
||||
const isSyncStatus = (value: unknown): value is InstagramSyncStatusValue =>
|
||||
typeof value === 'object' && value !== null
|
||||
|
||||
const relativeTimeFormatter = new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' })
|
||||
|
||||
const formatSyncTime = (iso?: string): string => {
|
||||
if (!iso) return ''
|
||||
const time = new Date(iso).getTime()
|
||||
if (Number.isNaN(time)) return ''
|
||||
const seconds = Math.floor((time - Date.now()) / 1000)
|
||||
if (seconds > -60) return 'agora'
|
||||
const absSeconds = -seconds
|
||||
if (absSeconds < 3_600) {
|
||||
return relativeTimeFormatter.format(-Math.floor(absSeconds / 60), 'minute')
|
||||
}
|
||||
if (absSeconds < 86_400) {
|
||||
return relativeTimeFormatter.format(-Math.floor(absSeconds / 3_600), 'hour')
|
||||
}
|
||||
return relativeTimeFormatter.format(-Math.floor(absSeconds / 86_400), 'day')
|
||||
}
|
||||
|
||||
const postCountLabel = (count?: number): string =>
|
||||
count === undefined ? '' : `${count} ${count === 1 ? 'post' : 'posts'}`
|
||||
|
||||
/**
|
||||
* Sync-status panel of the Instagram feed (S11) — the S3 fail-closed silence
|
||||
* made the assessoria blind: a wrong token left no trace beyond an empty
|
||||
* picker. This panel shows, in the same global where the credentials live,
|
||||
* the state of the last sync — "Sincronizado · há X min · N posts",
|
||||
* "Falha na última sincronização" with the product-language reason, or
|
||||
* "Instagram ainda não configurado" — with a "Tentar sincronizar de novo"
|
||||
* button that runs `POST /api/social-feed/sync` (admin session) and renders
|
||||
* the outcome. The status itself is persisted server-side by the render
|
||||
* path, the global's `afterChange` and the sync route; this component only
|
||||
* reads it from the form and keeps the in-flight result locally.
|
||||
*/
|
||||
export const InstagramSyncStatusPanel: FieldClientComponent = () => {
|
||||
const [fields] = useAllFormFields()
|
||||
const [outcome, setOutcome] = useState<SyncResponse | null>(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [requestError, setRequestError] = useState<string | null>(null)
|
||||
|
||||
const enabled = fields.enabled?.value !== false && fields.instagramEnabled?.value !== false
|
||||
const accessToken = fields.instagramAccessToken?.value
|
||||
const userId = fields.instagramUserId?.value
|
||||
const stored = fields.instagramSyncStatus?.value
|
||||
const persisted = isSyncStatus(stored) ? stored : {}
|
||||
|
||||
// The panel shows the SERVER-persisted state; the transient retry result in
|
||||
// `outcome` must not outlive an edit of the credentials it was produced with
|
||||
// (otherwise it keeps showing the failure of the old token pre-save).
|
||||
const credentialKey = typeof accessToken === 'string' ? accessToken : ''
|
||||
const userIdKey = typeof userId === 'string' ? userId : ''
|
||||
useEffect(() => {
|
||||
setOutcome(null)
|
||||
setRequestError(null)
|
||||
}, [credentialKey, userIdKey])
|
||||
|
||||
const configured =
|
||||
typeof accessToken === 'string' &&
|
||||
accessToken !== '' &&
|
||||
typeof userId === 'string' &&
|
||||
userId !== ''
|
||||
|
||||
const status = outcome?.status && isSyncStatus(outcome.status) ? outcome.status : persisted
|
||||
const failureMessage = status.error ?? null
|
||||
const lastSyncAt = status.lastSyncAt
|
||||
const postCount = status.postCount
|
||||
|
||||
const retrySync = async () => {
|
||||
setPending(true)
|
||||
setRequestError(null)
|
||||
try {
|
||||
const response = await fetch('/api/social-feed/sync', { method: 'POST' })
|
||||
const body = (await response.json()) as SyncResponse
|
||||
if (response.status === 401) {
|
||||
setRequestError(
|
||||
'Sessão do admin expirada. Salve a página e recarregue o admin para reautenticar.',
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!response.ok || body.error) {
|
||||
throw new Error(body.error ?? 'Falha na sincronização')
|
||||
}
|
||||
setOutcome(body)
|
||||
} catch {
|
||||
setRequestError('Não foi possível sincronizar agora. Tente novamente em instantes.')
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const retryButton = (label: string = 'Tentar sincronizar de novo') => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={retrySync}
|
||||
disabled={pending}
|
||||
aria-busy={pending}
|
||||
className="mt-3 rounded-md bg-red-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{pending ? 'Sincronizando…' : label}
|
||||
</button>
|
||||
)
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-300 bg-white p-4">
|
||||
<p className="text-sm font-bold text-zinc-800">Instagram desativado</p>
|
||||
<p className="mt-1 max-w-2xl text-sm text-zinc-500">
|
||||
Os posts do Instagram não aparecem no board enquanto o feed estiver desativado.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!configured) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-300 bg-white p-4">
|
||||
<p className="text-sm font-bold text-zinc-800">Instagram ainda não configurado</p>
|
||||
<p className="mt-1 max-w-2xl text-sm text-zinc-500">
|
||||
Informe o token de acesso e o ID da conta para publicar posts do Instagram na home. Sem
|
||||
configuração, o board funciona com artigos e YouTube.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (failureMessage) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-300 bg-red-50 p-4" aria-live="polite">
|
||||
<p className="text-sm font-bold text-red-800">Falha na última sincronização</p>
|
||||
<p className="mt-1 max-w-2xl text-sm text-red-700">{failureMessage}</p>
|
||||
{requestError ? <p className="mt-2 text-sm text-red-700">{requestError}</p> : null}
|
||||
{retryButton()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (lastSyncAt) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-300 bg-white p-4">
|
||||
<p className="text-sm font-bold text-zinc-800">
|
||||
Sincronizado · {formatSyncTime(lastSyncAt)}
|
||||
{postCount !== undefined ? ` · ${postCountLabel(postCount)}` : ''}
|
||||
</p>
|
||||
<p className="mt-1 max-w-2xl text-sm text-zinc-500">
|
||||
Próxima atualização automática em ~5 min.{' '}
|
||||
{postCount !== undefined
|
||||
? `Os ${postCountLabel(postCount)} mais recentes estão no board "Acompanhe de perto" da home.`
|
||||
: 'Os posts mais recentes estão no board "Acompanhe de perto" da home.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-300 bg-white p-4" aria-live="polite">
|
||||
<p className="text-sm font-bold text-zinc-800">Aguardando a primeira sincronização</p>
|
||||
<p className="mt-1 max-w-2xl text-sm text-zinc-500">
|
||||
Clique em "Tentar sincronizar" para buscar os posts do Instagram agora, ou aguarde
|
||||
a próxima atualização automática.
|
||||
</p>
|
||||
{requestError ? <p className="mt-2 text-sm text-red-700">{requestError}</p> : null}
|
||||
{retryButton('Tentar sincronizar')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
'use client'
|
||||
|
||||
import {
|
||||
DemandResponsibleMultiSelect,
|
||||
type DemandResponsibleOption,
|
||||
} from '@/components/campaign/demand/DemandResponsibleMultiSelect'
|
||||
import { AsyncSearchCombobox } from '@/components/campaign/shared/AsyncSearchCombobox'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -16,6 +20,8 @@ import {
|
|||
CAMPAIGN_DEMAND_BODY_MAX_LENGTH,
|
||||
CAMPAIGN_DEMAND_BODY_PLACEHOLDER,
|
||||
CAMPAIGN_DEMAND_KIND_LABEL,
|
||||
CAMPAIGN_DEMAND_RESPONSIBLES_DESCRIPTION,
|
||||
CAMPAIGN_DEMAND_RESPONSIBLES_LABEL,
|
||||
campaignDemandKindLabels,
|
||||
campaignDemandKinds,
|
||||
} from '@/lib/schemas/campaignDemand'
|
||||
|
|
@ -38,6 +44,12 @@ type DemandFieldsProps = {
|
|||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
}
|
||||
/** C143 — explicit-responsible picker; `aside` splits a right column (nova). */
|
||||
responsibles?: {
|
||||
currentUser: { id: number; name: string } | null
|
||||
search: (query: string) => Promise<RelationOption[]>
|
||||
layout: 'aside' | 'stacked'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -53,6 +65,7 @@ export const DemandFields = ({
|
|||
onActivityChange,
|
||||
searchActivities,
|
||||
municipality,
|
||||
responsibles,
|
||||
}: DemandFieldsProps) => {
|
||||
const municipalityReady = municipality ? municipality.value !== '' : true
|
||||
|
||||
|
|
@ -114,7 +127,7 @@ export const DemandFields = ({
|
|||
</Field>
|
||||
)
|
||||
|
||||
return (
|
||||
const coreFields = (
|
||||
<>
|
||||
{municipality ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
|
|
@ -172,4 +185,47 @@ export const DemandFields = ({
|
|||
</Field>
|
||||
</>
|
||||
)
|
||||
|
||||
if (!responsibles) return coreFields
|
||||
|
||||
const creatorValue: DemandResponsibleOption[] = responsibles.currentUser
|
||||
? [
|
||||
{
|
||||
id: responsibles.currentUser.id,
|
||||
name: responsibles.currentUser.name,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const responsiblesField = (
|
||||
<Field>
|
||||
<FieldLabel>{CAMPAIGN_DEMAND_RESPONSIBLES_LABEL}</FieldLabel>
|
||||
<DemandResponsibleMultiSelect
|
||||
name="responsibles"
|
||||
value={creatorValue}
|
||||
creatorUserId={responsibles.currentUser?.id ?? null}
|
||||
search={responsibles.search}
|
||||
triggerPlaceholder="Buscar assessor…"
|
||||
triggerAriaLabel={CAMPAIGN_DEMAND_RESPONSIBLES_LABEL}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<FieldDescription>{CAMPAIGN_DEMAND_RESPONSIBLES_DESCRIPTION}</FieldDescription>
|
||||
</Field>
|
||||
)
|
||||
|
||||
if (responsibles.layout === 'aside') {
|
||||
return (
|
||||
<div className="grid items-start gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
<div className="flex flex-col gap-4">{coreFields}</div>
|
||||
{responsiblesField}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{coreFields}
|
||||
{responsiblesField}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useActionState, useCallback, useState } from 'react'
|
||||
|
||||
import { searchDemandResponsibleOptions } from '@/app/(campaign)/campanha/(app)/demandas/responsibleSearchActions'
|
||||
import { DemandFields, type DemandActivityValue } from '@/components/campaign/demand/DemandFields'
|
||||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
|
|
@ -15,6 +16,7 @@ type DemandFormProps = {
|
|||
municipalityOptions: RelationOption[]
|
||||
initialActivity?: ActivityRelationOption | null
|
||||
initialMunicipalityId?: number
|
||||
currentUser?: { id: number; name: string } | null
|
||||
searchActivities: (
|
||||
query: string,
|
||||
municipalityId: number | null,
|
||||
|
|
@ -29,6 +31,7 @@ export const DemandForm = ({
|
|||
municipalityOptions,
|
||||
initialActivity = null,
|
||||
initialMunicipalityId,
|
||||
currentUser = null,
|
||||
searchActivities,
|
||||
formAction,
|
||||
}: DemandFormProps) => {
|
||||
|
|
@ -45,9 +48,13 @@ export const DemandForm = ({
|
|||
(query: string) => searchActivities(query, parsedMunicipalityId),
|
||||
[parsedMunicipalityId, searchActivities],
|
||||
)
|
||||
const searchResponsibles = useCallback(
|
||||
(query: string) => searchDemandResponsibleOptions(query, parsedMunicipalityId),
|
||||
[parsedMunicipalityId],
|
||||
)
|
||||
|
||||
return (
|
||||
<form action={submitAction} className="flex max-w-2xl flex-col gap-4">
|
||||
<form action={submitAction} className="flex max-w-3xl flex-col gap-4">
|
||||
<DemandFields
|
||||
idPrefix="demand"
|
||||
state={state}
|
||||
|
|
@ -59,6 +66,11 @@ export const DemandForm = ({
|
|||
value: municipalityId,
|
||||
onValueChange: setMunicipalityId,
|
||||
}}
|
||||
responsibles={{
|
||||
currentUser,
|
||||
search: searchResponsibles,
|
||||
layout: 'aside',
|
||||
}}
|
||||
/>
|
||||
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
|
||||
<Button type="submit" disabled={isPending} className="min-h-11 self-start">
|
||||
|
|
|
|||
216
src/components/campaign/demand/DemandResponsibleMultiSelect.tsx
Normal file
216
src/components/campaign/demand/DemandResponsibleMultiSelect.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronsUpDownIcon, XIcon } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/Command'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { campaignUserInitials } from '@/utilities/campaignUserProfile'
|
||||
|
||||
export type DemandResponsibleOption = RelationOption & { avatarUrl?: string | null }
|
||||
|
||||
type DemandResponsibleMultiSelectProps = {
|
||||
/** FormData field name — repeated hidden inputs, one per selected id. */
|
||||
name: string
|
||||
value: DemandResponsibleOption[]
|
||||
/** When set, this id renders as "(criador)" (informational marker). */
|
||||
creatorUserId?: number | null
|
||||
/** Create flow: the creator chip cannot be removed (the hook re-adds it). */
|
||||
lockCreator?: boolean
|
||||
/** Server action — short queries return municipality-advisor suggestions. */
|
||||
search: (query: string) => Promise<RelationOption[]>
|
||||
/** Trigger text while empty, e.g. "Buscar assessor…". */
|
||||
triggerPlaceholder: string
|
||||
/** Accessible name of the add trigger (the list renders its own sr-only names). */
|
||||
triggerAriaLabel: string
|
||||
disabled?: boolean
|
||||
/** Fires on every change with the full selected id list (dirty tracking). */
|
||||
onChange?: (ids: number[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* C143 — multi-value responsible picker for campaign demands (staff only).
|
||||
* The chips carry the selected list as repeated hidden inputs; the search
|
||||
* catalog is the `searchDemandResponsibleOptions` action, whose short-query
|
||||
* branch returns the municipality's advisors as suggestions (a fill-in
|
||||
* shortcut — visibility comes only from the saved list).
|
||||
*/
|
||||
export const DemandResponsibleMultiSelect = ({
|
||||
name,
|
||||
value,
|
||||
creatorUserId = null,
|
||||
lockCreator = true,
|
||||
search,
|
||||
triggerPlaceholder,
|
||||
triggerAriaLabel,
|
||||
disabled,
|
||||
onChange,
|
||||
}: DemandResponsibleMultiSelectProps) => {
|
||||
const [selected, setSelected] = useState<DemandResponsibleOption[]>(value)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [options, setOptions] = useState<RelationOption[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const requestId = useRef(0)
|
||||
const searchRef = useRef(search)
|
||||
|
||||
searchRef.current = search
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const currentRequestId = ++requestId.current
|
||||
const timeout = window.setTimeout(() => {
|
||||
setLoading(true)
|
||||
setFailed(false)
|
||||
void searchRef
|
||||
.current(query.trim())
|
||||
.then((nextOptions) => {
|
||||
if (requestId.current !== currentRequestId) return
|
||||
setOptions(nextOptions)
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestId.current !== currentRequestId) return
|
||||
setFailed(true)
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId.current === currentRequestId) setLoading(false)
|
||||
})
|
||||
}, 250)
|
||||
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [open, query])
|
||||
|
||||
const isCreator = (id: number) => creatorUserId != null && creatorUserId === id
|
||||
const selectedIds = new Set(selected.map((option) => option.id))
|
||||
|
||||
// Discrete user events re-render between calls, so reading `selected` here
|
||||
// is safe (same shape as `RelationMultiSelect`); an updater side effect
|
||||
// would double-fire onChange under StrictMode.
|
||||
const add = (option: RelationOption) => {
|
||||
if (selectedIds.has(option.id)) return
|
||||
const next = [...selected, { ...option, avatarUrl: undefined }]
|
||||
setSelected(next)
|
||||
onChange?.(next.map((entry) => entry.id))
|
||||
}
|
||||
|
||||
const remove = (option: DemandResponsibleOption) => {
|
||||
if (isCreator(option.id) && lockCreator) return
|
||||
const next = selected.filter((entry) => entry.id !== option.id)
|
||||
setSelected(next)
|
||||
onChange?.(next.map((entry) => entry.id))
|
||||
}
|
||||
|
||||
const visibleOptions = options.filter((option) => !selectedIds.has(option.id))
|
||||
const shortQuery = query.trim().length < 2
|
||||
const selectedLabel =
|
||||
selected.length === 0
|
||||
? triggerPlaceholder
|
||||
: `${selected.length} ${selected.length === 1 ? 'responsável' : 'responsáveis'}`
|
||||
|
||||
return (
|
||||
<>
|
||||
{selected.length ? (
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{selected.map((option) => (
|
||||
<li key={option.id}>
|
||||
<Badge variant="secondary" className="gap-1.5 pr-1">
|
||||
{option.avatarUrl ? (
|
||||
<Avatar size="sm" className="shrink-0">
|
||||
<AvatarImage src={option.avatarUrl} alt="" />
|
||||
<AvatarFallback>{campaignUserInitials(option.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
) : null}
|
||||
<input type="hidden" name={name} value={option.id} />
|
||||
<span className="font-normal">{option.name}</span>
|
||||
{isCreator(option.id) ? (
|
||||
<span className="text-muted-foreground">(criador)</span>
|
||||
) : null}
|
||||
{!isCreator(option.id) ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remover ${option.name}`}
|
||||
className="inline-flex size-6 items-center justify-center rounded-full hover:bg-foreground/10"
|
||||
disabled={disabled}
|
||||
onClick={() => remove(option)}
|
||||
>
|
||||
<XIcon className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 w-full justify-between font-normal"
|
||||
aria-label={`${triggerAriaLabel}: ${selectedLabel}`}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span className="truncate">{triggerPlaceholder}</span>
|
||||
<ChevronsUpDownIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={triggerAriaLabel}
|
||||
description="Busque por nome e escolha um ou mais responsáveis."
|
||||
showCloseButton
|
||||
>
|
||||
<Command shouldFilter={false} label={triggerAriaLabel}>
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder="Buscar assessor…"
|
||||
aria-label={triggerAriaLabel}
|
||||
/>
|
||||
<CommandList aria-busy={loading}>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
<Spinner aria-hidden="true" />
|
||||
Buscando…
|
||||
</div>
|
||||
) : failed ? (
|
||||
<p className="py-6 text-center text-sm text-destructive">
|
||||
Não foi possível concluir a busca.
|
||||
</p>
|
||||
) : visibleOptions.length > 0 ? (
|
||||
<CommandGroup heading={shortQuery ? 'Sugestões do município' : 'Assessores'}>
|
||||
{visibleOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={`${option.name}-${option.id}`}
|
||||
onSelect={() => add(option)}
|
||||
>
|
||||
<span className="truncate">{option.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{shortQuery
|
||||
? 'Sem assessores sugeridos para este município.'
|
||||
: 'Nenhum resultado encontrado.'}
|
||||
</p>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
90
src/components/campaign/demand/DemandResponsiblesCard.tsx
Normal file
90
src/components/campaign/demand/DemandResponsiblesCard.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
'use client'
|
||||
|
||||
import { useActionState, useEffect, useState } from 'react'
|
||||
|
||||
import { searchDemandResponsibleOptions } from '@/app/(campaign)/campanha/(app)/demandas/responsibleSearchActions'
|
||||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { CAMPAIGN_DEMAND_RESPONSIBLES_LABEL } from '@/lib/schemas/campaignDemand'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import {
|
||||
DemandResponsibleMultiSelect,
|
||||
type DemandResponsibleOption,
|
||||
} from './DemandResponsibleMultiSelect'
|
||||
|
||||
type DemandResponsiblesCardProps = {
|
||||
demandId: number
|
||||
municipalityId: number | null
|
||||
/** The creator id, shown as "(criador)" on the chip (removal stays allowed — hand-off). */
|
||||
creatorUserId: number | null
|
||||
initialResponsibles: DemandResponsibleOption[]
|
||||
formAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
}
|
||||
|
||||
/**
|
||||
* C143 — responsible management on the demand detail. Everyone who can read
|
||||
* the demand can update it (same row scope), so the card's edit controls are
|
||||
* available to whoever opens the page; the save submits the full list, the
|
||||
* action revalidates the page and the local state keeps the saved chips
|
||||
* (the success message is the confirmation, so no remount key is used).
|
||||
*/
|
||||
export const DemandResponsiblesCard = ({
|
||||
demandId,
|
||||
municipalityId,
|
||||
creatorUserId,
|
||||
initialResponsibles,
|
||||
formAction,
|
||||
}: DemandResponsiblesCardProps) => {
|
||||
const [state, submitAction, isPending] = useActionState(formAction, {})
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'success') setDirty(false)
|
||||
}, [state.status])
|
||||
|
||||
const search = (query: string) => searchDemandResponsibleOptions(query, municipalityId)
|
||||
|
||||
return (
|
||||
<form action={submitAction} className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-base font-medium">{CAMPAIGN_DEMAND_RESPONSIBLES_LABEL}</h2>
|
||||
<p className="text-sm text-muted-foreground">Só quem é responsável vê esta demanda.</p>
|
||||
</div>
|
||||
|
||||
<DemandResponsibleMultiSelect
|
||||
name="responsibles"
|
||||
value={initialResponsibles}
|
||||
creatorUserId={creatorUserId}
|
||||
lockCreator={false}
|
||||
search={search}
|
||||
triggerPlaceholder="Adicionar responsável…"
|
||||
triggerAriaLabel={CAMPAIGN_DEMAND_RESPONSIBLES_LABEL}
|
||||
disabled={isPending}
|
||||
onChange={() => setDirty(true)}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
O criador já entra como responsável automaticamente. Assessores do município aparecem como
|
||||
sugestão, mas só quem for marcado enxerga.
|
||||
</p>
|
||||
|
||||
<CampaignFormActionMessage state={state} />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="min-h-9 self-start"
|
||||
disabled={isPending || !dirty}
|
||||
>
|
||||
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
|
||||
Salvar responsáveis
|
||||
</Button>
|
||||
|
||||
<input type="hidden" name="demandId" value={demandId} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { useActionState, useCallback, useState } from 'react'
|
|||
|
||||
import { createWizardDemandFormAction } from '@/app/(campaign)/campanha/(app)/acoes/formActions'
|
||||
import { searchDemandActivityOptions } from '@/app/(campaign)/campanha/(app)/demandas/activitySearchActions'
|
||||
import { searchDemandResponsibleOptions } from '@/app/(campaign)/campanha/(app)/demandas/responsibleSearchActions'
|
||||
import { DemandFields, type DemandActivityValue } from '@/components/campaign/demand/DemandFields'
|
||||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
|
||||
|
|
@ -21,6 +22,7 @@ type WizardRegisterDemandStepProps = {
|
|||
municipalityName: string
|
||||
municipalitySlug: string
|
||||
returnPath?: string
|
||||
currentUser?: { id: number; name: string } | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -34,6 +36,7 @@ export const WizardRegisterDemandStep = ({
|
|||
municipalityName,
|
||||
municipalitySlug,
|
||||
returnPath,
|
||||
currentUser = null,
|
||||
}: WizardRegisterDemandStepProps) => {
|
||||
const router = useRouter()
|
||||
const [state, submitAction, isPending] = useActionState(createWizardDemandFormAction, {})
|
||||
|
|
@ -43,6 +46,10 @@ export const WizardRegisterDemandStep = ({
|
|||
(query: string) => searchDemandActivityOptions(query, municipalityId),
|
||||
[municipalityId],
|
||||
)
|
||||
const searchResponsibles = useCallback(
|
||||
(query: string) => searchDemandResponsibleOptions(query, municipalityId),
|
||||
[municipalityId],
|
||||
)
|
||||
|
||||
useCampaignFormSuccessToast(state, () => {
|
||||
recordLastActedMunicipality(municipalitySlug)
|
||||
|
|
@ -79,6 +86,11 @@ export const WizardRegisterDemandStep = ({
|
|||
activity={activity}
|
||||
onActivityChange={setActivity}
|
||||
searchActivities={searchActivities}
|
||||
responsibles={{
|
||||
currentUser,
|
||||
search: searchResponsibles,
|
||||
layout: 'stacked',
|
||||
}}
|
||||
/>
|
||||
|
||||
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest'
|
||||
import {
|
||||
INSTAGRAM_SYNC_TIMEOUT_MS,
|
||||
instagramCredentialsChanged,
|
||||
syncInstagramFeed,
|
||||
} from '@/utilities/socialFeed/instagramSync'
|
||||
import { revalidateTag } from 'next/cache'
|
||||
import type { GlobalConfig } from 'payload'
|
||||
import type { GlobalAfterChangeHook, GlobalConfig } from 'payload'
|
||||
|
||||
const slug = 'social-feed-settings'
|
||||
|
||||
|
|
@ -9,6 +14,33 @@ const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/
|
|||
|
||||
const revalidateFeed = async () => revalidateTag(REVALIDATE_SOCIAL_FEED_TAG)
|
||||
|
||||
/**
|
||||
* Re-syncs Instagram when a save changes the IG credentials (the primary
|
||||
* flow: the assessoria fills token + ID, saves, and the reload of the global
|
||||
* already shows the sync state WITH the new credentials — the status panel
|
||||
* would otherwise keep showing the last failure of the old token). Awaited
|
||||
* with a bounded deadline so a hanging Graph API never stalls the save; any
|
||||
* failure is swallowed — the sync's own status IS the result, and a broken
|
||||
* sync must never break the save.
|
||||
*
|
||||
* The sync modules must not import `@payload-config` (they do not), so the
|
||||
* static import here closes no cycle: `payload.config → this global →
|
||||
* instagramSync → instagramFeed` ends at `instagramFeed`, which is pure.
|
||||
*/
|
||||
const syncInstagramAfterChange: GlobalAfterChangeHook = async ({ doc, previousDoc, req }) => {
|
||||
if (!instagramCredentialsChanged(doc, previousDoc)) return
|
||||
try {
|
||||
// `req` lets the sync write inside this very transaction (same row lock
|
||||
// held by the save) instead of blocking a second pool connection.
|
||||
await syncInstagramFeed(req.payload, {
|
||||
signal: AbortSignal.timeout(INSTAGRAM_SYNC_TIMEOUT_MS),
|
||||
req,
|
||||
})
|
||||
} catch {
|
||||
// the status panel reflects the failure; the save itself stays green
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration of the campaign home content board's external feeds (plano-site
|
||||
* §4.2): YouTube now, Instagram in S3. The board's read path is the public home
|
||||
|
|
@ -29,7 +61,7 @@ export const SocialFeedSettings: GlobalConfig = {
|
|||
update: payloadAdminOnly,
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [revalidateFeed],
|
||||
afterChange: [revalidateFeed, syncInstagramAfterChange],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
|
|
@ -88,6 +120,16 @@ export const SocialFeedSettings: GlobalConfig = {
|
|||
description: 'Desliga só os cards de post do Instagram.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'instagramSyncStatusPanel',
|
||||
type: 'ui',
|
||||
label: 'Instagram — estado da sincronização',
|
||||
admin: {
|
||||
components: {
|
||||
Field: './components/admin/InstagramSyncStatusPanel#InstagramSyncStatusPanel',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'instagramAccessToken',
|
||||
type: 'text',
|
||||
|
|
@ -185,5 +227,12 @@ export const SocialFeedSettings: GlobalConfig = {
|
|||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'instagramSyncStatus',
|
||||
type: 'json',
|
||||
admin: {
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,11 @@ export const CAMPAIGN_DEMAND_COST_STAFF_MESSAGE =
|
|||
'Somente a coordenação, a assessoria e o candidato registram custos.'
|
||||
export const CAMPAIGN_DEMAND_EDIT_STAFF_MESSAGE =
|
||||
'Somente a coordenação, a assessoria e o candidato editam demandas.'
|
||||
export const CAMPAIGN_DEMAND_RESPONSIBLES_STAFF_MESSAGE =
|
||||
'Somente quem vê a demanda pode gerenciar os responsáveis.'
|
||||
export const CAMPAIGN_DEMAND_RESPONSIBLES_LABEL = 'Responsáveis pela demanda'
|
||||
export const CAMPAIGN_DEMAND_RESPONSIBLES_DESCRIPTION =
|
||||
'Sugestões: assessores do município. Se ninguém for marcado além de você, só você, candidato e coordenador verão a demanda.'
|
||||
export const CAMPAIGN_DEMAND_RECEIPT_STAFF_MESSAGE =
|
||||
'Somente a coordenação e a assessoria anexam comprovantes.'
|
||||
export const CAMPAIGN_DEMAND_RECEIPT_TYPE_MESSAGE = 'Envie uma imagem (JPEG, PNG, WebP) ou PDF.'
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export const campaignDemandCreateSchema = z.object({
|
|||
activity: positiveRelationshipId.optional(),
|
||||
/** Staff may record on behalf of a leadership; leaders get their own. */
|
||||
leadership: positiveRelationshipId.optional(),
|
||||
/** C143 — explicit responsibles; the creator is unioned in by the collection hook. */
|
||||
responsibles: z.array(positiveRelationshipId).default([]),
|
||||
})
|
||||
|
||||
export const campaignDemandUpdateSchema = z.object({
|
||||
|
|
@ -36,7 +38,14 @@ export const campaignDemandCostSchema = z.object({
|
|||
cost: z.number().min(0).max(100_000_000).nullable(),
|
||||
})
|
||||
|
||||
/** C143 — full-replace of the responsible list (advisory-locked by the action). */
|
||||
export const campaignDemandResponsiblesSchema = z.object({
|
||||
id: positiveRelationshipId,
|
||||
responsibles: z.array(positiveRelationshipId).default([]),
|
||||
})
|
||||
|
||||
export type CampaignDemandCreateInput = z.input<typeof campaignDemandCreateSchema>
|
||||
export type CampaignDemandUpdateInput = z.input<typeof campaignDemandUpdateSchema>
|
||||
export type CampaignDemandTransitionInput = z.input<typeof campaignDemandTransitionSchema>
|
||||
export type CampaignDemandCostInput = z.input<typeof campaignDemandCostSchema>
|
||||
export type CampaignDemandResponsiblesInput = z.input<typeof campaignDemandResponsiblesSchema>
|
||||
|
|
|
|||
13783
src/migrations/20260819_233503_add_campaign_demand_responsibles.json
Normal file
13783
src/migrations/20260819_233503_add_campaign_demand_responsibles.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,29 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "campaign_demand_rels" ADD COLUMN "campaign_user_id" integer;
|
||||
ALTER TABLE "campaign_demand_rels" ADD CONSTRAINT "campaign_demand_rels_campaign_user_fk" FOREIGN KEY ("campaign_user_id") REFERENCES "public"."campaign_user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "campaign_demand_rels_campaign_user_id_idx" ON "campaign_demand_rels" USING btree ("campaign_user_id");
|
||||
|
||||
-- C143 backfill: pre-existing demands keep their creator as responsible, so
|
||||
-- the explicit-responsible rule does not hide a demand from its own author.
|
||||
DO $$
|
||||
DECLARE backfilled integer;
|
||||
BEGIN
|
||||
INSERT INTO "campaign_demand_rels" ("order", "parent_id", "path", "campaign_user_id")
|
||||
SELECT 0, "id", 'responsibles', "created_by_id"
|
||||
FROM "campaign_demand"
|
||||
WHERE "created_by_id" IS NOT NULL;
|
||||
GET DIAGNOSTICS backfilled = ROW_COUNT;
|
||||
RAISE NOTICE 'add_campaign_demand_responsibles: backfilling % demanda(s) com o criador como responsável', backfilled;
|
||||
END $$;`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "campaign_demand_rels" DROP CONSTRAINT "campaign_demand_rels_campaign_user_fk";
|
||||
|
||||
DROP INDEX "campaign_demand_rels_campaign_user_id_idx";
|
||||
ALTER TABLE "campaign_demand_rels" DROP COLUMN "campaign_user_id";`)
|
||||
}
|
||||
13755
src/migrations/20260820_003426_add_instagram_sync_status.json
Normal file
13755
src/migrations/20260820_003426_add_instagram_sync_status.json
Normal file
File diff suppressed because it is too large
Load diff
11
src/migrations/20260820_003426_add_instagram_sync_status.ts
Normal file
11
src/migrations/20260820_003426_add_instagram_sync_status.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "social_feed_settings" ADD COLUMN "instagram_sync_status" jsonb;`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "social_feed_settings" DROP COLUMN "instagram_sync_status";`)
|
||||
}
|
||||
|
|
@ -59,6 +59,8 @@ import * as migration_20260818_192948_add_instagram_social_feed_fields from './2
|
|||
import * as migration_20260819_192605_add_campaign_newsletter_capture from './20260819_192605_add_campaign_newsletter_capture';
|
||||
import * as migration_20260819_213947_add_site_settings_facebook_pixel_id from './20260819_213947_add_site_settings_facebook_pixel_id';
|
||||
import * as migration_20260819_214755_add_campaign_user_advisor_permission_profile from './20260819_214755_add_campaign_user_advisor_permission_profile';
|
||||
import * as migration_20260819_233503_add_campaign_demand_responsibles from './20260819_233503_add_campaign_demand_responsibles';
|
||||
import * as migration_20260820_003426_add_instagram_sync_status from './20260820_003426_add_instagram_sync_status';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -366,4 +368,14 @@ export const migrations = [
|
|||
down: migration_20260819_214755_add_campaign_user_advisor_permission_profile.down,
|
||||
name: '20260819_214755_add_campaign_user_advisor_permission_profile',
|
||||
},
|
||||
{
|
||||
up: migration_20260819_233503_add_campaign_demand_responsibles.up,
|
||||
down: migration_20260819_233503_add_campaign_demand_responsibles.down,
|
||||
name: '20260819_233503_add_campaign_demand_responsibles',
|
||||
},
|
||||
{
|
||||
up: migration_20260820_003426_add_instagram_sync_status.up,
|
||||
down: migration_20260820_003426_add_instagram_sync_status.down,
|
||||
name: '20260820_003426_add_instagram_sync_status',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -725,6 +725,10 @@ export interface CampaignDemand {
|
|||
municipality: number | Municipality;
|
||||
activity?: (number | null) | Activity;
|
||||
leadership?: (number | null) | Leadership;
|
||||
/**
|
||||
* Só responsáveis, candidato e coordenador veem a demanda. O criador entra automaticamente.
|
||||
*/
|
||||
responsibles?: (number | CampaignUser)[] | null;
|
||||
status: 'aberta' | 'em_analise' | 'escalada' | 'aprovada' | 'rejeitada';
|
||||
decisionNote?: string | null;
|
||||
decidedBy?: (number | null) | CampaignUser;
|
||||
|
|
@ -1791,6 +1795,7 @@ export interface CampaignDemandSelect<T extends boolean = true> {
|
|||
municipality?: T;
|
||||
activity?: T;
|
||||
leadership?: T;
|
||||
responsibles?: T;
|
||||
status?: T;
|
||||
decisionNote?: T;
|
||||
decidedBy?: T;
|
||||
|
|
@ -2461,6 +2466,15 @@ export interface SocialFeedSetting {
|
|||
| number
|
||||
| boolean
|
||||
| null;
|
||||
instagramSyncStatus?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
|
@ -2578,6 +2592,7 @@ export interface SocialFeedSettingsSelect<T extends boolean = true> {
|
|||
};
|
||||
youtubeFeedSnapshot?: T;
|
||||
instagramFeedSnapshot?: T;
|
||||
instagramSyncStatus?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// Campaign demands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { Access } from 'payload'
|
||||
import type { Access, Where } from 'payload'
|
||||
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
|
||||
|
|
@ -12,7 +12,6 @@ import {
|
|||
isCampaignLeader,
|
||||
isCampaignUnrestricted,
|
||||
isPayloadAdmin,
|
||||
resolveActorScopedRead,
|
||||
} from '@/utilities/access/shared'
|
||||
|
||||
export const canCreateCampaignDemand: Access = async ({ data, req }) => {
|
||||
|
|
@ -34,8 +33,24 @@ export const canCreateCampaignDemand: Access = async ({ data, req }) => {
|
|||
return municipalityIDs?.includes(municipalityID) ?? false
|
||||
}
|
||||
|
||||
export const canReadCampaignDemand: Access = ({ req }) =>
|
||||
resolveActorScopedRead(req, 'municipality', getAccessibleMunicipalityIds)
|
||||
/**
|
||||
* C143 — explicit-responsible visibility, fail-closed. An advisor reads ONLY
|
||||
* demands where they are a listed responsible (the creator is auto-added on
|
||||
* create); a municipality-related advisor who is not responsible sees nothing
|
||||
* — not the list, not the URL, not the search (all reads run the collection
|
||||
* access). Candidate and coordinator (unrestricted) always see everything.
|
||||
*/
|
||||
export const canReadCampaignDemand: Access = async ({ req }): Promise<boolean | Where> => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
const currentUser = await getFreshCampaignUser(req)
|
||||
if (!currentUser || isCampaignLeader(currentUser)) return false
|
||||
if (isCampaignUnrestricted(currentUser)) return true
|
||||
|
||||
if (currentUser.role !== 'advisor') return false
|
||||
|
||||
return { responsibles: { contains: currentUser.id } }
|
||||
}
|
||||
|
||||
/**
|
||||
* C141 — demands stay out of the profile axes (C143 replaces this rule with
|
||||
|
|
@ -49,7 +64,7 @@ export const canUpdateCampaignDemand: Access = async ({ req }) => {
|
|||
if (isCampaignUnrestricted(currentUser)) return true
|
||||
if (advisorEditingAccess(currentUser) === 'none') return false
|
||||
|
||||
return resolveActorScopedRead(req, 'municipality', getAccessibleMunicipalityIds)
|
||||
return canReadCampaignDemand({ req })
|
||||
}
|
||||
|
||||
export const canDeleteCampaignDemand: Access = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
type DemandListState,
|
||||
} from '@/utilities/demand/demandListUrl'
|
||||
import {
|
||||
loadCampaignUserDisplayByIds,
|
||||
loadCampaignUserNamesByIds,
|
||||
loadLeadershipContactNamesByIds,
|
||||
loadMunicipalityLabelsByIds,
|
||||
|
|
@ -148,6 +149,10 @@ export type DemandDetailViewModel = DemandRowViewModel & {
|
|||
authorName: string | null
|
||||
createdAt: string | null
|
||||
}>
|
||||
/** C143 — explicit responsibles (creator included by the collection hook). */
|
||||
responsibles: Array<{ id: number; name: string; avatarUrl: string | null }>
|
||||
/** C143 — the creator id, for the "(criador)" chip marker on the detail. */
|
||||
createdBy: number | null
|
||||
canLeaderEdit: boolean
|
||||
activity: { title: string; slug: string } | null
|
||||
}
|
||||
|
|
@ -183,11 +188,21 @@ export const loadDemandDetail = async (
|
|||
]
|
||||
const authorNameById = await loadCampaignUserNamesByIds(payload, authorIDs)
|
||||
|
||||
const responsibleIDs = (demand.responsibles ?? [])
|
||||
.map((responsible) => relationshipId(responsible))
|
||||
.filter((id): id is number => id !== null)
|
||||
const responsibleById = await loadCampaignUserDisplayByIds(payload, responsibleIDs)
|
||||
|
||||
return {
|
||||
...row,
|
||||
description: demand.description ?? null,
|
||||
decisionNote: demand.decisionNote ?? null,
|
||||
decidedAt: demand.decidedAt ?? null,
|
||||
createdBy: relationshipId(demand.createdBy),
|
||||
responsibles: responsibleIDs.flatMap((id) => {
|
||||
const display = responsibleById.get(id)
|
||||
return display ? [{ id, ...display }] : []
|
||||
}),
|
||||
cost: isCampaignStaff(user) ? (demand.cost ?? null) : null,
|
||||
receipts: isCampaignStaff(user)
|
||||
? (demand.receipts ?? []).flatMap((receipt) =>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
nullableRelationshipFormValue,
|
||||
repeatedRelationshipFormValues,
|
||||
requiredFormText,
|
||||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
|
|
@ -22,5 +23,6 @@ export const parseCampaignDemandCreateFormData = (
|
|||
description: requiredFormText(formData, 'description'),
|
||||
municipality: requiredRelationshipFormValue(formData, 'municipalityId'),
|
||||
activity: nullableRelationshipFormValue(formData, 'activityId') ?? undefined,
|
||||
responsibles: repeatedRelationshipFormValues(formData, 'responsibles'),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { SocialFeedSetting } from '@/payload-types'
|
||||
import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest'
|
||||
import configPromise from '@payload-config'
|
||||
import { sql } from '@payloadcms/db-postgres'
|
||||
import { unstable_cache } from 'next/cache'
|
||||
import { getPayload, type Payload } from 'payload'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
const INSTAGRAM_API_BASE_URL = process.env.INSTAGRAM_API_BASE_URL ?? 'https://graph.instagram.com'
|
||||
|
||||
|
|
@ -33,6 +30,8 @@ export type LoadInstagramFeedArgs = {
|
|||
maxResults: number
|
||||
fetchImpl?: (input: string, init?: RequestInit) => Promise<Response>
|
||||
baseUrl?: string
|
||||
/** Deadline for the Graph API calls (hook-triggered sync must not hang a save). */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type LoadInstagramFeedResult = InstagramFeedResult & {
|
||||
|
|
@ -40,6 +39,57 @@ export type LoadInstagramFeedResult = InstagramFeedResult & {
|
|||
refreshedAccessToken?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* API-level failure of the Instagram Graph API: carries the HTTP status and
|
||||
* the parsed `{ error: { message, type } }` body when present, so
|
||||
* `describeInstagramError` can turn it into product language (invalid token
|
||||
* vs wrong user id vs API down) for the admin sync-status panel.
|
||||
*/
|
||||
export class InstagramApiError extends Error {
|
||||
readonly status: number | undefined
|
||||
readonly apiMessage: string | null
|
||||
readonly apiType: string | null
|
||||
|
||||
constructor(
|
||||
status: number | undefined,
|
||||
apiMessage: string | null,
|
||||
apiType: string | null,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'InstagramApiError'
|
||||
this.status = status
|
||||
this.apiMessage = apiMessage
|
||||
this.apiType = apiType
|
||||
}
|
||||
}
|
||||
|
||||
const parseApiErrorBody = async (
|
||||
response: Response,
|
||||
): Promise<{ message: string | null; type: string | null }> => {
|
||||
try {
|
||||
const body = (await response.json()) as { error?: { message?: unknown; type?: unknown } } | null
|
||||
const error = body?.error
|
||||
if (typeof error !== 'object' || error === null) return { message: null, type: null }
|
||||
return {
|
||||
message: typeof error.message === 'string' && error.message ? error.message : null,
|
||||
type: typeof error.type === 'string' && error.type ? error.type : null,
|
||||
}
|
||||
} catch {
|
||||
return { message: null, type: null }
|
||||
}
|
||||
}
|
||||
|
||||
const apiErrorFrom = async (response: Response): Promise<InstagramApiError> => {
|
||||
const { message, type } = await parseApiErrorBody(response)
|
||||
return new InstagramApiError(
|
||||
response.status,
|
||||
message,
|
||||
type,
|
||||
`Instagram API falhou com status ${response.status}`,
|
||||
)
|
||||
}
|
||||
|
||||
const MEDIA_FIELDS = [
|
||||
'id',
|
||||
'caption',
|
||||
|
|
@ -156,12 +206,13 @@ export const loadInstagramFeed = async ({
|
|||
maxResults,
|
||||
fetchImpl = fetch,
|
||||
baseUrl = INSTAGRAM_API_BASE_URL,
|
||||
signal,
|
||||
}: LoadInstagramFeedArgs): Promise<LoadInstagramFeedResult> => {
|
||||
const attempt = async (token: string): Promise<InstagramFeedResult> => {
|
||||
const userParams = new URLSearchParams({ fields: 'username', access_token: token })
|
||||
const userResponse = await fetchImpl(`${baseUrl}/${userId}?${userParams}`)
|
||||
const userResponse = await fetchImpl(`${baseUrl}/${userId}?${userParams}`, { signal })
|
||||
if (!userResponse.ok) {
|
||||
throw new Error(`Instagram user falhou com status ${userResponse.status}`)
|
||||
throw await apiErrorFrom(userResponse)
|
||||
}
|
||||
const userJson = (await jsonFrom(userResponse)) as { username?: unknown }
|
||||
const username = typeof userJson.username === 'string' ? userJson.username : null
|
||||
|
|
@ -171,9 +222,9 @@ export const loadInstagramFeed = async ({
|
|||
limit: String(Math.min(Math.max(maxResults, 1), INSTAGRAM_MAX_RESULTS_CAP)),
|
||||
access_token: token,
|
||||
})
|
||||
const mediaResponse = await fetchImpl(`${baseUrl}/${userId}/media?${mediaParams}`)
|
||||
const mediaResponse = await fetchImpl(`${baseUrl}/${userId}/media?${mediaParams}`, { signal })
|
||||
if (!mediaResponse.ok) {
|
||||
throw new Error(`Instagram media falhou com status ${mediaResponse.status}`)
|
||||
throw await apiErrorFrom(mediaResponse)
|
||||
}
|
||||
|
||||
return { username, posts: parseInstagramMediaResponse(await jsonFrom(mediaResponse)) }
|
||||
|
|
@ -186,9 +237,11 @@ export const loadInstagramFeed = async ({
|
|||
grant_type: 'ig_refresh_token',
|
||||
access_token: accessToken,
|
||||
})
|
||||
const refreshResponse = await fetchImpl(`${baseUrl}/refresh_access_token?${refreshParams}`)
|
||||
const refreshResponse = await fetchImpl(`${baseUrl}/refresh_access_token?${refreshParams}`, {
|
||||
signal,
|
||||
})
|
||||
if (!refreshResponse.ok) {
|
||||
throw new Error(`Instagram token refresh falhou com status ${refreshResponse.status}`)
|
||||
throw await apiErrorFrom(refreshResponse)
|
||||
}
|
||||
const refreshJson = (await jsonFrom(refreshResponse)) as { access_token?: unknown }
|
||||
if (typeof refreshJson.access_token !== 'string' || !refreshJson.access_token) {
|
||||
|
|
@ -201,42 +254,153 @@ export const loadInstagramFeed = async ({
|
|||
}
|
||||
}
|
||||
|
||||
const excludedInstagramIds = (settings: SocialFeedSetting): string[] =>
|
||||
(settings.excludedItems ?? [])
|
||||
.filter((item) => item.platform === 'instagram')
|
||||
.map((item) => item.itemId)
|
||||
.filter(Boolean)
|
||||
/**
|
||||
* Operational state of the Instagram sync, persisted in the global's
|
||||
* `instagram_sync_status` column and shown by the admin status panel:
|
||||
* `lastSyncAt`/`postCount` are the last SUCCESSFUL sync; `error`/`errorAt`
|
||||
* the last failure. A failure overwrites the success fields on purpose — the
|
||||
* panel shows either state, never both (draft cenas 1/2).
|
||||
*/
|
||||
export type InstagramSyncStatus = {
|
||||
lastSyncAt?: string
|
||||
postCount?: number
|
||||
error?: string
|
||||
errorAt?: string
|
||||
}
|
||||
|
||||
const isInstagramSnapshot = (value: unknown): value is { username?: unknown; posts?: unknown } =>
|
||||
typeof value === 'object' && value !== null
|
||||
export const successInstagramSyncStatus = (postCount: number): InstagramSyncStatus => ({
|
||||
lastSyncAt: new Date().toISOString(),
|
||||
postCount,
|
||||
})
|
||||
|
||||
export const failedInstagramSyncStatus = (error: string): InstagramSyncStatus => ({
|
||||
error,
|
||||
errorAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
const isTokenError = (error: InstagramApiError): boolean =>
|
||||
error.apiType === 'OAuthException' ||
|
||||
/(expired|invalid|revoked|token|session)/i.test(error.apiMessage ?? '')
|
||||
|
||||
/**
|
||||
* True when the API message points at a bad user id rather than a bad token.
|
||||
* Requires "user" AND "id" together (or an explicit "object with id ... does
|
||||
* not exist") — a single "user" alone is NOT enough, because real token
|
||||
* rejections read "the user must be logged in" / "the user has changed their
|
||||
* password", and those must stay on the token correction, not the ID one.
|
||||
*/
|
||||
const isInvalidUserIdMessage = (message: string): boolean =>
|
||||
/(?=.*\buser\b)(?=.*\bid\b)/i.test(message) ||
|
||||
/\bid\b[\s\S]*\b(?:does not exist|cannot find)\b/i.test(message)
|
||||
|
||||
const isAbortError = (cause: unknown): boolean =>
|
||||
cause instanceof Error && cause.name === 'AbortError'
|
||||
|
||||
/**
|
||||
* Turns a sync failure into product language for the admin status panel: the
|
||||
* assessoria must know WHY the board has no Instagram cards and what to do
|
||||
* (the S3 fail-closed silence is the bug this exists to fix). Token errors
|
||||
* carry the correction — regenerate via Instagram Login, never Facebook
|
||||
* Login (the Graph API refresh endpoint rejects FB-issued page tokens).
|
||||
*/
|
||||
export const describeInstagramError = (cause: unknown): string => {
|
||||
if (isAbortError(cause)) {
|
||||
return 'A sincronização demorou demais para responder (a API do Instagram não respondeu a tempo). Tente novamente.'
|
||||
}
|
||||
if (cause instanceof InstagramApiError) {
|
||||
if (cause.status === 400 && isInvalidUserIdMessage(cause.apiMessage ?? '')) {
|
||||
return 'O ID do usuário não foi reconhecido pela Graph API. Confira se é o ID numérico da conta Business/Creator.'
|
||||
}
|
||||
if (typeof cause.status === 'number' && cause.status >= 400 && cause.status < 500) {
|
||||
if (isTokenError(cause)) {
|
||||
return 'O token do Instagram foi recusado pela Graph API — está inválido/expirado ou foi emitido via Facebook Login (a Graph API só aceita tokens gerados pelo Instagram Login). Gere um novo token de longa duração pelo Instagram Login e atualize o campo acima.'
|
||||
}
|
||||
return 'A Graph API recusou a solicitação. Confira o token e o ID do usuário configurados.'
|
||||
}
|
||||
return 'A Graph API está indisponível no momento. Tente novamente em alguns minutos.'
|
||||
}
|
||||
if (cause instanceof TypeError) {
|
||||
return 'Não foi possível falar com a API do Instagram (rede indisponível).'
|
||||
}
|
||||
return 'Erro inesperado ao sincronizar o Instagram.'
|
||||
}
|
||||
|
||||
/**
|
||||
* Database seam for the raw-SQL persists. The default is the pool
|
||||
* (`payload.db.drizzle`); the hook-triggered sync passes the transaction-bound
|
||||
* database (`getPostgresTransactionDatabase`, S11) so the write lands inside
|
||||
* the global save's transaction — writing the same row from a second pool
|
||||
* connection while the save holds its row lock deadlocks on that lock.
|
||||
*/
|
||||
export type InstagramPersistDatabase = {
|
||||
execute: (query: ReturnType<typeof sql>) => Promise<unknown>
|
||||
}
|
||||
|
||||
const countAffected = (result: unknown): number | null | undefined =>
|
||||
(result as { rowCount?: number | null } | undefined)?.rowCount
|
||||
|
||||
/**
|
||||
* Persists the sync status the same way as the snapshot — deliberately raw
|
||||
* SQL instead of `payload.updateGlobal`: this runs INSIDE `unstable_cache`
|
||||
* during a page render, and Next 15.4 throws when `revalidateTag` (the
|
||||
* global's `afterChange` hook) fires inside a cached function. Same
|
||||
* precondition as the snapshot: at most one `social_feed_settings` row
|
||||
* exists; the INSERT only fires when none does.
|
||||
*/
|
||||
export const persistInstagramSyncStatus = async (
|
||||
payload: Payload,
|
||||
status: InstagramSyncStatus,
|
||||
database: InstagramPersistDatabase | null = null,
|
||||
): Promise<void> => {
|
||||
const db = database ?? payload.db.drizzle
|
||||
const json = JSON.stringify(status)
|
||||
const updated = await db.execute(sql`
|
||||
UPDATE "social_feed_settings"
|
||||
SET "instagram_sync_status" = ${json}::jsonb, "updated_at" = now()
|
||||
WHERE "id" = (SELECT "id" FROM "social_feed_settings" ORDER BY "id" LIMIT 1)
|
||||
`)
|
||||
if (!countAffected(updated)) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO "social_feed_settings" ("instagram_sync_status", "created_at", "updated_at")
|
||||
VALUES (${json}::jsonb, now(), now())
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the Instagram feed is armed to call the Graph API. */
|
||||
export const isInstagramFeedConfigured = (settings: SocialFeedSetting): boolean =>
|
||||
settings.enabled !== false &&
|
||||
settings.instagramEnabled !== false &&
|
||||
Boolean(settings.instagramAccessToken) &&
|
||||
Boolean(settings.instagramUserId)
|
||||
|
||||
/**
|
||||
* Persists the raw feed as the global's snapshot column. Deliberately raw SQL
|
||||
* (`payload.db.drizzle`) instead of `payload.updateGlobal`: this runs INSIDE
|
||||
* `unstable_cache` during a page render, and Next 15.4 throws when
|
||||
* `revalidateTag` (the global's `afterChange` hook) fires inside a cached
|
||||
* function. Raw SQL bypasses hooks; the row is created on first write when the
|
||||
* admin has not saved the global yet (a serial id is assigned, so no id
|
||||
* assumption is made).
|
||||
* instead of `payload.updateGlobal`: this runs INSIDE `unstable_cache` during
|
||||
* a page render, and Next 15.4 throws when `revalidateTag` (the global's
|
||||
* `afterChange` hook) fires inside a cached function. Raw SQL bypasses hooks;
|
||||
* the row is created on first write when the admin has not saved the global
|
||||
* yet (a serial id is assigned, so no id assumption is made).
|
||||
*
|
||||
* Precondition: at most one `social_feed_settings` row exists. The UPDATE
|
||||
* targets the single row and the INSERT only fires when none exists; a
|
||||
* concurrent double-insert would need two writers racing the very first write
|
||||
* (the row is created by the first admin save or feed render) — accepted.
|
||||
*/
|
||||
const persistInstagramSnapshot = async (
|
||||
export const persistInstagramSnapshot = async (
|
||||
payload: Payload,
|
||||
snapshot: InstagramFeedResult,
|
||||
database: InstagramPersistDatabase | null = null,
|
||||
): Promise<void> => {
|
||||
const database = payload.db.drizzle
|
||||
const db = database ?? payload.db.drizzle
|
||||
const json = JSON.stringify(snapshot)
|
||||
const updated = await database.execute(sql`
|
||||
const updated = await db.execute(sql`
|
||||
UPDATE "social_feed_settings"
|
||||
SET "instagram_feed_snapshot" = ${json}::jsonb, "updated_at" = now()
|
||||
WHERE "id" = (SELECT "id" FROM "social_feed_settings" ORDER BY "id" LIMIT 1)
|
||||
`)
|
||||
if (!updated?.rowCount) {
|
||||
await database.execute(sql`
|
||||
if (!countAffected(updated)) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO "social_feed_settings" ("instagram_feed_snapshot", "created_at", "updated_at")
|
||||
VALUES (${json}::jsonb, now(), now())
|
||||
`)
|
||||
|
|
@ -244,69 +408,15 @@ const persistInstagramSnapshot = async (
|
|||
}
|
||||
|
||||
/** Persists a refreshed token the same way as the snapshot (raw SQL, no hooks). */
|
||||
const persistInstagramAccessToken = async (payload: Payload, token: string): Promise<void> => {
|
||||
await payload.db.drizzle.execute(sql`
|
||||
export const persistInstagramAccessToken = async (
|
||||
payload: Payload,
|
||||
token: string,
|
||||
database: InstagramPersistDatabase | null = null,
|
||||
): Promise<void> => {
|
||||
const db = database ?? payload.db.drizzle
|
||||
await db.execute(sql`
|
||||
UPDATE "social_feed_settings"
|
||||
SET "instagram_access_token" = ${token}, "updated_at" = now()
|
||||
WHERE "id" = (SELECT "id" FROM "social_feed_settings" ORDER BY "id" LIMIT 1)
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached Instagram feed for the campaign home content board: `null` when the
|
||||
* feed is off (kill switch, platform toggle or missing credentials), otherwise
|
||||
* the latest eligible posts plus the profile username. A successful fetch
|
||||
* persists the RAW list (pre-exclusion) as the global snapshot; a failed fetch
|
||||
* falls back to that snapshot with the current exclusions applied — so a new
|
||||
* exclusion works even while the API is down, and the page never breaks.
|
||||
* Busted by the global's `afterChange` (tag `social-feed`); the per-entry
|
||||
* `revalidate` keeps new uploads appearing without an admin edit.
|
||||
*/
|
||||
export const getInstagramFeed = unstable_cache(
|
||||
async (): Promise<InstagramFeedResult | null> => {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const settings = await payload.findGlobal({
|
||||
slug: 'social-feed-settings',
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
if (
|
||||
settings.enabled === false ||
|
||||
settings.instagramEnabled === false ||
|
||||
!settings.instagramAccessToken ||
|
||||
!settings.instagramUserId
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const maxItems = Math.min(Math.max(settings.instagramMaxItems ?? 3, 1), 5)
|
||||
const excludedIds = excludedInstagramIds(settings)
|
||||
|
||||
try {
|
||||
const result = await loadInstagramFeed({
|
||||
accessToken: settings.instagramAccessToken,
|
||||
userId: settings.instagramUserId,
|
||||
maxResults: Math.min(maxItems + 10, INSTAGRAM_MAX_RESULTS_CAP),
|
||||
})
|
||||
await persistInstagramSnapshot(payload, { username: result.username, posts: result.posts })
|
||||
if (result.refreshedAccessToken) {
|
||||
await persistInstagramAccessToken(payload, result.refreshedAccessToken)
|
||||
}
|
||||
return {
|
||||
username: result.username,
|
||||
posts: eligibleInstagramPosts(result.posts, excludedIds, maxItems),
|
||||
}
|
||||
} catch {
|
||||
const snapshot = settings.instagramFeedSnapshot as unknown
|
||||
if (!isInstagramSnapshot(snapshot) || !Array.isArray(snapshot.posts)) {
|
||||
return { username: null, posts: [] }
|
||||
}
|
||||
return {
|
||||
username: typeof snapshot.username === 'string' ? snapshot.username : null,
|
||||
posts: eligibleInstagramPosts(snapshot.posts, excludedIds, maxItems),
|
||||
}
|
||||
}
|
||||
},
|
||||
['instagram-feed'],
|
||||
{ tags: [REVALIDATE_SOCIAL_FEED_TAG], revalidate: 300 },
|
||||
)
|
||||
|
|
|
|||
97
src/utilities/socialFeed/instagramFeedView.ts
Normal file
97
src/utilities/socialFeed/instagramFeedView.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { SocialFeedSetting } from '@/payload-types'
|
||||
import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest'
|
||||
import configPromise from '@payload-config'
|
||||
import { unstable_cache } from 'next/cache'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import {
|
||||
describeInstagramError,
|
||||
eligibleInstagramPosts,
|
||||
failedInstagramSyncStatus,
|
||||
INSTAGRAM_MAX_RESULTS_CAP,
|
||||
type InstagramFeedResult,
|
||||
isInstagramFeedConfigured,
|
||||
loadInstagramFeed,
|
||||
persistInstagramAccessToken,
|
||||
persistInstagramSnapshot,
|
||||
persistInstagramSyncStatus,
|
||||
successInstagramSyncStatus,
|
||||
} from '@/utilities/socialFeed/instagramFeed'
|
||||
|
||||
const excludedInstagramIds = (settings: SocialFeedSetting): string[] =>
|
||||
(settings.excludedItems ?? [])
|
||||
.filter((item) => item.platform === 'instagram')
|
||||
.map((item) => item.itemId)
|
||||
.filter(Boolean)
|
||||
|
||||
const isInstagramSnapshot = (value: unknown): value is { username?: unknown; posts?: unknown } =>
|
||||
typeof value === 'object' && value !== null
|
||||
|
||||
/**
|
||||
* Cached Instagram feed for the campaign home content board: `null` when the
|
||||
* feed is off (kill switch, platform toggle or missing credentials), otherwise
|
||||
* the latest eligible posts plus the profile username. A successful fetch
|
||||
* persists the RAW list (pre-exclusion) as the global snapshot AND the sync
|
||||
* status (so the admin panel shows "Sincronizado · há X min · N posts"); a
|
||||
* failed fetch persists the failure status with the product-language reason
|
||||
* and falls back to the snapshot with the current exclusions applied — so a
|
||||
* new exclusion works even while the API is down, and the page never breaks.
|
||||
* Busted by the global's `afterChange` (tag `social-feed`); the per-entry
|
||||
* `revalidate` keeps new uploads appearing without an admin edit.
|
||||
*
|
||||
* Lives in `instagramFeedView.ts` (NOT `instagramFeed.ts`) on purpose: this
|
||||
* module is the only one that imports `@payload-config`, while
|
||||
* `instagramFeed.ts` is reachable from `payload.config.ts` via the global's
|
||||
* sync hook — importing the config back from the feed module would close a
|
||||
* cycle the `check:cycles` gate rejects.
|
||||
*/
|
||||
export const getInstagramFeed = unstable_cache(
|
||||
async (): Promise<InstagramFeedResult | null> => {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const settings = await payload.findGlobal({
|
||||
slug: 'social-feed-settings',
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
if (!isInstagramFeedConfigured(settings)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const maxItems = Math.min(Math.max(settings.instagramMaxItems ?? 3, 1), 5)
|
||||
const excludedIds = excludedInstagramIds(settings)
|
||||
|
||||
try {
|
||||
const result = await loadInstagramFeed({
|
||||
accessToken: settings.instagramAccessToken as string,
|
||||
userId: settings.instagramUserId as string,
|
||||
maxResults: Math.min(maxItems + 10, INSTAGRAM_MAX_RESULTS_CAP),
|
||||
})
|
||||
await persistInstagramSnapshot(payload, { username: result.username, posts: result.posts })
|
||||
if (result.refreshedAccessToken) {
|
||||
await persistInstagramAccessToken(payload, result.refreshedAccessToken)
|
||||
}
|
||||
await persistInstagramSyncStatus(payload, successInstagramSyncStatus(result.posts.length))
|
||||
return {
|
||||
username: result.username,
|
||||
posts: eligibleInstagramPosts(result.posts, excludedIds, maxItems),
|
||||
}
|
||||
} catch (cause) {
|
||||
await persistInstagramSyncStatus(
|
||||
payload,
|
||||
failedInstagramSyncStatus(describeInstagramError(cause)),
|
||||
)
|
||||
const snapshot = settings.instagramFeedSnapshot as unknown
|
||||
if (!isInstagramSnapshot(snapshot) || !Array.isArray(snapshot.posts)) {
|
||||
return { username: null, posts: [] }
|
||||
}
|
||||
return {
|
||||
username: typeof snapshot.username === 'string' ? snapshot.username : null,
|
||||
posts: eligibleInstagramPosts(snapshot.posts, excludedIds, maxItems),
|
||||
}
|
||||
}
|
||||
},
|
||||
['instagram-feed'],
|
||||
{ tags: [REVALIDATE_SOCIAL_FEED_TAG], revalidate: 300 },
|
||||
)
|
||||
117
src/utilities/socialFeed/instagramSync.ts
Normal file
117
src/utilities/socialFeed/instagramSync.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { SocialFeedSetting } from '@/payload-types'
|
||||
import { getPostgresTransactionDatabase } from '@/utilities/postgresTransactionLocks'
|
||||
import {
|
||||
describeInstagramError,
|
||||
failedInstagramSyncStatus,
|
||||
INSTAGRAM_MAX_RESULTS_CAP,
|
||||
isInstagramFeedConfigured,
|
||||
loadInstagramFeed,
|
||||
persistInstagramAccessToken,
|
||||
persistInstagramSnapshot,
|
||||
persistInstagramSyncStatus,
|
||||
successInstagramSyncStatus,
|
||||
type InstagramPersistDatabase,
|
||||
type InstagramSyncStatus,
|
||||
} from '@/utilities/socialFeed/instagramFeed'
|
||||
import type { Payload, PayloadRequest } from 'payload'
|
||||
|
||||
/** Deadline for a sync triggered by the admin (hook or button) — a hanging
|
||||
* Graph API must never stall a global save. */
|
||||
export const INSTAGRAM_SYNC_TIMEOUT_MS = 10_000
|
||||
|
||||
export type InstagramSyncOutcome = {
|
||||
ok: boolean
|
||||
status: InstagramSyncStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a save of the global must re-sync Instagram: the credentials
|
||||
* changed (the primary flow — "fill token + ID, save, see the state") or the
|
||||
* feed was re-enabled. Exclusion-only saves skip the sync on purpose: they
|
||||
* change no credential and would burn an API call for nothing.
|
||||
*/
|
||||
export const instagramCredentialsChanged = (
|
||||
doc: SocialFeedSetting,
|
||||
previousDoc: SocialFeedSetting | null | undefined,
|
||||
): boolean => {
|
||||
if (!previousDoc) return true
|
||||
const credentialsChanged =
|
||||
(doc.instagramAccessToken ?? '') !== (previousDoc.instagramAccessToken ?? '') ||
|
||||
(doc.instagramUserId ?? '') !== (previousDoc.instagramUserId ?? '')
|
||||
const reenabled = doc.instagramEnabled === true && previousDoc.instagramEnabled !== true
|
||||
return credentialsChanged || reenabled
|
||||
}
|
||||
|
||||
/**
|
||||
* The afterChange hook runs INSIDE the global save's transaction (the row lock
|
||||
* is held until it returns), so a write to the same row from a second pool
|
||||
* connection would block on that lock. When a `req` with a `transactionID` is
|
||||
* present (hook path), the persists go through the transaction-bound database
|
||||
* instead — the status/snapshot land atomically with the save, no deadlock.
|
||||
* The render path and the sync route have no open transaction and keep the
|
||||
* pool (`payload.db.drizzle`).
|
||||
*/
|
||||
const resolvePersistDatabase = async (
|
||||
payload: Payload,
|
||||
req?: PayloadRequest,
|
||||
): Promise<InstagramPersistDatabase | null> => {
|
||||
if (!req?.transactionID) return null
|
||||
try {
|
||||
return await getPostgresTransactionDatabase(payload, req)
|
||||
} catch {
|
||||
// No transaction adapter available (e.g. non-postgres in tests) — pool.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one Instagram sync from the persisted settings: fetches the Graph API,
|
||||
* persists the raw snapshot + a refreshed token when one was minted, and
|
||||
* writes the outcome status. Never throws — the status IS the result (a
|
||||
* failure writes the product-language reason the admin panel shows). Shared
|
||||
* by the global's `afterChange` hook (on credential change) and the
|
||||
* `POST /api/social-feed/sync` route (the "Tentar sincronizar de novo"
|
||||
* button) — one implementation, two call sites.
|
||||
*/
|
||||
export const syncInstagramFeed = async (
|
||||
payload: Payload,
|
||||
options: { signal?: AbortSignal; req?: PayloadRequest } = {},
|
||||
): Promise<InstagramSyncOutcome> => {
|
||||
const settings = await payload.findGlobal({
|
||||
slug: 'social-feed-settings',
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
if (!isInstagramFeedConfigured(settings)) {
|
||||
return { ok: false, status: {} }
|
||||
}
|
||||
|
||||
const maxItems = Math.min(Math.max(settings.instagramMaxItems ?? 3, 1), 5)
|
||||
const database = await resolvePersistDatabase(payload, options.req)
|
||||
|
||||
try {
|
||||
const result = await loadInstagramFeed({
|
||||
accessToken: settings.instagramAccessToken as string,
|
||||
userId: settings.instagramUserId as string,
|
||||
maxResults: Math.min(maxItems + 10, INSTAGRAM_MAX_RESULTS_CAP),
|
||||
signal: options.signal,
|
||||
})
|
||||
await persistInstagramSnapshot(
|
||||
payload,
|
||||
{ username: result.username, posts: result.posts },
|
||||
database,
|
||||
)
|
||||
if (result.refreshedAccessToken) {
|
||||
await persistInstagramAccessToken(payload, result.refreshedAccessToken, database)
|
||||
}
|
||||
const status = successInstagramSyncStatus(result.posts.length)
|
||||
await persistInstagramSyncStatus(payload, status, database)
|
||||
return { ok: true, status }
|
||||
} catch (cause) {
|
||||
const status = failedInstagramSyncStatus(describeInstagramError(cause))
|
||||
await persistInstagramSyncStatus(payload, status, database)
|
||||
return { ok: false, status }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { login } from '../helpers/login'
|
||||
import { cleanupTestUser, seedTestUser, testUser } from '../helpers/seedUser'
|
||||
import { instagramStubUrlFor } from '../helpers/socialStub'
|
||||
import { expect, test } from './fixtures/e2eTest'
|
||||
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
|
||||
|
|
@ -127,6 +128,140 @@ test.describe('Admin Panel', () => {
|
|||
excludedItems: [],
|
||||
youtubeFeedSnapshot: null,
|
||||
instagramFeedSnapshot: null,
|
||||
instagramSyncStatus: null,
|
||||
},
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('shows the Instagram sync status and retries the sync from the panel', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// The seed POST fires the global's afterChange hook, whose sync hits the
|
||||
// stub — put it in a known state first and let the hook persist the
|
||||
// status so the panel has a real one to render.
|
||||
const stubUrl = instagramStubUrlFor(baseURL)
|
||||
const setStub = async (state: 'ok' | 'fail' | 'invalid-token') => {
|
||||
const response = await request.post(`${stubUrl}/__stub/state`, { data: { state } })
|
||||
expect(response.ok()).toBeTruthy()
|
||||
}
|
||||
await setStub('ok')
|
||||
|
||||
const login = await request.post(`${baseURL}/api/users/login`, {
|
||||
data: { email: testUser.email, password: testUser.password },
|
||||
})
|
||||
expect(login.ok()).toBeTruthy()
|
||||
const { token } = await login.json()
|
||||
const headers = { cookie: `payload-token=${token}` }
|
||||
|
||||
const seed = await request.post(`${baseURL}/api/globals/social-feed-settings`, {
|
||||
headers,
|
||||
data: {
|
||||
enabled: true,
|
||||
instagramEnabled: true,
|
||||
instagramAccessToken: 'e2e-ig-token',
|
||||
instagramUserId: '17841400000000000',
|
||||
instagramMaxItems: 3,
|
||||
excludedItems: [],
|
||||
instagramFeedSnapshot: null,
|
||||
},
|
||||
})
|
||||
expect(seed.ok()).toBeTruthy()
|
||||
|
||||
const seedToken = async (accessToken: string) => {
|
||||
const response = await request.post(`${baseURL}/api/globals/social-feed-settings`, {
|
||||
headers,
|
||||
data: {
|
||||
enabled: true,
|
||||
instagramEnabled: true,
|
||||
instagramAccessToken: accessToken,
|
||||
instagramUserId: '17841400000000000',
|
||||
instagramMaxItems: 3,
|
||||
excludedItems: [],
|
||||
instagramFeedSnapshot: null,
|
||||
},
|
||||
})
|
||||
expect(response.ok()).toBeTruthy()
|
||||
}
|
||||
|
||||
try {
|
||||
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
|
||||
await expect(page.getByText(/Sincronizado · .* · 5 posts/)).toBeVisible()
|
||||
|
||||
// Credential change + save triggers the hook sync — with the API now
|
||||
// rejecting the token (OAuthException), the save persists a failure
|
||||
// status and the reload shows it with the retry button.
|
||||
await setStub('invalid-token')
|
||||
await seedToken('e2e-ig-token-2')
|
||||
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
|
||||
await expect(page.getByText('Falha na última sincronização')).toBeVisible()
|
||||
await expect(page.getByText(/token do Instagram foi recusado/)).toBeVisible()
|
||||
|
||||
// Retry after fixing: the button runs the sync route → synchronized.
|
||||
await setStub('ok')
|
||||
await page.getByRole('button', { name: 'Tentar sincronizar de novo' }).click()
|
||||
await expect(page.getByText(/Sincronizado · .* · 5 posts/)).toBeVisible()
|
||||
|
||||
const read = await request.get(`${baseURL}/api/globals/social-feed-settings`, { headers })
|
||||
expect(read.ok()).toBeTruthy()
|
||||
const { instagramSyncStatus } = await read.json()
|
||||
expect(instagramSyncStatus).toEqual(
|
||||
expect.objectContaining({
|
||||
lastSyncAt: expect.any(String),
|
||||
postCount: 5,
|
||||
}),
|
||||
)
|
||||
expect(instagramSyncStatus.error).toBeUndefined()
|
||||
|
||||
// Fail-500 scene: a server-side API failure maps to the generic notice.
|
||||
await setStub('fail')
|
||||
await seedToken('e2e-ig-token-3')
|
||||
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
|
||||
await expect(page.getByText('Falha na última sincronização')).toBeVisible()
|
||||
await expect(page.getByText(/indisponível/)).toBeVisible()
|
||||
|
||||
// Disabled scene: the master kill switch off renders the disabled box.
|
||||
await setStub('ok')
|
||||
await request.post(`${baseURL}/api/globals/social-feed-settings`, {
|
||||
headers,
|
||||
data: {
|
||||
enabled: false,
|
||||
instagramEnabled: true,
|
||||
instagramAccessToken: 'e2e-ig-token',
|
||||
instagramUserId: '17841400000000000',
|
||||
instagramMaxItems: 3,
|
||||
excludedItems: [],
|
||||
instagramFeedSnapshot: null,
|
||||
},
|
||||
})
|
||||
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
|
||||
await expect(page.getByText('Instagram desativado')).toBeVisible()
|
||||
|
||||
// Unconfigured state: no credentials (feed enabled) → the panel says so.
|
||||
await seedToken('')
|
||||
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
|
||||
await expect(page.getByText('Instagram ainda não configurado')).toBeVisible()
|
||||
} finally {
|
||||
await setStub('ok').catch(() => undefined)
|
||||
await request
|
||||
.post(`${baseURL}/api/globals/social-feed-settings`, {
|
||||
headers,
|
||||
data: {
|
||||
enabled: true,
|
||||
youtubeEnabled: true,
|
||||
youtubeApiKey: '',
|
||||
youtubeChannelId: '',
|
||||
youtubeMaxItems: 3,
|
||||
instagramEnabled: true,
|
||||
instagramAccessToken: '',
|
||||
instagramUserId: '',
|
||||
instagramMaxItems: 3,
|
||||
excludedItems: [],
|
||||
youtubeFeedSnapshot: null,
|
||||
instagramFeedSnapshot: null,
|
||||
instagramSyncStatus: null,
|
||||
},
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
|
|
|||
102
tests/e2e/campaignDemandVisibility.e2e.spec.ts
Normal file
102
tests/e2e/campaignDemandVisibility.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import {
|
||||
campaignPageChrome,
|
||||
expect,
|
||||
test,
|
||||
type CampaignE2EFixture,
|
||||
} from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
test.describe('C143 — demand visibility (explicit responsibles)', () => {
|
||||
const setupDemand = async (
|
||||
campaign: CampaignE2EFixture,
|
||||
fixtures: CampaignE2EFixture['fixtures'],
|
||||
) => {
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora C143'),
|
||||
})
|
||||
const responsible = await fixtures.createCampaignUser('advisor', {
|
||||
name: fixtures.value('Assessora Responsável'),
|
||||
})
|
||||
const peer = await fixtures.createCampaignUser('advisor', {
|
||||
name: fixtures.value('Assessor Colega'),
|
||||
})
|
||||
await campaign.payload.update({
|
||||
collection: 'municipality',
|
||||
id: municipality.id,
|
||||
data: { advisors: [responsible.id, peer.id] },
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
const demand = await fixtures.createCampaignDemand({
|
||||
municipality: municipality.id,
|
||||
createdBy: coordinator.id,
|
||||
responsibles: [responsible.id],
|
||||
title: fixtures.value('Demanda de acesso restrito'),
|
||||
})
|
||||
|
||||
return { municipality, coordinator, responsible, peer, demand }
|
||||
}
|
||||
|
||||
test('hides the demand from a same-municipality advisor who is not responsible', async ({
|
||||
campaign,
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const { peer, demand } = await setupDemand(campaign, fixtures)
|
||||
|
||||
await campaign.sessionFor(context, peer)
|
||||
await page.goto(`${campaign.baseURL}/campanha/demandas`)
|
||||
// The list rendered for him: the empty state proves the page settled
|
||||
// (and that the demand is absent — the link assertion below is the
|
||||
// fail-closed complement).
|
||||
await expect(page.getByText('Nenhuma demanda por aqui')).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: demand.title })).toHaveCount(0)
|
||||
|
||||
// The URL does not open either — the demand simply does not exist for him
|
||||
// (the default 404 boundary is the root one, no campaign-specific page).
|
||||
await page.goto(`${campaign.baseURL}/campanha/demandas/${demand.slug}`)
|
||||
await expect(page.getByText('This page could not be found.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('lets a responsible hand access to a peer and revoke it again', async ({
|
||||
campaign,
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const { peer, responsible, demand } = await setupDemand(campaign, fixtures)
|
||||
const demandURL = `${campaign.baseURL}/campanha/demandas/${demand.slug}`
|
||||
|
||||
// The responsible manages the list: add the peer through the search dialog.
|
||||
await campaign.sessionFor(context, responsible)
|
||||
await page.goto(demandURL)
|
||||
await expect(page.getByText('Só quem é responsável vê esta demanda.')).toBeVisible()
|
||||
|
||||
// The trigger announces the current selection (e.g. "…: 1 responsável").
|
||||
await page.getByRole('button', { name: /Responsáveis pela demanda/ }).click()
|
||||
await page.getByPlaceholder('Buscar assessor…').fill(peer.name)
|
||||
await page.getByRole('option', { name: peer.name }).click()
|
||||
// The multi-add dialog stays open (modal) — close it before saving.
|
||||
await page.keyboard.press('Escape')
|
||||
await page.getByRole('button', { name: 'Salvar responsáveis' }).click()
|
||||
await expect(page.getByText('Responsáveis atualizados.')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: `Remover ${peer.name}` })).toBeVisible()
|
||||
|
||||
// The peer now opens the demand.
|
||||
await campaign.sessionFor(context, peer)
|
||||
await page.goto(demandURL)
|
||||
await expect(campaignPageChrome(page, demand.title)).toBeVisible()
|
||||
|
||||
// Back as the responsible, remove the peer and revoke the access again.
|
||||
await campaign.sessionFor(context, responsible)
|
||||
await page.goto(demandURL)
|
||||
await page.getByRole('button', { name: `Remover ${peer.name}` }).click()
|
||||
await page.getByRole('button', { name: 'Salvar responsáveis' }).click()
|
||||
await expect(page.getByRole('button', { name: `Remover ${peer.name}` })).toHaveCount(0)
|
||||
|
||||
await campaign.sessionFor(context, peer)
|
||||
await page.goto(demandURL)
|
||||
await expect(page.getByText('This page could not be found.')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
@ -220,6 +220,32 @@ export class CampaignE2EOwnership {
|
|||
return { contactName, contactId: contact.id, leadershipId: leadership.id }
|
||||
}
|
||||
|
||||
/**
|
||||
* C143 — demand factory with explicit ownership: the row is registered for
|
||||
* cleanup and the municipality touched (mirror of the int fixture helper).
|
||||
*/
|
||||
async createCampaignDemand(input: {
|
||||
municipality: number
|
||||
title: string
|
||||
createdBy?: number
|
||||
responsibles?: number[]
|
||||
}): Promise<{ id: number; slug: string; title: string }> {
|
||||
const demand = await this.payload.create({
|
||||
collection: 'campaignDemand',
|
||||
data: {
|
||||
kind: 'material',
|
||||
slug: this.value('demanda'),
|
||||
status: 'aberta',
|
||||
...input,
|
||||
},
|
||||
depth: 0,
|
||||
draft: false,
|
||||
})
|
||||
this.own('campaignDemand', demand.id)
|
||||
this.touchMunicipality(input.municipality)
|
||||
return demand
|
||||
}
|
||||
|
||||
private isOwnedCollection(collection: CollectionSlug): collection is OwnedCollection {
|
||||
return this.owned.has(collection as OwnedCollection)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue