diff --git a/AGENTS.md b/AGENTS.md index d5d57534..1c838b48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ The public news/publications system is backed by two collections (both in the `P **Self-revalidation on admin edits.** Both collections have an `afterChange` hook. `post` busts its own document tag + the `posts` listing on every publish/update (skipping only the initial draft that the admin create view generates during render). `tag` is broader: on change it re-reads every post referencing that tag (as `category` or in `tags`, passing `req` for transaction safety) and revalidates each one plus the listing — so flipping `hidden` is reflected on the site immediately. -**Seeding news content (`pnpm db:seed:posts`).** `scripts/seed-posts.mjs` (loaded via `scripts/seed-loader.mjs`) fetches ~39 articles live from jorgesolla.com.br (WordPress REST API, with an HTML-crawl fallback), imports them as `type: 'noticia'` published posts, converts the WP HTML body to Payload Lexical, and uploads cover images through the configured media storage (Garage S3 when the `S3_*` envs are set — the adapter overwrites the deterministic `.` key natively, no pre-delete needed; local disk otherwise). The taxonomy (categories + the `eleitoral` control tag, and the per-slug classification) is hardcoded in the script; it reads/writes the `hidden` flag. It is **idempotent by slug** (existing posts are skipped; tags/media are reused by slug/filename) and reuses the same non-local `DATABASE_URL` guard as `pnpm dev` (override only with `ALLOW_REMOTE_DB=true`). +**Seeding news content (`pnpm db:seed:posts`).** `scripts/seed-posts.mjs` (loaded via `scripts/seed-loader.mjs`) fetches ~43 articles live from jorgesolla.com.br (WordPress REST API, with an HTML-crawl fallback), imports them as `type: 'noticia'` published posts, converts the WP HTML body to Payload Lexical, and uploads cover images through the configured media storage (Garage S3 when the `S3_*` envs are set — the adapter overwrites the deterministic `.` key natively, no pre-delete needed; local disk otherwise). The taxonomy (categories + the `eleitoral` control tag, and the per-slug classification) is hardcoded in the script; it reads/writes the `hidden` flag. It is **idempotent by slug** (existing posts are skipped; tags/media are reused by slug/filename) and reuses the same non-local `DATABASE_URL` guard as `pnpm dev` (override only with `ALLOW_REMOTE_DB=true`). `pnpm db:seed:posts --dry-run` is the plan-only pre-flight (fetches, resolves covers, reports what WOULD be created — no writes). The OPS58 runbook documents the production sync (post-merge, homeserver, stack env, dry-run → sync → bust `posts` cache → verify) — copy it for future syncs (see `docs/plans/ops58-sincronizar-posts-prod-impl.md`). ### Revalidating after a manual/direct DB change diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index ef3c8078..1fd0fe7c 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -20,6 +20,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-18):** **OPS56**: o `forgejo-pr-automerge` nunca mais reporta falso positivo "já mergeado" com PR aberto. Raiz confirmada no source do servidor (Forgejo 9.0.3, tag v9.0.3): o endpoint `POST /pulls/{n}/merge` responde **200 com corpo vazio** no sucesso (falhas são 404/405/409/500), e o `request()` do `forgejo-api.mjs` convertia isso em `null` — o `autoMerge()` devolvia `null` cru e o CLI imprimia "já mergeado" (exit 0) independente do estado real do PR; o incidente (PR #27 da OPS52, aberto das 01:59 às 02:45) deixou o merge falhar silenciosamente com o PR aberto. Correção: o veredito de `autoMerge` agora é **re-read do PR** após a tentativa (`{ attempted, merged, pr }`) — 200-vazio → confirma no `GET /pulls/{n}`; POST falhou e o PR segue aberto → **re-throw** (falha ruidosa, exit 1); raça perdida (outro ator mergeou) → "já mergeado" só com o PR realmente mergeado; PR fechado sem merge → "fechado sem merge — skip" honesto. `waitForChecks` passou a esperar o `mergeable` amadurecer (false → throw imediato de conflito; null → segue no poll), mesmo timeout de 30 min. CLI sem shadowing de `pr` (const redeclarada). Testes: 6 specs novas em `forgejoApi.unit.spec.ts` (veredito por re-read, POST 405 + PR aberto → reject, raça concorrente, no-op de já-mergeado, poll de mergeable null, fail-fast de mergeable false). Gates: 2318 unit + 791 int + tsc/lint/prettier/knip/check:cycles verdes. Débito deferido: alargar o gatilho do `agent-pr-ready-automerge.yml` para branches não-`cursor/*` (decisão de ops separada). +**Recently resolved (2026-08-18):** **OPS58** — o sync de posts de produção ficou pronto para executar com runbook documentado: `scripts/seed-posts.mjs` ganhou o modo **`--dry-run`** (plan-only: fetch ao vivo + echo do alvo + relatório do que SERIA criado/saltado — zero escrita, pre-flight padrão OPS52-media antes de tocar o DB de prod; argumento desconhecido morre) e a **taxonomy `CLASSIFICATION` foi atualizada** com os 4 slugs novos do jorgesolla.com.br (43 posts no WP vs 39 mapeados): os 3 de campanha/pré-campanha (convenção estadual do PT, plenária "PT tem que disputar o programa de governo", caravana da Chapada) entraram com `tags: ['eleitoral']` para ficarem sob o controle de visibilidade eleitoral do repo (`isPostVisible` fail-closed — critério do tag documentado no seed), e o embate com Bruno Reis em `politica`. A execução em prod (pós-merge, homeserver, env do stack, `ALLOW_REMOTE_DB=true`) segue o runbook: dry-run → sync (create-only, idempotente por slug) → bust do cache `posts` via `/api/revalidate` → verify (`media:recover --verify` para capas + curl nas páginas dos novos). Runbook para as próximas sincronizações: `docs/plans/ops58-sincronizar-posts-prod-impl.md`. + **Recently resolved (2026-08-18):** **OPS57** — o safety net do `forgejo-pr-automerge` agora cobre **todas as PRs same-repo para `main`**, não só `cursor/*`. Motivação (débito deferido na OPS56): dos 25 PRs mergeados no repo, **zero** eram `cursor/*` — 9 de `plans/*`, 11 de worktrees humanos, 5 de `refs/pull/N/head` (era da migração) — o filtro antigo nunca cobriu nenhum PR real, e o incidente OPS52 (PR #27, humano, merge falhou silenciosamente) era exatamente a classe excluída. Mudanças: `if:` do job em `agent-pr-ready-automerge.yml` → `state == 'open' && head.repo.full_name == github.repository` (guarda de fork — o repo é público e uma PR de fork mergeada dispararia o deploy de prod; campos já usados no payload, sem dependência de `is_draft` do webhook); política de draft movida para o CLI `forgejo-pr-automerge.mjs` com o head ref do **re-read da API** — draft `cursor/*` → Ready (pool inalterado); draft não-`cursor/*` → skip exit 0 (**draft = veto do ator, PR humana nunca é forçada**); PRs ready não-`cursor/*` (humanas/plans) passam a mergear com CI verde — redundante no caminho feliz (o fluxo humano já arma `--auto --rebase`) e é o que pega a classe OPS52; `ready_for_review` em PR draft re-dispara o job e mergea. Docs sincronizadas (`docs/AGENT-OPS.md` tabela CI por alvo, `.agents/rules/agent-pr-workflow.mdc` seção Safety net). Rejeitado: `!is_draft` no YAML (campo do payload não verificado, falha fail-open) e allowlist de prefixos (códigos humanos sem prefixo comum — OPS/S1/PUB5). A própria PR desta entrega ainda usa o workflow de `main` (antigo); a próxima PR exercita o gatilho alargado. Sem migration/access/Consent/UI. Testes: nenhum novo (lógica de veredito já coberta pelos specs da OPS56; branch de draft é 3 linhas no CLI); gates unit/int verdes. **Recently resolved (2026-08-17):** **OPS51** — o banco de produção saiu do Neon para o Postgres do homeserver (`teqo_1313`, container `teqo-1313`), sem mudança de config: o `~/stack/teqo-1313.env` já apontava para o banco preparado (criado 16/08 com role própria + cadeia completa de 56 migrations + zero conteúdo — verificado), então restaurar = a produção "apareceu" no container. **Divergência deliberada da intenção (questão A/B validada como B):** rejeitado criar `teqo_prod` novo — o argumento "o banco do 1313 tem vida própria" não se sustenta (o site 1313 É a produção deste repo) e o OPS53 já trata `teqo_1313` como produção. Execução 100% no homeserver (PII nunca tocou a workstation): URL do Neon puxada do Vercel (`vercel env pull`, projeto `jorgesolla`, `DATABASE_URL_UNPOOLED` → `NEON_DATABASE_URL` em `~/stack/.env` chmod 600), `pg_dump -Fc` (Neon PG 17.10) → `/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump` (5,8 MB, sha256 `f12196f2…`), `pg_restore --no-owner --no-privileges` como a role `teqo_1313` (drop schema + grant, padrão do `db-pull`), verificado: contagens por tabela idênticas ao Neon (1919 contatos, 39 posts, 1487 assinaturas, 1484 subscrições, 35 campaign_users, 392 lideranças, 2 pledges, 435 municípios, 6 atividades; supporter segue 0 — blocker LGPD ainda de pé), sequences no topo (`last_value == max(id)`), objetos com dono `teqo_1313`, `payload migrate` no-op (56 = 56, serviço de maintenance `teqo-1313-migrate`), 3 credenciais WebAuthn + 34 sessions + 5 chaves Consent restauradas (RP ID não mudou — biometria segue valendo). Smoke sem deploy: `/`, artigo com conteúdo restaurado, `/campanha/login`, `/admin`, `POST /api/revalidate` (`posts` + `global_privacy-policy`), WebAuthn `login-options`, barreira 307 sem sessão — verdes; smoke de browser (login, biometria, convites) validado manualmente. **Rollback documentado:** Neon nunca foi escrito (só lido); voltar = re-apontar `DATABASE_URL` do `teqo-1313.env` para o Neon + `compose up -d`. **Débito registrado (→ OPS52):** as 40 rows de `media` têm URLs relativas (`/api/media/file/…`) sem arquivos em lugar nenhum — capas dão 500 no homeserver e **404 no próprio Vercel/produção atual** (estado pré-existente; migração manteve paridade). Repo: plano da intenção concluído, `AGENTS.md` e help de `db:pull`/`check-push-chain` sem a referência ao Neon; infra-solla `STATE.md` atualizado à parte. diff --git a/docs/changelog/2026-08-18-ops58.md b/docs/changelog/2026-08-18-ops58.md new file mode 100644 index 00000000..35fb56cb --- /dev/null +++ b/docs/changelog/2026-08-18-ops58.md @@ -0,0 +1 @@ +**Recently resolved (2026-08-18):** **OPS58** — o sync de posts de produção ficou pronto para executar com runbook documentado: `scripts/seed-posts.mjs` ganhou o modo **`--dry-run`** (plan-only: fetch ao vivo + echo do alvo + relatório do que SERIA criado/saltado — zero escrita, pre-flight padrão OPS52-media antes de tocar o DB de prod; argumento desconhecido morre) e a **taxonomy `CLASSIFICATION` foi atualizada** com os 4 slugs novos do jorgesolla.com.br (43 posts no WP vs 39 mapeados): os 3 de campanha/pré-campanha (convenção estadual do PT, plenária "PT tem que disputar o programa de governo", caravana da Chapada) entraram com `tags: ['eleitoral']` para ficarem sob o controle de visibilidade eleitoral do repo (`isPostVisible` fail-closed — critério do tag documentado no seed), e o embate com Bruno Reis em `politica`. A execução em prod (pós-merge, homeserver, env do stack, `ALLOW_REMOTE_DB=true`) segue o runbook: dry-run → sync (create-only, idempotente por slug) → bust do cache `posts` via `/api/revalidate` → verify (`media:recover --verify` para capas + curl nas páginas dos novos). Runbook para as próximas sincronizações: `docs/plans/ops58-sincronizar-posts-prod-impl.md`. diff --git a/docs/plans/ops58-sincronizar-posts-prod-impl.md b/docs/plans/ops58-sincronizar-posts-prod-impl.md new file mode 100644 index 00000000..95ca6f97 --- /dev/null +++ b/docs/plans/ops58-sincronizar-posts-prod-impl.md @@ -0,0 +1,241 @@ +# Impl: OPS58 — Atualizar artigos de produção com os novos posts do jorgesolla.com.br + +Status: aprovado (gate humano 2026-08-18) +Atualizado em: 2026-08-18 +Issue: #43 +Intenção: docs/plans/ops58-sincronizar-posts-prod.md +Appetite restante: herdado (~0,5 dia — execução no homeserver + runbook) + +## Leitura da intenção + +- **Outcome:** todos os artigos do jorgesolla.com.br ainda ausentes em produção + criados como notícia publicada; os existentes intocados (create-only, sem + duplicata); capas dos novos servindo 200 em `/api/media/file/...`; cache + `posts` bustado; procedimento documentado como runbook (padrão OPS52-media). +- **O que NÃO negociar:** create-only (nunca edita/sobrescreve conteúdo + existente); PII intocada; contrato público de URL intocado; guard + `ALLOW_REMOTE_DB=true` explícito (fail-closed mantido); capas dos novos no + bucket de prod (env `S3_*` do stack obrigatório — nunca disco local). +- **O que reavaliar:** a hipótese "ferramenta existe, só executar" precisa de + dois ajustes — (1) o seed não tem modo **plan-only** (pre-flight de prod + inexistente; o precedente OPS52-media provou o valor de `--dry-run` no + homeserver); (2) a taxonomy hardcoded está **defasada**: o WP tem 43 posts e + o `CLASSIFICATION` cobre 39 — os 4 slugs novos incluem 3 de + pré-campanha/campanha que, sem a entrada, caem em `politica` **sem o tag + `eleitoral`**, furando o controle de visibilidade eleitoral do repo. + +## Evidência coletada (2026-08-18, probes read-only) + +- **E1 — Contagem do WP:** `x-wp-total: 43` (REST `_embed`, paginado — + `fetchArticlesFromWordPress` traz todos). `CLASSIFICATION` cobre 39 slugs → + **4 slugs novos não mapeados** (caem no default `politica` hoje): + - `bruno-reis-foi-covarde-detonou-solla-ao-rebater-o-prefeito-sobre-saude-de-salvador` (2026-08-17 — resposta política a ataque) + - `chapada-diamantina-recebe-caravana-liderada-pelo-deputado-jorge-solla` (2026-07-31 — caravana, pré-campanha) + - `lula-reforca-o-time-de-jeronimo-wagner-rui-e-jorge-solla-na-convencao-estadual-do-pt` (2026-08-02 — convenção estadual do PT, campanha) + - `o-pt-tem-que-disputar-o-programa-de-governo-conclamou-jorge-solla-em-plenaria` (2026-08-11 — plenária, campanha) +- **E2 — Risco do lock `eleitoral`:** os 3 de campanha acima sem tag + `eleitoral` ficam visíveis no site público mesmo se a equipe puxar o + controle `hidden` na janela eleitoral (lock do repo: + `isPostVisible` fail-closed esconde só posts com tag/categoria `eleitoral`). +- **E3 — Seed sem pre-flight:** `seed-posts.mjs` não ecoa o alvo (host do DB) + e não tem modo que planeje sem escrever; `recover-media.mjs` (OPS52) tem + ambos — padrão a espelhar (echo do alvo antes de qualquer escrita; modos + mutuamente exclusivos; argumento desconhecido morre). +- **E4 — Homeserver:** clone do repo em `~/teqo-deploy` (OPS53 — deploy doc; + o deploy deixa o clone em **detached HEAD** no SHA do job). Env do stack em + `~/stack/teqo-1313.env` com `DATABASE_URL=…@postgres:5432` — o `postgres` + só resolve **dentro** da rede `stack_default`; do host, o caminho comprovado + é o **proxy socat `teqo-1313-build-proxy`** que o deploy script cria + idempotentemente (publicado em `127.0.0.1:5433` no host — é exatamente como + o build o usa, `deploy-homeserver.sh:91-108`). O `S3_ENDPOINT=host.docker.internal:3900` + do stack só resolve dentro do compose; o Garage roda **no host** (o container + alcança via `host-gateway`) → do host, `S3_ENDPOINT=http://127.0.0.1:3900` + (a workstation/tailnet usa `http://100.119.220.31:3900`). A forma exata de + execução do OPS52-media não está registrada de forma consistente entre os + docs (o runbook dela menciona `cd ~/stack`; o deploy doc diz workspace + `~/teqo-deploy`) — o pre-flight do runbook abaixo valida a forma escolhida, + e a primeira execução registra o que funcionou na Issue. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[seed-posts.mjs
+ CLASSIFICATION 4 slugs] -->|pnpm db:seed:posts --dry-run| B[pre-flight prod
plano sem escrita] + B -->|ok| C[ALLOW_REMOTE_DB=true
pnpm db:seed:posts] --> D[posts novos + capas
Garage S3 prod] + D -->|POST /api/revalidate?tag=posts| E[bust cache posts] + E -->|media:recover --verify + curl páginas| F[aceite público] +``` + +**Opções consideradas (forma de execução):** + +- **A — Estender o dono:** `--dry-run` (plan-only) no `scripts/seed-posts.mjs` + - classificar os 4 slugs novos no `CLASSIFICATION` + runbook no homeserver. + **Recomendada.** +- B — Executar o seed como está ("às cegas") e confiar no relatório final — + o relatório só diz created/skipped **depois** de já ter escrito em prod; + sem echo de alvo, sem chance de revisar "o que será criado" antes. +- C — Script novo de pre-flight separado — twin do fetch/lookup do seed; + conhecimento vazaria em 2 módulos. + +**Opções consideradas (taxonomy dos 4 slugs):** + +- **A2 — Expandir o `CLASSIFICATION` com os 4 slugs** (3 com + `tags: ['eleitoral']`, o bruno-reis em `politica`). **Recomendada.** +- B2 — Seed intocado: os 4 caem em `politica` sem tag `eleitoral` + triagem + manual pós-sync no admin. +- C2 — Bloquear o sync até classificar (abort no unmapped). + +**Recomendação: A + A2** — o dry-run é o pre-flight que o OPS52 provou no +homeserver (custo baixo: o seed já tem todo o fetch/lookup; dry-run troca +`payload.create` por contagem), e a classificação dos 4 slugs é **aditiva e +create-only** (não toca posts existentes — só decide a categoria/tag dos que +serão criados). Os 3 slugs de campanha **precisam** do tag `eleitoral` para o +lock de visibilidade do repo continuar valendo — registrá-los como `politica` +pura seria criar conteúdo de campanha fora do controle eleitoral em plena +janela. A manutenção contínua (próximos slugs) segue fora de escopo, como a +intenção manda. + +**Rejeitadas:** B (sem pre-flight — escreve antes de informar); C (twin do +seed); B2 (fura o lock `eleitoral`; triagem manual pós-sync é pior que a +classificação no commit, com os 4 slugs já em mãos); C2 (transforma 4 slugs +em gate de conteúdo — a intenção quer o sync desbloqueado). + +### Componentes / mudanças + +- **`scripts/seed-posts.mjs`** (extensão do dono — comportamento default + inalterado): + - Parse de args no topo (padrão `recover-media.mjs`): só `--dry-run` + aceito; argumento desconhecido → die; echo do alvo (`DB host: +` — sem senha) antes de qualquer trabalho. + - Modo dry-run: executa o fetch + lookups de tags/posts existentes + + resolução de covers (`resolveCoverSource`, **sem download** de imagem), + e no lugar de cada `payload.create` conta/lista "would create" / + "would skip" / unmapped / sem-cover. Zero escrita. Relatório final + espelhando o SUMMARY atual. + - `CLASSIFICATION`: +4 entradas (E1) — 3 com `tags: ['eleitoral']`. +- **`package.json`:** nada novo — `--dry-run` é argumento do script existente. +- **Migration:** nenhuma. **Access / Consent:** nenhum. **UI:** N/A + (Impeccable A — sem mudança de UI; conteúdo/operação). +- **Docs:** runbook de execução em prod neste impl plan (ordem do homeserver); + entrada `docs/changelog/2026-08-18-ops58.md` + `pnpm changelog:build`; + nota curta no AGENTS.md (§ seeding de conteúdo) apontando o runbook. + +## Fases verificáveis + +1. **Seed (dry-run + taxonomy)** — parse de args, echo do alvo, early-returns; + `CLASSIFICATION` +4. Gates locais: `pnpm gate:fast`, `pnpm format:check`, + `pnpm exec knip`, `pnpm check:cycles`. Validação de comportamento: + `pnpm db:seed:posts --dry-run` contra o DB local do worktree — relatório + com "to create"/"skip"/unmapped e **zero** posts/tags/media criados + (conferir via contagem antes/depois). +2. **Docs + changelog** — runbook completo neste impl plan, nota no AGENTS.md, + `docs/changelog/2026-08-18-ops58.md`, `pnpm changelog:build` + + `pnpm changelog:check`. +3. **Gates finais + merge** — `pnpm gate:fast`, `format:check`, `knip`, + `check:cycles`, `pnpm test` (unit+int), `pnpm build` local; PR → CI verde + → merge em `main` (deploy automático OPS53). +4. **Execução em prod (pós-merge, runbook — humano):** seção abaixo. + +## Runbook de execução em produção (pós-merge, humano) + +1. **Deploy do merge em `main`** (CI OPS53) — o seed com `--dry-run` e a + taxonomy chegam ao homeserver com a imagem do SHA. +2. **Pre-flight no homeserver** (no clone do repo `~/teqo-deploy` — o deploy + OPS53 deixa o clone em **detached HEAD**, então trazer para `main` primeiro; + env do stack sourceado; `postgres` só resolve dentro do compose → usar o + **proxy socat do build** em `127.0.0.1:5433`; o Garage roda no host → + `S3_ENDPOINT=http://127.0.0.1:3900`): + ```bash + ssh homeserver + cd ~/teqo-deploy + git fetch origin main && git checkout main && git pull --ff-only origin main + pnpm install # se preciso (hooks — padrão ensure-repo-deps) + set -a; source ~/stack/teqo-1313.env; set +a + export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}" # proxy socat do build (postgres só resolve dentro do compose) + export S3_ENDPOINT="http://127.0.0.1:3900" # Garage roda no host (host.docker.internal só resolve no compose) + ALLOW_REMOTE_DB=true pnpm db:seed:posts --dry-run + ``` + Esperado: echo do alvo (`DB: 127.0.0.1:5433`), relatório com os 4 slugs + novos a criar (`would-create … (cover: url)` — URL presente; o download/upload + só acontece no sync), slugs existentes a pular, sem escrita. Se o dry-run + falhar com ECONNREFUSED/ENOTFOUND: conferir o proxy (`docker ps | grep +teqo-1313-build-proxy` — o deploy script o cria idempotentemente; se + ausente, re-dispatchar o deploy ou rodar o trecho que o cria em + `scripts/deploy-homeserver.sh`) e registrar a forma que funcionou na Issue + (primeira execução do runbook). +3. **Sincronizar:** + ```bash + ALLOW_REMOTE_DB=true pnpm db:seed:posts + ``` + Esperado: `Posts created : 4` (os novos), `Posts skipped : 39` (ou o número + real de já existentes), capas baixadas e uploadadas para o Garage de prod + (`S3_ENDPOINT` já reescrito no passo 2). Se o WP ganhar slugs novos entre o + pre-flight e aqui, eles entram como `politica` com warning — registrar na + Issue e classificar (admin ou follow-up). +4. **Bust de cache:** + ```bash + curl -X POST https://jorgesolla1313.com.br/api/revalidate \ + -H "x-revalidate-secret: $REVALIDATE_SECRET" + ``` + Esperado: `200 { revalidated: true, tag: 'posts' }` (o smoke do deploy + OPS53 já exercita o endpoint com o secret real — o env está no stack). +5. **Aceite:** + - Capas: `ALLOW_REMOTE_DB=true pnpm media:recover --verify` — esperado + `42/44` filenames 200: 38 das 40 rows pré-existentes (as 2 exceções + conhecidas do OPS52 — `fim-escala-6x1.jpg`, `jorgesolla.jpg` — seguem + 404 e fazem o verify sair com exit 1; aceite: **as únicas falhas são as + 2 conhecidas**) + as capas novas dos 4 posts (se o dry-run acusou + `cover: url` para os 4). + - Páginas: `curl -s https://jorgesolla1313.com.br/ | grep ` + e `curl -o /dev/null -w "%{http_code}" https://jorgesolla1313.com.br/noticia/politica/` + → 200 para os 4 novos; conferir também `/artigos` e a seção S1 da home + de campanha. +6. **Registro:** resumo na Issue #43 (created/skipped, exceções de capa, + slugs unmapped adicionais, forma exata de execução usada no passo 2). + +## Rabbit holes / Não escopo (engenharia) + +- **`--verify` de posts em código** — curl no runbook basta; não criar + ferramenta para o aceite. +- **Upsert de edits do WP / sync agendado / hidden flag** — fora de escopo da + intenção; itens separados quando doer. +- **Testar o sync ponta a ponta fora do homeserver** — impossível por + contrato (sem DB de prod, sem `S3_*`); a prova E2E é o runbook (padrão + OPS52 fase 3). +- **Unidade de teste para o parse de args do seed** — mesmo padrão do + `recover-media.mjs` (parse inline, sem teste dedicado); o write path não + muda (create-only) e o dry-run é validado contra o DB local. + +## Riscos e mitigação + +- **R1 — Host do homeserver não alcança `postgres`** (DB só resolve dentro de + `stack_default`; a forma comprovada do host é o proxy socat do build em + `127.0.0.1:5433`). Mitigação: o pre-flight (dry-run) valida em segundos; + se o proxy estiver ausente, o deploy script o recria (re-dispatch); a forma + real usada na primeira execução é registrada na Issue. +- **R2 — Capa nova não resolve** (WP mudou/removeu a imagem). Mitigação: + post criado sem cover → card degrada para a banda cinza existente (sem + placeholder); exceção registrada na Issue; `media:recover --verify` acusa + (as únicas falhas aceitáveis são as 2 exceções conhecidas do OPS52). +- **R3 — Conteúdo eleitoral sem tag `eleitoral`** (se o gate rejeitar A2). + Mitigação: triagem manual no admin imediatamente pós-sync (passo 3 do + runbook vira passo de classificação obrigatório). +- **R4 — `REVALIDATE_SECRET` ausente no env do stack.** Mitigação: o smoke + do deploy (OPS53) já testa `/api/revalidate` com o secret real — presença + garantida; conferir no pre-flight se o curl der 401/500. +- **R5 — WP ganha slugs novos entre o commit e a execução** (fetch é live). + Mitigação: relatório final lista unmapped; runbook manda registrar e + classificar (admin ou follow-up) — nunca bloqueia o sync. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto: posts ausentes criados, + existentes intocados (create-only), capas 200, cache bustado, runbook + documentado (padrão OPS52-media) +- [ ] Invariantes AGENTS/engineering-standards: `ALLOW_REMOTE_DB=true` + explícito mantido; identificadores em inglês; zero access/Consent/ + transação/migration tocados; edit the owner (seed estendido, sem twin) +- [ ] Testes/validação de domínio: `--dry-run` contra DB local (relatório sem + escrita); gates da checklist do AGENTS; prova E2E é o runbook do + homeserver +- [ ] Docs: runbook de execução em prod + nota AGENTS.md + changelog diff --git a/scripts/seed-posts.mjs b/scripts/seed-posts.mjs index d4f51eee..b1ea4c97 100644 --- a/scripts/seed-posts.mjs +++ b/scripts/seed-posts.mjs @@ -21,6 +21,8 @@ * * Usage: * pnpm db:seed:posts + * pnpm db:seed:posts --dry-run (plan-only: fetches + resolves covers and + * reports what WOULD be created — no writes) * (against a remote DB, on purpose:) ALLOW_REMOTE_DB=true pnpm db:seed:posts */ import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext-lexical' @@ -48,12 +50,29 @@ const config = (await import('../src/payload.config.ts')).default const die = dieWithLabel('seed:posts') +const args = new Set(process.argv.slice(2)) +const unknown = [...args].filter((a) => a !== '--dry-run') +if (unknown.length > 0) die(`argumento desconhecido: ${unknown.join(', ')}`) +const dryRun = args.has('--dry-run') + +/** Host do DATABASE_URL para o echo do alvo — die limpo se a URL for inválida. */ +const echoTargetHost = () => { + try { + return new URL(process.env.DATABASE_URL).host + } catch { + die(`DATABASE_URL não é uma connection string válida: ${process.env.DATABASE_URL || '(vazia)'}`) + } +} + // --------------------------------------------------------------------------- // Taxonomy (Section 7): the categories + control tag, and the per-article map. // Keys are the WordPress slugs of the live articles. Every post gets exactly // one required `category`; `tags` is optional (currently only `eleitoral`). // The `eleitoral` tag marks pre-candidacy / campaign / PGP / party-event // content so Francisco can hide it all with one toggle in the electoral period. +// Criterion: content tied to a campaign event or candidacy (convenções, +// plenárias, caravanas, filiações, PGP) gets `eleitoral`; political +// rebuttals/position-taking without a campaign event stay plain `politica`. // --------------------------------------------------------------------------- const TAXONOMY_TAGS = [ @@ -120,6 +139,21 @@ const CLASSIFICATION = { category: 'politica', tags: ['eleitoral'], }, + 'bruno-reis-foi-covarde-detonou-solla-ao-rebater-o-prefeito-sobre-saude-de-salvador': { + category: 'politica', + }, + 'chapada-diamantina-recebe-caravana-liderada-pelo-deputado-jorge-solla': { + category: 'politica', + tags: ['eleitoral'], + }, + 'lula-reforca-o-time-de-jeronimo-wagner-rui-e-jorge-solla-na-convencao-estadual-do-pt': { + category: 'politica', + tags: ['eleitoral'], + }, + 'o-pt-tem-que-disputar-o-programa-de-governo-conclamou-jorge-solla-em-plenaria': { + category: 'politica', + tags: ['eleitoral'], + }, 'clube-2004-celebra-a-democracia-com-o-lancamento-da-pre-candidatura-de-jorge-solla-a-deputado-federal': { category: 'politica', tags: ['eleitoral'] }, 'articulada-por-solla-filiacao-de-zilar-portela-ao-pt-marca-lancamento-de-pre-candidatura-a-deputada-estadual': @@ -370,6 +404,12 @@ async function main() { ' 2. set DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo in .env.local', ) + console.log( + `\n[seed:posts] Alvo da execução:\n` + + ` DB : ${echoTargetHost()}\n` + + ` Modo : ${dryRun ? 'dry-run (plan-only — nenhuma escrita)' : 'sync (create-only)'}\n`, + ) + const payload = await getPayload({ config }) const editorConfig = await editorConfigFactory.default({ config: payload.config }) @@ -379,6 +419,7 @@ async function main() { console.log('[seed:posts] Ensuring taxonomy tags...') const tagIdBySlug = {} let tagsCreated = 0 + let tagsToCreate = 0 for (const tag of TAXONOMY_TAGS) { const found = await payload.find({ collection: 'tag', @@ -390,6 +431,10 @@ async function main() { tagIdBySlug[tag.slug] = found.docs[0].id continue } + if (dryRun) { + tagsToCreate += 1 + continue + } const created = await payload.create({ collection: 'tag', data: { name: tag.name, slug: tag.slug, hidden: false }, @@ -397,7 +442,11 @@ async function main() { tagIdBySlug[tag.slug] = created.id tagsCreated += 1 } - console.log(`[seed:posts] ${tagsCreated} tag(s) created, ${TAXONOMY_TAGS.length} total.`) + console.log( + dryRun + ? `[seed:posts] ${tagsToCreate} tag(s) to create, ${TAXONOMY_TAGS.length} total.` + : `[seed:posts] ${tagsCreated} tag(s) created, ${TAXONOMY_TAGS.length} total.`, + ) // 2. Live fetch (REST API primary, HTML crawl fallback). console.log('[seed:posts] Fetching articles from jorgesolla.com.br (WP REST API)...') @@ -417,6 +466,7 @@ async function main() { // 3. Upsert each article. let postsCreated = 0 let postsSkipped = 0 + let postsToCreate = 0 let mediaCreated = 0 const unmapped = [] @@ -429,6 +479,7 @@ async function main() { const tagSlugs = mapping?.tags || [] const categoryId = tagIdBySlug[categorySlug] const tagIds = tagSlugs.map((s) => tagIdBySlug[s]).filter(Boolean) + const categoryLabel = `[${categorySlug}${tagSlugs.length ? ' | ' + tagSlugs.join(',') : ''}]` const existing = await payload.find({ collection: 'post', @@ -441,6 +492,15 @@ async function main() { continue } + if (dryRun) { + const cover = resolveCoverSource(article) + postsToCreate += 1 + console.log( + `[seed:posts] would-create ${slug} ${categoryLabel} (cover: ${cover ? 'url' : 'sem-cover'})`, + ) + continue + } + const { subtitle, coverUrl, bodyHtml } = processContent(article) const cover = await ensureCoverMedia(payload, { @@ -466,18 +526,27 @@ async function main() { }, }) postsCreated += 1 - console.log( - `[seed:posts] + ${slug} [${categorySlug}${tagSlugs.length ? ' | ' + tagSlugs.join(',') : ''}]`, - ) + console.log(`[seed:posts] + ${slug} ${categoryLabel}`) } // 4. Report. + const summaryLines = [ + `Articles fetched : ${articles.length}`, + ...(dryRun + ? [ + `Posts to create : ${postsToCreate}`, + `Posts skipped : ${postsSkipped} (already existed)`, + `Tags to create : ${tagsToCreate}`, + ] + : [ + `Posts created : ${postsCreated}`, + `Posts skipped : ${postsSkipped} (already existed)`, + `Media created : ${mediaCreated}`, + `Tags created : ${tagsCreated} (of ${TAXONOMY_TAGS.length})`, + ]), + ] console.log('\n[seed:posts] ==================== SUMMARY ====================') - console.log(`[seed:posts] Articles fetched : ${articles.length}`) - console.log(`[seed:posts] Posts created : ${postsCreated}`) - console.log(`[seed:posts] Posts skipped : ${postsSkipped} (already existed)`) - console.log(`[seed:posts] Media created : ${mediaCreated}`) - console.log(`[seed:posts] Tags created : ${tagsCreated} (of ${TAXONOMY_TAGS.length})`) + for (const line of summaryLines) console.log(`[seed:posts] ${line}`) if (unmapped.length > 0) { console.warn( `\n[seed:posts] WARNING: ${unmapped.length} live slug(s) not in the taxonomy map ` +