OPS50: port CI e scripts de agentes do GitHub para o Forgejo
Workflows nativos em .forgejo/workflows/ (ci, ci-pr, agent-pool, automerge, issue-transition, plan-issue, vercel-promote, archive), removendo .github/workflows e .github/actions. Runner Forgejo v13 com services Postgres por porta + DB_HOST via gateway do bridge. Libs de agente sobre o novo cliente forgejo-api.mjs (zero dependências, GITHUB_SERVER_URL/FORGEJO_API_URL), helpers de workflow plain-Node. pnpm issue corrigido (await em função sync) e validado ao vivo. Testes: forgejoApi.unit.spec.ts novo, pin do matrix do ci.yml atualizado. Closes #1 (parte 1 — docs/issues/pool seguem na parte 2)
This commit is contained in:
parent
94204cfb5e
commit
f20305759a
39 changed files with 1195 additions and 658 deletions
|
|
@ -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 }}
|
||||
34
.forgejo/workflows/agent-pr-ready-automerge.yml
Normal file
34
.forgejo/workflows/agent-pr-ready-automerge.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Safety net: agent PRs (cursor/* → main) must be Ready + auto-merge.
|
||||
# Covers Cloud ManagePullRequest defaulting to draft and agents that forget
|
||||
# to arm the merge. No skill opt-out (engineering-audit included).
|
||||
#
|
||||
# Forgejo port (OPS50): Forgejo's API has no auto-merge scheduling, so the
|
||||
# workflow runs scripts/forgejo-pr-automerge.mjs (plain Node): marks draft →
|
||||
# ready, waits for the commit statuses (the branch-protection `checks` gate),
|
||||
# then merges by rebase. Same semantics as `gh pr merge --auto --rebase`,
|
||||
# implemented as poll + merge. GitHub's cancel-workflow-on-failure action was
|
||||
# dropped (no GITHUB_RUN_ID plumbing on Forgejo).
|
||||
name: Agent PR Ready + auto-merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
ready-automerge:
|
||||
if: >-
|
||||
github.event.pull_request.state == 'open' &&
|
||||
startsWith(github.event.pull_request.head.ref, 'cursor/')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
|
||||
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Mark Ready and merge when checks pass
|
||||
run: node scripts/forgejo-pr-automerge.mjs --pr "${{ github.event.pull_request.number }}"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
23
.forgejo/workflows/issue-done-on-main-merge.yml
Normal file
23
.forgejo/workflows/issue-done-on-main-merge.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Main merge flips claimed issues to done + in-prod.
|
||||
# Forgejo port (OPS50): the gh loop became scripts/forgejo-issue-transition.mjs
|
||||
# (plain Node, reads the PR body from the API itself).
|
||||
name: Main merge flips claimed issues to done + in-prod
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
issue-done:
|
||||
if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
|
||||
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Flip Closes/Fixes #N issues to done and in-prod
|
||||
run: node scripts/forgejo-issue-transition.mjs --pr "${{ github.event.pull_request.number }}"
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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
|
||||
|
|
@ -12,9 +12,6 @@ on:
|
|||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -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
|
||||
44
.github/workflows/agent-pr-ready-automerge.yml
vendored
44
.github/workflows/agent-pr-ready-automerge.yml
vendored
|
|
@ -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
|
||||
28
.github/workflows/issue-done-on-main-merge.yml
vendored
28
.github/workflows/issue-done-on-main-merge.yml
vendored
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
1
docs/changelog/2026-08-16-ops50.md
Normal file
1
docs/changelog/2026-08-16-ops50.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**Recently resolved (2026-08-16):** **OPS50** (parte 1): CI e scripts de agentes portados do GitHub para o Forgejo (`git.solla.dev`, tracker já migrado nas Issues #1–#3), sem nenhuma dependência do GitHub Actions. Os 10 workflows de `.github/workflows/` viraram 8 nativos em `.forgejo/workflows/` (`ci.yml` com deploy gated + requeue por `forgejo-dispatch.mjs`, `ci-pr.yml` com cascade de jobs e checks agregador, `agent-pool.yml`, `agent-pr-ready-automerge.yml`, `issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`, `vercel-promote.yml`, `archive-cursor-agent.yml`) e `.github/workflows/*` + `.github/actions/` foram removidos (cancel-workflow-on-failure não existe no Forgejo). Novo runner: Forgejo runner v13 na workstation (capacity 4), jobs paralelos em serviços Postgres isolados por porta (int 5433, build 5434, e2e shards 5435/5436) com `DB_HOST` calculado do gateway do bridge Docker (byte order corrigida via `strtonum`). Libs de agente: `agent-github.mjs`/`agent-pool-github.mjs` substituídas por `agent-forgejo.mjs` + `agent-pool-forgejo.mjs` sobre o novo cliente zero-dependência `forgejo-api.mjs` (token `FORGEJO_API_TOKEN`/`GITHUB_TOKEN`, base URL `FORGEJO_API_URL`/`GITHUB_SERVER_URL`/default, shapes normalizados ao contrato gh-flavored; novos `isDraft`/`markPullRequestReady`); helpers `forgejo-dispatch.mjs`, `forgejo-pr-automerge.mjs` (poll de checks + ready de draft), `forgejo-issue-transition.mjs`; auto-merge do PR de planos já validado em produção (PR #4). Bug de port corrigido em `pnpm issue` (await em função sync) e validado ao vivo contra o Forgejo. Testes: `forgejoApi.unit.spec.ts` novo (10 testes), pin do matrix do `ci.yml` movido para `.forgejo/workflows/`; tsc/lint/prettier/knip/check:cycles/2283 unit verdes. Pendente (parte 2): migrar as Issues abertas do GitHub, reescrever `.agents/`, `AGENTS.md`, `docs/AGENT-OPS.md` e `docs/roadmap.md`, e passos humanos (secrets no Forgejo, `pnpm configure:branch-protection`, arquivar o repo GitHub).
|
||||
88
docs/plans/ops50-ci-github-para-forgejo-impl.md
Normal file
88
docs/plans/ops50-ci-github-para-forgejo-impl.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Impl: Migrar o CI e o paradigma de agentes do GitHub para o Forgejo
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-16
|
||||
Issue: #1
|
||||
Intenção: docs/plans/ops50-ci-github-para-forgejo.md
|
||||
Appetite restante: ~2–3 dias eng
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** zero dependência do GitHub — CI, Issues, PRs, pool e docs rodando nativamente no Forgejo, sem fallback.
|
||||
- **O que NÃO negociar:** deploy de produção continua gated pelo CI; runner não é re-registrado sem necessidade; nenhum push re-dispara builds Git da Vercel; uma fonte única de tracker (Forgejo).
|
||||
- **O que reavaliar:** a hipótese de direção está correta, mas a exploração revelou **3 fatos novos** que mudam a abordagem:
|
||||
1. **CI do Forgejo está verde de mentira hoje.** O service `postgres:17-alpine` dos jobs falha no runner (`Bind for 0.0.0.0:5432 failed: port is already allocated` — a workstation tem o Postgres canônico na 5432) e o act_runner v0.2.11 **marca o job como success mesmo assim** (prova: run 119 — static/int/build/e2e×2 "passaram" em ~4s cada). O outcome "CI verde no Forgejo" exige corrigir isso e **provar com evidência** (duração/log), não com badge.
|
||||
2. **`gh` não existe em nenhuma máquina de trabalho** e os workflows chamam `gh` direto (requeue, automerge, labels). A API REST do Forgejo é GitHub-compatível e os scripts já são Node — a porta é direta, sem binário.
|
||||
3. **A API externa do Forgejo passa pela Cloudflare** (erro 1010 para clientes sem UA de navegador) — o pool (workers Cursor Cloud) precisa de caminho testado, não assumido.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[workflows .forgejo/] --> B[runner workstation]
|
||||
B --> C[CI real + service pg na 5433]
|
||||
D[scripts agent-*] --> E[lib forgejo-api.mjs]
|
||||
E --> F[API git.solla.dev]
|
||||
F --> G[Issues/PRs/variables]
|
||||
H[pool] --> E
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A)** `gh` CLI com `GH_HOST=git.solla.dev` — exige instalar o binário em 3 lugares (workstation, workers Cursor Cloud, imagem do runner) e `gh pr merge --auto` não é suportado na API do Forgejo. **Rejeitada.**
|
||||
- **B)** API REST direta via `fetch` do Node 24, numa lib nova `scripts/lib/forgejo-api.mjs`, mantendo a superfície de comandos (`pnpm agent:*`, `pnpm issue`). Sem dependência de binário, funciona em todo lugar com um token (`FORGEJO_API_TOKEN` ou o `GITHUB_TOKEN` nativo do Forgejo Actions). **Recomendada** — é a recomendação B já validada no gate da intenção.
|
||||
- **C)** `tea` (CLI do Gitea) — outro binário externo, menos maduro que a API. **Rejeitada.**
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`scripts/lib/forgejo-api.mjs`** (novo): cliente REST mínimo (fetch + headers `Authorization: token` + `User-Agent` explícito para passar o WAF), funções `issueList/get/create/updateLabels/comment`, `prCreate/mergeWhenChecksSucceed`, `workflowDispatch`, `actionsVariables`. Node puro (fetch nativo), sem deps — usável dentro de jobs de workflow sem `pnpm install`.
|
||||
- **`scripts/lib/agent-github.mjs`** (substituído): as funções `gh`/`ghJson` morrem; os callers passam a usar `forgejo-api.mjs`. Os helpers de frontmatter/labels/prioridade permanecem (são agnósticos de host). URLs de output viram `git.solla.dev/fsolla/teqo`.
|
||||
- **`scripts/agent-*.mjs` + `scripts/issue.mjs`**: mesma superfície (`register|claim|ready|prioritize|status|file-miss|pool`), backend retargetado. O guard de "só Issues `blocked`+plano" etc. não muda.
|
||||
- **`scripts/agent-pool-*.mjs`**: pool passa a listar/claimar no Forgejo; estado do pool em **Actions variables do Forgejo** (endpoint existe em Forgejo 9 — confirmar no spike); `POOL_GITHUB_TOKEN` → `POOL_FORGEJO_TOKEN` (PAT criado por humano).
|
||||
- **`.github/workflows/*.yml` → `.forgejo/workflows/*`** (8 arquivos + `.github/actions/cancel-workflow-on-failure` → `.forgejo/actions/`):
|
||||
- service postgres **por job em porta própria** (host do runner tem a 5432 canônica ocupada; services publicam na porta do host): int=5433, build=5434, e2e×2=5435/5436 + `DATABASE_URL` dos jobs correspondente — elimina colisão e permite concorrência;
|
||||
- **capacidade do runner sobe de 1 para 4** (`capacity: 4` no `~/.act_runner/config.yaml` da workstation + `systemctl --user restart act-runner` — configuração, não re-registro; workstation tem 16 cores/61GB, folga medida);
|
||||
- cache de browsers do Playwright via `actions/cache` (cache server do runner já habilitado);
|
||||
- chamadas `gh …` dentro dos jobs substituídas por scripts Node da lib (requeue via `workflowDispatch`, automerge via poll+merge, labels/comments via API);
|
||||
- `ci.yml` mantém o deploy Vercel gated (até o cutover de hospedagem, fora deste item);
|
||||
- verificação de honestidade: o job `checks` passa a exigir evidência de execução real (os jobs escrevem duração num output lido pelo checks; spike define o mecanismo mais simples).
|
||||
- **Tracker:** migrar as **11 Issues abertas do GitHub** para o Forgejo (mesmo frontmatter + labels), via script one-shot (lê API pública do GitHub, cria no Forgejo). Histórico GitHub congela (arquivar o repo é passo humano final).
|
||||
- **Docs/skills/rules (59 arquivos em `.agents/` + `AGENTS.md` + `docs/AGENT-OPS.md` + `docs/roadmap.md`):** reescrever referências — `gh pr …` → scripts equivalentes, github.com/fsolla/teqo → git.solla.dev/fsolla/teqo, "GitHub Issues" → "Issues do Forgejo". Nomes de comandos `agent:*` preservados para minimizar diff.
|
||||
- **`scripts/configure-branch-protection.mjs`**: portado para o Forgejo (POST `/branch_protections`, required status checks com os contextos reais observados após P1). Execução é passo humano (`pnpm configure:branch-protection`).
|
||||
- **Secrets (humano):** criar no Forgejo: `VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID`, `CURSOR_API_KEY`, `POOL_FORGEJO_TOKEN` (PAT com escrita no repo). O `GITHUB_TOKEN` nativo do Forgejo cobre o resto.
|
||||
- **Migration:** sem migration (não toca schema).
|
||||
- **Access / Consent:** N/A.
|
||||
- **UI:** A — sem UI.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Spike (probe, sem merge):** service postgres na 5433 alcançável do job? POST externo em git.solla.dev passa com UA (WAF)? Endpoints Forgejo 9: actions variables, merge com `merge_when_checks_succeed` (ou poll+merge), workflow dispatches, status checks observados.
|
||||
2. **Workflows nativos** — `.forgejo/workflows` completos; PR do próprio OPS50 roda e **prova** CI verde com evidência (duração real dos jobs, e2e de verdade).
|
||||
3. **Lib + scripts do agente** — `forgejo-api.mjs` + retarget de `agent-*`/`issue` + unit tests (request-building/labels/state com fetch injetado).
|
||||
4. **Pool** — `agent-pool-*` no Forgejo (state em variables), prompt/env dos workers atualizado.
|
||||
5. **Tracker + docs** — migração das 11 Issues abertas, reescrita dos 59 arquivos + docs canônicos; `infra-solla/STATE.md` anotado (repo separado).
|
||||
6. **Cutover + hardening** — branch protection aplicado (humano), secrets (humano), pool stop→start, arquivar GitHub (humano), checklist final (ciclo completo de Issue no Forgejo: register→claim→PR→merge→done).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Migrar histórico completo de Issues/PRs do GitHub — fora (congela).
|
||||
- Trocar o runner act_runner → forgejo-runner nativo — fora (o bug de false-green é contornado com porta + evidência; upgrade vira item futuro se aparecer outro).
|
||||
- Escalar além de capacity 4 / registrar runners extras — fora (capacidade 4 cobre o DAG atual; se o e2e crescer, item futuro).
|
||||
- Cutover de hospedagem Vercel→homeserver — OPS51/OPS52/infra §7.
|
||||
- Cloudflare WAF: se o spike provar bloqueio a clientes externos legítimos, o fix é regra WAF na Cloudflare (passo humano, creds em infra-solla) — registrado como débito se necessário.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **WAF bloqueia workers Cursor Cloud** (datacenter IPs). Mitigação: UA explícito no cliente; spike testa de fora; fallback = regra WAF `/api/v1/*` (humano) antes de P4.
|
||||
- **Service port mapeia para o host do runner** (semântica act_runner): portas por job (5433–5436) eliminam colisão, mas a verificação do spike é pré-condição de P2; fallback = subir o postgres como container via step (`docker.sock` já é bindado no job).
|
||||
- **Pool em transição:** entre o merge e o archive do GitHub, o tracker muda de lugar — durante a transição **não registrar Issues novas** (humano avisado no fechamento); o supervisor agenda continua no Forgejo a cada 10 min com o workflow novo após o merge.
|
||||
- **Deploy de prod vermelho até secrets no Forgejo** — o job já fail-closed em credencial ausente (requeue retorna quando os secrets entrarem).
|
||||
- **Capacity 4 com pico de memória:** 4 jobs pesados simultâneos ≈ 24–28GB — cabe (35Gi livres medidos); o spike mede um run real antes do cutover.
|
||||
- **59 arquivos de docs reescritos** — grep de saída (`gh pr`, `github.com/fsolla`, `GitHub Issues`) no gate para provar zero resíduo; knip/cycles cobrem imports.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (workflows nativos, scripts operacionais, docs Forgejo, secrets + branch protection, issues no Forgejo, deploy gated, runner intacto)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (sem segredos no repo; commits sem secrets; sem push direto fora do `pnpm push`)
|
||||
- [ ] Testes unit do lib `forgejo-api` (request/labels/state) onde os scripts mudam
|
||||
- [ ] Evidência de CI honesto: duração real dos jobs no run do próprio PR
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
* pnpm agent:prioritize -- <issue-number> P1
|
||||
*/
|
||||
|
||||
import { dieAgent, ghJson, labelNames, parseArgs, setLabels } from './lib/agent-github.mjs'
|
||||
import { dieAgent, labelNames, parseArgs, setLabels } from './lib/agent-forgejo.mjs'
|
||||
import { forgejoApi as api } from './lib/forgejo-api.mjs'
|
||||
|
||||
const die = dieAgent('prioritize')
|
||||
const { positional } = parseArgs(process.argv.slice(2), new Set())
|
||||
|
|
@ -14,9 +15,9 @@ if (!number || !/^P[0-3]$/.test(priority ?? '')) {
|
|||
die('Usage: pnpm agent:prioritize -- <issue-number> <P0|P1|P2|P3>')
|
||||
}
|
||||
|
||||
const issue = ghJson(['issue', 'view', number, '--json', 'number,title,labels'])
|
||||
const issue = await api.getIssue(number)
|
||||
const current = labelNames(issue).filter((label) => /^prio:P[0-3]$/.test(label))
|
||||
setLabels(number, { add: [`prio:${priority}`], remove: current })
|
||||
await setLabels(number, { add: [`prio:${priority}`], remove: current })
|
||||
console.log(
|
||||
`[agent:prioritize] #${issue.number} — ${issue.title}: ${current.join(',') || '(none)'} → prio:${priority}`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@
|
|||
* link stay for human triage.
|
||||
*/
|
||||
|
||||
import { dieAgent, gh, ghJson, parseArgs, setLabels } from './lib/agent-github.mjs'
|
||||
import { dieAgent, parseArgs, setLabels } from './lib/agent-forgejo.mjs'
|
||||
import { canPromotePlanIssue } from './lib/agent-plan-lifecycle.mjs'
|
||||
import { forgejoApi as api } from './lib/forgejo-api.mjs'
|
||||
|
||||
const die = dieAgent('ready')
|
||||
const { flags } = parseArgs(process.argv.slice(2), new Set(['issue']))
|
||||
|
|
@ -33,13 +34,7 @@ const numbers = String(flags.issue)
|
|||
if (numbers.length === 0) die('Usage: pnpm agent:ready -- --issue <N[,N…]>')
|
||||
|
||||
for (const number of numbers) {
|
||||
const issue = ghJson([
|
||||
'issue',
|
||||
'view',
|
||||
String(number),
|
||||
'--json',
|
||||
'number,title,body,state,labels',
|
||||
])
|
||||
const issue = await api.getIssue(number)
|
||||
const verdict = canPromotePlanIssue(issue)
|
||||
if (!verdict.ok) {
|
||||
if (verdict.reason === 'already-ready') {
|
||||
|
|
@ -49,13 +44,10 @@ for (const number of numbers) {
|
|||
die(`#${number} cannot mark ready (${verdict.reason}).`)
|
||||
}
|
||||
|
||||
setLabels(number, { add: ['ready'], remove: ['blocked'] })
|
||||
gh([
|
||||
'issue',
|
||||
'comment',
|
||||
String(number),
|
||||
'--body',
|
||||
await setLabels(number, { add: ['ready'], remove: ['blocked'] })
|
||||
await api.addComment(
|
||||
number,
|
||||
'Promovido a `ready`: plano de intenção em `main` (ciclo OPS17 — `pnpm agent:ready`).',
|
||||
])
|
||||
)
|
||||
console.log(`[agent:ready] #${number} blocked → ready`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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).')
|
||||
|
|
|
|||
32
scripts/forgejo-dispatch.mjs
Normal file
32
scripts/forgejo-dispatch.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Plain-Node CLI (no pnpm) — dispatch a Forgejo workflow via workflow_dispatch.
|
||||
* Used by CI jobs (requeue) and humans.
|
||||
*
|
||||
* node scripts/forgejo-dispatch.mjs <workflow-file> <ref> [key=value …]
|
||||
*/
|
||||
|
||||
import { createApi } from './lib/forgejo-api.mjs'
|
||||
|
||||
const [, , workflowFile, ref = 'main', ...inputPairs] = process.argv
|
||||
if (!workflowFile) {
|
||||
console.error('Usage: node scripts/forgejo-dispatch.mjs <workflow-file> [ref] [key=value …]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const inputs = {}
|
||||
for (const pair of inputPairs) {
|
||||
const eq = pair.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
inputs[pair.slice(0, eq)] = pair.slice(eq + 1)
|
||||
}
|
||||
|
||||
const api = createApi({})
|
||||
try {
|
||||
await api.workflowDispatch(workflowFile, { ref, inputs })
|
||||
console.log(
|
||||
`[forgejo-dispatch] ${workflowFile}@${ref} enviado${Object.keys(inputs).length > 0 ? ` (${Object.keys(inputs).join(', ')})` : ''}`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`[forgejo-dispatch] falhou: ${error instanceof Error ? error.message : error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
67
scripts/forgejo-issue-transition.mjs
Normal file
67
scripts/forgejo-issue-transition.mjs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Plain-Node CLI (no pnpm) — on merge to main, flip Issues cited as
|
||||
* `Closes #N` / `Fixes #N` in the PR body to `done` + `in-prod` and comment.
|
||||
* Replacement for the `gh issue edit/comment` loop of issue-done-on-main-merge.
|
||||
* The PR body comes from the API (Forgejo's PR webhook payload is not relied
|
||||
* on), so the workflow only needs the PR number.
|
||||
*
|
||||
* node scripts/forgejo-issue-transition.mjs --pr <N>
|
||||
*/
|
||||
|
||||
import { createApi } from './lib/forgejo-api.mjs'
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const flags = {}
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
if (arg.startsWith('--')) {
|
||||
const name = arg.slice(2)
|
||||
const next = argv[index + 1]
|
||||
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
|
||||
if (typeof next === 'string' && !next.startsWith('--')) index += 1
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
const flags = parseArgs(process.argv)
|
||||
const prNumber = Number(flags.pr)
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||
console.error('Usage: node scripts/forgejo-issue-transition.mjs --pr <N>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const api = createApi({})
|
||||
const pr = await api.getPullRequest(prNumber)
|
||||
if (!pr) {
|
||||
console.error(`[forgejo-issue-transition] PR #${prNumber} não encontrada`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (!pr.merged) {
|
||||
console.log(`[forgejo-issue-transition] PR #${prNumber} não mergeada — skip`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const numbers = [
|
||||
...new Set([...pr.body.matchAll(/(?:closes|fixes)\s+#(\d+)/gi)].map((match) => Number(match[1]))),
|
||||
]
|
||||
|
||||
if (numbers.length === 0) {
|
||||
console.log(`[forgejo-issue-transition] PR #${prNumber}: nenhum Closes/Fixes #N — nada a fazer`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
for (const number of numbers) {
|
||||
try {
|
||||
await api.setLabels(number, { add: ['done', 'in-prod'], remove: ['in-progress'] })
|
||||
await api.addComment(
|
||||
number,
|
||||
`Merged em main via PR #${prNumber} — \`done\` + \`in-prod\`. Deploy de produção segue o verificador \`ci.yml\` (gated).`,
|
||||
)
|
||||
console.log(`[forgejo-issue-transition] #${number}: done + in-prod`)
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`[forgejo-issue-transition] #${number}: skip (${error instanceof Error ? error.message : error})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
59
scripts/forgejo-pr-automerge.mjs
Normal file
59
scripts/forgejo-pr-automerge.mjs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Plain-Node CLI (no pnpm) — agent PR safety net: wait for checks and merge by
|
||||
* rebase (the Forgejo equivalent of `gh pr merge --auto --rebase`, which the
|
||||
* Forgejo API does not offer as a scheduled operation). Idempotent: already
|
||||
* merged → no-op success.
|
||||
*
|
||||
* node scripts/forgejo-pr-automerge.mjs --pr <N>
|
||||
*/
|
||||
|
||||
import { createApi } from './lib/forgejo-api.mjs'
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const flags = {}
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
if (arg.startsWith('--')) {
|
||||
const name = arg.slice(2)
|
||||
const next = argv[index + 1]
|
||||
flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true
|
||||
if (typeof next === 'string' && !next.startsWith('--')) index += 1
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
const flags = parseArgs(process.argv)
|
||||
const prNumber = Number(flags.pr)
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||
console.error('Usage: node scripts/forgejo-pr-automerge.mjs --pr <N>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const api = createApi({})
|
||||
try {
|
||||
const pr = await api.getPullRequest(prNumber)
|
||||
if (!pr || pr.state !== 'OPEN') {
|
||||
console.log(
|
||||
`[forgejo-pr-automerge] PR #${prNumber} não está aberto (${pr?.state ?? 'inexistente'}) — skip`,
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
if (pr.isDraft) {
|
||||
console.log(`[forgejo-pr-automerge] PR #${prNumber} é draft — marcando Ready`)
|
||||
await api.markPullRequestReady(prNumber)
|
||||
} else {
|
||||
console.log(`[forgejo-pr-automerge] PR #${prNumber} já Ready`)
|
||||
}
|
||||
const merged = await api.autoMerge(prNumber, {
|
||||
log: (line) => console.log(`[forgejo-pr-automerge] ${line}`),
|
||||
})
|
||||
console.log(
|
||||
merged
|
||||
? `[forgejo-pr-automerge] PR #${prNumber} mergeado (rebase)`
|
||||
: `[forgejo-pr-automerge] PR #${prNumber} já mergeado`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`[forgejo-pr-automerge] falhou: ${error instanceof Error ? error.message : error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
@ -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'
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
* where the comments are already being read).
|
||||
*/
|
||||
|
||||
import { labelNames } from './agent-github.mjs'
|
||||
import { labelNames } from './agent-forgejo.mjs'
|
||||
|
||||
export const POOL_CIRCUIT_BREAKER_FAILURES = 2
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ const EXCLUDED_STATE_LABELS = ['in-progress', 'blocked', 'done', 'in-prod']
|
|||
export const HUMAN_GATE_LABELS = ['requirements-changed', 'needs:consent']
|
||||
|
||||
/**
|
||||
* @param {ReturnType<import('./agent-github.mjs').buildClaimQueue>[number]} entry queue entry from buildClaimQueue
|
||||
* @param {ReturnType<import('./agent-forgejo.mjs').buildClaimQueue>[number]} entry queue entry from buildClaimQueue
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.migrationBusy]
|
||||
* @param {number} [options.poolFailureCount]
|
||||
|
|
@ -56,7 +56,7 @@ export const issueHasPlanLink = (issue) => /docs\/plans\//.test(issue?.body ?? '
|
|||
* Splits the claim queue into pool-eligible entries (order preserved — the
|
||||
* claim order IS the spawn order) and exclusions with their reason.
|
||||
*
|
||||
* @param {ReturnType<import('./agent-github.mjs').buildClaimQueue>} claimQueue
|
||||
* @param {ReturnType<import('./agent-forgejo.mjs').buildClaimQueue>} claimQueue
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.migrationBusy]
|
||||
* @param {Map<number, number>} [options.failureCountsByIssue]
|
||||
|
|
|
|||
131
scripts/lib/agent-pool-forgejo.mjs
Normal file
131
scripts/lib/agent-pool-forgejo.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* Forgejo IO wrappers for the agent pool supervisor. Pure logic lives in
|
||||
* agent-pool-state.mjs / agent-pool-eligibility.mjs; the Cursor REST client in
|
||||
* agent-pool-cursor.mjs. Everything here goes through scripts/lib/forgejo-api.mjs
|
||||
* — no GitHub/gh dependency, same contract the pool had against `gh`.
|
||||
*
|
||||
* Pool state (was: repo Actions variables) lives in `pool-state.json` on the
|
||||
* dedicated branch `pool-state` (Forgejo 9 exposes variables read-only via API
|
||||
* — creation is 405; the contents API gives us read/write without git).
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { labelNames } from './agent-forgejo.mjs'
|
||||
import { formatPoolEvent } from './agent-pool-state.mjs'
|
||||
import { forgejoApi as api } from './forgejo-api.mjs'
|
||||
|
||||
const POOL_STATE_FILE = 'pool-state.json'
|
||||
const POOL_STATE_BRANCH = 'pool-state'
|
||||
|
||||
/**
|
||||
* Pool variables as a plain map from `pool-state.json` on the `pool-state`
|
||||
* branch. Non-strict mode degrades to `{}` + a warn: inspection commands
|
||||
* (status, dry-run tick) still work and the resulting config is the disabled
|
||||
* default — the fail-closed direction. The live tick stays strict: if the
|
||||
* supervisor cannot read its own config it must stop loudly, never guess.
|
||||
*/
|
||||
export const readPoolVariables = async ({ strict = true } = {}) => {
|
||||
try {
|
||||
const { content } = await api.getFileContents(POOL_STATE_FILE, POOL_STATE_BRANCH)
|
||||
const parsed = JSON.parse(content)
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch (error) {
|
||||
if (strict) throw error
|
||||
console.error(
|
||||
'[agent:pool] aviso: pool-state.json ilegível (branch pool-state ausente?) — usando defaults (pool desligado).',
|
||||
)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Create or update one pool variable in pool-state.json (POST create, PUT update). */
|
||||
export const writePoolVariable = async (name, value) => {
|
||||
const current = await readPoolVariables()
|
||||
const next = { ...current, [name]: value }
|
||||
const message = `[agent:pool] ${name}=${value}`
|
||||
try {
|
||||
await api.updateFile(POOL_STATE_FILE, {
|
||||
message,
|
||||
content: JSON.stringify(next, null, 2) + '\n',
|
||||
branch: POOL_STATE_BRANCH,
|
||||
sha: (await api.getFileContents(POOL_STATE_FILE, POOL_STATE_BRANCH)).sha,
|
||||
})
|
||||
} catch (error) {
|
||||
if (/404|object does not exist/i.test(error.message)) {
|
||||
await api.createFile(POOL_STATE_FILE, {
|
||||
message,
|
||||
content: JSON.stringify(next, null, 2) + '\n',
|
||||
branch: POOL_STATE_BRANCH,
|
||||
})
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const listInProgressIssues = () =>
|
||||
api.listIssues({ state: 'open', labels: 'in-progress', limit: 200 })
|
||||
|
||||
export const listIssueComments = (number) => api.listIssueComments(number)
|
||||
|
||||
export const listOpenPrs = () => api.listPullRequests({ state: 'open', limit: 100 })
|
||||
|
||||
/** The open PR whose body closes this issue (`Closes #N` / `Fixes #N`), if any. */
|
||||
export const prClosingIssue = (prs, issueNumber) =>
|
||||
prs.find((pr) => new RegExp(`(?:closes|fixes) #${issueNumber}\\b`, 'i').test(pr.body ?? '')) ??
|
||||
null
|
||||
|
||||
// Path predicate for "schema-touching PR" — the pool serializes migration
|
||||
// spawns while one is open (the CI migration-lock was removed 2026-08-12).
|
||||
const SCHEMA_PR_PATH = /^(src\/migrations\/|src\/payload-types\.ts$|payload-types\.ts$)/
|
||||
|
||||
export const countOpenSchemaPrs = async () => {
|
||||
const prs = await api.listPullRequests({ state: 'open', limit: 100 })
|
||||
let count = 0
|
||||
for (const pr of prs) {
|
||||
const files = await api.listPullRequestFiles(pr.number).catch(() => null)
|
||||
if ((files ?? []).some((file) => SCHEMA_PR_PATH.test(file.filename ?? ''))) count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/** Comment on an issue with human text plus the machine-readable pool marker. */
|
||||
export const commentPoolEvent = (number, event, text) =>
|
||||
api.addComment(number, `${text} ${formatPoolEvent(event)}`)
|
||||
|
||||
/**
|
||||
* Coordinated claim (plan §4): the supervisor is the single allocator. Re-read
|
||||
* the issue (same optimistic lock as agent:claim), flip ready→in-progress and
|
||||
* leave the claim marker with the worker UUID the spawn will reuse as the
|
||||
* idempotent `agentId`. Returns `{ ok, workerUuid }` or `{ ok: false }` when
|
||||
* the race was lost — the tick just moves to the next queue entry.
|
||||
*/
|
||||
export const claimIssueForPool = async (number, { tickIso }) => {
|
||||
const fresh = await api.getIssue(number)
|
||||
const labels = labelNames(fresh)
|
||||
if (fresh.state !== 'OPEN' || !labels.includes('ready') || labels.includes('in-progress')) {
|
||||
return { ok: false, reason: 'lost-race' }
|
||||
}
|
||||
await api.setLabels(number, { add: ['in-progress'], remove: ['ready'] })
|
||||
const workerUuid = randomUUID()
|
||||
await commentPoolEvent(
|
||||
number,
|
||||
{ event: 'claim', tick: tickIso, worker: workerUuid },
|
||||
`Claimed pelo pool-supervisor (tick ${tickIso}) — worker em spawn.`,
|
||||
)
|
||||
return { ok: true, workerUuid }
|
||||
}
|
||||
|
||||
/** Undo the claim when the spawn itself failed — the issue goes back to ready. */
|
||||
export const rollbackPoolClaim = async (number, note) => {
|
||||
await api.setLabels(number, { add: ['ready'], remove: ['in-progress'] })
|
||||
return api.addComment(number, `Pool-supervisor: claim revertido — ${note}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Failure path: a terminally failed worker's issue leaves the claim queue for
|
||||
* human triage. The pool only ever blocks issues IT claimed (claim marker).
|
||||
*/
|
||||
export const blockIssueFromPool = (number) =>
|
||||
api.setLabels(number, { add: ['blocked'], remove: ['in-progress'] })
|
||||
|
|
@ -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'] })
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Pure pool-state helpers for the agent pool supervisor
|
||||
* (docs/plans/agent-pool-orchestrator.md §2). No IO — the gh wrappers live in
|
||||
* agent-pool-github.mjs and the Cursor REST client in agent-pool-cursor.mjs.
|
||||
* agent-pool-forgejo.mjs and the Cursor REST client in agent-pool-cursor.mjs.
|
||||
*
|
||||
* State model: scalar config lives in GitHub repo variables; the dynamic view
|
||||
* (who is active) is DERIVED from two sources of truth — pool-worker event
|
||||
|
|
|
|||
311
scripts/lib/forgejo-api.mjs
Normal file
311
scripts/lib/forgejo-api.mjs
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
/**
|
||||
* Forgejo REST client for the agent ops scripts (`pnpm agent:*`, workflow
|
||||
* helpers). Zero dependencies — plain Node `fetch` — so it also runs inside
|
||||
* Forgejo Actions jobs without `pnpm install`.
|
||||
*
|
||||
* Auth: `FORGEJO_API_TOKEN` (PAT) or the native `GITHUB_TOKEN` that Forgejo
|
||||
* Actions injects. Base URL: `FORGEJO_API_URL`, else `<GITHUB_SERVER_URL>/api/v1`
|
||||
* inside Actions, else `https://git.solla.dev/api/v1`. A browser-ish User-Agent
|
||||
* is sent because the instance sits behind Cloudflare.
|
||||
*
|
||||
* Shapes are normalized to the GitHub-flavored contract the agent scripts and
|
||||
* their unit specs already use: `issue.state` ∈ OPEN|CLOSED,
|
||||
* `issue.labels = [{ name, color }]`, `createdAt` ISO string.
|
||||
*/
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://git.solla.dev/api/v1'
|
||||
const DEFAULT_REPOSITORY = 'fsolla/teqo'
|
||||
const USER_AGENT = 'teqo-agent-scripts/1.0 (forgejo)'
|
||||
|
||||
/**
|
||||
* @typedef {object} ForgejoApiOptions
|
||||
* @property {string} [base]
|
||||
* @property {string} [token]
|
||||
* @property {string} [repository]
|
||||
* @property {(input: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: string }) => Promise<Response>} [fetchImpl]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {ForgejoApiOptions} [options]
|
||||
*/
|
||||
export const createApi = ({ base, token, repository, fetchImpl } = {}) => {
|
||||
const baseUrl = (
|
||||
base ??
|
||||
process.env.FORGEJO_API_URL ??
|
||||
(process.env.GITHUB_SERVER_URL ? `${process.env.GITHUB_SERVER_URL}/api/v1` : null) ??
|
||||
DEFAULT_BASE_URL
|
||||
).replace(/\/+$/, '')
|
||||
const repo =
|
||||
repository ??
|
||||
process.env.FORGEJO_REPOSITORY ??
|
||||
process.env.GITHUB_REPOSITORY ??
|
||||
DEFAULT_REPOSITORY
|
||||
const fetcher = fetchImpl ?? fetch
|
||||
const [owner, name] = repo.split('/')
|
||||
|
||||
const request = async (path, { method = 'GET', body, query } = {}) => {
|
||||
const authToken = token ?? process.env.FORGEJO_API_TOKEN ?? process.env.GITHUB_TOKEN
|
||||
if (!authToken) {
|
||||
throw new Error(
|
||||
'Sem token para a API do Forgejo — defina FORGEJO_API_TOKEN (ou rode dentro do Forgejo Actions).',
|
||||
)
|
||||
}
|
||||
const qs = query
|
||||
? '?' +
|
||||
Object.entries(query)
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||||
.join('&')
|
||||
: ''
|
||||
const response = await fetcher(`${baseUrl}${path}${qs}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `token ${authToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
})
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
throw new Error(`Forgejo API ${method} ${path} → ${response.status}: ${text.slice(0, 400)}`)
|
||||
}
|
||||
return text ? JSON.parse(text) : null
|
||||
}
|
||||
|
||||
const normalizeState = (state) => (String(state).toLowerCase() === 'closed' ? 'CLOSED' : 'OPEN')
|
||||
|
||||
const normalizeIssue = (issue) => ({
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
body: issue.body ?? '',
|
||||
state: normalizeState(issue.state),
|
||||
createdAt: issue.created_at,
|
||||
labels: (issue.labels ?? []).map((label) => ({
|
||||
name: label.name,
|
||||
color: label.color ?? '',
|
||||
})),
|
||||
})
|
||||
|
||||
const api = {
|
||||
/** GET /api/v1/repos/{owner}/{repo}/issues — shape normalized like `gh issue list --json`. */
|
||||
listIssues: async ({ state = 'open', labels, limit = 200, page = 1 } = {}) => {
|
||||
const issues = await request(`/repos/${owner}/${name}/issues`, {
|
||||
query: { state, labels, limit, page, type: 'issues' },
|
||||
})
|
||||
return (Array.isArray(issues) ? issues : []).map(normalizeIssue)
|
||||
},
|
||||
|
||||
getIssue: async (number) => {
|
||||
const issue = await request(`/repos/${owner}/${name}/issues/${number}`)
|
||||
return normalizeIssue(issue)
|
||||
},
|
||||
|
||||
createIssue: async ({ title, body }) => {
|
||||
const issue = await request(`/repos/${owner}/${name}/issues`, {
|
||||
method: 'POST',
|
||||
body: { title, body },
|
||||
})
|
||||
return normalizeIssue(issue)
|
||||
},
|
||||
|
||||
/** Adds labels (never removes). */
|
||||
addLabels: (number, labels) =>
|
||||
request(`/repos/${owner}/${name}/issues/${number}/labels`, {
|
||||
method: 'POST',
|
||||
body: { labels },
|
||||
}),
|
||||
|
||||
/** Removes labels by name (resolves ids, ignores unknown names). */
|
||||
removeLabels: async (number, labels) => {
|
||||
const current = await request(`/repos/${owner}/${name}/issues/${number}/labels`)
|
||||
for (const label of current ?? []) {
|
||||
if (labels.includes(label.name)) {
|
||||
await request(`/repos/${owner}/${name}/issues/${number}/labels/${label.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {number} number
|
||||
* @param {{ add?: string[], remove?: string[] }} [options]
|
||||
*/
|
||||
setLabels: async (number, { add = [], remove = [] } = {}) => {
|
||||
if (remove.length > 0) await api.removeLabels(number, remove)
|
||||
if (add.length > 0) await api.addLabels(number, add)
|
||||
},
|
||||
|
||||
addComment: (number, body) =>
|
||||
request(`/repos/${owner}/${name}/issues/${number}/comments`, {
|
||||
method: 'POST',
|
||||
body: { body },
|
||||
}),
|
||||
|
||||
listIssueComments: async (number) => {
|
||||
const comments = await request(`/repos/${owner}/${name}/issues/${number}/comments`)
|
||||
return (Array.isArray(comments) ? comments : []).map((comment) => ({
|
||||
body: comment.body ?? '',
|
||||
user: comment.user?.login ?? '',
|
||||
}))
|
||||
},
|
||||
|
||||
listPullRequestFiles: async (number) => {
|
||||
const files = await request(`/repos/${owner}/${name}/pulls/${number}/files`)
|
||||
return (Array.isArray(files) ? files : []).map((file) => ({
|
||||
filename: file.filename,
|
||||
status: file.status,
|
||||
}))
|
||||
},
|
||||
|
||||
/** GET /api/v1/repos/{owner}/{repo}/pulls — raw, normalized lightly. */
|
||||
listPullRequests: async ({ state = 'open', limit = 100 } = {}) => {
|
||||
const pulls = await request(`/repos/${owner}/${name}/pulls`, {
|
||||
query: { state, limit },
|
||||
})
|
||||
return (Array.isArray(pulls) ? pulls : []).map((pr) => ({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
state: normalizeState(pr.state),
|
||||
body: pr.body ?? '',
|
||||
head: { ref: pr.head?.ref, sha: pr.head?.sha },
|
||||
base: { ref: pr.base?.ref },
|
||||
}))
|
||||
},
|
||||
|
||||
getPullRequest: async (number) => {
|
||||
const pr = await request(`/repos/${owner}/${name}/pulls/${number}`)
|
||||
return {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
state: normalizeState(pr.state),
|
||||
merged: Boolean(pr.merged),
|
||||
mergeable: pr.mergeable,
|
||||
isDraft: Boolean(pr.is_draft),
|
||||
body: pr.body ?? '',
|
||||
head: { ref: pr.head?.ref, sha: pr.head?.sha },
|
||||
base: { ref: pr.base?.ref },
|
||||
}
|
||||
},
|
||||
|
||||
/** Marks a draft PR as ready for review (PATCH is_draft=false). */
|
||||
markPullRequestReady: (number) =>
|
||||
request(`/repos/${owner}/${name}/pulls/${number}`, {
|
||||
method: 'PATCH',
|
||||
body: { is_draft: false },
|
||||
}),
|
||||
|
||||
createPullRequest: async ({ head, base, title, body }) => {
|
||||
const pr = await request(`/repos/${owner}/${name}/pulls`, {
|
||||
method: 'POST',
|
||||
body: { head, base, title, body },
|
||||
})
|
||||
return { number: pr.number, title: pr.title, htmlUrl: pr.html_url }
|
||||
},
|
||||
|
||||
/**
|
||||
* Commit statuses of a sha (Forgejo Actions check runs surface here).
|
||||
* Returns [{ context, state }] with state ∈ success|failure|pending|error.
|
||||
*/
|
||||
listCommitStatuses: async (sha) => {
|
||||
const statuses = await request(`/repos/${owner}/${name}/commits/${sha}/status`)
|
||||
const combined = statuses?.statuses ?? []
|
||||
return combined.map((entry) => ({ context: entry.context, state: entry.status }))
|
||||
},
|
||||
|
||||
/** Merges a PR with rebase (the repo's canonical merge style). */
|
||||
mergePullRequest: (number) =>
|
||||
request(`/repos/${owner}/${name}/pulls/${number}/merge`, {
|
||||
method: 'POST',
|
||||
body: { Do: 'rebase' },
|
||||
}),
|
||||
|
||||
/** Triggers a workflow via workflow_dispatch. */
|
||||
workflowDispatch: (workflowFile, { ref = 'main', inputs = {} } = {}) =>
|
||||
request(`/repos/${owner}/${name}/actions/workflows/${workflowFile}/dispatches`, {
|
||||
method: 'POST',
|
||||
body: { ref, inputs },
|
||||
}),
|
||||
|
||||
/** GET /api/v1/repos/{owner}/{repo}/contents/{path}?ref=… — { content, sha } base64-decoded. */
|
||||
getFileContents: async (path, ref = 'main') => {
|
||||
const file = await request(`/repos/${owner}/${name}/contents/${path}`, {
|
||||
query: { ref },
|
||||
})
|
||||
return {
|
||||
content: Buffer.from(file.content ?? '', 'base64').toString('utf8'),
|
||||
sha: file.sha,
|
||||
}
|
||||
},
|
||||
|
||||
/** POST /contents — creates a file on a branch (fails if it exists). */
|
||||
createFile: (path, { message, content, branch }) =>
|
||||
request(`/repos/${owner}/${name}/contents/${path}`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
message,
|
||||
content: Buffer.from(content, 'utf8').toString('base64'),
|
||||
branch,
|
||||
},
|
||||
}),
|
||||
|
||||
/** PUT /contents — updates an existing file (sha required). */
|
||||
updateFile: (path, { message, content, branch, sha }) =>
|
||||
request(`/repos/${owner}/${name}/contents/${path}`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
message,
|
||||
content: Buffer.from(content, 'utf8').toString('base64'),
|
||||
branch,
|
||||
sha,
|
||||
},
|
||||
}),
|
||||
|
||||
/** POST /api/v1/repos/{owner}/{repo}/branch_protections (Forgejo rule-based protection). */
|
||||
updateBranchProtection: (branch, payload) =>
|
||||
request(`/repos/${owner}/${name}/branch_protections`, {
|
||||
method: 'POST',
|
||||
body: { ...payload, branch_name: branch },
|
||||
}),
|
||||
|
||||
/** All required statuses on the PR head are success (or a failure exists). */
|
||||
waitForChecks: async (number, { timeoutMs = 30 * 60 * 1000, pollMs = 15000, log } = {}) => {
|
||||
const started = Date.now()
|
||||
for (;;) {
|
||||
const pr = await api.getPullRequest(number)
|
||||
if (!pr || pr.state !== 'OPEN' || pr.merged) return pr
|
||||
const statuses = await api.listCommitStatuses(pr.head.sha)
|
||||
const failed = statuses.filter(
|
||||
(entry) => entry.state === 'failure' || entry.state === 'error',
|
||||
)
|
||||
const pending = statuses.filter((entry) => entry.state === 'pending')
|
||||
if (failed.length > 0) {
|
||||
const names = failed.map((entry) => entry.context).join(', ')
|
||||
throw new Error(`Checks falharam no PR #${number}: ${names}`)
|
||||
}
|
||||
if (statuses.length > 0 && pending.length === 0) return pr
|
||||
if (Date.now() - started > timeoutMs) {
|
||||
throw new Error(`Timeout esperando checks do PR #${number}.`)
|
||||
}
|
||||
if (log)
|
||||
log(`[pr#${number}] ${statuses.length} status, ${pending.length} pendente — aguardando…`)
|
||||
await new Promise((resolve) => setTimeout(resolve, pollMs))
|
||||
}
|
||||
},
|
||||
|
||||
/** Wait for checks then merge by rebase (the `gh pr merge --auto --rebase` equivalent). */
|
||||
autoMerge: async (number, options) => {
|
||||
const pr = await api.waitForChecks(number, options)
|
||||
if (pr && !pr.merged && pr.state === 'OPEN') {
|
||||
return api.mergePullRequest(number)
|
||||
}
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
/** Default instance bound to the environment. */
|
||||
export const forgejoApi = createApi({})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
buildClaimQueue,
|
||||
claimQueueEntry,
|
||||
claimTargetVerdict,
|
||||
} from '../../scripts/lib/agent-github.mjs'
|
||||
} from '../../scripts/lib/agent-forgejo.mjs'
|
||||
import {
|
||||
buildPoolQueue,
|
||||
isAutonomousClaimable,
|
||||
|
|
|
|||
212
tests/unit/forgejoApi.unit.spec.ts
Normal file
212
tests/unit/forgejoApi.unit.spec.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createApi } from '../../scripts/lib/forgejo-api.mjs'
|
||||
|
||||
type FetchCall = {
|
||||
url: string
|
||||
method?: string
|
||||
init?: { headers?: Record<string, string>; method?: string; body?: string }
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
const ok = (body: unknown, status = 200) =>
|
||||
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
describe('forgejo-api', () => {
|
||||
it('builds the request with token, JSON body and explicit User-Agent', async () => {
|
||||
const calls: FetchCall[] = []
|
||||
const api = createApi({
|
||||
base: 'https://git.solla.dev/api/v1',
|
||||
token: 'tok',
|
||||
repository: 'fsolla/teqo',
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init })
|
||||
return ok({ number: 1, title: 'x', body: '', state: 'open', created_at: '', labels: [] })
|
||||
},
|
||||
})
|
||||
|
||||
await api.createIssue({ title: 't', body: 'b' })
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].url).toBe('https://git.solla.dev/api/v1/repos/fsolla/teqo/issues')
|
||||
expect(calls[0].init!.method).toBe('POST')
|
||||
expect(calls[0].init!.headers!.Authorization).toBe('token tok')
|
||||
expect(calls[0].init!.headers!['User-Agent']).toContain('teqo-agent-scripts')
|
||||
expect(JSON.parse(calls[0].init!.body!)).toEqual({ title: 't', body: 'b' })
|
||||
})
|
||||
|
||||
it('defaults the base URL to git.solla.dev with the default repo path', async () => {
|
||||
const calls: FetchCall[] = []
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async (url) => {
|
||||
calls.push({ url: String(url) })
|
||||
return ok([{ number: 1, title: '', body: '', state: 'open', created_at: '', labels: [] }])
|
||||
},
|
||||
})
|
||||
|
||||
await api.listIssues()
|
||||
|
||||
expect(calls[0].url).toContain('https://git.solla.dev/api/v1/repos/fsolla/teqo/issues?')
|
||||
})
|
||||
|
||||
it('normalizes state and labels to the gh-flavored contract', async () => {
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async () =>
|
||||
ok({
|
||||
number: 7,
|
||||
title: 't',
|
||||
body: 'b',
|
||||
state: 'closed',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
labels: [{ name: 'ready', color: '0E8A16' }],
|
||||
}),
|
||||
})
|
||||
|
||||
const issue = await api.getIssue(7)
|
||||
|
||||
expect(issue.state).toBe('CLOSED')
|
||||
expect(issue.labels).toEqual([{ name: 'ready', color: '0E8A16' }])
|
||||
expect(issue.createdAt).toBe('2026-01-01T00:00:00Z')
|
||||
})
|
||||
|
||||
it('throws with the API error body on non-2xx', async () => {
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async () => ok('{"message":"nope"}', 403),
|
||||
})
|
||||
|
||||
await expect(api.listIssues()).rejects.toThrow(/403/)
|
||||
await expect(api.listIssues()).rejects.toThrow(/nope/)
|
||||
})
|
||||
|
||||
it('fails closed when no token is available', async () => {
|
||||
delete process.env.FORGEJO_API_TOKEN
|
||||
delete process.env.GITHUB_TOKEN
|
||||
const api = createApi({ fetchImpl: async () => ok('[]') })
|
||||
|
||||
await expect(api.listIssues()).rejects.toThrow(/Sem token/)
|
||||
})
|
||||
|
||||
it('setLabels removes by id and adds by name', async () => {
|
||||
const calls: FetchCall[] = []
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), method: init?.method, body: init?.body })
|
||||
if (String(url).endsWith('/labels') && init?.method === 'GET') {
|
||||
return ok([{ name: 'blocked', id: 42 }])
|
||||
}
|
||||
return ok(null)
|
||||
},
|
||||
})
|
||||
|
||||
await api.setLabels(5, { add: ['ready'], remove: ['blocked'] })
|
||||
|
||||
expect(calls.map((call) => [call.method, call.url])).toEqual([
|
||||
['GET', 'https://git.solla.dev/api/v1/repos/fsolla/teqo/issues/5/labels'],
|
||||
['DELETE', 'https://git.solla.dev/api/v1/repos/fsolla/teqo/issues/5/labels/42'],
|
||||
['POST', 'https://git.solla.dev/api/v1/repos/fsolla/teqo/issues/5/labels'],
|
||||
])
|
||||
expect(JSON.parse(calls[2].body as string)).toEqual({ labels: ['ready'] })
|
||||
})
|
||||
|
||||
it('workflowDispatch posts inputs and ref', async () => {
|
||||
const calls: FetchCall[] = []
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), body: JSON.parse(String(init?.body ?? '{}')) })
|
||||
return ok(null)
|
||||
},
|
||||
})
|
||||
|
||||
await api.workflowDispatch('agent-pool.yml', { ref: 'main', inputs: { action: 'tick' } })
|
||||
|
||||
expect(calls[0].url).toContain('/actions/workflows/agent-pool.yml/dispatches')
|
||||
expect(calls[0].body).toEqual({ ref: 'main', inputs: { action: 'tick' } })
|
||||
})
|
||||
|
||||
it('merges with rebase (repo canonical merge style)', async () => {
|
||||
const calls: FetchCall[] = []
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), body: JSON.parse(String(init?.body ?? '{}')) })
|
||||
return ok(null)
|
||||
},
|
||||
})
|
||||
|
||||
await api.mergePullRequest(4)
|
||||
|
||||
expect(calls[0].url).toContain('/pulls/4/merge')
|
||||
expect(calls[0].body).toEqual({ Do: 'rebase' })
|
||||
})
|
||||
|
||||
it('waitForChecks returns once every status is settled and no failure exists', async () => {
|
||||
let poll = 0
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async (url) => {
|
||||
if (String(url).includes('/pulls/')) {
|
||||
return ok({
|
||||
number: 4,
|
||||
state: 'open',
|
||||
merged: false,
|
||||
mergeable: true,
|
||||
head: { ref: 'x', sha: 'abc' },
|
||||
})
|
||||
}
|
||||
poll += 1
|
||||
const statuses =
|
||||
poll < 2
|
||||
? [{ context: 'CI / static', status: 'pending' }]
|
||||
: [{ context: 'CI / static', status: 'success' }]
|
||||
return ok({ statuses })
|
||||
},
|
||||
})
|
||||
|
||||
const pr = await api.waitForChecks(4, { pollMs: 1 })
|
||||
|
||||
expect(pr).toBeTruthy()
|
||||
expect(poll).toBe(2)
|
||||
})
|
||||
|
||||
it('getFileContents decodes base64 and updateFile sends sha', async () => {
|
||||
const calls: FetchCall[] = []
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({
|
||||
url: String(url),
|
||||
method: init?.method,
|
||||
body: init?.body ? JSON.parse(String(init.body)) : null,
|
||||
})
|
||||
if (init?.method === 'GET') {
|
||||
return ok({ content: Buffer.from('{"a":1}').toString('base64'), sha: 'shasha' })
|
||||
}
|
||||
return ok(null)
|
||||
},
|
||||
})
|
||||
|
||||
const file = await api.getFileContents('pool-state.json', 'pool-state')
|
||||
expect(file.content).toBe('{"a":1}')
|
||||
expect(file.sha).toBe('shasha')
|
||||
|
||||
await api.updateFile('pool-state.json', {
|
||||
message: 'm',
|
||||
content: '{"a":2}',
|
||||
branch: 'pool-state',
|
||||
sha: 'shasha',
|
||||
})
|
||||
expect(calls.at(-1)!.method).toBe('PUT')
|
||||
expect(
|
||||
Buffer.from((calls.at(-1)!.body as { content: string }).content, 'base64').toString(),
|
||||
).toBe('{"a":2}')
|
||||
expect((calls.at(-1)!.body as { sha: string }).sha).toBe('shasha')
|
||||
})
|
||||
})
|
||||
|
|
@ -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(', ')}]`)
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue