OPS50: port CI e scripts de agentes do GitHub para o Forgejo

Workflows nativos em .forgejo/workflows/ (ci, ci-pr, agent-pool,
automerge, issue-transition, plan-issue, vercel-promote, archive),
removendo .github/workflows e .github/actions. Runner Forgejo v13 com
services Postgres por porta + DB_HOST via gateway do bridge. Libs de
agente sobre o novo cliente forgejo-api.mjs (zero dependências,
GITHUB_SERVER_URL/FORGEJO_API_URL), helpers de workflow plain-Node.
pnpm issue corrigido (await em função sync) e validado ao vivo. Testes:
forgejoApi.unit.spec.ts novo, pin do matrix do ci.yml atualizado.

Closes #1 (parte 1 — docs/issues/pool seguem na parte 2)
This commit is contained in:
Francisco Solla 2026-08-16 23:32:20 -03:00
parent 94204cfb5e
commit f20305759a
39 changed files with 1195 additions and 658 deletions

View file

@ -8,9 +8,12 @@
#
# Secrets:
# CURSOR_API_KEY — Cursor Cloud Agents API key
# POOL_GITHUB_TOKEN — PAT with actions:write + issues:write (required to read/
# write repo Actions variables; default GITHUB_TOKEN often 403s — mirror of
# the old PROMOTE_GITHUB_TOKEN pattern)
# FORGEJO_API_TOKEN — optional PAT; the native GITHUB_TOKEN (full repo
# access) covers issue/label/contents ops inside Forgejo Actions
#
# Forgejo port (OPS50): no `concurrency:`/`permissions:` blocks; pool state
# lives in pool-state.json on the `pool-state` branch (variables API is
# GET-only here), read/written by scripts/lib/agent-pool-forgejo.mjs.
name: Agent pool supervisor
on:
@ -37,16 +40,6 @@ on:
required: false
type: string
concurrency:
group: agent-pool
cancel-in-progress: false
permissions:
actions: write
contents: read
issues: write
pull-requests: read
jobs:
tick:
runs-on: ubuntu-latest
@ -62,7 +55,7 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: agent:pool workflow
env:
GH_TOKEN: ${{ secrets.POOL_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
ACTION: ${{ inputs.action }}
MAX_SLOTS: ${{ inputs.maxSlots }}

View file

@ -0,0 +1,34 @@
# Safety net: agent PRs (cursor/* → main) must be Ready + auto-merge.
# Covers Cloud ManagePullRequest defaulting to draft and agents that forget
# to arm the merge. No skill opt-out (engineering-audit included).
#
# Forgejo port (OPS50): Forgejo's API has no auto-merge scheduling, so the
# workflow runs scripts/forgejo-pr-automerge.mjs (plain Node): marks draft →
# ready, waits for the commit statuses (the branch-protection `checks` gate),
# then merges by rebase. Same semantics as `gh pr merge --auto --rebase`,
# implemented as poll + merge. GitHub's cancel-workflow-on-failure action was
# dropped (no GITHUB_RUN_ID plumbing on Forgejo).
name: Agent PR Ready + auto-merge
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
branches: [main]
jobs:
ready-automerge:
if: >-
github.event.pull_request.state == 'open' &&
startsWith(github.event.pull_request.head.ref, 'cursor/')
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@v5
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Mark Ready and merge when checks pass
run: node scripts/forgejo-pr-automerge.mjs --pr "${{ github.event.pull_request.number }}"

View file

@ -12,23 +12,19 @@
# High-risk diffs (schema, lockfile, classifiers — see
# scripts/lib/test-affected-core.mjs) force full unit/int/e2e. Push to main
# always runs the full suite in ci.yml (no affected skips).
#
# Forgejo port (OPS50): no `concurrency:`/`permissions:` (unsupported /
# native token); Postgres services publish per-job ports (5433 int, 5434
# build, 5435/5436 e2e shards) and jobs reach them through the bridge gateway
# (DB_HOST from /proc/net/route). The GitHub fail-fast action was dropped
# (Forgejo has no cancel-run plumbing); job timeouts cover that role.
name: CI (PR)
on:
pull_request:
branches: [main]
concurrency:
group: ci-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
actions: write
pull-requests: read
env:
DATABASE_URL: postgresql://teqo:teqo@localhost:5432/teqo_test
PAYLOAD_SECRET: test-only-secret-not-used-in-production
NEXT_PUBLIC_SITE_URL: http://localhost:3000
@ -71,9 +67,6 @@ jobs:
# waste). Single source: e2eShardConfig in scripts/lib/test-affected-core.mjs.
echo "e2e_matrix=$(echo "$result" | jq -c .e2e_shards.matrix)" >> "$GITHUB_OUTPUT"
echo "e2e_shards=$(echo "$result" | jq -r .e2e_shards.total)" >> "$GITHUB_OUTPUT"
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
lint:
runs-on: ubuntu-latest
@ -89,9 +82,6 @@ jobs:
- name: Check test file locations
run: node scripts/check-test-locations.mjs
- run: pnpm lint
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
format:
runs-on: ubuntu-latest
@ -105,9 +95,6 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm format:check
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
typecheck:
runs-on: ubuntu-latest
@ -123,9 +110,6 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
knip:
runs-on: ubuntu-latest
@ -141,9 +125,6 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm knip
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
cycles:
runs-on: ubuntu-latest
@ -159,9 +140,6 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm check:cycles
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
unit:
runs-on: ubuntu-latest
@ -186,9 +164,6 @@ jobs:
- name: Unit tests (changed since PR base)
if: needs.scope.outputs.test_mode == 'changed'
run: pnpm test:unit -- --changed origin/${{ github.base_ref }} --passWithNoTests
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
int:
runs-on: ubuntu-latest
@ -212,7 +187,7 @@ jobs:
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5432:5432
- 5433:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
@ -228,6 +203,11 @@ jobs:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Prepare database URL (service via bridge gateway)
run: |
set -euo pipefail
DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route)
echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:5433/teqo_test" >> "$GITHUB_ENV"
- run: pnpm migrate
- run: pnpm db:seed:minimal
@ -238,9 +218,6 @@ jobs:
- name: Integration tests (changed since PR base)
if: needs.scope.outputs.test_mode == 'changed'
run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
build:
runs-on: ubuntu-latest
@ -264,7 +241,7 @@ jobs:
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5432:5432
- 5434:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
@ -278,11 +255,13 @@ jobs:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Prepare database URL (service via bridge gateway)
run: |
set -euo pipefail
DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route)
echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:5434/teqo_test" >> "$GITHUB_ENV"
- name: Build (against the local service database)
run: pnpm build
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
e2e:
runs-on: ubuntu-latest
@ -315,7 +294,7 @@ jobs:
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5432:5432
- ${{ matrix.shard == 1 && '5435' || '5436' }}:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
@ -331,8 +310,21 @@ jobs:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-${{ runner.os }}-
- name: Install Playwright chromium
run: pnpm exec playwright install --with-deps chromium
- name: Prepare database URL (service via bridge gateway)
run: |
set -euo pipefail
DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route)
DB_PORT=${{ matrix.shard == 1 && '5435' || '5436' }}
echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" >> "$GITHUB_ENV"
- run: pnpm migrate
- run: pnpm db:seed:minimal
- name: Build for e2e
@ -349,9 +341,6 @@ jobs:
- name: E2E tests (manifest-selected specs)
if: needs.scope.outputs.e2e_mode == 'selected'
run: pnpm test:e2e -- ${{ needs.scope.outputs.e2e_specs }}
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
docs-guards:
runs-on: ubuntu-latest
@ -371,9 +360,6 @@ jobs:
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
run: node scripts/check-docs-conflict-markers.mjs
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
plans-only-closes:
runs-on: ubuntu-latest
@ -387,9 +373,6 @@ jobs:
GITHUB_BASE_REF: ${{ github.base_ref }}
PR_BODY: ${{ github.event.pull_request.body }}
run: node scripts/check-plans-only-pr-closes.mjs
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
checks:
name: checks

View file

@ -2,6 +2,16 @@
# When green, deploys production via `vercel deploy --prod` (Vercel Git builds
# are ignored — see scripts/vercel-ignore-build.sh). If HEAD moved while this
# run was in flight, `requeue` dispatches another run for the new tip.
#
# Forgejo port (OPS50): Postgres services publish to per-job HOST ports on the
# runner machine (act/forgejo runner semantics — the canonical 5432 is the
# workstation's own dev database), and jobs reach them through the bridge
# gateway: DB_HOST = gateway IP from /proc/net/route. No `permissions:`
# (Forgejo issues one native GITHUB_TOKEN) and no `concurrency:` (unsupported).
# `gh workflow run` requeues became `node scripts/forgejo-dispatch.mjs`.
# The GitHub fail-fast action was dropped (Forgejo has no cancel-run plumbing);
# job timeouts cover that role.
name: CI
on:
@ -9,16 +19,7 @@ on:
branches: [main]
workflow_dispatch:
concurrency:
group: main-verify
cancel-in-progress: false
permissions:
contents: read
actions: write
env:
DATABASE_URL: postgresql://teqo:teqo@localhost:5432/teqo_test
PAYLOAD_SECRET: test-only-secret-not-used-in-production
NEXT_PUBLIC_SITE_URL: http://localhost:3000
@ -41,9 +42,6 @@ jobs:
- run: pnpm knip
- run: pnpm check:cycles
- run: pnpm test:unit
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
int:
runs-on: ubuntu-latest
@ -56,7 +54,7 @@ jobs:
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5432:5432
- 5433:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
@ -70,12 +68,14 @@ jobs:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Prepare database URL (service via bridge gateway)
run: |
set -euo pipefail
DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route)
echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:5433/teqo_test" >> "$GITHUB_ENV"
- run: pnpm migrate
- run: pnpm db:seed:minimal
- run: pnpm test:int
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
build:
runs-on: ubuntu-latest
@ -88,7 +88,7 @@ jobs:
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5432:5432
- 5434:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
@ -102,16 +102,18 @@ jobs:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build (against the local service database)
- name: Prepare database URL (service via bridge gateway)
run: |
set -euo pipefail
DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route)
echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:5434/teqo_test" >> "$GITHUB_ENV"
- name: Build (against the service database)
run: pnpm build
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
e2e:
runs-on: ubuntu-latest
timeout-minutes: 40
# Main always runs the full suite: split it across 2 runners (OPS34), each
# Main always runs the full suite: split it across 2 jobs (OPS34), each
# building its own `.next/e2e` (measured 2026-08-10: build 172s + tests
# 196s + ~54s fixed → ≈ 5.5 min per shard vs 435s serial). `fail-fast:
# false` so one shard's failure keeps the other's diagnostics. Keep the
@ -132,7 +134,7 @@ jobs:
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5432:5432
- ${{ matrix.shard == 1 && '5435' || '5436' }}:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
@ -146,8 +148,21 @@ jobs:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-${{ runner.os }}-
- name: Install Playwright chromium
run: pnpm exec playwright install --with-deps chromium
- name: Prepare database URL (service via bridge gateway)
run: |
set -euo pipefail
DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route)
DB_PORT=${{ matrix.shard == 1 && '5435' || '5436' }}
echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" >> "$GITHUB_ENV"
- run: pnpm migrate
- run: pnpm db:seed:minimal
- name: Build for e2e
@ -158,9 +173,6 @@ jobs:
# No `--` separator: pnpm would forward it verbatim to playwright and
# the flag would be parsed as a file pattern ("No tests found").
run: pnpm test:e2e --shard=${{ matrix.shard }}/2
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
checks:
name: checks
@ -289,17 +301,12 @@ jobs:
needs: [checks, deploy]
if: always()
timeout-minutes: 40
permissions:
actions: write
contents: read
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Re-dispatch if main advanced or deploy deferred
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
DEPLOY_RESULT: ${{ needs.deploy.result }}
DEPLOY_COOLDOWN: ${{ needs.deploy.outputs.cooldown }}
DEPLOY_WAIT_SECONDS: ${{ needs.deploy.outputs.wait_seconds }}
@ -309,7 +316,7 @@ jobs:
tip=$(git rev-parse origin/main)
if [ "$tip" != "$GITHUB_SHA" ]; then
echo "origin/main moved to $tip (verified $GITHUB_SHA) — requeue ci.yml"
gh workflow run ci.yml --ref main
node scripts/forgejo-dispatch.mjs ci.yml main
exit 0
fi
if [ "${DEPLOY_RESULT:-}" != "success" ]; then
@ -327,7 +334,7 @@ jobs:
else
echo "Cooldown elapsed for $GITHUB_SHA — requeue ci.yml"
fi
gh workflow run ci.yml --ref main
node scripts/forgejo-dispatch.mjs ci.yml main
exit 0
fi
echo "HEAD still $GITHUB_SHA — idle"

View file

@ -0,0 +1,23 @@
# Main merge flips claimed issues to done + in-prod.
# Forgejo port (OPS50): the gh loop became scripts/forgejo-issue-transition.mjs
# (plain Node, reads the PR body from the API itself).
name: Main merge flips claimed issues to done + in-prod
on:
pull_request:
types: [closed]
jobs:
issue-done:
if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
# setup-node@v5 defaults to true and looks for pnpm before pnpm/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

