ops(OPS76): tracker de Issues migra do Forgejo para o GitHub — GitHub como host único

Decisão (2026-08-21): o GitHub passa a ser a fonte única — main, branches,
PRs, CI via Actions E tracker de Issues. O Forgejo deixa de ser canal ativo
(git.e issue tracker congelados pós-convergência).

Data: 17 issues migradas do Forgejo para o GitHub (frontmatter id/depends/
serializes/priority/model preservados, labels mapeados, comment de origem
em cada uma; tabela Forgejo→GitHub registrada).

Tooling: github-api.mjs estendido com a camada de issues/labels/comments/
close/workflow-dispatch/contents (mesmo shape normalizado do forgejo-api);
18 scripts trocam o transporte forgejo-api → github-api; forgejo-api.mjs e
seu teste (961 linhas) removidos; teste githubApi.unit.spec.ts novo (7 casos);
issue-transition-on-merge.mjs renomeado e reapontado.

Flips pós-merge: issue-done e plan-issue-ready agora usam o GITHUB_TOKEN com
issues:write no repo (sem FORGEJO_API_TOKEN) e fecham/flipam Issues no GitHub.

Docs: AGENTS.md, AGENT-OPS.md e skills do fluxo vivo (work-issue, plan-issue,
project-status, capture-review-debts, worktree-next-issue, agent-work-issue)
apontam o tracker para github.com/fsolla/teqo/issues.

Gates: tsc/lint/format/knip/cycles/unit 2416 verdes.
This commit is contained in:
Francisco Solla 2026-08-21 08:09:10 -03:00
parent c723d6accf
commit c90f70aab4
25 changed files with 303 additions and 1688 deletions

View file

@ -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

View file

@ -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/<slug>.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/<slug>.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)

View file

@ -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

View file

@ -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 <N>` 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

View file

@ -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 }}"

View file

@ -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

File diff suppressed because one or more lines are too long

View file

@ -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 -- <issue> <P0..P3>` | 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 <sha>` **localmente** no homeserver (sem SSH; idempotente; HEAD guard; flock; migrator→migrate→runner→rollout→smoke) |
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Closes`/`Fixes` → `done` + `in-prod` + **close** **no Forgejo** (PAT `FORGEJO_API_TOKEN`; body do PR via `PR_BODY` do evento GitHub — no dispatch, busca via `gh` com o mesmo contrato: merged + main + same-repo) |
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Related #N` aguardando plano → `ready` **no Forgejo** (OPS18; soft-skip; mesmo PAT) |
| `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 <N>` com o `GITHUB_TOKEN` do ambiente (PAT). O flip agora também **fecha** a Issue (`PATCH state=closed` — o merge no GitHub não fecha Issues do Forgejo como o merge nativo da era Forgejo fazia). Falha em qualquer passo = job vermelho (OPS61, nunca mentir); o `forgejo-api.mjs` retenta 410/5xx transitórios (OPS67 + OPS71-FLIP: 410 do proxy retenta em qualquer método — a origem nunca viu o request).
**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).

View file

@ -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']))

View file

@ -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'] })

View file

@ -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')

View file

@ -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())

View file

@ -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']))

View file

@ -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']))

View file

@ -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

View file

@ -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')

View file

@ -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 <N>
* PR_BODY='Closes #97' node scripts/forgejo-issue-transition.mjs --pr <N>
* node scripts/issue-transition-on-merge.mjs --pr <N>
* PR_BODY='Closes #97' node scripts/issue-transition-on-merge.mjs --pr <N>
*/
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) {

View file

@ -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)

View file

@ -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 ---',
'',

View file

@ -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'

View file

@ -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 `<GITHUB_SERVER_URL>/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<string, string>; body?: string }) => Promise<Response>} [fetchImpl]
* @property {number} [retries] Additional attempts after the first (total = retries + 1). Default 3.
* @property {number} [backoffMs] Base exponential delay. Default 300.
* @property {boolean} [jitter] ±20% randomization on each delay. Default true.
* @property {(ms: number) => Promise<void>} [sleepImpl] Test seam for the backoff delay.
*/
/**
* @param {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*</.test(text)
const snippet = isProxyHtml
? `corpo não-JSON (proxy/nginx — HTTP ${response.status}): ${text
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120)}`
: text.slice(0, 400)
throw new Error(`Forgejo API ${method} ${path} → ${response.status}: ${snippet}`)
}
if (!text) return null
try {
return JSON.parse(text)
} catch {
throw new Error(`Forgejo API ${method} ${path}: resposta não-JSON: ${text.slice(0, 80)}`)
}
}
}
const normalizeState = (state) => (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({})

View file

@ -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({})

View file

@ -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,

View file

@ -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<string, string>; 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('<html>410 Gone</html>', 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('<html>410 Gone</html>', 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('<html>\n<head><title>410 Gone</title></head>\n<body>gone</body></html>', 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)
})
})
})

View file

@ -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<string, string>; 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<string, unknown> = {}) => ({
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')
})
})