From 76b6e77e96bf037167c01139068438364824ecef Mon Sep 17 00:00:00 2001 From: tabellio-admin Date: Sun, 12 Jul 2026 22:44:02 +0000 Subject: [PATCH] Add headless remote repository control plane (#3) --- CHANGELOG.md | 12 + CONTRIBUTING.md | 2 +- README.md | 20 +- docs/getting-started.md | 11 +- docs/headless-control-plane.md | 77 ++++ docs/operations-hardening.md | 2 +- docs/review-loop.md | 2 +- docs/tooling-stack.md | 15 +- infra/forgejo/README.md | 12 + infra/forgejo/compose.production.yml | 70 +++ infra/forgejo/nginx.git-only.conf | 29 ++ infra/postgres/001_control_plane.sql | 90 ++++ package.json | 8 - schemas/platform.schema.json | 32 +- schemas/review-cycle.schema.json | 4 +- scripts/check-tabellio-platform.mjs | 12 +- scripts/lib/change-request.mjs | 99 +++++ scripts/lib/headless-api.mjs | 471 +++++++++++++++++++++ scripts/lib/headless-http.mjs | 52 +++ scripts/lib/job-worker.mjs | 70 +++ scripts/lib/platform-config.mjs | 61 ++- scripts/lib/remote-repository-provider.mjs | 29 ++ scripts/lib/repository-identity.mjs | 7 +- scripts/lib/repository-store.mjs | 5 + scripts/lib/review-cycle.mjs | 83 ++-- scripts/providers/forgejo-provider.mjs | 52 ++- scripts/tabellio-forge.mjs | 16 +- scripts/tabellio-review.mjs | 3 +- tabellio.platform.json | 18 +- tests/change-request.test.mjs | 44 ++ tests/context-and-evidence.test.mjs | 2 +- tests/forgejo-provider.test.mjs | 50 ++- tests/headless-api.test.mjs | 189 +++++++++ tests/headless-http.test.mjs | 57 +++ tests/job-worker.test.mjs | 90 ++++ tests/platform-config.test.mjs | 69 +++ tests/review-cycle.test.mjs | 13 + 37 files changed, 1757 insertions(+), 121 deletions(-) create mode 100644 docs/headless-control-plane.md create mode 100644 infra/forgejo/compose.production.yml create mode 100644 infra/forgejo/nginx.git-only.conf create mode 100644 infra/postgres/001_control_plane.sql create mode 100644 scripts/lib/change-request.mjs create mode 100644 scripts/lib/headless-api.mjs create mode 100644 scripts/lib/headless-http.mjs create mode 100644 scripts/lib/job-worker.mjs create mode 100644 scripts/lib/remote-repository-provider.mjs create mode 100644 tests/change-request.test.mjs create mode 100644 tests/headless-api.test.mjs create mode 100644 tests/headless-http.test.mjs create mode 100644 tests/job-worker.test.mjs create mode 100644 tests/platform-config.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index bd9d33b..bd61d48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ All notable changes to Tabellio are recorded here. ### Added +- Headless `tabellio-platform/v0.2` contract with configurable remote provider and Git-only public surface. +- Legacy v0.1 platform reader for rollback and staged migration. +- Provider-neutral remote repository contract and Forgejo repository provisioning/archive operations. +- Stable Tabellio change-request identities independent from backend IDs. +- Scoped agent API for repositories, credentials, change requests, validation, approval-bound merge intents, and jobs. +- Bounded JSON HTTP adapter with tenant isolation, exact-SHA validation, and mutation idempotency. +- Lease-based worker contract with heartbeat, bounded retries, terminal failure, and expired-lease recovery. +- PostgreSQL control-plane schema for repositories, jobs, idempotency, webhooks, and credential audit metadata. +- Private Forgejo production topology with PostgreSQL and Git-only Nginx gateway. - Provider-neutral exact-commit validation runner with committed argv manifests and no shell execution. - Bounded SHA-256 output evidence, detached worktree cleanup, and durable results on `refs/tabellio/validations`. - Local validation results integrated into durable review readiness. @@ -46,6 +55,9 @@ All notable changes to Tabellio are recorded here. ### Changed +- Forgejo is now private replaceable backend rather than product review surface. +- Repository identity defaults to configured canonical remote instead of `origin`. +- Removed GitHub package metadata and Code Storage transition placeholder. - Replaced Graphite as the planned stacked-review integration with host-agnostic git-spice. ## 0.1.0 - 2026-07-08 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 370ab04..81138ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,4 +97,4 @@ Before tagging a release: - run local checks - run an exact-head `tabellio-validate` pass - confirm the durable review cycle is ready -- tag from a clean canonical Forgejo `main` commit +- tag from clean canonical remote `main` commit diff --git a/README.md b/README.md index 3e04461..e06fd56 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ![Tabellio product overview](docs/assets/tabellio-hero.svg) [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D20-339933?logo=node.js&logoColor=white)](https://nodejs.org/) -[![Forgejo](https://img.shields.io/badge/Forgejo-canonical%20forge-FB923C?logo=forgejo&logoColor=white)](https://forgejo.org/) +[![Forgejo](https://img.shields.io/badge/Forgejo-private%20backend-FB923C?logo=forgejo&logoColor=white)](https://forgejo.org/) [![JSON Schema](https://img.shields.io/badge/JSON%20Schema-evidence%20contract-0B6BFF)](https://json-schema.org/) [![SARIF](https://img.shields.io/badge/SARIF-code%20scanning-2563EB)](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) [![git-spice](https://img.shields.io/badge/git--spice-stacked%20review-B45309)](https://abhinav.github.io/git-spice/) @@ -12,7 +12,7 @@ Provider-neutral Git context and evidence for agentic development. -Tabellio gives coding agents a deterministic Git foundation: standard Git repositories, isolated worktrees, immutable commit IDs, merge previews, compare-and-swap ref updates, and context packets tied to the exact diff. Forgejo is the canonical review adapter. Any Git remote may store code; no GitHub API or hosted workflow runtime is required. +Tabellio gives coding agents a deterministic Git foundation: standard Git repositories, isolated worktrees, immutable commit IDs, merge previews, compare-and-swap ref updates, and context packets tied to the exact diff. Tabellio owns the headless workflow; Forgejo is the current private remote-repository adapter. Any compatible Git provider may replace it. ## What It Adds @@ -60,31 +60,35 @@ AI-assisted pull requests should not depend on reviewer trust alone. Tabellio gi | Runtime | [Node.js 20+](https://nodejs.org/) | Runs the local writer and validators | | Validation | `tabellio-validate` on any trusted worker | Runs an exact committed command manifest and stores results on a Git ref | | Evidence contract | [JSON Schema](https://json-schema.org/) | Validates the evidence envelope and external-action policy | -| Review surface | [Forgejo](https://forgejo.org/) | Hosts repositories, change requests, comments, reviews, and commit status | +| Review surface | Tabellio | Owns canonical change-request identity, review state, validation, and approvals | +| Remote backend | [Forgejo](https://forgejo.org/) | Privately implements Git transport, repository lifecycle, and synchronized review metadata | | Stacked review | [git-spice](https://abhinav.github.io/git-spice/) | Host-agnostic stack engine for small dependent change requests | | Checkpoint ledger | [Entire](https://entire.io/) and [Entire CLI](https://github.com/entireio/cli) | Required default for agent session and checkpoint context | | Git substrate | Standard Git CLI, bare repositories, and worktrees | Stores repositories, branches, commits, patches, and agent-created code state | | Agent review | [OpenAI Codex](https://openai.com/codex/) | Produces provider-neutral findings imported into the durable review ledger | | Prior art | [SLSA](https://slsa.dev/) and [in-toto](https://in-toto.io/) | Inspiration for provenance and supply-chain evidence, without a compliance claim | -The current public origin may remain a code-storage mirror during migration. It is not a runtime dependency. Entire is the required checkpoint ledger; git-spice manages stacks; Forgejo hosts review; Tabellio owns validation and durable control refs. +The public origin may remain a code-storage mirror during migration. It is not a runtime dependency. Entire is the required checkpoint ledger; git-spice manages stacks; private Forgejo implements the current remote backend; Tabellio owns review, validation, approvals, and durable control refs. ## Core Files | Path | Purpose | | --- | --- | -| `tabellio.platform.json` | Canonical forge, stack, ledger, validation, review, and control-ref contract | +| `tabellio.platform.json` | Headless remote, stack, ledger, validation, review, and control-ref contract | | `schemas/` | Evidence and external-action JSON schemas | | `scripts/providers/native-git-store.mjs` | Standard Git storage provider | | `scripts/providers/git-spice-stack-manager.mjs` | Read-only git-spice stack adapter | | `scripts/providers/git-spice-operations.mjs` | Approval-gated git-spice submit, update, sync, restack, and merge adapter | | `scripts/providers/entire-ledger-provider.mjs` | Metadata-only Entire checkpoint adapter | -| `scripts/providers/forgejo-provider.mjs` | Read-only Forgejo repository and review adapter | +| `scripts/providers/forgejo-provider.mjs` | Forgejo repository lifecycle and review backend adapter | +| `scripts/lib/headless-api.mjs` | Scoped, idempotent agent API and queue contract | +| `scripts/lib/headless-http.mjs` | Bounded JSON-over-HTTP adapter for external agents | +| `scripts/lib/job-worker.mjs` | Lease-based worker execution and bounded retry contract | | `scripts/lib/git-json-ledger.mjs` | Versioned, compare-and-swap JSON state on standard Git refs | | `scripts/lib/review-cycle.mjs` | Durable forge and agent review/fix state machine | | `scripts/lib/validation-runner.mjs` | Exact-commit, shell-free validation with bounded evidence logs | | `scripts/lib/control-ref-transport.mjs` | Approval-gated, fast-forward-only sharing of review, validation, and Entire refs | -| `infra/forgejo/` | Disposable localhost Forgejo integration lab | +| `infra/forgejo/` | Disposable lab plus private Git-only production topology | | `scripts/lib/` | Git process, repository contract, worktree, and context primitives | | `scripts/` | Dependency-free capture, writer, and validators | | `examples/` | Minimal valid context, evidence, review, validation, stack, and ledger fixtures | @@ -102,7 +106,7 @@ npm run tabellio:platform:check node scripts/tabellio-validate.mjs run --repo . --commit HEAD --manifest tabellio.validation.json ``` -Configure `TABELLIO_FORGE_URL`, `TABELLIO_FORGE_API_URL`, and `TABELLIO_FORGE_TOKEN_FILE` for Forgejo operations. Tokens stay in owner-readable files and never enter remote URLs or command arguments. +Configure `TABELLIO_REMOTE_URL`, `TABELLIO_REMOTE_API_URL`, `TABELLIO_REMOTE_CREDENTIAL_FILE`, and `TABELLIO_REMOTE_NAME` for remote operations. Legacy Forgejo variables remain readable during migration. Credentials stay in owner-readable files and never enter remote URLs or command arguments. Validate the bundled fixture: diff --git a/docs/getting-started.md b/docs/getting-started.md index 60b407b..bacc605 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -54,13 +54,14 @@ The lab is disposable and localhost-only. Generated secrets and Forgejo data rem ## Configure The Platform -`tabellio.platform.json` makes the operating model explicit: Forgejo review, git-spice stacks, Entire checkpoints, local validation, and durable control refs. +`tabellio.platform.json` makes the operating model explicit: headless remote repository, Tabellio review state, git-spice stacks, Entire checkpoints, local validation, and durable control refs. ```bash npm run tabellio:platform:check -export TABELLIO_FORGE_URL=https://forge.example.test -export TABELLIO_FORGE_API_URL=https://forge.example.test/api/v1 -export TABELLIO_FORGE_TOKEN_FILE=$HOME/.config/tabellio/forgejo-token +export TABELLIO_REMOTE_URL=https://git.example.test +export TABELLIO_REMOTE_API_URL=https://forgejo.internal.example.test/api/v1 +export TABELLIO_REMOTE_CREDENTIAL_FILE=$HOME/.config/tabellio/forgejo-token +export TABELLIO_REMOTE_NAME=forgejo ``` Run `tabellio-validate` from any trusted worker. The runner checks out the exact revision in an isolated worktree, executes only argv arrays committed in `tabellio.validation.json`, bounds captured output, and writes the result to `refs/tabellio/validations`. @@ -84,7 +85,7 @@ node scripts/tabellio-control-ref.mjs plan \ --operation publish \ --remote forgejo \ --repo-id example/repository \ - --token-file "$TABELLIO_FORGE_TOKEN_FILE" \ + --token-file "$TABELLIO_REMOTE_CREDENTIAL_FILE" \ --out /tmp/control-ref-intent.json ``` diff --git a/docs/headless-control-plane.md b/docs/headless-control-plane.md new file mode 100644 index 0000000..67e2762 --- /dev/null +++ b/docs/headless-control-plane.md @@ -0,0 +1,77 @@ +# Headless Control Plane + +Tabellio owns agent workflow. Remote provider owns Git transport and repository implementation. Forgejo stays private and replaceable. + +## Public Surfaces + +| Surface | Purpose | Exposure | +| --- | --- | --- | +| Tabellio API | Repository jobs, scoped credentials, change requests, validation, merge intents, job status | Agent-facing | +| Git gateway | Standard clone, fetch, and push | Agent-facing; smart HTTP routes only | +| Forgejo API | Repository and review backend adapter | Private network only | +| Forgejo UI | Operator break-glass debugging | Private network only; not product | + +## API Contract + +| Method | Path | Scope | Result | +| --- | --- | --- | --- | +| `GET` | `/v1/health` | none | Service health | +| `POST` | `/v1/repositories` | `repository:write` | `202` provisioning job | +| `GET` | `/v1/repositories/{id}` | `repository:read` | Canonical repository record | +| `POST` | `/v1/repositories/{id}/credentials` | `credential:issue` | Short-lived Git credential; `no-store` | +| `POST` | `/v1/change-requests` | `change-request:write` | `202` creation job | +| `POST` | `/v1/validations` | `validation:run` | `202` exact-commit validation job | +| `POST` | `/v1/merge-intents` | `merge:intent` | `202` exact-head intent job | +| `POST` | `/v1/merge-intents/{id}/approvals` | `merge:approve` | `202` short-lived approval job | +| `POST` | `/v1/merge-intents/{id}/executions` | `merge:execute` | `202` approval-bound merge job | +| `GET` | `/v1/jobs/{id}` | `job:read` | Tenant-bound job state | + +Mutation requests require `Idempotency-Key`. API layer validates shape, scope, tenant, repository identity, and exact Git object IDs before queueing work. Routes stay thin. Workers perform provider calls and untrusted validation. + +## Runtime Boundaries + +```text +agent + |-- JSON + scoped token --> Tabellio API --> PostgreSQL + durable queue + | \--> isolated workers --> private Forgejo API + \-- Git smart HTTP ------> Git-only gateway ------------------> private Forgejo +``` + +Code, review ledgers, validation results, and Entire checkpoints remain Git-native. PostgreSQL stores operational records: repository bindings, jobs, leases, idempotency keys, webhook deliveries, and credential audit metadata. Credential secrets never enter PostgreSQL. + +## Deployment + +`infra/forgejo/compose.production.yml` proves production topology locally: + +- pinned Forgejo +- PostgreSQL metadata database +- durable named volumes +- registration and Actions disabled +- Forgejo port private +- Nginx exposing only Git smart HTTP and health + +Production cloud mapping: + +- Forgejo container on private compute +- managed PostgreSQL +- encrypted durable filesystem for live repositories +- object storage for backups, artifacts, and optional LFS +- load balancer in front of Git gateway +- separate Tabellio API and worker services +- durable queue with dead-letter handling + +Do not mount object storage as live Git repository storage. Git needs filesystem semantics and atomic ref updates. + +## Failure Rules + +- Database unavailable: reject mutations before accepting work. +- Queue unavailable: return `503`; never claim queued work. +- Forgejo unavailable: retry bounded provider jobs; preserve queued intent. +- Worker crash: lease expires; another worker reclaims job. +- Duplicate request: return original job when digest matches; `409` when input differs. +- Stale merge head: fail closed; require new review and approval. +- Duplicate webhook: deduplicate by provider delivery ID. + +## Rollback + +Platform v0.1 configuration remains readable. Standard Git refs remain portable. Backend switch changes adapter configuration, not code or control-ledger formats. Never delete canonical refs during provider migration. diff --git a/docs/operations-hardening.md b/docs/operations-hardening.md index b8fdbea..cc6902f 100644 --- a/docs/operations-hardening.md +++ b/docs/operations-hardening.md @@ -19,7 +19,7 @@ Tabellio keeps code and control state in standard Git objects, but production sa ## Canonical And Mirror Repositories -Forgejo is the canonical merge authority. A secondary Git host may store a mirror, but must receive the exact canonical main commit by fast-forward only: +Configured remote repository is canonical merge authority. Current backend is Forgejo. A secondary Git host may store a mirror, but must receive the exact canonical main commit by fast-forward only: 1. Validate the exact Forgejo change-request head. 2. Merge through the approved git-spice operation. diff --git a/docs/review-loop.md b/docs/review-loop.md index 708cddd..3309835 100644 --- a/docs/review-loop.md +++ b/docs/review-loop.md @@ -1,6 +1,6 @@ # Durable Review And Fix Loop -Tabellio keeps review state in standard Git, not in a GitHub-only database. Forgejo remains the visible collaboration surface. The durable control-plane record lives on `refs/tabellio/reviews` as immutable JSON ledger commits updated with compare-and-swap. +Tabellio keeps review state in standard Git. Tabellio is the agent-facing review surface; Forgejo remains a private backend adapter. Durable control-plane records live on `refs/tabellio/reviews` as immutable JSON ledger commits updated with compare-and-swap. ## State Flow diff --git a/docs/tooling-stack.md b/docs/tooling-stack.md index 4631c91..54d0771 100644 --- a/docs/tooling-stack.md +++ b/docs/tooling-stack.md @@ -1,6 +1,6 @@ # Agentic Tooling Stack -Tabellio now owns the minimum Git substrate agents need. It uses standard Git rather than requiring a proprietary code-storage API. A forge can still store repositories and host review. +Tabellio owns agent workflow and standard Git contracts. Private remote provider stores repositories and implements Git transport. Forgejo is current backend, not product surface. The main idea: agentic Git should be built around more than a patch. It should preserve the work request, the reason for the change, the runtime that produced it, the commands that ran, the checkpoints that explain it, and the side effects that require approval. @@ -14,7 +14,7 @@ The main idea: agentic Git should be built around more than a patch. It should p | Checkpoint ledger | [Entire Checkpoints](https://entire.io/) and [Entire CLI](https://github.com/entireio/cli) | Links commits to agent sessions, prompts, transcript context, token usage, and attribution | Required default through `EntireLedgerProvider`; metadata normalized as `tabellio-ledger/v0.1` | | Evidence gate | Tabellio | Writes and validates the change evidence envelope and external-action policy | Core product surface | | Stacked review | [git-spice](https://abhinav.github.io/git-spice/) | Keeps dependent change requests small, ordered, reviewable, and resubmittable across Forgejo, Gitea, GitLab, Bitbucket, or GitHub | Read through `GitSpiceStackManager` into `tabellio-stack/v0.1` | -| Canonical forge | Forgejo | Hosts code, change requests, comments, reviews, and commit status | Forgejo API adapter; no hosted workflow dependency | +| Remote repository | Forgejo today; replaceable provider contract | Hosts Git objects and implements private repository/review APIs | Agent-facing surface remains Tabellio and standard Git | | Validation workers | Local agents or operator-managed workers | Run committed argv manifests against exact commits | Durable results under `refs/tabellio/validations` | | Control-ref transport | Standard Git protocol | Shares review, validation, and Entire state | Approval-gated and fast-forward-only | @@ -38,7 +38,7 @@ task source -> immutable context packet -> read-only merge preview -> exact-commit validation result - -> Forgejo review and checks + -> Tabellio review with remote-provider synchronization -> approved control-ref publication -> explicit compare-and-swap merge or release gate ``` @@ -72,16 +72,19 @@ Included: - local writer and validators - provider-neutral change-request template and docs - disposable Forgejo 15.0.3 lab bound to localhost -- read-only Forgejo provider for repositories, pull requests, reviews, comments, and commit statuses +- Forgejo provider for repository lifecycle, Git remotes, pull requests, reviews, comments, and commit statuses - approval-gated git-spice submit, update, sync, restack, and merge operations with one-use receipts - Git-native review ledger with Forgejo feedback, provider-neutral agent findings, triage, fixes, and readiness state - provider-neutral exact-commit validation runner with durable results on `refs/tabellio/validations` -- canonical Forgejo platform contract +- provider-neutral headless platform contract with legacy v0.1 reader +- scoped and idempotent headless API contract +- lease-based worker retry and recovery contract +- private Forgejo, PostgreSQL, and Git-only gateway production topology - approval-gated fast-forward transport for review, validation, and Entire refs Not included yet: -- production Forgejo deployment +- live production deployment - transcript indexing or storage outside Entire - forge comment publication, general review-thread mutation, and signed approvals - Codex review automation diff --git a/infra/forgejo/README.md b/infra/forgejo/README.md index f9bd897..db5fabf 100644 --- a/infra/forgejo/README.md +++ b/infra/forgejo/README.md @@ -2,6 +2,18 @@ This disposable Forgejo instance proves Tabellio can store and review code without GitHub. +Production-shaped topology lives in `compose.production.yml`. It uses PostgreSQL, durable named volumes, private Forgejo networking, disabled registration and Actions, and an Nginx gateway exposing only Git smart HTTP. + +```bash +export FORGEJO_DATABASE_PASSWORD='replace-through-secret-manager' +export TABELLIO_GIT_DOMAIN=git.example.test +export TABELLIO_GIT_ROOT_URL=https://git.example.test/ +docker compose -f infra/forgejo/compose.production.yml config +docker compose -f infra/forgejo/compose.production.yml up -d +``` + +Example proves topology. Production operator must add TLS termination, encrypted volumes, managed database, backup/restore automation, secret-manager injection, monitoring, and tested upgrades. Do not expose Forgejo port or UI publicly. + - HTTP binds only to `127.0.0.1:3300`. - SSH binds only to `127.0.0.1:2222`. - SQLite data stays under ignored `.tabellio/forgejo/`. diff --git a/infra/forgejo/compose.production.yml b/infra/forgejo/compose.production.yml new file mode 100644 index 0000000..e4ee798 --- /dev/null +++ b/infra/forgejo/compose.production.yml @@ -0,0 +1,70 @@ +name: tabellio-remote + +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: forgejo + POSTGRES_USER: forgejo + POSTGRES_PASSWORD: ${FORGEJO_DATABASE_PASSWORD:?set FORGEJO_DATABASE_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U forgejo -d forgejo"] + interval: 10s + timeout: 5s + retries: 12 + volumes: + - forgejo-postgres:/var/lib/postgresql/data + restart: unless-stopped + security_opt: + - no-new-privileges:true + + forgejo: + image: data.forgejo.org/forgejo/forgejo:15.0.3 + depends_on: + postgres: + condition: service_healthy + environment: + USER_UID: "1000" + USER_GID: "1000" + FORGEJO__database__DB_TYPE: postgres + FORGEJO__database__HOST: postgres:5432 + FORGEJO__database__NAME: forgejo + FORGEJO__database__USER: forgejo + FORGEJO__database__PASSWD: ${FORGEJO_DATABASE_PASSWORD:?set FORGEJO_DATABASE_PASSWORD} + FORGEJO__server__DOMAIN: ${TABELLIO_GIT_DOMAIN:?set TABELLIO_GIT_DOMAIN} + FORGEJO__server__ROOT_URL: ${TABELLIO_GIT_ROOT_URL:?set TABELLIO_GIT_ROOT_URL} + FORGEJO__server__HTTP_PORT: "3000" + FORGEJO__server__DISABLE_SSH: "true" + FORGEJO__service__DISABLE_REGISTRATION: "true" + FORGEJO__security__INSTALL_LOCK: "true" + FORGEJO__actions__ENABLED: "false" + FORGEJO__repository__ENABLE_PUSH_CREATE_USER: "false" + FORGEJO__repository__ENABLE_PUSH_CREATE_ORG: "false" + FORGEJO__log__MODE: console + expose: + - "3000" + volumes: + - forgejo-data:/data + restart: unless-stopped + security_opt: + - no-new-privileges:true + + git-gateway: + image: nginx:1.28-alpine + depends_on: + - forgejo + ports: + - "${TABELLIO_GIT_BIND_ADDRESS:-0.0.0.0}:${TABELLIO_GIT_PORT:-3400}:8080" + volumes: + - ./nginx.git-only.conf:/etc/nginx/conf.d/default.conf:ro + restart: unless-stopped + read_only: true + tmpfs: + - /var/cache/nginx + - /var/run + security_opt: + - no-new-privileges:true + +volumes: + forgejo-data: + forgejo-postgres: diff --git a/infra/forgejo/nginx.git-only.conf b/infra/forgejo/nginx.git-only.conf new file mode 100644 index 0000000..6acda95 --- /dev/null +++ b/infra/forgejo/nginx.git-only.conf @@ -0,0 +1,29 @@ +server { + listen 8080; + server_name _; + client_max_body_size 2g; + + location = /healthz { + access_log off; + default_type application/json; + return 200 '{"ok":true,"surface":"git-only"}\n'; + } + + location ~ ^/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+\.git/(info/refs|git-upload-pack|git-receive-pack)$ { + proxy_pass http://forgejo:3000; + proxy_http_version 1.1; + proxy_request_buffering off; + proxy_buffering off; + proxy_read_timeout 900s; + proxy_send_timeout 900s; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Authorization $http_authorization; + } + + location / { + default_type application/json; + return 404 '{"error":{"code":"surface_not_exposed","message":"Only Git smart HTTP is exposed."}}\n'; + } +} diff --git a/infra/postgres/001_control_plane.sql b/infra/postgres/001_control_plane.sql new file mode 100644 index 0000000..dea17f0 --- /dev/null +++ b/infra/postgres/001_control_plane.sql @@ -0,0 +1,90 @@ +BEGIN; + +CREATE SCHEMA IF NOT EXISTS tabellio; + +CREATE TABLE IF NOT EXISTS tabellio.repositories ( + tenant_id text NOT NULL, + id text NOT NULL, + slug text NOT NULL, + backend_provider text NOT NULL, + backend_id text, + backend_owner text NOT NULL, + backend_name text NOT NULL, + clone_url text, + default_branch text NOT NULL DEFAULT 'main', + state text NOT NULL CHECK (state IN ('provisioning', 'active', 'archived', 'failed')), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, id), + UNIQUE (tenant_id, slug), + UNIQUE (backend_provider, backend_id) +); + +CREATE TABLE IF NOT EXISTS tabellio.jobs ( + tenant_id text NOT NULL, + id text NOT NULL, + repository_id text, + type text NOT NULL CHECK (type IN ('repository.provision', 'change-request.create', 'validation.run', 'merge.intent.create', 'merge.approval.record', 'merge.execute')), + state text NOT NULL CHECK (state IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')), + requested_by text NOT NULL, + payload jsonb NOT NULL, + result jsonb, + error jsonb, + attempt integer NOT NULL DEFAULT 0 CHECK (attempt >= 0), + lease_worker_id text, + lease_expires_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + completed_at timestamptz, + PRIMARY KEY (tenant_id, id) +); + +CREATE INDEX IF NOT EXISTS jobs_claim_index + ON tabellio.jobs (state, created_at) + WHERE state IN ('queued', 'running'); + +CREATE INDEX IF NOT EXISTS jobs_repository_index + ON tabellio.jobs (tenant_id, repository_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS tabellio.idempotency_keys ( + tenant_id text NOT NULL, + agent_id text NOT NULL, + key text NOT NULL, + request_digest text NOT NULL CHECK (request_digest ~ '^[0-9a-f]{64}$'), + job_id text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + PRIMARY KEY (tenant_id, agent_id, key), + FOREIGN KEY (tenant_id, job_id) REFERENCES tabellio.jobs (tenant_id, id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS tabellio.webhook_deliveries ( + provider text NOT NULL, + delivery_id text NOT NULL, + tenant_id text NOT NULL, + repository_id text, + event_type text NOT NULL, + payload_digest text NOT NULL CHECK (payload_digest ~ '^[0-9a-f]{64}$'), + status text NOT NULL CHECK (status IN ('received', 'processed', 'rejected', 'failed')), + received_at timestamptz NOT NULL DEFAULT now(), + processed_at timestamptz, + PRIMARY KEY (provider, delivery_id) +); + +CREATE TABLE IF NOT EXISTS tabellio.credential_grants ( + tenant_id text NOT NULL, + id text NOT NULL, + repository_id text NOT NULL, + agent_id text NOT NULL, + scopes text[] NOT NULL, + provider text NOT NULL, + provider_credential_id text, + issued_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + PRIMARY KEY (tenant_id, id) +); + +COMMENT ON TABLE tabellio.credential_grants IS 'Audit metadata only. Never store Git credential secrets in PostgreSQL.'; + +COMMIT; diff --git a/package.json b/package.json index f483da2..ba5fa44 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,6 @@ "type": "module", "license": "Apache-2.0", "author": "IntelIP", - "homepage": "https://github.com/IntelIP/Tabellio#readme", - "repository": { - "type": "git", - "url": "git+https://github.com/IntelIP/Tabellio.git" - }, "bin": { "tabellio-run": "scripts/tabellio-run.mjs", "tabellio-stack": "scripts/tabellio-stack.mjs", @@ -20,9 +15,6 @@ "tabellio-ledger": "scripts/tabellio-ledger.mjs", "tabellio-forge": "scripts/tabellio-forge.mjs" }, - "bugs": { - "url": "https://github.com/IntelIP/Tabellio/issues" - }, "private": false, "publishConfig": { "access": "public" diff --git a/schemas/platform.schema.json b/schemas/platform.schema.json index 0c03353..528ccf9 100644 --- a/schemas/platform.schema.json +++ b/schemas/platform.schema.json @@ -1,21 +1,23 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:tabellio:schema:platform:v0.1", + "$id": "urn:tabellio:schema:platform:v0.2", "title": "Tabellio Platform Configuration", "type": "object", "additionalProperties": false, - "required": ["schemaVersion", "canonicalForge", "git", "ledger", "validation", "reviews", "transition"], + "required": ["schemaVersion", "remoteRepository", "git", "ledger", "validation", "reviews"], "properties": { - "schemaVersion": { "const": "tabellio-platform/v0.1" }, - "canonicalForge": { + "schemaVersion": { "const": "tabellio-platform/v0.2" }, + "remoteRepository": { "type": "object", "additionalProperties": false, - "required": ["provider", "urlEnv", "apiUrlEnv", "tokenFileEnv"], + "required": ["provider", "remoteName", "publicSurface", "gitUrlEnv", "apiUrlEnv", "credentialFileEnv"], "properties": { - "provider": { "const": "forgejo" }, - "urlEnv": { "const": "TABELLIO_FORGE_URL" }, - "apiUrlEnv": { "const": "TABELLIO_FORGE_API_URL" }, - "tokenFileEnv": { "const": "TABELLIO_FORGE_TOKEN_FILE" } + "provider": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "remoteName": { "type": "string", "pattern": "^[A-Za-z0-9._-]+$" }, + "publicSurface": { "const": "git-only" }, + "gitUrlEnv": { "type": "string", "minLength": 1 }, + "apiUrlEnv": { "type": "string", "minLength": 1 }, + "credentialFileEnv": { "type": "string", "minLength": 1 } } }, "git": { @@ -28,6 +30,7 @@ "controlRefs": { "type": "array", "minItems": 3, + "maxItems": 3, "uniqueItems": true, "items": { "enum": ["refs/tabellio/reviews", "refs/tabellio/validations", "refs/heads/entire/checkpoints/v1"] }, "allOf": [ @@ -62,18 +65,9 @@ "additionalProperties": false, "required": ["provider", "stateRef"], "properties": { - "provider": { "const": "forgejo" }, + "provider": { "const": "tabellio" }, "stateRef": { "const": "refs/tabellio/reviews" } } - }, - "transition": { - "type": "object", - "additionalProperties": false, - "required": ["codeStorage", "runtimeRequired"], - "properties": { - "codeStorage": { "type": "string", "minLength": 1 }, - "runtimeRequired": { "const": false } - } } } } diff --git a/schemas/review-cycle.schema.json b/schemas/review-cycle.schema.json index 0e1d5a4..ccbd805 100644 --- a/schemas/review-cycle.schema.json +++ b/schemas/review-cycle.schema.json @@ -19,7 +19,7 @@ "required": ["id", "owner", "repo"], "additionalProperties": false, "properties": { - "id": { "const": "forgejo" }, + "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, "owner": { "type": "string", "minLength": 1 }, "repo": { "type": "string", "minLength": 1 } } @@ -83,7 +83,7 @@ "additionalProperties": false, "properties": { "id": { "type": "string", "minLength": 1 }, - "source": { "enum": ["forgejo", "agent"] }, + "source": { "type": "string", "pattern": "^(?:agent|[a-z][a-z0-9-]*)$" }, "providerId": { "type": "string", "minLength": 1 }, "kind": { "enum": ["review", "review-comment", "issue-comment", "check", "agent-finding"] }, "author": { "$ref": "#/$defs/nullableString" }, diff --git a/scripts/check-tabellio-platform.mjs b/scripts/check-tabellio-platform.mjs index 49d87af..56b5b08 100644 --- a/scripts/check-tabellio-platform.mjs +++ b/scripts/check-tabellio-platform.mjs @@ -3,14 +3,22 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { validatePlatformConfig } from "./lib/platform-config.mjs"; +import { platformRemoteRepository, validatePlatformConfig } from "./lib/platform-config.mjs"; const path = resolve(process.argv[2] ?? "tabellio.platform.json"); try { if (process.argv.length > 3) throw new Error("Usage: check-tabellio-platform [path]."); const config = JSON.parse(await readFile(path, "utf8")); validatePlatformConfig(config); - console.log(JSON.stringify({ ok: true, status: "platform_ready", path, canonicalForge: config.canonicalForge.provider }, null, 2)); + const remoteRepository = platformRemoteRepository(config); + console.log(JSON.stringify({ + ok: true, + status: "platform_ready", + path, + schemaVersion: config.schemaVersion, + remoteRepository: remoteRepository.provider, + publicSurface: remoteRepository.publicSurface, + }, null, 2)); } catch (error) { process.exitCode = 1; console.error(JSON.stringify({ ok: false, status: "blocked", path, error: error instanceof Error ? error.message : String(error) }, null, 2)); diff --git a/scripts/lib/change-request.mjs b/scripts/lib/change-request.mjs new file mode 100644 index 0000000..4a8fa36 --- /dev/null +++ b/scripts/lib/change-request.mjs @@ -0,0 +1,99 @@ +import { createHash } from "node:crypto"; + +export const CHANGE_REQUEST_SCHEMA_VERSION = "tabellio-change-request/v0.1"; + +export function canonicalChangeRequestId({ repositoryId, providerId, backendId }) { + const normalizedRepositoryId = requiredString(repositoryId, "repositoryId"); + const normalizedProviderId = providerIdentifier(providerId, "providerId"); + const normalizedBackendId = requiredString(backendId, "backendId"); + const digest = createHash("sha256") + .update(`${normalizedRepositoryId}\0${normalizedProviderId}\0${normalizedBackendId}`) + .digest("hex") + .slice(0, 24); + return `cr_${digest}`; +} + +export function canonicalChangeRequest({ repositoryId, providerId, value }) { + object(value, "change request"); + const backendId = String(value.id ?? ""); + return { + schemaVersion: CHANGE_REQUEST_SCHEMA_VERSION, + id: canonicalChangeRequestId({ repositoryId, providerId, backendId }), + repository: { id: requiredString(repositoryId, "repositoryId") }, + backend: { + provider: providerIdentifier(providerId, "providerId"), + id: requiredString(backendId, "change request.id"), + number: positiveInteger(value.number, "change request.number"), + url: httpUrl(value.webUrl, "change request.webUrl"), + }, + title: requiredString(value.title, "change request.title"), + state: member(value.state, ["open", "closed", "merged"], "change request.state"), + draft: boolean(value.draft, "change request.draft"), + mergeable: nullableBoolean(value.mergeable, "change request.mergeable"), + source: branch(value.source, "change request.source"), + target: branch(value.target, "change request.target"), + updatedAt: isoDate(value.updatedAt, "change request.updatedAt"), + }; +} + +function branch(value, path) { + object(value, path); + return { + branch: requiredString(value.branch, `${path}.branch`), + commit: oid(value.commit, `${path}.commit`), + }; +} + +function object(value, path) { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError(`${path} must be an object.`); + return value; +} + +function requiredString(value, path) { + if (typeof value !== "string" || value.trim() === "") throw new TypeError(`${path} must be a non-empty string.`); + return value.trim(); +} + +function providerIdentifier(value, path) { + requiredString(value, path); + if (!/^[a-z][a-z0-9-]*$/.test(value)) throw new TypeError(`${path} must be a lowercase provider identifier.`); + return value; +} + +function positiveInteger(value, path) { + if (!Number.isInteger(value) || value <= 0) throw new TypeError(`${path} must be a positive integer.`); + return value; +} + +function boolean(value, path) { + if (typeof value !== "boolean") throw new TypeError(`${path} must be a boolean.`); + return value; +} + +function nullableBoolean(value, path) { + if (value === null || typeof value === "boolean") return value; + throw new TypeError(`${path} must be a boolean or null.`); +} + +function member(value, values, path) { + if (!values.includes(value)) throw new TypeError(`${path} must be one of: ${values.join(", ")}.`); + return value; +} + +function oid(value, path) { + if (typeof value !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value)) throw new TypeError(`${path} must be a Git object ID.`); + return value; +} + +function httpUrl(value, path) { + requiredString(value, path); + const parsed = new URL(value); + if (!["http:", "https:"].includes(parsed.protocol)) throw new TypeError(`${path} must be an HTTP URL.`); + return value; +} + +function isoDate(value, path) { + requiredString(value, path); + if (Number.isNaN(Date.parse(value))) throw new TypeError(`${path} must be an ISO date-time.`); + return value; +} diff --git a/scripts/lib/headless-api.mjs b/scripts/lib/headless-api.mjs new file mode 100644 index 0000000..fd553ff --- /dev/null +++ b/scripts/lib/headless-api.mjs @@ -0,0 +1,471 @@ +import { createHash, randomUUID } from "node:crypto"; + +const JOB_TYPES = new Set([ + "repository.provision", + "change-request.create", + "validation.run", + "merge.intent.create", + "merge.approval.record", + "merge.execute", +]); +const GIT_SCOPES = new Set(["git:read", "git:write"]); + +export class HeadlessApi { + constructor({ store, credentialBroker, authorizer, clock = () => new Date() }) { + requiredMethod(store, "repository"); + requiredMethod(store, "job"); + requiredMethod(store, "enqueue"); + requiredMethod(credentialBroker, "issue"); + requiredMethod(authorizer, "authorize"); + this.store = store; + this.credentialBroker = credentialBroker; + this.authorizer = authorizer; + this.clock = clock; + } + + async handle(request) { + const requestId = header(request.headers, "x-request-id") ?? `req_${randomUUID()}`; + try { + const method = requiredString(request.method, "method").toUpperCase(); + const url = new URL(requiredString(request.path, "path"), "https://api.tabellio.invalid"); + if (url.search) throw new ApiError(400, "unsupported_query", "Query parameters are not supported."); + const segments = url.pathname.split("/").filter(Boolean).map(decodeSegment); + if (method === "GET" && equalSegments(segments, ["v1", "health"])) { + return response(200, { ok: true, service: "tabellio-control-plane" }, requestId); + } + if (method === "POST" && equalSegments(segments, ["v1", "repositories"])) { + const principal = await this.#authorize(request, "repository:write"); + const body = repositoryProvisionInput(request.body); + const repositoryId = repositoryRequestId(principal.tenantId, body.owner, body.name); + return await this.#enqueue({ request, requestId, principal, type: "repository.provision", repositoryId, payload: { ...body, repositoryId } }); + } + if (method === "GET" && segments.length === 3 && segments[0] === "v1" && segments[1] === "repositories") { + const repositoryId = identifier(segments[2], "repositoryId"); + const principal = await this.#authorize(request, "repository:read", repositoryId); + const repository = await this.store.repository({ tenantId: principal.tenantId, repositoryId }); + if (!repository) throw new ApiError(404, "repository_not_found", "Repository was not found."); + return response(200, repository, requestId); + } + if (method === "POST" && segments.length === 4 && segments[0] === "v1" && segments[1] === "repositories" && segments[3] === "credentials") { + const repositoryId = identifier(segments[2], "repositoryId"); + const principal = await this.#authorize(request, "credential:issue", repositoryId); + const repository = await this.store.repository({ tenantId: principal.tenantId, repositoryId }); + if (!repository) throw new ApiError(404, "repository_not_found", "Repository was not found."); + const input = credentialInput(request.body); + const credential = await this.credentialBroker.issue({ + tenantId: principal.tenantId, + agentId: principal.agentId, + repository, + scopes: input.scopes, + ttlSeconds: input.ttlSeconds, + now: this.clock(), + }); + return response(201, credential, requestId, { "cache-control": "no-store" }); + } + if (method === "POST" && equalSegments(segments, ["v1", "change-requests"])) { + const body = changeRequestInput(request.body); + const principal = await this.#authorize(request, "change-request:write", body.repositoryId); + await this.#requireRepository(principal, body.repositoryId); + return await this.#enqueue({ request, requestId, principal, type: "change-request.create", repositoryId: body.repositoryId, payload: body }); + } + if (method === "POST" && equalSegments(segments, ["v1", "validations"])) { + const body = validationInput(request.body); + const principal = await this.#authorize(request, "validation:run", body.repositoryId); + await this.#requireRepository(principal, body.repositoryId); + return await this.#enqueue({ request, requestId, principal, type: "validation.run", repositoryId: body.repositoryId, payload: body }); + } + if (method === "POST" && equalSegments(segments, ["v1", "merge-intents"])) { + const body = mergeIntentInput(request.body); + const principal = await this.#authorize(request, "merge:intent", body.repositoryId); + await this.#requireRepository(principal, body.repositoryId); + return await this.#enqueue({ request, requestId, principal, type: "merge.intent.create", repositoryId: body.repositoryId, payload: body }); + } + if (method === "POST" && segments.length === 4 && segments[0] === "v1" && segments[1] === "merge-intents" && segments[3] === "approvals") { + const intentId = identifier(segments[2], "intentId"); + const body = mergeApprovalInput(request.body, this.clock()); + const principal = await this.#authorize(request, "merge:approve", body.repositoryId); + await this.#requireRepository(principal, body.repositoryId); + return await this.#enqueue({ + request, + requestId, + principal, + type: "merge.approval.record", + repositoryId: body.repositoryId, + payload: { ...body, intentId, approvedBy: principal.agentId }, + }); + } + if (method === "POST" && segments.length === 4 && segments[0] === "v1" && segments[1] === "merge-intents" && segments[3] === "executions") { + const intentId = identifier(segments[2], "intentId"); + const body = mergeExecutionInput(request.body); + const principal = await this.#authorize(request, "merge:execute", body.repositoryId); + await this.#requireRepository(principal, body.repositoryId); + return await this.#enqueue({ + request, + requestId, + principal, + type: "merge.execute", + repositoryId: body.repositoryId, + payload: { ...body, intentId }, + }); + } + if (method === "GET" && segments.length === 3 && segments[0] === "v1" && segments[1] === "jobs") { + const jobId = identifier(segments[2], "jobId"); + const principal = await this.#authorize(request, "job:read"); + const job = await this.store.job({ tenantId: principal.tenantId, jobId }); + if (!job) throw new ApiError(404, "job_not_found", "Job was not found."); + return response(200, job, requestId); + } + throw new ApiError(404, "route_not_found", "Route was not found."); + } catch (error) { + const apiError = error instanceof ApiError + ? error + : new ApiError(500, "internal_error", "Request failed."); + return response(apiError.status, { error: { code: apiError.code, message: apiError.message } }, requestId); + } + } + + async #authorize(request, scope, repositoryId = null) { + const authorization = header(request.headers, "authorization"); + if (!authorization) throw new ApiError(401, "missing_authorization", "Authorization header is required."); + const principal = await this.authorizer.authorize({ authorization, scope, repositoryId }); + if (!principal) throw new ApiError(403, "forbidden", "Credential does not grant required scope."); + requiredString(principal.tenantId, "principal.tenantId"); + requiredString(principal.agentId, "principal.agentId"); + return principal; + } + + async #requireRepository(principal, repositoryId) { + const repository = await this.store.repository({ tenantId: principal.tenantId, repositoryId }); + if (!repository) throw new ApiError(404, "repository_not_found", "Repository was not found."); + return repository; + } + + async #enqueue({ request, requestId, principal, type, repositoryId, payload }) { + const idempotencyKey = header(request.headers, "idempotency-key"); + if (!idempotencyKey || idempotencyKey.length > 200) { + throw new ApiError(400, "invalid_idempotency_key", "Idempotency-Key header is required and must contain at most 200 characters."); + } + const job = await this.store.enqueue({ + tenantId: principal.tenantId, + agentId: principal.agentId, + type, + repositoryId, + payload, + idempotencyKey, + now: this.clock(), + }); + return response(202, job, requestId, { location: `/v1/jobs/${job.id}` }); + } +} + +export class ControlPlaneStore { + async repository(_options) { throw new Error("ControlPlaneStore.repository must be implemented."); } + async job(_options) { throw new Error("ControlPlaneStore.job must be implemented."); } + async enqueue(_options) { throw new Error("ControlPlaneStore.enqueue must be implemented."); } + async claim(_options) { throw new Error("ControlPlaneStore.claim must be implemented."); } + async heartbeat(_options) { throw new Error("ControlPlaneStore.heartbeat must be implemented."); } + async complete(_options) { throw new Error("ControlPlaneStore.complete must be implemented."); } + async fail(_options) { throw new Error("ControlPlaneStore.fail must be implemented."); } +} + +export class CredentialBroker { + async issue(_options) { throw new Error("CredentialBroker.issue must be implemented."); } +} + +export class InMemoryControlPlaneStore extends ControlPlaneStore { + #repositories = new Map(); + #jobs = new Map(); + #idempotency = new Map(); + + putRepository(record) { + object(record, "repository"); + const tenantId = requiredString(record.tenantId, "repository.tenantId"); + const id = identifier(record.id, "repository.id"); + this.#repositories.set(`${tenantId}\0${id}`, structuredClone(record)); + return structuredClone(record); + } + + async repository({ tenantId, repositoryId }) { + const value = this.#repositories.get(`${tenantId}\0${repositoryId}`); + return value ? structuredClone(value) : null; + } + + async job({ tenantId, jobId }) { + const value = this.#jobs.get(`${tenantId}\0${jobId}`); + return value ? structuredClone(value) : null; + } + + async enqueue({ tenantId, agentId, type, repositoryId, payload, idempotencyKey, now }) { + if (!JOB_TYPES.has(type)) throw new TypeError(`Unsupported job type: ${type}.`); + const digest = createHash("sha256").update(stableJson({ type, repositoryId, payload })).digest("hex"); + const idempotencyId = `${tenantId}\0${agentId}\0${idempotencyKey}`; + const existing = this.#idempotency.get(idempotencyId); + if (existing) { + if (existing.digest !== digest) throw new ApiError(409, "idempotency_conflict", "Idempotency key was reused with different input."); + return this.job({ tenantId, jobId: existing.jobId }); + } + const timestamp = now.toISOString(); + const job = { + schemaVersion: "tabellio-job/v0.1", + id: `job_${randomUUID()}`, + tenantId, + repositoryId, + type, + state: "queued", + requestedBy: agentId, + payload: structuredClone(payload), + attempt: 0, + lease: null, + result: null, + error: null, + completedAt: null, + createdAt: timestamp, + updatedAt: timestamp, + }; + this.#jobs.set(`${tenantId}\0${job.id}`, job); + this.#idempotency.set(idempotencyId, { digest, jobId: job.id }); + return structuredClone(job); + } + + async claim({ workerId, leaseMs, now, types = null }) { + requiredString(workerId, "workerId"); + if (!Number.isInteger(leaseMs) || leaseMs < 1_000 || leaseMs > 3_600_000) throw new TypeError("leaseMs must be between 1000 and 3600000."); + const allowedTypes = types ? new Set(types) : null; + const candidates = [...this.#jobs.entries()] + .filter(([, job]) => (job.state === "queued" || leaseExpired(job, now)) && (!allowedTypes || allowedTypes.has(job.type))) + .sort((left, right) => left[1].createdAt.localeCompare(right[1].createdAt) || left[1].id.localeCompare(right[1].id)); + if (candidates.length === 0) return null; + const [key, job] = candidates[0]; + const timestamp = now.toISOString(); + job.state = "running"; + job.attempt += 1; + job.lease = { workerId, expiresAt: new Date(now.getTime() + leaseMs).toISOString() }; + job.error = null; + job.updatedAt = timestamp; + this.#jobs.set(key, job); + return structuredClone(job); + } + + async heartbeat({ tenantId, jobId, workerId, leaseMs, now }) { + const job = this.#leasedJob({ tenantId, jobId, workerId }); + job.lease.expiresAt = new Date(now.getTime() + leaseMs).toISOString(); + job.updatedAt = now.toISOString(); + return structuredClone(job); + } + + async complete({ tenantId, jobId, workerId, result, now }) { + const job = this.#leasedJob({ tenantId, jobId, workerId }); + job.state = "succeeded"; + job.lease = null; + job.result = structuredClone(result ?? null); + job.error = null; + job.completedAt = now.toISOString(); + job.updatedAt = job.completedAt; + return structuredClone(job); + } + + async fail({ tenantId, jobId, workerId, error, retry, now }) { + const job = this.#leasedJob({ tenantId, jobId, workerId }); + job.state = retry ? "queued" : "failed"; + job.lease = null; + job.error = { message: String(error).slice(0, 2_000), retryable: retry }; + job.completedAt = retry ? null : now.toISOString(); + job.updatedAt = now.toISOString(); + return structuredClone(job); + } + + #leasedJob({ tenantId, jobId, workerId }) { + const job = this.#jobs.get(`${tenantId}\0${jobId}`); + if (!job) throw new ApiError(404, "job_not_found", "Job was not found."); + if (job.state !== "running" || job.lease?.workerId !== workerId) { + throw new ApiError(409, "lease_conflict", "Worker does not own active job lease."); + } + return job; + } +} + +export class ApiError extends Error { + constructor(status, code, message) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + } +} + +function repositoryProvisionInput(value) { + exactObject(value, ["owner", "name", "private", "defaultBranch"], "repository request"); + return { + owner: slug(value.owner, "repository request.owner"), + name: slug(value.name, "repository request.name"), + private: boolean(value.private, "repository request.private"), + defaultBranch: branchName(value.defaultBranch, "repository request.defaultBranch"), + }; +} + +function credentialInput(value) { + exactObject(value, ["scopes", "ttlSeconds"], "credential request"); + if (!Array.isArray(value.scopes) || value.scopes.length === 0 || new Set(value.scopes).size !== value.scopes.length) { + throw new ApiError(400, "invalid_request", "credential request.scopes must be a non-empty unique array."); + } + for (const scope of value.scopes) if (!GIT_SCOPES.has(scope)) throw new ApiError(400, "invalid_request", `Unsupported Git scope: ${scope}.`); + if (!Number.isInteger(value.ttlSeconds) || value.ttlSeconds < 60 || value.ttlSeconds > 3600) { + throw new ApiError(400, "invalid_request", "credential request.ttlSeconds must be between 60 and 3600."); + } + return { scopes: [...value.scopes].sort(), ttlSeconds: value.ttlSeconds }; +} + +function changeRequestInput(value) { + exactObject(value, ["repositoryId", "sourceBranch", "targetBranch", "title", "draft"], "change-request request"); + const title = requiredString(value.title, "change-request request.title"); + if (title.length > 500) throw new ApiError(400, "invalid_request", "change-request request.title must contain at most 500 characters."); + return { + repositoryId: identifier(value.repositoryId, "change-request request.repositoryId"), + sourceBranch: branchName(value.sourceBranch, "change-request request.sourceBranch"), + targetBranch: branchName(value.targetBranch, "change-request request.targetBranch"), + title, + draft: boolean(value.draft, "change-request request.draft"), + }; +} + +function validationInput(value) { + exactObject(value, ["repositoryId", "baseCommit", "headCommit"], "validation request"); + return { + repositoryId: identifier(value.repositoryId, "validation request.repositoryId"), + baseCommit: oid(value.baseCommit, "validation request.baseCommit"), + headCommit: oid(value.headCommit, "validation request.headCommit"), + }; +} + +function mergeIntentInput(value) { + exactObject(value, ["repositoryId", "changeRequestId", "headCommit"], "merge request"); + return { + repositoryId: identifier(value.repositoryId, "merge request.repositoryId"), + changeRequestId: identifier(value.changeRequestId, "merge request.changeRequestId"), + headCommit: oid(value.headCommit, "merge request.headCommit"), + }; +} + +function mergeApprovalInput(value, now) { + exactObject(value, ["repositoryId", "headCommit", "expiresAt"], "merge approval request"); + const expiresAt = isoDate(value.expiresAt, "merge approval request.expiresAt"); + const lifetimeMs = Date.parse(expiresAt) - now.getTime(); + if (lifetimeMs <= 0 || lifetimeMs > 15 * 60 * 1000) { + throw new ApiError(400, "invalid_request", "merge approval request.expiresAt must be within the next 15 minutes."); + } + return { + repositoryId: identifier(value.repositoryId, "merge approval request.repositoryId"), + headCommit: oid(value.headCommit, "merge approval request.headCommit"), + expiresAt, + }; +} + +function mergeExecutionInput(value) { + exactObject(value, ["repositoryId", "headCommit", "approvalId"], "merge execution request"); + return { + repositoryId: identifier(value.repositoryId, "merge execution request.repositoryId"), + headCommit: oid(value.headCommit, "merge execution request.headCommit"), + approvalId: identifier(value.approvalId, "merge execution request.approvalId"), + }; +} + +function repositoryRequestId(tenantId, owner, name) { + const digest = createHash("sha256").update(`${tenantId}\0${owner}\0${name}`).digest("hex").slice(0, 24); + return `repo_${digest}`; +} + +function response(status, body, requestId, extraHeaders = {}) { + return { + status, + headers: { "content-type": "application/json", "x-request-id": requestId, ...extraHeaders }, + body, + }; +} + +function header(headers = {}, name) { + for (const [key, value] of Object.entries(headers ?? {})) { + if (key.toLowerCase() === name) return typeof value === "string" ? value : null; + } + return null; +} + +function equalSegments(actual, expected) { + return actual.length === expected.length && actual.every((value, index) => value === expected[index]); +} + +function decodeSegment(value) { + try { + return decodeURIComponent(value); + } catch { + throw new ApiError(400, "invalid_path", "Path contains invalid encoding."); + } +} + +function exactObject(value, keys, path) { + object(value, path); + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new ApiError(400, "invalid_request", `${path} must contain exactly: ${expected.join(", ")}.`); +} + +function object(value, path) { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ApiError(400, "invalid_request", `${path} must be an object.`); + return value; +} + +function requiredString(value, path) { + if (typeof value !== "string" || value.trim() === "") throw new ApiError(400, "invalid_request", `${path} must be a non-empty string.`); + return value.trim(); +} + +function identifier(value, path) { + const result = requiredString(value, path); + if (!/^[A-Za-z0-9._-]+$/.test(result)) throw new ApiError(400, "invalid_request", `${path} contains unsupported characters.`); + return result; +} + +function slug(value, path) { + const result = requiredString(value, path); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(result)) throw new ApiError(400, "invalid_request", `${path} must be a safe repository slug.`); + return result; +} + +function branchName(value, path) { + const result = requiredString(value, path); + if (result.length > 255 || result.startsWith("-") || result.includes("..") || /[\s~^:?*[\\]/.test(result)) { + throw new ApiError(400, "invalid_request", `${path} must be a safe Git branch name.`); + } + return result; +} + +function oid(value, path) { + if (typeof value !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value)) throw new ApiError(400, "invalid_request", `${path} must be a Git object ID.`); + return value; +} + +function isoDate(value, path) { + const result = requiredString(value, path); + if (Number.isNaN(Date.parse(result))) throw new ApiError(400, "invalid_request", `${path} must be an ISO date-time.`); + return result; +} + +function boolean(value, path) { + if (typeof value !== "boolean") throw new ApiError(400, "invalid_request", `${path} must be a boolean.`); + return value; +} + +function requiredMethod(value, method) { + if (!value || typeof value[method] !== "function") throw new TypeError(`${method} must be implemented.`); +} + +function stableJson(value) { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function leaseExpired(job, now) { + return job.state === "running" && job.lease && Date.parse(job.lease.expiresAt) <= now.getTime(); +} diff --git a/scripts/lib/headless-http.mjs b/scripts/lib/headless-http.mjs new file mode 100644 index 0000000..c57fd97 --- /dev/null +++ b/scripts/lib/headless-http.mjs @@ -0,0 +1,52 @@ +export function createHeadlessHttpHandler(api, { maxBodyBytes = 1_048_576 } = {}) { + if (!api || typeof api.handle !== "function") throw new TypeError("api.handle must be implemented."); + if (!Number.isInteger(maxBodyBytes) || maxBodyBytes < 1_024 || maxBodyBytes > 10_485_760) { + throw new TypeError("maxBodyBytes must be between 1024 and 10485760."); + } + return async function headlessHttpHandler(request, response) { + try { + const body = await readBody(request, maxBodyBytes); + const result = await api.handle({ + method: request.method ?? "GET", + path: request.url ?? "/", + headers: request.headers, + body, + }); + response.writeHead(result.status, result.headers); + response.end(`${JSON.stringify(result.body)}\n`); + } catch (error) { + const status = error instanceof HttpInputError ? error.status : 500; + const code = error instanceof HttpInputError ? error.code : "internal_error"; + const message = error instanceof HttpInputError ? error.message : "Request failed."; + response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }); + response.end(`${JSON.stringify({ error: { code, message } })}\n`); + } + }; +} + +async function readBody(request, maxBodyBytes) { + if (["GET", "HEAD"].includes(request.method ?? "GET")) return null; + const contentType = String(request.headers["content-type"] ?? "").split(";", 1)[0].trim().toLowerCase(); + if (contentType !== "application/json") throw new HttpInputError(415, "unsupported_media_type", "Content-Type must be application/json."); + const chunks = []; + let bytes = 0; + for await (const chunk of request) { + bytes += chunk.length; + if (bytes > maxBodyBytes) throw new HttpInputError(413, "body_too_large", "Request body exceeds configured limit."); + chunks.push(chunk); + } + if (bytes === 0) throw new HttpInputError(400, "missing_body", "JSON request body is required."); + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + throw new HttpInputError(400, "invalid_json", "Request body must contain valid JSON."); + } +} + +class HttpInputError extends Error { + constructor(status, code, message) { + super(message); + this.status = status; + this.code = code; + } +} diff --git a/scripts/lib/job-worker.mjs b/scripts/lib/job-worker.mjs new file mode 100644 index 0000000..c99ba0d --- /dev/null +++ b/scripts/lib/job-worker.mjs @@ -0,0 +1,70 @@ +export class JobWorker { + constructor({ queue, handlers, workerId, leaseMs = 60_000, maxAttempts = 3, clock = () => new Date() }) { + requiredMethod(queue, "claim"); + requiredMethod(queue, "heartbeat"); + requiredMethod(queue, "complete"); + requiredMethod(queue, "fail"); + if (typeof handlers !== "object" || handlers === null || Array.isArray(handlers)) throw new TypeError("handlers must be an object."); + requiredString(workerId, "workerId"); + if (!Number.isInteger(leaseMs) || leaseMs < 1_000 || leaseMs > 3_600_000) throw new TypeError("leaseMs must be between 1000 and 3600000."); + if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 100) throw new TypeError("maxAttempts must be between 1 and 100."); + this.queue = queue; + this.handlers = handlers; + this.workerId = workerId; + this.leaseMs = leaseMs; + this.maxAttempts = maxAttempts; + this.clock = clock; + } + + async runOnce() { + const types = Object.keys(this.handlers); + const job = await this.queue.claim({ workerId: this.workerId, leaseMs: this.leaseMs, now: this.clock(), types }); + if (!job) return null; + const handler = this.handlers[job.type]; + if (typeof handler !== "function") { + return this.queue.fail({ + tenantId: job.tenantId, + jobId: job.id, + workerId: this.workerId, + error: `No handler registered for ${job.type}.`, + retry: false, + now: this.clock(), + }); + } + try { + const result = await handler(structuredClone(job), { + heartbeat: () => this.queue.heartbeat({ + tenantId: job.tenantId, + jobId: job.id, + workerId: this.workerId, + leaseMs: this.leaseMs, + now: this.clock(), + }), + }); + return await this.queue.complete({ + tenantId: job.tenantId, + jobId: job.id, + workerId: this.workerId, + result, + now: this.clock(), + }); + } catch (error) { + return this.queue.fail({ + tenantId: job.tenantId, + jobId: job.id, + workerId: this.workerId, + error: error instanceof Error ? error.message : String(error), + retry: job.attempt < this.maxAttempts, + now: this.clock(), + }); + } + } +} + +function requiredMethod(value, method) { + if (!value || typeof value[method] !== "function") throw new TypeError(`${method} must be implemented.`); +} + +function requiredString(value, path) { + if (typeof value !== "string" || value.trim() === "") throw new TypeError(`${path} must be a non-empty string.`); +} diff --git a/scripts/lib/platform-config.mjs b/scripts/lib/platform-config.mjs index d349878..b41d01a 100644 --- a/scripts/lib/platform-config.mjs +++ b/scripts/lib/platform-config.mjs @@ -1,13 +1,56 @@ export function validatePlatformConfig(value) { object(value, "platform"); + if (value.schemaVersion === "tabellio-platform/v0.1") return validateV01(value); + if (value.schemaVersion === "tabellio-platform/v0.2") return validateV02(value); + throw new Error("platform.schemaVersion must be tabellio-platform/v0.1 or tabellio-platform/v0.2."); +} + +export function platformRemoteRepository(value) { + validatePlatformConfig(value); + if (value.schemaVersion === "tabellio-platform/v0.2") return value.remoteRepository; + return { + provider: value.canonicalForge.provider, + remoteName: "forgejo", + publicSurface: "forge-compatibility", + gitUrlEnv: value.canonicalForge.urlEnv, + apiUrlEnv: value.canonicalForge.apiUrlEnv, + credentialFileEnv: value.canonicalForge.tokenFileEnv, + }; +} + +function validateV01(value) { exact(value, ["schemaVersion", "canonicalForge", "git", "ledger", "validation", "reviews", "transition"], "platform"); - equals(value.schemaVersion, "tabellio-platform/v0.1", "platform.schemaVersion"); exactObject(value.canonicalForge, { provider: "forgejo", urlEnv: "TABELLIO_FORGE_URL", apiUrlEnv: "TABELLIO_FORGE_API_URL", tokenFileEnv: "TABELLIO_FORGE_TOKEN_FILE", }, "platform.canonicalForge"); + validateSharedConfig(value); + exactObject(value.reviews, { provider: "forgejo", stateRef: "refs/tabellio/reviews" }, "platform.reviews"); + object(value.transition, "platform.transition"); + exact(value.transition, ["codeStorage", "runtimeRequired"], "platform.transition"); + string(value.transition.codeStorage, "platform.transition.codeStorage"); + equals(value.transition.runtimeRequired, false, "platform.transition.runtimeRequired"); + return value; +} + +function validateV02(value) { + exact(value, ["schemaVersion", "remoteRepository", "git", "ledger", "validation", "reviews"], "platform"); + object(value.remoteRepository, "platform.remoteRepository"); + exact(value.remoteRepository, ["provider", "remoteName", "publicSurface", "gitUrlEnv", "apiUrlEnv", "credentialFileEnv"], "platform.remoteRepository"); + identifier(value.remoteRepository.provider, "platform.remoteRepository.provider"); + remoteName(value.remoteRepository.remoteName, "platform.remoteRepository.remoteName"); + equals(value.remoteRepository.publicSurface, "git-only", "platform.remoteRepository.publicSurface"); + string(value.remoteRepository.gitUrlEnv, "platform.remoteRepository.gitUrlEnv"); + string(value.remoteRepository.apiUrlEnv, "platform.remoteRepository.apiUrlEnv"); + string(value.remoteRepository.credentialFileEnv, "platform.remoteRepository.credentialFileEnv"); + validateSharedConfig(value); + exactObject(value.reviews, { provider: "tabellio", stateRef: "refs/tabellio/reviews" }, "platform.reviews"); + return value; +} + +function validateSharedConfig(value) { object(value.git, "platform.git"); exact(value.git, ["stackManager", "codeRef", "controlRefs"], "platform.git"); equals(value.git.stackManager, "git-spice", "platform.git.stackManager"); @@ -24,12 +67,6 @@ export function validatePlatformConfig(value) { equals(value.validation.runner, "tabellio-validate", "platform.validation.runner"); string(value.validation.manifest, "platform.validation.manifest"); equals(value.validation.resultRef, "refs/tabellio/validations", "platform.validation.resultRef"); - exactObject(value.reviews, { provider: "forgejo", stateRef: "refs/tabellio/reviews" }, "platform.reviews"); - object(value.transition, "platform.transition"); - exact(value.transition, ["codeStorage", "runtimeRequired"], "platform.transition"); - string(value.transition.codeStorage, "platform.transition.codeStorage"); - equals(value.transition.runtimeRequired, false, "platform.transition.runtimeRequired"); - return value; } function exactObject(value, expected, path) { @@ -56,6 +93,16 @@ function string(value, path) { if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`); } +function identifier(value, path) { + string(value, path); + if (!/^[a-z][a-z0-9-]*$/.test(value)) throw new Error(`${path} must be a lowercase provider identifier.`); +} + +function remoteName(value, path) { + string(value, path); + if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error(`${path} must be a valid Git remote name.`); +} + function equals(value, expected, path) { if (value !== expected) throw new Error(`${path} must be ${JSON.stringify(expected)}.`); } diff --git a/scripts/lib/remote-repository-provider.mjs b/scripts/lib/remote-repository-provider.mjs new file mode 100644 index 0000000..59876cd --- /dev/null +++ b/scripts/lib/remote-repository-provider.mjs @@ -0,0 +1,29 @@ +import { ForgeProvider } from "./forge-provider.mjs"; + +export class RemoteRepositoryProvider extends ForgeProvider { + constructor({ providerId }) { + super(); + if (typeof providerId !== "string" || !/^[a-z][a-z0-9-]*$/.test(providerId)) { + throw new TypeError("providerId must be a lowercase provider identifier."); + } + this.providerId = providerId; + } + + async createRepository(_options) { + throw new Error("RemoteRepositoryProvider.createRepository must be implemented."); + } + + async archiveRepository(_options) { + throw new Error("RemoteRepositoryProvider.archiveRepository must be implemented."); + } + + async gitRemote(options) { + const repository = await this.repository(options); + return { + provider: this.providerId, + repositoryId: repository.id, + cloneUrl: repository.cloneUrl, + defaultBranch: repository.defaultBranch, + }; + } +} diff --git a/scripts/lib/repository-identity.mjs b/scripts/lib/repository-identity.mjs index f9d5f4c..2a48ffe 100644 --- a/scripts/lib/repository-identity.mjs +++ b/scripts/lib/repository-identity.mjs @@ -1,8 +1,11 @@ import { createHash } from "node:crypto"; -export async function repositoryIdentity(store, explicitId = null) { +export async function repositoryIdentity(store, explicitId = null, { + remoteName = process.env.TABELLIO_REMOTE_NAME ?? "forgejo", +} = {}) { if (explicitId) return explicitId; - const remote = await store.gitConfig("remote.origin.url"); + if (!/^[A-Za-z0-9._-]+$/.test(remoteName)) throw new Error("remoteName must be a valid Git remote name."); + const remote = await store.gitConfig(`remote.${remoteName}.url`); return remote ? normalizeRepositoryRemote(remote) : localRepositoryId(store.repoPath); } diff --git a/scripts/lib/repository-store.mjs b/scripts/lib/repository-store.mjs index 0b22b12..b7e3458 100644 --- a/scripts/lib/repository-store.mjs +++ b/scripts/lib/repository-store.mjs @@ -1,4 +1,9 @@ export class RepositoryStore { + /** @param {string} key @returns {Promise} */ + async gitConfig(_key) { + throw new Error("RepositoryStore.gitConfig must be implemented."); + } + /** @param {string} revision @returns {Promise} */ async resolveRef(_revision) { throw new Error("RepositoryStore.resolveRef must be implemented."); diff --git a/scripts/lib/review-cycle.mjs b/scripts/lib/review-cycle.mjs index 5e1a1e7..35ec33e 100644 --- a/scripts/lib/review-cycle.mjs +++ b/scripts/lib/review-cycle.mjs @@ -1,5 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; +import { canonicalChangeRequest } from "./change-request.mjs"; import { runGit } from "./git-process.mjs"; import { digestObject } from "./stack-operation.mjs"; import { latestValidationResult } from "./validation-runner.mjs"; @@ -11,11 +12,13 @@ const MAX_AGENT_FINDINGS = 1_000; const MAX_TEXT_BODY = 64 * 1024; export class ReviewCycleManager { - constructor({ store, ledger, validationLedger = null, provider, repositoryId, owner, repo }) { + constructor({ store, ledger, validationLedger = null, provider, providerId = null, repositoryId, owner, repo }) { this.store = store; this.ledger = ledger; this.validationLedger = validationLedger; this.provider = provider; + this.providerId = provider?.providerId ?? providerId; + providerIdentifier(this.providerId, "providerId"); this.repositoryId = repositoryId; this.owner = owner; this.repo = repo; @@ -41,16 +44,25 @@ export class ReviewCycleManager { if (localValidation && localValidation.repository.id !== this.repositoryId) { throw new Error("Local validation repository does not match the review cycle."); } + const canonical = canonicalChangeRequest({ + repositoryId: this.repositoryId, + providerId: this.providerId, + value: changeRequest, + }); const checks = mergeChecks(forgeChecks, localValidation); const record = await this.#read(number); const existing = record.value; if (existing) validateReviewCycle(existing); const timestamp = now.toISOString(); const feedback = mergeProviderFeedback(existing?.feedback ?? [], [ - ...reviews.map((value) => reviewFeedback(value, timestamp)), - ...reviewComments.map(reviewCommentFeedback), - ...issueComments.map(issueCommentFeedback), - ...checks.statuses.filter((status) => ["error", "failure", "failed"].includes(status.state)).map((value) => checkFeedback(value, timestamp)), + ...reviews.map((value) => reviewFeedback(value, timestamp, this.providerId)), + ...reviewComments.map((value) => reviewCommentFeedback(value, this.providerId)), + ...issueComments.map((value) => issueCommentFeedback(value, this.providerId)), + ...checks.statuses.filter((status) => ["error", "failure", "failed"].includes(status.state)).map((value) => checkFeedback( + value, + timestamp, + value.id.startsWith("validation:") ? "tabellio" : this.providerId, + )), ], timestamp); const fixes = await Promise.all((existing?.fixes ?? []).map((fix) => this.#reconcileFix( fix, @@ -62,20 +74,20 @@ export class ReviewCycleManager { schemaVersion: REVIEW_CYCLE_SCHEMA_VERSION, id: existing?.id ?? cycleId(this.repositoryId, this.owner, this.repo, number), repository: { id: this.repositoryId }, - provider: { id: "forgejo", owner: this.owner, repo: this.repo }, + provider: { id: this.providerId, owner: this.owner, repo: this.repo }, changeRequest: { - id: changeRequest.id, - number: changeRequest.number, - url: changeRequest.webUrl, - title: changeRequest.title, - state: changeRequest.state, - draft: changeRequest.draft, - mergeable: changeRequest.mergeable, - headBranch: changeRequest.source.branch, - headCommit: changeRequest.source.commit, - baseBranch: changeRequest.target.branch, - baseCommit: changeRequest.target.commit, - updatedAt: changeRequest.updatedAt, + id: canonical.id, + number: canonical.backend.number, + url: canonical.backend.url, + title: canonical.title, + state: canonical.state, + draft: canonical.draft, + mergeable: canonical.mergeable, + headBranch: canonical.source.branch, + headCommit: canonical.source.commit, + baseBranch: canonical.target.branch, + baseCommit: canonical.target.commit, + updatedAt: canonical.updatedAt, }, status: "needs_triage", round: existing ? existing.round + (headChanged ? 1 : 0) : 1, @@ -195,7 +207,7 @@ export class ReviewCycleManager { async #read(number) { positiveInteger(number, "number"); - return this.ledger.read(cyclePath(this.repositoryId, this.owner, this.repo, number)); + return this.ledger.read(cyclePath(this.repositoryId, this.providerId, this.owner, this.repo, number)); } async #required(number) { @@ -209,13 +221,13 @@ export class ReviewCycleManager { cycle.status = deriveStatus(cycle); cycle.integrity = { algorithm: "sha256", digest: cycleDigest(cycle) }; validateReviewCycle(cycle); - const path = cyclePath(this.repositoryId, this.owner, this.repo, cycle.changeRequest.number); + const path = cyclePath(this.repositoryId, this.providerId, this.owner, this.repo, cycle.changeRequest.number); const written = await this.ledger.write(path, cycle, { expectedVersion }); return { cycle, path, version: written.version }; } #requireProvider() { - if (!this.provider) throw new Error("Review sync requires a forge provider."); + if (!this.provider) throw new Error("Review sync requires a remote repository provider."); } async #requireCheckpoint(baseCommit, fixCommit, checkpointId) { @@ -267,7 +279,7 @@ export function validateReviewCycle(value) { requiredString(value.repository.id, "repository.id"); object(value.provider, "provider"); exactKeys(value.provider, ["id", "owner", "repo"], "provider"); - equals(value.provider.id, "forgejo", "provider.id"); + providerIdentifier(value.provider.id, "provider.id"); requiredString(value.provider.owner, "provider.owner"); requiredString(value.provider.repo, "provider.repo"); validateChangeRequest(value.changeRequest); @@ -366,10 +378,11 @@ function mergeProviderFeedback(existing, incoming, timestamp) { return merged.sort((left, right) => left.id.localeCompare(right.id)); } -function reviewFeedback(value, timestamp) { +function reviewFeedback(value, timestamp, source) { const requestedChanges = ["request_changes", "changes_requested"].includes(value.state); const approved = value.state === "approved"; return feedback({ + source, id: `review:${value.id}`, providerId: value.id, kind: "review", @@ -385,8 +398,9 @@ function reviewFeedback(value, timestamp) { }); } -function reviewCommentFeedback(value) { +function reviewCommentFeedback(value, source) { return feedback({ + source, id: `review-comment:${value.id}`, providerId: value.id, kind: "review-comment", @@ -404,8 +418,9 @@ function reviewCommentFeedback(value) { }); } -function issueCommentFeedback(value) { +function issueCommentFeedback(value, source) { return feedback({ + source, id: `issue-comment:${value.id}`, providerId: value.id, kind: "issue-comment", @@ -420,8 +435,9 @@ function issueCommentFeedback(value) { }); } -function checkFeedback(value, timestamp) { +function checkFeedback(value, timestamp, source) { return feedback({ + source, id: `check:${value.id}`, providerId: value.id, kind: "check", @@ -437,6 +453,7 @@ function checkFeedback(value, timestamp) { } function feedback({ + source, id, providerId, kind, @@ -455,7 +472,7 @@ function feedback({ }) { return { id, - source: "forgejo", + source, providerId, kind, author, @@ -516,8 +533,8 @@ function cycleId(repositoryId, owner, repo, number) { return `review-${createHash("sha256").update(`${repositoryId}\0${owner}\0${repo}\0${number}`).digest("hex").slice(0, 24)}`; } -function cyclePath(repositoryId, owner, repo, number) { - return `cycles/forgejo-${number}-${createHash("sha256").update(`${repositoryId}\0${owner}\0${repo}`).digest("hex").slice(0, 16)}.json`; +function cyclePath(repositoryId, providerId, owner, repo, number) { + return `cycles/${providerId}-${number}-${createHash("sha256").update(`${repositoryId}\0${owner}\0${repo}`).digest("hex").slice(0, 16)}.json`; } function event(type, actor, at, detail) { @@ -550,7 +567,7 @@ function validateFeedback(value, path) { object(value, path); exactKeys(value, ["id", "source", "providerId", "kind", "author", "title", "body", "path", "line", "commit", "severity", "providerState", "disposition", "resolution", "fixId", "createdAt", "updatedAt"], path); requiredString(value.id, `${path}.id`); - member(value.source, ["forgejo", "agent"], `${path}.source`); + providerIdentifier(value.source, `${path}.source`, { allowAgent: true }); requiredString(value.providerId, `${path}.providerId`); member(value.kind, ["review", "review-comment", "issue-comment", "check", "agent-finding"], `${path}.kind`); if (value.author !== null) requiredString(value.author, `${path}.author`); @@ -641,6 +658,12 @@ function equals(value, expected, path) { if (value !== expected) throw new Error(`${path} must be ${JSON.stringify(expected)}.`); } +function providerIdentifier(value, path, { allowAgent = false } = {}) { + requiredString(value, path); + if (allowAgent && value === "agent") return; + if (!/^[a-z][a-z0-9-]*$/.test(value)) throw new Error(`${path} must be a lowercase provider identifier.`); +} + function oid(value, path) { if (typeof value !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value)) throw new Error(`${path} must be a Git object ID.`); } diff --git a/scripts/providers/forgejo-provider.mjs b/scripts/providers/forgejo-provider.mjs index 1ffdbeb..fa09409 100644 --- a/scripts/providers/forgejo-provider.mjs +++ b/scripts/providers/forgejo-provider.mjs @@ -1,14 +1,14 @@ -import { ForgeProvider } from "../lib/forge-provider.mjs"; +import { RemoteRepositoryProvider } from "../lib/remote-repository-provider.mjs"; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_PAGE_LIMIT = 50; const MAX_PAGES = 20; -export class ForgejoProvider extends ForgeProvider { +export class ForgejoProvider extends RemoteRepositoryProvider { #token; constructor({ baseUrl, token, timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = globalThis.fetch }) { - super(); + super({ providerId: "forgejo" }); requiredString(baseUrl, "baseUrl"); requiredString(token, "token"); if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) throw new TypeError("timeoutMs must be a positive integer."); @@ -18,6 +18,7 @@ export class ForgejoProvider extends ForgeProvider { if (parsed.username || parsed.password) throw new TypeError("baseUrl must not contain credentials."); if (parsed.search || parsed.hash) throw new TypeError("baseUrl must not contain a query or fragment."); parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + if (parsed.pathname.endsWith("/api/v1")) parsed.pathname = parsed.pathname.slice(0, -"/api/v1".length); this.baseUrl = parsed.toString().replace(/\/$/, ""); this.#token = token; this.timeoutMs = timeoutMs; @@ -35,6 +36,32 @@ export class ForgejoProvider extends ForgeProvider { return normalizeRepository(value); } + async createRepository({ owner, name, private: privateRepository = true, defaultBranch = "main" }) { + requiredString(owner, "owner"); + requiredString(name, "name"); + requiredString(defaultBranch, "defaultBranch"); + if (typeof privateRepository !== "boolean") throw new TypeError("private must be a boolean."); + const value = await this.#request(`/api/v1/orgs/${encodeURIComponent(owner)}/repos`, {}, { + method: "POST", + body: { + name, + private: privateRepository, + default_branch: defaultBranch, + auto_init: false, + }, + }); + return normalizeRepository(value); + } + + async archiveRepository({ owner, repo, archived = true }) { + if (typeof archived !== "boolean") throw new TypeError("archived must be a boolean."); + const value = await this.#request(repoPath(owner, repo), {}, { + method: "PATCH", + body: { archived }, + }); + return normalizeRepository(value); + } + async listChangeRequests({ owner, repo, state = "open" }) { if (!["open", "closed", "all"].includes(state)) throw new TypeError("state must be open, closed, or all."); const values = await this.#paginate(`${repoPath(owner, repo)}/pulls`, { state }); @@ -88,25 +115,30 @@ export class ForgejoProvider extends ForgeProvider { throw new ForgejoResponseError(`${path} exceeded the ${MAX_PAGES}-page safety limit.`); } - async #request(path, query = {}) { + async #request(path, query = {}, { method = "GET", body: requestBody = null } = {}) { const url = new URL(`${this.baseUrl}${path}`); for (const [key, value] of Object.entries(query)) url.searchParams.set(key, String(value)); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.timeoutMs); try { + const headers = { + Accept: "application/json", + Authorization: `token ${this.#token}`, + }; + if (requestBody !== null) headers["Content-Type"] = "application/json"; const response = await this.fetchImpl(url, { - method: "GET", + method, headers: { - Accept: "application/json", - Authorization: `token ${this.#token}`, + ...headers, }, + body: requestBody === null ? undefined : JSON.stringify(requestBody), signal: controller.signal, }); const body = await response.text(); if (!response.ok) { throw new ForgejoHttpError({ status: response.status, - method: "GET", + method, url: url.toString(), body: redact(body, this.#token), }); @@ -115,10 +147,10 @@ export class ForgejoProvider extends ForgeProvider { try { return JSON.parse(body); } catch { - throw new ForgejoResponseError(`GET ${url} returned invalid JSON.`); + throw new ForgejoResponseError(`${method} ${url} returned invalid JSON.`); } } catch (error) { - if (error?.name === "AbortError") throw new ForgejoResponseError(`GET ${url} timed out after ${this.timeoutMs}ms.`); + if (error?.name === "AbortError") throw new ForgejoResponseError(`${method} ${url} timed out after ${this.timeoutMs}ms.`); throw error; } finally { clearTimeout(timeout); diff --git a/scripts/tabellio-forge.mjs b/scripts/tabellio-forge.mjs index 2e87138..4b91aa2 100755 --- a/scripts/tabellio-forge.mjs +++ b/scripts/tabellio-forge.mjs @@ -56,8 +56,12 @@ function parseArgs(args) { } const allowed = new Set(["--base-url", "--owner", "--repo", "--token-file", "--timeout-ms", "--state", "--number", "--commit"]); for (const flag of values.keys()) if (!allowed.has(flag)) throw new Error(`Unsupported option: ${flag}.`); - const baseUrl = values.get("--base-url") ?? process.env.TABELLIO_FORGE_URL; - requiredString(baseUrl, "--base-url or TABELLIO_FORGE_URL"); + const baseUrl = values.get("--base-url") + ?? process.env.TABELLIO_REMOTE_API_URL + ?? process.env.TABELLIO_FORGE_API_URL + ?? process.env.TABELLIO_REMOTE_URL + ?? process.env.TABELLIO_FORGE_URL; + requiredString(baseUrl, "--base-url, TABELLIO_REMOTE_API_URL, TABELLIO_REMOTE_URL, or a legacy Forgejo URL variable"); const needsRepository = command !== "version"; const owner = values.get("--owner") ?? process.env.TABELLIO_FORGE_OWNER; const repo = values.get("--repo") ?? process.env.TABELLIO_FORGE_REPO; @@ -80,7 +84,7 @@ function parseArgs(args) { baseUrl, owner, repo, - tokenFile: values.get("--token-file") ?? process.env.TABELLIO_FORGE_TOKEN_FILE ?? null, + tokenFile: values.get("--token-file") ?? process.env.TABELLIO_REMOTE_CREDENTIAL_FILE ?? process.env.TABELLIO_FORGE_TOKEN_FILE ?? null, timeoutMs, state, number, @@ -89,8 +93,10 @@ function parseArgs(args) { } async function loadToken(tokenFile) { - const token = tokenFile ? (await readFile(tokenFile, "utf8")).trim() : process.env.TABELLIO_FORGE_TOKEN?.trim(); - requiredString(token, "--token-file, TABELLIO_FORGE_TOKEN_FILE, or TABELLIO_FORGE_TOKEN"); + const token = tokenFile + ? (await readFile(tokenFile, "utf8")).trim() + : (process.env.TABELLIO_REMOTE_CREDENTIAL ?? process.env.TABELLIO_FORGE_TOKEN)?.trim(); + requiredString(token, "--token-file, TABELLIO_REMOTE_CREDENTIAL_FILE, TABELLIO_REMOTE_CREDENTIAL, or a legacy Forgejo credential variable"); return token; } diff --git a/scripts/tabellio-review.mjs b/scripts/tabellio-review.mjs index a090873..f87b60d 100755 --- a/scripts/tabellio-review.mjs +++ b/scripts/tabellio-review.mjs @@ -32,6 +32,7 @@ try { ledger, validationLedger, provider, + providerId: options.providerId ?? process.env.TABELLIO_REMOTE_PROVIDER ?? "forgejo", repositoryId, owner: options.owner, repo: options.forgeRepo, @@ -89,7 +90,7 @@ function parseArgs(args) { if (Object.hasOwn(values, key)) throw new Error(`Duplicate option: ${flag}.`); values[key] = value; } - const common = ["repo", "repoId", "owner", "forgeRepo", "number", "actor", "ledgerRef", "validationLedgerRef"]; + const common = ["repo", "repoId", "providerId", "owner", "forgeRepo", "number", "actor", "ledgerRef", "validationLedgerRef"]; const allowed = { sync: [...common, "baseUrl", "tokenFile"], status: common, diff --git a/tabellio.platform.json b/tabellio.platform.json index 8cdd71d..d11557d 100644 --- a/tabellio.platform.json +++ b/tabellio.platform.json @@ -1,10 +1,12 @@ { - "schemaVersion": "tabellio-platform/v0.1", - "canonicalForge": { + "schemaVersion": "tabellio-platform/v0.2", + "remoteRepository": { "provider": "forgejo", - "urlEnv": "TABELLIO_FORGE_URL", - "apiUrlEnv": "TABELLIO_FORGE_API_URL", - "tokenFileEnv": "TABELLIO_FORGE_TOKEN_FILE" + "remoteName": "forgejo", + "publicSurface": "git-only", + "gitUrlEnv": "TABELLIO_REMOTE_URL", + "apiUrlEnv": "TABELLIO_REMOTE_API_URL", + "credentialFileEnv": "TABELLIO_REMOTE_CREDENTIAL_FILE" }, "git": { "stackManager": "git-spice", @@ -25,11 +27,7 @@ "resultRef": "refs/tabellio/validations" }, "reviews": { - "provider": "forgejo", + "provider": "tabellio", "stateRef": "refs/tabellio/reviews" - }, - "transition": { - "codeStorage": "current-origin", - "runtimeRequired": false } } diff --git a/tests/change-request.test.mjs b/tests/change-request.test.mjs new file mode 100644 index 0000000..5f9a250 --- /dev/null +++ b/tests/change-request.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { canonicalChangeRequest, canonicalChangeRequestId } from "../scripts/lib/change-request.mjs"; + +test("canonical change request identity is stable and backend-neutral", () => { + const input = { + repositoryId: "acme/project", + providerId: "forgejo", + value: { + id: "42", + number: 7, + title: "Agent change", + state: "open", + draft: false, + mergeable: true, + source: { branch: "agent/change", commit: "b".repeat(40) }, + target: { branch: "main", commit: "a".repeat(40) }, + webUrl: "https://git.example.test/acme/project/pulls/7", + updatedAt: "2026-07-12T12:00:00.000Z", + }, + }; + const record = canonicalChangeRequest(input); + assert.match(record.id, /^cr_[0-9a-f]{24}$/); + assert.equal(record.id, canonicalChangeRequestId({ repositoryId: "acme/project", providerId: "forgejo", backendId: "42" })); + assert.deepEqual(record.backend, { + provider: "forgejo", + id: "42", + number: 7, + url: "https://git.example.test/acme/project/pulls/7", + }); + assert.notEqual( + record.id, + canonicalChangeRequestId({ repositoryId: "acme/project", providerId: "code-storage", backendId: "42" }), + ); + assert.throws( + () => canonicalChangeRequest({ ...input, value: { ...input.value, draft: "false" } }), + /draft must be a boolean/, + ); + assert.equal( + canonicalChangeRequestId({ repositoryId: " acme/project ", providerId: "forgejo", backendId: " 42 " }), + record.id, + ); +}); diff --git a/tests/context-and-evidence.test.mjs b/tests/context-and-evidence.test.mjs index da9838a..f6b38bd 100644 --- a/tests/context-and-evidence.test.mjs +++ b/tests/context-and-evidence.test.mjs @@ -122,7 +122,7 @@ test("capture CLI hashes Windows local origin paths", async (t) => { "--head", "refs/heads/feature", "--run-id", "run-private-origin", "--out", contextPath, - ]); + ], { TABELLIO_REMOTE_NAME: "origin" }); const packet = JSON.parse(await readFile(contextPath, "utf8")); assert.match(packet.repository.id, /^remote\/[0-9a-f]{16}$/); assert.doesNotMatch(JSON.stringify(packet), /Users|secret-product/); diff --git a/tests/forgejo-provider.test.mjs b/tests/forgejo-provider.test.mjs index 38039f0..e827d74 100644 --- a/tests/forgejo-provider.test.mjs +++ b/tests/forgejo-provider.test.mjs @@ -88,6 +88,9 @@ test("Forgejo provider normalizes repositories, change requests, reviews, commen }); assert.ok(requests.every((request) => request.authorization === "token secret-token")); assert.equal(requests.find((request) => request.path.endsWith("/pulls")).query.limit, "50"); + + const apiUrlProvider = new ForgejoProvider({ baseUrl: `${fixture.baseUrl}/api/v1`, token: "secret-token" }); + assert.equal(await apiUrlProvider.version(), "15.0.3+gitea-1.22.0"); }); test("Forgejo provider rejects credential-bearing URLs and redacts tokens from failures", async (t) => { @@ -111,14 +114,51 @@ test("Forgejo provider rejects credential-bearing URLs and redacts tokens from f ); }); -function repository() { +test("Forgejo remote provider provisions, archives, and exposes standard Git remotes", async (t) => { + const requests = []; + const fixture = await startServer(async (request, response) => { + const body = await requestJson(request); + requests.push({ method: request.method, path: request.url, body }); + if (request.method === "POST" && request.url === "/api/v1/orgs/acme/repos") { + return json(response, repository()); + } + if (request.method === "PATCH" && request.url === "/api/v1/repos/acme/project") { + return json(response, repository({ archived: true })); + } + if (request.method === "GET" && request.url === "/api/v1/repos/acme/project") { + return json(response, repository()); + } + return json(response, { message: "not found" }, 404); + }); + t.after(fixture.close); + const provider = new ForgejoProvider({ baseUrl: fixture.baseUrl, token: "secret-token" }); + assert.equal(provider.providerId, "forgejo"); + + const created = await provider.createRepository({ owner: "acme", name: "project", private: true, defaultBranch: "main" }); + assert.equal(created.fullName, "acme/project"); + const createRequest = requests.find((request) => request.method === "POST"); + assert.deepEqual(createRequest.body, { name: "project", private: true, default_branch: "main", auto_init: false }); + + const remote = await provider.gitRemote({ owner: "acme", repo: "project" }); + assert.deepEqual(remote, { + provider: "forgejo", + repositoryId: "11", + cloneUrl: `${fixture.baseUrl}/acme/project.git`, + defaultBranch: "main", + }); + const archived = await provider.archiveRepository({ owner: "acme", repo: "project" }); + assert.equal(archived.archived, true); + assert.deepEqual(requests.find((request) => request.method === "PATCH").body, { archived: true }); +}); + +function repository({ archived = false } = {}) { return { id: 11, owner: { login: "acme" }, name: "project", full_name: "acme/project", private: true, - archived: false, + archived, default_branch: "main", html_url: "__BASE__/acme/project", clone_url: "__BASE__/acme/project.git", @@ -221,3 +261,9 @@ function json(response, value, status = 200) { response.writeHead(status, { "content-type": "application/json" }); response.end(JSON.stringify(value)); } + +async function requestJson(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + return chunks.length === 0 ? null : JSON.parse(Buffer.concat(chunks).toString("utf8")); +} diff --git a/tests/headless-api.test.mjs b/tests/headless-api.test.mjs new file mode 100644 index 0000000..9b1fd0e --- /dev/null +++ b/tests/headless-api.test.mjs @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { HeadlessApi, InMemoryControlPlaneStore } from "../scripts/lib/headless-api.mjs"; + +const now = new Date("2026-07-12T18:00:00.000Z"); +const token = "Bearer agent-token"; + +test("headless API exposes health without exposing forge UI semantics", async () => { + const { api } = fixture(); + const result = await api.handle({ method: "GET", path: "/v1/health", headers: {} }); + assert.equal(result.status, 200); + assert.deepEqual(result.body, { ok: true, service: "tabellio-control-plane" }); + assert.match(result.headers["x-request-id"], /^req_/); + + const missing = await api.handle({ method: "GET", path: "/", headers: {} }); + assert.equal(missing.status, 404); + assert.equal(missing.body.error.code, "route_not_found"); +}); + +test("repository provisioning is asynchronous and idempotent", async () => { + const { api } = fixture(); + const request = { + method: "POST", + path: "/v1/repositories", + headers: { authorization: token, "idempotency-key": "repo-create-1" }, + body: { owner: "acme", name: "project", private: true, defaultBranch: "main" }, + }; + const first = await api.handle(request); + const replay = await api.handle(structuredClone(request)); + assert.equal(first.status, 202); + assert.equal(replay.status, 202); + assert.equal(replay.body.id, first.body.id); + assert.equal(first.body.type, "repository.provision"); + assert.match(first.body.repositoryId, /^repo_[0-9a-f]{24}$/); + assert.equal(first.headers.location, `/v1/jobs/${first.body.id}`); + + const changed = structuredClone(request); + changed.body.name = "different"; + const conflict = await api.handle(changed); + assert.equal(conflict.status, 409); + assert.equal(conflict.body.error.code, "idempotency_conflict"); +}); + +test("repository reads stay tenant-bound and credential responses are non-cacheable", async () => { + const { api, store } = fixture(); + store.putRepository(repository()); + const found = await api.handle({ + method: "GET", + path: "/v1/repositories/repo_example", + headers: { authorization: token }, + }); + assert.equal(found.status, 200); + assert.equal(found.body.backend.provider, "forgejo"); + + const credential = await api.handle({ + method: "POST", + path: "/v1/repositories/repo_example/credentials", + headers: { authorization: token }, + body: { scopes: ["git:write", "git:read"], ttlSeconds: 300 }, + }); + assert.equal(credential.status, 201); + assert.equal(credential.headers["cache-control"], "no-store"); + assert.equal(credential.body.secret, "one-use-secret"); + assert.deepEqual(credential.body.scopes, ["git:read", "git:write"]); + + const missingAuth = await api.handle({ + method: "GET", + path: "/v1/repositories/repo_example", + headers: {}, + }); + assert.equal(missingAuth.status, 401); +}); + +test("workflow jobs bind validation and merge to exact commits", async () => { + const { api, store } = fixture(); + store.putRepository(repository()); + const common = { authorization: token, "idempotency-key": "job-1" }; + const validation = await api.handle({ + method: "POST", + path: "/v1/validations", + headers: common, + body: { repositoryId: "repo_example", baseCommit: "a".repeat(40), headCommit: "b".repeat(40) }, + }); + assert.equal(validation.status, 202); + assert.equal(validation.body.type, "validation.run"); + assert.equal(validation.body.payload.headCommit, "b".repeat(40)); + + const mergeIntent = await api.handle({ + method: "POST", + path: "/v1/merge-intents", + headers: { ...common, "idempotency-key": "merge-1" }, + body: { repositoryId: "repo_example", changeRequestId: "cr_example", headCommit: "b".repeat(40) }, + }); + assert.equal(mergeIntent.status, 202); + assert.equal(mergeIntent.body.type, "merge.intent.create"); + + const approval = await api.handle({ + method: "POST", + path: "/v1/merge-intents/intent_example/approvals", + headers: { ...common, "idempotency-key": "approval-1" }, + body: { repositoryId: "repo_example", headCommit: "b".repeat(40), expiresAt: "2026-07-12T18:10:00.000Z" }, + }); + assert.equal(approval.status, 202); + assert.equal(approval.body.type, "merge.approval.record"); + assert.equal(approval.body.payload.approvedBy, "codex"); + + const merge = await api.handle({ + method: "POST", + path: "/v1/merge-intents/intent_example/executions", + headers: { ...common, "idempotency-key": "execute-1" }, + body: { repositoryId: "repo_example", headCommit: "b".repeat(40), approvalId: "approval_example" }, + }); + assert.equal(merge.status, 202); + assert.equal(merge.body.type, "merge.execute"); + + const changeRequest = await api.handle({ + method: "POST", + path: "/v1/change-requests", + headers: { ...common, "idempotency-key": "change-1" }, + body: { repositoryId: "repo_example", sourceBranch: "agent/change", targetBranch: "main", title: "Agent change", draft: true }, + }); + assert.equal(changeRequest.status, 202); + assert.equal(changeRequest.body.type, "change-request.create"); + + const status = await api.handle({ method: "GET", path: `/v1/jobs/${merge.body.id}`, headers: { authorization: token } }); + assert.equal(status.status, 200); + assert.equal(status.body.id, merge.body.id); + + const invalid = await api.handle({ + method: "POST", + path: "/v1/validations", + headers: { ...common, "idempotency-key": "invalid-1" }, + body: { repositoryId: "repo_example", baseCommit: "main", headCommit: "HEAD" }, + }); + assert.equal(invalid.status, 400); + assert.match(invalid.body.error.message, /Git object ID/); +}); + +function fixture() { + const store = new InMemoryControlPlaneStore(); + const allowed = new Set([ + "repository:write", + "repository:read", + "credential:issue", + "change-request:write", + "validation:run", + "merge:intent", + "merge:approve", + "merge:execute", + "job:read", + ]); + const authorizer = { + async authorize({ authorization, scope }) { + return authorization === token && allowed.has(scope) + ? { tenantId: "tenant-acme", agentId: "codex" } + : null; + }, + }; + const credentialBroker = { + async issue({ repository, scopes, ttlSeconds, now: issuedAt }) { + return { + schemaVersion: "tabellio-git-credential/v0.1", + repositoryId: repository.id, + username: "tabellio-agent", + secret: "one-use-secret", + cloneUrl: repository.git.cloneUrl, + scopes, + expiresAt: new Date(issuedAt.getTime() + ttlSeconds * 1000).toISOString(), + }; + }, + }; + return { + store, + api: new HeadlessApi({ store, credentialBroker, authorizer, clock: () => new Date(now) }), + }; +} + +function repository() { + return { + schemaVersion: "tabellio-repository/v0.1", + id: "repo_example", + tenantId: "tenant-acme", + slug: "acme/project", + backend: { provider: "forgejo", id: "17" }, + git: { cloneUrl: "https://git.example.test/acme/project.git", defaultBranch: "main" }, + state: "active", + }; +} diff --git a/tests/headless-http.test.mjs b/tests/headless-http.test.mjs new file mode 100644 index 0000000..15e0e1d --- /dev/null +++ b/tests/headless-http.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import test from "node:test"; + +import { HeadlessApi, InMemoryControlPlaneStore } from "../scripts/lib/headless-api.mjs"; +import { createHeadlessHttpHandler } from "../scripts/lib/headless-http.mjs"; + +test("HTTP adapter serves headless API and bounds JSON input", async (t) => { + const store = new InMemoryControlPlaneStore(); + const api = new HeadlessApi({ + store, + authorizer: { + async authorize({ authorization }) { + return authorization === "Bearer test" ? { tenantId: "tenant-acme", agentId: "codex" } : null; + }, + }, + credentialBroker: { async issue() { throw new Error("not used"); } }, + clock: () => new Date("2026-07-12T18:00:00.000Z"), + }); + const server = createServer(createHeadlessHttpHandler(api, { maxBodyBytes: 1_024 })); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))); + const address = server.address(); + const baseUrl = `http://127.0.0.1:${address.port}`; + + const health = await fetch(`${baseUrl}/v1/health`); + assert.equal(health.status, 200); + assert.equal((await health.json()).service, "tabellio-control-plane"); + + const create = await fetch(`${baseUrl}/v1/repositories`, { + method: "POST", + headers: { + authorization: "Bearer test", + "content-type": "application/json", + "idempotency-key": "create-1", + }, + body: JSON.stringify({ owner: "acme", name: "project", private: true, defaultBranch: "main" }), + }); + assert.equal(create.status, 202); + assert.equal((await create.json()).type, "repository.provision"); + + const invalid = await fetch(`${baseUrl}/v1/repositories`, { + method: "POST", + headers: { authorization: "Bearer test", "content-type": "text/plain" }, + body: "not-json", + }); + assert.equal(invalid.status, 415); + assert.equal((await invalid.json()).error.code, "unsupported_media_type"); + + const oversized = await fetch(`${baseUrl}/v1/repositories`, { + method: "POST", + headers: { authorization: "Bearer test", "content-type": "application/json" }, + body: JSON.stringify({ value: "x".repeat(2_000) }), + }); + assert.equal(oversized.status, 413); + assert.equal((await oversized.json()).error.code, "body_too_large"); +}); diff --git a/tests/job-worker.test.mjs b/tests/job-worker.test.mjs new file mode 100644 index 0000000..faf0f7e --- /dev/null +++ b/tests/job-worker.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { InMemoryControlPlaneStore } from "../scripts/lib/headless-api.mjs"; +import { JobWorker } from "../scripts/lib/job-worker.mjs"; + +test("worker leases, heartbeats, and completes exact queued job", async () => { + const queue = new InMemoryControlPlaneStore(); + const job = await enqueue(queue, "success-1"); + let currentTime = new Date("2026-07-12T18:00:00.000Z"); + const worker = new JobWorker({ + queue, + workerId: "worker-1", + clock: () => new Date(currentTime), + handlers: { + "validation.run": async (claimed, { heartbeat }) => { + assert.equal(claimed.id, job.id); + currentTime = new Date("2026-07-12T18:00:10.000Z"); + const renewed = await heartbeat(); + assert.equal(renewed.lease.workerId, "worker-1"); + return { validatedCommit: claimed.payload.headCommit }; + }, + }, + }); + const result = await worker.runOnce(); + assert.equal(result.state, "succeeded"); + assert.equal(result.attempt, 1); + assert.equal(result.lease, null); + assert.equal(result.result.validatedCommit, "b".repeat(40)); +}); + +test("worker retries bounded failures then records terminal failure", async () => { + const queue = new InMemoryControlPlaneStore(); + await enqueue(queue, "failure-1"); + const worker = new JobWorker({ + queue, + workerId: "worker-1", + maxAttempts: 2, + clock: () => new Date("2026-07-12T18:00:00.000Z"), + handlers: { "validation.run": async () => { throw new Error("sandbox failed"); } }, + }); + const first = await worker.runOnce(); + assert.equal(first.state, "queued"); + assert.equal(first.error.retryable, true); + const second = await worker.runOnce(); + assert.equal(second.state, "failed"); + assert.equal(second.attempt, 2); + assert.equal(second.error.retryable, false); + assert.equal(await worker.runOnce(), null); +}); + +test("expired lease can be reclaimed without concurrent ownership", async () => { + const queue = new InMemoryControlPlaneStore(); + const job = await enqueue(queue, "lease-1"); + const first = await queue.claim({ + workerId: "dead-worker", + leaseMs: 1_000, + now: new Date("2026-07-12T18:00:00.000Z"), + types: ["validation.run"], + }); + assert.equal(first.id, job.id); + const early = await queue.claim({ + workerId: "recovery-worker", + leaseMs: 1_000, + now: new Date("2026-07-12T18:00:00.500Z"), + types: ["validation.run"], + }); + assert.equal(early, null); + const recovered = await queue.claim({ + workerId: "recovery-worker", + leaseMs: 1_000, + now: new Date("2026-07-12T18:00:01.001Z"), + types: ["validation.run"], + }); + assert.equal(recovered.id, job.id); + assert.equal(recovered.attempt, 2); + assert.equal(recovered.lease.workerId, "recovery-worker"); +}); + +function enqueue(queue, idempotencyKey) { + return queue.enqueue({ + tenantId: "tenant-acme", + agentId: "codex", + type: "validation.run", + repositoryId: "repo_example", + payload: { headCommit: "b".repeat(40) }, + idempotencyKey, + now: new Date("2026-07-12T18:00:00.000Z"), + }); +} diff --git a/tests/platform-config.test.mjs b/tests/platform-config.test.mjs new file mode 100644 index 0000000..4a86238 --- /dev/null +++ b/tests/platform-config.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { platformRemoteRepository, validatePlatformConfig } from "../scripts/lib/platform-config.mjs"; +import { repositoryIdentity } from "../scripts/lib/repository-identity.mjs"; + +const projectRoot = new URL("../", import.meta.url).pathname; + +test("v0.2 platform exposes a headless provider-neutral remote contract", async () => { + const config = JSON.parse(await readFile(`${projectRoot}/tabellio.platform.json`, "utf8")); + assert.equal(validatePlatformConfig(config), config); + assert.deepEqual(platformRemoteRepository(config), { + provider: "forgejo", + remoteName: "forgejo", + publicSurface: "git-only", + gitUrlEnv: "TABELLIO_REMOTE_URL", + apiUrlEnv: "TABELLIO_REMOTE_API_URL", + credentialFileEnv: "TABELLIO_REMOTE_CREDENTIAL_FILE", + }); + assert.equal(config.reviews.provider, "tabellio"); + assert.equal("transition" in config, false); + assert.equal("canonicalForge" in config, false); +}); + +test("v0.1 platform remains readable during migration", () => { + const legacy = { + schemaVersion: "tabellio-platform/v0.1", + canonicalForge: { + provider: "forgejo", + urlEnv: "TABELLIO_FORGE_URL", + apiUrlEnv: "TABELLIO_FORGE_API_URL", + tokenFileEnv: "TABELLIO_FORGE_TOKEN_FILE", + }, + git: sharedGit(), + ledger: { provider: "entire", checkpointRef: "refs/heads/entire/checkpoints/v1" }, + validation: { runner: "tabellio-validate", manifest: "tabellio.validation.json", resultRef: "refs/tabellio/validations" }, + reviews: { provider: "forgejo", stateRef: "refs/tabellio/reviews" }, + transition: { codeStorage: "current-origin", runtimeRequired: false }, + }; + assert.equal(validatePlatformConfig(legacy), legacy); + assert.equal(platformRemoteRepository(legacy).publicSurface, "forge-compatibility"); +}); + +test("repository identity uses canonical remote instead of GitHub-style origin", async () => { + const requested = []; + const store = { + repoPath: "/tmp/repository", + async gitConfig(key) { + requested.push(key); + return key === "remote.forgejo.url" ? "ssh://git@git.example.test/acme/project.git" : null; + }, + }; + assert.equal(await repositoryIdentity(store), "git.example.test/acme/project"); + assert.deepEqual(requested, ["remote.forgejo.url"]); + assert.equal(await repositoryIdentity(store, "stable/repository"), "stable/repository"); +}); + +function sharedGit() { + return { + stackManager: "git-spice", + codeRef: "refs/heads/main", + controlRefs: [ + "refs/tabellio/reviews", + "refs/tabellio/validations", + "refs/heads/entire/checkpoints/v1", + ], + }; +} diff --git a/tests/review-cycle.test.mjs b/tests/review-cycle.test.mjs index b734c7f..c1958fb 100644 --- a/tests/review-cycle.test.mjs +++ b/tests/review-cycle.test.mjs @@ -143,6 +143,16 @@ test("review cycle persists forge and agent feedback through triage and checkpoi assert.throws(() => validateReviewCycle(tampered), /digest does not match|status does not match/); const history = await runGit({ args: ["rev-list", "--count", "refs/tabellio/reviews"], cwd: fixture.seed }); assert.equal(Number(history.stdout.trim()), 9); + const reader = new ReviewCycleManager({ + store, + ledger, + provider: null, + providerId: "forgejo", + repositoryId: "example/repository", + owner: "acme", + repo: "project", + }); + assert.equal((await reader.status({ number: 7 })).value.id, result.cycle.id); const worktree = await runGit({ args: ["status", "--porcelain=v1"], cwd: fixture.seed }); assert.equal(worktree.stdout, ""); }); @@ -178,6 +188,7 @@ test("review readiness consumes only the latest validation for the exact PR head await validationLedger.write(`commits/${fixture.featureCommit}/${failed.runId}.json`, failed, { expectedVersion: current.version }); result = await manager.sync({ number: 7, actor: "sync-agent", now: new Date("2026-07-10T20:04:00.000Z") }); assert.equal(result.cycle.status, "blocked"); + assert.equal(result.cycle.feedback.find((item) => item.id === "check:validation:validation-fail").source, "tabellio"); }); test("agent review contract bounds finding count and text size", () => { @@ -203,6 +214,7 @@ function fakeProvider(fixture) { let draft = false; let mergeable = true; return { + providerId: "forgejo", setChecks(value) { checkState = value; }, setHead(value) { headCommit = value; }, setDraft(value) { draft = value; }, @@ -292,6 +304,7 @@ function fakeProvider(fixture) { function emptyProvider(fixture) { return { + providerId: "forgejo", async changeRequest() { return { id: "21",