@ -4,6 +4,7 @@
#
# Does NOT run on open PRs — only after merge — so the claim race stays closed.
# Job is gated on Related # in the body so non-plan merges skip checkout/Node.
# Forgejo port (OPS50): script reads the PR via the Forgejo API (plain Node).
name: Promote plan-issue Related on main merge
on:
@ -11,11 +12,6 @@ on:
types: [closed]
branches: [main]
permissions:
issues: write
pull-requests: read
contents: read
jobs:
plan-issue-ready:
if: >-
@ -27,13 +23,9 @@ jobs:
)
runs-on: ubuntu-latest
timeout-minutes: 5
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_BODY: ${{ github.event.pull_request.body }}
steps:
- uses: actions/checkout@v5
# Script is plain Node (gh CLI, stdlib) — no pnpm. Disable
# Script is plain Node (stdlib) — no pnpm. Disable
# package-manager-cache: setup-node@v5 defaults to true and looks for
# pnpm (packageManager in package.json) before pnpm/action-setup.
- uses: actions/setup-node@v5
@ -41,4 +33,4 @@ jobs:
node-version: 24
package-manager-cache: false
- name: Promote Related #N awaiting plan → ready
run: node scripts/agent-promote-related-on-merge.mjs --pr "$PR_NUMBER"
run: node scripts/agent-promote-related-on-merge.mjs --pr "${{ github.event.pull_request.number }}"

View file

@ -1,39 +0,0 @@
name: Spike — forgejo-runner v13 node24 actions + gateway
on:
push:
branches: [OPS50-spike-services]
jobs:
probe:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5433:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v5
- name: Probe gateway
run: |
set -uo pipefail
GW=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route)
echo "gateway=$GW"
for i in $(seq 1 15); do
if (echo > /dev/tcp/"$GW"/5433) 2>/dev/null; then
echo "REACHABLE: gateway:$GW:5433"
exit 0
fi
sleep 2
done
echo "FINAL: gateway $GW:5433 unreachable"
exit 1

View file

@ -12,9 +12,6 @@ on:
type: string
default: ''
permissions:
contents: read
jobs:
promote:
runs-on: ubuntu-latest

View file

@ -1,7 +0,0 @@
name: Cancel workflow on failure
description: Cancels the entire workflow run when invoked after a job failure (fail-fast).
runs:
using: composite
steps:
- name: Cancel workflow run
uses: styfle/cancel-workflow-action@0.13.1

View file

@ -1,44 +0,0 @@
# Safety net: agent PRs (cursor/* → main) must be Ready + auto-merge.
# Covers Cloud ManagePullRequest defaulting to draft and agents that forget
# `gh pr merge --auto --rebase`. No skill opt-out (engineering-audit included).
name: Agent 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:
if: >-
github.event.pull_request.state == 'open' &&
startsWith(github.event.pull_request.head.ref, 'cursor/')
runs-on: ubuntu-latest
timeout-minutes: 5
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- name: Mark Ready and enable auto-merge
run: |
set -euo pipefail
is_draft="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)"
if [ "$is_draft" = "true" ]; then
echo "PR #$PR_NUMBER is draft — marking Ready"
gh pr ready "$PR_NUMBER" --repo "$GITHUB_REPOSITORY"
else
echo "PR #$PR_NUMBER already Ready"
fi
# Arm auto-merge (idempotent when already enabled; fails only on hard blocks).
if gh pr merge --auto --rebase "$PR_NUMBER" --repo "$GITHUB_REPOSITORY"; then
echo "Auto-merge armed for PR #$PR_NUMBER"
else
# Already merged / already armed / race — succeed only if request exists or merged.
armed="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json autoMergeRequest,state --jq '(.autoMergeRequest != null) or (.state == "MERGED")')"
echo "gh pr merge --auto --rebase exited non-zero; armed_or_merged=$armed"
[ "$armed" = "true" ]
fi

View file

@ -1,28 +0,0 @@
name: Main merge flips claimed issues to done + in-prod
on:
pull_request:
types: [closed]
permissions:
issues: write
pull-requests: read
contents: read
jobs:
issue-done:
if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- name: Flip Closes/Fixes #N issues to done and in-prod
run: |
body="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json body --jq .body)"
numbers="$(printf '%s' "$body" | grep -oiE '(closes|fixes) #[0-9]+' | grep -oE '[0-9]+' | sort -u)"
for n in $numbers; do
gh issue edit "$n" --repo "$GITHUB_REPOSITORY" --remove-label in-progress --add-label done --add-label in-prod || true
gh issue comment "$n" --repo "$GITHUB_REPOSITORY" \
--body "Merged em main via PR #${PR_NUMBER} — \`done\` + \`in-prod\`. Deploy de produção segue o verificador \`ci.yml\` (gated)." || true
done

View file

