mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
c723d6accf
commit
c90f70aab4
25 changed files with 303 additions and 1688 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
.github/workflows/issue-done-on-main-merge.yml
vendored
22
.github/workflows/issue-done-on-main-merge.yml
vendored
|
|
@ -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 }}"
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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']))
|
||||
|
|
|
|||
|
|
@ -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'] })
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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']))
|
||||
|
|
|
|||
|
|
@ -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']))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ---',
|
||||
'',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
|
|
@ -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({})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue