diff --git a/.agents/skills/capture-review-debts/SKILL.md b/.agents/skills/capture-review-debts/SKILL.md index 7ddf8d4a..f9558703 100644 --- a/.agents/skills/capture-review-debts/SKILL.md +++ b/.agents/skills/capture-review-debts/SKILL.md @@ -1,6 +1,6 @@ --- name: capture-review-debts -description: Use when the user asks to register leftover /simplify or /impeccable findings as trackable Forgejo Issues, harvest session review debts, triage post-simplify or post-critique follow-ups, or says "registra os débitos", "o que ficou do simplify", "coloca como issue o que o critique apontou", "harvest debts". +description: Use when the user asks to register leftover /simplify or /impeccable findings as trackable GitHub Issues, harvest session review debts, triage post-simplify or post-critique follow-ups, or says "registra os débitos", "o que ficou do simplify", "coloca como issue o que o critique apontou", "harvest debts". --- # Capturar débitos de /simplify e /impeccable em Issues diff --git a/.agents/skills/plan-issue/SKILL.md b/.agents/skills/plan-issue/SKILL.md index 8cd7d6cb..7b77cb8c 100644 --- a/.agents/skills/plan-issue/SKILL.md +++ b/.agents/skills/plan-issue/SKILL.md @@ -1,7 +1,7 @@ --- name: plan-issue description: >- - Transforma ideias humanas em Issues do Forgejo + planos de intenção em + Transforma ideias humanas em Issues do GitHub + planos de intenção em docs/plans/ (persona, fluxo, objetivo, direção suave no código — sem decisões duras de engenharia). Se o item muda UI, apresenta no gate um rascunho visual HTML+Tailwind (PNG embutido no plano de intenção). Divide @@ -13,7 +13,7 @@ disable-model-invocation: true # Planejar Issues (intenção, não engenharia) -Esta skill transforma ideias soltas em: (1) um **plano de intenção** em `docs/plans/.md` por item, e (2) uma **Issue rastreável no Forgejo** (`pnpm agent:register`, frontmatter `id/depends/serializes/priority/model`). As Issues do Forgejo (`git.solla.dev/fsolla/teqo`) são a fonte canônica de spec/status/deps/prio/modelo — `docs/roadmap.md` é legado congelado e **nunca** é editado aqui. +Esta skill transforma ideias soltas em: (1) um **plano de intenção** em `docs/plans/.md` por item, e (2) uma **Issue rastreável no GitHub** (`pnpm agent:register`, frontmatter `id/depends/serializes/priority/model`). As Issues do GitHub (`github.com/fsolla/teqo/issues`) são a fonte canônica de spec/status/deps/prio/modelo — `docs/roadmap.md` é legado congelado e **nunca** é editado aqui. ## Ciclo de vida (obrigatório) diff --git a/.agents/skills/project-status/SKILL.md b/.agents/skills/project-status/SKILL.md index 7366f49e..e33a5506 100644 --- a/.agents/skills/project-status/SKILL.md +++ b/.agents/skills/project-status/SKILL.md @@ -1,11 +1,11 @@ --- name: project-status -description: Apresenta o estado do projeto Teqo a partir das Issues do Forgejo rastreáveis — overview (estado/prio/kind), fila atual na ordem do agent:claim com o modelo de cada item, grafo mermaid de dependências, bloqueios com motivo e sugestões de consolidação (merge/break-down). Read-only; NUNCA modifica Issues, em particular nunca toca Issues in-progress. Usar quando o usuário pedir status/visão geral do projeto, "como está o projeto", "qual a fila", "o que está bloqueado", "mostra o grafo", ou perguntar o que dá para consolidar. +description: Apresenta o estado do projeto Teqo a partir das Issues do GitHub rastreáveis — overview (estado/prio/kind), fila atual na ordem do agent:claim com o modelo de cada item, grafo mermaid de dependências, bloqueios com motivo e sugestões de consolidação (merge/break-down). Read-only; NUNCA modifica Issues, em particular nunca toca Issues in-progress. Usar quando o usuário pedir status/visão geral do projeto, "como está o projeto", "qual a fila", "o que está bloqueado", "mostra o grafo", ou perguntar o que dá para consolidar. --- # Status do projeto (read-only) -Esta skill apresenta o estado do projeto a partir das Issues do Forgejo rastreáveis — a fonte canônica de spec/status/deps/prio/modelo. **Read-only por contrato:** nenhuma Issue é criada, editada ou relabelada aqui, e Issues `in-progress` são intocáveis (risco de conflito com o agente que está executando). +Esta skill apresenta o estado do projeto a partir das Issues do GitHub rastreáveis — a fonte canônica de spec/status/deps/prio/modelo. **Read-only por contrato:** nenhuma Issue é criada, editada ou relabelada aqui, e Issues `in-progress` são intocáveis (risco de conflito com o agente que está executando). ## Fluxo diff --git a/.agents/skills/work-issue/SKILL.md b/.agents/skills/work-issue/SKILL.md index 36717b97..f8636c16 100644 --- a/.agents/skills/work-issue/SKILL.md +++ b/.agents/skills/work-issue/SKILL.md @@ -42,10 +42,11 @@ pular a pausa do impl plan; Draft / sem auto-merge. ## Passo 1 — Contexto da sessão A Issue chega no prompt/`$ARGUMENTS` do `/work-issue` (`--issue ` ou o número — -contrato OPS33; chamada manual: passar o número). Sempre consulte o Forgejo para o +contrato OPS33; chamada manual: passar o número). Sempre consulte o tracker do GitHub +(`github.com/fsolla/teqo/issues`, via `pnpm issue` / `scripts/issue.mjs`) para o resto (fonte única é a Issue, nunca um brief duplicado). -- **Presente** → valide o básico (issue do Forgejo `git.solla.dev/fsolla/teqo`: +- **Presente** → valide o básico (issue do GitHub `https://github.com/fsolla/teqo/issues`: existe, `OPEN`, label `in-progress` — confirma que o claim foi feito fora da skill) e use. - **Ausente** → **uma** pergunta ao humano ("Qual Issue?"), valide o número diff --git a/.github/workflows/issue-done-on-main-merge.yml b/.github/workflows/issue-done-on-main-merge.yml index 5a3ba77f..c75d86a9 100644 --- a/.github/workflows/issue-done-on-main-merge.yml +++ b/.github/workflows/issue-done-on-main-merge.yml @@ -1,11 +1,10 @@ # 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). +# OPS76: PRs AND issues live on GitHub. The job passes the PR body via PR_BODY +# (GitHub event payload) and scripts/issue-transition-on-merge.mjs flips +# labels/comments and closes the issue on the GitHub API with the repo +# GITHUB_TOKEN (issues: write). 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 @@ -28,6 +27,7 @@ on: permissions: contents: read pull-requests: read + issues: write jobs: issue-done: @@ -36,10 +36,8 @@ jobs: (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 + # The github-api.mjs client reads GITHUB_TOKEN; the built-in token has + # `issues: write` scoped to this repo (the issue tracker is on GitHub). GH_TOKEN: ${{ github.token }} steps: - uses: actions/checkout@v5 @@ -77,4 +75,4 @@ jobs: 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 }}" + run: node scripts/issue-transition-on-merge.mjs --pr "${{ steps.pr.outputs.pr_number || github.event.pull_request.number }}" diff --git a/.github/workflows/plan-issue-ready-on-main-merge.yml b/.github/workflows/plan-issue-ready-on-main-merge.yml index 2c45d59b..c6b1ec6b 100644 --- a/.github/workflows/plan-issue-ready-on-main-merge.yml +++ b/.github/workflows/plan-issue-ready-on-main-merge.yml @@ -1,10 +1,9 @@ # 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). +# to `ready`. Idempotent with `pnpm agent:ready` (agent happy path). OPS76: +# the PR and the issues live on GitHub — the job passes the PR body via PR_BODY +# and scripts/agent-promote-related-on-merge.mjs flips labels on the GitHub API +# (repo GITHUB_TOKEN, `issues: write`). # # 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. @@ -34,6 +33,7 @@ on: permissions: contents: read pull-requests: read + issues: write jobs: plan-issue-ready: @@ -43,10 +43,8 @@ jobs: 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 + # The github-api.mjs client reads GITHUB_TOKEN; the built-in token has + # `issues: write` scoped to this repo (the tracker is on GitHub). GH_TOKEN: ${{ github.token }} steps: - uses: actions/checkout@v5 diff --git a/AGENTS.md b/AGENTS.md index 83e164e6..2553189d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ These are product/architecture constraints — not a ban on editing existing cod - Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"), and `candidate` ("Candidato", unrestricted visibility), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the municipalities they administer; a leader is locked down to the supporter contact tool. - **2026-07-23 remodel (DEPLOYED to production 2026-07-23): "Município" is the operational unit** (it replaced "Praça", which replaced "Núcleo Eleitoral"). Municípios are PREDEFINED (seeded, geography read-only): 435 rows — one per Bahia municipality (416), except Salvador (19 zone Municípios, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `municipality`. Leaderships are one record per person (`contact` UNIQUE) linked to N municipalities and N organizations; vote pledges are per leadership×municipality with `declaredVotes` (staff-entered, leader-visible) vs `estimatedVotes` (three scenarios, staff-only, hidden from the leader). Campaign production data was reset by the remodel migrations (public-site data untouched). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `remodelagem-pracas.md`). Details: "Campaign Municípios model" below. -- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy live on GitHub Actions (OPS71, 2026-08-19); the ISSUE TRACKER stays on the Forgejo** (`git.solla.dev/fsolla/teqo` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips all keep talking to the Forgejo API; GitHub is only the Actions host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado, skips via `ci-scope.mjs`); its check-run `CI (PR) / checks` is the required check of `main` — 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) — and the gate of the **native auto-merge** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`, GraphQL `enablePullRequestAutoMerge`, rebase — armed with the `AUTOMERGE_PAT` real-user PAT, fail-closed without it; the built-in GITHUB*TOKEN would make the merge happen as `github-actions[bot]`, whose events never create workflow runs — the post-merge `closed` flips would silently never run, live finding PR #746 (OPS71-FLIP) — the server only merges with the required check green). **There is NO main verifier (`ci.yml`) anymore**: publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`) with two jobs — `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full) → `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 tunnel; the hosted job never touches the homeserver). The remote script is idempotent ("already deployed" → skip), builds the migrator image first, applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before the runner build** (OPS66: the static build reads the new schema — build-after-migrate, never the reverse), builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), and recreates the container; the repo comes from `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, public). `pnpm push` pushes to `origin` — **the GitHub remote** (one-time local cutover: `git remote set-url origin git@github.com:fsolla/teqo.git`; the Forgejo stays as a secondary remote and its git repo freezes at the cutover point — tracker intacto por API). The Forgejo-era files (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs`) were removed in the OPS71 Fase 2 (2026-08-19); rollback to the Forgejo CI flow is a revert of that removal — the frozen Forgejo `main` still holds the full pre-cutover tree. Runbook (rollback, falhas conhecidas, passos do cutover): `docs/ops/teqo-1313-deploy.md`. **The agent-pool supervisor workflow was killed in OPS65** (the `*/10`tick cost 144 full`pnpm install`/day on the workstation; `agent-pool.yml` is gone, pool scripts/skills stay dormant). **The production database lives on the homeserver Postgres (`teqo\*1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore`(dump in`/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL`in`~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media`collection uploads via`@payloadcms/storage-s3`when the`S3\*\*`envs are set, and Payload proxies the files through`/api/media/file/...`(the bucket stays private). Production env lives in`~/stack/teqo-1313.env`(bucket`teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3\__`config aborts the boot, and no`S3\_\*`means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em`/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg`e`jorgesolla.jpg`) — re-executar via `pnpm media:recover`(restaura objetos sem tocar no DB; runbook em`docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1`— guard de intenção explícita para escrita no bucket,`--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`\_test`-suffixed targets. +- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy AND the ISSUE TRACKER live on GitHub (OPS71 + OPS76, 2026-08-21)** (`github.com/fsolla/teqo/issues` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips talk to the GitHub API; GitHub is the single host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado, skips via `ci-scope.mjs`); its check-run `CI (PR) / checks` is the required check of `main` — 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) — and the gate of the **native auto-merge** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`, GraphQL `enablePullRequestAutoMerge`, rebase — armed with the `AUTOMERGE_PAT` real-user PAT, fail-closed without it; the built-in GITHUB*TOKEN would make the merge happen as `github-actions[bot]`, whose events never create workflow runs — the post-merge `closed` flips would silently never run, live finding PR #746 (OPS71-FLIP) — the server only merges with the required check green). **There is NO main verifier (`ci.yml`) anymore**: publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`) with two jobs — `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full) → `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 tunnel; the hosted job never touches the homeserver). The remote script is idempotent ("already deployed" → skip), builds the migrator image first, applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before the runner build** (OPS66: the static build reads the new schema — build-after-migrate, never the reverse), builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), and recreates the container; the repo comes from `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, public). `pnpm push` pushes to `origin` — **the GitHub remote** (one-time local cutover: `git remote set-url origin git@github.com:fsolla/teqo.git`; the Forgejo stays as a frozen secondary repo at the cutover point, its git repo and issue tracker inactive — OPS76 migrated the tracker to GitHub). The Forgejo-era files (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs`) were removed in the OPS71 Fase 2 (2026-08-19); rollback to the Forgejo CI flow is a revert of that removal — the frozen Forgejo `main` still holds the full pre-cutover tree. Runbook (rollback, falhas conhecidas, passos do cutover): `docs/ops/teqo-1313-deploy.md`. **The agent-pool supervisor workflow was killed in OPS65** (the `*/10`tick cost 144 full`pnpm install`/day on the workstation; `agent-pool.yml` is gone, pool scripts/skills stay dormant). **The production database lives on the homeserver Postgres (`teqo\*1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore`(dump in`/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL`in`~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media`collection uploads via`@payloadcms/storage-s3`when the`S3\*\*`envs are set, and Payload proxies the files through`/api/media/file/...`(the bucket stays private). Production env lives in`~/stack/teqo-1313.env`(bucket`teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3\__`config aborts the boot, and no`S3\_\*`means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em`/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg`e`jorgesolla.jpg`) — re-executar via `pnpm media:recover`(restaura objetos sem tocar no DB; runbook em`docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1`— guard de intenção explícita para escrita no bucket,`--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`\_test`-suffixed targets. - Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL. - **Embasamento de produto/domínio:** `docs/research/` guarda o compêndio de literatura (campanha de DF na Bahia, geografia do voto, cartografia eleitoral) e o relatório de discovery aprovado (2026-07-21) com o playbook dado→decisão. Para métricas, análises territoriais, mapa e priorização em `/campanha`, consulte-o antes de inventar — o kernel: a disputa de DF é conta de quociente fragmentada; leitura útil é relativa e local (captura, LQ, quantis, cobertura de meta por pledges), nunca % estadual absoluto. diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index d887924d..9c7683ff 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -1,6 +1,6 @@ # AGENT-OPS — operação do paradigma de agentes paralelos -Uma página. Norte: simplicidade (Forgejo = tracker, GitHub = Actions), agente entrega em `main`, deploy de produção é **manual** com verificação full (OPS71). +Uma página. Norte: simplicidade (GitHub = tracker E Actions — OPS76), agente entrega em `main`, deploy de produção é **manual** com verificação full (OPS71). ## Ambientes de dados (test ladder — nunca misturar) @@ -18,7 +18,7 @@ claim → feature branch → PR --base main (GitHub) → CI PR green (cascade + 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` (GitHub) → `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 GitHub (`github.com/fsolla/teqo/issues`). - **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`). @@ -36,9 +36,9 @@ main → (opcional, deliberado) workflow_dispatch deploy.yml → verify full sui | Comando | Faz | | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief (Forgejo) | +| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief (GitHub) | | `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 no Forgejo (`--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 GitHub (`--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 -- ` | Troca `prio:*` | @@ -49,7 +49,7 @@ main → (opcional, deliberado) workflow_dispatch deploy.yml → verify full sui | `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 (Forgejo): `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`. +Labels (GitHub): `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`. @@ -74,8 +74,8 @@ Arquivos de "lista" que N agentes escrevem em paralelo (changelog, plans, migra | ------------------------------------ | --------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `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 ` **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) | +| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Closes`/`Fixes` → `done` + `in-prod` + **close** **no GitHub** (token `GITHUB_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 GitHub** (OPS18; soft-skip; mesmo token) | | `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 | @@ -87,19 +87,18 @@ Fast gate: `pnpm gate:fast`. Push: `pnpm push`. ### Secrets (humano, uma vez — repo GitHub) -| 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 | +| Secret | Uso | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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 | -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). +Local: `GITHUB_TOKEN` (PAT com escopo `repo` + `issues: write`) para `node scripts/github-pr.mjs`, `pnpm configure:branch-protection`, `pnpm issue`/`agent:*` 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). `FORGEJO_API_TOKEN` (git.solla.dev) é **legado da era pré-OPS76** — o tracker migrou para o GitHub e o cliente `forgejo-api.mjs` foi removido. ### Flips pós-merge (OPS71-FLIP): o evento `closed` e a recuperação manual 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 ` 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). +**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). O flip **fecha** a Issue (`PATCH state=closed` — o `closed` do GitHub por keyword pode fechar, mas o flip explicita labels `done`/`in-prod` + close para o tracker GitHub). Falha em qualquer passo = job vermelho (OPS61, nunca mentir). 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). diff --git a/scripts/agent-claim.mjs b/scripts/agent-claim.mjs index 300a03c9..7db59db4 100644 --- a/scripts/agent-claim.mjs +++ b/scripts/agent-claim.mjs @@ -22,7 +22,7 @@ import { issuesById, parseArgs, } from './lib/agent-forgejo.mjs' -import { forgejoApi as api } from './lib/forgejo-api.mjs' +import { githubApi as api } from './lib/github-api.mjs' const die = dieAgent('claim') const { flags } = parseArgs(process.argv.slice(2), new Set(['issue'])) diff --git a/scripts/agent-file-miss.mjs b/scripts/agent-file-miss.mjs index 994a0cd7..073d4ca3 100644 --- a/scripts/agent-file-miss.mjs +++ b/scripts/agent-file-miss.mjs @@ -10,7 +10,7 @@ */ import { dieAgent, parseArgs } from './lib/agent-forgejo.mjs' -import { forgejoApi as api } from './lib/forgejo-api.mjs' +import { githubApi as api } from './lib/github-api.mjs' const die = dieAgent('file-miss') const { flags } = parseArgs(process.argv.slice(2), new Set(['title', 'body', 'kind'])) @@ -32,7 +32,7 @@ const created = await api.createIssue({ title: flags.title, body, }) -const url = `https://git.solla.dev/fsolla/teqo/issues/${created.number}` +const url = `https://github.com/fsolla/teqo/issues/${created.number}` await api.setLabels(created.number, { add: [`kind:${kind}`, 'prio:P2'] }) diff --git a/scripts/agent-pool.mjs b/scripts/agent-pool.mjs index 79f21fc9..82db00d5 100644 --- a/scripts/agent-pool.mjs +++ b/scripts/agent-pool.mjs @@ -64,9 +64,9 @@ import { POOL_VARIABLE_NAMES, reconcilePoolClaims, } from './lib/agent-pool-state.mjs' -import { forgejoApi as api } from './lib/forgejo-api.mjs' +import { githubApi as api } from './lib/github-api.mjs' -const REPO_URL = 'https://git.solla.dev/fsolla/teqo' +const REPO_URL = 'https://github.com/fsolla/teqo' const POOL_BASE_REF = 'main' const die = dieAgent('pool') diff --git a/scripts/agent-prioritize.mjs b/scripts/agent-prioritize.mjs index eb85807c..3261782f 100644 --- a/scripts/agent-prioritize.mjs +++ b/scripts/agent-prioritize.mjs @@ -5,7 +5,7 @@ */ import { dieAgent, labelNames, parseArgs, setLabels } from './lib/agent-forgejo.mjs' -import { forgejoApi as api } from './lib/forgejo-api.mjs' +import { githubApi as api } from './lib/github-api.mjs' const die = dieAgent('prioritize') const { positional } = parseArgs(process.argv.slice(2), new Set()) diff --git a/scripts/agent-promote-related-on-merge.mjs b/scripts/agent-promote-related-on-merge.mjs index 633282c7..3800fd74 100644 --- a/scripts/agent-promote-related-on-merge.mjs +++ b/scripts/agent-promote-related-on-merge.mjs @@ -9,12 +9,12 @@ * node scripts/agent-promote-related-on-merge.mjs --pr 123 * PR_BODY='Related #296' node scripts/agent-promote-related-on-merge.mjs --pr 123 * - * Env: GITHUB_TOKEN (Forgejo Actions) / FORGEJO_API_TOKEN; optional PR_BODY skips the PR read. + * Env: GITHUB_TOKEN (the tracker lives on GitHub); optional PR_BODY skips the PR read. */ import { dieAgent, parseArgs, setLabels } from './lib/agent-forgejo.mjs' import { canPromotePlanIssue, parseRelatedIssueNumbers } from './lib/agent-plan-lifecycle.mjs' -import { forgejoApi as api } from './lib/forgejo-api.mjs' +import { githubApi as api } from './lib/github-api.mjs' const die = dieAgent('promote-related') const { flags } = parseArgs(process.argv.slice(2), new Set(['pr'])) diff --git a/scripts/agent-ready.mjs b/scripts/agent-ready.mjs index bc8803b3..5d7f7ba0 100644 --- a/scripts/agent-ready.mjs +++ b/scripts/agent-ready.mjs @@ -13,7 +13,7 @@ import { dieAgent, parseArgs, setLabels } from './lib/agent-forgejo.mjs' import { canPromotePlanIssue } from './lib/agent-plan-lifecycle.mjs' -import { forgejoApi as api } from './lib/forgejo-api.mjs' +import { githubApi as api } from './lib/github-api.mjs' const die = dieAgent('ready') const { flags } = parseArgs(process.argv.slice(2), new Set(['issue'])) diff --git a/scripts/agent-register.mjs b/scripts/agent-register.mjs index 1eae8474..14b52180 100644 --- a/scripts/agent-register.mjs +++ b/scripts/agent-register.mjs @@ -12,7 +12,7 @@ import { dieAgent, issuesById, parseArgs, serializeFrontmatter } from './lib/agent-forgejo.mjs' import { resolveRegisterStateLabel } from './lib/agent-plan-lifecycle.mjs' -import { forgejoApi as api } from './lib/forgejo-api.mjs' +import { githubApi as api } from './lib/github-api.mjs' const die = dieAgent('register') const { flags } = parseArgs( @@ -69,7 +69,7 @@ const created = await api.createIssue({ body, }) await api.setLabels(created.number, { add: labels }) -const url = `https://git.solla.dev/fsolla/teqo/issues/${created.number}` +const url = `https://github.com/fsolla/teqo/issues/${created.number}` const hint = stateLabel === 'blocked' && flags.plan && !flags.blocked diff --git a/scripts/agent-status.mjs b/scripts/agent-status.mjs index f126946d..72a73eae 100644 --- a/scripts/agent-status.mjs +++ b/scripts/agent-status.mjs @@ -15,7 +15,7 @@ import { parseFrontmatter, priorityRank, } from './lib/agent-forgejo.mjs' -import { forgejoApi as api } from './lib/forgejo-api.mjs' +import { githubApi as api } from './lib/github-api.mjs' const die = dieAgent('status') diff --git a/scripts/forgejo-issue-transition.mjs b/scripts/issue-transition-on-merge.mjs similarity index 77% rename from scripts/forgejo-issue-transition.mjs rename to scripts/issue-transition-on-merge.mjs index 28822b36..b01d45ab 100644 --- a/scripts/forgejo-issue-transition.mjs +++ b/scripts/issue-transition-on-merge.mjs @@ -3,22 +3,22 @@ * `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. * - * 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). + * OPS76: the PR lives on GitHub and the ISSUE TRACKER is on GitHub too. The + * workflow passes the PR body via the `PR_BODY` env (from the GitHub event + * payload) and the script flips Issues on the GitHub API (repo GITHUB_TOKEN, + * `issues: write`). Without PR_BODY the script falls back to reading the PR + * from the GitHub API (the manual `workflow_dispatch` recovery path). * * 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 * "success" while the status labels lie. Sibling issues are still attempted * (the loop does not abort), but the job goes red so the breakage is visible. * - * node scripts/forgejo-issue-transition.mjs --pr - * PR_BODY='Closes #97' node scripts/forgejo-issue-transition.mjs --pr + * node scripts/issue-transition-on-merge.mjs --pr + * PR_BODY='Closes #97' node scripts/issue-transition-on-merge.mjs --pr */ -import { createApi } from './lib/forgejo-api.mjs' +import { createApi } from './lib/github-api.mjs' const parseArgs = (argv) => { const flags = {} @@ -73,8 +73,9 @@ for (const number of numbers) { number, `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.`, ) - // 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. + // OPS76: o PR e a Issue vivem no GitHub; o close nativo por keyword + // (`Closes #N`) só acontece para issues AINDA abertas citadas no body — + // o flip de labels (done/in-prod/remover in-progress) é explícito aqui. await api.closeIssue(number) console.log(`[forgejo-issue-transition] #${number}: done + in-prod + fechada`) } catch (error) { diff --git a/scripts/issue.mjs b/scripts/issue.mjs index 7c11cf6c..177e3e7b 100644 --- a/scripts/issue.mjs +++ b/scripts/issue.mjs @@ -14,7 +14,7 @@ import { parseFrontmatter, priorityRank, } from './lib/agent-forgejo.mjs' -import { forgejoApi as api } from './lib/forgejo-api.mjs' +import { githubApi as api } from './lib/github-api.mjs' const die = dieAgent('issue') @@ -35,7 +35,7 @@ const cmdNext = async () => { if (satisfiedWithoutIssue.length > 0) { console.log(`deps satisfeitas (roadmap, sem Issue): ${satisfiedWithoutIssue.join(', ')}`) } - console.log(`url: https://git.solla.dev/fsolla/teqo/issues/${issue.number}`) + console.log(`url: https://github.com/fsolla/teqo/issues/${issue.number}`) } const count = (map, key) => map.set(key, (map.get(key) ?? 0) + 1) diff --git a/scripts/lib/agent-forgejo.mjs b/scripts/lib/agent-forgejo.mjs index 2ff442a1..db6495e1 100644 --- a/scripts/lib/agent-forgejo.mjs +++ b/scripts/lib/agent-forgejo.mjs @@ -19,7 +19,7 @@ */ import { dieWithLabel } from './cli.mjs' -import { forgejoApi as api } from './forgejo-api.mjs' +import { githubApi as api } from './github-api.mjs' const PRIORITIES = ['P0', 'P1', 'P2', 'P3'] export const priorityRank = (priority) => PRIORITIES.indexOf(priority) @@ -211,7 +211,7 @@ export const claimBriefLines = (entry) => { ` deps sem issue (roadmap entregue, satisfeitas): ${entry.satisfiedWithoutIssue.join(', ')}`, ] : []), - ` url: https://git.solla.dev/fsolla/teqo/issues/${entry.issue.number}`, + ` url: https://github.com/fsolla/teqo/issues/${entry.issue.number}`, '', '--- spec ---', '', diff --git a/scripts/lib/agent-pool-forgejo.mjs b/scripts/lib/agent-pool-forgejo.mjs index 49c09ab1..b0c38314 100644 --- a/scripts/lib/agent-pool-forgejo.mjs +++ b/scripts/lib/agent-pool-forgejo.mjs @@ -13,7 +13,7 @@ import { randomUUID } from 'node:crypto' import { labelNames } from './agent-forgejo.mjs' import { formatPoolEvent } from './agent-pool-state.mjs' -import { forgejoApi as api } from './forgejo-api.mjs' +import { githubApi as api } from './github-api.mjs' const POOL_STATE_FILE = 'pool-state.json' const POOL_STATE_BRANCH = 'pool-state' diff --git a/scripts/lib/forgejo-api.mjs b/scripts/lib/forgejo-api.mjs deleted file mode 100644 index 0fd60762..00000000 --- a/scripts/lib/forgejo-api.mjs +++ /dev/null @@ -1,491 +0,0 @@ -/** - * Forgejo REST client for the agent ops scripts (`pnpm agent:*`, workflow - * helpers). Zero dependencies — plain Node `fetch` — so it also runs inside - * Forgejo Actions jobs without `pnpm install`. - * - * Auth: `FORGEJO_API_TOKEN` (PAT) or the native `GITHUB_TOKEN` that Forgejo - * Actions injects. Base URL: `FORGEJO_API_URL`, else `/api/v1` - * inside Actions, else `https://git.solla.dev/api/v1`. A browser-ish User-Agent - * is sent because the instance sits behind Cloudflare. - * - * Retry (OPS67): transient failures retry with exponential backoff — a fetch - * 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. 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, - * `issue.labels = [{ name, color }]`, `createdAt` ISO string. - */ - -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 - * @property {string} [base] - * @property {string} [token] - * @property {string} [repository] - * @property {(input: string | URL, init?: { method?: string; headers?: Record; body?: string }) => Promise} [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} [sleepImpl] Test seam for the backoff delay. - */ - -/** - * @param {ForgejoApiOptions} [options] - */ -export const createApi = ({ - base, - token, - repository, - fetchImpl, - retries = 3, - backoffMs = 300, - jitter = true, - sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), -} = {}) => { - const baseUrl = ( - base ?? - process.env.FORGEJO_API_URL ?? - (process.env.GITHUB_SERVER_URL ? `${process.env.GITHUB_SERVER_URL}/api/v1` : null) ?? - DEFAULT_BASE_URL - ).replace(/\/+$/, '') - const repo = - repository ?? - process.env.FORGEJO_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( - `[forgejo-api] ${method} ${path} falhou (tentativa ${attempt}/${attemptCount}): ${reason} — retry em ${delay}ms`, - ) - - const request = async (path, { method = 'GET', body, query } = {}) => { - const authToken = token ?? process.env.FORGEJO_API_TOKEN ?? process.env.GITHUB_TOKEN - if (!authToken) { - throw new Error( - 'Sem token para a API do Forgejo — defina FORGEJO_API_TOKEN (ou rode dentro do Forgejo Actions).', - ) - } - 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 init = { - method, - headers: { - Authorization: `token ${authToken}`, - 'Content-Type': 'application/json', - 'User-Agent': USER_AGENT, - }, - ...(body !== undefined ? { body: JSON.stringify(body) } : {}), - } - 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, init) - } 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 - } - 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() - } catch (error) { - if (isLastAttempt || method !== 'GET') throw error - await retryAfter(attempt, error.message) - continue - } - if (!response.ok) { - const isProxyHtml = /^\s* (String(state).toLowerCase() === 'closed' ? 'CLOSED' : 'OPEN') - - const normalizeIssue = (issue) => ({ - number: issue.number, - title: issue.title, - body: issue.body ?? '', - state: normalizeState(issue.state), - createdAt: issue.created_at, - labels: (issue.labels ?? []).map((label) => ({ - name: label.name, - color: label.color ?? '', - })), - }) - - const api = { - /** GET /api/v1/repos/{owner}/{repo}/issues — shape normalized like `gh issue list --json`. */ - listIssues: async ({ state = 'open', labels, limit = 200, page = 1 } = {}) => { - const issues = await request(`/repos/${owner}/${name}/issues`, { - query: { state, labels, limit, page, type: 'issues' }, - }) - return (Array.isArray(issues) ? issues : []).map(normalizeIssue) - }, - - getIssue: async (number) => { - const issue = await request(`/repos/${owner}/${name}/issues/${number}`) - return normalizeIssue(issue) - }, - - createIssue: async ({ title, body }) => { - const issue = await request(`/repos/${owner}/${name}/issues`, { - method: 'POST', - body: { title, body }, - }) - return normalizeIssue(issue) - }, - - /** Adds labels (never removes). */ - addLabels: (number, labels) => - request(`/repos/${owner}/${name}/issues/${number}/labels`, { - method: 'POST', - body: { labels }, - }), - - /** Removes labels by name (resolves ids, ignores unknown names). */ - removeLabels: async (number, labels) => { - const current = await request(`/repos/${owner}/${name}/issues/${number}/labels`) - for (const label of current ?? []) { - if (labels.includes(label.name)) { - await request(`/repos/${owner}/${name}/issues/${number}/labels/${label.id}`, { - method: 'DELETE', - }) - } - } - }, - - /** - * @param {number} number - * @param {{ add?: string[], remove?: string[] }} [options] - */ - setLabels: async (number, { add = [], remove = [] } = {}) => { - if (remove.length > 0) await api.removeLabels(number, remove) - if (add.length > 0) await api.addLabels(number, add) - }, - - addComment: (number, body) => - request(`/repos/${owner}/${name}/issues/${number}/comments`, { - method: 'POST', - 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) => ({ - body: comment.body ?? '', - user: comment.user?.login ?? '', - })) - }, - - listPullRequestFiles: async (number) => { - const files = await request(`/repos/${owner}/${name}/pulls/${number}/files`) - return (Array.isArray(files) ? files : []).map((file) => ({ - filename: file.filename, - status: file.status, - })) - }, - - /** GET /api/v1/repos/{owner}/{repo}/pulls — raw, normalized lightly. */ - listPullRequests: async ({ state = 'open', limit = 100 } = {}) => { - const pulls = await request(`/repos/${owner}/${name}/pulls`, { - query: { state, limit }, - }) - return (Array.isArray(pulls) ? pulls : []).map((pr) => ({ - number: pr.number, - title: pr.title, - state: normalizeState(pr.state), - body: pr.body ?? '', - head: { ref: pr.head?.ref, sha: pr.head?.sha }, - base: { ref: pr.base?.ref }, - })) - }, - - getPullRequest: async (number) => { - const pr = await request(`/repos/${owner}/${name}/pulls/${number}`) - return { - number: pr.number, - title: pr.title, - state: normalizeState(pr.state), - merged: Boolean(pr.merged), - mergeable: pr.mergeable, - isDraft: Boolean(pr.is_draft), - body: pr.body ?? '', - head: { ref: pr.head?.ref, sha: pr.head?.sha }, - base: { ref: pr.base?.ref }, - } - }, - - /** Marks a draft PR as ready for review (PATCH is_draft=false). */ - markPullRequestReady: (number) => - request(`/repos/${owner}/${name}/pulls/${number}`, { - method: 'PATCH', - body: { is_draft: false }, - }), - - createPullRequest: async ({ head, base, 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 } - }, - - /** - * Commit statuses of a sha (Forgejo Actions check runs surface here). - * Returns [{ context, state }] with state ∈ success|failure|pending|error. - */ - listCommitStatuses: async (sha) => { - const statuses = await request(`/repos/${owner}/${name}/commits/${sha}/status`) - const combined = statuses?.statuses ?? [] - return combined.map((entry) => ({ context: entry.context, state: entry.status })) - }, - - /** Merges a PR with rebase (the repo's canonical merge style). */ - mergePullRequest: (number) => - request(`/repos/${owner}/${name}/pulls/${number}/merge`, { - method: 'POST', - body: { Do: 'rebase' }, - }), - - /** Triggers a workflow via workflow_dispatch. */ - workflowDispatch: (workflowFile, { ref = 'main', inputs = {} } = {}) => - request(`/repos/${owner}/${name}/actions/workflows/${workflowFile}/dispatches`, { - method: 'POST', - body: { ref, inputs }, - }), - - /** GET /api/v1/repos/{owner}/{repo}/contents/{path}?ref=… — { content, sha } base64-decoded. */ - getFileContents: async (path, ref = 'main') => { - const file = await request(`/repos/${owner}/${name}/contents/${path}`, { - query: { ref }, - }) - return { - content: Buffer.from(file.content ?? '', 'base64').toString('utf8'), - sha: file.sha, - } - }, - - /** POST /contents — creates a file on a branch (fails if it exists). */ - createFile: (path, { message, content, branch }) => - request(`/repos/${owner}/${name}/contents/${path}`, { - method: 'POST', - body: { - message, - content: Buffer.from(content, 'utf8').toString('base64'), - branch, - }, - }), - - /** PUT /contents — updates an existing file (sha required). */ - updateFile: (path, { message, content, branch, sha }) => - request(`/repos/${owner}/${name}/contents/${path}`, { - method: 'PUT', - body: { - message, - content: Buffer.from(content, 'utf8').toString('base64'), - branch, - sha, - }, - }), - - /** POST /api/v1/repos/{owner}/{repo}/branch_protections (Forgejo rule-based protection). */ - updateBranchProtection: (branch, payload) => - request(`/repos/${owner}/${name}/branch_protections`, { - method: 'POST', - body: { ...payload, branch_name: branch }, - }), - - /** GET /api/v1/repos/{owner}/{repo}/branch_protections — normalized rules. */ - listBranchProtections: async () => { - const rules = await request(`/repos/${owner}/${name}/branch_protections`) - return (Array.isArray(rules) ? rules : []).map((rule) => ({ - rule_name: rule.rule_name ?? '', - branch_name: rule.branch_name ?? '', - enable_status_check: Boolean(rule.enable_status_check), - status_check_contexts: rule.status_check_contexts ?? [], - enable_push: Boolean(rule.enable_push), - required_approvals: rule.required_approvals ?? 0, - dismiss_stale_approvals: Boolean(rule.dismiss_stale_approvals), - })) - }, - - /** PATCH /api/v1/repos/{owner}/{repo}/branch_protections/{name} — updates an existing rule. */ - editBranchProtection: (name, payload) => - request(`/repos/${owner}/${name}/branch_protections/${name}`, { - method: 'PATCH', - body: payload, - }), - - /** - * The CI (PR) cascade rollup is green and Forgejo finished computing - * mergeability. Returns the last PR read. A draft PR stops the wait - * (converted mid-run): the caller decides what "no merge" means — the CLI - * skips non-`cursor/*` drafts (OPS57 veto). - * - * OPS61 rollup gate: the CI (PR) cascade's aggregate job (`checks`, the - * `if: always()` rollup over every ci-pr job) must have POSTED and be - * success. Job statuses are posted as jobs finish, so a snapshot with zero - * pending can still miss jobs that were not scheduled yet. The rollup only - * exists once the whole cascade settled (it `needs` every job), so its - * absence keeps the wait going. - * - * The safety-net's OWN context (`PR Ready + auto-merge / …`) is posted as - * `pending` for the whole run and flips only at the end — it must never - * gate the verdict. Live finding (PR #67): the pre-OPS61 - * `pending.length === 0` gate was unsatisfiable for that reason, so the - * CLI never auto-merged anything — every Forgejo merge so far was manual - * (`merged_by` the owner). The other pre-OPS61 blocker was the token: the - * merge POST pushes to main, and Forgejo's pre-receive hook rejects the - * push when the caller's user has no repo write — the native GITHUB_TOKEN - * acts as the Actions system user, so the CLI's own merge attempts died - * on 409 "User permission denied for writing" (the job must run with - * FORGEJO_API_TOKEN). The branch-protection rule on the server gates - * manual merges too: the merge POST is rejected (405) while the required - * context is not green. - * - * `mergeable === false` is only trusted after the cascade settled — while - * jobs still run (or the branch-protection rule blocks), Forgejo may - * report false without meaning "conflict". - */ - waitForChecks: async (number, { timeoutMs = 30 * 60 * 1000, pollMs = 15000, log } = {}) => { - const started = Date.now() - for (;;) { - const pr = await api.getPullRequest(number) - if (!pr || pr.state !== 'OPEN' || pr.merged || pr.isDraft) return pr - const statuses = await api.listCommitStatuses(pr.head.sha) - const verdicts = statuses.filter( - (entry) => !entry.context.startsWith('PR Ready + auto-merge'), - ) - const rollup = verdicts.find((entry) => entry.context.startsWith('CI (PR) / checks')) - const cascadeSettled = Boolean(rollup && rollup.state === 'success') - const failed = verdicts.filter( - (entry) => entry.state === 'failure' || entry.state === 'error', - ) - if (failed.length > 0) { - const names = failed.map((entry) => entry.context).join(', ') - throw new Error(`Checks falharam no PR #${number}: ${names}`) - } - if (pr.mergeable === false && cascadeSettled) { - throw new Error(`PR #${number} não mergeável (conflito?) — resolver manualmente.`) - } - if (cascadeSettled && pr.mergeable === true) return pr - if (Date.now() - started > timeoutMs) { - throw new Error(`Timeout esperando checks do PR #${number}.`) - } - if (log) - log( - `[pr#${number}] ${verdicts.length} status, rollup=${ - rollup ? rollup.state : 'ausente' - }, mergeable=${pr.mergeable ?? 'computando'} — aguardando…`, - ) - await new Promise((resolve) => setTimeout(resolve, pollMs)) - } - }, - - /** - * Wait for checks then merge by rebase (the `gh pr merge --auto --rebase` - * equivalent). The verdict is a RE-READ of the PR, never the merge POST - * response: Forgejo 9 answers a successful merge with 200 + empty body, so - * response shape alone cannot distinguish "merged" from "still open". - * - * @returns {Promise<{ attempted: boolean, merged: boolean, pr: object | null }>} - */ - autoMerge: async (number, options) => { - const pr = await api.waitForChecks(number, options) - if (!pr || pr.merged || pr.state !== 'OPEN' || pr.isDraft) { - return { attempted: false, merged: Boolean(pr?.merged), pr } - } - try { - await api.mergePullRequest(number) - } catch (error) { - const after = await api.getPullRequest(number) - if (!after?.merged && after?.state === 'OPEN') throw error - return { attempted: true, merged: Boolean(after?.merged), pr: after } - } - const after = await api.getPullRequest(number) - return { attempted: true, merged: Boolean(after?.merged), pr: after } - }, - } - - return api -} - -/** Default instance bound to the environment. */ -export const forgejoApi = createApi({}) diff --git a/scripts/lib/github-api.mjs b/scripts/lib/github-api.mjs index ccc1fbd3..2ae14a50 100644 --- a/scripts/lib/github-api.mjs +++ b/scripts/lib/github-api.mjs @@ -180,6 +180,19 @@ export const createApi = ({ base: { ref: pr.base?.ref ?? '' }, }) + /** Issue shape normalized to the agent-script contract (same as forgejo-api). */ + const normalizeIssue = (issue) => ({ + number: issue.number, + title: issue.title, + body: issue.body ?? '', + state: normalizeState(issue.state), + createdAt: issue.created_at, + labels: (issue.labels ?? []).map((label) => ({ + name: label.name, + color: label.color ?? '', + })), + }) + const api = { /** * GET /repos/{owner}/{repo}/pulls/{number} — normalized. @@ -190,6 +203,153 @@ export const createApi = ({ return pr ? normalizePullRequest(pr) : null }, + /** GET /repos/{owner}/{repo}/issues — normalized; `limit` → `per_page`. */ + listIssues: async ({ state = 'open', labels, limit = 100, page = 1 } = {}) => { + const issues = await request(`/repos/${owner}/${name}/issues`, { + query: { state, labels, per_page: limit, page }, + }) + // GitHub returns PRs in the issues list; filter them out (the Forgejo + // `type: issues` query had no GitHub equivalent). + return (Array.isArray(issues) ? issues : []) + .filter((issue) => !issue.pull_request) + .map(normalizeIssue) + }, + + /** GET /repos/{owner}/{repo}/issues/{number} — normalized. */ + getIssue: async (number) => { + const issue = await request(`/repos/${owner}/${name}/issues/${number}`) + return issue ? normalizeIssue(issue) : null + }, + + /** POST /repos/{owner}/{repo}/issues — creates with title+body. */ + createIssue: async ({ title, body }) => { + const issue = await request(`/repos/${owner}/${name}/issues`, { + method: 'POST', + body: { title, body }, + }) + return normalizeIssue(issue) + }, + + /** Adds labels by NAME (GitHub labels are repo-level, addressed by name). */ + addLabels: (number, labels) => + request(`/repos/${owner}/${name}/issues/${number}/labels`, { + method: 'POST', + body: { labels }, + }), + + /** Removes labels by NAME (GitHub DELETE is by label name). */ + removeLabels: async (number, labels) => { + for (const label of labels) { + await request( + `/repos/${owner}/${name}/issues/${number}/labels/${encodeURIComponent(label)}`, + { + method: 'DELETE', + }, + ) + } + }, + + /** + * @param {number} number + * @param {{ add?: string[], remove?: string[] }} [options] + */ + setLabels: async (number, { add = [], remove = [] } = {}) => { + if (remove.length > 0) await api.removeLabels(number, remove) + if (add.length > 0) await api.addLabels(number, add) + }, + + /** POST /issues/{number}/comments — { body }. */ + addComment: (number, body) => + request(`/repos/${owner}/${name}/issues/${number}/comments`, { + method: 'POST', + body: { body }, + }), + + /** PATCH state=closed — closes the issue on the GitHub tracker. */ + closeIssue: (number) => + request(`/repos/${owner}/${name}/issues/${number}`, { + method: 'PATCH', + body: { state: 'closed' }, + }), + + /** GET /issues/{number}/comments — [{ body, user }]. */ + listIssueComments: async (number) => { + const comments = await request(`/repos/${owner}/${name}/issues/${number}/comments`) + return (Array.isArray(comments) ? comments : []).map((comment) => ({ + body: comment.body ?? '', + user: comment.user?.login ?? '', + })) + }, + + /** GET /pulls/{number}/files — [{ filename, status }]. */ + listPullRequestFiles: async (number) => { + const files = await request(`/repos/${owner}/${name}/pulls/${number}/files`) + return (Array.isArray(files) ? files : []).map((file) => ({ + filename: file.filename ?? '', + status: file.status ?? '', + })) + }, + + /** GET /pulls — normalized, GitHub PATCH uses `draft` not `is_draft`. */ + listPullRequests: async ({ state = 'open', limit = 100 } = {}) => { + const pulls = await request(`/repos/${owner}/${name}/pulls`, { + query: { state, per_page: limit }, + }) + return (Array.isArray(pulls) ? pulls : []).map(normalizePullRequest) + }, + + /** POST /actions/workflows/{file}/dispatches — triggers a workflow. */ + workflowDispatch: (workflowFile, { ref = 'main', inputs = {} } = {}) => + request(`/repos/${owner}/${name}/actions/workflows/${workflowFile}/dispatches`, { + method: 'POST', + body: { ref, inputs }, + }), + + /** GET /contents/{path}?ref= — { content, sha } base64-decoded. */ + getFileContents: async (path, ref = 'main') => { + const file = await request(`/repos/${owner}/${name}/contents/${path}`, { query: { ref } }) + if (!file) return null + return { + content: Buffer.from(file.content ?? '', 'base64').toString('utf8'), + sha: file.sha, + } + }, + + /** PUT /contents/{path} — creates/updates a file on a branch (sha for update). */ + writeFile: (path, { message, content, branch = 'main', sha } = {}) => + request(`/repos/${owner}/${name}/contents/${path}`, { + method: 'PUT', + body: { + message, + content: Buffer.from(content, 'utf8').toString('base64'), + branch, + ...(sha ? { sha } : {}), + }, + }), + + /** POST /contents/{path} — creates a file on a branch (fails if it exists). */ + createFile: (path, { message, content, branch = 'main' } = {}) => + request(`/repos/${owner}/${name}/contents/${path}`, { + method: 'PUT', + body: { + message, + content: Buffer.from(content, 'utf8').toString('base64'), + branch, + }, + }), + + /** PUT /contents/{path} — updates an existing file (sha required). */ + updateFile: (path, { message, content, branch = 'main', sha } = {}) => + request(`/repos/${owner}/${name}/contents/${path}`, { + method: 'PUT', + body: { + message, + content: Buffer.from(content, 'utf8').toString('base64'), + branch, + sha, + }, + }), + /** PATCH draft:false — marks a draft PR ready for review. */ markPullRequestReady: (number) => request(`/repos/${owner}/${name}/pulls/${number}`, { @@ -262,3 +422,6 @@ export const createApi = ({ return api } + +/** Default instance bound to the environment. */ +export const githubApi = createApi({}) diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs index 9d7e69f8..c33867f5 100644 --- a/scripts/worktree.mjs +++ b/scripts/worktree.mjs @@ -124,7 +124,7 @@ import { parseArgs, } from './lib/agent-forgejo.mjs' import { startSharedPostgres } from './lib/db-start.mjs' -import { forgejoApi as api } from './lib/forgejo-api.mjs' +import { githubApi as api } from './lib/github-api.mjs' import { DEV_PORT_BASE, GENERATED_ENV_MARKER, diff --git a/tests/unit/forgejoApi.unit.spec.ts b/tests/unit/forgejoApi.unit.spec.ts deleted file mode 100644 index 18cb5de2..00000000 --- a/tests/unit/forgejoApi.unit.spec.ts +++ /dev/null @@ -1,961 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { createApi } from '../../scripts/lib/forgejo-api.mjs' - -type FetchCall = { - url: string - method?: string - init?: { headers?: Record; method?: string; body?: string } - body?: unknown -} - -const ok = (body: unknown, status = 200) => - new Response(typeof body === 'string' ? body : JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }) - -describe('forgejo-api', () => { - it('builds the request with token, JSON body and explicit User-Agent', async () => { - const calls: FetchCall[] = [] - const api = createApi({ - base: 'https://git.solla.dev/api/v1', - token: 'tok', - repository: 'fsolla/teqo', - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init }) - return ok({ number: 1, title: 'x', body: '', state: 'open', created_at: '', labels: [] }) - }, - }) - - await api.createIssue({ title: 't', body: 'b' }) - - expect(calls).toHaveLength(1) - expect(calls[0].url).toBe('https://git.solla.dev/api/v1/repos/fsolla/teqo/issues') - expect(calls[0].init!.method).toBe('POST') - expect(calls[0].init!.headers!.Authorization).toBe('token tok') - expect(calls[0].init!.headers!['User-Agent']).toContain('teqo-agent-scripts') - expect(JSON.parse(calls[0].init!.body!)).toEqual({ title: 't', body: 'b' }) - }) - - it('defaults the base URL to git.solla.dev with the default repo path', async () => { - // OPS71: the CI now runs on GitHub Actions, where GITHUB_SERVER_URL is - // set (https://github.com) — forgejo-api's env chain would resolve a - // different base. Pin the fallback default by isolating the env vars. - const savedApi = process.env.FORGEJO_API_URL - const savedServer = process.env.GITHUB_SERVER_URL - delete process.env.FORGEJO_API_URL - delete process.env.GITHUB_SERVER_URL - try { - const calls: FetchCall[] = [] - const api = createApi({ - token: 'tok', - fetchImpl: async (url) => { - calls.push({ url: String(url) }) - return ok([{ number: 1, title: '', body: '', state: 'open', created_at: '', labels: [] }]) - }, - }) - - await api.listIssues() - - expect(calls[0].url).toContain('https://git.solla.dev/api/v1/repos/fsolla/teqo/issues?') - } finally { - if (savedApi) process.env.FORGEJO_API_URL = savedApi - else delete process.env.FORGEJO_API_URL - if (savedServer) process.env.GITHUB_SERVER_URL = savedServer - else delete process.env.GITHUB_SERVER_URL - } - }) - - it('normalizes state and labels to the gh-flavored contract', async () => { - const api = createApi({ - token: 'tok', - fetchImpl: async () => - ok({ - number: 7, - title: 't', - body: 'b', - state: 'closed', - created_at: '2026-01-01T00:00:00Z', - labels: [{ name: 'ready', color: '0E8A16' }], - }), - }) - - const issue = await api.getIssue(7) - - expect(issue.state).toBe('CLOSED') - expect(issue.labels).toEqual([{ name: 'ready', color: '0E8A16' }]) - expect(issue.createdAt).toBe('2026-01-01T00:00:00Z') - }) - - it('throws with the API error body on non-2xx', async () => { - let calls = 0 - const api = createApi({ - token: 'tok', - fetchImpl: async () => { - calls += 1 - return ok('{"message":"nope"}', 403) - }, - }) - - await expect(api.listIssues()).rejects.toThrow(/403/) - await expect(api.listIssues()).rejects.toThrow(/nope/) - expect(calls).toBe(2) - }) - - it('fails closed when no token is available', async () => { - delete process.env.FORGEJO_API_TOKEN - delete process.env.GITHUB_TOKEN - const api = createApi({ fetchImpl: async () => ok('[]') }) - - await expect(api.listIssues()).rejects.toThrow(/Sem token/) - }) - - it('setLabels removes by id and adds by name', async () => { - const calls: FetchCall[] = [] - const api = createApi({ - // OPS71: explicit base — the GitHub Actions CI sets GITHUB_SERVER_URL, - // which would change the env-resolved base and break the URL pins. - base: 'https://git.solla.dev/api/v1', - token: 'tok', - fetchImpl: async (url, init) => { - calls.push({ url: String(url), method: init?.method, body: init?.body }) - if (String(url).endsWith('/labels') && init?.method === 'GET') { - return ok([{ name: 'blocked', id: 42 }]) - } - return ok(null) - }, - }) - - await api.setLabels(5, { add: ['ready'], remove: ['blocked'] }) - - expect(calls.map((call) => [call.method, call.url])).toEqual([ - ['GET', 'https://git.solla.dev/api/v1/repos/fsolla/teqo/issues/5/labels'], - ['DELETE', 'https://git.solla.dev/api/v1/repos/fsolla/teqo/issues/5/labels/42'], - ['POST', 'https://git.solla.dev/api/v1/repos/fsolla/teqo/issues/5/labels'], - ]) - expect(JSON.parse(calls[2].body as string)).toEqual({ labels: ['ready'] }) - }) - - it('closeIssue PATCHes state=closed (OPS71-FLIP: the GitHub PR merge no longer closes the Forgejo issue)', async () => { - const calls: FetchCall[] = [] - const api = createApi({ - base: 'https://git.solla.dev/api/v1', - token: 'tok', - fetchImpl: async (url, init) => { - calls.push({ url: String(url), method: init?.method, body: init?.body }) - return ok(null) - }, - }) - - await api.closeIssue(9) - - expect(calls).toEqual([ - { - url: 'https://git.solla.dev/api/v1/repos/fsolla/teqo/issues/9', - method: 'PATCH', - body: JSON.stringify({ state: 'closed' }), - }, - ]) - }) - - it('workflowDispatch posts inputs and ref', async () => { - const calls: FetchCall[] = [] - const api = createApi({ - token: 'tok', - fetchImpl: async (url, init) => { - calls.push({ url: String(url), body: JSON.parse(String(init?.body ?? '{}')) }) - return ok(null) - }, - }) - - await api.workflowDispatch('agent-pool.yml', { ref: 'main', inputs: { action: 'tick' } }) - - expect(calls[0].url).toContain('/actions/workflows/agent-pool.yml/dispatches') - expect(calls[0].body).toEqual({ ref: 'main', inputs: { action: 'tick' } }) - }) - - it('merges with rebase (repo canonical merge style)', async () => { - const calls: FetchCall[] = [] - const api = createApi({ - token: 'tok', - fetchImpl: async (url, init) => { - calls.push({ url: String(url), body: JSON.parse(String(init?.body ?? '{}')) }) - return ok(null) - }, - }) - - await api.mergePullRequest(4) - - expect(calls[0].url).toContain('/pulls/4/merge') - expect(calls[0].body).toEqual({ Do: 'rebase' }) - }) - - it('waitForChecks returns once every status is settled and no failure exists', async () => { - let poll = 0 - const api = createApi({ - token: 'tok', - fetchImpl: async (url) => { - if (String(url).includes('/pulls/')) { - return ok({ - number: 4, - state: 'open', - merged: false, - mergeable: true, - head: { ref: 'x', sha: 'abc' }, - }) - } - poll += 1 - const statuses = - poll < 2 - ? [{ context: 'CI / static', status: 'pending' }] - : [ - { context: 'CI / static', status: 'success' }, - { context: 'CI (PR) / checks (pull_request)', status: 'success' }, - ] - return ok({ statuses }) - }, - }) - - const pr = await api.waitForChecks(4, { pollMs: 1 }) - - expect(pr).toBeTruthy() - expect(poll).toBe(2) - }) - - it('waitForChecks keeps polling while mergeable is still being computed', async () => { - let poll = 0 - const api = createApi({ - token: 'tok', - fetchImpl: async (url) => { - if (String(url).includes('/pulls/')) { - poll += 1 - return ok({ - number: 4, - state: 'open', - merged: false, - mergeable: poll < 2 ? null : true, - head: { ref: 'x', sha: 'abc' }, - }) - } - return ok({ - statuses: [ - { context: 'CI / static', status: 'success' }, - { context: 'CI (PR) / checks (pull_request)', status: 'success' }, - ], - }) - }, - }) - - const pr = await api.waitForChecks(4, { pollMs: 1 }) - - expect(poll).toBe(2) - expect(pr.mergeable).toBe(true) - }) - - it('waitForChecks fails fast when the PR is not mergeable', async () => { - const api = createApi({ - token: 'tok', - fetchImpl: async (url) => { - if (String(url).includes('/pulls/')) { - return ok({ - number: 4, - state: 'open', - merged: false, - mergeable: false, - head: { ref: 'x', sha: 'abc' }, - }) - } - return ok({ - statuses: [ - { context: 'CI / static', status: 'success' }, - { context: 'CI (PR) / checks (pull_request)', status: 'success' }, - ], - }) - }, - }) - - await expect(api.waitForChecks(4, { pollMs: 1 })).rejects.toThrow(/não mergeável/) - }) - - it('waitForChecks stops when the PR is converted to draft mid-wait (OPS57 veto)', async () => { - let poll = 0 - const api = createApi({ - token: 'tok', - fetchImpl: async (url) => { - poll += 1 - if (String(url).includes('/pulls/')) { - return ok({ - number: 4, - state: 'open', - merged: false, - mergeable: true, - is_draft: poll >= 3, - head: { ref: 'OPS57-x', sha: 'abc' }, - }) - } - return ok({ - statuses: - poll <= 2 - ? [{ context: 'CI / static', status: 'pending' }] - : [{ context: 'CI / static', status: 'success' }], - }) - }, - }) - - const pr = await api.waitForChecks(4, { pollMs: 1 }) - - expect(poll).toBe(3) - expect(pr.isDraft).toBe(true) - }) - - it('waitForChecks keeps polling while the CI (PR) checks rollup has not posted (OPS61 gate)', async () => { - let statusPoll = 0 - const api = createApi({ - token: 'tok', - fetchImpl: async (url) => { - if (String(url).includes('/pulls/')) { - return ok({ - number: 4, - state: 'open', - merged: false, - mergeable: true, - head: { ref: 'x', sha: 'abc' }, - }) - } - statusPoll += 1 - // First snapshots: every POSTED status is green — but jobs that were - // not scheduled yet (docs-guards/unit/checks) posted nothing. The - // rollup is the only context that exists once the WHOLE cascade - // settled; without the gate a snapshot can look green before the - // cascade finished. - const statuses = - statusPoll < 3 - ? [{ context: 'CI / static', status: 'success' }] - : [ - { context: 'CI / static', status: 'success' }, - { context: 'CI (PR) / checks (pull_request)', status: 'success' }, - ] - return ok({ statuses }) - }, - }) - - const pr = await api.waitForChecks(4, { pollMs: 1 }) - - expect(pr).toBeTruthy() - expect(statusPoll).toBe(3) - }) - - it('waitForChecks throws when the CI (PR) checks rollup reports failure', async () => { - const api = createApi({ - token: 'tok', - fetchImpl: async (url) => { - if (String(url).includes('/pulls/')) { - return ok({ - number: 4, - state: 'open', - merged: false, - mergeable: true, - head: { ref: 'x', sha: 'abc' }, - }) - } - return ok({ - statuses: [ - { context: 'CI / static', status: 'success' }, - { context: 'CI (PR) / checks (pull_request)', status: 'failure' }, - ], - }) - }, - }) - - await expect(api.waitForChecks(4, { pollMs: 1 })).rejects.toThrow(/Checks falharam/) - }) - - it('waitForChecks throws when a matrix shard is red even if the rollup is green (run 607, OPS64)', async () => { - // Forgejo reports the LAST shard's result as the matrix parent result, so - // the ci-pr `checks` rollup job can post SUCCESS with e2e (1) red (runs - // 604/607/610). The CLI must NEVER trust the rollup alone: it scans the - // individual shard statuses (`CI (PR) / e2e (1)` = failure) and refuses to - // merge — this is what kept PR #55 out of main red in run 607. - const api = createApi({ - token: 'tok', - fetchImpl: async (url) => { - if (String(url).includes('/pulls/')) { - return ok({ - number: 55, - state: 'open', - merged: false, - mergeable: true, - head: { ref: 'OPS59-x', sha: 'abc' }, - }) - } - return ok({ - statuses: [ - { context: 'CI / static', status: 'success' }, - { context: 'CI (PR) / e2e (1) (pull_request)', status: 'failure' }, - { context: 'CI (PR) / e2e (2) (pull_request)', status: 'success' }, - { context: 'CI (PR) / checks (pull_request)', status: 'success' }, - ], - }) - }, - }) - - await expect(api.waitForChecks(55, { pollMs: 1 })).rejects.toThrow(/Checks falharam/) - }) - - it('waitForChecks ignores the safety-net own status, pending for the whole run (OPS61 finding)', async () => { - let statusPoll = 0 - const api = createApi({ - token: 'tok', - fetchImpl: async (url) => { - if (String(url).includes('/pulls/')) { - return ok({ - number: 4, - state: 'open', - merged: false, - mergeable: true, - head: { ref: 'x', sha: 'abc' }, - }) - } - statusPoll += 1 - // Forgejo posts the job's own commit status as pending at job start - // and flips it only at the end — the pre-OPS61 `pending.length === 0` - // gate was unsatisfiable and no PR was ever merged by the CLI (every - // Forgejo merge was manual). The own context must never gate. - return ok({ - statuses: [ - { - context: 'PR Ready + auto-merge / ready-automerge (pull_request)', - status: 'pending', - }, - { context: 'CI / static', status: 'success' }, - { context: 'CI (PR) / checks (pull_request)', status: 'success' }, - ], - }) - }, - }) - - const pr = await api.waitForChecks(4, { pollMs: 1 }) - - expect(pr).toBeTruthy() - expect(statusPoll).toBe(1) - }) - - it('waitForChecks does not treat mergeable=false as conflict before the rollup settles (OPS61)', async () => { - let poll = 0 - const api = createApi({ - token: 'tok', - fetchImpl: async (url) => { - if (String(url).includes('/pulls/')) { - poll += 1 - const settled = poll >= 3 - return ok({ - number: 4, - state: 'open', - merged: false, - mergeable: settled ? true : false, - head: { ref: 'x', sha: 'abc' }, - }) - } - return ok({ - statuses: - poll < 3 - ? [{ context: 'CI / static', status: 'success' }] - : [ - { context: 'CI / static', status: 'success' }, - { context: 'CI (PR) / checks (pull_request)', status: 'success' }, - ], - }) - }, - }) - - const pr = await api.waitForChecks(4, { pollMs: 1 }) - - expect(poll).toBe(3) - expect(pr.mergeable).toBe(true) - }) - - it('autoMerge verifies the merge by re-reading the PR (POST answers 200 + empty body)', async () => { - const calls: string[] = [] - let poll = 0 - const api = createApi({ - // OPS71: explicit base — the GitHub Actions CI sets GITHUB_SERVER_URL, - // which would change the env-resolved base and break the URL pins. - base: 'https://git.solla.dev/api/v1', - token: 'tok', - fetchImpl: async (url, init) => { - calls.push(`${init?.method ?? 'GET'} ${url}`) - if (String(url).includes('/merge')) return new Response('', { status: 200 }) - if (String(url).includes('/pulls/')) { - poll += 1 - const merged = poll > 1 - return ok({ - number: 27, - state: merged ? 'closed' : 'open', - merged, - mergeable: true, - head: { ref: 'x', sha: 'abc' }, - }) - } - return ok({ - statuses: [ - { context: 'CI / static', status: 'success' }, - { context: 'CI (PR) / checks (pull_request)', status: 'success' }, - ], - }) - }, - }) - - const result = await api.autoMerge(27, { pollMs: 1 }) - - expect(result).toEqual({ - attempted: true, - merged: true, - pr: expect.objectContaining({ merged: true }), - }) - expect(calls.filter((call) => call.includes('/merge'))).toHaveLength(1) - expect( - calls.filter( - (call) => call === 'GET https://git.solla.dev/api/v1/repos/fsolla/teqo/pulls/27', - ), - ).toHaveLength(2) - }) - - it('autoMerge rethrows when the merge POST fails and the PR stays open', async () => { - const api = createApi({ - token: 'tok', - fetchImpl: async (url, _init) => { - if (String(url).includes('/merge')) - return ok('{"message":"PR not in mergeable state"}', 405) - if (String(url).includes('/pulls/')) { - return ok({ - number: 27, - state: 'open', - merged: false, - mergeable: true, - head: { ref: 'x', sha: 'abc' }, - }) - } - return ok({ - statuses: [ - { context: 'CI / static', status: 'success' }, - { context: 'CI (PR) / checks (pull_request)', status: 'success' }, - ], - }) - }, - }) - - await expect(api.autoMerge(27, { pollMs: 1 })).rejects.toThrow(/405/) - }) - - it('autoMerge reports merged when a concurrent actor merged after our POST failed', async () => { - let poll = 0 - const api = createApi({ - token: 'tok', - fetchImpl: async (url, _init) => { - if (String(url).includes('/merge')) - return ok('{"message":"Pull request was already merged"}', 405) - if (String(url).includes('/pulls/')) { - poll += 1 - const merged = poll > 1 - return ok({ - number: 27, - state: merged ? 'closed' : 'open', - merged, - mergeable: true, - head: { ref: 'x', sha: 'abc' }, - }) - } - return ok({ - statuses: [ - { context: 'CI / static', status: 'success' }, - { context: 'CI (PR) / checks (pull_request)', status: 'success' }, - ], - }) - }, - }) - - const result = await api.autoMerge(27, { pollMs: 1 }) - - expect(result).toEqual({ - attempted: true, - merged: true, - pr: expect.objectContaining({ merged: true }), - }) - }) - - it('autoMerge is a no-op for an already merged PR (no POST)', async () => { - const calls: string[] = [] - const api = createApi({ - token: 'tok', - fetchImpl: async (url, init) => { - calls.push(`${init?.method ?? 'GET'} ${url}`) - return ok({ - number: 4, - state: 'closed', - merged: true, - mergeable: false, - head: { ref: 'x', sha: 'abc' }, - }) - }, - }) - - const result = await api.autoMerge(4, { pollMs: 1 }) - - expect(result).toEqual({ - attempted: false, - merged: true, - pr: expect.objectContaining({ merged: true }), - }) - expect(calls.some((call) => call.includes('/merge'))).toBe(false) - }) - - it('getFileContents decodes base64 and updateFile sends sha', async () => { - const calls: FetchCall[] = [] - const api = createApi({ - token: 'tok', - fetchImpl: async (url, init) => { - calls.push({ - url: String(url), - method: init?.method, - body: init?.body ? JSON.parse(String(init.body)) : null, - }) - if (init?.method === 'GET') { - return ok({ content: Buffer.from('{"a":1}').toString('base64'), sha: 'shasha' }) - } - return ok(null) - }, - }) - - const file = await api.getFileContents('pool-state.json', 'pool-state') - expect(file.content).toBe('{"a":1}') - expect(file.sha).toBe('shasha') - - await api.updateFile('pool-state.json', { - message: 'm', - content: '{"a":2}', - branch: 'pool-state', - sha: 'shasha', - }) - expect(calls.at(-1)!.method).toBe('PUT') - expect( - Buffer.from((calls.at(-1)!.body as { content: string }).content, 'base64').toString(), - ).toBe('{"a":2}') - expect((calls.at(-1)!.body as { sha: string }).sha).toBe('shasha') - }) - - describe('retry with backoff (OPS67)', () => { - const issueOk = [{ number: 1, title: '', body: '', state: 'open', created_at: '', labels: [] }] - - afterEach(() => { - vi.restoreAllMocks() - }) - - const silentWarn = () => vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const brokenBody = () => - new Response( - new ReadableStream({ - start: (controller) => controller.error(new TypeError('terminated')), - }), - { status: 200 }, - ) - - it('retries a transient network failure and succeeds', async () => { - silentWarn() - let calls = 0 - const sleeps: number[] = [] - const api = createApi({ - token: 'tok', - retries: 2, - backoffMs: 100, - jitter: false, - sleepImpl: async (ms) => { - sleeps.push(ms) - }, - fetchImpl: async () => { - calls += 1 - if (calls === 1) throw new TypeError('fetch failed') - return ok(issueOk) - }, - }) - - const issues = await api.listIssues() - - expect(calls).toBe(2) - expect(sleeps).toEqual([100]) - expect(issues).toHaveLength(1) - }) - - it('retries a transient network failure on write methods too', async () => { - silentWarn() - let calls = 0 - const api = createApi({ - token: 'tok', - retries: 2, - backoffMs: 0, - jitter: false, - sleepImpl: async () => {}, - fetchImpl: async () => { - calls += 1 - if (calls === 1) throw new TypeError('fetch failed') - return ok(null) - }, - }) - - await api.addComment(1, 'x') - - expect(calls).toBe(2) - }) - - it('retries a GET whose body stream drops mid-read', async () => { - silentWarn() - let calls = 0 - const api = createApi({ - token: 'tok', - retries: 2, - backoffMs: 0, - jitter: false, - sleepImpl: async () => {}, - fetchImpl: async () => { - calls += 1 - return calls === 1 ? brokenBody() : ok(issueOk) - }, - }) - - const issues = await api.listIssues() - - expect(calls).toBe(2) - expect(issues).toHaveLength(1) - }) - - it('does not retry a write whose body drops after a status was received', async () => { - let calls = 0 - const api = createApi({ - token: 'tok', - retries: 2, - backoffMs: 0, - fetchImpl: async () => { - calls += 1 - return calls === 1 ? brokenBody() : ok(null) - }, - }) - - await expect(api.addComment(1, 'x')).rejects.toThrow(/terminated/) - expect(calls).toBe(1) - }) - - it('throws after exhausting retries on a persistent network failure', async () => { - silentWarn() - let calls = 0 - const api = createApi({ - token: 'tok', - retries: 2, - backoffMs: 0, - jitter: false, - sleepImpl: async () => {}, - fetchImpl: async () => { - calls += 1 - throw new TypeError('fetch failed') - }, - }) - - await expect(api.listIssues()).rejects.toThrow(/fetch failed/) - expect(calls).toBe(3) - }) - - it('honors retries: 0 as a single attempt', async () => { - let calls = 0 - const api = createApi({ - token: 'tok', - retries: 0, - fetchImpl: async () => { - calls += 1 - throw new TypeError('fetch failed') - }, - }) - - await expect(api.listIssues()).rejects.toThrow(/fetch failed/) - expect(calls).toBe(1) - }) - - it('grows the backoff exponentially between retries', async () => { - const warn = silentWarn() - let calls = 0 - const sleeps: number[] = [] - const api = createApi({ - token: 'tok', - retries: 3, - backoffMs: 100, - jitter: false, - sleepImpl: async (ms) => { - sleeps.push(ms) - }, - fetchImpl: async () => { - calls += 1 - throw new TypeError('fetch failed') - }, - }) - - await expect(api.listIssues()).rejects.toThrow(/fetch failed/) - expect(calls).toBe(4) - expect(sleeps).toEqual([100, 200, 400]) - expect(warn.mock.calls.flat().join(' ')).toContain('tentativa 1/4') - }) - - it('applies the ±20% jitter around the exponential delay', async () => { - silentWarn() - vi.spyOn(Math, 'random').mockReturnValue(0.5) - const sleeps: number[] = [] - const api = createApi({ - token: 'tok', - retries: 2, - backoffMs: 100, - jitter: true, - sleepImpl: async (ms) => { - sleeps.push(ms) - }, - fetchImpl: async () => { - throw new TypeError('fetch failed') - }, - }) - - await expect(api.listIssues()).rejects.toThrow(/fetch failed/) - expect(sleeps).toEqual([100, 200]) - }) - - it('does not retry 4xx responses', async () => { - let calls = 0 - const api = createApi({ - token: 'tok', - retries: 3, - fetchImpl: async () => { - calls += 1 - return ok('{"message":"nope"}', 403) - }, - }) - - await expect(api.listIssues()).rejects.toThrow(/403/) - expect(calls).toBe(1) - }) - - it('retries 5xx responses on GET', async () => { - silentWarn() - let calls = 0 - const api = createApi({ - token: 'tok', - retries: 2, - backoffMs: 0, - jitter: false, - sleepImpl: async () => {}, - fetchImpl: async () => { - calls += 1 - return calls === 1 ? ok('{"message":"boom"}', 502) : ok(issueOk) - }, - }) - - const issues = await api.listIssues() - - expect(calls).toBe(2) - expect(issues).toHaveLength(1) - }) - - it('throws the API error after exhausting 5xx retries on GET', async () => { - silentWarn() - let calls = 0 - const api = createApi({ - token: 'tok', - retries: 2, - backoffMs: 0, - jitter: false, - sleepImpl: async () => {}, - fetchImpl: async () => { - calls += 1 - return ok('{"message":"boom"}', 503) - }, - }) - - await expect(api.listIssues()).rejects.toThrow(/503/) - expect(calls).toBe(3) - }) - - it('does not retry 5xx responses on write methods', async () => { - let calls = 0 - const api = createApi({ - token: 'tok', - retries: 2, - fetchImpl: async () => { - calls += 1 - return ok('{"message":"boom"}', 503) - }, - }) - - await expect(api.addComment(1, 'x')).rejects.toThrow(/503/) - expect(calls).toBe(1) - }) - - it('retries a 410 on GET (proxy answered — origin untouched)', async () => { - silentWarn() - let calls = 0 - const api = createApi({ - token: 'tok', - retries: 2, - backoffMs: 0, - jitter: false, - sleepImpl: async () => {}, - fetchImpl: async () => { - calls += 1 - return calls === 1 ? ok('410 Gone', 410) : ok(issueOk) - }, - }) - - const issues = await api.listIssues() - - expect(calls).toBe(2) - expect(issues).toHaveLength(1) - }) - - it('retries a 410 on write methods too (nginx answered before the origin — no duplicated side effect)', async () => { - silentWarn() - let calls = 0 - const api = createApi({ - token: 'tok', - retries: 2, - backoffMs: 0, - jitter: false, - sleepImpl: async () => {}, - fetchImpl: async () => { - calls += 1 - return calls === 1 ? ok('410 Gone', 410) : ok(null) - }, - }) - - await api.addComment(1, 'x') - - expect(calls).toBe(2) - }) - - it('throws a compact proxy marker after exhausting 410 retries (nginx html not swallowed raw)', async () => { - silentWarn() - let calls = 0 - const api = createApi({ - token: 'tok', - retries: 1, - backoffMs: 0, - jitter: false, - sleepImpl: async () => {}, - fetchImpl: async () => { - calls += 1 - return ok('\n410 Gone\ngone', 410) - }, - }) - - const error = await api.addComment(1, 'x').catch((e: unknown) => e) - - expect((error as Error).message).toMatch(/410/) - expect((error as Error).message).toMatch(/corpo não-JSON \(proxy\/nginx/) - expect((error as Error).message).toMatch(/410 Gone/) - expect(calls).toBe(2) - }) - }) -}) diff --git a/tests/unit/githubApi.unit.spec.ts b/tests/unit/githubApi.unit.spec.ts index 5e16363f..2a8c35ad 100644 --- a/tests/unit/githubApi.unit.spec.ts +++ b/tests/unit/githubApi.unit.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createApi } from '../../scripts/lib/github-api.mjs' @@ -6,7 +6,6 @@ type FetchCall = { url: string method?: string init?: { headers?: Record; method?: string; body?: string } - body?: unknown } const ok = (body: unknown, status = 200) => @@ -15,214 +14,122 @@ const ok = (body: unknown, status = 200) => headers: { 'Content-Type': 'application/json' }, }) -describe('github-api', () => { - it('builds the request with bearer token, JSON body and explicit User-Agent', async () => { +const issue = (over: Record = {}) => ({ + number: 1, + title: 'x', + body: '---\nid: T1\n---\n', + state: 'open', + created_at: '2026-08-20T10:00:00Z', + labels: [{ name: 'ready', color: '0e8a16' }], + ...over, +}) + +describe('github-api (issue tracker layer)', () => { + afterEach(() => vi.restoreAllMocks()) + + it('listIssues filters out pull-request entries and normalizes to the contract', async () => { const calls: FetchCall[] = [] const api = createApi({ + base: 'https://api.github.com', token: 'tok', repository: 'fsolla/teqo', fetchImpl: async (url, init) => { calls.push({ url: String(url), init }) - return ok({ number: 1, title: 't', body: '', state: 'open', node_id: 'PR_x' }) + return ok([issue({ number: 1 }), issue({ number: 2, pull_request: { url: 'x' } })]) }, }) - - await api.getPullRequest(1) - - expect(calls).toHaveLength(1) - expect(calls[0].url).toBe('https://api.github.com/repos/fsolla/teqo/pulls/1') - expect(calls[0].init!.headers!.Authorization).toBe('Bearer tok') - expect(calls[0].init!.headers!['User-Agent']).toContain('teqo-agent-scripts') + const issues = await api.listIssues({ state: 'open' }) + expect(issues).toHaveLength(1) + expect(issues[0].number).toBe(1) + expect(issues[0].state).toBe('OPEN') + expect(issues[0].createdAt).toBe('2026-08-20T10:00:00Z') + expect(issues[0].labels).toEqual([{ name: 'ready', color: '0e8a16' }]) + expect(calls[0].url).toContain('/repos/fsolla/teqo/issues?state=open&per_page=100&page=1') }) - it('fails closed when no token is available', async () => { - delete process.env.GITHUB_TOKEN - const api = createApi({ fetchImpl: async () => ok('{}') }) - - await expect(api.getPullRequest(1)).rejects.toThrow(/GITHUB_TOKEN/) - }) - - it('normalizes the pull request to the CLI contract (state/draft/nodeId/head/base)', async () => { + it('getIssue normalizes a single issue', async () => { const api = createApi({ token: 'tok', - fetchImpl: async () => - ok({ - number: 7, - title: 't', - body: 'Closes #1', - state: 'closed', - merged_at: '2026-08-19T00:00:00Z', - draft: false, - mergeable: true, - node_id: 'PR_abc123', - head: { ref: 'OPS71-x', sha: 'deadbeef' }, - base: { ref: 'main' }, - }), - }) - - const pr = await api.getPullRequest(7) - - expect(pr).toEqual({ - number: 7, - title: 't', - body: 'Closes #1', - state: 'CLOSED', - merged: true, - draft: false, - mergeable: true, - nodeId: 'PR_abc123', - head: { ref: 'OPS71-x', sha: 'deadbeef' }, - base: { ref: 'main' }, + fetchImpl: async () => ok(issue({ number: 42 })), }) + const got = await api.getIssue(42) + expect(got?.number).toBe(42) + expect(got?.state).toBe('OPEN') }) - it('markPullRequestReady patches draft:false', async () => { + it('createIssue POSTs title+body and returns the normalized issue', async () => { const calls: FetchCall[] = [] const api = createApi({ token: 'tok', fetchImpl: async (url, init) => { - calls.push({ url: String(url), method: init?.method, body: init?.body }) - return ok(null) + calls.push({ url: String(url), init }) + return ok(issue({ number: 7 })) }, }) + const created = await api.createIssue({ title: 'T', body: 'B' }) + expect(created.number).toBe(7) + expect(calls[0].init?.method).toBe('POST') + expect(JSON.parse(calls[0].init?.body ?? '{}')).toEqual({ title: 'T', body: 'B' }) + }) - await api.markPullRequestReady(4) - - expect(calls).toEqual([ - { - url: 'https://api.github.com/repos/fsolla/teqo/pulls/4', - method: 'PATCH', - body: JSON.stringify({ draft: false }), + it('setLabels removes by name then adds', async () => { + const calls: FetchCall[] = [] + const api = createApi({ + token: 'tok', + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init }) + return ok([]) }, + }) + await api.setLabels(5, { add: ['done'], remove: ['in-progress'] }) + expect(calls.map((c) => c.url)).toEqual([ + 'https://api.github.com/repos/fsolla/teqo/issues/5/labels/in-progress', + 'https://api.github.com/repos/fsolla/teqo/issues/5/labels', ]) + expect(calls[0].init?.method).toBe('DELETE') + expect(calls[1].init?.method).toBe('POST') + expect(JSON.parse(calls[1].init?.body ?? '{}')).toEqual({ labels: ['done'] }) }) - it('createPullRequest posts a Ready PR (never draft) with the given body', async () => { + it('closeIssue PATCHes state=closed', async () => { const calls: FetchCall[] = [] const api = createApi({ token: 'tok', fetchImpl: async (url, init) => { - calls.push({ url: String(url), method: init?.method, body: init?.body }) - return ok({ number: 9, title: 't', html_url: 'https://github.com/fsolla/teqo/pull/9' }) + calls.push({ url: String(url), init }) + return ok(issue({ state: 'closed' })) }, }) - - const created = await api.createPullRequest({ - head: 'OPS71-x', - base: 'main', - title: 'OPS71 — x', - body: 'Closes #97', - }) - - expect(created).toEqual({ - number: 9, - title: 't', - htmlUrl: 'https://github.com/fsolla/teqo/pull/9', - }) - expect(calls[0].url).toBe('https://api.github.com/repos/fsolla/teqo/pulls') - expect(JSON.parse(String(calls[0].body))).toEqual({ - head: 'OPS71-x', - base: 'main', - title: 'OPS71 — x', - body: 'Closes #97', - }) + await api.closeIssue(9) + expect(calls[0].url).toContain('/issues/9') + expect(calls[0].init?.method).toBe('PATCH') + expect(JSON.parse(calls[0].init?.body ?? '{}')).toEqual({ state: 'closed' }) }) - it('enableAutoMerge posts the GraphQL mutation with rebase', async () => { + it('workflowDispatch posts inputs and ref', async () => { const calls: FetchCall[] = [] const api = createApi({ token: 'tok', fetchImpl: async (url, init) => { - calls.push({ url: String(url), body: init?.body }) - return ok({ data: { enablePullRequestAutoMerge: { pullRequest: { number: 4 } } } }) + calls.push({ url: String(url), init }) + return ok({}) }, }) - - await api.enableAutoMerge('PR_abc123') - - expect(calls).toHaveLength(1) - expect(calls[0].url).toBe('https://api.github.com/graphql') - const { query, variables } = JSON.parse(String(calls[0].body)) - expect(query).toContain('enablePullRequestAutoMerge') - expect(query).toContain('mergeMethod') - expect(variables).toEqual({ id: 'PR_abc123', mergeMethod: 'REBASE' }) - }) - - it('enableAutoMerge throws with the GraphQL error message', async () => { - const api = createApi({ - token: 'tok', - fetchImpl: async () => - ok({ - data: null, - errors: [{ message: 'auto-merge is disabled on this repository' }], - }), - }) - - await expect(api.enableAutoMerge('PR_x')).rejects.toThrow(/auto-merge is disabled/) - }) - - it('getBranchProtection returns null on 404 and normalizes an existing rule', async () => { - let calls = 0 - const api = createApi({ - token: 'tok', - fetchImpl: async () => { - calls += 1 - if (calls === 1) return ok('{"message":"Not Found"}', 404) - return ok({ - url: 'https://api.github.com/repos/fsolla/teqo/branches/main/protection', - required_status_checks: { - strict: false, - checks: [{ context: 'CI (PR) / checks', app_id: null }], - contexts: ['CI (PR) / checks'], - }, - enforce_admins: { enabled: true, url: '…' }, - required_pull_request_reviews: null, - }) - }, - }) - - expect(await api.getBranchProtection('main')).toBeNull() - - const rule = await api.getBranchProtection('main') - expect(rule).toEqual({ - required_status_checks: { - strict: false, - contexts: ['CI (PR) / checks'], - }, - enforce_admins: true, - required_pull_request_reviews: null, + await api.workflowDispatch('agent-pool.yml', { ref: 'main', inputs: { action: 'start' } }) + expect(calls[0].url).toContain('/actions/workflows/agent-pool.yml/dispatches') + expect(JSON.parse(calls[0].init?.body ?? '{}')).toEqual({ + ref: 'main', + inputs: { action: 'start' }, }) }) - it('updateBranchProtection PUTs the full desired rule', async () => { - const calls: FetchCall[] = [] + it('getFileContents base64-decodes content', async () => { const api = createApi({ token: 'tok', - fetchImpl: async (url, init) => { - calls.push({ url: String(url), method: init?.method }) - return ok(null) - }, + fetchImpl: async () => ok({ content: Buffer.from('olá mundo').toString('base64'), sha: 's' }), }) - - await api.updateBranchProtection({ enforce_admins: true }, 'main') - - expect(calls[0].url).toBe('https://api.github.com/repos/fsolla/teqo/branches/main/protection') - expect(calls[0].method).toBe('PUT') - }) - - it('throws with the API error body on non-2xx', async () => { - let calls = 0 - const api = createApi({ - token: 'tok', - retries: 0, - fetchImpl: async () => { - calls += 1 - return ok('{"message":"Bad credentials"}', 401) - }, - }) - - await expect(api.getPullRequest(1)).rejects.toThrow(/401/) - await expect(api.getPullRequest(1)).rejects.toThrow(/Bad credentials/) - expect(calls).toBe(2) + const file = await api.getFileContents('a/b.md', 'main') + expect(file?.content).toBe('olá mundo') + expect(file?.sha).toBe('s') }) })