@ -6,6 +6,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-16):** **CAMPAIGN-HOME-RESPONSIVE**: a primeira dobra da home pública agora acompanha as referências do Penpot em 393, 768, 1024, 1280, 1366 e 1920 px. Retrato, aliados, marca, texto, CTAs e prova parlamentar interpolam entre os tamanhos com `clamp()`, `vw` e `dvh`, sem saltos por proporção de tela nem overflow horizontal; texto, ações e condecorações permanecem no fluxo e crescem com os ajustes de espaçamento da WCAG 1.4.12. No desktop, a escala e a posição horizontal das fotos dependem somente da largura: Solla, Lula e Wagner ficam ancorados ao rodapé real do hero até quando o conteúdo aumenta a seção, e um palco centralizado de 1920 px preserva a proximidade entre retrato e texto em monitores ultrawide. O E2E fixa a geometria das seis referências, larguras intermediárias, transições de proporção, desktops baixos, ultrawide e os overrides de acessibilidade; o caso de regressão também foi executado no Firefox. Sem migration, schema ou mudança de conteúdo.
**Recently resolved (2026-08-16):** **OPS50** (parte 1): CI e scripts de agentes portados do GitHub para o Forgejo (`git.solla.dev`, tracker já migrado nas Issues #1–#3), sem nenhuma dependência do GitHub Actions. Os 10 workflows de `.github/workflows/` viraram 8 nativos em `.forgejo/workflows/` (`ci.yml` com deploy gated + requeue por `forgejo-dispatch.mjs`, `ci-pr.yml` com cascade de jobs e checks agregador, `agent-pool.yml`, `agent-pr-ready-automerge.yml`, `issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`, `vercel-promote.yml`, `archive-cursor-agent.yml`) e `.github/workflows/*` + `.github/actions/` foram removidos (cancel-workflow-on-failure não existe no Forgejo). Novo runner: Forgejo runner v13 na workstation (capacity 4), jobs paralelos em serviços Postgres isolados por porta (int 5433, build 5434, e2e shards 5435/5436) com `DB_HOST` calculado do gateway do bridge Docker (byte order corrigida via `strtonum`). Libs de agente: `agent-github.mjs`/`agent-pool-github.mjs` substituídas por `agent-forgejo.mjs` + `agent-pool-forgejo.mjs` sobre o novo cliente zero-dependência `forgejo-api.mjs` (token `FORGEJO_API_TOKEN`/`GITHUB_TOKEN`, base URL `FORGEJO_API_URL`/`GITHUB_SERVER_URL`/default, shapes normalizados ao contrato gh-flavored; novos `isDraft`/`markPullRequestReady`); helpers `forgejo-dispatch.mjs`, `forgejo-pr-automerge.mjs` (poll de checks + ready de draft), `forgejo-issue-transition.mjs`; auto-merge do PR de planos já validado em produção (PR #4). Bug de port corrigido em `pnpm issue` (await em função sync) e validado ao vivo contra o Forgejo. Testes: `forgejoApi.unit.spec.ts` novo (10 testes), pin do matrix do `ci.yml` movido para `.forgejo/workflows/`; tsc/lint/prettier/knip/check:cycles/2283 unit verdes. Pendente (parte 2): migrar as Issues abertas do GitHub, reescrever `.agents/`, `AGENTS.md`, `docs/AGENT-OPS.md` e `docs/roadmap.md`, e passos humanos (secrets no Forgejo, `pnpm configure:branch-protection`, arquivar o repo GitHub).
**Recently resolved (2026-08-15):** **IMG-RESIZE** — otimizador local de imagens para o draft do site de campanha (fotos pesadas travavam o PenPot). `pnpm images:resize` (`scripts/resize-images.mjs`) aceita múltiplos paths (arquivos ou pastas, varridas recursivamente), redimensiona para no máximo 3840px — 2× a tela desktop de 1920 — sem upscaling (`fit: 'inside'`, `withoutEnlargement`), aplica EXIF-rotation, remove metadados e converte para **AVIF** por padrão (formato leve moderno; WebP/JPEG via `--format`, qualidade por formato via `--quality`, `--out` para outra pasta — default `public/`). Saída mantém o basename (`foto.jpg` → `foto.avif`) e recusa colidir com a fonte. Derivação pura em `scripts/lib/imageResize.mjs` (parse fail-closed, alias jpg→jpeg, default de qualidade por formato), unit-testada em `tests/unit/imageResize.unit.spec.ts`. Sem migration/access/Consent/UI/schema.
**Recently resolved (2026-08-15):** **IMG-RESIZE** — otimizador local de imagens para o draft do site de campanha (fotos pesadas travavam o PenPot). `pnpm images:resize` (`scripts/resize-images.mjs`) aceita múltiplos paths (arquivos ou pastas, varridas recursivamente), redimensiona para no máximo 3840px — 2× a tela desktop de 1920 — sem upscaling (`fit: 'inside'`, `withoutEnlargement`), aplica EXIF-rotation, remove metadados e gera **DUAS versões por padrão**: `.avif` (formato leve moderno, para o site) e `.webp` (aceito em mais lugares, incluindo PenPot e navegadores antigos); o conjunto muda via `--format avif,webp` (lista separada por vírgula), qualidade por formato via `--quality`, pasta via `--out` (default `public/`). Saída mantém o basename (`foto.jpg` → `foto.avif` + `foto.webp`) e recusa colidir com a fonte. Derivação pura em `scripts/lib/imageResize.mjs` (parse fail-closed, alias jpg→jpeg, default de qualidade por formato), unit-testada em `tests/unit/imageResize.unit.spec.ts`. Sem migration/access/Consent/UI/schema.

View file

@ -0,0 +1 @@
**Recently resolved (2026-08-16):** **OPS50** (parte 1): CI e scripts de agentes portados do GitHub para o Forgejo (`git.solla.dev`, tracker já migrado nas Issues #1–#3), sem nenhuma dependência do GitHub Actions. Os 10 workflows de `.github/workflows/` viraram 8 nativos em `.forgejo/workflows/` (`ci.yml` com deploy gated + requeue por `forgejo-dispatch.mjs`, `ci-pr.yml` com cascade de jobs e checks agregador, `agent-pool.yml`, `agent-pr-ready-automerge.yml`, `issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`, `vercel-promote.yml`, `archive-cursor-agent.yml`) e `.github/workflows/*` + `.github/actions/` foram removidos (cancel-workflow-on-failure não existe no Forgejo). Novo runner: Forgejo runner v13 na workstation (capacity 4), jobs paralelos em serviços Postgres isolados por porta (int 5433, build 5434, e2e shards 5435/5436) com `DB_HOST` calculado do gateway do bridge Docker (byte order corrigida via `strtonum`). Libs de agente: `agent-github.mjs`/`agent-pool-github.mjs` substituídas por `agent-forgejo.mjs` + `agent-pool-forgejo.mjs` sobre o novo cliente zero-dependência `forgejo-api.mjs` (token `FORGEJO_API_TOKEN`/`GITHUB_TOKEN`, base URL `FORGEJO_API_URL`/`GITHUB_SERVER_URL`/default, shapes normalizados ao contrato gh-flavored; novos `isDraft`/`markPullRequestReady`); helpers `forgejo-dispatch.mjs`, `forgejo-pr-automerge.mjs` (poll de checks + ready de draft), `forgejo-issue-transition.mjs`; auto-merge do PR de planos já validado em produção (PR #4). Bug de port corrigido em `pnpm issue` (await em função sync) e validado ao vivo contra o Forgejo. Testes: `forgejoApi.unit.spec.ts` novo (10 testes), pin do matrix do `ci.yml` movido para `.forgejo/workflows/`; tsc/lint/prettier/knip/check:cycles/2283 unit verdes. Pendente (parte 2): migrar as Issues abertas do GitHub, reescrever `.agents/`, `AGENTS.md`, `docs/AGENT-OPS.md` e `docs/roadmap.md`, e passos humanos (secrets no Forgejo, `pnpm configure:branch-protection`, arquivar o repo GitHub).

View file

@ -0,0 +1,88 @@
# Impl: Migrar o CI e o paradigma de agentes do GitHub para o Forgejo
Status: aprovado
Atualizado em: 2026-08-16
Issue: #1
Intenção: docs/plans/ops50-ci-github-para-forgejo.md
Appetite restante: ~2–3 dias eng
## Leitura da intenção
- **Outcome:** zero dependência do GitHub — CI, Issues, PRs, pool e docs rodando nativamente no Forgejo, sem fallback.
- **O que NÃO negociar:** deploy de produção continua gated pelo CI; runner não é re-registrado sem necessidade; nenhum push re-dispara builds Git da Vercel; uma fonte única de tracker (Forgejo).
- **O que reavaliar:** a hipótese de direção está correta, mas a exploração revelou **3 fatos novos** que mudam a abordagem:
1. **CI do Forgejo está verde de mentira hoje.** O service `postgres:17-alpine` dos jobs falha no runner (`Bind for 0.0.0.0:5432 failed: port is already allocated` — a workstation tem o Postgres canônico na 5432) e o act_runner v0.2.11 **marca o job como success mesmo assim** (prova: run 119 — static/int/build/e2e×2 "passaram" em ~4s cada). O outcome "CI verde no Forgejo" exige corrigir isso e **provar com evidência** (duração/log), não com badge.
2. **`gh` não existe em nenhuma máquina de trabalho** e os workflows chamam `gh` direto (requeue, automerge, labels). A API REST do Forgejo é GitHub-compatível e os scripts já são Node — a porta é direta, sem binário.
3. **A API externa do Forgejo passa pela Cloudflare** (erro 1010 para clientes sem UA de navegador) — o pool (workers Cursor Cloud) precisa de caminho testado, não assumido.
## Abordagem recomendada
```mermaid
flowchart LR
A[workflows .forgejo/] --> B[runner workstation]
B --> C[CI real + service pg na 5433]
D[scripts agent-*] --> E[lib forgejo-api.mjs]
E --> F[API git.solla.dev]
F --> G[Issues/PRs/variables]
H[pool] --> E
```
**Opções consideradas:**
- **A)** `gh` CLI com `GH_HOST=git.solla.dev` — exige instalar o binário em 3 lugares (workstation, workers Cursor Cloud, imagem do runner) e `gh pr merge --auto` não é suportado na API do Forgejo. **Rejeitada.**
- **B)** API REST direta via `fetch` do Node 24, numa lib nova `scripts/lib/forgejo-api.mjs`, mantendo a superfície de comandos (`pnpm agent:*`, `pnpm issue`). Sem dependência de binário, funciona em todo lugar com um token (`FORGEJO_API_TOKEN` ou o `GITHUB_TOKEN` nativo do Forgejo Actions). **Recomendada** — é a recomendação B já validada no gate da intenção.
- **C)** `tea` (CLI do Gitea) — outro binário externo, menos maduro que a API. **Rejeitada.**
### Componentes / mudanças
- **`scripts/lib/forgejo-api.mjs`** (novo): cliente REST mínimo (fetch + headers `Authorization: token` + `User-Agent` explícito para passar o WAF), funções `issueList/get/create/updateLabels/comment`, `prCreate/mergeWhenChecksSucceed`, `workflowDispatch`, `actionsVariables`. Node puro (fetch nativo), sem deps — usável dentro de jobs de workflow sem `pnpm install`.
- **`scripts/lib/agent-github.mjs`** (substituído): as funções `gh`/`ghJson` morrem; os callers passam a usar `forgejo-api.mjs`. Os helpers de frontmatter/labels/prioridade permanecem (são agnósticos de host). URLs de output viram `git.solla.dev/fsolla/teqo`.
- **`scripts/agent-*.mjs` + `scripts/issue.mjs`**: mesma superfície (`register|claim|ready|prioritize|status|file-miss|pool`), backend retargetado. O guard de "só Issues `blocked`+plano" etc. não muda.
- **`scripts/agent-pool-*.mjs`**: pool passa a listar/claimar no Forgejo; estado do pool em **Actions variables do Forgejo** (endpoint existe em Forgejo 9 — confirmar no spike); `POOL_GITHUB_TOKEN` → `POOL_FORGEJO_TOKEN` (PAT criado por humano).
- **`.github/workflows/*.yml` → `.forgejo/workflows/*`** (8 arquivos + `.github/actions/cancel-workflow-on-failure` → `.forgejo/actions/`):
- service postgres **por job em porta própria** (host do runner tem a 5432 canônica ocupada; services publicam na porta do host): int=5433, build=5434, e2e×2=5435/5436 + `DATABASE_URL` dos jobs correspondente — elimina colisão e permite concorrência;
- **capacidade do runner sobe de 1 para 4** (`capacity: 4` no `~/.act_runner/config.yaml` da workstation + `systemctl --user restart act-runner` — configuração, não re-registro; workstation tem 16 cores/61GB, folga medida);
- cache de browsers do Playwright via `actions/cache` (cache server do runner já habilitado);
- chamadas `gh …` dentro dos jobs substituídas por scripts Node da lib (requeue via `workflowDispatch`, automerge via poll+merge, labels/comments via API);
- `ci.yml` mantém o deploy Vercel gated (até o cutover de hospedagem, fora deste item);
- verificação de honestidade: o job `checks` passa a exigir evidência de execução real (os jobs escrevem duração num output lido pelo checks; spike define o mecanismo mais simples).
- **Tracker:** migrar as **11 Issues abertas do GitHub** para o Forgejo (mesmo frontmatter + labels), via script one-shot (lê API pública do GitHub, cria no Forgejo). Histórico GitHub congela (arquivar o repo é passo humano final).
- **Docs/skills/rules (59 arquivos em `.agents/` + `AGENTS.md` + `docs/AGENT-OPS.md` + `docs/roadmap.md`):** reescrever referências — `gh pr …` → scripts equivalentes, github.com/fsolla/teqo → git.solla.dev/fsolla/teqo, "GitHub Issues" → "Issues do Forgejo". Nomes de comandos `agent:*` preservados para minimizar diff.
- **`scripts/configure-branch-protection.mjs`**: portado para o Forgejo (POST `/branch_protections`, required status checks com os contextos reais observados após P1). Execução é passo humano (`pnpm configure:branch-protection`).
- **Secrets (humano):** criar no Forgejo: `VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID`, `CURSOR_API_KEY`, `POOL_FORGEJO_TOKEN` (PAT com escrita no repo). O `GITHUB_TOKEN` nativo do Forgejo cobre o resto.
- **Migration:** sem migration (não toca schema).
- **Access / Consent:** N/A.
- **UI:** A — sem UI.
## Fases verificáveis
1. **Spike (probe, sem merge):** service postgres na 5433 alcançável do job? POST externo em git.solla.dev passa com UA (WAF)? Endpoints Forgejo 9: actions variables, merge com `merge_when_checks_succeed` (ou poll+merge), workflow dispatches, status checks observados.
2. **Workflows nativos** — `.forgejo/workflows` completos; PR do próprio OPS50 roda e **prova** CI verde com evidência (duração real dos jobs, e2e de verdade).
3. **Lib + scripts do agente** — `forgejo-api.mjs` + retarget de `agent-*`/`issue` + unit tests (request-building/labels/state com fetch injetado).
4. **Pool** — `agent-pool-*` no Forgejo (state em variables), prompt/env dos workers atualizado.
5. **Tracker + docs** — migração das 11 Issues abertas, reescrita dos 59 arquivos + docs canônicos; `infra-solla/STATE.md` anotado (repo separado).
6. **Cutover + hardening** — branch protection aplicado (humano), secrets (humano), pool stop→start, arquivar GitHub (humano), checklist final (ciclo completo de Issue no Forgejo: register→claim→PR→merge→done).
## Rabbit holes / Não escopo (engenharia)
- Migrar histórico completo de Issues/PRs do GitHub — fora (congela).
- Trocar o runner act_runner → forgejo-runner nativo — fora (o bug de false-green é contornado com porta + evidência; upgrade vira item futuro se aparecer outro).
- Escalar além de capacity 4 / registrar runners extras — fora (capacidade 4 cobre o DAG atual; se o e2e crescer, item futuro).
- Cutover de hospedagem Vercel→homeserver — OPS51/OPS52/infra §7.
- Cloudflare WAF: se o spike provar bloqueio a clientes externos legítimos, o fix é regra WAF na Cloudflare (passo humano, creds em infra-solla) — registrado como débito se necessário.
## Riscos e mitigação
- **WAF bloqueia workers Cursor Cloud** (datacenter IPs). Mitigação: UA explícito no cliente; spike testa de fora; fallback = regra WAF `/api/v1/*` (humano) antes de P4.
- **Service port mapeia para o host do runner** (semântica act_runner): portas por job (5433–5436) eliminam colisão, mas a verificação do spike é pré-condição de P2; fallback = subir o postgres como container via step (`docker.sock` já é bindado no job).
- **Pool em transição:** entre o merge e o archive do GitHub, o tracker muda de lugar — durante a transição **não registrar Issues novas** (humano avisado no fechamento); o supervisor agenda continua no Forgejo a cada 10 min com o workflow novo após o merge.
- **Deploy de prod vermelho até secrets no Forgejo** — o job já fail-closed em credencial ausente (requeue retorna quando os secrets entrarem).
- **Capacity 4 com pico de memória:** 4 jobs pesados simultâneos ≈ 24–28GB — cabe (35Gi livres medidos); o spike mede um run real antes do cutover.
- **59 arquivos de docs reescritos** — grep de saída (`gh pr`, `github.com/fsolla`, `GitHub Issues`) no gate para provar zero resíduo; knip/cycles cobrem imports.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (workflows nativos, scripts operacionais, docs Forgejo, secrets + branch protection, issues no Forgejo, deploy gated, runner intacto)
- [ ] Invariantes AGENTS/engineering-standards (sem segredos no repo; commits sem secrets; sem push direto fora do `pnpm push`)
- [ ] Testes unit do lib `forgejo-api` (request/labels/state) onde os scripts mudam
- [ ] Evidência de CI honesto: duração real dos jobs no run do próprio PR

View file

