chore(worktree): preset model do launcher via OPENCODE_WORKTREE_MODEL — fallback comum deepseek-v4-flash #22
27 changed files with 1725 additions and 52 deletions
|
|
@ -1,2 +1,3 @@
|
|||
FORGEJO_REPOSITORY=amana/iara-pwa
|
||||
GITHUB_REPOSITORY=fsolla/iara-pwa
|
||||
WORKTREES_ROOT=~/.worktrees/iara
|
||||
|
|
|
|||
54
.github/workflows/agent-pr-ready-automerge.yml
vendored
Normal file
54
.github/workflows/agent-pr-ready-automerge.yml
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Safety net (OPS1 — GitHub): todo PR same-repo para main nasce com o
|
||||
# auto-merge ARMADO (rebase — estilo canônico do repo). Cobre PRs humanos e de
|
||||
# agente (as branches de entrega da Iara são `<Code>-<slug>` para os dois —
|
||||
# não há distinção por prefixo), e PRs que esqueceram o `--automerge` local.
|
||||
# Draft é o veto do ator: nunca é marcado Ready (a Iara não cria draft por
|
||||
# nenhum fluxo) — o job skips (exit 0).
|
||||
#
|
||||
# O auto-merge NATIVO do GitHub substitui o poll CLI da era Forgejo
|
||||
# (forgejo-pr-automerge.mjs — dormente até a Fase 2):
|
||||
# `enablePullRequestAutoMerge` (GraphQL, REBASE) é server-side — o GitHub só
|
||||
# mergeia quando todo required check (o check-run `checks` do ci.yml) está
|
||||
# verde e o PR é mergeável. Nada a pollar, nada a confiar localmente.
|
||||
#
|
||||
# Permissions: a mutation GraphQL `enablePullRequestAutoMerge` exige
|
||||
# `contents: write` + `pull-requests: write` com o GITHUB_TOKEN built-in —
|
||||
# VERIFICADO AO VIVO (PR #2, 2026-08-20): reduzir para `contents: read` +
|
||||
# `pull-requests: write` faz o job falhar com "GitHub GraphQL: Resource not
|
||||
# accessible by integration". Não "otimizar" de volta para read — quebra o
|
||||
# safety net.
|
||||
|
||||
name: PR Ready + auto-merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
ready-automerge:
|
||||
# Same-repo only. Tem que ficar no `if:` (pré-checkout): para um fork PR,
|
||||
# o actions/checkout puxa o head do fork, então uma guarda interna ao CLI
|
||||
# já estaria rodando o script do próprio fork — RCE com o token do Actions.
|
||||
if: >-
|
||||
github.event.pull_request.state == 'open' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script é plain Node (stdlib) — sem bun. Disable package-manager-cache.
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Arm auto-merge (rebase)
|
||||
env:
|
||||
# `github.token` explícito: o env GITHUB_TOKEN não é garantido em
|
||||
# toda via de evento; a forma robusta é o contexto documentado.
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: node scripts/github-pr-automerge.mjs --pr "${{ github.event.pull_request.number }}"
|
||||
57
.github/workflows/ci.yml
vendored
Normal file
57
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# PR gate (OPS1 — GitHub Actions): feature → main roda `checks` — um job único
|
||||
# sequencial (lint → build → unit → e2e) em runner hosted. O merge é gated pelo
|
||||
# required check `checks` da proteção de `main` + auto-merge nativo do GitHub
|
||||
# (o servidor só mergeia com o required check verde — sem branch protection
|
||||
# server-side na era Forgejo, aqui a regra é real e vale até para admins).
|
||||
#
|
||||
# SEM verificador de `main` (decidido no gate): publicar é ato manual via
|
||||
# `workflow_dispatch` (deploy.yml), que roda a suíte full antes de tocar o
|
||||
# homeserver.
|
||||
#
|
||||
# Iara é um SPA — sem services/Postgres. O job id `checks` preserva o check-run
|
||||
# `CI / checks` que o required check e o auto-merge usam (literal de match na
|
||||
# proteção: `checks` — nome do check-run/job, não o display).
|
||||
#
|
||||
# Repo público → fork PR não queima minutes hosted: `if:` same-repo no job
|
||||
# (mesma classe de guarda de RCE dos outros workflows).
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-pr-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Instalar dependências
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Build (tsc + vite)
|
||||
run: bun run build
|
||||
|
||||
- name: Testes unitários (bun test)
|
||||
run: bun run test:unit
|
||||
|
||||
- name: Instalar navegador do Playwright
|
||||
run: bunx playwright install --with-deps chromium
|
||||
|
||||
- name: E2E (Playwright — só aqui, nunca local)
|
||||
run: bun run test:e2e
|
||||
68
.github/workflows/deploy.yml
vendored
Normal file
68
.github/workflows/deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Deploy de produção manual (OPS1 — GitHub Actions). `workflow_dispatch`
|
||||
# only: NÃO existe verificador automático de `main` (o ci.yml não roda em
|
||||
# push) — publicar é um ato deliberado do operador, e este workflow roda a
|
||||
# suíte FULL (sem skips, e2e incluída) antes do job de deploy tocar o
|
||||
# homeserver.
|
||||
#
|
||||
# DOIS jobs, mesmo workflow:
|
||||
# verify — ubuntu-latest (hosted): cascata full.
|
||||
# deploy — needs: [verify], runs-on [self-hosted, homeserver]: o runner
|
||||
# self-hosted do GitHub NO homeserver executa
|
||||
# scripts/deploy-iara.sh LOCALMENTE (sem SSH; runner outbound-only,
|
||||
# funciona atrás do túnel Cloudflare). O job hosted nunca alcança
|
||||
# o homeserver; o job self-hosted nunca conta minutes hosted.
|
||||
#
|
||||
# O deploy é idempotente (guard "already deployed" via marker do SHA no
|
||||
# deploy-iara.sh) e serializado por flock no homeserver. O `deploy` busca o
|
||||
# repo de github.com (público); o HEAD guard só deploya quando o SHA
|
||||
# disparado é o HEAD de main (dispatch defasado → skip).
|
||||
|
||||
name: Deploy (manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Instalar dependências
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Build (tsc + vite)
|
||||
run: bun run build
|
||||
|
||||
- name: Testes unitários (bun test)
|
||||
run: bun run test:unit
|
||||
|
||||
- name: Instalar navegador do Playwright
|
||||
run: bunx playwright install --with-deps chromium
|
||||
|
||||
- name: E2E (Playwright)
|
||||
run: bun run test:e2e
|
||||
|
||||
deploy:
|
||||
name: deploy no homeserver
|
||||
# Roda no runner self-hosted do GitHub NO homeserver (labels self-hosted +
|
||||
# homeserver): o script executa localmente — sem SSH hop, sem workstation.
|
||||
# Deliberadamente ligado SÓ aqui (dispatch manual), nunca no ci.yml: um
|
||||
# job self-hosted é execução de código na máquina de produção.
|
||||
needs: [verify]
|
||||
if: needs.verify.result == 'success'
|
||||
runs-on: [self-hosted, homeserver]
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Deploy do dist no homeserver
|
||||
run: bash scripts/deploy-iara.sh "$GITHUB_SHA"
|
||||
43
.github/workflows/issue-done-on-main-merge.yml
vendored
Normal file
43
.github/workflows/issue-done-on-main-merge.yml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Main merge flips claimed issues to done + in-prod.
|
||||
# OPS1: o PR vive no GitHub; as ISSUES ficam no tracker do Forgejo. O job passa
|
||||
# o corpo do PR via PR_BODY (evento do GitHub) e
|
||||
# scripts/forgejo-issue-transition.mjs flipa labels/comentário na API do
|
||||
# Forgejo com o FORGEJO_API_TOKEN (PAT) — o GITHUB_TOKEN built-in não toca
|
||||
# Issues do Forgejo. FORGEJO_REPOSITORY explícito: o repo do tracker
|
||||
# (amana/iara-pwa) difere do GitHub (fsolla/iara-pwa) — sem isso, a lib
|
||||
# resolveria GITHUB_REPOSITORY e o flip 404a no repo errado. Fail-loud: um
|
||||
# flip que falhe deixa o job vermelho (I10 absorvida).
|
||||
# Guarda de fork: num PR de fork mergeado, o checkout puxaria o script do
|
||||
# próprio fork, que rodaria com o PAT do repo (RCE) — same-repo no `if:`.
|
||||
|
||||
name: Main merge flips claimed issues to done + in-prod
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
issue-done:
|
||||
if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
|
||||
# Explícito: em Actions, GITHUB_SERVER_URL aponta github.com e
|
||||
# forgejo-api.mjs resolveria a base errada (410).
|
||||
FORGEJO_API_URL: https://git.solla.dev/api/v1
|
||||
FORGEJO_REPOSITORY: amana/iara-pwa
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script é plain Node (stdlib) — sem bun. Disable package-manager-cache:
|
||||
# setup-node@v5 olha packageManager no package.json antes do action-setup.
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Flip Closes/Fixes #N issues to done and in-prod
|
||||
run: node scripts/forgejo-issue-transition.mjs --pr "${{ github.event.pull_request.number }}"
|
||||
43
.github/workflows/plan-issue-ready-on-main-merge.yml
vendored
Normal file
43
.github/workflows/plan-issue-ready-on-main-merge.yml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Safety net: PRs mergeados em main com `Related #N` promovem Issues que ainda
|
||||
# aguardam o plano de intenção (`blocked` + docs/plans/ link) para `ready`.
|
||||
# Idempotente com `bun run agent:ready` (caminho feliz do agente). OPS1: o PR
|
||||
# vive no GitHub, as Issues no tracker do Forgejo — o corpo do PR vem via
|
||||
# PR_BODY (evento do GitHub) e scripts/agent-promote-related-on-merge.mjs
|
||||
# flipa labels na API do Forgejo (FORGEJO_API_TOKEN; FORGEJO_REPOSITORY
|
||||
# explícito — repo do tracker difere do GitHub). Guarda de fork (RCE) no `if:`.
|
||||
|
||||
name: Promote plan-issue Related on main merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
plan-issue-ready:
|
||||
if: >-
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
(
|
||||
contains(github.event.pull_request.body, 'Related #') ||
|
||||
contains(github.event.pull_request.body, 'related #') ||
|
||||
contains(github.event.pull_request.body, 'RELATED #')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
|
||||
FORGEJO_API_URL: https://git.solla.dev/api/v1
|
||||
FORGEJO_REPOSITORY: amana/iara-pwa
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Promote Related #N awaiting plan → ready
|
||||
run: node scripts/agent-promote-related-on-merge.mjs --pr "${{ github.event.pull_request.number }}"
|
||||
52
AGENTS.md
52
AGENTS.md
|
|
@ -64,7 +64,7 @@ Ciclo completo (detalhes nas skills `plan-issue` / `work-issue`):
|
|||
3. **`/work-issue`** → plano de implementação (`docs/plans/<slug>-impl.md`), pausa para aprovação humana, execução.
|
||||
4. **`/simplify`** (skill `code-simplification`) → 3 reviews locais paralelos no diff.
|
||||
5. **`capture-review-debts`** → triagem + débitos (`bun run agent:register` / `agent:file-miss`).
|
||||
6. **PR Ready** `Closes #N` com auto-merge (`bun run pr -- --automerge`) → CI (lint+build+e2e) → merge → `done`/`in-prod` → deploy.
|
||||
6. **PR Ready** `Closes #N` com auto-merge (`bun run pr -- --automerge`) → CI (lint+build+e2e) → merge → `done`/`in-prod` → deploy (manual, `workflow_dispatch`).
|
||||
|
||||
### Comandos de agente (bun)
|
||||
|
||||
|
|
@ -77,24 +77,54 @@ Ciclo completo (detalhes nas skills `plan-issue` / `work-issue`):
|
|||
| `bun run agent:ready` | promove `blocked` → `ready` (plano em main) |
|
||||
| `bun run agent:file-miss` | registra défice do fluxo de agentes |
|
||||
| `bun run agent:status` | grafo/overview read-only |
|
||||
| `bun run pr -- --head <b> --body "Closes #N" --automerge` | cria PR e mergea quando os checks passarem |
|
||||
| `bun run pr -- --head <b> --body "Closes #N" --automerge` | cria PR no GitHub (Ready) e arma o auto-merge nativo — o servidor mergea quando `checks` ficar green (requer `GITHUB_TOKEN` env) |
|
||||
| `bun run configure:branch-protection` | (re)aplica a proteção de `main` no GitHub (required check `checks` + enforce admins; idempotente) |
|
||||
|
||||
**O comando de terminal `worktree` é o roteador global** (`~/.config/shell/worktree.sh`): descobre o projeto pelo diretório atual (`git rev-parse`) e roda o script do projeto; config por projeto em `.forgejo/worktree.env` (commitado, sem secrets).
|
||||
|
||||
## CI (Forgejo Actions — `.forgejo/workflows/`)
|
||||
## CI (GitHub Actions — `.github/workflows/`)
|
||||
|
||||
- `ci.yml` — em push (main) e PR: job `checks` (lint + build + **e2e** Playwright) e job `deploy` (só main, `needs: checks`, rsync para o homeserver).
|
||||
- `issue-done-on-main-merge.yml` — PRs mergeadas com `Closes #N` flipam a Issue para `done`/`in-prod` (`scripts/forgejo-issue-transition.mjs`).
|
||||
- `plan-issue-ready-on-main-merge.yml` — PRs com `Related #N` promovem `blocked` → `ready` (`scripts/agent-promote-related-on-merge.mjs`).
|
||||
- `agent-pr-ready-automerge.yml` — marca Ready e mergea quando `checks` ficar green (safety net do auto-merge).
|
||||
O **código/PR/CI vive no GitHub** (`github.com/fsolla/iara-pwa`, público; o
|
||||
`origin` local aponta para ele após o cutover); o **tracker de Issues
|
||||
permanece no Forgejo** (`git.solla.dev/amana/iara-pwa` — labels, claims,
|
||||
`bun run issue`/`agent:*` e os flips pós-merge continuam falando com a API do
|
||||
Forgejo; GitHub é só o host do Actions).
|
||||
|
||||
**Sem branch protection server-side em `main`** (espelha o teqo): o gate de merge é o
|
||||
próprio script de auto-merge (`pr.mjs --automerge` / `forgejo-pr-automerge.mjs`),
|
||||
que espera os status checks ficarem green antes de mergear; o deploy é gated pelo
|
||||
`needs: checks` do `ci.yml`.
|
||||
- `ci.yml` — **PR gate**: job único `checks` em `ubuntu-latest` (lint + build +
|
||||
unit + **e2e** Playwright); seu check-run `CI / checks` é o required check de
|
||||
`main` — literal de match na proteção: `checks` (o GitHub casa pelo nome do
|
||||
check-run/job; a UI exibe `CI / checks`). **Sem verificador de `main`** —
|
||||
publicar é ato manual (deploy.yml).
|
||||
- `deploy.yml` — **manual (`workflow_dispatch`)**: `verify` (hosted, suíte
|
||||
**full**) → `deploy` (`needs: [verify]`, runner **self-hosted no
|
||||
homeserver**) executando `scripts/deploy-iara.sh` localmente (sem SSH;
|
||||
idempotente via marker do SHA).
|
||||
- `issue-done-on-main-merge.yml` — PRs mergeadas com `Closes #N` flipam a Issue
|
||||
para `done`/`in-prod` no Forgejo via API (`scripts/forgejo-issue-transition.mjs`,
|
||||
fail-loud).
|
||||
- `plan-issue-ready-on-main-merge.yml` — PRs com `Related #N` promovem `blocked`
|
||||
→ `ready` (`scripts/agent-promote-related-on-merge.mjs`).
|
||||
- `agent-pr-ready-automerge.yml` — safety net: arma o **auto-merge nativo** do
|
||||
GitHub (GraphQL `enablePullRequestAutoMerge`, rebase) para todo PR same-repo
|
||||
não-draft (`scripts/github-pr-automerge.mjs`). O servidor só mergeia com o
|
||||
required check `checks` verde — nada de poll.
|
||||
|
||||
**Branch protection REAL em `main`** (era Forgejo não tinha): required check
|
||||
`checks` + `enforce_admins: true` (nem admin mergeia com CI vermelho).
|
||||
Configurado via `bun run configure:branch-protection` (idempotente). O merge
|
||||
automático é o nativo do GitHub — a semântica de "cria e mergea quando os
|
||||
checks passarem" (`bun run pr -- --automerge`) é preservada pela garantia do
|
||||
servidor (o `--automerge` local arma o auto-merge; sem poll local).
|
||||
|
||||
**Deploy:** manual via `workflow_dispatch` no GitHub (job `Deploy (manual)`), só
|
||||
após `verify` full green. Nada de deploy automático em push.
|
||||
|
||||
**e2e só no CI** — nunca rode `bun run test:e2e` local como parte do fluxo normal.
|
||||
|
||||
**Arquivos do fluxo Forgejo** (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`)
|
||||
permanecem **dormentes** como via de rollback até a remoção na entrega
|
||||
sucessora (Fase 2); o runner do Forgejo fica desligado após o cutover.
|
||||
|
||||
## Regras de entrega
|
||||
|
||||
- **Gate antes de push:** `bun run gate` limpo. CI roda lint+build+e2e de novo no PR.
|
||||
|
|
|
|||
87
docs/ops/deploy-github-runbook.md
Normal file
87
docs/ops/deploy-github-runbook.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# Runbook — CI/PR no GitHub, tracker no Forgejo, deploy manual (OPS1)
|
||||
|
||||
O código/PR/CI vive no GitHub (`github.com/fsolla/iara-pwa`, público); as
|
||||
Issues/labels/claims continuam no Forgejo (`git.solla.dev/amana/iara-pwa`,
|
||||
via API); o deploy é **manual** (`workflow_dispatch` no GitHub) com o runner
|
||||
self-hosted **no homeserver** — nada de deploy automático em push e a
|
||||
workstation não roda mais a suíte de CI.
|
||||
|
||||
## Cutover (uma vez, nesta ordem — passos (a)–(c) ANTES do primeiro push)
|
||||
|
||||
1. **(a) Remotes locais.** `origin` passa a ser o GitHub; o Forgejo fica como
|
||||
remote secundário congelado (o repo lá para de receber push; tracker
|
||||
intacto por API).
|
||||
|
||||
```bash
|
||||
git remote set-url origin git@github.com:fsolla/iara-pwa.git
|
||||
git remote add forgejo ssh://git@192.168.15.142:2222/amana/iara-pwa.git 2>/dev/null || true
|
||||
```
|
||||
|
||||
O config de remotes é compartilhado entre os worktrees do repo (`.git/config`
|
||||
único) — vale para todos. Sem este passo, `bun run gate`/diff comparariam
|
||||
contra um `origin/main` defasado (Forgejo).
|
||||
|
||||
2. **(b) Secrets e config do repo no GitHub.**
|
||||
- Secret `FORGEJO_API_TOKEN` no repo (PAT do Forgejo com escopo de repo —
|
||||
usado pelos flips pós-merge; nunca o `GITHUB_TOKEN`).
|
||||
- Repo setting **Allow auto-merge** habilitado (Settings → General →
|
||||
Allow auto-merge) — sem isso o GraphQL `enablePullRequestAutoMerge`
|
||||
falha e o job do safety net vai vermelho (visível).
|
||||
- Localmente: `GITHUB_TOKEN` PAT do usuário (escopo `repo`) para
|
||||
`bun run pr` e `bun run configure:branch-protection`.
|
||||
|
||||
3. **(c) Branch protection de `main`** (required check `checks` +
|
||||
`enforce_admins: true`):
|
||||
|
||||
```bash
|
||||
GITHUB_TOKEN=<PAT> bun run configure:branch-protection -- --dry-run
|
||||
GITHUB_TOKEN=<PAT> bun run configure:branch-protection
|
||||
```
|
||||
|
||||
4. **(d) Desligar o runner do Forgejo** (reversível) — logo após o primeiro PR
|
||||
GitHub validado: sem ele, nada mais dispara no repo congelado; o runner
|
||||
volta a disputar a workstation à toa.
|
||||
|
||||
5. **(e) Homeserver: bun + runner self-hosted do GitHub.**
|
||||
- Instalar `bun` no homeserver (o `scripts/deploy-iara.sh` usa `bun install`
|
||||
+ `bun run build` lá — precisa existir no PATH do runner; `BUN` env
|
||||
sobrepõe o default).
|
||||
- Instalar o `actions-runner` com labels `self-hosted, homeserver`.
|
||||
- **Não ligar** "Send workflows to a self-hosted runner" para fork PRs
|
||||
(default OFF no GitHub): o repo é público e o `deploy` executa código na
|
||||
máquina de produção — se esse setting ligar algum dia, um fork PR pode
|
||||
virar RCE no homeserver.
|
||||
- Não bloqueia PRs: o deploy é manual e só existe depois do runner.
|
||||
|
||||
6. **(f) Push → PR → CI → merge → flips.** O PR do próprio OPS1 valida o fluxo
|
||||
ao vivo: CI `checks` no GitHub → auto-merge → flip da Issue no Forgejo.
|
||||
|
||||
## Deploy de produção
|
||||
|
||||
1. `workflow_dispatch` no GitHub (`Deploy (manual)`) — roda `verify` (hosted,
|
||||
suíte full incl. e2e) e, só se verde, `deploy` no homeserver
|
||||
(`scripts/deploy-iara.sh <sha>`: HEAD guard → flock → marker do SHA →
|
||||
build → rsync local para `~/iara-pwa/dist/`).
|
||||
2. Dispatch do mesmo SHA de novo → no-op idempotente (marker).
|
||||
|
||||
## Rollback
|
||||
|
||||
- **CI/PR:** os arquivos do fluxo Forgejo (`.forgejo/workflows/`,
|
||||
`scripts/forgejo-pr-automerge.mjs`) permanecem **dormentes** no repo até a
|
||||
Fase 2 (remoção na entrega sucessora). Reverter a este PR restaura o fluxo
|
||||
GitHub anterior; reverter a Fase 2 restaura o fluxo Forgejo (religar o
|
||||
runner e apontar `origin` de volta para o Forgejo).
|
||||
- **Deploy:** o site servido é um diretório estático (`~/iara-pwa/dist/`); o
|
||||
marker `~/iara-pwa/.deployed-sha` registra o SHA em produção — para reverter,
|
||||
re-dispatch do SHA anterior (ou rsync manual).
|
||||
|
||||
## Falhas conhecidas
|
||||
|
||||
- **Literal do required check:** a proteção usa `checks` (nome do check-run/
|
||||
job), não o display `CI / checks` — pinado no spec unit. Se o PR não mergear
|
||||
com `mergeable_state: blocked`, re-verifique o literal com
|
||||
`configure:branch-protection` (drift reporta).
|
||||
- **Flip com 404:** se `FORGEJO_REPOSITORY`/`FORGEJO_API_URL` sumirem de um
|
||||
workflow de flip, a lib resolve `GITHUB_REPOSITORY` (repo GitHub) → 404 no
|
||||
tracker. Os workflows novos setam ambos explicitamente; falha agora sai
|
||||
vermelha (fail-loud), nunca silenciosa.
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
# Impl: Mover CI/PR do Forgejo Actions para o GitHub Actions — tracker de Issues permanece no Forgejo
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-20
|
||||
Issue: #17
|
||||
Intenção: docs/plans/mover-ci-forgejo-para-github-actions-tracker-forgejo.md
|
||||
Appetite restante: herdado (~2–3 dias eng; este impl é a Fase 1 — remoção dos arquivos do Forgejo é entrega sucessora)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** código/PR/CI no GitHub Actions (`fsolla/iara-pwa` público) com a mesma cascata de verificações (lint → build → unit → e2e) em job único `checks`; tracker de Issues permanece no Forgejo por API (labels, claims, flips); deploy **manual** (`workflow_dispatch`) com `verify` hosted full → `deploy` no runner self-hosted do homeserver (sem SSH); rollup do job `checks` é o required check de `main` e o gate do merge automático — agora no GitHub; `bun run pr` mantém a semântica; runner do Forgejo desligado após o cutover.
|
||||
- **O que NÃO negociar:** tracker no Forgejo (Issues/labels/claims/flips — nunca migra); estrutura de verificação de job único preservada (nada de matrix); e2e full no PR preservado; deploy NÃO volta a ser automático; nunca merge com CI vermelho; workstation livre da suíte; repo do Forgejo permanece o principal (GitHub é o espelho de código + superfície de PR/CI).
|
||||
- **O que reavaliar:** a intenção sugere "API/`gh` equivalente" para `pr.mjs` e "auto-merge nativo ou safety net equivalente". Reavaliado contra o precedente teqo OPS71 (merged 2026-08-19, mesma migração): **`gh` não é usado** (filosofia plain-Node zero-dep do repo); os helpers novos são scripts Node stdlib sobre a REST+GraphQL do GitHub; auto-merge é o **nativo do GitHub** (GraphQL `enablePullRequestAutoMerge`, REBASE — o servidor só mergeia com required checks verdes; nada de poll/semáforo portado). O "sem branch protection server-side" do AGENTS.md morre com a migração: o rollup `checks` vira required check REAL no GitHub (`enforce_admins: true`).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
W[worktree local] -->|push origin| GH[github.com/fsolla/iara-pwa público]
|
||||
GH -->|PR Ready base main Closes #N| CI["ci.yml — job único checks hosted (lint→build→unit→e2e full)"]
|
||||
CI -->|required check checks| AM["agent-pr-ready-automerge.yml — auto-merge nativo (GraphQL, rebase)"]
|
||||
AM -->|merge| MAIN[main no GitHub]
|
||||
MAIN -->|PR closed merged + PR_BODY| FLIP["issue-done / plan-issue-ready — flip no Forgejo via FORGEJO_API_TOKEN + FORGEJO_REPOSITORY explícitos"]
|
||||
MAIN -->|dispatch manual| V[deploy.yml verify hosted full suite]
|
||||
V -->|needs| D["deploy runs-on self-hosted homeserver — scripts/deploy-iara.sh local"]
|
||||
D --> P[~/iara-pwa/dist no homeserver]
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A — Port do teqo OPS71 (mesma migração, merged 2026-08-19):** libs plain-Node (`github-api.mjs`, `github-pr-flow.mjs`, `github-branch-protection.mjs`) + CLIs (`github-pr-automerge.mjs`, `github-pr.mjs`, `configure-branch-protection.mjs`) + 5 workflows `.github/workflows/` + flip scripts com `PR_BODY` + runbook de cutover. Sem `gh`, sem poll.
|
||||
- **B — `gh` CLI nos scripts/workflows:** binário novo em todas as máquinas + runner, contra a convenção zero-dep do repo — rejeitada (teqo OPS71 rejeitou o mesmo).
|
||||
- **C — Poll-and-merge portado (`waitForChecks` do Forgejo) como mecanismo único:** reimplementa o semáforo que a intenção manda cortar; o bug do rollup do runner Forgejo (OPS64) não existe no GitHub com job único + required check real — rejeitada.
|
||||
- **D — Deploy via artifact do hosted → download no homeserver:** plumbing de artifact que o teqo rejeitou; o build no homeserver espelha o teqo (deploy local, sem SSH) — rejeitada.
|
||||
- **E — Deletar os arquivos do Forgejo já nesta entrega:** queima a via de rollback (repo Forgejo congelado no main pré-cutover mantém workflows vivos); teqo fez Fase 2 separada — rejeitada nesta entrega, vira Issue sucessora.
|
||||
- **F — Auto-merge só para branch `agent/*` (igual `.forgejo/workflows/agent-pr-ready-automerge.yml`):** as branches de entrega da Iara são `<Code>-<slug>` para humano e agente igualmente (worktree compartilhado) — não há distinção por prefixo; o safety net arma auto-merge para **todo PR same-repo não-draft** (draft = veto do ator), como no teqo — a opção é descartada pela inexistência do prefixo.
|
||||
- **G — Manter `--draft` no `bun run pr`:** regra do repo é "PR nunca draft"; teqo tornou a ausência estrutural (sem flag). `--draft` cai, falha loud se passado.
|
||||
|
||||
**Recomendação:** A + variante de F (auto-merge para todo PR same-repo não-draft) + G — porque cada peça reusa o molde existente (plain Node stdlib, libs puras unit-testadas, drift idempotente), o aceite da intenção exige exatamente esse desenho, e o precedente teqo foi validado ao vivo no mesmo mês (required-check literal, auto-merge nativo, flips via API, deploy verify→self-hosted).
|
||||
|
||||
**Rejeitadas:** B, C, D, E, e manter `--draft` (G, refutada). Hipótese da intenção "sem branch protection server-side" — **refinada**: no GitHub a proteção existe e é o gate final (required check `checks`, `enforce_admins: true`); o AGENTS.md é corrigido.
|
||||
|
||||
### Deltas Iara × teqo OPS71 (importantes)
|
||||
|
||||
1. **`FORGEJO_REPOSITORY` explícito nos workflows de flip.** No teqo, repo Forgejo e GitHub têm o mesmo nome (`fsolla/teqo`); na Iara são `amana/iara-pwa` (tracker) vs `fsolla/iara-pwa` (GitHub). Sem a variável, `forgejo-api.mjs` resolveria `GITHUB_REPOSITORY` (setado pelo Actions) → flip no repo errado → 404. Todos os workflows que falam com o tracker setam `FORGEJO_REPOSITORY=amana/iara-pwa` + `FORGEJO_API_URL=https://git.solla.dev/api/v1` + `FORGEJO_API_TOKEN` (o `GITHUB_SERVER_URL` do GitHub resolveria a base errada — mesma armadilha que o teqo documentou).
|
||||
2. **`bun` no CI, sem serviços.** Iara é SPA: ci.yml sem Postgres/services; `oven-sh/setup-bun@v2`; e2e com `bunx playwright install --with-deps chromium` (hosted ubuntu precisa das deps de sistema; a workstation não tinha). Flip workflows usam `setup-node` (scripts plain-Node, sem install).
|
||||
3. **Sem convenção `cursor/*`.** Draft = veto em qualquer caso (`github-pr-flow.mjs` sem o caminho mark-ready do teqo — a Iara não cria draft por nenhum fluxo).
|
||||
4. **`pr.mjs` arma o auto-merge direto.** O `bun run pr -- --automerge` local cria o PR E arma o auto-merge nativo via GraphQL (o PAT do usuário tem direito); o safety net cobre PRs esquecidos. Semântica de "cria e mergea quando os checks passarem" preservada — o "quando" é garantido pelo servidor, não por poll.
|
||||
5. **Deploy é site estático.** `scripts/deploy-iara.sh`: HEAD guard (`git ls-remote` no GitHub público) → flock → workspace fetch no SHA → marker `~/iara-pwa/.deployed-sha` (idempotência — dispatch duplicado é no-op, no molde do guard do teqo) → `bun install` + `bun run build` → `rsync -a --delete` do dist para `~/iara-pwa/dist/` (mesmo destino do rsync via SSH de hoje, agora local). Sem docker/migrate.
|
||||
6. **`configure-branch-protection.mjs` com literal `checks`.** Verificado ao vivo no teqo (PR #742): o GitHub casa o required check pelo nome do CHECK-RUN (o job `checks`), não pelo display `CI / checks`. O literal `checks` é pinado no spec unit + verificado no primeiro PR (fail-closed: literal errado = PR não mergea, visível, nunca merge vermelho).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`.github/workflows/ci.yml`** (novo): port do `.forgejo/workflows/ci.yml` job `checks` — `pull_request: branches: [main]`; `runs-on: ubuntu-latest`; `timeout-minutes: 30`; `permissions: contents: read`; `concurrency: { group: ci-pr-${{ github.head_ref }}, cancel-in-progress: true }`; `if:` same-repo no job (repo público — fork não queima minutes). Steps: checkout → setup-bun → `bun install --frozen-lockfile` → lint → build → test:unit → `bunx playwright install --with-deps chromium` → e2e. Sem job `deploy` (ver deploy.yml) e sem push-main (sem verificador de main — decisão do gate).
|
||||
- **`.github/workflows/deploy.yml`** (novo): `workflow_dispatch` (sem inputs, ref main). Job `verify` (ubuntu-latest): suíte **full** sem skips (lint → build → unit → e2e). Job `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`): checkout → `bash scripts/deploy-iara.sh "$GITHUB_SHA"`.
|
||||
- **`.github/workflows/issue-done-on-main-merge.yml`** (novo): `pull_request: types: [closed]`; `if:` merged && base main && **same-repo** (guarda RCE — checkout de fork + PAT = RCE); env `FORGEJO_API_TOKEN` (secret), `FORGEJO_API_URL`, `FORGEJO_REPOSITORY`, `PR_BODY` (do evento); setup-node; `node scripts/forgejo-issue-transition.mjs --pr <N>`.
|
||||
- **`.github/workflows/plan-issue-ready-on-main-merge.yml`** (novo): idem + `if:` com `Related #` no body (e same-repo).
|
||||
- **`.github/workflows/agent-pr-ready-automerge.yml`** (novo): `pull_request: [opened, reopened, synchronize, ready_for_review, converted_to_draft]`; `if:` open && same-repo; `permissions: { contents: write, pull-requests: write }`; `GITHUB_TOKEN: ${{ github.token }}` explícito; `node scripts/github-pr-automerge.mjs --pr <N>`.
|
||||
- **`scripts/lib/github-api.mjs`** (novo, plain Node): REST+GraphQL zero-dep (molde do `forgejo-api.mjs`), auth `GITHUB_TOKEN`, base `GITHUB_API_URL`/default `https://api.github.com`, repo `GITHUB_REPOSITORY`/default `fsolla/iara-pwa`; retry com backoff (política do teqo: 5xx retry só GET; 4xx nunca); shapes normalizados (`pr.state` OPEN|CLOSED, `pr.draft`, `pr.nodeId`); endpoints: `getPullRequest`, `markPullRequestReady`, `createPullRequest` (nunca draft), `enableAutoMerge` (GraphQL REBASE), `getBranchProtection` (normalizado p/ drift), `updateBranchProtection`, `updateRepository`.
|
||||
- **`scripts/lib/github-pr-flow.mjs`** (novo, puro): `decideAutomergeAction(pr)` → skip (inexistente/merged/não-open/base≠main/**draft-veto**) | enable-auto-merge. Pin OPS57 (draft = veto) e OPS64 (nunca merge vermelho — estrutural no GitHub) no jeito GitHub. Unit-testado.
|
||||
- **`scripts/github-pr-automerge.mjs`** (novo, plain Node): CLI do workflow — lê PR, `decideAutomergeAction`, arma auto-merge no `node_id`; erro do GraphQL → exit 1 (job vermelho, visível); idempotente.
|
||||
- **`scripts/pr.mjs`** (reescrito): `bun run pr -- --head <b> [--title <t>] [--body "…"] [--automerge]` contra a REST do GitHub (GITHUB_TOKEN env; `loadProjectEnv` + `GITHUB_REPOSITORY` do env file). `--automerge` arma auto-merge nativo (GraphQL) após criar. `--draft` removido (falha loud). Sem poll — o servidor mergea quando o required check verde.
|
||||
- **`scripts/lib/github-branch-protection.mjs`** (novo, puro): `REQUIRED_CHECK_CONTEXT = 'checks'` (literal verificado no teqo), `DESIRED_RULE` (strict=false, 0 reviews, `enforce_admins: true`, sem restrictions), `ruleMatches`/`planBranchProtectionRule` (create|update|noop). Unit-testado.
|
||||
- **`scripts/configure-branch-protection.mjs`** (novo): idempotente (read → plan → apply → verify), `--dry-run`; adiciona `bun run configure:branch-protection`.
|
||||
- **`scripts/forgejo-issue-transition.mjs`** (editado): aceita `PR_BODY` env (o corpo do PR agora vem do evento GitHub; sem PR_BODY mantém o fallback da API do Forgejo, dormente); **falha loud** (exit 1 se qualquer flip falhar — I10 absorvida: "o flip novo roda no GitHub com token apropriado e falha loud"); comentário do flip atualizado ("deploy manual via workflow_dispatch").
|
||||
- **`scripts/agent-promote-related-on-merge.mjs`** (editado): `PR_BODY` já suportado (usado pelos workflows novos; sem PR_BODY mantém a leitura via API do Forgejo).
|
||||
- **`scripts/deploy-iara.sh`** (novo): ver Opção 5 acima. Env defaults: `IARA_REPO_URL=https://github.com/fsolla/iara-pwa.git`, `WORKSPACE_DIR=$HOME/iara-deploy`, `SERVE_DIR=$HOME/iara-pwa/dist`, `DEPLOY_LOCK=/tmp/iara-deploy.lock`.
|
||||
- **`scripts/lib/load-project-env.mjs` + `.forgejo/worktree.env`**: `GITHUB_REPOSITORY=fsolla/iara-pwa` no env file (commitado, sem segredos) e carregado no process.env como `FORGEJO_REPOSITORY` (default local p/ os scripts GitHub).
|
||||
- **`package.json`**: `pr:automerge` → `scripts/github-pr-automerge.mjs`; novo `configure:branch-protection` → `scripts/configure-branch-protection.mjs`. `issue:transition` permanece (mesmo arquivo).
|
||||
- **`opencode.jsonc`**: comentário do MCP `github` atualizado (fluxo de PR/CI volta a ser GitHub, mas via scripts `bun run pr` — MCP continua desligado; o `forgejo` MCP permanece, é o tracker).
|
||||
- **`AGENTS.md` + skills (`work-issue`/execution-pipeline)**: seção CI/Workflow reescrita para GitHub (workflows, auto-merge nativo, required check `checks`, deploy manual, branch protection REAL — corrige o invariante "sem branch protection"); comandos `bun run pr`/`worktree` mantidos (semântica preservada); passo do cutover nomeado.
|
||||
- **`docs/ops/deploy-github-runbook.md`** (novo): runbook do cutover na ordem + rollback + secrets + runners (ver Fases).
|
||||
|
||||
### Migration / Access / UI
|
||||
|
||||
- **Migration:** sem migration (nenhum schema).
|
||||
- **Access / Consent:** nenhum.
|
||||
- **UI:** Impeccable N/A.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Libs + scripts base** — `github-api.mjs`, `github-pr-flow.mjs` (puro), `github-branch-protection.mjs` (puro), `pr.mjs` reescrito, `github-pr-automerge.mjs`, `configure-branch-protection.mjs`, `deploy-iara.sh`, flips com PR_BODY, `load-project-env`/env file + specs unit novas (`tests/unit/github-api.test.ts`, `github-pr-flow.test.ts`, `github-branch-protection.test.ts`, molde bun:test do `ws.test.ts`). Gate: `bun run test:unit`.
|
||||
2. **Workflows** — `.github/workflows/*` (5). Nada do `.forgejo/workflows/` é tocado nesta fase. Gate: `bun run gate` + review YAML.
|
||||
3. **Docs/skills/runbook** — AGENTS.md, skills, `docs/ops/deploy-github-runbook.md`, opencode.jsonc.
|
||||
4. **Gates finais + entrega** — `bun run gate`; PR via `bun run pr -- --automerge` (semântica nova) com `Closes #17`; CI verde no GitHub → auto-merge → flips da #17 (validação ao vivo do fluxo no merge do próprio OPS1) → deploy via dispatch manual quando o runner do homeserver estiver pronto. **Passos manuais do cutover listados para o humano** (ordem do teqo OPS71): (a) remotes (`origin` → GitHub; Forgejo secundário); (b) PAT `GITHUB_TOKEN` local + secret `FORGEJO_API_TOKEN` no GitHub + `allow_auto_merge` no repo; (c) `bun run configure:branch-protection`; (d) desligar o runner do Forgejo após validação; (e) runner self-hosted do GitHub no homeserver + `bun` no homeserver (deploy; não bloqueia PRs); (f) push → PR → CI → merge. O PR em si não depende de (d)/(e).
|
||||
|
||||
## Fase 2 — entrega sucessora (pós-validação)
|
||||
|
||||
Registrada no fechamento desta Issue (capture-review-debts, autônomo): remove `.forgejo/workflows/*`, `scripts/forgejo-pr-automerge.mjs` e docs residuais do fluxo Forgejo. Critério de entrada: um ciclo completo validado no GitHub (PR → CI → auto-merge → flip → deploy manual).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Portar `waitForChecks`/statuses para o GitHub — não há; auto-merge nativo (servidor).
|
||||
- Re-engenharia do deploy além do necessário — só a origem do clone, o modo de invocação (local, sem SSH) e o destino final mudam; rsync `--delete` preservado.
|
||||
- Sincronizar o repo Forgejo com main — congelado no cutover (decidido; tracker intacto por API).
|
||||
- Migrar Issues/labels/claims — anti-goal explícito.
|
||||
- e2e local afetado / scoping do CI (teqo OPS72) — fora de escopo (e2e full preservado).
|
||||
- Upgrade do Forgejo / infra do homeserver — decisões separadas.
|
||||
- Secrets (`FORGEJO_API_TOKEN` no GitHub, `GITHUB_TOKEN` PAT local, runner) — passos manuais documentados no runbook, não código.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Literal do required check:** `checks` (nome do check-run/job), pinado no spec + verificado ao vivo no primeiro PR (teqo: validado no PR #742). Errado → PRs não mergeam (fail-closed, visível) — nunca merge sem verificação.
|
||||
- **GITHUB_TOKEN vs repo:** `fsolla/iara-pwa` é repo de usuário — o token built-in do Actions com `pull-requests`+`contents` write arma auto-merge normalmente (o bloqueio de repo de org era específico do Forgejo — I10, absorvida; flip usa `FORGEJO_API_TOKEN`, nunca o GITHUB_TOKEN).
|
||||
- **Flip no repo errado (deltas #1):** `FORGEJO_REPOSITORY` + `FORGEJO_API_URL` explícitos em TODOS os workflows de flip + fail-loud (exit 1) — o 403/404 da era I10 não repete em silêncio.
|
||||
- **Fork PR (repo público):** `if:` same-repo em todos os jobs que usam checkout/secrets (guarda RCE preservada do teqo).
|
||||
- **Runner do homeserver ainda não instalado / sem bun:** deploy indisponível até o passo manual (e); PRs mergeam normalmente (CI hosted não depende dele); o PR deste item não publica nada sozinho (dispatch manual).
|
||||
- **Runner do Forgejo ligado durante a transição:** com o repo congelado, o ci.yml antigo rodaria no push do main congelado... não há push novo (origin vira GitHub) — runner desligado no passo (d), reversível.
|
||||
- **`gate` local vs `origin/main`:** sem o passo (a) dos remotes, o diff local compara contra o Forgejo defasado — passo (a) é o PRIMEIRO do cutover e o runbook avisa (comando exato).
|
||||
- **Deploy duplicado (dispatch repetido do mesmo SHA):** marker `~/.deployed-sha` + HEAD guard — no-op, molde do teqo.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (CI/PR GitHub, tracker Forgejo por API, deploy manual verify→self-hosted, rollup `checks` required check, `bun run pr` com semântica preservada)
|
||||
- [ ] Cutover em duas fases: sistema GitHub criado e validado ao vivo primeiro; arquivos do Forgejo preservados nesta entrega (rollback), remoção em entrega sucessora
|
||||
- [ ] Invariantes AGENTS/engineering-brief (plain Node stdlib nos helpers de CI; zero-dep nos workflows; sem migration; tracker intocado)
|
||||
- [ ] Testes de domínio previstos: github-api (normalização/endpoints), github-pr-flow (decisões de skip/auto-merge — pins OPS57/OPS64), github-branch-protection (regra desejada + drift)
|
||||
54
docs/plans/ops1-simplify-dry-pos.md
Normal file
54
docs/plans/ops1-simplify-dry-pos.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Plano: Pós-OPS1 — consolidar parseArgs e extrair cascata `checks` em workflow reutilizável
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-20
|
||||
Issue: OPS1-DRY (sucessora de #17 OPS1)
|
||||
Priority: P3
|
||||
Appetite: ~1 sessão leve (fill-in)
|
||||
|
||||
## Intenção
|
||||
|
||||
Débitos de DRY encontrados no review do OPS1 (simplify reviewers), que ficaram
|
||||
para trás para não misturar refactor com a migração de CI:
|
||||
|
||||
1. **parseArgs duplicado em 4 scripts.** `scripts/pr.mjs`, `scripts/github-pr-automerge.mjs`
|
||||
e `scripts/configure-branch-protection.mjs` têm cópias locais; `scripts/lib/agent-forgejo.mjs`
|
||||
exporta outro idêntico (flagsWithValue + positional + kebab→camel). Consolidar num
|
||||
`scripts/lib/cli.mjs` único e importar nos 4 — sem tocar a semântica (fail-closed nos
|
||||
flags sem valor, positional, prefixo `--`).
|
||||
2. **Cascata `checks` duplicada em `ci.yml` e no `verify` do `deploy.yml`.** O mesmo
|
||||
fluxo (checkout → setup-bun → install → lint → build → unit → playwright → e2e) em
|
||||
dois arquivos; risco de drift (ex.: esquecer `playwright install` em um dos dois).
|
||||
Extrair numa reusable workflow (`workflow_call`) chamada pelos dois, preservando o
|
||||
nome do job `checks` em `ci.yml` (o required-check literal `checks` não pode mudar).
|
||||
|
||||
## O que NÃO negociar
|
||||
|
||||
- O literal `checks` (required check + auto-merge) — com reusable workflow, o job
|
||||
`checks` fica declarado em `ci.yml` (o check-run `CI / checks` é inalterado).
|
||||
- Comportamento/política do parser (as 3 variantes hoje divergem só em
|
||||
flagsWithValue/positional — unificar com o superset; cada chamador filtra).
|
||||
|
||||
## Fases
|
||||
|
||||
1. `parseArgs` em `cli.mjs` + importar em `pr.mjs`, `github-pr-automerge.mjs`,
|
||||
`configure-branch-protection.mjs`, `agent-forgejo.mjs` (remover as cópias). Gate:
|
||||
`bun run test:unit`.
|
||||
2. Reusable workflow `checks` (workflow_call) + `ci.yml` e `deploy.yml` `verify`
|
||||
passando a usá-la. Gate: `bun run gate` + validação ao vivo (PR → check `checks`).
|
||||
|
||||
## Rabbit holes / fora de escopo
|
||||
|
||||
- Não mudar o comportamento do parser (só mover/consolidar).
|
||||
- Não mesclar DRY com UX.
|
||||
- `agent-forgejo.mjs` (tracker) só troca o parseArgs — nada da lógica de claim.
|
||||
|
||||
## Já resolvido no simplify (não reabrir)
|
||||
|
||||
- Nada do OPS1-DRY foi resolvido — é o próprio conteúdo pós-review diferido.
|
||||
|
||||
## Aceite
|
||||
|
||||
- Zero `parseArgs` duplicado (busca por `const parseArgs` → 1 em cli.mjs).
|
||||
- `ci.yml` e `deploy.yml` `verify` usam o mesmo `workflow_call`; um PR validado mostra
|
||||
o check-run `CI / checks` idêntico.
|
||||
52
docs/plans/remover-fluxo-forgejo.md
Normal file
52
docs/plans/remover-fluxo-forgejo.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Plano: Pós-OPS1 Fase 2 — remover arquivos do fluxo Forgejo (workflows/scripts mortos)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-20
|
||||
Issue: OPS1-F2 (sucessora de #17 OPS1)
|
||||
Priority: P2
|
||||
Appetite: ~1 sessão (remoção + fixups de consumidores + docs)
|
||||
|
||||
## Intenção
|
||||
|
||||
O fluxo GitHub Actions (OPS1, validado ao vivo: PRs #1/#3 no GitHub — CI `checks`
|
||||
verde → auto-merge nativo → flips via API → deploy manual `workflow_dispatch`) é a
|
||||
única via de CI/PR/deploy no repo. Os arquivos do fluxo Forgejo ficam **dormentes**
|
||||
desde o cutover e devem sair do tree — é a Fase 2 prometida no impl plan do OPS1.
|
||||
|
||||
## O que NÃO negociar
|
||||
|
||||
- Tracker no Forgejo (Issues/labels/claims/flips via API): `forgejo-api.mjs`,
|
||||
`agent-*.mjs`, `issue.mjs`, `forgejo-issue-transition.mjs`,
|
||||
`agent-promote-related-on-merge.mjs` **ficam**; `.forgejo/worktree.env` fica
|
||||
(consumido pelo provisionamento de worktree — fora do escopo `workflows/*`).
|
||||
- Desligar o runner do Forgejo é passo manual do runbook (fora do código).
|
||||
- `ci.yml` de main não volta (deploy = dispatch manual).
|
||||
- Nunca fechar o tracker (anti-goal do OPS1).
|
||||
|
||||
## O que remover
|
||||
|
||||
- `.forgejo/workflows/*` (4 arquivos: ci.yml, issue-done-on-main-merge.yml,
|
||||
plan-issue-ready-on-main-merge.yml, agent-pr-ready-automerge.yml).
|
||||
- `scripts/forgejo-pr-automerge.mjs` (poll automerger da era Forgejo — substituído
|
||||
pelo auto-merge nativo + `github-pr-automerge.mjs`).
|
||||
- Doc residuais que descrevem o fluxo Forgejo (varredura por `forgejo-pr-automerge`,
|
||||
`.forgejo/workflows`, "rsync homeserver:" etc.).
|
||||
|
||||
## Rabbit holes / fora de escopo
|
||||
|
||||
- Não deletar scripts do tracker (item NÃO negociar) nem `.forgejo/worktree.env`.
|
||||
- Não editar `docs/plans/*` já congelado (história; a narrativa "dormentes até a
|
||||
Fase 2" permanece).
|
||||
- Rollback continua sendo git history / PR reversível (não manter arquivos).
|
||||
|
||||
## Já resolvido no simplify (não reabrir)
|
||||
|
||||
- Resistência de reverter a Fase 2 foi mudada para "rollback = git history" (o main
|
||||
congelado do Forgejo ainda tem a árvore pré-cutover).
|
||||
|
||||
## Aceite
|
||||
|
||||
- `git grep` por `forgejo-pr-automerge|.forgejo/workflows` em scripts/workflows
|
||||
vivo → zero hits (fora do tracker e do env file).
|
||||
- `bun run gate` limpo; um PR inteiro validado no GitHub depois da remoção
|
||||
(CI + merge + flip).
|
||||
|
|
@ -13,6 +13,33 @@
|
|||
"stalwart": { "enabled": false }
|
||||
// penpot: config de repo do teqo (design); sem fluxo de design na Iara —
|
||||
// nem chega a carregar, então não há entrada aqui.
|
||||
// github: já desligado nos globais (fluxo migrou para o Forgejo).
|
||||
// github: fluxo de PR/CI voltou para o GitHub (OPS1), mas é dirigido por
|
||||
// scripts (`bun run pr`, `github-pr-automerge.mjs`, configure) — o MCP
|
||||
// github continua desligado; o `forgejo` MCP é o tracker de Issues.
|
||||
},
|
||||
"provider": {
|
||||
"cheapest-inference": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Cheapest Inference",
|
||||
"options": {
|
||||
"baseURL": "https://api.cheapestinference.com/v1"
|
||||
},
|
||||
"models": {
|
||||
"deepseek-v4-flash": {
|
||||
"name": "DeepSeek V4 Flash",
|
||||
"limit": {
|
||||
"context": 1000000,
|
||||
"output": 384000
|
||||
}
|
||||
},
|
||||
"mimo-v2.5": {
|
||||
"name": "MiMo v2.5",
|
||||
"limit": {
|
||||
"context": 1000000,
|
||||
"output": 128000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@
|
|||
"agent:file-miss": "node scripts/agent-file-miss.mjs",
|
||||
"agent:status": "node scripts/agent-status.mjs",
|
||||
"agent:promote-related": "node scripts/agent-promote-related-on-merge.mjs",
|
||||
"pr:automerge": "node scripts/forgejo-pr-automerge.mjs",
|
||||
"issue:transition": "node scripts/forgejo-issue-transition.mjs"
|
||||
"pr:automerge": "node scripts/github-pr-automerge.mjs",
|
||||
"issue:transition": "node scripts/forgejo-issue-transition.mjs",
|
||||
"configure:branch-protection": "node scripts/configure-branch-protection.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "^0.15.14",
|
||||
|
|
|
|||
81
scripts/configure-branch-protection.mjs
Normal file
81
scripts/configure-branch-protection.mjs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Drift repair for `main` branch protection — GitHub (OPS1). Idempotent:
|
||||
* reads the existing rule and creates / updates / no-ops (never duplicates).
|
||||
*
|
||||
* bun run configure:branch-protection # apply
|
||||
* bun run configure:branch-protection -- --dry-run
|
||||
*
|
||||
* Desired rule: required check-run `checks` (the ci-pr cascade job),
|
||||
* strict=false, 0 reviews, `enforce_admins: true` (nobody, admin included,
|
||||
* merges with a red required check). PRs auto-merge when that check is
|
||||
* green; the server rule is the final defense against every merge path,
|
||||
* manual API merges included.
|
||||
*/
|
||||
|
||||
import { createApi } from './lib/github-api.mjs'
|
||||
import {
|
||||
DESIRED_RULE,
|
||||
planBranchProtectionRule,
|
||||
ruleMatches,
|
||||
} from './lib/github-branch-protection.mjs'
|
||||
import { loadProjectEnv } from './lib/load-project-env.mjs'
|
||||
|
||||
loadProjectEnv()
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const flags = {}
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
if (arg.startsWith('--')) {
|
||||
const name = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
||||
const next = argv[index + 1]
|
||||
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
|
||||
if (typeof next === 'string' && !next.startsWith('--')) index += 1
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
const flags = parseArgs(process.argv)
|
||||
const dryRun = flags.dryRun === true
|
||||
const branch = 'main'
|
||||
|
||||
const api = createApi({})
|
||||
const repo = process.env.GITHUB_REPOSITORY
|
||||
const existing = await api.getBranchProtection(branch)
|
||||
const { action } = planBranchProtectionRule(existing)
|
||||
|
||||
console.log(
|
||||
`[configure:branch-protection] repo ${repo}${dryRun ? ' (dry-run)' : ''} — regra "${branch}": ${
|
||||
action === 'noop'
|
||||
? 'já conforme — nada a fazer'
|
||||
: action === 'update'
|
||||
? 'atualizar (drift)'
|
||||
: 'criar'
|
||||
}`,
|
||||
)
|
||||
|
||||
if (action === 'noop') {
|
||||
console.log(`[configure:branch-protection] no-op: ${JSON.stringify(existing)}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(
|
||||
`[dry-run] PUT /repos/${repo}/branches/${branch}/protection ${JSON.stringify(DESIRED_RULE)}`,
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await api.updateBranchProtection(DESIRED_RULE, branch)
|
||||
|
||||
const after = await api.getBranchProtection(branch)
|
||||
if (!after || !ruleMatches(after)) {
|
||||
console.error(
|
||||
`[configure:branch-protection] FALHOU: PUT respondeu mas a regra "${branch}" no servidor não bate com a desejada — ${JSON.stringify(after)}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(
|
||||
`[configure:branch-protection] done — regra verificada no servidor: ${JSON.stringify(after)}`,
|
||||
)
|
||||
106
scripts/deploy-iara.sh
Normal file
106
scripts/deploy-iara.sh
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Deploy Iara (SPA estático) para o homeserver (OPS1).
|
||||
#
|
||||
# Roda NO homeserver via o runner self-hosted do GitHub (job `deploy` de
|
||||
# .github/workflows/deploy.yml):
|
||||
# bash scripts/deploy-iara.sh <commit-sha>
|
||||
# (A invocação da era Forgejo — `rsync dist/ homeserver:...` via SSH a partir
|
||||
# da workstation — foi substituída: o runner executa localmente, sem SSH, sem
|
||||
# envolvimento da workstation.)
|
||||
#
|
||||
# Flow: HEAD guard (só o HEAD atual de main deploya) -> flock (serialização)
|
||||
# -> guard de idempotência (marker do SHA já deployado) -> workspace fetch no
|
||||
# SHA -> bun install + build -> rsync local do dist para o diretório servido.
|
||||
# Qualquer falha deixa o site atual intocado e o job vermelho.
|
||||
#
|
||||
# Environment defaults assumem o layout do homeserver: repo clonado de
|
||||
# github.com/fsolla/iara-pwa (público — sem credencial; o clone pré-cutover
|
||||
# apontava para o Forgejo local e é re-apontado idempotentemente abaixo).
|
||||
# Requer `bun` no homeserver (passo manual do cutover).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SHA="${1:?usage: deploy-iara.sh <commit-sha>}"
|
||||
IARA_REPO_URL="${IARA_REPO_URL:-https://github.com/fsolla/iara-pwa.git}"
|
||||
WORKSPACE_DIR="${WORKSPACE_DIR:-$HOME/iara-deploy}"
|
||||
SERVE_DIR="${SERVE_DIR:-$HOME/iara-pwa/dist}"
|
||||
DEPLOY_LOCK="${DEPLOY_LOCK:-/tmp/iara-deploy.lock}"
|
||||
MARKER_FILE="${MARKER_FILE:-$HOME/iara-pwa/.deployed-sha}"
|
||||
BUN_BIN="${BUN:-$(command -v bun || echo "$HOME/.bun/bin/bun")}"
|
||||
|
||||
say() { printf '[deploy] %s\n' "$*"; }
|
||||
|
||||
fatal() {
|
||||
say "FAILED: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- bun no homeserver (passo manual do cutover) -------------------------
|
||||
|
||||
[ -x "$BUN_BIN" ] || fatal "bun não encontrado em $BUN_BIN — instale no homeserver (ou defina BUN)"
|
||||
|
||||
# --- guards ------------------------------------------------------------
|
||||
|
||||
main_head="$(git ls-remote "$IARA_REPO_URL" refs/heads/main | awk '{print $1}')"
|
||||
if [ "$main_head" != "$SHA" ]; then
|
||||
say "stale run: main is $main_head, job deploys $SHA — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec 9>"$DEPLOY_LOCK"
|
||||
# ~20min de espera pelo lock: o job tem timeout de 30min — esperar mais que
|
||||
# isso seria descartado pelo GitHub de qualquer forma.
|
||||
flock -w 1200 9 || fatal "another deploy holds $DEPLOY_LOCK"
|
||||
|
||||
main_head="$(git ls-remote "$IARA_REPO_URL" refs/heads/main | awk '{print $1}')"
|
||||
if [ "$main_head" != "$SHA" ]; then
|
||||
say "stale run after lock: main is $main_head, job deploys $SHA — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- idempotency -------------------------------------------------------
|
||||
# Um dispatch manual duplicado do mesmo SHA é um no-op: o marker guarda o
|
||||
# SHA já servido em produção (a ausência do marker conta como "não
|
||||
# deployado" — o deploy prossegue, direção segura).
|
||||
|
||||
if [ -f "$MARKER_FILE" ] && [ "$(cat "$MARKER_FILE")" = "$SHA" ]; then
|
||||
say "already deployed: $SERVE_DIR runs $SHA — nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- workspace at <sha> ------------------------------------------------
|
||||
|
||||
if [ ! -d "$WORKSPACE_DIR/.git" ]; then
|
||||
say "cloning $IARA_REPO_URL into $WORKSPACE_DIR"
|
||||
git clone "$IARA_REPO_URL" "$WORKSPACE_DIR"
|
||||
else
|
||||
# OPS1: o clone pré-cutover apontava para o Forgejo local — re-aponta
|
||||
# idempotentemente para o repo que recebeu o merge.
|
||||
current_url="$(git -C "$WORKSPACE_DIR" remote get-url origin 2>/dev/null || true)"
|
||||
if [ -n "$current_url" ] && [ "$current_url" != "$IARA_REPO_URL" ]; then
|
||||
say "re-pointing workspace origin: $current_url -> $IARA_REPO_URL"
|
||||
git -C "$WORKSPACE_DIR" remote set-url origin "$IARA_REPO_URL"
|
||||
fi
|
||||
git -C "$WORKSPACE_DIR" fetch -q origin
|
||||
fi
|
||||
|
||||
# Workspace limpo e no SHA exato: um deploy anterior falhado (ou residuo de
|
||||
# build) não contamina o build — `bun install` recria o que precisar.
|
||||
git -C "$WORKSPACE_DIR" reset -q --hard "$SHA"
|
||||
git -C "$WORKSPACE_DIR" clean -q -fdx
|
||||
git -C "$WORKSPACE_DIR" checkout -q --detach "$SHA"
|
||||
say "workspace at $SHA"
|
||||
|
||||
# --- build -------------------------------------------------------------
|
||||
|
||||
"$BUN_BIN" install --frozen-lockfile --cwd "$WORKSPACE_DIR"
|
||||
"$BUN_BIN" run --cwd "$WORKSPACE_DIR" build
|
||||
|
||||
# --- sync (local — o runner já está no homeserver) ----------------------
|
||||
|
||||
mkdir -p "$SERVE_DIR"
|
||||
rsync -a --delete "$WORKSPACE_DIR/dist/" "$SERVE_DIR/"
|
||||
printf '%s\n' "$SHA" > "$MARKER_FILE"
|
||||
|
||||
say "deploy concluido em $(date -u +%H:%M:%SZ) — $SERVE_DIR runs $SHA"
|
||||
|
|
@ -1,11 +1,22 @@
|
|||
/**
|
||||
* Plain-Node CLI (no pnpm) — on merge to main, flip Issues cited as
|
||||
* `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.
|
||||
* The PR body comes from the API (Forgejo's PR webhook payload is not relied
|
||||
* on), so the workflow only needs the PR number.
|
||||
*
|
||||
* OPS1: the PR now lives on GitHub; the ISSUES stay on the Forgejo tracker.
|
||||
* The workflow passes the PR body via the `PR_BODY` env (from the GitHub
|
||||
* event payload) and the script flips Issues on the Forgejo 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 OPS1 Fase 2 removal).
|
||||
*
|
||||
* Fail-loud (I10 absorbed): a failed flip exits 1 — the flip is the whole
|
||||
* purpose of this workflow, and the era of silent failures 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 #17' node scripts/forgejo-issue-transition.mjs --pr <N>
|
||||
*/
|
||||
|
||||
import { createApi } from './lib/forgejo-api.mjs'
|
||||
|
|
@ -32,7 +43,11 @@ if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
|||
}
|
||||
|
||||
const api = createApi({})
|
||||
const pr = await api.getPullRequest(prNumber)
|
||||
const bodyFromEnv = process.env.PR_BODY
|
||||
const pr =
|
||||
typeof bodyFromEnv === 'string'
|
||||
? { merged: true, body: bodyFromEnv }
|
||||
: await api.getPullRequest(prNumber)
|
||||
if (!pr) {
|
||||
console.error(`[forgejo-issue-transition] PR #${prNumber} não encontrada`)
|
||||
process.exit(1)
|
||||
|
|
@ -51,17 +66,25 @@ if (numbers.length === 0) {
|
|||
process.exit(0)
|
||||
}
|
||||
|
||||
let flipFailed = false
|
||||
for (const number of numbers) {
|
||||
try {
|
||||
await api.setLabels(number, { add: ['done', 'in-prod'], remove: ['in-progress'] })
|
||||
await api.addComment(
|
||||
number,
|
||||
`Merged em main via PR #${prNumber} — \`done\` + \`in-prod\`. Deploy de produção segue o verificador \`ci.yml\` (gated).`,
|
||||
`Merged em main via PR #${prNumber} — \`done\` + \`in-prod\`. Deploy de produção é manual (workflow_dispatch no GitHub Actions).`,
|
||||
)
|
||||
console.log(`[forgejo-issue-transition] #${number}: done + in-prod`)
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`[forgejo-issue-transition] #${number}: skip (${error instanceof Error ? error.message : error})`,
|
||||
flipFailed = true
|
||||
console.error(
|
||||
`[forgejo-issue-transition] #${number}: FLIP FALHOU — ${error instanceof Error ? error.message : error}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (flipFailed) {
|
||||
console.error(
|
||||
`[forgejo-issue-transition] PR #${prNumber}: ${numbers.length} Issue(s) citada(s), pelo menos 1 flip falhou — os labels de status podem estar mentindo.`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
|
|||
63
scripts/github-pr-automerge.mjs
Normal file
63
scripts/github-pr-automerge.mjs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Plain-Node CLI (no bun) — GitHub PR safety net: arm GitHub's native
|
||||
* auto-merge (rebase) for same-repo PRs to main. Runs in
|
||||
* .github/workflows/agent-pr-ready-automerge.yml with the built-in
|
||||
* GITHUB_TOKEN (permissions: contents + pull-requests write).
|
||||
*
|
||||
* Unlike the Forgejo-era automerger, there is NO poll loop:
|
||||
* `enablePullRequestAutoMerge` is server-side — GitHub only merges when every
|
||||
* required check (incl. `checks`) is green and the PR is mergeable. The
|
||||
* OPS64 "never trust the rollup" pin is structural here: a single sequential
|
||||
* job posts one honest check-run, and the server enforces it. Draft policy
|
||||
* preserved: any draft is the actor's veto (skip, exit 0).
|
||||
*
|
||||
* Idempotent: already merged / closed / non-main base / draft → no-op success.
|
||||
* A GraphQL error (e.g. repo auto-merge disabled, PR in conflict) exits 1 so
|
||||
* the job goes red and the situation is visible.
|
||||
*
|
||||
* node scripts/github-pr-automerge.mjs --pr <N>
|
||||
*/
|
||||
|
||||
import { createApi } from './lib/github-api.mjs'
|
||||
import { decideAutomergeAction } from './lib/github-pr-flow.mjs'
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const flags = {}
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
if (arg.startsWith('--')) {
|
||||
const name = arg.slice(2)
|
||||
const next = argv[index + 1]
|
||||
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
|
||||
if (typeof next === 'string' && !next.startsWith('--')) index += 1
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
const flags = parseArgs(process.argv)
|
||||
const prNumber = Number(flags.pr)
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||
console.error('Usage: node scripts/github-pr-automerge.mjs --pr <N>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const api = createApi({})
|
||||
try {
|
||||
const pr = await api.getPullRequest(prNumber)
|
||||
const verdict = decideAutomergeAction(pr)
|
||||
|
||||
if (verdict.action === 'skip') {
|
||||
console.log(`[github-pr-automerge] PR #${prNumber}: skip (${verdict.reason})`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.log(`[github-pr-automerge] PR #${prNumber}: armando auto-merge (rebase)`)
|
||||
await api.enableAutoMerge(pr.nodeId)
|
||||
console.log(
|
||||
`[github-pr-automerge] PR #${prNumber}: auto-merge armado — o GitHub mergea quando o required check "checks" fica verde`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`[github-pr-automerge] falhou: ${error instanceof Error ? error.message : error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
274
scripts/lib/github-api.mjs
Normal file
274
scripts/lib/github-api.mjs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
/**
|
||||
* GitHub REST + GraphQL client for the CI/PR scripts (`pr`, `github-pr*`,
|
||||
* branch protection). Zero dependencies — plain Node `fetch` — so it also runs
|
||||
* inside GitHub Actions jobs without `bun install`.
|
||||
*
|
||||
* Auth: `GITHUB_TOKEN` (PAT locally; the built-in Actions token in
|
||||
* workflows). Base URL: `GITHUB_API_URL`, else `https://api.github.com`.
|
||||
* Repository: `GITHUB_REPOSITORY` (set by `loadProjectEnv` from
|
||||
* `.forgejo/worktree.env` locally; by the runner in Actions), else the default.
|
||||
*
|
||||
* Retry (exponential backoff): um fetch que REJEITA a nível de rede
|
||||
* (DNS/TCP/reset — provavelmente nunca chegou ao servidor) retrya em
|
||||
* QUALQUER método; um 5xx retrya só em GET — resposta 5xx significa que o
|
||||
* servidor respondeu, então escritas falham fechado ali (sem duplicar efeito
|
||||
* colateral). 4xx nunca retrya. Defaults: 3 retries, base 300 ms ×2,
|
||||
* ±20% jitter.
|
||||
*
|
||||
* Shapes are normalized to the contract the CLI scripts use: `pr.state` ∈
|
||||
* OPEN|CLOSED, `pr.draft`, `pr.nodeId` (GraphQL auto-merge), branch
|
||||
* protection normalized for drift comparison.
|
||||
*
|
||||
* Portado do teqo (scripts/lib/github-api.mjs, OPS71) com o default de repo
|
||||
* da Iara (`fsolla/iara-pwa`).
|
||||
*/
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.github.com'
|
||||
const DEFAULT_GRAPHQL_URL = 'https://api.github.com/graphql'
|
||||
const DEFAULT_REPOSITORY = 'fsolla/iara-pwa'
|
||||
const USER_AGENT = 'iara-agent-scripts/1.0 (github)'
|
||||
const RETRYABLE_STATUSES = new Set([502, 503, 504])
|
||||
|
||||
/**
|
||||
* @typedef {object} GithubApiOptions
|
||||
* @property {string} [base]
|
||||
* @property {string} [graphqlBase]
|
||||
* @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 {GithubApiOptions} [options]
|
||||
*/
|
||||
export const createApi = ({
|
||||
base,
|
||||
graphqlBase,
|
||||
token,
|
||||
repository,
|
||||
fetchImpl,
|
||||
retries = 3,
|
||||
backoffMs = 300,
|
||||
jitter = true,
|
||||
sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
} = {}) => {
|
||||
const baseUrl = (base ?? process.env.GITHUB_API_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, '')
|
||||
const graphqlUrl = (graphqlBase ?? process.env.GITHUB_GRAPHQL_URL ?? DEFAULT_GRAPHQL_URL).replace(
|
||||
/\/+$/,
|
||||
'',
|
||||
)
|
||||
const repo = 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(
|
||||
`[github-api] ${method} ${path} falhou (tentativa ${attempt}/${attemptCount}): ${reason} — retry em ${delay}ms`,
|
||||
)
|
||||
|
||||
const headers = () => {
|
||||
const authToken = token ?? process.env.GITHUB_TOKEN
|
||||
if (!authToken) {
|
||||
throw new Error('Sem token para a API do GitHub — defina GITHUB_TOKEN.')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
'User-Agent': USER_AGENT,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
const request = async (path, { method = 'GET', body, query } = {}) => {
|
||||
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 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, {
|
||||
method,
|
||||
headers: headers(),
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
})
|
||||
} 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
|
||||
}
|
||||
let text
|
||||
try {
|
||||
text = await response.text()
|
||||
} catch (error) {
|
||||
if (isLastAttempt || method !== 'GET') throw error
|
||||
await retryAfter(attempt, error.message)
|
||||
continue
|
||||
}
|
||||
if (response.status === 404 && method === 'GET') return null
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API ${method} ${path} → ${response.status}: ${text.slice(0, 400)}`)
|
||||
}
|
||||
if (!text) return null
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(`GitHub API ${method} ${path}: resposta não-JSON: ${text.slice(0, 80)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const graphql = async (query, variables) => {
|
||||
let response
|
||||
try {
|
||||
response = await fetcher(graphqlUrl, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ query, variables }),
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`GitHub GraphQL falhou (rede): ${error.message}`)
|
||||
}
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub GraphQL → ${response.status}: ${text.slice(0, 400)}`)
|
||||
}
|
||||
const parsed = JSON.parse(text)
|
||||
const firstError = parsed?.errors?.[0]
|
||||
if (firstError) {
|
||||
throw new Error(`GitHub GraphQL: ${firstError.message}`)
|
||||
}
|
||||
return parsed.data ?? null
|
||||
}
|
||||
|
||||
const normalizeState = (state) => (String(state).toLowerCase() === 'closed' ? 'CLOSED' : 'OPEN')
|
||||
|
||||
const normalizePullRequest = (pr) => ({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
body: pr.body ?? '',
|
||||
state: normalizeState(pr.state),
|
||||
merged: Boolean(pr.merged_at || pr.merged),
|
||||
draft: Boolean(pr.draft),
|
||||
mergeable: pr.mergeable ?? null,
|
||||
nodeId: pr.node_id ?? '',
|
||||
head: { ref: pr.head?.ref ?? '', sha: pr.head?.sha ?? '' },
|
||||
base: { ref: pr.base?.ref ?? '' },
|
||||
})
|
||||
|
||||
const api = {
|
||||
/**
|
||||
* GET /repos/{owner}/{repo}/pulls/{number} — normalized.
|
||||
* @param {number} number
|
||||
*/
|
||||
getPullRequest: async (number) => {
|
||||
const pr = await request(`/repos/${owner}/${name}/pulls/${number}`)
|
||||
return pr ? normalizePullRequest(pr) : null
|
||||
},
|
||||
|
||||
/** POST /pulls — creates a PR (never draft — repo rule). */
|
||||
createPullRequest: async ({ head, base = 'main', 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,
|
||||
nodeId: pr.node_id ?? '',
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Enables GitHub's native auto-merge (GraphQL) with the repo's canonical
|
||||
* merge style (rebase). The server waits for the required checks — a PR
|
||||
* with a red required check can never auto-merge.
|
||||
*/
|
||||
enableAutoMerge: async (nodeId, mergeMethod = 'REBASE') => {
|
||||
await graphql(
|
||||
`
|
||||
mutation EnablePullRequestAutoMerge($id: ID!, $mergeMethod: PullRequestMergeMethod!) {
|
||||
enablePullRequestAutoMerge(input: { pullRequestId: $id, mergeMethod: $mergeMethod }) {
|
||||
pullRequest {
|
||||
number
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id: nodeId, mergeMethod },
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /repos/{owner}/{repo}/branches/{branch}/protection — normalized for
|
||||
* drift comparison; `null` when the branch has no protection rule (404).
|
||||
* Cobre todos os campos do DESIRED_RULE (ruleMatches os compara), então o
|
||||
* drift repair é completo: reviews/restrictions sem exigência viram
|
||||
* `null` (o GitHub pode ecoar um objeto com count 0), booleanos são
|
||||
* desembrulhados de `{ enabled }`.
|
||||
*/
|
||||
getBranchProtection: async (branch = 'main') => {
|
||||
const rule = await request(`/repos/${owner}/${name}/branches/${branch}/protection`)
|
||||
if (!rule) return null
|
||||
const reviews = rule.required_pull_request_reviews
|
||||
return {
|
||||
required_status_checks: rule.required_status_checks
|
||||
? {
|
||||
strict: Boolean(rule.required_status_checks.strict),
|
||||
contexts: (rule.required_status_checks.checks ?? []).map((entry) => entry.context),
|
||||
}
|
||||
: null,
|
||||
enforce_admins: Boolean(rule.enforce_admins?.enabled ?? rule.enforce_admins),
|
||||
required_pull_request_reviews:
|
||||
!reviews || (reviews.required_approving_review_count ?? 0) === 0 ? null : reviews,
|
||||
restrictions: rule.restrictions ?? null,
|
||||
required_linear_history: Boolean(rule.required_linear_history),
|
||||
allow_force_pushes: Boolean(rule.allow_force_pushes?.enabled ?? rule.allow_force_pushes),
|
||||
allow_deletions: Boolean(rule.allow_deletions?.enabled ?? rule.allow_deletions),
|
||||
block_creations: Boolean(rule.block_creations),
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /repos/{owner}/{repo}/branches/{branch}/protection — replaces the
|
||||
* whole rule (GitHub replace semantics: omitted protections are removed).
|
||||
*/
|
||||
updateBranchProtection: (payload, branch = 'main') =>
|
||||
request(`/repos/${owner}/${name}/branches/${branch}/protection`, {
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
}),
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
82
scripts/lib/github-branch-protection.mjs
Normal file
82
scripts/lib/github-branch-protection.mjs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* Pure decision for the GitHub `main` branch protection rule (OPS1).
|
||||
*
|
||||
* The desired rule is the repo contract: main requires the ci-pr cascade
|
||||
* check-run as the only status check (strict=false, 0 reviews) and
|
||||
* `enforce_admins: true` so even repo admins cannot merge with a red required
|
||||
* check (the Forgejo-era "no server-side protection" invariant is replaced:
|
||||
* on GitHub the rule IS the final defense against every merge path, manual
|
||||
* API merges included).
|
||||
*
|
||||
* Context literal (verified live on the teqo PR #742, 2026-08-19 — same
|
||||
* GitHub infra): the required-check matching uses the CHECK-RUN NAME — the
|
||||
* bare JOB name (`checks`), NOT the UI display `<workflow name> / <job
|
||||
* name>`. The latter never matches and leaves the PR mergeable_state
|
||||
* "blocked" forever.
|
||||
*/
|
||||
|
||||
export const REQUIRED_CHECK_CONTEXT = 'checks'
|
||||
|
||||
/**
|
||||
* GitHub PUT /branches/{branch}/protection replace-semantics payload. Only
|
||||
* `checks` (modern) is sent — `contexts` is legacy-derived and GitHub's API
|
||||
* rejects the two together (422 anyOf). The GET normalization reads the
|
||||
* check-run contexts from `checks`, so drift comparison is unaffected.
|
||||
*/
|
||||
export const DESIRED_RULE = Object.freeze({
|
||||
required_status_checks: {
|
||||
strict: false,
|
||||
checks: [{ context: REQUIRED_CHECK_CONTEXT }],
|
||||
},
|
||||
enforce_admins: true,
|
||||
required_pull_request_reviews: null,
|
||||
restrictions: null,
|
||||
required_linear_history: false,
|
||||
allow_force_pushes: false,
|
||||
allow_deletions: false,
|
||||
block_creations: false,
|
||||
})
|
||||
|
||||
const sameStrings = (have, want) =>
|
||||
have.length === want.length &&
|
||||
want.every((value) => have.includes(value)) &&
|
||||
have.every((value) => want.includes(value))
|
||||
|
||||
/**
|
||||
* True when the existing rule (from `getBranchProtection`, already
|
||||
* normalized) matches the desired rule on EVERY field of DESIRED_RULE —
|
||||
* array fields as sets. Cobre o contrato inteiro: reviews/restrictions sem
|
||||
* exigência e os booleanos (linear history, force pushes, deletions,
|
||||
* creations) — drift em qualquer um deles é reparado.
|
||||
*/
|
||||
export const ruleMatches = (existing) => {
|
||||
if (!existing) return false
|
||||
const wantStatus = DESIRED_RULE.required_status_checks
|
||||
if (existing.enforce_admins !== DESIRED_RULE.enforce_admins) return false
|
||||
if (existing.required_pull_request_reviews !== DESIRED_RULE.required_pull_request_reviews)
|
||||
return false
|
||||
if (existing.restrictions !== DESIRED_RULE.restrictions) return false
|
||||
if (existing.required_linear_history !== DESIRED_RULE.required_linear_history) return false
|
||||
if (existing.allow_force_pushes !== DESIRED_RULE.allow_force_pushes) return false
|
||||
if (existing.allow_deletions !== DESIRED_RULE.allow_deletions) return false
|
||||
if (existing.block_creations !== DESIRED_RULE.block_creations) return false
|
||||
const haveStatus = existing.required_status_checks
|
||||
if (!haveStatus) return false
|
||||
if (haveStatus.strict !== wantStatus.strict) return false
|
||||
return sameStrings(
|
||||
haveStatus.contexts,
|
||||
wantStatus.checks.map((entry) => entry.context),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What to do with the rule: `create` (no rule), `update` (rule exists with
|
||||
* drift), `noop` (already compliant). Idempotent re-runs land on `noop`.
|
||||
*
|
||||
* @param {object|null} existing - normalized rule from the API, or null
|
||||
* @returns {{ action: 'create' | 'update' | 'noop' }}
|
||||
*/
|
||||
export const planBranchProtectionRule = (existing) => {
|
||||
if (ruleMatches(existing)) return { action: 'noop' }
|
||||
return { action: existing ? 'update' : 'create' }
|
||||
}
|
||||
28
scripts/lib/github-pr-flow.mjs
Normal file
28
scripts/lib/github-pr-flow.mjs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Pure decision logic for the GitHub PR auto-merge safety net
|
||||
* (scripts/github-pr-automerge.mjs) and `bun run pr -- --automerge`
|
||||
* (scripts/pr.mjs). Unit-tested in tests/unit/github-pr-flow.test.ts — the
|
||||
* OPS57 (draft veto) / OPS64 (never merge on red CI) pins ported to the
|
||||
* GitHub mechanism.
|
||||
*
|
||||
* GitHub's native auto-merge only fires with the required checks green, so
|
||||
* the decision is reduced to: skip (non-main base, draft veto, already
|
||||
* merged/closed) or arm auto-merge.
|
||||
*
|
||||
* Unlike the teqo OPS71 port, there is NO `cursor/*` mark-ready path: Iara
|
||||
* has no draft-creating flow (`bun run pr` never drafts) — any draft is the
|
||||
* actor's veto, always skipped.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {object|null} pr - normalized PR from github-api (or null)
|
||||
* @returns {{ action: 'skip' | 'enable-auto-merge', reason: string }}
|
||||
*/
|
||||
export const decideAutomergeAction = (pr) => {
|
||||
if (!pr) return { action: 'skip', reason: 'pr-inexistente' }
|
||||
if (pr.merged) return { action: 'skip', reason: 'ja-mergeada' }
|
||||
if (pr.state !== 'OPEN') return { action: 'skip', reason: 'pr-nao-aberta' }
|
||||
if (pr.base?.ref !== 'main') return { action: 'skip', reason: 'base-nao-main' }
|
||||
if (pr.draft) return { action: 'skip', reason: 'draft-veto' }
|
||||
return { action: 'enable-auto-merge', reason: 'ready' }
|
||||
}
|
||||
|
|
@ -4,6 +4,10 @@
|
|||
* permite que os scripts rodem por qualquer caminho (comando `/worktree` do
|
||||
* opencode, `bun run issue`, roteador de terminal) sem depender de exportação
|
||||
* global. Arquivo commitado, sem segredos.
|
||||
*
|
||||
* OPS1: além do tracker (FORGEJO_REPOSITORY), também resolve o repo GitHub
|
||||
* (GITHUB_REPOSITORY) — o default dos scripts de PR/CI (`pr.mjs`,
|
||||
* `configure-branch-protection.mjs`).
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
|
|
@ -52,11 +56,14 @@ export const loadProjectEnv = (from = process.cwd()) => {
|
|||
|
||||
const repo =
|
||||
process.env.FORGEJO_REPOSITORY ?? parsed.FORGEJO_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? 'amana/iara-pwa'
|
||||
const githubRepo =
|
||||
process.env.GITHUB_REPOSITORY ?? parsed.GITHUB_REPOSITORY ?? 'fsolla/iara-pwa'
|
||||
const worktreesRoot = expandHome(
|
||||
process.env.WORKTREES_ROOT ?? parsed.WORKTREES_ROOT ?? `~/.worktrees/${repo.split('/')[1] ?? 'work'}`,
|
||||
)
|
||||
|
||||
if (!process.env.FORGEJO_REPOSITORY) process.env.FORGEJO_REPOSITORY = repo
|
||||
if (!process.env.GITHUB_REPOSITORY) process.env.GITHUB_REPOSITORY = githubRepo
|
||||
if (!process.env.WORKTREES_ROOT) process.env.WORKTREES_ROOT = worktreesRoot
|
||||
|
||||
return { repo, worktreesRoot, envFile: file }
|
||||
|
|
|
|||
|
|
@ -17,8 +17,15 @@ export const PLAN_BRANCH_PREFIX = 'plans/plan-issue'
|
|||
/** Env var the interactive-terminal `worktree()` function sets to request the opencode launch. */
|
||||
export const WORKTREE_TERMINAL_ENV = 'TEQO_WORKTREE_TERMINAL'
|
||||
|
||||
/** Preset model for the opencode launch — change the preset by editing this constant. */
|
||||
export const OPENCODE_PRESET_MODEL = 'deepseek/deepseek-v4-flash'
|
||||
/**
|
||||
* Preset model for the opencode launch — fallback comum aos 3 repos do fluxo
|
||||
* (infra-solla, teqo, iara-pwa). Override sem editar código: global via
|
||||
* `export OPENCODE_WORKTREE_MODEL=…` no profile (~/.bashrc), ou por repo na
|
||||
* chave `OPENCODE_WORKTREE_MODEL` da `.forgejo/worktree.env` (o roteador
|
||||
* `worktree()` exporta a chave antes de rodar este script).
|
||||
*/
|
||||
export const OPENCODE_PRESET_MODEL =
|
||||
process.env.OPENCODE_WORKTREE_MODEL || 'deepseek/deepseek-v4-flash'
|
||||
|
||||
/**
|
||||
* Skill command sent as the launch's initial message per purpose. `next`
|
||||
|
|
|
|||
|
|
@ -1,57 +1,78 @@
|
|||
/**
|
||||
* `bun run pr` — cria PR para `main` (Ready ou draft) e, opcionalmente, aguarda
|
||||
* os checks e mergea por rebase. Equivalente local do `gh pr create` +
|
||||
* `gh pr merge --auto --rebase` do fluxo do teqo, sem depender do `gh`.
|
||||
* `bun run pr` — cria PR para `main` no GitHub (Ready, nunca draft) e,
|
||||
* opcionalmente, arma o auto-merge nativo. Equivalente local do
|
||||
* `gh pr create` + `gh pr merge --auto --rebase` do fluxo do teqo, sem
|
||||
* depender do `gh` (plain Node zero-dep sobre a REST/GraphQL do GitHub).
|
||||
*
|
||||
* bun run pr -- --head <branch> [--title "…"] [--body "…"] [--draft] [--automerge]
|
||||
* bun run pr -- --head <branch> [--title "…"] [--body "…"] [--automerge]
|
||||
*
|
||||
* Defaults: head = branch atual, base = main, title = primeiro commit, body vazio.
|
||||
* Com `--automerge`, espera o status check `checks` ficar green e mergea
|
||||
* (rebase). `--no-pr` não é suportado — criação é o objetivo.
|
||||
* Com `--automerge`, o PR nasce com o auto-merge armado (GraphQL, rebase) —
|
||||
* o servidor mergea quando o required check `checks` ficar verde (sem poll
|
||||
* local; a semântica "cria e mergea quando os checks passarem" é preservada
|
||||
* pela garantia do servidor). `--draft` foi REMOVIDO — PR nunca draft é regra
|
||||
* do repo (estrutural, como no teqo OPS71).
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { dieWithLabel } from './lib/cli.mjs'
|
||||
import { createApi } from './lib/github-api.mjs'
|
||||
import { loadProjectEnv } from './lib/load-project-env.mjs'
|
||||
import { dieAgent, parseArgs } from './lib/agent-forgejo.mjs'
|
||||
|
||||
loadProjectEnv()
|
||||
const forgejoModule = await import('./lib/forgejo-api.mjs');;
|
||||
const { createApi } = forgejoModule
|
||||
|
||||
const die = dieAgent('pr')
|
||||
const api = createApi({})
|
||||
const { flags, positional } = parseArgs(process.argv.slice(2), new Set(['head', 'title', 'body']))
|
||||
|
||||
const die = dieWithLabel('pr')
|
||||
|
||||
const parseArgs = (argv, flagsWithValue) => {
|
||||
const flags = {}
|
||||
const positional = []
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
if (arg.startsWith('--')) {
|
||||
const name = arg.slice(2)
|
||||
if (flagsWithValue.has(name)) {
|
||||
const next = argv[index + 1]
|
||||
flags[name] = typeof next === 'string' && next.startsWith('--') ? undefined : next
|
||||
index += 1
|
||||
} else {
|
||||
flags[name] = true
|
||||
}
|
||||
} else {
|
||||
positional.push(arg)
|
||||
}
|
||||
}
|
||||
return { flags, positional }
|
||||
}
|
||||
|
||||
const currentBranch = () =>
|
||||
execFileSync('git', ['branch', '--show-current'], { encoding: 'utf8' }).trim()
|
||||
const firstCommitSubject = () =>
|
||||
execFileSync('git', ['log', '-1', '--format=%s'], { encoding: 'utf8' }).trim()
|
||||
|
||||
const { flags, positional } = parseArgs(process.argv.slice(2), new Set(['head', 'title', 'body']))
|
||||
|
||||
const head = flags.head ?? positional[0] ?? currentBranch()
|
||||
if (!head) die('Não consegui detectar a branch atual (detached?) — use --head <branch>.')
|
||||
if (head === 'main') die('PR para main não faz sentido a partir de main.')
|
||||
if (flags.draft) die('`--draft` foi removido — PR da Iara é sempre Ready (regra do repo).')
|
||||
|
||||
const title = flags.title ?? firstCommitSubject()
|
||||
const body = flags.body ?? ''
|
||||
|
||||
console.log(`[pr] criando PR ${head} → main: ${title}`)
|
||||
const pr = await api.createPullRequest({
|
||||
head,
|
||||
base: 'main',
|
||||
title,
|
||||
body,
|
||||
draft: Boolean(flags.draft),
|
||||
})
|
||||
console.log(`[pr] PR #${pr.number} criado: ${pr.htmlUrl}${pr.isDraft ? ' (draft)' : ''}`)
|
||||
const pr = await api.createPullRequest({ head, base: 'main', title, body })
|
||||
console.log(`[pr] PR #${pr.number} criado (Ready): ${pr.htmlUrl}`)
|
||||
|
||||
if (flags.automerge) {
|
||||
console.log(`[pr] aguardando checks do PR #${pr.number} e mergeando…`)
|
||||
const merged = await api.autoMerge(pr.number, {
|
||||
log: (line) => console.log(`[pr] ${line}`),
|
||||
})
|
||||
console.log(
|
||||
merged
|
||||
? `[pr] PR #${pr.number} mergeado (rebase)`
|
||||
: `[pr] PR #${pr.number} já mergeado ou fechado`,
|
||||
)
|
||||
try {
|
||||
console.log(`[pr] armando auto-merge do PR #${pr.number} (rebase)…`)
|
||||
await api.enableAutoMerge(pr.nodeId)
|
||||
console.log(
|
||||
`[pr] auto-merge armado — o GitHub mergea quando o required check "checks" ficar verde`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`[pr] falhou ao armar auto-merge: ${error instanceof Error ? error.message : error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
211
tests/unit/github-api.test.ts
Normal file
211
tests/unit/github-api.test.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { createApi } from "../../scripts/lib/github-api.mjs";
|
||||
|
||||
const ok = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
const apiWith = (handler: (url: string, init?: Record<string, unknown>) => Response) =>
|
||||
createApi({
|
||||
token: "t",
|
||||
repository: "fsolla/iara-pwa",
|
||||
retries: 0,
|
||||
fetchImpl: async (url: string | URL, init?: Record<string, unknown>) =>
|
||||
handler(String(url), init),
|
||||
});
|
||||
|
||||
describe("github-api", () => {
|
||||
test("getPullRequest normaliza o shape do PR", async () => {
|
||||
let seen = "";
|
||||
const api = apiWith((url) => {
|
||||
seen = url;
|
||||
return ok({
|
||||
number: 7,
|
||||
title: "t",
|
||||
body: "Closes #1",
|
||||
state: "open",
|
||||
draft: false,
|
||||
mergeable: true,
|
||||
merged_at: null,
|
||||
node_id: "PR_kwDO",
|
||||
head: { ref: "OPS1-x", sha: "abc" },
|
||||
base: { ref: "main" },
|
||||
});
|
||||
});
|
||||
|
||||
const pr = await api.getPullRequest(7);
|
||||
|
||||
expect(seen).toBe("https://api.github.com/repos/fsolla/iara-pwa/pulls/7");
|
||||
expect(pr).toEqual({
|
||||
number: 7,
|
||||
title: "t",
|
||||
body: "Closes #1",
|
||||
state: "OPEN",
|
||||
merged: false,
|
||||
draft: false,
|
||||
mergeable: true,
|
||||
nodeId: "PR_kwDO",
|
||||
head: { ref: "OPS1-x", sha: "abc" },
|
||||
base: { ref: "main" },
|
||||
});
|
||||
});
|
||||
|
||||
test("getPullRequest: estado fechado (caixa baixa, como o GitHub envia) e merged_at", async () => {
|
||||
const api = apiWith(() =>
|
||||
ok({
|
||||
number: 8,
|
||||
title: "t",
|
||||
body: "",
|
||||
state: "closed",
|
||||
draft: false,
|
||||
merged_at: "2026-08-20T00:00:00Z",
|
||||
node_id: "PR_2",
|
||||
head: { ref: "b", sha: "s" },
|
||||
base: { ref: "main" },
|
||||
}),
|
||||
);
|
||||
|
||||
const pr = await api.getPullRequest(8);
|
||||
|
||||
expect(pr?.state).toBe("CLOSED");
|
||||
expect(pr?.merged).toBe(true);
|
||||
});
|
||||
|
||||
test("getPullRequest 404 → null", async () => {
|
||||
const api = apiWith(() => ok({ message: "Not Found" }, 404));
|
||||
expect(await api.getPullRequest(999)).toBeNull();
|
||||
});
|
||||
|
||||
test("createPullRequest POST /pulls e devolve nodeId (para auto-merge)", async () => {
|
||||
let body: Record<string, unknown> = {};
|
||||
const api = apiWith((_url, init) => {
|
||||
if (!init || init.method !== "POST") throw new Error("esperava POST");
|
||||
body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
return ok({ number: 7, title: "t", html_url: "https://github.com/x/7", node_id: "PR_1" });
|
||||
});
|
||||
|
||||
const pr = await api.createPullRequest({ head: "OPS1-x", title: "t", body: "Closes #1" });
|
||||
|
||||
expect(body).toEqual({ head: "OPS1-x", base: "main", title: "t", body: "Closes #1" });
|
||||
expect(pr).toEqual({ number: 7, title: "t", htmlUrl: "https://github.com/x/7", nodeId: "PR_1" });
|
||||
});
|
||||
|
||||
test("getBranchProtection normaliza para drift completo (reviews count 0 → null, booleanos)", async () => {
|
||||
let seen = "";
|
||||
const api = apiWith((url) => {
|
||||
seen = url;
|
||||
return ok({
|
||||
required_status_checks: { strict: false, checks: [{ context: "checks" }] },
|
||||
enforce_admins: { enabled: true },
|
||||
required_pull_request_reviews: {
|
||||
required_approving_review_count: 0,
|
||||
dismiss_stale_reviews: false,
|
||||
require_code_owner_reviews: false,
|
||||
},
|
||||
restrictions: null,
|
||||
required_linear_history: false,
|
||||
allow_force_pushes: { enabled: false },
|
||||
allow_deletions: { enabled: true },
|
||||
block_creations: false,
|
||||
});
|
||||
});
|
||||
|
||||
const rule = await api.getBranchProtection("main");
|
||||
|
||||
expect(seen).toBe("https://api.github.com/repos/fsolla/iara-pwa/branches/main/protection");
|
||||
expect(rule).toEqual({
|
||||
required_status_checks: { strict: false, contexts: ["checks"] },
|
||||
enforce_admins: true,
|
||||
required_pull_request_reviews: null,
|
||||
restrictions: null,
|
||||
required_linear_history: false,
|
||||
allow_force_pushes: false,
|
||||
allow_deletions: true,
|
||||
block_creations: false,
|
||||
});
|
||||
|
||||
const api404 = apiWith(() => ok({}, 404));
|
||||
expect(await api404.getBranchProtection("main")).toBeNull();
|
||||
});
|
||||
|
||||
test("enableAutoMerge envia mutation GraphQL com mergeMethod REBASE", async () => {
|
||||
let payload: { query?: string; variables?: Record<string, unknown> } = {};
|
||||
const api = apiWith((url, init) => {
|
||||
if (!url.includes("graphql")) throw new Error(`esperava GraphQL, vi ${url}`);
|
||||
payload = JSON.parse(String(init?.body)) as { query?: string; variables?: Record<string, unknown> };
|
||||
return ok({ data: { enablePullRequestAutoMerge: { pullRequest: { number: 7 } } } });
|
||||
});
|
||||
|
||||
await api.enableAutoMerge("PR_1");
|
||||
|
||||
expect(payload?.query).toContain("enablePullRequestAutoMerge");
|
||||
expect(payload?.variables).toEqual({ id: "PR_1", mergeMethod: "REBASE" });
|
||||
});
|
||||
|
||||
test("enableAutoMerge propaga erro do GraphQL (exit 1 no CLI)", async () => {
|
||||
const api = apiWith(() => ok({ errors: [{ message: "auto-merge disabled" }] }));
|
||||
await expect(api.enableAutoMerge("PR_1")).rejects.toThrow("auto-merge disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("github-api retry", () => {
|
||||
const retryApi = (handler: (url: string, init?: Record<string, unknown>) => Response | never) =>
|
||||
createApi({
|
||||
token: "t",
|
||||
repository: "fsolla/iara-pwa",
|
||||
retries: 2,
|
||||
backoffMs: 0,
|
||||
jitter: false,
|
||||
sleepImpl: async () => {},
|
||||
fetchImpl: async (url: string | URL, init?: Record<string, unknown>) =>
|
||||
handler(String(url), init),
|
||||
});
|
||||
|
||||
test("GET 5xx (503) retrya até sucesso", async () => {
|
||||
let calls = 0;
|
||||
const api = retryApi(() => {
|
||||
calls += 1;
|
||||
if (calls === 1) return ok({ message: "Service Unavailable" }, 503);
|
||||
return ok({
|
||||
number: 7,
|
||||
title: "t",
|
||||
body: "",
|
||||
state: "open",
|
||||
draft: false,
|
||||
merged_at: null,
|
||||
node_id: "PR_1",
|
||||
head: { ref: "b", sha: "s" },
|
||||
base: { ref: "main" },
|
||||
});
|
||||
});
|
||||
|
||||
const pr = await api.getPullRequest(7);
|
||||
|
||||
expect(calls).toBe(2);
|
||||
expect(pr?.number).toBe(7);
|
||||
});
|
||||
|
||||
test("escrita com 5xx não retrya (fail-closed — servidor respondeu)", async () => {
|
||||
let calls = 0;
|
||||
const api = retryApi(() => {
|
||||
calls += 1;
|
||||
return ok({ message: "boom" }, 503);
|
||||
});
|
||||
|
||||
await expect(api.createPullRequest({ head: "x", title: "t", body: "b" })).rejects.toThrow();
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
test("rejeição de rede retrya em qualquer método (não chegou ao servidor)", async () => {
|
||||
let calls = 0;
|
||||
const api = retryApi(() => {
|
||||
calls += 1;
|
||||
throw new Error("ECONNRESET");
|
||||
});
|
||||
|
||||
await expect(api.getPullRequest(1)).rejects.toThrow("ECONNRESET");
|
||||
expect(calls).toBe(3);
|
||||
});
|
||||
});
|
||||
81
tests/unit/github-branch-protection.test.ts
Normal file
81
tests/unit/github-branch-protection.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
DESIRED_RULE,
|
||||
REQUIRED_CHECK_CONTEXT,
|
||||
planBranchProtectionRule,
|
||||
ruleMatches,
|
||||
} from "../../scripts/lib/github-branch-protection.mjs";
|
||||
|
||||
const compliant = () => ({
|
||||
required_status_checks: { strict: false, contexts: [REQUIRED_CHECK_CONTEXT] },
|
||||
enforce_admins: true,
|
||||
required_pull_request_reviews: null,
|
||||
restrictions: null,
|
||||
required_linear_history: false,
|
||||
allow_force_pushes: false,
|
||||
allow_deletions: false,
|
||||
block_creations: false,
|
||||
});
|
||||
|
||||
describe("github-branch-protection", () => {
|
||||
test("REQUIRED_CHECK_CONTEXT é o literal do check-run (nome do job `checks`)", () => {
|
||||
expect(REQUIRED_CHECK_CONTEXT).toBe("checks");
|
||||
});
|
||||
|
||||
test("ruleMatches: regra conforme → true", () => {
|
||||
expect(ruleMatches(compliant())).toBe(true);
|
||||
});
|
||||
|
||||
test("ruleMatches: sem regra → false", () => {
|
||||
expect(ruleMatches(null)).toBe(false);
|
||||
});
|
||||
|
||||
test("ruleMatches: enforce_admins false → false", () => {
|
||||
expect(ruleMatches({ ...compliant(), enforce_admins: false })).toBe(false);
|
||||
});
|
||||
|
||||
test("ruleMatches: context extra → false (drift)", () => {
|
||||
expect(
|
||||
ruleMatches({
|
||||
...compliant(),
|
||||
required_status_checks: { strict: false, contexts: [REQUIRED_CHECK_CONTEXT, "outro"] },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("ruleMatches: strict true → false", () => {
|
||||
expect(
|
||||
ruleMatches({
|
||||
...compliant(),
|
||||
required_status_checks: { strict: true, contexts: [REQUIRED_CHECK_CONTEXT] },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("ruleMatches: drift em allow_deletions / restrictions é detectado", () => {
|
||||
expect(ruleMatches({ ...compliant(), allow_deletions: true })).toBe(false);
|
||||
expect(
|
||||
ruleMatches({ ...compliant(), restrictions: { users: [], teams: [], apps: [] } }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("plan: sem regra → create; conforme → noop; drift → update", () => {
|
||||
expect(planBranchProtectionRule(null)).toEqual({ action: "create" });
|
||||
expect(planBranchProtectionRule(compliant())).toEqual({ action: "noop" });
|
||||
expect(
|
||||
planBranchProtectionRule({ ...compliant(), required_status_checks: { strict: false, contexts: [] } }),
|
||||
).toEqual({ action: "update" });
|
||||
expect(planBranchProtectionRule({ ...compliant(), allow_force_pushes: true })).toEqual({
|
||||
action: "update",
|
||||
});
|
||||
});
|
||||
|
||||
test("DESIRED_RULE usa apenas `checks` moderno (sem `contexts` legacy)", () => {
|
||||
expect(DESIRED_RULE.required_status_checks.checks).toEqual([
|
||||
{ context: REQUIRED_CHECK_CONTEXT },
|
||||
]);
|
||||
expect("contexts" in DESIRED_RULE.required_status_checks).toBe(false);
|
||||
expect(DESIRED_RULE.enforce_admins).toBe(true);
|
||||
expect(DESIRED_RULE.required_pull_request_reviews).toBeNull();
|
||||
});
|
||||
});
|
||||
54
tests/unit/github-pr-flow.test.ts
Normal file
54
tests/unit/github-pr-flow.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { decideAutomergeAction } from "../../scripts/lib/github-pr-flow.mjs";
|
||||
|
||||
const pr = (overrides: Record<string, unknown> = {}) => ({
|
||||
number: 7,
|
||||
title: "t",
|
||||
body: "",
|
||||
state: "OPEN",
|
||||
merged: false,
|
||||
draft: false,
|
||||
mergeable: true,
|
||||
nodeId: "PR_1",
|
||||
head: { ref: "OPS1-x", sha: "abc" },
|
||||
base: { ref: "main" },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("github-pr-flow", () => {
|
||||
test("skip: PR inexistente", () => {
|
||||
expect(decideAutomergeAction(null)).toEqual({ action: "skip", reason: "pr-inexistente" });
|
||||
});
|
||||
|
||||
test("skip: já mergeada", () => {
|
||||
expect(decideAutomergeAction(pr({ merged: true }))).toEqual({
|
||||
action: "skip",
|
||||
reason: "ja-mergeada",
|
||||
});
|
||||
});
|
||||
|
||||
test("skip: PR fechada", () => {
|
||||
expect(decideAutomergeAction(pr({ state: "CLOSED" }))).toEqual({
|
||||
action: "skip",
|
||||
reason: "pr-nao-aberta",
|
||||
});
|
||||
});
|
||||
|
||||
test("skip: base não é main", () => {
|
||||
expect(decideAutomergeAction(pr({ base: { ref: "release" } }))).toEqual({
|
||||
action: "skip",
|
||||
reason: "base-nao-main",
|
||||
});
|
||||
});
|
||||
|
||||
test("skip: draft é veto do ator (qualquer branch)", () => {
|
||||
expect(decideAutomergeAction(pr({ draft: true }))).toEqual({
|
||||
action: "skip",
|
||||
reason: "draft-veto",
|
||||
});
|
||||
});
|
||||
|
||||
test("enable-auto-merge: PR open, não-draft, base main", () => {
|
||||
expect(decideAutomergeAction(pr())).toEqual({ action: "enable-auto-merge", reason: "ready" });
|
||||
});
|
||||
});
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"allowJs": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
|
|
|
|||
Loading…
Reference in a new issue