feat(ops): auto-promote stage→main e merge em stage sem exigir head (#68)

* feat(ops): auto-promote stage→main e merge em stage sem exigir head

Substitui o promote manual por workflow disparado no ci-stage green,
relaxa strict na proteção de stage (script configure:branch-protection)
e documenta o novo paradigma em AGENT-OPS e work-issue.

* style: format AGENT-OPS and configure-branch-protection for ci-pr

* feat(ops): pnpm push com ensure-repo-deps determinístico

Bootstrap install + Husky antes de todo push (pnpm push e pre-push).
Documenta fluxo canônico em work-issue, agent-pr-workflow e AGENT-OPS.

* fix(ops): pnpm push roda gate no script, sem depender do Husky

Cloud environment.json chama prepare explicitamente após install.

* test(int): isolate advisor portfolio search from shared cairu slug

Parallel int specs were stomping municipality.advisors on the fixed
cairu row; allocate per-test municipalities instead.
This commit is contained in:
Francisco Solla 2026-07-30 21:12:52 -03:00 committed by GitHub
parent 5ee9e840a2
commit e04db48742
15 changed files with 320 additions and 64 deletions

View file

@ -1,4 +1,4 @@
{ {
"install": "pnpm install --frozen-lockfile && bash ./.cursor/cloud-setup.sh", "install": "pnpm install --frozen-lockfile && pnpm run prepare && bash ./.cursor/cloud-setup.sh",
"start": "bash ./.cursor/ensure-postgres.sh" "start": "bash ./.cursor/ensure-postgres.sh"
} }

View file

@ -10,7 +10,7 @@ alwaysApply: true
## Create (Ready, base stage) ## Create (Ready, base stage)
```bash ```bash
git push -u origin HEAD pnpm push -u origin HEAD
gh pr create --base stage --title "<id> — <title>" --body "$(cat <<'EOF' gh pr create --base stage --title "<id> — <title>" --body "$(cat <<'EOF'
## O que é ## O que é
<2–4 linhas> <2–4 linhas>
@ -21,7 +21,23 @@ EOF
``` ```
- **Never** `--draft`. PRs must be **Ready for review** on creation. - **Never** `--draft`. PRs must be **Ready for review** on creation.
- Base is always **`stage`** (never `main` — promote is human-only via `pnpm agent:promote --i-am-human`). - Base is always **`stage`** (never `main` — promote é automático via `promote-stage-to-main.yml` após CI stage green).
## Push (determinístico)
**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` (inclui `format:check`)
3. `git push --no-verify` — só porque o gate já rodou no passo 2 (não é escape hatch)
Cursor Cloud já roda `pnpm install` em `.cursor/environment.json` (`install`); worktrees locais sem `pnpm i` dependem do passo 1.
```bash
pnpm push -u origin HEAD # canonical
git push # só depois de `pnpm i` — hook repete ensure-deps + gate:push
git push --no-verify # WIP escape — CI repete tudo
```
## Auto-merge immediately ## Auto-merge immediately

View file

@ -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. 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. 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). **Pre-push hook** roda `pnpm gate:push` → `pnpm gate:ci` — espelho serial do `ci-pr.yml`: estático sempre; unit/int/e2e no **mesmo blast radius** que o CI (`scripts/test-affected.mjs`, `scripts/e2e-affected.mjs` vs `origin/stage`); build sempre. Fonte única: scripts `gate:*` em `package.json`; escape para WIP: `git push --no-verify`. `migration-lock` só existe no GitHub (conta PRs abertos em schema). O handoff humano (promote `stage→main`) exige CI stage + ci-pr green — não o build local. **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`, inclui `format:check`). 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`. `migration-lock` só existe no GitHub (conta PRs abertos em schema). O handoff humano (promote `stage→main`) é automático após CI stage green. **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). 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 ## Type honesty

View file

@ -7,14 +7,14 @@ description: Conduz uma GitHub Issue rastreável do Teqo do claim ao merge em st
Esta skill conduz UMA Issue rastreável do claim ao merge em `stage`. Substitui o antigo fluxo implement-roadmap-item + close-delivery + ship-to-main. **GitHub Issues são a fonte canônica** de spec/status/deps/prio/modelo; `docs/roadmap.md` é legado congelado e nunca é editado aqui (registro de entrega vai na Issue + `docs/plans/<slug>.md` + notebook do projeto). Esta skill conduz UMA Issue rastreável do claim ao merge em `stage`. Substitui o antigo fluxo implement-roadmap-item + close-delivery + ship-to-main. **GitHub Issues são a fonte canônica** de spec/status/deps/prio/modelo; `docs/roadmap.md` é legado congelado e nunca é editado aqui (registro de entrega vai na Issue + `docs/plans/<slug>.md` + notebook do projeto).
**Proibido neste fluxo:** `pnpm agent:promote` (promote stage→main é humano), qualquer acesso a DB de stage/prod, merge sem CI green, editar outras Issues `in-progress`. **Proibido neste fluxo:** qualquer acesso a DB de stage/prod, merge sem CI green, editar outras Issues `in-progress`. `pnpm agent:promote` é só override manual — o promote normal é automático após CI stage green.
**Qualidade de decisão e dados:** aplique [decision-quality.md](../plan-issue/decision-quality.md) ao auditar e fatiar (caro vs barato, Opções+Recomendação+rejeitadas, appetite, rabbit holes, tracer bullet cedo) e [data-presentation.md](../plan-issue/data-presentation.md) ao auditar/fatiar superfícies com KPI/mapa/série/ranking. **Qualidade de decisão e dados:** aplique [decision-quality.md](../plan-issue/decision-quality.md) ao auditar e fatiar (caro vs barato, Opções+Recomendação+rejeitadas, appetite, rabbit holes, tracer bullet cedo) e [data-presentation.md](../plan-issue/data-presentation.md) ao auditar/fatiar superfícies com KPI/mapa/série/ranking.
## Checklist do fluxo ## Checklist do fluxo
``` ```
- [ ] 0. Prep: pnpm i (node_modules obrigatório para agent:claim) - [ ] 0. Prep: `pnpm i` antes do `agent:claim` (Cloud Agents já rodam via `.cursor/environment.json` → `install`; worktrees locais: obrigatório, ou deixe para o `ensure-repo-deps` do `pnpm push`)
- [ ] 1. Claim: pnpm agent:claim (ou -- --issue <N>) — o brief do stdout é o contrato - [ ] 1. Claim: pnpm agent:claim (ou -- --issue <N>) — o brief do stdout é o contrato
- [ ] 1b. Renomear a sessão (cursor-app-control rename_chat) — padrão `#<N> <id> — <título>` - [ ] 1b. Renomear a sessão (cursor-app-control rename_chat) — padrão `#<N> <id> — <título>`
- [ ] 1c. Abrir o plano no editor (cursor-app-control open_resource) para o humano acompanhar - [ ] 1c. Abrir o plano no editor (cursor-app-control open_resource) para o humano acompanhar
@ -22,7 +22,7 @@ Esta skill conduz UMA Issue rastreável do claim ao merge em `stage`. Substitui
- [ ] 3. Freshness audit (enxuto) do plano contra o repositório - [ ] 3. Freshness audit (enxuto) do plano contra o repositório
- [ ] 4. Executar as fases (schema/server → UI via /impeccable → gates de engenharia) - [ ] 4. Executar as fases (schema/server → UI via /impeccable → gates de engenharia)
- [ ] 5. /simplify no diff da sessão → capture-review-debts - [ ] 5. /simplify no diff da sessão → capture-review-debts
- [ ] 6. Fechar em stage: pnpm gate:push → branch → commit → gh pr create --base stage (Closes #N, sem --draft) - [ ] 6. Fechar em stage: branch → commit → **`pnpm push -u origin HEAD`** (não `git push` nu) → gh pr create --base stage (Closes #N, sem --draft)
→ gh pr merge --auto --merge → gh pr checks --watch --required até o merge → consolidar pontas soltas → gh pr merge --auto --merge → gh pr checks --watch --required até o merge → consolidar pontas soltas
``` ```
@ -109,7 +109,7 @@ Ordem fixa, fases pequenas e verificáveis, respeitando o appetite do plano:
1. **Schema e server** — migrations (`pnpm migrate:create`, seguir `payload-migrations`), collections, utilities, server actions, testes de domínio. Guardrails do repo: Local API com `user` → `overrideAccess: false`; escrita multi-collection → transação com `req: { transactionID }`; pessoa → join com `Contact`; opt-in/PII → `Consent` por chave estável falhando fechado. 1. **Schema e server** — migrations (`pnpm migrate:create`, seguir `payload-migrations`), collections, utilities, server actions, testes de domínio. Guardrails do repo: Local API com `user` → `overrideAccess: false`; escrita multi-collection → transação com `req: { transactionID }`; pessoa → join com `Contact`; opt-in/PII → `Consent` por chave estável falhando fechado.
2. **UI via /impeccable** (classes B/C/D — a classe está no plano; se A, declare "Impeccable: N/A" e siga só engenharia): shape conforme a classe → craft → critique → (harden/optimize **só sob gatilho**) → polish. Paleta = tokens `data-theme='campaign'`; reusar `src/components/ui` e shells existentes; shape obrigatório em C **para** para confirmação do brief antes do craft. 2. **UI via /impeccable** (classes B/C/D — a classe está no plano; se A, declare "Impeccable: N/A" e siga só engenharia): shape conforme a classe → craft → critique → (harden/optimize **só sob gatilho**) → polish. Paleta = tokens `data-theme='campaign'`; reusar `src/components/ui` e shells existentes; shape obrigatório em C **para** para confirmação do brief antes do craft.
3. **Gates de engenharia** — iteração: `pnpm gate:fast`. Antes do push o hook roda `pnpm gate:push` (= `gate:ci`: estático + build sempre; unit/int/e2e no blast radius do CI via `test-affected`/`e2e-affected`). Scan Aikido dos arquivos editados. Comandos bare, nunca piped. Escape WIP: `git push --no-verify`. 3. **Gates de engenharia** — iteração: `pnpm gate:fast`. Push: **`pnpm push`** (ensure-deps + `gate:push` no script — não depende do hook Husky). `pnpm gate:push` manual só para debug sem push. Scan Aikido dos arquivos editados. Comandos bare, nunca piped. Escape WIP: `git push --no-verify` sem passar por `pnpm push`.
Tracer bullet: se a Issue for grande, a primeira fatia vertical real (schema mínimo → uma action → uma superfície UI) vem cedo. Tracer bullet: se a Issue for grande, a primeira fatia vertical real (schema mínimo → uma action → uma superfície UI) vem cedo.
@ -138,13 +138,13 @@ Lance **três** subagentes via `Task` **em paralelo** na mesma mensagem — read
## Passo 6 — Fechar em stage ## Passo 6 — Fechar em stage
1. `pnpm gate:push` verde (ou `pnpm gate:ci` — espelho do ci-pr; o hook pre-push roda na sequência do `git push`). 1. Branch `agent/<id>-<slug>` (worktrees do Cursor são donos da criação; commits lógicos).
2. Branch `agent/<id>-<slug>` (worktrees do Cursor são donos da criação; commits lógicos). 2. **`pnpm push -u origin HEAD`** — canonical; roda `ensure-repo-deps` + `gate:push` + push (não depende do Husky). Não use `git push` nu.
3. `gh pr create --base stage` com `Closes #<N>` no body — **nunca `--draft`** (`.cursor/rules/agent-pr-workflow.mdc`). 3. `gh pr create --base stage` com `Closes #<N>` no body — **nunca `--draft`** (`.cursor/rules/agent-pr-workflow.mdc`).
4. `gh pr merge --auto --merge <PR>` imediatamente após criar o PR. 4. `gh pr merge --auto --merge <PR>` imediatamente após criar o PR (não precisa rebasear na head de `stage` se não houver conflito — `strict=false` na proteção de `stage`).
5. **Acompanhe só os checks obrigatórios** (`gh pr checks <PR> --watch --required`): gate = `checks` + `migration-lock`; Vercel não bloqueia. Falha no ci-pr → corrige na mesma branch. 5. **Acompanhe só os checks obrigatórios** (`gh pr checks <PR> --watch --required`): gate = `checks` + `migration-lock`; Vercel não bloqueia. Falha no ci-pr → corrige na mesma branch. **Conflito de merge** → rebase em `stage` e reempurre; não force-push salvo para destravar CI após rebase.
6. O flip `in-progress → done` é **determinístico no CI** (workflow `issue-done-on-stage-merge.yml` lê o `Closes #N` no merge em stage). O agente espera o merge apenas para verificar e **consolidar pontas soltas**: débitos não registrados, doc da sessão pendente (notebook do projeto + `docs/plans/<slug>.md` com Status/Atualizado em), comentário de fechamento na Issue com o que entrou. Se o agente falhar depois do merge, o status já está correto. 6. O flip `in-progress → done` é **determinístico no CI** (workflow `issue-done-on-stage-merge.yml` lê o `Closes #N` no merge em stage). O agente espera o merge apenas para verificar e **consolidar pontas soltas**: débitos não registrados, doc da sessão pendente (notebook do projeto + `docs/plans/<slug>.md` com Status/Atualizado em), comentário de fechamento na Issue com o que entrou. Se o agente falhar depois do merge, o status já está correto.
## Resumo final ao usuário ## Resumo final ao usuário
Issue trabalhada + sessão renomeada + plano aberto no editor + verificação de modelo (declarado vs sessão, ou registro do ausente), veredito do freshness audit, o que entrou por fase, resultado do critique/polish (se UI), simplify (3 reviewers paralelos) + débitos registrados, gates, link do PR e estado do merge em stage, pontas soltas consolidadas. Nunca anuncie promote — `in-prod` é humano (`pnpm agent:promote --i-am-human`). Issue trabalhada + sessão renomeada + plano aberto no editor + verificação de modelo (declarado vs sessão, ou registro do ausente), veredito do freshness audit, o que entrou por fase, resultado do critique/polish (se UI), simplify (3 reviewers paralelos) + débitos registrados, gates, link do PR e estado do merge em stage, pontas soltas consolidadas. `in-prod` entra no auto-promote (`promote-stage-to-main.yml`) após CI stage green — não é passo do agente.

View file

@ -24,5 +24,5 @@ jobs:
for n in $numbers; do for n in $numbers; do
gh issue edit "$n" --repo "$GITHUB_REPOSITORY" --remove-label in-progress --add-label done || true gh issue edit "$n" --repo "$GITHUB_REPOSITORY" --remove-label in-progress --add-label done || true
gh issue comment "$n" --repo "$GITHUB_REPOSITORY" \ gh issue comment "$n" --repo "$GITHUB_REPOSITORY" \
--body "Merged em stage via PR #${PR_NUMBER} — done; \`in-prod\` entra no promote humano." || true --body "Merged em stage via PR #${PR_NUMBER} — done; \`in-prod\` entra no auto-promote após CI stage green." || true
done done

View file

@ -0,0 +1,94 @@
# Auto-promote: when ci-stage is green on the current `stage` head, merge stage → main.
# Replaces the manual `pnpm agent:promote --i-am-human` gate. The human review on main
# is replaced by ci-stage (Neon smoke) + ci.yml on main (full safety net post-merge).
#
# Stale runs are ignored: only the workflow run whose headSha still matches
# origin/stage may promote (newest commit wins when ci-stage queues).
name: Promote stage to main
on:
workflow_run:
workflows: ['CI (stage)']
types: [completed]
branches: [stage]
concurrency:
group: promote-stage-to-main
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
jobs:
promote:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
env:
# Admin PAT bypasses main branch protection (required review + status checks on
# the promote PR). Falls back to GITHUB_TOKEN when the PAT is not configured.
GH_TOKEN: ${{ secrets.PROMOTE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
RUN_SHA: ${{ github.event.workflow_run.head_sha }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Promote only when this run is still the stage head
id: gate
run: |
set -euo pipefail
git fetch origin main stage
stage_sha="$(git rev-parse origin/stage)"
main_sha="$(git rev-parse origin/main)"
if [ "$RUN_SHA" != "$stage_sha" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Stale ci-stage run ($RUN_SHA); current stage head is $stage_sha — skip."
exit 0
fi
if git merge-base --is-ancestor origin/main origin/stage; then
if [ "$main_sha" = "$stage_sha" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "main already at stage head ($stage_sha) — nothing to promote."
exit 0
fi
else
echo "::error::main diverged from stage (hotfix on main?). Merge main into stage, wait for ci-stage green, then re-run."
exit 1
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "Promoting stage $stage_sha → main ($main_sha)."
- name: Merge stage into main
if: steps.gate.outputs.skip != 'true'
run: |
set -euo pipefail
pr="$(gh pr list --state open --base main --head stage --json number --jq '.[0].number // empty')"
if [ -z "$pr" ]; then
gh pr create \
--base main \
--head stage \
--title "Promote stage → main (auto)" \
--body "Auto-promote após CI (stage) verde. Gate: ci-stage no head de stage. Rede pós-merge: ci.yml em main."
pr="$(gh pr list --state open --base main --head stage --json number --jq '.[0].number')"
fi
# --admin bypasses the human review + pre-merge status checks on main; ci-stage
# is the intentional pre-promote gate (see docs/AGENT-OPS.md).
gh pr merge "$pr" --admin --merge
echo "Promoted via PR #$pr."
- name: Label promoted issues in-prod
if: steps.gate.outputs.skip != 'true'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
for n in $(gh issue list --state open --label done --limit 200 --json number --jq '.[].number'); do
gh issue edit "$n" --add-label in-prod --remove-label done || true
done

View file

@ -1,3 +1,3 @@
# Full ci-pr mirror before push (source of truth: package.json `gate:push` → `gate:ci`). # For `git push` after `pnpm i` — same gate as `pnpm push`. Agents: use `pnpm push` (runs gate in-script; does not need this hook).
# Escape hatch para WIP: `git push --no-verify`. node scripts/ensure-repo-deps.mjs
pnpm gate:push pnpm gate:push

View file

@ -1,6 +1,6 @@
# AGENT-OPS — operação do paradigma de agentes paralelos # AGENT-OPS — operação do paradigma de agentes paralelos
Uma página. Norte: simplicidade (só GitHub + Cursor), agente para no merge a `stage`, `main`/prod só via promote humano. Uma página. Norte: simplicidade (só GitHub + Cursor), agente para no merge a `stage`, `main`/prod via auto-promote quando o CI stage fica verde.
## Ambientes de dados (test ladder — nunca misturar) ## Ambientes de dados (test ladder — nunca misturar)
@ -15,20 +15,22 @@ Agentes **nunca** recebem `DATABASE_URL` de stage/prod e nunca setam `ALLOW_REMO
## Fluxo ## Fluxo
```text ```text
claim → feature branch → PR --base stage → CI PR green → auto-merge em stage claim → feature branch → PR --base stage → CI PR green → auto-merge em stage (sem exigir estar na head de stage; conflito = rebase)
stage → CI stage green → (humano) pnpm agent:promote --i-am-human → main → Vercel prod stage → CI stage green → auto-promote (workflow promote-stage-to-main.yml) → main → Vercel prod
``` ```
**Skills do fluxo (desde 2026-07-30):** `plan-issue` (ideia → plano completo + gate de confirmação + Issue via `agent:register --model`) → `work-issue` (claim → verificação de modelo → freshness audit → execução → /simplify → PR `--base stage` → acompanha CI até o merge) → `project-status` (overview/fila/mermaid/bloqueios/consolidação, read-only). `docs/roadmap.md` = **legado congelado** (stub); a fonte canônica são as GitHub Issues. **Skills do fluxo (desde 2026-07-30):** `plan-issue` (ideia → plano completo + gate de confirmação + Issue via `agent:register --model`) → `work-issue` (claim → verificação de modelo → freshness audit → execução → /simplify → PR `--base stage` → acompanha CI até o merge) → `project-status` (overview/fila/mermaid/bloqueios/consolidação, read-only). `docs/roadmap.md` = **legado congelado** (stub); a fonte canônica são as GitHub Issues.
- **Agente faz sozinho:** `pnpm agent:claim` → implementa → `pnpm gate:push` (hook pre-push = espelho ci-pr) → `gh pr create --base stage` com `Closes #N` (**Ready**, sem `--draft`) → `gh pr merge --auto --merge` → `gh pr checks --watch --required` até o merge. Regra PR: `.cursor/rules/agent-pr-workflow.mdc`. - **Agente faz sozinho:** `pnpm agent:claim` → implementa → **`pnpm push`** (bootstrap deps/hooks + hook `gate:push`) → `gh pr create --base stage` com `Closes #N` (**Ready**, sem `--draft`) → `gh pr merge --auto --merge` → `gh pr checks --watch --required` até o merge. Regra PR: `.cursor/rules/agent-pr-workflow.mdc`.
### Dono do PR, dono do CI (desde 2026-07-30 — incidente PR #50) ### Dono do PR, dono do CI (desde 2026-07-30 — incidente PR #50)
**Done = PR mergeado em `stage` com CI verde.** Falha de CI no teu PR — infra do workflow, teste pré-existente ou regressão da feature — é tua até o merge. "Fora do escopo da feature" não é motivo para parar: corrige na mesma branch. Triagem das três classes: (a) **infra do workflow** (job mal configurado, step faltando) → fix no workflow no mesmo PR; (b) **teste pré-existente quebrado ou frágil** → repair no mesmo PR, no padrão dos specs irmãos; (c) **regressão da feature** → fix normal. Exceção de blast radius: se o fix exigir migration, access control ou Consent, para e escala com Opções — esses nunca se corrigem "de passagem". Flaky não se resolve com retry: repair, ou quarentena justificada no PR + débito registrado (`capture-review-debts`). **Done = PR mergeado em `stage` com CI verde.** Falha de CI no teu PR — infra do workflow, teste pré-existente ou regressão da feature — é tua até o merge. "Fora do escopo da feature" não é motivo para parar: corrige na mesma branch. Triagem das três classes: (a) **infra do workflow** (job mal configurado, step faltando) → fix no workflow no mesmo PR; (b) **teste pré-existente quebrado ou frágil** → repair no mesmo PR, no padrão dos specs irmãos; (c) **regressão da feature** → fix normal. Exceção de blast radius: se o fix exigir migration, access control ou Consent, para e escala com Opções — esses nunca se corrigem "de passagem". Flaky não se resolve com retry: repair, ou quarentena justificada no PR + débito registrado (`capture-review-debts`).
- **Só humano:** `pnpm agent:promote --i-am-human` (PR `stage→main` + merge com CI stage + ci-pr green; recusa se `main` divergiu de `stage` — merge main em stage antes), `pnpm db:refresh:stage` (refresh semanal do snapshot), editar envs Vercel/Neon, rodar `pnpm build` contra qualquer banco remoto. - **Só humano:** `pnpm db:refresh:stage` (refresh semanal do snapshot), editar envs Vercel/Neon, rodar `pnpm build` contra qualquer banco remoto. `pnpm agent:promote --i-am-human` permanece como override manual de emergência (mesmas verificações, sem esperar o workflow).
- Se `main` divergir de `stage` (hotfix): merge/rebase de `main` em `stage` + CI stage green de novo, só depois promote. - **Auto-promote:** `promote-stage-to-main.yml` dispara quando `ci-stage.yml` termina com sucesso. Só promove se o `headSha` do run ainda for o head de `stage` (commit mais novo ganha quando o CI enfileira). Recusa se `main` divergiu de `stage` (hotfix: merge `main` em `stage` + CI stage green de novo).
- **Merge em stage sem estar na head:** branch protection de `stage` com `strict=false` — PR pode auto-merger com CI green mesmo que outro PR tenha entrado antes; conflito de merge continua bloqueando (rebase obrigatório). Aplicar/reparar: `pnpm configure:branch-protection`.
- **Secret opcional:** `PROMOTE_GITHUB_TOKEN` (PAT admin do repo) se `GITHUB_TOKEN` não conseguir `gh pr merge --admin` na proteção de `main`. Settings → Actions → "Allow GitHub Actions to create and approve pull requests" também ajuda.
- **Engineering-audit é solitário:** quando roda, o paralelismo pausa (nenhum outro agente no repo) e o agente do audit executa as remediações P0/P1 na mesma sessão; inclui o harvest `kind:agent-miss` → guardrails. - **Engineering-audit é solitário:** quando roda, o paralelismo pausa (nenhum outro agente no repo) e o agente do audit executa as remediações P0/P1 na mesma sessão; inclui o harvest `kind:agent-miss` → guardrails.
## Comandos ## Comandos
@ -41,7 +43,9 @@ stage → CI stage green → (humano) pnpm agent:promote --i-am-human → main
| `pnpm agent:prioritize -- <issue> <P0..P3>` | Troca a label `prio:*` | | `pnpm agent:prioritize -- <issue> <P0..P3>` | Troca a label `prio:*` |
| `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` → harvest em guardrail (`docs/GUARDRAILS.md`) | | `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` → harvest em guardrail (`docs/GUARDRAILS.md`) |
| `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Inspeção local do pool de workers Cloud; start/stop/pause **remotos** via `gh workflow run agent-pool.yml -f action=…` (ver "Agent pool" abaixo) | | `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Inspeção local do pool de workers Cloud; start/stop/pause **remotos** via `gh workflow run agent-pool.yml -f action=…` (ver "Agent pool" abaixo) |
| `pnpm agent:promote -- --i-am-human` | **Humano:** PR `stage→main` + merge se CI green | | `pnpm agent:promote -- --i-am-human` | **Override manual:** PR `stage→main` + merge se CI stage green (auto-promote faz isso sozinho) |
| `pnpm push [-- -u origin HEAD …]` | **Canônico:** `ensure-repo-deps` + `gate:push` + `git push --no-verify` (gate no script — não depende do Husky) |
| `pnpm configure:branch-protection [-- --dry-run]` | Ajusta proteção de `stage` (`strict=false`) e `main` (0 reviews — auto-promote substitui o humano) |
| `pnpm db:seed:minimal` | DB mínimo sintético (contrato: [`scripts/lib/seed-minimal-manifest.mjs`](../scripts/lib/seed-minimal-manifest.mjs), pin `tests/unit/seedMinimalManifest.unit.spec.ts`) | | `pnpm db:seed:minimal` | DB mínimo sintético (contrato: [`scripts/lib/seed-minimal-manifest.mjs`](../scripts/lib/seed-minimal-manifest.mjs), pin `tests/unit/seedMinimalManifest.unit.spec.ts`) |
| `pnpm db:refresh:stage` | **Humano:** nova Neon branch `stage` de prod + swap do secret `STAGE_DATABASE_URL` (requer `NEON_API_KEY`) | | `pnpm db:refresh:stage` | **Humano:** nova Neon branch `stage` de prod + swap do secret `STAGE_DATABASE_URL` (requer `NEON_API_KEY`) |
@ -56,13 +60,14 @@ Labels: estado `ready|in-progress|blocked|done|in-prod`, `prio:P0..P3`, `kind:fe
## CI por alvo ## CI por alvo
| Workflow | Trigger | Banco | Passos | | Workflow | Trigger | Banco | Passos |
| -------------- | ------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------- | --------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ci-pr.yml` | PR → `stage`/`main` | service Postgres 17 (mínimo) | jobs paralelos: `lint`/`format`/`typecheck`/`knip`/`cycles` · `unit`/`int` (**afetados pelo diff**: `vitest --changed` vs base do PR; fallback full em paths de risco — `scripts/test-affected.mjs`) · `e2e` (manifesto path→spec — `scripts/e2e-affected.mjs`, bloqueante) · `build` + `migration-lock`; job rollup **`checks`** agrega todos (nome legado exigido pela branch protection — não re-serializa a suíte) | | `ci-pr.yml` | PR → `stage`/`main` | service Postgres 17 (mínimo) | jobs paralelos: `lint`/`format`/`typecheck`/`knip`/`cycles` · `unit`/`int` (**afetados pelo diff**: `vitest --changed` vs base do PR; fallback full em paths de risco — `scripts/test-affected.mjs`) · `e2e` (manifesto path→spec — `scripts/e2e-affected.mjs`, bloqueante) · `build` + `migration-lock`; job rollup **`checks`** agrega todos (nome legado exigido pela branch protection — não re-serializa a suíte) |
| `ci-stage.yml` | push em `stage` | `STAGE_DATABASE_URL` (Environment `stage`) | **só** `migrate` + smoke int (subset curado — a suíte completa é lenta demais contra Neon remoto, estourou timeout 2× em 2026-07-30; a full fica no ci-pr/ci.yml) — **NUNCA `pnpm build`** (build migra o banco que vê), nunca seed:minimal | | `ci-stage.yml` | push em `stage` | `STAGE_DATABASE_URL` (Environment `stage`) | **smoke** `migrate` + subset int curado contra snapshot Neon (não é suíte completa — full int estourou timeout 2× em 2026-07-30). Gate de **promote automático** para `main`. **NUNCA `pnpm build`** |
| `ci.yml` | push em `main` | service Postgres | **gate completo sempre** (rede de segurança pós-promote): estático + unit + int full (com seed:minimal) + build + e2e full | | `promote-stage-to-main.yml` | ci-stage success em `stage` | — | auto-merge `stage→main` se head ainda é o de `stage`; marca Issues `done` → `in-prod` |
| `ci.yml` | push em `main` | service Postgres | **gate completo sempre** (rede de segurança pós-promote): estático + unit + int full (com seed:minimal) + build + e2e full |
Fast gate local do agente (iteração): `pnpm gate:fast` (lint + typecheck + test:unit). O hook `.husky/pre-push` roda `pnpm gate:push` → `scripts/gate-ci.mjs` — **espelho serial do `ci-pr.yml`**: estático + build sempre; unit/int/e2e no **mesmo blast radius** que o CI de feature (`test-affected.mjs` / `e2e-affected.mjs` vs `origin/stage`). Requer Postgres local com `teqo_test` (`pnpm db:start` ou `.cursor/ensure-postgres.sh` no Cloud). `migration-lock` só no GitHub. Escape WIP: `git push --no-verify`. Debug e2e isolado: `pnpm test:e2e:affected`. Fast gate local do agente (iteração): `pnpm gate:fast` (lint + typecheck + test:unit). **Push canônico:** `pnpm push` — `ensure-repo-deps` + `gate:push` no script + push (`--no-verify` só porque o gate já rodou). Hook `.husky/pre-push` repete o mesmo para quem usa `git push` após `pnpm i`. Cursor Cloud: `pnpm install` já está em `.cursor/environment.json` (`install`). Requer Postgres local com `teqo_test`. Escape WIP: `git push --no-verify` sem `pnpm push`. Debug e2e: `pnpm test:e2e:affected`.
## Stage DB — runbook de refresh (semanal, humano) ## Stage DB — runbook de refresh (semanal, humano)

View file

@ -38,6 +38,7 @@
"agent:file-miss": "node scripts/agent-file-miss.mjs", "agent:file-miss": "node scripts/agent-file-miss.mjs",
"agent:promote": "node scripts/agent-promote.mjs", "agent:promote": "node scripts/agent-promote.mjs",
"agent:pool": "node scripts/agent-pool.mjs", "agent:pool": "node scripts/agent-pool.mjs",
"configure:branch-protection": "node scripts/configure-branch-protection.mjs",
"build:demographics": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-demographics.mjs", "build:demographics": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-demographics.mjs",
"build:election-aggregates": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/build-election-aggregates.mjs", "build:election-aggregates": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/build-election-aggregates.mjs",
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs", "build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs",
@ -50,6 +51,7 @@
"gate:fast": "pnpm lint && pnpm typecheck && pnpm test:unit", "gate:fast": "pnpm lint && pnpm typecheck && pnpm test:unit",
"gate:ci": "node scripts/gate-ci.mjs", "gate:ci": "node scripts/gate-ci.mjs",
"gate:push": "pnpm gate:ci", "gate:push": "pnpm gate:ci",
"push": "node scripts/git-push.mjs",
"check:cycles": "madge --circular --extensions ts,tsx --ts-config tsconfig.json src", "check:cycles": "madge --circular --extensions ts,tsx --ts-config tsconfig.json src",
"generate:importmap": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:importmap", "generate:importmap": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:types", "generate:types": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:types",

View file

@ -1,17 +1,15 @@
/** /**
* `pnpm agent:promote -- --i-am-human` — promote `stage` → `main` (production). * `pnpm agent:promote -- --i-am-human` — manual override: promote `stage` → `main`.
* *
* HUMAN-ONLY. The flag is the review: branch protection on `main` requires a * Normal path: `promote-stage-to-main.yml` auto-merges when ci-stage is green.
* human review, and this script is it. It refuses to run without the flag and * Use this only when the workflow failed or you need to promote out of band.
* never runs from Cursor Automations.
* *
* Checks (in order): * Checks (in order):
* 1. `main` has not diverged from `stage` (hotfix direto em main exige antes * 1. `main` has not diverged from `stage` (hotfix direto em main exige antes
* merge/rebase de main em stage + CI stage green de novo). * merge/rebase de main em stage + CI stage green de novo).
* 2. Latest ci-stage run on `stage` is green. * 2. Latest ci-stage run on `stage` is green.
* 3. Creates the PR stage→main if none is open, then requires the PR checks * 3. Creates the PR stage→main if none is open, then merges (ci-stage is the
* (ci-pr: checks + migration-lock) green before merging. * pre-promote gate; ci.yml on main is the post-merge safety net).
* 4. Merges. Labels on done issues gain `in-prod` for the promoted range.
*/ */
import { execFileSync } from 'node:child_process' import { execFileSync } from 'node:child_process'
@ -23,9 +21,9 @@ const { flags } = parseArgs(process.argv.slice(2), new Set())
if (flags['i-am-human'] !== true) { if (flags['i-am-human'] !== true) {
die( die(
'Refused: promote is human-only. Re-run as a human with:\n' + 'Refused: promote override is human-only. Re-run as a human with:\n' +
' pnpm agent:promote -- --i-am-human\n' + ' pnpm agent:promote -- --i-am-human\n' +
'Agents stop at merge-to-stage. Promote moves stage → main → produção (Vercel).', 'Auto-promote runs via promote-stage-to-main.yml when ci-stage is green.',
) )
} }
@ -70,7 +68,7 @@ if (
} }
console.log('[agent:promote] CI stage green ✓') console.log('[agent:promote] CI stage green ✓')
// 3. PR stage → main. // 3. PR stage → main (create if needed).
let pr = ghJson([ let pr = ghJson([
'pr', 'pr',
'list', 'list',
@ -94,7 +92,7 @@ if (!pr) {
'--title', '--title',
`Promote stage → main (${new Date().toISOString().slice(0, 10)})`, `Promote stage → main (${new Date().toISOString().slice(0, 10)})`,
'--body', '--body',
'Promote humano via `pnpm agent:promote --i-am-human`. CI stage green verificado antes da criação.', 'Override manual via `pnpm agent:promote --i-am-human`. CI stage green verificado antes do merge.',
]) ])
pr = { number: url.split('/').pop(), url } pr = { number: url.split('/').pop(), url }
console.log(`[agent:promote] created PR ${pr.url}`) console.log(`[agent:promote] created PR ${pr.url}`)
@ -102,17 +100,6 @@ if (!pr) {
console.log(`[agent:promote] reusing open PR ${pr.url}`) console.log(`[agent:promote] reusing open PR ${pr.url}`)
} }
// 4. PR checks green, then merge (the human running this script IS the review // 4. Merge (ci-stage was the pre-promote gate; ci.yml on main is the post-merge safety net).
// required by main's branch protection; --admin applies that judgment).
const checks = ghJson(['pr', 'checks', String(pr.number), '--json', 'name,state'])
const notGreen = checks.filter((check) => check.state !== 'SUCCESS')
if (notGreen.length > 0) {
die(
`PR #${pr.number} ainda tem checks não-verdes: ${notGreen
.map((check) => `${check.name}=${check.state}`)
.join(', ')}. Espere o ci-pr terminar e re-rode o promote.`,
)
}
gh(['pr', 'merge', String(pr.number), '--admin', '--merge']) gh(['pr', 'merge', String(pr.number), '--admin', '--merge'])
console.log(`[agent:promote] merged stage → main via PR #${pr.number}. Deploy Vercel a caminho.`) console.log(`[agent:promote] merged stage → main via PR #${pr.number}. Deploy Vercel a caminho.`)

View file

@ -0,0 +1,53 @@
/**
* One-time / drift repair for branch protection aligned with auto-promote + parallel agents.
*
* pnpm configure:branch-protection # apply
* pnpm configure:branch-protection -- --dry-run
*
* Changes:
* - stage: strict=false (allow merge without being up-to-date; conflicts still block)
* - main: required_approving_review_count=0 (auto-promote replaces human review)
*
* Auto-promote still needs secrets.PROMOTE_GITHUB_TOKEN (admin PAT) on the repo if
* GITHUB_TOKEN cannot bypass main protection — see docs/AGENT-OPS.md.
*/
import { execFileSync } from 'node:child_process'
import { parseArgs } from './lib/agent-github.mjs'
const { flags } = parseArgs(process.argv.slice(2), new Set())
const dryRun = flags['dry-run'] === true
const repo = execFileSync(
'gh',
['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'],
{
encoding: 'utf8',
},
).trim()
const ghApiJson = (method, path, body) => {
const payload = JSON.stringify(body)
if (dryRun) {
console.log(`[dry-run] gh api -X ${method} ${path} ${payload}`)
return
}
execFileSync('gh', ['api', '-X', method, path, '--input', '-'], {
input: payload,
stdio: ['pipe', 'inherit', 'inherit'],
})
}
console.log(`[configure:branch-protection] repo ${repo}${dryRun ? ' (dry-run)' : ''}`)
ghApiJson('PATCH', `repos/${repo}/branches/stage/protection/required_status_checks`, {
strict: false,
contexts: ['checks', 'migration-lock'],
})
ghApiJson('PATCH', `repos/${repo}/branches/main/protection/required_pull_request_reviews`, {
required_approving_review_count: 0,
})
console.log('[configure:branch-protection] done.')

View file

@ -0,0 +1,57 @@
#!/usr/bin/env node
/**
* Idempotent bootstrap for worktrees/clones before hooks or gate scripts run.
* Installs deps when `node_modules` is missing/stale and wires Husky hooks.
*
* Used by `.husky/pre-push` and `pnpm push` — no imports from `scripts/lib/`
* (those need `node_modules` first).
*/
import { execFileSync, spawnSync } from 'node:child_process'
import { existsSync, statSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const runOrDie = (label, command, args) => {
console.log(`[ensure-deps] ${label}`)
const result = spawnSync(command, args, { cwd: repoRoot, stdio: 'inherit' })
if ((result.status ?? 1) !== 0) {
console.error(`[ensure-deps] failed: ${command} ${args.join(' ')}`)
process.exit(result.status ?? 1)
}
}
const needsInstall = () => {
const nodeModules = path.join(repoRoot, 'node_modules')
const lockfile = path.join(repoRoot, 'pnpm-lock.yaml')
if (!existsSync(nodeModules) || !existsSync(path.join(nodeModules, '.pnpm'))) return true
if (!existsSync(path.join(nodeModules, '.bin', 'prettier'))) return true
if (!existsSync(lockfile)) return false
return statSync(lockfile).mtimeMs > statSync(nodeModules).mtimeMs
}
const huskyHooksMissing = () => {
try {
const hooksPath = execFileSync('git', ['config', 'core.hooksPath'], {
cwd: repoRoot,
encoding: 'utf8',
}).trim()
return !hooksPath.includes('husky')
} catch {
return true
}
}
export const ensureRepoDeps = () => {
if (needsInstall()) {
runOrDie('pnpm install --frozen-lockfile', 'pnpm', ['install', '--frozen-lockfile'])
}
if (huskyHooksMissing()) {
runOrDie('pnpm run prepare (husky)', 'pnpm', ['run', 'prepare'])
}
}
if (process.argv[1]?.endsWith('ensure-repo-deps.mjs')) {
ensureRepoDeps()
}

36
scripts/git-push.mjs Normal file
View file

@ -0,0 +1,36 @@
#!/usr/bin/env node
/**
* Canonical push: bootstrap deps/hooks, run gate:push, then push.
*
* Does NOT rely on the Husky pre-push hook — `gate:push` runs here on purpose
* so worktrees/agents without wired hooks still get the ci-pr mirror (incl.
* format:check). Uses `--no-verify` only because the gate already ran; this is
* not an escape hatch (`git push --no-verify` without `pnpm push` still is).
*
* Humans who prefer `git push` after `pnpm i` still hit `.husky/pre-push`.
*/
import { spawnSync } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { ensureRepoDeps } from './ensure-repo-deps.mjs'
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const runOrDie = (label, command, args) => {
console.log(`[pnpm push] ▶ ${label}`)
const result = spawnSync(command, args, { cwd: repoRoot, stdio: 'inherit' })
if ((result.status ?? 1) !== 0) {
process.exit(result.status ?? 1)
}
}
ensureRepoDeps()
runOrDie('gate:push', 'pnpm', ['gate:push'])
const args = process.argv.slice(2)
const result = spawnSync('git', ['push', '--no-verify', ...args], {
cwd: repoRoot,
stdio: 'inherit',
})
process.exit(result.status ?? 1)

View file

@ -47,18 +47,21 @@ describe('searchHomeMunicipalities (B48)', () => {
const advisor = await fixtures.createCampaignUser('advisor') const advisor = await fixtures.createCampaignUser('advisor')
const coordinator = await fixtures.createCampaignUser('coordinator') const coordinator = await fixtures.createCampaignUser('coordinator')
const administered = await fixtures.getMunicipality('cairu') const administered = await fixtures.getMunicipality()
const other = await fixtures.getMunicipality('feira-de-santana') const other = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id]) await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
const advisorResult = await searchHomeMunicipalities(payload, advisor, 'Feira') const otherQuery = other.name.split(/\s+/)[0]!
const administeredQuery = administered.name.split(/\s+/)[0]!
const advisorResult = await searchHomeMunicipalities(payload, advisor, otherQuery)
expect(advisorResult.municipalities.map((hit) => hit.slug)).toEqual([]) expect(advisorResult.municipalities.map((hit) => hit.slug)).toEqual([])
const coordinatorResult = await searchHomeMunicipalities(payload, coordinator, 'Feira') const coordinatorResult = await searchHomeMunicipalities(payload, coordinator, otherQuery)
expect(coordinatorResult.municipalities.some((hit) => hit.slug === other.slug)).toBe(true) expect(coordinatorResult.municipalities.some((hit) => hit.slug === other.slug)).toBe(true)
const advisorCairu = await searchHomeMunicipalities(payload, advisor, 'Cairu') const advisorAdmin = await searchHomeMunicipalities(payload, advisor, administeredQuery)
expect(advisorCairu.municipalities.map((hit) => hit.slug)).toEqual(['cairu']) expect(advisorAdmin.municipalities.map((hit) => hit.slug)).toEqual([administered.slug])
}) })
it('rejects leaders from home search', async () => { it('rejects leaders from home search', async () => {

View file

@ -46,18 +46,21 @@ describe('searchStaffMunicipalityHits (B60)', () => {
const advisor = await fixtures.createCampaignUser('advisor') const advisor = await fixtures.createCampaignUser('advisor')
const coordinator = await fixtures.createCampaignUser('coordinator') const coordinator = await fixtures.createCampaignUser('coordinator')
const administered = await fixtures.getMunicipality('cairu') const administered = await fixtures.getMunicipality()
const other = await fixtures.getMunicipality('feira-de-santana') const other = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id]) await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
const advisorResult = await searchStaffMunicipalityHits(payload, advisor, 'Feira') const otherQuery = other.name.split(/\s+/)[0]!
const administeredQuery = administered.name.split(/\s+/)[0]!
const advisorResult = await searchStaffMunicipalityHits(payload, advisor, otherQuery)
expect(advisorResult.map((hit) => hit.slug)).toEqual([]) expect(advisorResult.map((hit) => hit.slug)).toEqual([])
const coordinatorResult = await searchStaffMunicipalityHits(payload, coordinator, 'Feira') const coordinatorResult = await searchStaffMunicipalityHits(payload, coordinator, otherQuery)
expect(coordinatorResult.some((hit) => hit.slug === other.slug)).toBe(true) expect(coordinatorResult.some((hit) => hit.slug === other.slug)).toBe(true)
const advisorCairu = await searchStaffMunicipalityHits(payload, advisor, 'Cairu') const advisorAdmin = await searchStaffMunicipalityHits(payload, advisor, administeredQuery)
expect(advisorCairu.map((hit) => hit.slug)).toEqual(['cairu']) expect(advisorAdmin.map((hit) => hit.slug)).toEqual([administered.slug])
}) })
it('rejects leaders from wizard municipality search', async () => { it('rejects leaders from wizard municipality search', async () => {