@ -19,31 +19,19 @@ import {
claimBriefLines,
claimIssue,
dieAgent,
ghJson,
issuesById,
parseArgs,
} from './lib/agent-github.mjs'
} from './lib/agent-forgejo.mjs'
import { forgejoApi as api } from './lib/forgejo-api.mjs'
const die = dieAgent('claim')
const { flags } = parseArgs(process.argv.slice(2), new Set(['issue']))
const openReady = ghJson([
'issue',
'list',
'--state',
'open',
'--label',
'ready',
'--limit',
'200',
// Include `state` for parity with the pool queue (isAutonomousClaimable).
'--json',
'number,title,body,labels,createdAt,state',
])
const openReady = await api.listIssues({ state: 'open', labels: 'ready', limit: 200 })
// Queue builder lives in agent-github.mjs (shared with the agent pool —
// Queue builder lives in agent-forgejo.mjs (shared with the agent pool —
// identical ordering/filtering, pinned by agentPoolEligibility.unit.spec.ts).
const queue = buildClaimQueue(openReady, issuesById())
const queue = buildClaimQueue(openReady, await issuesById())
if (flags['dry-run'] || flags['dryrun']) {
console.log(`[agent:claim] ${queue.length} unblocked ready issue(s):`)
@ -67,7 +55,7 @@ if (!pick) {
}
// Optimistic lock: re-read right before flipping (shared with `worktree next`).
claimIssue(pick, die)
await claimIssue(pick, die)
console.log(`\n[agent:claim] Claimed #${pick.issue.number} — ${pick.issue.title}`)
for (const line of claimBriefLines(pick)) console.log(line)

View file

@ -9,7 +9,8 @@
* [--body "contexto..."] [--kind agent-miss|defect]
*/
import { dieAgent, gh, parseArgs } from './lib/agent-github.mjs'
import { dieAgent, parseArgs } from './lib/agent-forgejo.mjs'
import { forgejoApi as api } from './lib/forgejo-api.mjs'
const die = dieAgent('file-miss')
const { flags } = parseArgs(process.argv.slice(2), new Set(['title', 'body', 'kind']))
@ -27,17 +28,12 @@ const body = [
'_Registrado por `pnpm agent:file-miss`. Harvest: avaliar guardrail programático (teste de convenção, ESLint, check de CI) e registrar em `docs/GUARDRAILS.md`._',
].join('\n')
const url = gh([
'issue',
'create',
'--title',
flags.title,
'--body',
const created = await api.createIssue({
title: flags.title,
body,
'--label',
`kind:${kind}`,
'--label',
'prio:P2',
])
})
const url = `https://git.solla.dev/fsolla/teqo/issues/${created.number}`
await api.setLabels(created.number, { add: [`kind:${kind}`, 'prio:P2'] })
console.log(`[agent:file-miss] filed ${url}`)

View file

@ -22,12 +22,10 @@ import { appendFileSync } from 'node:fs'
import {
buildClaimQueue,
dieAgent,
gh,
ghJson,
issuesById,
labelNames,
parseArgs,
} from './lib/agent-github.mjs'
} from './lib/agent-forgejo.mjs'
import {
archiveCursorAgent,
@ -52,7 +50,7 @@ import {
readPoolVariables,
rollbackPoolClaim,
writePoolVariable,
} from './lib/agent-pool-github.mjs'
} from './lib/agent-pool-forgejo.mjs'
import { resolvePoolModel } from './lib/agent-pool-models.mjs'
import { buildPoolWorkerPrompt, extractPlanPath } from './lib/agent-pool-prompt.mjs'
import {
@ -66,8 +64,9 @@ import {
POOL_VARIABLE_NAMES,
reconcilePoolClaims,
} from './lib/agent-pool-state.mjs'
import { forgejoApi as api } from './lib/forgejo-api.mjs'
const REPO_URL = 'https://github.com/fsolla/teqo'
const REPO_URL = 'https://git.solla.dev/fsolla/teqo'
const POOL_BASE_REF = 'main'
const die = dieAgent('pool')
@ -94,22 +93,10 @@ const flushSummary = (title) => {
}
}
const loadClaimQueue = () =>
const loadClaimQueue = async () =>
buildClaimQueue(
ghJson([
'issue',
'list',
'--state',
'open',
'--label',
'ready',
'--limit',
'200',
// `state` is required: isAutonomousClaimable rejects missing/non-OPEN as not-open.
'--json',
'number,title,body,labels,createdAt,state',
]),
issuesById(),
await api.listIssues({ state: 'open', labels: 'ready', limit: 200 }),
await issuesById(),
)
/**
@ -123,11 +110,11 @@ const loadClaimQueue = () =>
* dry-run only reads.
*/
const derivePoolClaims = async ({ live }) => {
const openPrs = listOpenPrs()
const openPrs = await listOpenPrs()
const hasKey = Boolean(process.env.CURSOR_API_KEY)
const claims = []
for (const issue of listInProgressIssues()) {
const events = parsePoolEvents(listIssueComments(issue.number))
for (const issue of await listInProgressIssues()) {
const events = parsePoolEvents(await listIssueComments(issue.number))
if (!events.some((event) => event.event === 'claim')) continue
if (!events.some((event) => event.event === 'spawn') && hasKey) {
@ -144,7 +131,7 @@ const derivePoolClaims = async ({ live }) => {
}
events.push(spawn)
if (live) {
commentPoolEvent(
await commentPoolEvent(
issue.number,
spawn,
`Worker do pool (recuperado pelo tick): ${agent.url}`,
@ -201,7 +188,7 @@ const describeClaim = ({ issue, classification }) => {
}
const runStatus = async () => {
const config = parsePoolConfig(readPoolVariables({ strict: false }))
const config = parsePoolConfig(await readPoolVariables({ strict: false }))
log(
`pool: ${config.enabled ? 'LIGADO' : 'desligado'}${config.paused ? ' (pausado — audit solitário)' : ''}` +
` maxSlots=${config.maxSlots}` +
@ -224,8 +211,8 @@ const runStatus = async () => {
}
try {
const migrationBusy = countOpenSchemaPrs() > 0
const { eligible, excluded } = buildPoolQueue(loadClaimQueue(), { migrationBusy })
const migrationBusy = (await countOpenSchemaPrs()) > 0
const { eligible, excluded } = buildPoolQueue(await loadClaimQueue(), { migrationBusy })
log(
`\nfila elegível: ${eligible.length}${migrationBusy ? ' (migration-busy: PR de schema aberto)' : ''}`,
)
@ -267,7 +254,7 @@ const spawnPoolWorker = async ({ entry, workerUuid, models }) => {
repos: [{ url: REPO_URL, startingRef: POOL_BASE_REF }],
autoCreatePR: false,
})
commentPoolEvent(
await commentPoolEvent(
entry.issue.number,
{ event: 'spawn', agentId: agent.id, runId: run.id, url: agent.url },
`Worker do pool em execução: ${agent.url}`,
@ -279,7 +266,7 @@ const spawnPoolWorker = async ({ entry, workerUuid, models }) => {
} catch (error) {
if (error instanceof CursorApiError && error.status === 409) {
const existing = await getCursorAgent(agentId)
commentPoolEvent(
await commentPoolEvent(
entry.issue.number,
{
event: 'spawn',
@ -292,8 +279,8 @@ const spawnPoolWorker = async ({ entry, workerUuid, models }) => {
log(` spawn #${entry.issue.number} já existia (409) — recuperado: ${existing.url}`)
return
}
rollbackPoolClaim(entry.issue.number, `spawn falhou (${error.message})`)
commentPoolEvent(
await rollbackPoolClaim(entry.issue.number, `spawn falhou (${error.message})`)
await commentPoolEvent(
entry.issue.number,
{ event: 'failure', reason: 'spawn-error' },
'Pool-supervisor: spawn falhou — claim revertido para `ready`.',
@ -304,7 +291,7 @@ const spawnPoolWorker = async ({ entry, workerUuid, models }) => {
const runTick = async ({ dryRun }) => {
summary(`- modo: **${dryRun ? 'dry-run' : 'LIVE'}**`)
const config = parsePoolConfig(readPoolVariables({ strict: !dryRun }))
const config = parsePoolConfig(await readPoolVariables({ strict: !dryRun }))
log(
`tick (${dryRun ? 'dry-run' : 'LIVE'}) — enabled=${config.enabled} paused=${config.paused} maxSlots=${config.maxSlots}`,
)
@ -334,7 +321,7 @@ const runTick = async ({ dryRun }) => {
summary(`- falha #${claim.issue.number} (${reason}) → blocked + archive`)
continue
}
commentPoolEvent(
await commentPoolEvent(
claim.issue.number,
{ event: 'failure', reason, agentId: claim.classification?.agentId ?? null },
`Pool-supervisor: falha terminal do worker (${reason}) — a Issue vai a \`blocked\` para triagem humana (ver runs em cursor.com/agents).`,
@ -356,8 +343,8 @@ const runTick = async ({ dryRun }) => {
}
const active = reconciliation.active
const migrationBusy = countOpenSchemaPrs() > 0
const { eligible, excluded } = buildPoolQueue(loadClaimQueue(), { migrationBusy })
const migrationBusy = (await countOpenSchemaPrs()) > 0
const { eligible, excluded } = buildPoolQueue(await loadClaimQueue(), { migrationBusy })
const plan = computeSpawnPlan({
eligible,
activeCount: active.length,
@ -393,7 +380,7 @@ const runTick = async ({ dryRun }) => {
summary(`- skip #${entry.issue.number} (${verdict.reason})`)
continue
}
const claim = claimIssueForPool(entry.issue.number, { tickIso })
const claim = await claimIssueForPool(entry.issue.number, { tickIso })
if (!claim.ok) {
log(` #${entry.issue.number} claim perdido (${claim.reason}) — próxima da fila.`)
continue
@ -413,7 +400,7 @@ const runTick = async ({ dryRun }) => {
if (dryRun) {
log('fila drenada e zero ativos — o tick live desligaria o pool (POOL_ENABLED=false).')
} else {
writePoolVariable(POOL_VARIABLE_NAMES.enabled, 'false')
await writePoolVariable(POOL_VARIABLE_NAMES.enabled, 'false')
log('fila drenada e zero ativos — pool desligado automaticamente (POOL_ENABLED=false).')
summary('- **fila drenada — pool desligado automaticamente**')
}
@ -426,29 +413,29 @@ const runWorkflow = async ({ action, actor, maxSlots }) => {
const now = new Date().toISOString()
switch (action) {
case 'start': {
writePoolVariable(POOL_VARIABLE_NAMES.enabled, 'true')
writePoolVariable(POOL_VARIABLE_NAMES.startedAt, now)
writePoolVariable(POOL_VARIABLE_NAMES.startedBy, actor || 'desconhecido')
writePoolVariable(POOL_VARIABLE_NAMES.paused, 'false')
if (maxSlots) writePoolVariable(POOL_VARIABLE_NAMES.maxSlots, String(maxSlots))
await writePoolVariable(POOL_VARIABLE_NAMES.enabled, 'true')
await writePoolVariable(POOL_VARIABLE_NAMES.startedAt, now)
await writePoolVariable(POOL_VARIABLE_NAMES.startedBy, actor || 'desconhecido')
await writePoolVariable(POOL_VARIABLE_NAMES.paused, 'false')
if (maxSlots) await writePoolVariable(POOL_VARIABLE_NAMES.maxSlots, String(maxSlots))
log(`pool ligado por ${actor ?? '?'} (${now})`)
await runTick({ dryRun: false })
return
}
case 'stop':
writePoolVariable(POOL_VARIABLE_NAMES.enabled, 'false')
await writePoolVariable(POOL_VARIABLE_NAMES.enabled, 'false')
log('pool desligado — sem novos spawns; ativos drenam até o merge.')
summary(`- **stop** por ${actor ?? '?'} (${now}) — sem novos spawns`)
flushSummary('agent:pool stop')
return
case 'pause':
writePoolVariable(POOL_VARIABLE_NAMES.paused, 'true')
await writePoolVariable(POOL_VARIABLE_NAMES.paused, 'true')
log('pool pausado (audit solitário) — sem novos spawns.')
summary(`- **pause** por ${actor ?? '?'} (${now})`)
flushSummary('agent:pool pause')
return
case 'resume':
writePoolVariable(POOL_VARIABLE_NAMES.paused, 'false')
await writePoolVariable(POOL_VARIABLE_NAMES.paused, 'false')
log('pool retomado.')
await runTick({ dryRun: false })
return
@ -464,35 +451,29 @@ const runWorkflow = async ({ action, actor, maxSlots }) => {
}
/** Human-facing wrapper: dispatch the workflow (canonical channel stays remote). */
const dispatchWorkflow = (action) => {
const args = ['workflow', 'run', 'agent-pool.yml', '-f', `action=${action}`]
if (flags.ref) args.push('--ref', flags.ref)
if (action === 'start' && flags['max-slots']) args.push('-f', `maxSlots=${flags['max-slots']}`)
gh(args)
const dispatchWorkflow = async (action) => {
const inputs = { action }
if (action === 'start' && flags['max-slots']) inputs.maxSlots = String(flags['max-slots'])
await api.workflowDispatch('agent-pool.yml', { ref: flags.ref ?? 'main', inputs })
log(
`workflow_dispatch enviado (action=${action}). Acompanhe: gh run list --workflow agent-pool.yml --limit 3`,
`workflow_dispatch enviado (action=${action}). Acompanhe: ${REPO_URL}/actions?workflow=agent-pool.yml`,
)
}
const runDoctor = async () => {
log('doctor — pré-requisitos do pool')
try {
gh(['auth', 'status'])
log(' gh auth: OK')
} catch {
log(' gh auth: FALHOU (rode `gh auth login`)')
if (process.env.FORGEJO_API_TOKEN || process.env.GITHUB_TOKEN) {
log(' token Forgejo: OK (FORGEJO_API_TOKEN ou GITHUB_TOKEN nativo do Actions)')
} else {
log(' token Forgejo: AUSENTE (defina FORGEJO_API_TOKEN)')
}
if (process.env.POOL_GITHUB_TOKEN) {
log(' POOL_GITHUB_TOKEN: presente no env (usará este token para variables)')
} else if (process.env.GH_TOKEN || process.env.GITHUB_TOKEN) {
log(
' POOL_GITHUB_TOKEN: ausente — usando GH_TOKEN/GITHUB_TOKEN (GHA: configure o secret POOL_GITHUB_TOKEN se variables der 403)',
' POOL_GITHUB_TOKEN: legado presente no env — ignorar (o pool não usa mais variáveis do GitHub)',
)
} else {
log(' POOL_GITHUB_TOKEN / GH_TOKEN: ausente')
}
try {
const vars = readPoolVariables()
const vars = await readPoolVariables()
log(` repo variables: OK (${Object.keys(vars).length} lidas)`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)

View file

@ -4,7 +4,8 @@
* pnpm agent:prioritize -- <issue-number> P1
*/
import { dieAgent, ghJson, labelNames, parseArgs, setLabels } from './lib/agent-github.mjs'
import { dieAgent, labelNames, parseArgs, setLabels } from './lib/agent-forgejo.mjs'
import { forgejoApi as api } from './lib/forgejo-api.mjs'
const die = dieAgent('prioritize')
const { positional } = parseArgs(process.argv.slice(2), new Set())
@ -14,9 +15,9 @@ if (!number || !/^P[0-3]$/.test(priority ?? '')) {
die('Usage: pnpm agent:prioritize -- <issue-number> <P0|P1|P2|P3>')
}
const issue = ghJson(['issue', 'view', number, '--json', 'number,title,labels'])
const issue = await api.getIssue(number)
const current = labelNames(issue).filter((label) => /^prio:P[0-3]$/.test(label))
setLabels(number, { add: [`prio:${priority}`], remove: current })
await setLabels(number, { add: [`prio:${priority}`], remove: current })
console.log(
`[agent:prioritize] #${issue.number} — ${issue.title}: ${current.join(',') || '(none)'} → prio:${priority}`,
)

View file

@ -9,11 +9,12 @@
* node scripts/agent-promote-related-on-merge.mjs --pr 123
* PR_BODY='Related #296' node scripts/agent-promote-related-on-merge.mjs --pr 123
*
* Env: GH_TOKEN (Actions); optional PR_BODY skips `gh pr view` (Actions event).
* Env: GITHUB_TOKEN (Forgejo Actions) / FORGEJO_API_TOKEN; optional PR_BODY skips the PR read.
*/
import { dieAgent, gh, ghJson, parseArgs, setLabels } from './lib/agent-github.mjs'
import { dieAgent, parseArgs, setLabels } from './lib/agent-forgejo.mjs'
import { canPromotePlanIssue, parseRelatedIssueNumbers } from './lib/agent-plan-lifecycle.mjs'
import { forgejoApi as api } from './lib/forgejo-api.mjs'
const die = dieAgent('promote-related')
const { flags } = parseArgs(process.argv.slice(2), new Set(['pr']))
@ -25,9 +26,7 @@ if (!flags.pr || !/^\d+$/.test(String(flags.pr))) {
const prNumber = Number(flags.pr)
const bodyFromEnv = process.env.PR_BODY
const body =
typeof bodyFromEnv === 'string'
? bodyFromEnv
: ghJson(['pr', 'view', String(prNumber), '--json', 'body']).body
typeof bodyFromEnv === 'string' ? bodyFromEnv : (await api.getPullRequest(prNumber)).body
const numbers = parseRelatedIssueNumbers(body)
if (numbers.length === 0) {
@ -42,7 +41,7 @@ console.log(
for (const number of numbers) {
let issue
try {
issue = ghJson(['issue', 'view', String(number), '--json', 'number,title,body,state,labels'])
issue = await api.getIssue(number)
} catch (error) {
console.log(
`[agent:promote-related] #${number}: skip (issue view failed: ${
@ -63,14 +62,11 @@ for (const number of numbers) {
}
try {
setLabels(number, { add: ['ready'], remove: ['blocked'] })
gh([
'issue',
'comment',
String(number),
'--body',
await setLabels(number, { add: ['ready'], remove: ['blocked'] })
await api.addComment(
number,
`Promovido a \`ready\` via Action OPS18 — plano de intenção em \`main\` (PR #${prNumber} com \`Related #${number}\`).`,
])
)
console.log(`[agent:promote-related] #${number}: blocked → ready`)
} catch (error) {
console.log(

View file

@ -11,8 +11,9 @@
* link stay for human triage.
*/
import { dieAgent, gh, ghJson, parseArgs, setLabels } from './lib/agent-github.mjs'
import { dieAgent, parseArgs, setLabels } from './lib/agent-forgejo.mjs'
import { canPromotePlanIssue } from './lib/agent-plan-lifecycle.mjs'
import { forgejoApi as api } from './lib/forgejo-api.mjs'
const die = dieAgent('ready')
const { flags } = parseArgs(process.argv.slice(2), new Set(['issue']))
@ -33,13 +34,7 @@ const numbers = String(flags.issue)
if (numbers.length === 0) die('Usage: pnpm agent:ready -- --issue <N[,N…]>')
for (const number of numbers) {
const issue = ghJson([
'issue',
'view',
String(number),
'--json',
'number,title,body,state,labels',
])
const issue = await api.getIssue(number)
const verdict = canPromotePlanIssue(issue)
if (!verdict.ok) {
if (verdict.reason === 'already-ready') {
@ -49,13 +44,10 @@ for (const number of numbers) {
die(`#${number} cannot mark ready (${verdict.reason}).`)
}
setLabels(number, { add: ['ready'], remove: ['blocked'] })
gh([
'issue',
'comment',
String(number),
'--body',
await setLabels(number, { add: ['ready'], remove: ['blocked'] })
await api.addComment(
number,
'Promovido a `ready`: plano de intenção em `main` (ciclo OPS17 — `pnpm agent:ready`).',
])
)
console.log(`[agent:ready] #${number} blocked → ready`)
}

View file

@ -10,8 +10,9 @@
* and `pnpm agent:ready` promotes them).
*/
import { dieAgent, gh, issuesById, parseArgs, serializeFrontmatter } from './lib/agent-github.mjs'
import { dieAgent, issuesById, parseArgs, serializeFrontmatter } from './lib/agent-forgejo.mjs'
import { resolveRegisterStateLabel } from './lib/agent-plan-lifecycle.mjs'
import { forgejoApi as api } from './lib/forgejo-api.mjs'
const die = dieAgent('register')
const { flags } = parseArgs(
@ -32,7 +33,7 @@ const depends = (flags.depends ?? '')
.map((v) => v.trim())
.filter(Boolean)
const existing = issuesById().get(flags.id)
const existing = (await issuesById()).get(flags.id)
if (existing)
die(`An issue with id ${flags.id} already exists: #${existing.number} — ${existing.title}`)
@ -63,15 +64,12 @@ const labels = [
.filter(Boolean),
]
const url = gh([
'issue',
'create',
'--title',
`${flags.id} — ${flags.title}`,
'--body',
const created = await api.createIssue({
title: `${flags.id} — ${flags.title}`,
body,
...labels.flatMap((label) => ['--label', label]),
])
})
await api.setLabels(created.number, { add: labels })
const url = `https://git.solla.dev/fsolla/teqo/issues/${created.number}`
const hint =
stateLabel === 'blocked' && flags.plan && !flags.blocked

View file

@ -10,29 +10,20 @@
import {
dieAgent,
ghJson,
issuesById,
labelNames,
parseFrontmatter,
priorityRank,
} from './lib/agent-github.mjs'
} from './lib/agent-forgejo.mjs'
import { forgejoApi as api } from './lib/forgejo-api.mjs'
const die = dieAgent('status')
let issues
try {
issues = ghJson([
'issue',
'list',
'--state',
'all',
'--limit',
'500',
'--json',
'number,title,body,state,labels,createdAt',
])
issues = await api.listIssues({ state: 'all', limit: 200 })
} catch (error) {
die(`gh issue list failed: ${error.message}`)
die(`Forgejo issue list failed: ${error.message}`)
}
const rows = issues
@ -123,7 +114,7 @@ if (recentDone.length > 0) {
// --- Fila atual (mesma ordem do agent:claim --dry-run) ------------------------
console.log('\n## Fila atual (ordem do agent:claim)\n')
const byId = issuesById()
const byId = await issuesById()
const queue = open
.filter((row) => row.state === 'ready')
.map((row) => ({

View file

@ -1,55 +1,38 @@
/**
* One-time / drift repair for branch protection (main-only).
* One-time / drift repair for branch protection (main-only) — Forgejo.
*
* pnpm configure:branch-protection # apply
* pnpm configure:branch-protection -- --dry-run
*
* main: required checks `checks`, strict=false, 0 reviews.
* main: required status checks `checks`, strict=false, 0 reviews.
* Feature PRs auto-merge when that context is green.
* (migration-lock removed 2026-08-12 — see ci-pr.yml header.)
*/
import { execFileSync } from 'node:child_process'
import { parseArgs } from './lib/agent-github.mjs'
import { parseArgs } from './lib/agent-forgejo.mjs'
import { createApi } from './lib/forgejo-api.mjs'
const { flags } = parseArgs(process.argv.slice(2), new Set())
const dryRun = flags['dry-run'] === true
const repo = execFileSync(
'gh',
['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'],
{
encoding: 'utf8',
},
).trim()
const api = createApi({})
const repo = process.env.FORGEJO_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? 'fsolla/teqo'
const ghApiJson = (method, path, body) => {
const payload = JSON.stringify(body)
if (dryRun) {
console.log(`[dry-run] gh api -X ${method} ${path} ${payload}`)
return
}
execFileSync('gh', ['api', '-X', method, path, '--input', '-'], {
input: payload,
stdio: ['pipe', 'inherit', 'inherit'],
})
const payload = {
rule_name: 'main',
enable_status_check: true,
status_check_contexts: ['checks'],
enable_push: false,
required_approvals: 0,
dismiss_stale_approvals: false,
}
console.log(`[configure:branch-protection] repo ${repo}${dryRun ? ' (dry-run)' : ''}`)
ghApiJson('PUT', `repos/${repo}/branches/main/protection`, {
required_status_checks: {
strict: false,
contexts: ['checks'],
},
enforce_admins: false,
required_pull_request_reviews: {
required_approving_review_count: 0,
},
restrictions: null,
allow_force_pushes: false,
allow_deletions: false,
})
if (dryRun) {
console.log(`[dry-run] POST /repos/${repo}/branch_protections ${JSON.stringify(payload)}`)
process.exit(0)
}
await api.updateBranchProtection('main', payload)
console.log('[configure:branch-protection] done (main: checks, strict=false, 0 reviews).')

View file

@ -0,0 +1,32 @@
/**
* Plain-Node CLI (no pnpm) — dispatch a Forgejo workflow via workflow_dispatch.
* Used by CI jobs (requeue) and humans.
*
* node scripts/forgejo-dispatch.mjs <workflow-file> <ref> [key=value …]
*/
import { createApi } from './lib/forgejo-api.mjs'
const [, , workflowFile, ref = 'main', ...inputPairs] = process.argv
if (!workflowFile) {
console.error('Usage: node scripts/forgejo-dispatch.mjs <workflow-file> [ref] [key=value …]')
process.exit(1)
}
const inputs = {}
for (const pair of inputPairs) {
const eq = pair.indexOf('=')
if (eq <= 0) continue
inputs[pair.slice(0, eq)] = pair.slice(eq + 1)
}
const api = createApi({})
try {
await api.workflowDispatch(workflowFile, { ref, inputs })
console.log(
`[forgejo-dispatch] ${workflowFile}@${ref} enviado${Object.keys(inputs).length > 0 ? ` (${Object.keys(inputs).join(', ')})` : ''}`,
)
} catch (error) {
console.error(`[forgejo-dispatch] falhou: ${error instanceof Error ? error.message : error}`)
process.exit(1)
}

View file

@ -0,0 +1,67 @@
/**
* 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.
*
* node scripts/forgejo-issue-transition.mjs --pr <N>
*/
import { createApi } from './lib/forgejo-api.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/forgejo-issue-transition.mjs --pr <N>')
process.exit(1)
}
const api = createApi({})
const pr = await api.getPullRequest(prNumber)
if (!pr) {
console.error(`[forgejo-issue-transition] PR #${prNumber} não encontrada`)
process.exit(1)
}
if (!pr.merged) {
console.log(`[forgejo-issue-transition] PR #${prNumber} não mergeada — skip`)
process.exit(0)
}
const numbers = [
...new Set([...pr.body.matchAll(/(?:closes|fixes)\s+#(\d+)/gi)].map((match) => Number(match[1]))),
]
if (numbers.length === 0) {
console.log(`[forgejo-issue-transition] PR #${prNumber}: nenhum Closes/Fixes #N — nada a fazer`)
process.exit(0)
}
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).`,
)
console.log(`[forgejo-issue-transition] #${number}: done + in-prod`)
} catch (error) {
console.log(
`[forgejo-issue-transition] #${number}: skip (${error instanceof Error ? error.message : error})`,
)
}
}

View file

@ -0,0 +1,59 @@
/**
* Plain-Node CLI (no pnpm) — agent PR safety net: wait for checks and merge by
* rebase (the Forgejo equivalent of `gh pr merge --auto --rebase`, which the
* Forgejo API does not offer as a scheduled operation). Idempotent: already
* merged → no-op success.
*
* node scripts/forgejo-pr-automerge.mjs --pr <N>
*/
import { createApi } from './lib/forgejo-api.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/forgejo-pr-automerge.mjs --pr <N>')
process.exit(1)
}
const api = createApi({})
try {
const pr = await api.getPullRequest(prNumber)
if (!pr || pr.state !== 'OPEN') {
console.log(
`[forgejo-pr-automerge] PR #${prNumber} não está aberto (${pr?.state ?? 'inexistente'}) — skip`,
)
process.exit(0)
}
if (pr.isDraft) {
console.log(`[forgejo-pr-automerge] PR #${prNumber} é draft — marcando Ready`)
await api.markPullRequestReady(prNumber)
} else {
console.log(`[forgejo-pr-automerge] PR #${prNumber} já Ready`)
}
const merged = await api.autoMerge(prNumber, {
log: (line) => console.log(`[forgejo-pr-automerge] ${line}`),
})
console.log(
merged
? `[forgejo-pr-automerge] PR #${prNumber} mergeado (rebase)`
: `[forgejo-pr-automerge] PR #${prNumber} já mergeado`,
)
} catch (error) {
console.error(`[forgejo-pr-automerge] falhou: ${error instanceof Error ? error.message : error}`)
process.exit(1)
}

View file

@ -8,18 +8,18 @@
import {
dieAgent,
ghJson,
labelNames,
nextClaimableIssue,
parseArgs,
parseFrontmatter,
priorityRank,
} from './lib/agent-github.mjs'
} from './lib/agent-forgejo.mjs'
import { forgejoApi as api } from './lib/forgejo-api.mjs'
const die = dieAgent('issue')
const cmdNext = () => {
const pick = nextClaimableIssue()
const cmdNext = async () => {
const pick = await nextClaimableIssue()
if (!pick) {
die('Fila vazia — nada `ready` desbloqueado. Rode `pnpm issue all` para a overview.')
}
@ -35,22 +35,13 @@ const cmdNext = () => {
if (satisfiedWithoutIssue.length > 0) {
console.log(`deps satisfeitas (roadmap, sem Issue): ${satisfiedWithoutIssue.join(', ')}`)
}
console.log(`url: https://github.com/fsolla/teqo/issues/${issue.number}`)
console.log(`url: https://git.solla.dev/fsolla/teqo/issues/${issue.number}`)
}
const count = (map, key) => map.set(key, (map.get(key) ?? 0) + 1)
const cmdAll = () => {
const issues = ghJson([
'issue',
'list',
'--state',
'open',
'--limit',
'200',
'--json',
'number,title,body,labels,createdAt,state',
])
const cmdAll = async () => {
const issues = await api.listIssues({ state: 'open', limit: 200 })
const rows = []
for (const issue of issues) {
@ -107,8 +98,8 @@ if (!subcommand) {
}
try {
if (subcommand === 'next') cmdNext()
else if (subcommand === 'all') cmdAll()
if (subcommand === 'next') await cmdNext()
else if (subcommand === 'all') await cmdAll()
else die(`subcomando desconhecido: ${subcommand} (esperado: next | all)`)
} catch (error) {
if (error?.stderr) die(error.stderr.toString().trim())

View file

@ -1,9 +1,10 @@
/**
* Shared helpers for the agent ops scripts (`pnpm agent:*`). Everything goes
* through the authenticated `gh` CLI — agents never get a GitHub token of
* their own, and these scripts never touch stage/prod database URLs.
* through the Forgejo REST API (scripts/lib/forgejo-api.mjs) — agents never
* get a token of their own beyond the env, and these scripts never touch
* stage/prod database URLs.
*
* Issue contract (spec + status + deps in GitHub Issues, per the parallel
* Issue contract (spec + status + deps in Forgejo Issues, per the parallel
* paradigm plan): each trackable issue carries a YAML-ish frontmatter block
* at the top of the body:
*
@ -17,21 +18,14 @@
* State lives in labels: ready | in-progress | blocked | done (+ in-prod).
*/
import { execFileSync } from 'node:child_process'
import { dieWithLabel } from './cli.mjs'
import { forgejoApi as api } from './forgejo-api.mjs'
const PRIORITIES = ['P0', 'P1', 'P2', 'P3']
export const priorityRank = (priority) => PRIORITIES.indexOf(priority)
export const dieAgent = (script) => dieWithLabel(`agent:${script}`)
/** Run gh and return stdout (throws with the gh stderr on failure). */
export const gh = (args) =>
execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).trim()
export const ghJson = (args) => JSON.parse(gh(args))
export const parseFrontmatter = (body) => {
const match = /^---\n([\s\S]*?)\n---\n?/.exec(body ?? '')
if (!match) return { meta: {}, rest: body ?? '' }
@ -61,17 +55,8 @@ export const serializeFrontmatter = (meta, rest) => {
}
/** All issues (open or closed) that carry a frontmatter `id`, keyed by id. */
export const issuesById = () => {
const issues = ghJson([
'issue',
'list',
'--state',
'all',
'--limit',
'500',
'--json',
'number,title,body,state,labels',
])
export const issuesById = async () => {
const issues = await api.listIssues({ state: 'all', limit: 200 })
const byId = new Map()
for (const issue of issues) {
const { meta } = parseFrontmatter(issue.body)
@ -175,14 +160,8 @@ export const claimTargetVerdict = (issue) => {
* caller so the failure carries the right script label. The pool keeps its own
* coordinated `claimIssueForPool` — this is the human/CLI lock.
*/
export const claimIssue = (entry, die) => {
const fresh = ghJson([
'issue',
'view',
String(entry.issue.number),
'--json',
'number,labels,state',
])
export const claimIssue = async (entry, die) => {
const fresh = await api.getIssue(entry.issue.number)
const freshLabels = labelNames(fresh)
if (
fresh.state !== 'OPEN' ||
@ -197,14 +176,11 @@ export const claimIssue = (entry, die) => {
)
}
setLabels(entry.issue.number, { add: ['in-progress'], remove: ['ready'] })
gh([
'issue',
'comment',
String(entry.issue.number),
'--body',
await setLabels(entry.issue.number, { add: ['in-progress'], remove: ['ready'] })
await api.addComment(
entry.issue.number,
`Claimed by agent run at ${new Date().toISOString()}. Lock otimista: outro claim deve falhar e re-rodar \`pnpm agent:claim\`.`,
])
)
}
/**
@ -229,13 +205,13 @@ export const claimBriefLines = (entry) => {
` rename_chat: ${sessionTitle.slice(0, 200)}`,
entry.meta.model
? ` model: ${entry.meta.model} (metadata consultiva — o work-issue não verifica modelo; o pool spawna nele; ver skill model-selection)`
: ' model: ausente — registrar slug único na Issue (gh issue edit; ver skill model-selection)',
: ' model: ausente — registrar slug único na Issue (ver skill model-selection)',
...(entry.satisfiedWithoutIssue.length > 0
? [
` deps sem issue (roadmap entregue, satisfeitas): ${entry.satisfiedWithoutIssue.join(', ')}`,
]
: []),
` url: https://github.com/fsolla/teqo/issues/${entry.issue.number}`,
` url: https://git.solla.dev/fsolla/teqo/issues/${entry.issue.number}`,
'',
'--- spec ---',
'',
@ -243,27 +219,13 @@ export const claimBriefLines = (entry) => {
]
}
export const setLabels = (number, { add = [], remove = [] }) => {
const args = ['issue', 'edit', String(number)]
for (const label of add) args.push('--add-label', label)
for (const label of remove) args.push('--remove-label', label)
if (add.length + remove.length > 0) gh(args)
}
export const setLabels = (number, { add = [], remove = [] }) =>
api.setLabels(number, { add, remove })
export const nextClaimableIssue = () => {
const openReady = ghJson([
'issue',
'list',
'--state',
'open',
'--label',
'ready',
'--limit',
'200',
'--json',
'number,title,body,labels,createdAt,state',
])
return buildClaimQueue(openReady, issuesById())[0] ?? null
export const nextClaimableIssue = async () => {
const openReady = await api.listIssues({ state: 'open', labels: 'ready', limit: 200 })
const queue = buildClaimQueue(openReady, await issuesById())
return queue[0] ?? null
}
export const parseArgs = (argv, flagsWithValue) => {

View file

@ -7,7 +7,7 @@
* script and pinned by unit tests.
*/
import { labelNames } from './agent-github.mjs'
import { labelNames } from './agent-forgejo.mjs'
import { HUMAN_GATE_LABELS, issueHasPlanLink } from './agent-pool-eligibility.mjs'
/**

View file

@ -11,7 +11,7 @@
* where the comments are already being read).
*/
import { labelNames } from './agent-github.mjs'
import { labelNames } from './agent-forgejo.mjs'
export const POOL_CIRCUIT_BREAKER_FAILURES = 2
@ -19,7 +19,7 @@ const EXCLUDED_STATE_LABELS = ['in-progress', 'blocked', 'done', 'in-prod']
export const HUMAN_GATE_LABELS = ['requirements-changed', 'needs:consent']
/**
* @param {ReturnType<import('./agent-github.mjs').buildClaimQueue>[number]} entry queue entry from buildClaimQueue
* @param {ReturnType<import('./agent-forgejo.mjs').buildClaimQueue>[number]} entry queue entry from buildClaimQueue
* @param {Object} [options]
* @param {boolean} [options.migrationBusy]
* @param {number} [options.poolFailureCount]
@ -56,7 +56,7 @@ export const issueHasPlanLink = (issue) => /docs\/plans\//.test(issue?.body ?? '
* Splits the claim queue into pool-eligible entries (order preserved — the
* claim order IS the spawn order) and exclusions with their reason.
*
* @param {ReturnType<import('./agent-github.mjs').buildClaimQueue>} claimQueue
* @param {ReturnType<import('./agent-forgejo.mjs').buildClaimQueue>} claimQueue
* @param {Object} [options]
* @param {boolean} [options.migrationBusy]
* @param {Map<number, number>} [options.failureCountsByIssue]

View file

@ -0,0 +1,131 @@
/**
* Forgejo IO wrappers for the agent pool supervisor. Pure logic lives in
* agent-pool-state.mjs / agent-pool-eligibility.mjs; the Cursor REST client in
* agent-pool-cursor.mjs. Everything here goes through scripts/lib/forgejo-api.mjs
* — no GitHub/gh dependency, same contract the pool had against `gh`.
*
* Pool state (was: repo Actions variables) lives in `pool-state.json` on the
* dedicated branch `pool-state` (Forgejo 9 exposes variables read-only via API
* — creation is 405; the contents API gives us read/write without git).
*/
import { randomUUID } from 'node:crypto'
import { labelNames } from './agent-forgejo.mjs'
import { formatPoolEvent } from './agent-pool-state.mjs'
import { forgejoApi as api } from './forgejo-api.mjs'
const POOL_STATE_FILE = 'pool-state.json'
const POOL_STATE_BRANCH = 'pool-state'
/**
* Pool variables as a plain map from `pool-state.json` on the `pool-state`
* branch. Non-strict mode degrades to `{}` + a warn: inspection commands
* (status, dry-run tick) still work and the resulting config is the disabled
* default — the fail-closed direction. The live tick stays strict: if the
* supervisor cannot read its own config it must stop loudly, never guess.
*/
export const readPoolVariables = async ({ strict = true } = {}) => {
try {
const { content } = await api.getFileContents(POOL_STATE_FILE, POOL_STATE_BRANCH)
const parsed = JSON.parse(content)
return parsed && typeof parsed === 'object' ? parsed : {}
} catch (error) {
if (strict) throw error
console.error(
'[agent:pool] aviso: pool-state.json ilegível (branch pool-state ausente?) — usando defaults (pool desligado).',
)
return {}
}
}
/** Create or update one pool variable in pool-state.json (POST create, PUT update). */
export const writePoolVariable = async (name, value) => {
const current = await readPoolVariables()
const next = { ...current, [name]: value }
const message = `[agent:pool] ${name}=${value}`
try {
await api.updateFile(POOL_STATE_FILE, {
message,
content: JSON.stringify(next, null, 2) + '\n',
branch: POOL_STATE_BRANCH,
sha: (await api.getFileContents(POOL_STATE_FILE, POOL_STATE_BRANCH)).sha,
})
} catch (error) {
if (/404|object does not exist/i.test(error.message)) {
await api.createFile(POOL_STATE_FILE, {
message,
content: JSON.stringify(next, null, 2) + '\n',
branch: POOL_STATE_BRANCH,
})
} else {
throw error
}
}
}
export const listInProgressIssues = () =>
api.listIssues({ state: 'open', labels: 'in-progress', limit: 200 })
export const listIssueComments = (number) => api.listIssueComments(number)
export const listOpenPrs = () => api.listPullRequests({ state: 'open', limit: 100 })
/** The open PR whose body closes this issue (`Closes #N` / `Fixes #N`), if any. */
export const prClosingIssue = (prs, issueNumber) =>
prs.find((pr) => new RegExp(`(?:closes|fixes) #${issueNumber}\\b`, 'i').test(pr.body ?? '')) ??
null
// Path predicate for "schema-touching PR" — the pool serializes migration
// spawns while one is open (the CI migration-lock was removed 2026-08-12).
const SCHEMA_PR_PATH = /^(src\/migrations\/|src\/payload-types\.ts$|payload-types\.ts$)/
export const countOpenSchemaPrs = async () => {
const prs = await api.listPullRequests({ state: 'open', limit: 100 })
let count = 0
for (const pr of prs) {
const files = await api.listPullRequestFiles(pr.number).catch(() => null)
if ((files ?? []).some((file) => SCHEMA_PR_PATH.test(file.filename ?? ''))) count += 1
}
return count
}
/** Comment on an issue with human text plus the machine-readable pool marker. */
export const commentPoolEvent = (number, event, text) =>
api.addComment(number, `${text} ${formatPoolEvent(event)}`)
/**
* Coordinated claim (plan §4): the supervisor is the single allocator. Re-read
* the issue (same optimistic lock as agent:claim), flip ready→in-progress and
* leave the claim marker with the worker UUID the spawn will reuse as the
* idempotent `agentId`. Returns `{ ok, workerUuid }` or `{ ok: false }` when
* the race was lost — the tick just moves to the next queue entry.
*/
export const claimIssueForPool = async (number, { tickIso }) => {
const fresh = await api.getIssue(number)
const labels = labelNames(fresh)
if (fresh.state !== 'OPEN' || !labels.includes('ready') || labels.includes('in-progress')) {
return { ok: false, reason: 'lost-race' }
}
await api.setLabels(number, { add: ['in-progress'], remove: ['ready'] })
const workerUuid = randomUUID()
await commentPoolEvent(
number,
{ event: 'claim', tick: tickIso, worker: workerUuid },
`Claimed pelo pool-supervisor (tick ${tickIso}) — worker em spawn.`,
)
return { ok: true, workerUuid }
}
/** Undo the claim when the spawn itself failed — the issue goes back to ready. */
export const rollbackPoolClaim = async (number, note) => {
await api.setLabels(number, { add: ['ready'], remove: ['in-progress'] })
return api.addComment(number, `Pool-supervisor: claim revertido — ${note}`)
}
/**
* Failure path: a terminally failed worker's issue leaves the claim queue for
* human triage. The pool only ever blocks issues IT claimed (claim marker).
*/
export const blockIssueFromPool = (number) =>
api.setLabels(number, { add: ['blocked'], remove: ['in-progress'] })

View file

@ -1,150 +0,0 @@
/**
* gh-CLI IO wrappers for the agent pool supervisor. Pure logic lives in
* agent-pool-state.mjs / agent-pool-eligibility.mjs; the Cursor REST client in
* agent-pool-cursor.mjs. Everything here shells the authenticated `gh` — no
* GitHub token of its own, same contract as agent-github.mjs.
*/
import { randomUUID } from 'node:crypto'
import { gh, ghJson, labelNames, setLabels } from './agent-github.mjs'
import { formatPoolEvent } from './agent-pool-state.mjs'
/**
* Repo variables (Settings → Variables) as a plain map. One `gh api` call;
* pool values are scalars, so the `name=value` encoding splits on the first
* `=` only. Requires `actions: read` (read) / `actions: write` (write).
*
* Non-strict mode degrades to `{}` + a warn: inspection commands (status,
* dry-run tick) still work for a token without Actions permissions, and the
* resulting config is the disabled default — the fail-closed direction. The
* live tick stays strict: if the supervisor cannot read its own config it
* must stop loudly, never guess.
*/
export const readPoolVariables = ({ strict = true } = {}) => {
let out
try {
out = gh([
'api',
'repos/{owner}/{repo}/actions/variables',
'--paginate',
'--jq',
'.variables[] | .name + "=" + .value',
])
} catch (error) {
if (strict) throw error
console.error(
'[agent:pool] aviso: repo variables ilegíveis (token sem actions:read?) — usando defaults (pool desligado).',
)
return {}
}
const vars = {}
for (const line of out.split('\n')) {
if (!line) continue
const eq = line.indexOf('=')
vars[line.slice(0, eq)] = line.slice(eq + 1)
}
return vars
}
export const listInProgressIssues = () =>
ghJson([
'issue',
'list',
'--state',
'open',
'--label',
'in-progress',
'--limit',
'200',
'--json',
'number,title,body,state,labels,createdAt',
])
export const listIssueComments = (number) =>
ghJson(['issue', 'view', String(number), '--json', 'comments']).comments ?? []
export const listOpenPrs = () =>
ghJson(['pr', 'list', '--state', 'open', '--limit', '100', '--json', 'number,body,url'])
/** The open PR whose body closes this issue (`Closes #N` / `Fixes #N`), if any. */
export const prClosingIssue = (prs, issueNumber) =>
prs.find((pr) => new RegExp(`(?:closes|fixes) #${issueNumber}\\b`, 'i').test(pr.body ?? '')) ??
null
// Path predicate for "schema-touching PR" — the pool serializes migration
// spawns while one is open (the CI migration-lock was removed 2026-08-12).
const SCHEMA_PR_PATH = /^(src\/migrations\/|src\/payload-types\.ts$|payload-types\.ts$)/
export const countOpenSchemaPrs = () =>
ghJson(['pr', 'list', '--state', 'open', '--limit', '100', '--json', 'number,files']).filter(
(pr) => (pr.files ?? []).some((file) => SCHEMA_PR_PATH.test(file.path)),
).length
/** Create or update one repo variable (POST first time, PATCH after). */
export const writePoolVariable = (name, value) => {
const existing = readPoolVariables()
if (Object.hasOwn(existing, name)) {
gh([
'api',
'-X',
'PATCH',
`repos/{owner}/{repo}/actions/variables/${name}`,
'-f',
`value=${value}`,
])
} else {
gh([
'api',
'-X',
'POST',
'repos/{owner}/{repo}/actions/variables',
'-f',
`name=${name}`,
'-f',
`value=${value}`,
])
}
}
/** Comment on an issue with human text plus the machine-readable pool marker. */
export const commentPoolEvent = (number, event, text) => {
gh(['issue', 'comment', String(number), '--body', `${text} ${formatPoolEvent(event)}`])
}
/**
* Coordinated claim (plan §4): the supervisor is the single allocator. Re-read
* the issue (same optimistic lock as agent:claim), flip ready→in-progress and
* leave the claim marker with the worker UUID the spawn will reuse as the
* idempotent `agentId`. Returns `{ ok, workerUuid }` or `{ ok: false }` when
* the race was lost — the tick just moves to the next queue entry.
*/
export const claimIssueForPool = (number, { tickIso }) => {
const fresh = ghJson(['issue', 'view', String(number), '--json', 'number,state,labels'])
const labels = labelNames(fresh)
if (fresh.state !== 'OPEN' || !labels.includes('ready') || labels.includes('in-progress')) {
return { ok: false, reason: 'lost-race' }
}
setLabels(number, { add: ['in-progress'], remove: ['ready'] })
const workerUuid = randomUUID()
commentPoolEvent(
number,
{ event: 'claim', tick: tickIso, worker: workerUuid },
`Claimed pelo pool-supervisor (tick ${tickIso}) — worker em spawn.`,
)
return { ok: true, workerUuid }
}
/** Undo the claim when the spawn itself failed — the issue goes back to ready. */
export const rollbackPoolClaim = (number, note) => {
setLabels(number, { add: ['ready'], remove: ['in-progress'] })
gh(['issue', 'comment', String(number), '--body', `Pool-supervisor: claim revertido — ${note}`])
}
/**
* Failure path: a terminally failed worker's issue leaves the claim queue for
* human triage. The pool only ever blocks issues IT claimed (claim marker).
*/
export const blockIssueFromPool = (number) => {
setLabels(number, { add: ['blocked'], remove: ['in-progress'] })
}

View file

@ -1,7 +1,7 @@
/**
* Pure pool-state helpers for the agent pool supervisor
* (docs/plans/agent-pool-orchestrator.md §2). No IO — the gh wrappers live in
* agent-pool-github.mjs and the Cursor REST client in agent-pool-cursor.mjs.
* agent-pool-forgejo.mjs and the Cursor REST client in agent-pool-cursor.mjs.
*
* State model: scalar config lives in GitHub repo variables; the dynamic view
* (who is active) is DERIVED from two sources of truth — pool-worker event

311
scripts/lib/forgejo-api.mjs Normal file
View file

@ -0,0 +1,311 @@
/**
* Forgejo REST client for the agent ops scripts (`pnpm agent:*`, workflow
* helpers). Zero dependencies — plain Node `fetch` — so it also runs inside
* Forgejo Actions jobs without `pnpm install`.
*
* Auth: `FORGEJO_API_TOKEN` (PAT) or the native `GITHUB_TOKEN` that Forgejo
* Actions injects. Base URL: `FORGEJO_API_URL`, else `<GITHUB_SERVER_URL>/api/v1`
* inside Actions, else `https://git.solla.dev/api/v1`. A browser-ish User-Agent
* is sent because the instance sits behind Cloudflare.
*
* Shapes are normalized to the GitHub-flavored contract the agent scripts and
* their unit specs already use: `issue.state` ∈ OPEN|CLOSED,
* `issue.labels = [{ name, color }]`, `createdAt` ISO string.
*/
const DEFAULT_BASE_URL = 'https://git.solla.dev/api/v1'
const DEFAULT_REPOSITORY = 'fsolla/teqo'
const USER_AGENT = 'teqo-agent-scripts/1.0 (forgejo)'
/**
* @typedef {object} ForgejoApiOptions
* @property {string} [base]
* @property {string} [token]
* @property {string} [repository]
* @property {(input: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: string }) => Promise<Response>} [fetchImpl]
*/
/**
* @param {ForgejoApiOptions} [options]
*/
export const createApi = ({ base, token, repository, fetchImpl } = {}) => {
const baseUrl = (
base ??
process.env.FORGEJO_API_URL ??
(process.env.GITHUB_SERVER_URL ? `${process.env.GITHUB_SERVER_URL}/api/v1` : null) ??
DEFAULT_BASE_URL
).replace(/\/+$/, '')
const repo =
repository ??
process.env.FORGEJO_REPOSITORY ??
process.env.GITHUB_REPOSITORY ??
DEFAULT_REPOSITORY
const fetcher = fetchImpl ?? fetch
const [owner, name] = repo.split('/')
const request = async (path, { method = 'GET', body, query } = {}) => {
const authToken = token ?? process.env.FORGEJO_API_TOKEN ?? process.env.GITHUB_TOKEN
if (!authToken) {
throw new Error(
'Sem token para a API do Forgejo — defina FORGEJO_API_TOKEN (ou rode dentro do Forgejo Actions).',
)
}
const qs = query
? '?' +
Object.entries(query)
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
.join('&')
: ''
const response = await fetcher(`${baseUrl}${path}${qs}`, {
method,
headers: {
Authorization: `token ${authToken}`,
'Content-Type': 'application/json',
'User-Agent': USER_AGENT,
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
})
const text = await response.text()
if (!response.ok) {
throw new Error(`Forgejo API ${method} ${path} → ${response.status}: ${text.slice(0, 400)}`)
}
return text ? JSON.parse(text) : null
}
const normalizeState = (state) => (String(state).toLowerCase() === 'closed' ? 'CLOSED' : 'OPEN')
const normalizeIssue = (issue) => ({
number: issue.number,
title: issue.title,
body: issue.body ?? '',
state: normalizeState(issue.state),
createdAt: issue.created_at,
labels: (issue.labels ?? []).map((label) => ({
name: label.name,
color: label.color ?? '',
})),
})
const api = {
/** GET /api/v1/repos/{owner}/{repo}/issues — shape normalized like `gh issue list --json`. */
listIssues: async ({ state = 'open', labels, limit = 200, page = 1 } = {}) => {
const issues = await request(`/repos/${owner}/${name}/issues`, {
query: { state, labels, limit, page, type: 'issues' },
})
return (Array.isArray(issues) ? issues : []).map(normalizeIssue)
},
getIssue: async (number) => {
const issue = await request(`/repos/${owner}/${name}/issues/${number}`)
return normalizeIssue(issue)
},
createIssue: async ({ title, body }) => {
const issue = await request(`/repos/${owner}/${name}/issues`, {
method: 'POST',
body: { title, body },
})
return normalizeIssue(issue)
},
/** Adds labels (never removes). */
addLabels: (number, labels) =>
request(`/repos/${owner}/${name}/issues/${number}/labels`, {
method: 'POST',
body: { labels },
}),
/** Removes labels by name (resolves ids, ignores unknown names). */
removeLabels: async (number, labels) => {
const current = await request(`/repos/${owner}/${name}/issues/${number}/labels`)
for (const label of current ?? []) {
if (labels.includes(label.name)) {
await request(`/repos/${owner}/${name}/issues/${number}/labels/${label.id}`, {
method: 'DELETE',
})
}
}
},
/**
* @param {number} number
* @param {{ add?: string[], remove?: string[] }} [options]
*/
setLabels: async (number, { add = [], remove = [] } = {}) => {
if (remove.length > 0) await api.removeLabels(number, remove)
if (add.length > 0) await api.addLabels(number, add)
},
addComment: (number, body) =>
request(`/repos/${owner}/${name}/issues/${number}/comments`, {
method: 'POST',
body: { body },
}),
listIssueComments: async (number) => {
const comments = await request(`/repos/${owner}/${name}/issues/${number}/comments`)
return (Array.isArray(comments) ? comments : []).map((comment) => ({
body: comment.body ?? '',
user: comment.user?.login ?? '',
}))
},
listPullRequestFiles: async (number) => {
const files = await request(`/repos/${owner}/${name}/pulls/${number}/files`)
return (Array.isArray(files) ? files : []).map((file) => ({
filename: file.filename,
status: file.status,
}))
},
/** GET /api/v1/repos/{owner}/{repo}/pulls — raw, normalized lightly. */
listPullRequests: async ({ state = 'open', limit = 100 } = {}) => {
const pulls = await request(`/repos/${owner}/${name}/pulls`, {
query: { state, limit },
})
return (Array.isArray(pulls) ? pulls : []).map((pr) => ({
number: pr.number,
title: pr.title,
state: normalizeState(pr.state),
body: pr.body ?? '',
head: { ref: pr.head?.ref, sha: pr.head?.sha },
base: { ref: pr.base?.ref },
}))
},
getPullRequest: async (number) => {
const pr = await request(`/repos/${owner}/${name}/pulls/${number}`)
return {
number: pr.number,
title: pr.title,
state: normalizeState(pr.state),
merged: Boolean(pr.merged),
mergeable: pr.mergeable,
isDraft: Boolean(pr.is_draft),
body: pr.body ?? '',
head: { ref: pr.head?.ref, sha: pr.head?.sha },
base: { ref: pr.base?.ref },
}
},
/** Marks a draft PR as ready for review (PATCH is_draft=false). */
markPullRequestReady: (number) =>
request(`/repos/${owner}/${name}/pulls/${number}`, {
method: 'PATCH',
body: { is_draft: false },
}),
createPullRequest: async ({ head, base, title, body }) => {
const pr = await request(`/repos/${owner}/${name}/pulls`, {
method: 'POST',
body: { head, base, title, body },
})
return { number: pr.number, title: pr.title, htmlUrl: pr.html_url }
},
/**
* Commit statuses of a sha (Forgejo Actions check runs surface here).
* Returns [{ context, state }] with state ∈ success|failure|pending|error.
*/
listCommitStatuses: async (sha) => {
const statuses = await request(`/repos/${owner}/${name}/commits/${sha}/status`)
const combined = statuses?.statuses ?? []
return combined.map((entry) => ({ context: entry.context, state: entry.status }))
},
/** Merges a PR with rebase (the repo's canonical merge style). */
mergePullRequest: (number) =>
request(`/repos/${owner}/${name}/pulls/${number}/merge`, {
method: 'POST',
body: { Do: 'rebase' },
}),
/** Triggers a workflow via workflow_dispatch. */
workflowDispatch: (workflowFile, { ref = 'main', inputs = {} } = {}) =>
request(`/repos/${owner}/${name}/actions/workflows/${workflowFile}/dispatches`, {
method: 'POST',
body: { ref, inputs },
}),
/** GET /api/v1/repos/{owner}/{repo}/contents/{path}?ref=… — { content, sha } base64-decoded. */
getFileContents: async (path, ref = 'main') => {
const file = await request(`/repos/${owner}/${name}/contents/${path}`, {
query: { ref },
})
return {
content: Buffer.from(file.content ?? '', 'base64').toString('utf8'),
sha: file.sha,
}
},
/** POST /contents — creates a file on a branch (fails if it exists). */
createFile: (path, { message, content, branch }) =>
request(`/repos/${owner}/${name}/contents/${path}`, {
method: 'POST',
body: {
message,
content: Buffer.from(content, 'utf8').toString('base64'),
branch,
},
}),
/** PUT /contents — updates an existing file (sha required). */
updateFile: (path, { message, content, branch, sha }) =>
request(`/repos/${owner}/${name}/contents/${path}`, {
method: 'PUT',
body: {
message,
content: Buffer.from(content, 'utf8').toString('base64'),
branch,
sha,
},
}),
/** POST /api/v1/repos/{owner}/{repo}/branch_protections (Forgejo rule-based protection). */
updateBranchProtection: (branch, payload) =>
request(`/repos/${owner}/${name}/branch_protections`, {
method: 'POST',
body: { ...payload, branch_name: branch },
}),
/** All required statuses on the PR head are success (or a failure exists). */
waitForChecks: async (number, { timeoutMs = 30 * 60 * 1000, pollMs = 15000, log } = {}) => {
const started = Date.now()
for (;;) {
const pr = await api.getPullRequest(number)
if (!pr || pr.state !== 'OPEN' || pr.merged) return pr
const statuses = await api.listCommitStatuses(pr.head.sha)
const failed = statuses.filter(
(entry) => entry.state === 'failure' || entry.state === 'error',
)
const pending = statuses.filter((entry) => entry.state === 'pending')
if (failed.length > 0) {
const names = failed.map((entry) => entry.context).join(', ')
throw new Error(`Checks falharam no PR #${number}: ${names}`)
}
if (statuses.length > 0 && pending.length === 0) return pr
if (Date.now() - started > timeoutMs) {
throw new Error(`Timeout esperando checks do PR #${number}.`)
}
if (log)
log(`[pr#${number}] ${statuses.length} status, ${pending.length} pendente — aguardando…`)
await new Promise((resolve) => setTimeout(resolve, pollMs))
}
},
/** Wait for checks then merge by rebase (the `gh pr merge --auto --rebase` equivalent). */
autoMerge: async (number, options) => {
const pr = await api.waitForChecks(number, options)
if (pr && !pr.merged && pr.state === 'OPEN') {
return api.mergePullRequest(number)
}
return null
},
}
return api
}
/** Default instance bound to the environment. */
export const forgejoApi = createApi({})

View file

@ -106,12 +106,12 @@ import {
claimQueueEntry,
claimTargetVerdict,
dieAgent,
ghJson,
issuesById,
nextClaimableIssue,
parseArgs,
} from './lib/agent-github.mjs'
} from './lib/agent-forgejo.mjs'
import { startSharedPostgres } from './lib/db-start.mjs'
import { forgejoApi as api } from './lib/forgejo-api.mjs'
import {
DEV_PORT_BASE,
GENERATED_ENV_MARKER,
@ -402,23 +402,17 @@ const provision = async ({ dir, branch, issue, env, skipMigrate, mainRoot, purpo
* derivação/validação do branch — uma Issue sem frontmatter id (ou branch
* inválido) morre antes do flip de labels, nunca deixando claim órfão.
*/
const pickNextIssue = ({ requestedIssueNumber, die }) => {
const pickNextIssue = async ({ requestedIssueNumber, die }) => {
if (requestedIssueNumber !== null) {
const raw = String(requestedIssueNumber)
const number = Number(raw)
if (!Number.isInteger(number) || number <= 0) {
die(`--issue inválido: ${raw}`)
}
const target = ghJson([
'issue',
'view',
raw,
'--json',
'number,title,body,labels,createdAt,state',
])
const target = await api.getIssue(number)
const verdict = claimTargetVerdict(target)
if (verdict.kind === 'error') die(verdict.message)
const entry = claimQueueEntry(target, issuesById())
const entry = claimQueueEntry(target, await issuesById())
if (verdict.kind === 'reopen') {
// Sessão já claimada — reabrir não re-claima (reopen é sobre a sessão,
// nunca sobre a fila: deps atuais não importam).
@ -430,7 +424,7 @@ const pickNextIssue = ({ requestedIssueNumber, die }) => {
return { entry, reopened: false, directed: true }
}
const pick = nextClaimableIssue()
const pick = await nextClaimableIssue()
if (!pick) {
die('Fila vazia — nada `ready` desbloqueado. Rode `pnpm agent:status` para ver a fila.')
}
@ -438,7 +432,7 @@ const pickNextIssue = ({ requestedIssueNumber, die }) => {
}
const cmdNext = async (stay, skipMigrate, requestedIssueNumber) => {
const { entry, reopened, directed } = pickNextIssue({ requestedIssueNumber, die })
const { entry, reopened, directed } = await pickNextIssue({ requestedIssueNumber, die })
const issue = entry.issue
const branch = branchNameForIssue({ ...issue, meta: entry.meta })
@ -449,7 +443,7 @@ const cmdNext = async (stay, skipMigrate, requestedIssueNumber) => {
// O claim flips labels — só depois da derivação do branch provar que a Issue
// tem tudo para virar worktree.
if (!reopened) claimIssue(entry, die)
if (!reopened) await claimIssue(entry, die)
const claimedIssueNumber = reopened ? null : issue.number
const headline = reopened

View file

@ -6,7 +6,7 @@ import {
buildClaimQueue,
claimQueueEntry,
claimTargetVerdict,
} from '../../scripts/lib/agent-github.mjs'
} from '../../scripts/lib/agent-forgejo.mjs'
import {
buildPoolQueue,
isAutonomousClaimable,

View file

@ -0,0 +1,212 @@
import { describe, expect, it } from 'vitest'
import { createApi } from '../../scripts/lib/forgejo-api.mjs'
type FetchCall = {
url: string
method?: string
init?: { headers?: Record<string, string>; method?: string; body?: string }
body?: unknown
}
const ok = (body: unknown, status = 200) =>
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
describe('forgejo-api', () => {
it('builds the request with token, JSON body and explicit User-Agent', async () => {
const calls: FetchCall[] = []
const api = createApi({
base: 'https://git.solla.dev/api/v1',
token: 'tok',
repository: 'fsolla/teqo',
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init })
return ok({ number: 1, title: 'x', body: '', state: 'open', created_at: '', labels: [] })
},
})
await api.createIssue({ title: 't', body: 'b' })
expect(calls).toHaveLength(1)
expect(calls[0].url).toBe('https://git.solla.dev/api/v1/repos/fsolla/teqo/issues')
expect(calls[0].init!.method).toBe('POST')
expect(calls[0].init!.headers!.Authorization).toBe('token tok')
expect(calls[0].init!.headers!['User-Agent']).toContain('teqo-agent-scripts')
expect(JSON.parse(calls[0].init!.body!)).toEqual({ title: 't', body: 'b' })
})
it('defaults the base URL to git.solla.dev with the default repo path', async () => {
const calls: FetchCall[] = []
const api = createApi({
token: 'tok',
fetchImpl: async (url) => {
calls.push({ url: String(url) })
return ok([{ number: 1, title: '', body: '', state: 'open', created_at: '', labels: [] }])
},
})
await api.listIssues()
expect(calls[0].url).toContain('https://git.solla.dev/api/v1/repos/fsolla/teqo/issues?')
})
it('normalizes state and labels to the gh-flavored contract', async () => {
const api = createApi({
token: 'tok',
fetchImpl: async () =>
ok({
number: 7,
title: 't',
body: 'b',
state: 'closed',
created_at: '2026-01-01T00:00:00Z',
labels: [{ name: 'ready', color: '0E8A16' }],
}),
})
const issue = await api.getIssue(7)
expect(issue.state).toBe('CLOSED')
expect(issue.labels).toEqual([{ name: 'ready', color: '0E8A16' }])
expect(issue.createdAt).toBe('2026-01-01T00:00:00Z')
})
it('throws with the API error body on non-2xx', async () => {
const api = createApi({
token: 'tok',
fetchImpl: async () => ok('{"message":"nope"}', 403),
})
await expect(api.listIssues()).rejects.toThrow(/403/)
await expect(api.listIssues()).rejects.toThrow(/nope/)
})
it('fails closed when no token is available', async () => {
delete process.env.FORGEJO_API_TOKEN
delete process.env.GITHUB_TOKEN
const api = createApi({ fetchImpl: async () => ok('[]') })
await expect(api.listIssues()).rejects.toThrow(/Sem token/)
})
it('setLabels removes by id and adds by name', async () => {
const calls: FetchCall[] = []
const api = createApi({
token: 'tok',
fetchImpl: async (url, init) => {
calls.push({ url: String(url), method: init?.method, body: init?.body })
if (String(url).endsWith('/labels') && init?.method === 'GET') {
return ok([{ name: 'blocked', id: 42 }])
}
return ok(null)
},
})
await api.setLabels(5, { add: ['ready'], remove: ['blocked'] })
expect(calls.map((call) => [call.method, call.url])).toEqual([
['GET', 'https://git.solla.dev/api/v1/repos/fsolla/teqo/issues/5/labels'],
['DELETE', 'https://git.solla.dev/api/v1/repos/fsolla/teqo/issues/5/labels/42'],
['POST', 'https://git.solla.dev/api/v1/repos/fsolla/teqo/issues/5/labels'],
])
expect(JSON.parse(calls[2].body as string)).toEqual({ labels: ['ready'] })
})
it('workflowDispatch posts inputs and ref', async () => {
const calls: FetchCall[] = []
const api = createApi({
token: 'tok',
fetchImpl: async (url, init) => {
calls.push({ url: String(url), body: JSON.parse(String(init?.body ?? '{}')) })
return ok(null)
},
})
await api.workflowDispatch('agent-pool.yml', { ref: 'main', inputs: { action: 'tick' } })
expect(calls[0].url).toContain('/actions/workflows/agent-pool.yml/dispatches')
expect(calls[0].body).toEqual({ ref: 'main', inputs: { action: 'tick' } })
})
it('merges with rebase (repo canonical merge style)', async () => {
const calls: FetchCall[] = []
const api = createApi({
token: 'tok',
fetchImpl: async (url, init) => {
calls.push({ url: String(url), body: JSON.parse(String(init?.body ?? '{}')) })
return ok(null)
},
})
await api.mergePullRequest(4)
expect(calls[0].url).toContain('/pulls/4/merge')
expect(calls[0].body).toEqual({ Do: 'rebase' })
})
it('waitForChecks returns once every status is settled and no failure exists', async () => {
let poll = 0
const api = createApi({
token: 'tok',
fetchImpl: async (url) => {
if (String(url).includes('/pulls/')) {
return ok({
number: 4,
state: 'open',
merged: false,
mergeable: true,
head: { ref: 'x', sha: 'abc' },
})
}
poll += 1
const statuses =
poll < 2
? [{ context: 'CI / static', status: 'pending' }]
: [{ context: 'CI / static', status: 'success' }]
return ok({ statuses })
},
})
const pr = await api.waitForChecks(4, { pollMs: 1 })
expect(pr).toBeTruthy()
expect(poll).toBe(2)
})
it('getFileContents decodes base64 and updateFile sends sha', async () => {
const calls: FetchCall[] = []
const api = createApi({
token: 'tok',
fetchImpl: async (url, init) => {
calls.push({
url: String(url),
method: init?.method,
body: init?.body ? JSON.parse(String(init.body)) : null,
})
if (init?.method === 'GET') {
return ok({ content: Buffer.from('{"a":1}').toString('base64'), sha: 'shasha' })
}
return ok(null)
},
})
const file = await api.getFileContents('pool-state.json', 'pool-state')
expect(file.content).toBe('{"a":1}')
expect(file.sha).toBe('shasha')
await api.updateFile('pool-state.json', {
message: 'm',
content: '{"a":2}',
branch: 'pool-state',
sha: 'shasha',
})
expect(calls.at(-1)!.method).toBe('PUT')
expect(
Buffer.from((calls.at(-1)!.body as { content: string }).content, 'base64').toString(),
).toBe('{"a":2}')
expect((calls.at(-1)!.body as { sha: string }).sha).toBe('shasha')
})
})

View file

@ -166,7 +166,7 @@ describe('e2eShardConfig (OPS34)', () => {
it('pins the ci.yml matrix literal against the single source', () => {
const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '../..')
const workflow = readFileSync(join(repoRoot, '.github/workflows/ci.yml'), 'utf8')
const workflow = readFileSync(join(repoRoot, '.forgejo/workflows/ci.yml'), 'utf8')
const { matrix } = e2eShardConfig('full')
expect(workflow).toContain(`shard: [${matrix.join(', ')}]`)
})