From f20305759a17bf87ff0396a5267cf5eaabfda73d Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 16 Aug 2026 23:32:20 -0300 Subject: [PATCH] OPS50: port CI e scripts de agentes do GitHub para o Forgejo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../workflows/agent-pool.yml | 21 +- .../workflows/agent-pr-ready-automerge.yml | 34 ++ .../workflows/archive-cursor-agent.yml | 0 {.github => .forgejo}/workflows/ci-pr.yml | 81 ++--- {.github => .forgejo}/workflows/ci.yml | 73 ++-- .../workflows/issue-done-on-main-merge.yml | 23 ++ .../plan-issue-ready-on-main-merge.yml | 14 +- .forgejo/workflows/spike-services.yml | 39 --- .../workflows/vercel-promote.yml | 3 - .../cancel-workflow-on-failure/action.yml | 7 - .../workflows/agent-pr-ready-automerge.yml | 44 --- .../workflows/issue-done-on-main-merge.yml | 28 -- docs/CHANGELOG-AGENTS.md | 2 + docs/changelog/2026-08-16-ops50.md | 1 + .../ops50-ci-github-para-forgejo-impl.md | 88 +++++ scripts/agent-claim.mjs | 24 +- scripts/agent-file-miss.mjs | 20 +- scripts/agent-pool.mjs | 105 +++--- scripts/agent-prioritize.mjs | 7 +- scripts/agent-promote-related-on-merge.mjs | 22 +- scripts/agent-ready.mjs | 22 +- scripts/agent-register.mjs | 18 +- scripts/agent-status.mjs | 19 +- scripts/configure-branch-protection.mjs | 53 +-- scripts/forgejo-dispatch.mjs | 32 ++ scripts/forgejo-issue-transition.mjs | 67 ++++ scripts/forgejo-pr-automerge.mjs | 59 ++++ scripts/issue.mjs | 27 +- .../{agent-github.mjs => agent-forgejo.mjs} | 80 ++--- scripts/lib/agent-plan-lifecycle.mjs | 2 +- scripts/lib/agent-pool-eligibility.mjs | 6 +- scripts/lib/agent-pool-forgejo.mjs | 131 ++++++++ scripts/lib/agent-pool-github.mjs | 150 --------- scripts/lib/agent-pool-state.mjs | 2 +- scripts/lib/forgejo-api.mjs | 311 ++++++++++++++++++ scripts/worktree.mjs | 22 +- tests/unit/agentPoolEligibility.unit.spec.ts | 2 +- tests/unit/forgejoApi.unit.spec.ts | 212 ++++++++++++ tests/unit/testAffected.unit.spec.ts | 2 +- 39 files changed, 1195 insertions(+), 658 deletions(-) rename {.github => .forgejo}/workflows/agent-pool.yml (81%) create mode 100644 .forgejo/workflows/agent-pr-ready-automerge.yml rename {.github => .forgejo}/workflows/archive-cursor-agent.yml (100%) rename {.github => .forgejo}/workflows/ci-pr.yml (85%) rename {.github => .forgejo}/workflows/ci.yml (82%) create mode 100644 .forgejo/workflows/issue-done-on-main-merge.yml rename {.github => .forgejo}/workflows/plan-issue-ready-on-main-merge.yml (80%) delete mode 100644 .forgejo/workflows/spike-services.yml rename {.github => .forgejo}/workflows/vercel-promote.yml (98%) delete mode 100644 .github/actions/cancel-workflow-on-failure/action.yml delete mode 100644 .github/workflows/agent-pr-ready-automerge.yml delete mode 100644 .github/workflows/issue-done-on-main-merge.yml create mode 100644 docs/changelog/2026-08-16-ops50.md create mode 100644 docs/plans/ops50-ci-github-para-forgejo-impl.md create mode 100644 scripts/forgejo-dispatch.mjs create mode 100644 scripts/forgejo-issue-transition.mjs create mode 100644 scripts/forgejo-pr-automerge.mjs rename scripts/lib/{agent-github.mjs => agent-forgejo.mjs} (81%) create mode 100644 scripts/lib/agent-pool-forgejo.mjs delete mode 100644 scripts/lib/agent-pool-github.mjs create mode 100644 scripts/lib/forgejo-api.mjs create mode 100644 tests/unit/forgejoApi.unit.spec.ts diff --git a/.github/workflows/agent-pool.yml b/.forgejo/workflows/agent-pool.yml similarity index 81% rename from .github/workflows/agent-pool.yml rename to .forgejo/workflows/agent-pool.yml index cc17b58f..4d5ed619 100644 --- a/.github/workflows/agent-pool.yml +++ b/.forgejo/workflows/agent-pool.yml @@ -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 }} diff --git a/.forgejo/workflows/agent-pr-ready-automerge.yml b/.forgejo/workflows/agent-pr-ready-automerge.yml new file mode 100644 index 00000000..39ffbc06 --- /dev/null +++ b/.forgejo/workflows/agent-pr-ready-automerge.yml @@ -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 }}" diff --git a/.github/workflows/archive-cursor-agent.yml b/.forgejo/workflows/archive-cursor-agent.yml similarity index 100% rename from .github/workflows/archive-cursor-agent.yml rename to .forgejo/workflows/archive-cursor-agent.yml diff --git a/.github/workflows/ci-pr.yml b/.forgejo/workflows/ci-pr.yml similarity index 85% rename from .github/workflows/ci-pr.yml rename to .forgejo/workflows/ci-pr.yml index 3cfe13cc..85679ae4 100644 --- a/.github/workflows/ci-pr.yml +++ b/.forgejo/workflows/ci-pr.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.forgejo/workflows/ci.yml similarity index 82% rename from .github/workflows/ci.yml rename to .forgejo/workflows/ci.yml index 591ca560..5aa7652f 100644 --- a/.github/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -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" diff --git a/.forgejo/workflows/issue-done-on-main-merge.yml b/.forgejo/workflows/issue-done-on-main-merge.yml new file mode 100644 index 00000000..aefe95f0 --- /dev/null +++ b/.forgejo/workflows/issue-done-on-main-merge.yml @@ -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 }}" diff --git a/.github/workflows/plan-issue-ready-on-main-merge.yml b/.forgejo/workflows/plan-issue-ready-on-main-merge.yml similarity index 80% rename from .github/workflows/plan-issue-ready-on-main-merge.yml rename to .forgejo/workflows/plan-issue-ready-on-main-merge.yml index bac75572..64438507 100644 --- a/.github/workflows/plan-issue-ready-on-main-merge.yml +++ b/.forgejo/workflows/plan-issue-ready-on-main-merge.yml @@ -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 }}" diff --git a/.forgejo/workflows/spike-services.yml b/.forgejo/workflows/spike-services.yml deleted file mode 100644 index 47b7e4e6..00000000 --- a/.forgejo/workflows/spike-services.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/vercel-promote.yml b/.forgejo/workflows/vercel-promote.yml similarity index 98% rename from .github/workflows/vercel-promote.yml rename to .forgejo/workflows/vercel-promote.yml index c9f3202a..86e5bdb2 100644 --- a/.github/workflows/vercel-promote.yml +++ b/.forgejo/workflows/vercel-promote.yml @@ -12,9 +12,6 @@ on: type: string default: '' -permissions: - contents: read - jobs: promote: runs-on: ubuntu-latest diff --git a/.github/actions/cancel-workflow-on-failure/action.yml b/.github/actions/cancel-workflow-on-failure/action.yml deleted file mode 100644 index 8b45f510..00000000 --- a/.github/actions/cancel-workflow-on-failure/action.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/agent-pr-ready-automerge.yml b/.github/workflows/agent-pr-ready-automerge.yml deleted file mode 100644 index eb054f1b..00000000 --- a/.github/workflows/agent-pr-ready-automerge.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/issue-done-on-main-merge.yml b/.github/workflows/issue-done-on-main-merge.yml deleted file mode 100644 index ecba387a..00000000 --- a/.github/workflows/issue-done-on-main-merge.yml +++ /dev/null @@ -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 diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 277b6dcc..1c293922 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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. diff --git a/docs/changelog/2026-08-16-ops50.md b/docs/changelog/2026-08-16-ops50.md new file mode 100644 index 00000000..75d03fa2 --- /dev/null +++ b/docs/changelog/2026-08-16-ops50.md @@ -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). \ No newline at end of file diff --git a/docs/plans/ops50-ci-github-para-forgejo-impl.md b/docs/plans/ops50-ci-github-para-forgejo-impl.md new file mode 100644 index 00000000..c0929254 --- /dev/null +++ b/docs/plans/ops50-ci-github-para-forgejo-impl.md @@ -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 diff --git a/scripts/agent-claim.mjs b/scripts/agent-claim.mjs index e3267136..300a03c9 100644 --- a/scripts/agent-claim.mjs +++ b/scripts/agent-claim.mjs @@ -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) diff --git a/scripts/agent-file-miss.mjs b/scripts/agent-file-miss.mjs index bf56bada..994a0cd7 100644 --- a/scripts/agent-file-miss.mjs +++ b/scripts/agent-file-miss.mjs @@ -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}`) diff --git a/scripts/agent-pool.mjs b/scripts/agent-pool.mjs index 7aa52c37..79f21fc9 100644 --- a/scripts/agent-pool.mjs +++ b/scripts/agent-pool.mjs @@ -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) diff --git a/scripts/agent-prioritize.mjs b/scripts/agent-prioritize.mjs index bb19c2a6..eb85807c 100644 --- a/scripts/agent-prioritize.mjs +++ b/scripts/agent-prioritize.mjs @@ -4,7 +4,8 @@ * pnpm agent:prioritize -- 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 -- ') } -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}`, ) diff --git a/scripts/agent-promote-related-on-merge.mjs b/scripts/agent-promote-related-on-merge.mjs index 029e827b..633282c7 100644 --- a/scripts/agent-promote-related-on-merge.mjs +++ b/scripts/agent-promote-related-on-merge.mjs @@ -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( diff --git a/scripts/agent-ready.mjs b/scripts/agent-ready.mjs index 210b5156..bc8803b3 100644 --- a/scripts/agent-ready.mjs +++ b/scripts/agent-ready.mjs @@ -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 ') 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`) } diff --git a/scripts/agent-register.mjs b/scripts/agent-register.mjs index 108afaaa..1eae8474 100644 --- a/scripts/agent-register.mjs +++ b/scripts/agent-register.mjs @@ -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 diff --git a/scripts/agent-status.mjs b/scripts/agent-status.mjs index 5c618540..f126946d 100644 --- a/scripts/agent-status.mjs +++ b/scripts/agent-status.mjs @@ -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) => ({ diff --git a/scripts/configure-branch-protection.mjs b/scripts/configure-branch-protection.mjs index e95b3019..c946b32a 100644 --- a/scripts/configure-branch-protection.mjs +++ b/scripts/configure-branch-protection.mjs @@ -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).') diff --git a/scripts/forgejo-dispatch.mjs b/scripts/forgejo-dispatch.mjs new file mode 100644 index 00000000..6972dc38 --- /dev/null +++ b/scripts/forgejo-dispatch.mjs @@ -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 [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 [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) +} diff --git a/scripts/forgejo-issue-transition.mjs b/scripts/forgejo-issue-transition.mjs new file mode 100644 index 00000000..d89bbea2 --- /dev/null +++ b/scripts/forgejo-issue-transition.mjs @@ -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 + */ + +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 ') + 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})`, + ) + } +} diff --git a/scripts/forgejo-pr-automerge.mjs b/scripts/forgejo-pr-automerge.mjs new file mode 100644 index 00000000..b1ac0f1e --- /dev/null +++ b/scripts/forgejo-pr-automerge.mjs @@ -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 + */ + +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 ') + 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) +} diff --git a/scripts/issue.mjs b/scripts/issue.mjs index 9ccc363a..7c11cf6c 100644 --- a/scripts/issue.mjs +++ b/scripts/issue.mjs @@ -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()) diff --git a/scripts/lib/agent-github.mjs b/scripts/lib/agent-forgejo.mjs similarity index 81% rename from scripts/lib/agent-github.mjs rename to scripts/lib/agent-forgejo.mjs index 4bc0103c..2ff442a1 100644 --- a/scripts/lib/agent-github.mjs +++ b/scripts/lib/agent-forgejo.mjs @@ -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) => { diff --git a/scripts/lib/agent-plan-lifecycle.mjs b/scripts/lib/agent-plan-lifecycle.mjs index 9ae91f4f..749d765d 100644 --- a/scripts/lib/agent-plan-lifecycle.mjs +++ b/scripts/lib/agent-plan-lifecycle.mjs @@ -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' /** diff --git a/scripts/lib/agent-pool-eligibility.mjs b/scripts/lib/agent-pool-eligibility.mjs index e8c0af10..c09d7d7a 100644 --- a/scripts/lib/agent-pool-eligibility.mjs +++ b/scripts/lib/agent-pool-eligibility.mjs @@ -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[number]} entry queue entry from buildClaimQueue + * @param {ReturnType[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} claimQueue + * @param {ReturnType} claimQueue * @param {Object} [options] * @param {boolean} [options.migrationBusy] * @param {Map} [options.failureCountsByIssue] diff --git a/scripts/lib/agent-pool-forgejo.mjs b/scripts/lib/agent-pool-forgejo.mjs new file mode 100644 index 00000000..49c09ab1 --- /dev/null +++ b/scripts/lib/agent-pool-forgejo.mjs @@ -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'] }) diff --git a/scripts/lib/agent-pool-github.mjs b/scripts/lib/agent-pool-github.mjs deleted file mode 100644 index a760e100..00000000 --- a/scripts/lib/agent-pool-github.mjs +++ /dev/null @@ -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'] }) -} diff --git a/scripts/lib/agent-pool-state.mjs b/scripts/lib/agent-pool-state.mjs index 08bbf5d4..c0b07113 100644 --- a/scripts/lib/agent-pool-state.mjs +++ b/scripts/lib/agent-pool-state.mjs @@ -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 diff --git a/scripts/lib/forgejo-api.mjs b/scripts/lib/forgejo-api.mjs new file mode 100644 index 00000000..71545d6c --- /dev/null +++ b/scripts/lib/forgejo-api.mjs @@ -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 `/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; body?: string }) => Promise} [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({}) diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs index 4ac1a121..9b077f82 100644 --- a/scripts/worktree.mjs +++ b/scripts/worktree.mjs @@ -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 diff --git a/tests/unit/agentPoolEligibility.unit.spec.ts b/tests/unit/agentPoolEligibility.unit.spec.ts index ba16dd48..34ee0e37 100644 --- a/tests/unit/agentPoolEligibility.unit.spec.ts +++ b/tests/unit/agentPoolEligibility.unit.spec.ts @@ -6,7 +6,7 @@ import { buildClaimQueue, claimQueueEntry, claimTargetVerdict, -} from '../../scripts/lib/agent-github.mjs' +} from '../../scripts/lib/agent-forgejo.mjs' import { buildPoolQueue, isAutonomousClaimable, diff --git a/tests/unit/forgejoApi.unit.spec.ts b/tests/unit/forgejoApi.unit.spec.ts new file mode 100644 index 00000000..b334b74b --- /dev/null +++ b/tests/unit/forgejoApi.unit.spec.ts @@ -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; 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') + }) +}) diff --git a/tests/unit/testAffected.unit.spec.ts b/tests/unit/testAffected.unit.spec.ts index b5a88abb..734fab4f 100644 --- a/tests/unit/testAffected.unit.spec.ts +++ b/tests/unit/testAffected.unit.spec.ts @@ -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(', ')}]`) })