chore(worktree): preset model do launcher via OPENCODE_WORKTREE_MODEL — fallback comum deepseek-v4-flash #22

Open
fsolla wants to merge 6 commits from chore/worktree-preset-model-env into main
27 changed files with 1725 additions and 52 deletions

View file

@ -1,2 +1,3 @@
FORGEJO_REPOSITORY=amana/iara-pwa
GITHUB_REPOSITORY=fsolla/iara-pwa
WORKTREES_ROOT=~/.worktrees/iara

View 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
View 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
View 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"

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

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

View file

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

View 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.

View file

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

View 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.

View 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).

View file

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

View file

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

View 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
View 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"

View file

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

View 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
View 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
}

View 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' }
}

View 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' }
}

View file

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

View 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`

View file

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

View 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);
});
});

View 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();
});
});

View 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" });
});
});

View file

@ -13,6 +13,7 @@
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"allowJs": true,
/* Linting */
"noUnusedLocals": true,