From 23d5781ef702fd46b913ead011d6a861952f9ac9 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Tue, 14 Jul 2026 21:31:34 -0700 Subject: [PATCH 1/6] Define GitHub code storage boundary --- CHANGELOG.md | 2 + README.md | 17 +++-- docs/getting-started.md | 28 +++---- docs/github-code-storage-boundary.md | 29 +++++++ docs/operations-hardening.md | 19 +++-- docs/tooling-stack.md | 24 +++--- schemas/platform.schema.json | 57 ++++++++------ scripts/check-tabellio-platform.mjs | 11 ++- scripts/lib/approval-validation.mjs | 64 ++++++++++++++++ scripts/lib/control-ref-transport.mjs | 34 ++++----- scripts/lib/platform-config.mjs | 106 +++++++++++++++----------- scripts/lib/stack-operation.mjs | 26 ++----- scripts/tabellio-control-ref.mjs | 5 +- tabellio.platform.json | 30 ++++---- tests/control-ref-transport.test.mjs | 87 +++++++++++++-------- tests/platform-config.test.mjs | 40 ++++++++++ 16 files changed, 379 insertions(+), 200 deletions(-) create mode 100644 docs/github-code-storage-boundary.md create mode 100644 scripts/lib/approval-validation.mjs create mode 100644 tests/platform-config.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index bd9d33b..0ca2a42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,8 @@ All notable changes to Tabellio are recorded here. ### Changed - Replaced Graphite as the planned stacked-review integration with host-agnostic git-spice. +- Made GitHub `origin` the canonical code store and thin pull-request shell while keeping agent control state external. +- Rejected publication or fetching of review, validation, and Entire control refs through the code-storage remote. ## 0.1.0 - 2026-07-08 diff --git a/README.md b/README.md index 3e04461..1c6f99f 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/) +[![GitHub](https://img.shields.io/badge/GitHub-code%20storage-181717?logo=github)](https://github.com/) [![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. GitHub stores code and provides a thin pull-request shell. Tabellio keeps agent transcripts, review state, validation results, and control refs outside that public code-storage boundary. ## What It Adds @@ -60,31 +60,31 @@ 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 | +| Code storage | [GitHub](https://github.com/) | Stores code refs and tags; provides a thin pull-request shell | | 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. +`origin` is the canonical GitHub code remote. Entire is the required checkpoint ledger; git-spice manages stacks; Tabellio owns validation and durable review state. Private control refs are external state and are rejected when their destination is `origin`. ## Core Files | Path | Purpose | | --- | --- | -| `tabellio.platform.json` | Canonical forge, stack, ledger, validation, review, and control-ref contract | +| `tabellio.platform.json` | Code-storage boundary, 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` | Legacy migration-only Forgejo adapter; not a canonical service | | `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/` | Legacy disposable adapter fixture scheduled for removal | | `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 +102,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. +Keep `origin` limited to ordinary code branches and tags. Configure a separate authenticated Git remote before publishing control refs. The transport refuses to target `origin`. Validate the bundled fixture: @@ -193,6 +193,7 @@ The external-action checker fails when an action is marked `attempted: true` wit - [Getting started](docs/getting-started.md) - [Agentic tooling stack](docs/tooling-stack.md) +- [GitHub code-storage boundary](docs/github-code-storage-boundary.md) - [Agent run lifecycle](docs/agent-run-lifecycle.md) - [Approved stack operations](docs/stack-operations.md) - [Durable review and fix loop](docs/review-loop.md) diff --git a/docs/getting-started.md b/docs/getting-started.md index 60b407b..a52dfe1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -7,10 +7,10 @@ Tabellio captures provider-neutral Git context and can attach a machine-readable - Git repository - Node.js 20 or later - Git 2.38 or later with `merge-tree --write-tree` -- git-spice 0.18 or later for optional stack snapshots; Forgejo support requires 0.30 or later +- git-spice 0.18 or later for optional stack snapshots - Entire CLI 0.7.7 or later for mandatory checkpoint metadata export -Forgejo 15 or later is the canonical review host. Any standard Git remote may remain a code mirror. No hosted workflow runtime is required. +GitHub is the canonical code store through the `origin` remote. Tabellio does not use it for private agent transcripts, validation results, review ledgers, or control refs. No hosted workflow runtime is required. Enable Entire for Codex before creating agent commits: @@ -35,9 +35,9 @@ node scripts/check-tabellio-stack.mjs --stack tabellio-stack.json The snapshot adapter uses documented JSON output and disables change-request status and comment queries. Approved write operations use the separate flow in [Approved stack operations](stack-operations.md). -## Run The Forgejo Lab +## Legacy Forgejo Adapter Lab -Docker users can prove the forge adapter without GitHub: +Docker users can still exercise the migration-only Forgejo adapter: ```bash node scripts/dev/forgejo-lab.mjs up @@ -50,17 +50,14 @@ node scripts/tabellio-forge.mjs pulls \ --token-file .tabellio/forgejo/credentials/admin-token ``` -The lab is disposable and localhost-only. Generated secrets and Forgejo data remain below ignored `.tabellio/forgejo/`. Stop the container with `node scripts/dev/forgejo-lab.mjs down`. +The lab is disposable, localhost-only, and not part of the target platform. Generated secrets and Forgejo data remain below ignored `.tabellio/forgejo/`. Stop the container with `node scripts/dev/forgejo-lab.mjs down`. ## 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: GitHub code storage, git-spice stacks, external Entire checkpoints, external validation, and external review state. ```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 ``` 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`. @@ -73,7 +70,7 @@ node scripts/tabellio-validate.mjs run \ --manifest tabellio.validation.json ``` -The worker can be a local agent, a scheduled service, or a Forgejo runner. Tabellio's contract stays identical. +The worker can be a local agent or an operator-managed scheduled service. Tabellio's contract stays identical. ## Share Control State @@ -82,13 +79,12 @@ Review cycles, validation results, and Entire checkpoints use standard Git refs. ```bash node scripts/tabellio-control-ref.mjs plan \ --operation publish \ - --remote forgejo \ + --remote "$TABELLIO_CONTROL_REMOTE" \ --repo-id example/repository \ - --token-file "$TABELLIO_FORGE_TOKEN_FILE" \ --out /tmp/control-ref-intent.json ``` -Create a matching `tabellio-control-ref-approval/v0.1` document after reviewing the exact local and remote OIDs, then execute it once with `tabellio-control-ref.mjs execute`. Multi-ref publication is atomic. Non-fast-forward publication, divergence, changed refs, expired approvals, and reused approvals fail closed. +`TABELLIO_CONTROL_REMOTE` must name a separately configured external Git destination. It cannot be `origin`. Create a matching `tabellio-control-ref-approval/v0.1` document after reviewing the exact local and remote OIDs, then execute it once with `tabellio-control-ref.mjs execute`. Multi-ref publication is atomic. Non-fast-forward publication, divergence, changed refs, expired approvals, and reused approvals fail closed. ## Local Validation @@ -145,8 +141,8 @@ Keep the first PR small: 1. Add `tabellio.platform.json` and `tabellio.validation.json`. 2. Enable Entire and initialize git-spice. -3. Open a test change request on Forgejo. +3. Push a code branch to `origin` and open a thin pull request. 4. Run exact-head validation and sync the durable review cycle. -5. Publish canonical control refs with an approved one-use operation. +5. Publish control refs to the configured external destination with an approved one-use operation. -Before production deployment, apply the concurrency, worker isolation, backup, mirror, and monitoring guidance in [Operations hardening](operations-hardening.md). +Before production deployment, apply the concurrency, worker isolation, backup, and monitoring guidance in [Operations hardening](operations-hardening.md). diff --git a/docs/github-code-storage-boundary.md b/docs/github-code-storage-boundary.md new file mode 100644 index 0000000..aa18a34 --- /dev/null +++ b/docs/github-code-storage-boundary.md @@ -0,0 +1,29 @@ +# GitHub Code-Storage Boundary + +GitHub has one narrow job in the Tabellio platform: store code and expose a thin pull-request shell. It is not the agent workflow database. + +## Data Placement + +| Data | Destination | Reason | +| --- | --- | --- | +| `refs/heads/*` code branches | GitHub `origin` | Shared source history and pull-request heads | +| `refs/tags/*` release tags | GitHub `origin` | Shared code release markers | +| Pull-request title, description, checks summary, and review decision | GitHub | Minimum human accountability surface | +| Entire transcript and checkpoint state | External control state | Private agent context stays outside code storage | +| `refs/tabellio/reviews` | External control state | Full machine review ledger may contain internal context | +| `refs/tabellio/validations` | External control state | Full validation evidence and logs remain independently governed | +| `refs/heads/entire/checkpoints/v1` | External control state | Agent-session checkpoints do not become ordinary code branches | + +## Enforced Contract + +`tabellio.platform.json` declares GitHub `origin` as code storage, sets control state to external, and sets `publishControlRefsToCodeStorage` to `false`. The JSON Schema and runtime validator reject drift from those values. + +The control-ref transport also rejects `origin` before reading or writing remote state. Callers must name a separate configured remote for review, validation, and Entire refs. This makes a mistaken private-state push fail closed instead of relying on operator memory. + +## Pull-Request Boundary + +The pull request remains useful but thin. It carries the code diff, a concise change explanation, required check summaries, and the final review decision. Detailed agent transcripts, internal reasoning, full validation logs, and durable review events stay external. A reference or digest can bind the thin pull request to external evidence without copying that evidence into GitHub. + +## Migration State + +The Forgejo provider and localhost lab remain temporarily as legacy migration fixtures. They are not canonical services and must receive no new production state. A later cleanup PR removes the provider, lab, commands, docs, and package metadata after the external control-state migration is complete. diff --git a/docs/operations-hardening.md b/docs/operations-hardening.md index b8fdbea..13bbb6a 100644 --- a/docs/operations-hardening.md +++ b/docs/operations-hardening.md @@ -17,28 +17,27 @@ Tabellio keeps code and control state in standard Git objects, but production sa - Agent reviews allow at most 1,000 findings. Review cycles bound feedback, fixes, check statuses, titles, bodies, summaries, event details, and retained event history. - Remote control-ref reads and atomic pushes use a 15-minute timeout. Local atomic ref updates use a 30-second timeout. -## Canonical And Mirror Repositories +## Canonical Code Repository -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: +GitHub `origin` is the canonical code repository and merge authority: -1. Validate the exact Forgejo change-request head. +1. Validate the exact pull-request head. 2. Merge through the approved git-spice operation. -3. Fetch canonical `main` from Forgejo. -4. Fast-forward the mirror to that exact object ID. -5. Verify local `main`, `forgejo/main`, and every mirror `main` resolve to the same object ID. +3. Fetch canonical `main` from `origin`. +4. Verify local `main` and `origin/main` resolve to the same object ID. -Never merge the same change independently on two hosts. Independent squash or rebase merges create different histories even when file content matches. +Do not maintain a second merge authority. Independent squash or rebase merges create different histories even when file content matches. ## Control-State Publication Review cycles, validation results, and Entire checkpoints are published together with `git push --atomic`, explicit force-with-lease expectations, and a one-use approval. Publication rejects non-fast-forward updates, divergence, changed local or remote object IDs, expired approvals, and reused approval IDs. -Automatic Entire session pushes remain disabled. The approved control-ref transport publishes `refs/heads/entire/checkpoints/v1` with the review and validation refs, keeping credentials and remote writes inside the same policy boundary. +Automatic Entire session pushes to `origin` remain disabled. The approved control-ref transport publishes `refs/heads/entire/checkpoints/v1` with the review and validation refs only to a separately configured external destination. Planning and execution reject `origin`. ## Production Checklist -- Run Forgejo on durable storage with tested backups and restore drills. +- Back up the selected external control-state service and test restore drills. - Isolate validation workers for untrusted code; detached worktrees are not sandboxes. -- Scope forge and Git credentials per repository and keep them out of URLs, arguments, and logs. +- Scope code-storage and external Git credentials per repository and keep them out of URLs, arguments, and logs. - Monitor failed receipts, stale cross-host locks, validation duration, queue depth, and ref divergence. - Reconcile and republish control refs before retrying any divergence failure. diff --git a/docs/tooling-stack.md b/docs/tooling-stack.md index 4631c91..3c130bc 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 the minimum Git substrate agents need. It uses standard Git rather than requiring a proprietary code-storage API. GitHub is the code store and thin pull-request shell, not the workflow control plane. 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. @@ -13,8 +13,8 @@ The main idea: agentic Git should be built around more than a patch. It should p | Git substrate | Standard Git CLI, bare repositories, and worktrees | Stores repositories, branches, commits, patches, and agent-created code state | Implemented by `NativeGitStore` | | 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 | +| Stacked review | [git-spice](https://abhinav.github.io/git-spice/) | Keeps dependent changes small, ordered, reviewable, and resubmittable | Read through `GitSpiceStackManager` into `tabellio-stack/v0.1` | +| Code storage | GitHub `origin` | Stores ordinary code branches and tags; provides a thin pull-request shell | No private control refs or agent transcripts | | 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 + -> thin code pull request -> approved control-ref publication -> explicit compare-and-swap merge or release gate ``` @@ -76,12 +76,12 @@ Included: - 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 +- explicit GitHub code-storage and external control-state contract - approval-gated fast-forward transport for review, validation, and Entire refs Not included yet: -- production Forgejo deployment +- external control-state service selection and deployment - transcript indexing or storage outside Entire - forge comment publication, general review-thread mutation, and signed approvals - Codex review automation @@ -112,10 +112,16 @@ Tabellio calls `entire checkpoint explain --json` and stores normalized metadata Entire remains the source of truth for transcripts, rewind, and resume. Tabellio stores checkpoint IDs, commit bindings, summaries, token totals, and integrity digests for orchestration and review. -This repository disables automatic checkpoint pushes until a private Forgejo destination exists. Commit trailers remain shareable; transcript-bearing checkpoint data stays local during migration. +This repository disables automatic checkpoint pushes to `origin`. Commit trailers remain shareable; transcript-bearing checkpoint data stays local until an external control-state destination is configured. -## Forgejo Boundary +## GitHub Code-Storage Boundary + +GitHub receives ordinary code branches, tags, and the minimum pull-request metadata needed for human accountability. `refs/tabellio/reviews`, `refs/tabellio/validations`, and `refs/heads/entire/checkpoints/v1` remain external. The control-ref transport rejects `origin` even when a caller supplies it explicitly. + +The boundary is contractual, not only documentary: `tabellio.platform.json`, its JSON Schema, runtime validation, and transport tests all fail closed on provider or publication-policy drift. See [GitHub code-storage boundary](github-code-storage-boundary.md). + +## Legacy Forgejo Boundary `ForgejoProvider` reads the documented Forgejo v1 API. It normalizes repository identity, change requests, reviews, inline comments, issue comments, and commit status without exposing the access token. The CLI accepts tokens only through a file or environment variable; URLs containing credentials are rejected. -The disposable lab pins Forgejo 15.0.3, binds HTTP and SSH to localhost, disables registration and bundled job execution, and stores all state below ignored `.tabellio/forgejo/`. The lab proves API compatibility; it is not production infrastructure. git-spice performs approval-gated change-request writes. Standard Git performs approval-gated control-ref transport. +The adapter and disposable lab remain only as migration fixtures until their dedicated removal PR. They are not canonical services and receive no new production state. diff --git a/schemas/platform.schema.json b/schemas/platform.schema.json index 0c03353..3638386 100644 --- a/schemas/platform.schema.json +++ b/schemas/platform.schema.json @@ -1,33 +1,46 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:tabellio:schema:platform:v0.1", + "$id": "urn:tabellio:schema:platform:v0.3", "title": "Tabellio Platform Configuration", "type": "object", "additionalProperties": false, - "required": ["schemaVersion", "canonicalForge", "git", "ledger", "validation", "reviews", "transition"], + "required": ["schemaVersion", "codeStorage", "workflow", "ledger", "validation", "reviews"], "properties": { - "schemaVersion": { "const": "tabellio-platform/v0.1" }, - "canonicalForge": { + "schemaVersion": { "const": "tabellio-platform/v0.3" }, + "codeStorage": { "type": "object", "additionalProperties": false, - "required": ["provider", "urlEnv", "apiUrlEnv", "tokenFileEnv"], + "required": ["provider", "remoteName", "publicSurface", "codeRef", "allowedRefPrefixes"], "properties": { - "provider": { "const": "forgejo" }, - "urlEnv": { "const": "TABELLIO_FORGE_URL" }, - "apiUrlEnv": { "const": "TABELLIO_FORGE_API_URL" }, - "tokenFileEnv": { "const": "TABELLIO_FORGE_TOKEN_FILE" } + "provider": { "const": "github" }, + "remoteName": { "const": "origin" }, + "publicSurface": { "const": "code-and-thin-pr" }, + "codeRef": { "const": "refs/heads/main" }, + "allowedRefPrefixes": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { "enum": ["refs/heads/", "refs/tags/"] }, + "allOf": [ + { "contains": { "const": "refs/heads/" } }, + { "contains": { "const": "refs/tags/" } } + ] + } } }, - "git": { + "workflow": { "type": "object", "additionalProperties": false, - "required": ["stackManager", "codeRef", "controlRefs"], + "required": ["stackManager", "controlState", "publishControlRefsToCodeStorage", "controlRefs"], "properties": { "stackManager": { "const": "git-spice" }, - "codeRef": { "const": "refs/heads/main" }, + "controlState": { "const": "external" }, + "publishControlRefsToCodeStorage": { "const": false }, "controlRefs": { "type": "array", "minItems": 3, + "maxItems": 3, "uniqueItems": true, "items": { "enum": ["refs/tabellio/reviews", "refs/tabellio/validations", "refs/heads/entire/checkpoints/v1"] }, "allOf": [ @@ -41,39 +54,33 @@ "ledger": { "type": "object", "additionalProperties": false, - "required": ["provider", "checkpointRef"], + "required": ["provider", "storage", "checkpointRef"], "properties": { "provider": { "const": "entire" }, + "storage": { "const": "external" }, "checkpointRef": { "const": "refs/heads/entire/checkpoints/v1" } } }, "validation": { "type": "object", "additionalProperties": false, - "required": ["runner", "manifest", "resultRef"], + "required": ["runner", "manifest", "storage", "resultRef"], "properties": { "runner": { "const": "tabellio-validate" }, "manifest": { "type": "string", "minLength": 1 }, + "storage": { "const": "external" }, "resultRef": { "const": "refs/tabellio/validations" } } }, "reviews": { "type": "object", "additionalProperties": false, - "required": ["provider", "stateRef"], + "required": ["provider", "storage", "stateRef"], "properties": { - "provider": { "const": "forgejo" }, + "provider": { "const": "tabellio" }, + "storage": { "const": "external" }, "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/scripts/check-tabellio-platform.mjs b/scripts/check-tabellio-platform.mjs index 49d87af..a58670f 100644 --- a/scripts/check-tabellio-platform.mjs +++ b/scripts/check-tabellio-platform.mjs @@ -10,7 +10,16 @@ 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)); + console.log(JSON.stringify({ + ok: true, + status: "platform_ready", + path, + codeStorage: config.codeStorage.provider, + codeRemote: config.codeStorage.remoteName, + publicSurface: config.codeStorage.publicSurface, + controlState: config.workflow.controlState, + publishesControlRefsToCodeStorage: config.workflow.publishControlRefsToCodeStorage, + }, 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/approval-validation.mjs b/scripts/lib/approval-validation.mjs new file mode 100644 index 0000000..bd46319 --- /dev/null +++ b/scripts/lib/approval-validation.mjs @@ -0,0 +1,64 @@ +const APPROVAL_KEYS = ["schemaVersion", "id", "intentDigest", "approved", "approvedBy", "approvedAt", "expiresAt", "reason"]; + +export function validateOperationApproval(value, intent, { schemaVersion, validateIntent, now = new Date() }) { + validateIntent(intent); + record(value, "approval"); + exactKeys(value, APPROVAL_KEYS, "approval"); + equals(value.schemaVersion, schemaVersion, "approval.schemaVersion"); + validateIdentity(value, intent); + const window = approvalWindow(value); + validateSequence(window, intent); + validateActiveWindow(window, now); + return value; +} + +function validateIdentity(value, intent) { + if (typeof value.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value.id)) throw new Error("approval.id contains unsupported characters."); + equals(value.intentDigest, intent.integrity.digest, "approval.intentDigest"); + equals(value.approved, true, "approval.approved"); + requiredString(value.approvedBy, "approval.approvedBy"); + requiredString(value.reason, "approval.reason"); +} + +function approvalWindow(value) { + date(value.approvedAt, "approval.approvedAt"); + date(value.expiresAt, "approval.expiresAt"); + return { approvedAt: Date.parse(value.approvedAt), expiresAt: Date.parse(value.expiresAt) }; +} + +function validateSequence({ approvedAt, expiresAt }, intent) { + if (approvedAt < Date.parse(intent.createdAt)) throw new Error("approval.approvedAt must not precede the intent."); + if (expiresAt <= approvedAt) throw new Error("approval.expiresAt must be later than approval.approvedAt."); +} + +function validateActiveWindow({ approvedAt, expiresAt }, now) { + if (now.getTime() < approvedAt) throw new Error("approval is not active yet."); + if (now.getTime() >= expiresAt) throw new Error("approval has expired."); +} + +function exactKeys(value, expected, path) { + const actual = Object.keys(value); + const wanted = new Set(expected); + if (actual.length !== wanted.size) throw new Error(`${path} must contain exactly: ${expected.join(", ")}.`); + if (actual.some((key) => !wanted.has(key))) throw new Error(`${path} must contain exactly: ${expected.join(", ")}.`); +} + +function record(value, path) { + if (Object.prototype.toString.call(value) !== "[object Object]") throw new Error(`${path} must be an object.`); +} + +function requiredString(value, path) { + if (typeof value !== "string") throw new Error(`${path} must be a non-empty string.`); + if (!value.trim()) throw new Error(`${path} must be a non-empty string.`); +} + +function date(value, path) { + requiredString(value, path); + const pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + if (!pattern.test(value)) throw new Error(`${path} must be an ISO date-time string.`); + if (Number.isNaN(Date.parse(value))) throw new Error(`${path} must be an ISO date-time string.`); +} + +function equals(value, expected, path) { + if (value !== expected) throw new Error(`${path} must be ${JSON.stringify(expected)}.`); +} diff --git a/scripts/lib/control-ref-transport.mjs b/scripts/lib/control-ref-transport.mjs index c6bfccb..508ad0a 100644 --- a/scripts/lib/control-ref-transport.mjs +++ b/scripts/lib/control-ref-transport.mjs @@ -2,6 +2,7 @@ import { open, readFile, realpath, rename, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { spawn } from "node:child_process"; +import { validateOperationApproval } from "./approval-validation.mjs"; import { runGit } from "./git-process.mjs"; import { withOperationLock } from "./operation-lock.mjs"; import { digestObject } from "./stack-operation.mjs"; @@ -9,6 +10,7 @@ import { digestObject } from "./stack-operation.mjs"; export const CONTROL_REF_INTENT_VERSION = "tabellio-control-ref-operation/v0.1"; export const CONTROL_REF_APPROVAL_VERSION = "tabellio-control-ref-approval/v0.1"; const DEFAULT_REMOTE_TIMEOUT_MS = 15 * 60 * 1000; +const CODE_STORAGE_REMOTE = "origin"; export const CONTROL_REFS = [ "refs/tabellio/reviews", "refs/tabellio/validations", @@ -38,7 +40,7 @@ export function validateControlRefIntent(value) { object(value.repository, "intent.repository"); exact(value.repository, ["id"], "intent.repository"); string(value.repository.id, "intent.repository.id"); - remoteName(value.remote); + controlRemoteName(value.remote); if (!Array.isArray(value.refs) || value.refs.length === 0) throw new Error("intent.refs must be a non-empty array."); const names = new Set(); for (const [index, entry] of value.refs.entries()) { @@ -63,28 +65,15 @@ export function validateControlRefIntent(value) { } export function validateControlRefApproval(value, intent, { now = new Date() } = {}) { - validateControlRefIntent(intent); - object(value, "approval"); - exact(value, ["schemaVersion", "id", "intentDigest", "approved", "approvedBy", "approvedAt", "expiresAt", "reason"], "approval"); - equals(value.schemaVersion, CONTROL_REF_APPROVAL_VERSION, "approval.schemaVersion"); - if (typeof value.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value.id)) throw new Error("approval.id contains unsupported characters."); - equals(value.intentDigest, intent.integrity.digest, "approval.intentDigest"); - equals(value.approved, true, "approval.approved"); - string(value.approvedBy, "approval.approvedBy"); - string(value.reason, "approval.reason"); - date(value.approvedAt, "approval.approvedAt"); - date(value.expiresAt, "approval.expiresAt"); - const approvedAt = Date.parse(value.approvedAt); - const expiresAt = Date.parse(value.expiresAt); - if (approvedAt < Date.parse(intent.createdAt)) throw new Error("approval.approvedAt must not precede the intent."); - if (expiresAt <= approvedAt) throw new Error("approval.expiresAt must be later than approval.approvedAt."); - if (now.getTime() < approvedAt) throw new Error("approval is not active yet."); - if (now.getTime() >= expiresAt) throw new Error("approval has expired."); - return value; + return validateOperationApproval(value, intent, { + schemaVersion: CONTROL_REF_APPROVAL_VERSION, + validateIntent: validateControlRefIntent, + now, + }); } export async function snapshotControlRefs({ repoPath, remote, refs, env = {} }) { - remoteName(remote); + controlRemoteName(remote); const unique = [...new Set(refs)]; if (unique.length === 0 || unique.some((ref) => !CONTROL_REFS.includes(ref))) throw new Error("refs must be a non-empty subset of canonical control refs."); return Promise.all(unique.map(async (name) => ({ @@ -296,6 +285,11 @@ function remoteName(value) { if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) throw new Error("intent.remote must be a safe Git remote name."); } +function controlRemoteName(value) { + remoteName(value); + if (value === CODE_STORAGE_REMOTE) throw new Error(`Control refs must not target code-storage remote ${CODE_STORAGE_REMOTE}.`); +} + function nullableOid(value, path) { if (value !== null && (typeof value !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value))) throw new Error(`${path} must be null or a Git object ID.`); } diff --git a/scripts/lib/platform-config.mjs b/scripts/lib/platform-config.mjs index d349878..786102c 100644 --- a/scripts/lib/platform-config.mjs +++ b/scripts/lib/platform-config.mjs @@ -1,61 +1,75 @@ +const CONTROL_REFS = [ + "refs/tabellio/reviews", + "refs/tabellio/validations", + "refs/heads/entire/checkpoints/v1", +]; + +const CODE_REF_PREFIXES = ["refs/heads/", "refs/tags/"]; + export function validatePlatformConfig(value) { - object(value, "platform"); - 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"); - object(value.git, "platform.git"); - exact(value.git, ["stackManager", "codeRef", "controlRefs"], "platform.git"); - equals(value.git.stackManager, "git-spice", "platform.git.stackManager"); - equals(value.git.codeRef, "refs/heads/main", "platform.git.codeRef"); - array(value.git.controlRefs, "platform.git.controlRefs"); - const requiredRefs = ["refs/tabellio/reviews", "refs/tabellio/validations", "refs/heads/entire/checkpoints/v1"]; - if (new Set(value.git.controlRefs).size !== value.git.controlRefs.length || requiredRefs.some((ref) => !value.git.controlRefs.includes(ref))) { - throw new Error(`platform.git.controlRefs must contain each canonical control ref exactly once: ${requiredRefs.join(", ")}.`); + exactObject(value, { schemaVersion: "tabellio-platform/v0.3" }, "platform", ["codeStorage", "workflow", "ledger", "validation", "reviews"]); + + exactObject(value.codeStorage, { + provider: "github", + remoteName: "origin", + publicSurface: "code-and-thin-pr", + codeRef: "refs/heads/main", + }, "platform.codeStorage", ["allowedRefPrefixes"]); + exactSet(value.codeStorage.allowedRefPrefixes, CODE_REF_PREFIXES, "platform.codeStorage.allowedRefPrefixes"); + + exactObject(value.workflow, { + stackManager: "git-spice", + controlState: "external", + publishControlRefsToCodeStorage: false, + }, "platform.workflow", ["controlRefs"]); + exactSet(value.workflow.controlRefs, CONTROL_REFS, "platform.workflow.controlRefs"); + + exactObject(value.ledger, { + provider: "entire", + storage: "external", + checkpointRef: "refs/heads/entire/checkpoints/v1", + }, "platform.ledger"); + exactObject(value.validation, { + runner: "tabellio-validate", + storage: "external", + resultRef: "refs/tabellio/validations", + }, "platform.validation", ["manifest"]); + if (typeof value.validation.manifest !== "string" || value.validation.manifest.trim() === "") { + throw new Error("platform.validation.manifest must be a non-empty string."); } - if (value.git.controlRefs.some((ref) => !requiredRefs.includes(ref))) throw new Error("platform.git.controlRefs contains an unsupported ref."); - exactObject(value.ledger, { provider: "entire", checkpointRef: "refs/heads/entire/checkpoints/v1" }, "platform.ledger"); - object(value.validation, "platform.validation"); - exact(value.validation, ["runner", "manifest", "resultRef"], "platform.validation"); - 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"); + exactObject(value.reviews, { + provider: "tabellio", + storage: "external", + stateRef: "refs/tabellio/reviews", + }, "platform.reviews"); return value; } -function exactObject(value, expected, path) { - object(value, path); - exact(value, Object.keys(expected), path); - for (const [key, wanted] of Object.entries(expected)) equals(value[key], wanted, `${path}.${key}`); +function exactObject(value, expected, path, additionalKeys = []) { + assertObject(value, path); + const wantedKeys = [...Object.keys(expected), ...additionalKeys].sort(); + const actualKeys = Object.keys(value).sort(); + assertKeys(actualKeys, wantedKeys, path); + assertValues(value, expected, path); } -function exact(value, keys, path) { - const actual = Object.keys(value).sort(); - const expected = [...keys].sort(); - if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`${path} must contain exactly: ${expected.join(", ")}.`); -} - -function object(value, path) { +function assertObject(value, path) { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object.`); } -function array(value, path) { - if (!Array.isArray(value)) throw new Error(`${path} must be an array.`); +function assertKeys(actual, wanted, path) { + if (JSON.stringify(actual) !== JSON.stringify(wanted)) throw new Error(`${path} must contain exactly: ${wanted.join(", ")}.`); } -function string(value, path) { - if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`); +function assertValues(value, expected, path) { + for (const [key, wanted] of Object.entries(expected)) { + if (value[key] !== wanted) throw new Error(`${path}.${key} must be ${JSON.stringify(wanted)}.`); + } } -function equals(value, expected, path) { - if (value !== expected) throw new Error(`${path} must be ${JSON.stringify(expected)}.`); +function exactSet(value, expected, path) { + if (!Array.isArray(value)) throw new Error(`${path} must be an array.`); + const actual = JSON.stringify([...value].sort()); + const wanted = JSON.stringify([...expected].sort()); + if (actual !== wanted) throw new Error(`${path} must contain each required value exactly once: ${expected.join(", ")}.`); } diff --git a/scripts/lib/stack-operation.mjs b/scripts/lib/stack-operation.mjs index a4f7185..ead8a8e 100644 --- a/scripts/lib/stack-operation.mjs +++ b/scripts/lib/stack-operation.mjs @@ -1,5 +1,7 @@ import { createHash } from "node:crypto"; +import { validateOperationApproval } from "./approval-validation.mjs"; + export const STACK_OPERATION_SCHEMA_VERSION = "tabellio-stack-operation/v0.1"; export const STACK_APPROVAL_SCHEMA_VERSION = "tabellio-stack-approval/v0.1"; export const STACK_OPERATIONS = ["submit", "update", "sync", "restack", "merge"]; @@ -56,25 +58,11 @@ export function validateStackOperationIntent(value) { } export function validateStackOperationApproval(value, intent, { now = new Date() } = {}) { - validateStackOperationIntent(intent); - object(value, "approval"); - exactKeys(value, ["schemaVersion", "id", "intentDigest", "approved", "approvedBy", "approvedAt", "expiresAt", "reason"], "approval"); - equals(value.schemaVersion, STACK_APPROVAL_SCHEMA_VERSION, "approval.schemaVersion"); - string(value.id, "approval.id"); - if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value.id)) throw new Error("approval.id contains unsupported characters."); - equals(value.intentDigest, intent.integrity.digest, "approval.intentDigest"); - equals(value.approved, true, "approval.approved"); - string(value.approvedBy, "approval.approvedBy"); - string(value.reason, "approval.reason"); - date(value.approvedAt, "approval.approvedAt"); - date(value.expiresAt, "approval.expiresAt"); - const approvedAt = Date.parse(value.approvedAt); - const expiresAt = Date.parse(value.expiresAt); - if (approvedAt < Date.parse(intent.createdAt)) throw new Error("approval.approvedAt must not precede the intent."); - if (expiresAt <= approvedAt) throw new Error("approval.expiresAt must be later than approval.approvedAt."); - if (now.getTime() < approvedAt) throw new Error("approval is not active yet."); - if (now.getTime() >= expiresAt) throw new Error("approval has expired."); - return value; + return validateOperationApproval(value, intent, { + schemaVersion: STACK_APPROVAL_SCHEMA_VERSION, + validateIntent: validateStackOperationIntent, + now, + }); } export function digestObject(value) { diff --git a/scripts/tabellio-control-ref.mjs b/scripts/tabellio-control-ref.mjs index 5549c7d..cf3e4bf 100644 --- a/scripts/tabellio-control-ref.mjs +++ b/scripts/tabellio-control-ref.mjs @@ -30,11 +30,12 @@ async function plan(options) { const store = await NativeGitStore.open(repoPath); const env = credentialEnv(options); const refs = options.refs ? options.refs.split(",") : CONTROL_REFS; + if (!options.remote) throw new Error("plan requires --remote for an external control-state destination."); const intent = createControlRefIntent({ operation: options.operation, repositoryId: await repositoryIdentity(store, options.repoId), - remote: options.remote ?? "forgejo", - refs: await snapshotControlRefs({ repoPath, remote: options.remote ?? "forgejo", refs, env }), + remote: options.remote, + refs: await snapshotControlRefs({ repoPath, remote: options.remote, refs, env }), }); await output(intent, options.out); } diff --git a/tabellio.platform.json b/tabellio.platform.json index 8cdd71d..e8b56ba 100644 --- a/tabellio.platform.json +++ b/tabellio.platform.json @@ -1,14 +1,19 @@ { - "schemaVersion": "tabellio-platform/v0.1", - "canonicalForge": { - "provider": "forgejo", - "urlEnv": "TABELLIO_FORGE_URL", - "apiUrlEnv": "TABELLIO_FORGE_API_URL", - "tokenFileEnv": "TABELLIO_FORGE_TOKEN_FILE" + "schemaVersion": "tabellio-platform/v0.3", + "codeStorage": { + "provider": "github", + "remoteName": "origin", + "publicSurface": "code-and-thin-pr", + "codeRef": "refs/heads/main", + "allowedRefPrefixes": [ + "refs/heads/", + "refs/tags/" + ] }, - "git": { + "workflow": { "stackManager": "git-spice", - "codeRef": "refs/heads/main", + "controlState": "external", + "publishControlRefsToCodeStorage": false, "controlRefs": [ "refs/tabellio/reviews", "refs/tabellio/validations", @@ -17,19 +22,18 @@ }, "ledger": { "provider": "entire", + "storage": "external", "checkpointRef": "refs/heads/entire/checkpoints/v1" }, "validation": { "runner": "tabellio-validate", "manifest": "tabellio.validation.json", + "storage": "external", "resultRef": "refs/tabellio/validations" }, "reviews": { - "provider": "forgejo", + "provider": "tabellio", + "storage": "external", "stateRef": "refs/tabellio/reviews" - }, - "transition": { - "codeStorage": "current-origin", - "runtimeRequired": false } } diff --git a/tests/control-ref-transport.test.mjs b/tests/control-ref-transport.test.mjs index e534b2c..8a4b02f 100644 --- a/tests/control-ref-transport.test.mjs +++ b/tests/control-ref-transport.test.mjs @@ -22,21 +22,11 @@ const now = new Date("2026-07-10T12:02:00.000Z"); test("approved control refs publish and fetch exact fast-forward state", async (t) => { const fixture = await createFixture(); + await addControlRemote(fixture.seed, fixture.bare); const stateRoot = await mkdtemp(join(tmpdir(), "tabellio-control-ref-")); - t.after(() => Promise.all([ - rm(fixture.root, { recursive: true, force: true }), - rm(stateRoot, { recursive: true, force: true }), - ])); + removeAfter(t, fixture.root, stateRoot); - const ledger = await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/validations" }); - await ledger.write("runs/one.json", { status: "passed" }, { expectedVersion: null }); - const publish = createControlRefIntent({ - operation: "publish", - repositoryId: "example/repository", - remote: "origin", - refs: await snapshotControlRefs({ repoPath: fixture.seed, remote: "origin", refs: ["refs/tabellio/validations"] }), - createdAt, - }); + const { intent: publish } = await createValidationPublishIntent(fixture.seed); validateControlRefIntent(publish); const publisher = await ApprovedControlRefTransport.open({ repoPath: fixture.seed, stateRoot: join(stateRoot, "publish") }); const published = await publisher.execute({ intent: publish, approval: approvalFor(publish, "publish-once"), repositoryId: "example/repository", now }); @@ -49,11 +39,12 @@ test("approved control refs publish and fetch exact fast-forward state", async ( const consumer = join(fixture.root, "consumer"); await runGit({ args: ["clone", fixture.bare, consumer], cwd: fixture.root }); + await addControlRemote(consumer, fixture.bare); const fetch = createControlRefIntent({ operation: "fetch", repositoryId: "example/repository", - remote: "origin", - refs: await snapshotControlRefs({ repoPath: consumer, remote: "origin", refs: ["refs/tabellio/validations"] }), + remote: "ledger", + refs: await snapshotControlRefs({ repoPath: consumer, remote: "ledger", refs: ["refs/tabellio/validations"] }), createdAt, }); const fetcher = await ApprovedControlRefTransport.open({ repoPath: consumer, stateRoot: join(stateRoot, "fetch") }); @@ -65,20 +56,10 @@ test("approved control refs publish and fetch exact fast-forward state", async ( test("control ref operations reject tampering, expiry, and changed refs", async (t) => { const fixture = await createFixture(); + await addControlRemote(fixture.seed, fixture.bare); const stateRoot = await mkdtemp(join(tmpdir(), "tabellio-control-ref-block-")); - t.after(() => Promise.all([ - rm(fixture.root, { recursive: true, force: true }), - rm(stateRoot, { recursive: true, force: true }), - ])); - const ledger = await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/validations" }); - await ledger.write("runs/one.json", { status: "passed" }, { expectedVersion: null }); - const intent = createControlRefIntent({ - operation: "publish", - repositoryId: "example/repository", - remote: "origin", - refs: await snapshotControlRefs({ repoPath: fixture.seed, remote: "origin", refs: ["refs/tabellio/validations"] }), - createdAt, - }); + removeAfter(t, fixture.root, stateRoot); + const { intent, ledger } = await createValidationPublishIntent(fixture.seed); const tampered = structuredClone(intent); tampered.remote = "other"; assert.throws(() => validateControlRefIntent(tampered), /digest does not match/); @@ -101,6 +82,7 @@ test("control ref transport opens bare repositories", async (t) => { test("multi-ref publication is atomic when one remote ref is rejected", async (t) => { const fixture = await createFixture(); + await addControlRemote(fixture.seed, fixture.bare); const stateRoot = await mkdtemp(join(tmpdir(), "tabellio-control-ref-atomic-")); t.after(() => Promise.all([ rm(fixture.root, { recursive: true, force: true }), @@ -116,10 +98,10 @@ test("multi-ref publication is atomic when one remote ref is rejected", async (t const intent = createControlRefIntent({ operation: "publish", repositoryId: "example/repository", - remote: "origin", + remote: "ledger", refs: await snapshotControlRefs({ repoPath: fixture.seed, - remote: "origin", + remote: "ledger", refs: ["refs/tabellio/validations", "refs/tabellio/reviews"], }), createdAt, @@ -130,12 +112,34 @@ test("multi-ref publication is atomic when one remote ref is rejected", async (t /git push .* failed/, ); const remote = await runGit({ - args: ["ls-remote", "--refs", "origin", "refs/tabellio/validations", "refs/tabellio/reviews"], + args: ["ls-remote", "--refs", "ledger", "refs/tabellio/validations", "refs/tabellio/reviews"], cwd: fixture.seed, }); assert.equal(remote.stdout, ""); }); +test("code-storage remote rejects control-state publication", async (t) => { + const fixture = await createFixture(); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + const ledger = await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/validations" }); + await ledger.write("runs/one.json", { status: "passed" }, { expectedVersion: null }); + + await assert.rejects( + snapshotControlRefs({ repoPath: fixture.seed, remote: "origin", refs: ["refs/tabellio/validations"] }), + /must not target code-storage remote origin/, + ); + assert.throws( + () => createControlRefIntent({ + operation: "publish", + repositoryId: "example/repository", + remote: "origin", + refs: [{ name: "refs/tabellio/validations", localOid: "a".repeat(40), remoteOid: null }], + createdAt, + }), + /must not target code-storage remote origin/, + ); +}); + test("control-ref JSON schema encodes runtime uniqueness and required OIDs", async () => { const schema = JSON.parse(await readFile(new URL("../schemas/control-ref-operation.schema.json", import.meta.url), "utf8")); assert.equal(schema.properties.refs.uniqueItems, true); @@ -159,3 +163,24 @@ function approvalFor(intent, id) { reason: "Approved exact control-ref transfer.", }; } + +async function addControlRemote(repoPath, barePath) { + await runGit({ args: ["remote", "add", "ledger", barePath], cwd: repoPath }); +} + +async function createValidationPublishIntent(repoPath) { + const ledger = await GitJsonLedger.open({ repoPath, ref: "refs/tabellio/validations" }); + await ledger.write("runs/one.json", { status: "passed" }, { expectedVersion: null }); + const intent = createControlRefIntent({ + operation: "publish", + repositoryId: "example/repository", + remote: "ledger", + refs: await snapshotControlRefs({ repoPath, remote: "ledger", refs: ["refs/tabellio/validations"] }), + createdAt, + }); + return { intent, ledger }; +} + +function removeAfter(t, ...paths) { + t.after(() => Promise.all(paths.map((path) => rm(path, { recursive: true, force: true })))); +} diff --git a/tests/platform-config.test.mjs b/tests/platform-config.test.mjs new file mode 100644 index 0000000..79b90dc --- /dev/null +++ b/tests/platform-config.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { validatePlatformConfig } from "../scripts/lib/platform-config.mjs"; + +const projectRoot = new URL("../", import.meta.url).pathname; + +test("platform v0.3 makes GitHub code-only storage and external control state explicit", async () => { + const config = JSON.parse(await readFile(`${projectRoot}/tabellio.platform.json`, "utf8")); + assert.equal(validatePlatformConfig(config), config); + assert.deepEqual(config.codeStorage, { + provider: "github", + remoteName: "origin", + publicSurface: "code-and-thin-pr", + codeRef: "refs/heads/main", + allowedRefPrefixes: ["refs/heads/", "refs/tags/"], + }); + assert.equal(config.workflow.controlState, "external"); + assert.equal(config.workflow.publishControlRefsToCodeStorage, false); + assert.equal(config.ledger.storage, "external"); + assert.equal(config.validation.storage, "external"); + assert.equal(config.reviews.storage, "external"); +}); + +test("platform v0.3 rejects provider drift and private-state publication", async () => { + const config = JSON.parse(await readFile(`${projectRoot}/tabellio.platform.json`, "utf8")); + assert.throws( + () => validatePlatformConfig({ ...config, codeStorage: { ...config.codeStorage, provider: "forgejo" } }), + /platform.codeStorage.provider must be "github"/, + ); + assert.throws( + () => validatePlatformConfig({ ...config, workflow: { ...config.workflow, publishControlRefsToCodeStorage: true } }), + /platform.workflow.publishControlRefsToCodeStorage must be false/, + ); + assert.throws( + () => validatePlatformConfig({ ...config, codeStorage: { ...config.codeStorage, allowedRefPrefixes: ["refs/heads/", "refs/tabellio/"] } }), + /allowedRefPrefixes must contain each required value exactly once/, + ); +}); From 3c4301638da39e763626f198a9ac5aee9c3f0413 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Tue, 14 Jul 2026 21:42:46 -0700 Subject: [PATCH 2/6] Remove legacy Forgejo integration --- CHANGELOG.md | 8 +- CONTRIBUTING.md | 2 +- README.md | 6 +- docs/codex-review.md | 2 +- docs/getting-started.md | 19 +- docs/github-code-storage-boundary.md | 2 +- docs/native-git-foundation.md | 2 +- docs/review-loop.md | 37 ++- docs/stack-operations.md | 12 +- docs/tooling-stack.md | 17 +- docs/validation-runner.md | 2 +- .../tabellio-evidence/minimal-evidence.json | 4 +- examples/tabellio-review/minimal-cycle.json | 10 +- examples/tabellio-stack/minimal-stack.json | 2 +- infra/forgejo/README.md | 23 -- infra/forgejo/compose.yml | 23 -- package.json | 5 +- schemas/review-cycle.schema.json | 8 +- scripts/dev/forgejo-lab.mjs | 223 ------------------ scripts/lib/change-request-provider.mjs | 29 +++ scripts/lib/forge-provider.mjs | 33 --- scripts/lib/review-cycle.mjs | 32 +-- scripts/providers/git-spice-operations.mjs | 2 +- ...rgejo-provider.mjs => github-provider.mjs} | 178 ++++++++------ scripts/tabellio-forge.mjs | 99 -------- scripts/tabellio-review.mjs | 22 +- scripts/tabellio-stack-operation.mjs | 4 +- tests/git-spice-operations.test.mjs | 8 +- tests/git-spice-stack-manager.test.mjs | 2 +- ...ider.test.mjs => github-provider.test.mjs} | 139 ++++++----- tests/platform-config.test.mjs | 2 +- tests/review-cycle.test.mjs | 16 +- 32 files changed, 320 insertions(+), 653 deletions(-) delete mode 100644 infra/forgejo/README.md delete mode 100644 infra/forgejo/compose.yml delete mode 100755 scripts/dev/forgejo-lab.mjs create mode 100644 scripts/lib/change-request-provider.mjs delete mode 100644 scripts/lib/forge-provider.mjs rename scripts/providers/{forgejo-provider.mjs => github-provider.mjs} (57%) delete mode 100755 scripts/tabellio-forge.mjs rename tests/{forgejo-provider.test.mjs => github-provider.test.mjs} (53%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca2a42..54bfdbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,12 @@ All notable changes to Tabellio are recorded here. - Bounded SHA-256 output evidence, detached worktree cleanup, and durable results on `refs/tabellio/validations`. - Local validation results integrated into durable review readiness. - Git-native JSON ledger on compare-and-swap refs for durable control-plane state. -- Durable review cycles covering Forgejo feedback, provider-neutral agent findings, triage, checks, fixes, and readiness. +- Durable review cycles covering GitHub feedback, provider-neutral agent findings, triage, checks, fixes, and readiness. - Entire-checkpoint-bound fix records with commit remapping across git-spice restacks. - Approval-gated git-spice submit, update, sync, restack, and merge operations. - Integrity-bound operation intents, short-lived approvals, one-use receipts, and branch-set race checks. -- File-backed Forgejo API and Git HTTPS authentication without credentials in remote URLs or command arguments. -- Read-only Forgejo provider for repository, pull request, review, comment, and commit-status reads. -- Disposable localhost Forgejo 15.0.3 lab with idempotent bootstrap and seeded integration fixture. -- `tabellio-forge` CLI with file-backed or environment-backed token loading and redacted failures. +- File-backed GitHub API and Git HTTPS authentication without credentials in remote URLs or command arguments. +- Read-only GitHub provider for repository, pull request, review, comment, commit-status, and check-run reads. - Mandatory-by-default Entire ledger provider with metadata-only checkpoint export and context binding. - Provider-neutral `tabellio-ledger/v0.1` schema, CLI, validator, example, and tests. - Read-only git-spice stack adapter with provider-neutral `tabellio-stack/v0.1` snapshots. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 370ab04..eb9e08d 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 a clean `origin/main` commit diff --git a/README.md b/README.md index 1c6f99f..37b7db0 100644 --- a/README.md +++ b/README.md @@ -79,12 +79,11 @@ AI-assisted pull requests should not depend on reviewer trust alone. Tabellio gi | `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` | Legacy migration-only Forgejo adapter; not a canonical service | +| `scripts/providers/github-provider.mjs` | Read-only GitHub pull-request, review, comment, status, and check adapter | | `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/review-cycle.mjs` | Durable GitHub 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/` | Legacy disposable adapter fixture scheduled for removal | | `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 | @@ -166,7 +165,6 @@ npm run tabellio:review:example:check npm run tabellio:validate:example:check npm run tabellio:ledger -- --repo . --repo-id IntelIP/Tabellio --base main --head HEAD --out tabellio-ledger.json npm run tabellio:ledger:check -npm run tabellio:forge -- version --base-url http://127.0.0.1:3300 --token-file .tabellio/forgejo/credentials/admin-token npm run tabellio:context:capture npm run tabellio:context:check npm run tabellio:evidence:write diff --git a/docs/codex-review.md b/docs/codex-review.md index 265a402..1883dd8 100644 --- a/docs/codex-review.md +++ b/docs/codex-review.md @@ -4,7 +4,7 @@ Codex review is a provider-neutral review producer. It inspects the diff and evi ## Local Preflight -Run review against the exact Forgejo change-request head: +Run review against the exact GitHub pull-request head: ```bash CODEX_HOME=/tmp/codex-review-home codex review --base main diff --git a/docs/getting-started.md b/docs/getting-started.md index a52dfe1..003e03a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -22,7 +22,7 @@ Every agent change range must contain at least one `Entire-Checkpoint` commit tr ## Capture A Stack -Initialize git-spice in a normal working repository, then capture its local stack graph without contacting the forge: +Initialize git-spice in a normal working repository, then capture its local stack graph without contacting GitHub: ```bash git-spice repo init @@ -35,23 +35,6 @@ node scripts/check-tabellio-stack.mjs --stack tabellio-stack.json The snapshot adapter uses documented JSON output and disables change-request status and comment queries. Approved write operations use the separate flow in [Approved stack operations](stack-operations.md). -## Legacy Forgejo Adapter Lab - -Docker users can still exercise the migration-only Forgejo adapter: - -```bash -node scripts/dev/forgejo-lab.mjs up -node scripts/dev/forgejo-lab.mjs bootstrap -node scripts/dev/forgejo-lab.mjs seed -node scripts/tabellio-forge.mjs pulls \ - --base-url http://127.0.0.1:3300 \ - --owner tabellio-admin \ - --repo tabellio-lab \ - --token-file .tabellio/forgejo/credentials/admin-token -``` - -The lab is disposable, localhost-only, and not part of the target platform. Generated secrets and Forgejo data remain below ignored `.tabellio/forgejo/`. Stop the container with `node scripts/dev/forgejo-lab.mjs down`. - ## Configure The Platform `tabellio.platform.json` makes the operating model explicit: GitHub code storage, git-spice stacks, external Entire checkpoints, external validation, and external review state. diff --git a/docs/github-code-storage-boundary.md b/docs/github-code-storage-boundary.md index aa18a34..5fb2128 100644 --- a/docs/github-code-storage-boundary.md +++ b/docs/github-code-storage-boundary.md @@ -26,4 +26,4 @@ The pull request remains useful but thin. It carries the code diff, a concise ch ## Migration State -The Forgejo provider and localhost lab remain temporarily as legacy migration fixtures. They are not canonical services and must receive no new production state. A later cleanup PR removes the provider, lab, commands, docs, and package metadata after the external control-state migration is complete. +Legacy self-hosted collaboration code and local lab infrastructure have been removed. GitHub `origin` is the only code-storage remote in the supported platform contract. Private control state still requires a separately configured external destination. diff --git a/docs/native-git-foundation.md b/docs/native-git-foundation.md index f0ff30a..601c6ce 100644 --- a/docs/native-git-foundation.md +++ b/docs/native-git-foundation.md @@ -77,4 +77,4 @@ The packet contains repository identity, not `/srv/git/product.git`. Local paths ## Current Boundary -The foundation manages local Git state. Higher adapters authenticate to Forgejo, manage change requests with git-spice, and share canonical control refs through explicit approved operations. The core still does not host repositories, deploy code, or execute protected external actions. +The foundation manages local Git state. Higher adapters read GitHub pull-request state, manage change requests with git-spice, and share control refs through explicit approved operations. The core still does not host repositories, deploy code, or execute protected external actions. diff --git a/docs/review-loop.md b/docs/review-loop.md index 708cddd..36bc599 100644 --- a/docs/review-loop.md +++ b/docs/review-loop.md @@ -1,47 +1,46 @@ # 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. +GitHub provides the visible pull-request surface. Tabellio keeps the full review state outside code storage on `refs/tabellio/reviews` as immutable JSON ledger commits updated with compare-and-swap. ## State Flow ```text -Forgejo reviews/comments/checks ----+ +GitHub reviews/comments/checks -----+ +-> review cycle -> triage -> fix commit + Entire checkpoint Codex or another agent review ------+ | v approved restack/update | v - forge resync -> ready + GitHub resync -> ready ``` Review status is deterministic: | Status | Meaning | | --- | --- | -| `draft` | The forge still marks the change request as draft | +| `draft` | GitHub still marks the pull request as draft | | `needs_triage` | New feedback has not been classified | | `changes_requested` | Actionable feedback remains open | | `update_required` | Fix exists locally but is not in the remote PR head | -| `blocked` | A remote check failed or the forge reports the change as non-mergeable | +| `blocked` | A remote check failed or GitHub reports the change as non-mergeable | | `validating` | No validation result exists yet, or checks are pending/running | | `ready` | Feedback is handled, fixes are published, and checks are clear | -| `merged` / `closed` | Forge terminal state | +| `merged` / `closed` | Pull-request terminal state | -## Sync Forgejo +## Sync GitHub ```bash node scripts/tabellio-review.mjs sync \ --repo . \ --owner example \ - --forge-repo project \ + --remote-repo project \ --number 7 \ - --base-url https://forgejo.example.com \ - --token-file /secure/path/forgejo-token \ + --token-file /secure/path/github-token \ --actor review-sync-agent ``` -Sync imports reviews, inline review comments, issue comments, forge commit checks, and the newest provider-neutral Tabellio validation for the PR head. Missing provider items are retained as stale evidence rather than silently deleted. A PR with no validation remains `validating`. +Sync imports GitHub reviews, inline review comments, issue comments, commit statuses, check runs, and the newest provider-neutral Tabellio validation for the PR head. `GITHUB_TOKEN` may replace `--token-file`; `GITHUB_API_URL` or `--api-url` may target GitHub Enterprise Server. Missing provider items are retained as stale evidence rather than silently deleted. A PR with no validation remains `validating`. ## Import A Codex Review @@ -51,7 +50,7 @@ Codex and other agents emit `tabellio-agent-review/v0.1`: node scripts/tabellio-review.mjs import \ --repo . \ --owner example \ - --forge-repo project \ + --remote-repo project \ --number 7 \ --input /tmp/codex-review.json \ --actor codex @@ -63,14 +62,14 @@ Imports fail when the repository, PR number, or reviewed head commit is stale. A ```bash node scripts/tabellio-review.mjs triage \ - --repo . --owner example --forge-repo project --number 7 \ + --repo . --owner example --remote-repo project --number 7 \ --feedback-id review-comment:41 \ --disposition actionable \ --reason "Correctness issue" \ --actor reviewer node scripts/tabellio-review.mjs fix \ - --repo . --owner example --forge-repo project --number 7 \ + --repo . --owner example --remote-repo project --number 7 \ --feedback-ids review-comment:41 \ --commit HEAD \ --checkpoint 61229f2bfcff \ @@ -78,7 +77,7 @@ node scripts/tabellio-review.mjs fix \ --actor fix-agent ``` -A fix must descend from the last synchronized PR head. Its commit range must contain exactly one matching `Entire-Checkpoint` trailer. The cycle stays `update_required` until a later forge sync proves the fix is contained in the remote PR head. +A fix must descend from the last synchronized PR head. Its commit range must contain exactly one matching `Entire-Checkpoint` trailer. The cycle stays `update_required` until a later GitHub sync proves the fix is contained in the remote PR head. git-spice restacks rewrite commit IDs. Tabellio retains `originalCommit` and remaps the active fix commit by its unique Entire checkpoint after the rewritten branch is pushed. Ambiguous checkpoint matches stay unpublished and cannot become ready. @@ -86,11 +85,11 @@ git-spice restacks rewrite commit IDs. Tabellio retains `originalCommit` and rem Ledger writes create normal Git blobs, trees, and commits without changing the working tree. Concurrent writers use compare-and-swap on `refs/tabellio/reviews`; stale writers fail instead of overwriting newer state. The same implementation works in normal and bare repositories. The latest cycle retains the newest 100 audit events; older versions remain recoverable from the ledger's Git commit history. -To share the ledger, configure explicit ref transport for the chosen forge, for example: +To share the ledger, configure a separate external control-state remote, for example: ```bash -git push origin refs/tabellio/reviews:refs/tabellio/reviews -git fetch origin refs/tabellio/reviews:refs/tabellio/reviews +git push "$TABELLIO_CONTROL_REMOTE" refs/tabellio/reviews:refs/tabellio/reviews +git fetch "$TABELLIO_CONTROL_REMOTE" refs/tabellio/reviews:refs/tabellio/reviews ``` -Publishing that ref is a remote write and should use the same explicit approval boundary as other Git transport mutations. +The control-state remote must not be `origin`. Publishing that ref is a remote write and uses the same explicit approval boundary as other Git transport mutations. diff --git a/docs/stack-operations.md b/docs/stack-operations.md index 4aaa95e..537794a 100644 --- a/docs/stack-operations.md +++ b/docs/stack-operations.md @@ -10,7 +10,7 @@ Supported operations: | `update` | `branch submit --update-only` | Pushes and updates an existing change request | | `sync` | `repo sync` | Fetches remote state and may remove locally tracked merged branches | | `restack` | `branch restack` | Rebases one tracked branch onto its current base | -| `merge` | `branch merge` | Merges one change request through the forge | +| `merge` | `branch merge` | Merges one change request through GitHub | The wrapper never exposes git-spice force push, hook bypass, or stale-base bypass flags. Restack is separate from sync because a normal repository sync does not rewrite every independently stale branch. @@ -58,22 +58,22 @@ node scripts/check-tabellio-stack-operation.mjs \ ## Execute -For a self-hosted Forgejo repository using HTTPS: +For GitHub using an explicit short-lived token: ```bash node scripts/tabellio-stack-operation.mjs execute \ --repo . \ --intent /tmp/stack-intent.json \ --approval /tmp/stack-approval.json \ - --token-file /secure/path/forgejo-token \ - --git-username forgejo-user + --token-file /secure/path/github-token \ + --git-username x-access-token ``` -The token is supplied to the Forgejo API through `FORGEJO_TOKEN`. Git HTTPS authentication uses an internal askpass process, so the token never enters the remote URL or command arguments. Prefer SSH for Git transport and the operating-system keyring for long-lived production use. +The token is supplied to git-spice through `GITHUB_TOKEN`. Git HTTPS authentication uses an internal askpass process, so the token never enters the remote URL or command arguments. Prefer `gh` authentication, SSH, or the operating-system keyring for long-lived production use. Each approved attempt creates a receipt below Git's private common directory. Approval IDs are one-use even when execution fails. Concurrent Tabellio stack writes are locked. Failed receipts retain sanitized diagnostics without PR bodies or token values. -git-spice 0.30 requires an explicit repository opt-in before forge merge: +git-spice 0.30 requires an explicit repository opt-in before hosted merge: ```bash git config spice.experiment.merge true diff --git a/docs/tooling-stack.md b/docs/tooling-stack.md index 3c130bc..935c3e7 100644 --- a/docs/tooling-stack.md +++ b/docs/tooling-stack.md @@ -23,7 +23,7 @@ The main idea: agentic Git should be built around more than a patch. It should p | Tool | Tag | Why It Matters | | --- | --- | --- | | [Entire](https://entire.io/) | `entire` | Checkpoint and session ledger for agent-assisted work, with checkpoint metadata stored in Git | -| [git-spice](https://abhinav.github.io/git-spice/) | `git-spice` | Offline-first stacked branch and change-request workflow with forge-specific adapters | +| [git-spice](https://abhinav.github.io/git-spice/) | `git-spice` | Offline-first stacked branch and change-request workflow with host-specific adapters | | [OpenAI Codex](https://openai.com/codex/) | `codex` | Coding and review agent used to produce or inspect changes | Entire is required by default for context capture. Legacy Git-note capture remains an explicit migration mode. All ledger reads stay local through the installed Entire CLI. @@ -71,10 +71,9 @@ Included: - default-deny external-action policy - 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 +- read-only GitHub provider for repositories, pull requests, reviews, comments, commit statuses, and check runs - 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 +- Git-native review ledger with GitHub feedback, provider-neutral agent findings, triage, fixes, and readiness state - provider-neutral exact-commit validation runner with durable results on `refs/tabellio/validations` - explicit GitHub code-storage and external control-state contract - approval-gated fast-forward transport for review, validation, and Entire refs @@ -83,7 +82,7 @@ Not included yet: - external control-state service selection and deployment - transcript indexing or storage outside Entire -- forge comment publication, general review-thread mutation, and signed approvals +- GitHub comment publication, general review-thread mutation, and signed approvals - Codex review automation - signed evidence - formal SLSA or in-toto compliance @@ -92,7 +91,7 @@ Not included yet: | Integration | Evidence Field | | --- | --- | -| Forge repository or branch id | adapter metadata outside the provider-neutral core | +| GitHub repository or branch id | adapter metadata outside the provider-neutral core | | Entire checkpoint id | `tabellio-ledger/v0.1` and context `checkpoints[]` | | git-spice branch parent or change-request id | `tabellio-stack/v0.1` snapshot | | Codex review result | `checks[]` and `artifacts[]` | @@ -120,8 +119,6 @@ GitHub receives ordinary code branches, tags, and the minimum pull-request metad The boundary is contractual, not only documentary: `tabellio.platform.json`, its JSON Schema, runtime validation, and transport tests all fail closed on provider or publication-policy drift. See [GitHub code-storage boundary](github-code-storage-boundary.md). -## Legacy Forgejo Boundary +## GitHub Review Adapter -`ForgejoProvider` reads the documented Forgejo v1 API. It normalizes repository identity, change requests, reviews, inline comments, issue comments, and commit status without exposing the access token. The CLI accepts tokens only through a file or environment variable; URLs containing credentials are rejected. - -The adapter and disposable lab remain only as migration fixtures until their dedicated removal PR. They are not canonical services and receive no new production state. +`GitHubProvider` reads the versioned GitHub REST API and normalizes repository identity, pull requests, reviews, inline comments, issue comments, commit statuses, and check runs. The token stays in a file or `GITHUB_TOKEN`; credential-bearing API URLs are rejected. diff --git a/docs/validation-runner.md b/docs/validation-runner.md index 5c1f56a..82b7b56 100644 --- a/docs/validation-runner.md +++ b/docs/validation-runner.md @@ -55,7 +55,7 @@ node scripts/tabellio-validate.mjs latest --repo . --commit HEAD ## CI Worker Shape -The runner is deliberately scheduler-agnostic. A Forgejo webhook worker, systemd service, AWS Batch task, Kubernetes Job, Buildkite agent, Woodpecker pipeline, or local Codex worker can invoke the same CLI. The scheduler supplies capacity; Tabellio supplies the immutable validation contract and result ledger. +The runner is deliberately scheduler-agnostic. A GitHub webhook worker, systemd service, AWS Batch task, Kubernetes Job, Buildkite agent, or local Codex worker can invoke the same CLI. The scheduler supplies capacity; Tabellio supplies the immutable validation contract and result ledger. Publishing `refs/tabellio/validations` to a remote is a separate approved Git write. Review sync consumes the latest result for the exact PR head and will not reuse a result from an older commit. diff --git a/examples/tabellio-evidence/minimal-evidence.json b/examples/tabellio-evidence/minimal-evidence.json index f06f72d..4823998 100644 --- a/examples/tabellio-evidence/minimal-evidence.json +++ b/examples/tabellio-evidence/minimal-evidence.json @@ -7,7 +7,7 @@ "headRef": "tabellio/example-evidence", "sha": "0000000000000000000000000000000000000000", "pullRequest": "1", - "pullRequestUrl": "https://forge.example.test/example/tabellio/pulls/1" + "pullRequestUrl": "https://github.com/example/tabellio/pull/1" }, "actor": { "type": "agent", @@ -16,7 +16,7 @@ "agentRuntime": { "name": "codex", "model": "gpt-5", - "tooling": ["git", "forgejo", "tabellio-validate"] + "tooling": ["git", "github", "tabellio-validate"] }, "taskSource": { "type": "chat", diff --git a/examples/tabellio-review/minimal-cycle.json b/examples/tabellio-review/minimal-cycle.json index 65bf69c..33d759c 100644 --- a/examples/tabellio-review/minimal-cycle.json +++ b/examples/tabellio-review/minimal-cycle.json @@ -1,12 +1,12 @@ { - "schemaVersion": "tabellio-review-cycle/v0.1", + "schemaVersion": "tabellio-review-cycle/v0.2", "id": "review-example-001", "repository": { "id": "example/tabellio" }, - "provider": { "id": "forgejo", "owner": "example", "repo": "tabellio" }, + "provider": { "id": "github", "owner": "example", "repo": "tabellio" }, "changeRequest": { "id": "7", "number": 7, - "url": "https://forgejo.example.test/example/tabellio/pulls/7", + "url": "https://github.com/example/tabellio/pull/7", "title": "Example agent change", "state": "open", "draft": false, @@ -30,7 +30,7 @@ "context": "tests", "state": "success", "description": "Checks passed", - "targetUrl": "https://forgejo.example.test/checks/51", + "targetUrl": "https://github.com/example/tabellio/actions/runs/51", "createdAt": "2026-07-10T12:00:00.000Z", "updatedAt": "2026-07-10T12:00:00.000Z" }] @@ -46,6 +46,6 @@ "updatedAt": "2026-07-10T12:00:00.000Z", "integrity": { "algorithm": "sha256", - "digest": "71c327c3342583e5fcb72dc2c1da90c6408300ac69d7d41c497e3b6edc80499d" + "digest": "88a01451f8b6e7f9d7df66c89dcc0e44234dcc89f78c18e5fef3ca750341e040" } } diff --git a/examples/tabellio-stack/minimal-stack.json b/examples/tabellio-stack/minimal-stack.json index 2e85b6a..d4424e1 100644 --- a/examples/tabellio-stack/minimal-stack.json +++ b/examples/tabellio-stack/minimal-stack.json @@ -30,7 +30,7 @@ "checkedOutElsewhere": false, "changeRequest": { "id": "#12", - "url": "https://forgejo.example.test/example/tabellio/pulls/12", + "url": "https://github.com/example/tabellio/pull/12", "status": null }, "push": { diff --git a/infra/forgejo/README.md b/infra/forgejo/README.md deleted file mode 100644 index f9bd897..0000000 --- a/infra/forgejo/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Local Forgejo Lab - -This disposable Forgejo instance proves Tabellio can store and review code without GitHub. - -- 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/`. -- Forgejo Actions stays disabled; runner work is a separate sprint. -- Image version is pinned to Forgejo `15.0.3`. - -Use the lab CLI: - -```bash -node scripts/dev/forgejo-lab.mjs up -node scripts/dev/forgejo-lab.mjs bootstrap -node scripts/dev/forgejo-lab.mjs seed -node scripts/dev/forgejo-lab.mjs status -node scripts/dev/forgejo-lab.mjs down -``` - -Bootstrap writes generated credentials below `.tabellio/forgejo/credentials/` with owner-only permissions. It prints file paths, never secret values. Its local admin token has only the scopes needed to create repositories, issues, and repository content in the disposable lab. - -`seed` creates an idempotent private repository and open pull request for live provider checks. It reads the token from disk and never prints it. diff --git a/infra/forgejo/compose.yml b/infra/forgejo/compose.yml deleted file mode 100644 index 9798b45..0000000 --- a/infra/forgejo/compose.yml +++ /dev/null @@ -1,23 +0,0 @@ -services: - forgejo: - image: data.forgejo.org/forgejo/forgejo:15.0.3 - container_name: tabellio-forgejo - environment: - USER_UID: "1000" - USER_GID: "1000" - FORGEJO__database__DB_TYPE: sqlite3 - FORGEJO__database__PATH: /data/forgejo/forgejo.db - FORGEJO__server__DOMAIN: 127.0.0.1 - FORGEJO__server__ROOT_URL: http://127.0.0.1:3300/ - FORGEJO__server__HTTP_PORT: "3000" - FORGEJO__server__SSH_DOMAIN: 127.0.0.1 - FORGEJO__server__SSH_PORT: "2222" - FORGEJO__service__DISABLE_REGISTRATION: "true" - FORGEJO__security__INSTALL_LOCK: "true" - FORGEJO__actions__ENABLED: "false" - ports: - - "127.0.0.1:3300:3000" - - "127.0.0.1:2222:22" - volumes: - - ../../.tabellio/forgejo/data:/data - restart: unless-stopped diff --git a/package.json b/package.json index f483da2..374d86a 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,7 @@ "tabellio-review": "scripts/tabellio-review.mjs", "tabellio-validate": "scripts/tabellio-validate.mjs", "tabellio-control-ref": "scripts/tabellio-control-ref.mjs", - "tabellio-ledger": "scripts/tabellio-ledger.mjs", - "tabellio-forge": "scripts/tabellio-forge.mjs" + "tabellio-ledger": "scripts/tabellio-ledger.mjs" }, "bugs": { "url": "https://github.com/IntelIP/Tabellio/issues" @@ -60,7 +59,6 @@ "tabellio:ledger": "node scripts/tabellio-ledger.mjs", "tabellio:ledger:check": "node scripts/check-tabellio-ledger.mjs --ledger tabellio-ledger.json", "tabellio:ledger:example:check": "node scripts/check-tabellio-ledger.mjs --ledger examples/tabellio-ledger/minimal-ledger.json", - "tabellio:forge": "node scripts/tabellio-forge.mjs", "tabellio:context:capture": "node scripts/capture-tabellio-context.mjs --out tabellio-context.json", "tabellio:context:check": "node scripts/check-tabellio-context.mjs --context tabellio-context.json", "tabellio:context:example:check": "node scripts/check-tabellio-context.mjs --context examples/tabellio-context/minimal-context.json", @@ -89,7 +87,6 @@ "sarif", "stacked-pr", "git-spice", - "forgejo", "entire", "codex" ] diff --git a/schemas/review-cycle.schema.json b/schemas/review-cycle.schema.json index 0e1d5a4..34e5c2b 100644 --- a/schemas/review-cycle.schema.json +++ b/schemas/review-cycle.schema.json @@ -1,12 +1,12 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:tabellio:schema:review-cycle:v0.1", + "$id": "urn:tabellio:schema:review-cycle:v0.2", "title": "Tabellio Review Cycle", "type": "object", "required": ["schemaVersion", "id", "repository", "provider", "changeRequest", "status", "round", "feedback", "fixes", "checks", "events", "createdAt", "updatedAt", "integrity"], "additionalProperties": false, "properties": { - "schemaVersion": { "const": "tabellio-review-cycle/v0.1" }, + "schemaVersion": { "const": "tabellio-review-cycle/v0.2" }, "id": { "type": "string", "minLength": 1 }, "repository": { "type": "object", @@ -19,7 +19,7 @@ "required": ["id", "owner", "repo"], "additionalProperties": false, "properties": { - "id": { "const": "forgejo" }, + "id": { "const": "github" }, "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": { "enum": ["github", "agent"] }, "providerId": { "type": "string", "minLength": 1 }, "kind": { "enum": ["review", "review-comment", "issue-comment", "check", "agent-finding"] }, "author": { "$ref": "#/$defs/nullableString" }, diff --git a/scripts/dev/forgejo-lab.mjs b/scripts/dev/forgejo-lab.mjs deleted file mode 100755 index 33b3e9a..0000000 --- a/scripts/dev/forgejo-lab.mjs +++ /dev/null @@ -1,223 +0,0 @@ -#!/usr/bin/env node - -import { execFile } from "node:child_process"; -import { randomBytes } from "node:crypto"; -import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); -const composePath = resolve(projectRoot, "infra/forgejo/compose.yml"); -const credentialsRoot = resolve(projectRoot, ".tabellio/forgejo/credentials"); -const passwordPath = resolve(credentialsRoot, "admin-password"); -const tokenPath = resolve(credentialsRoot, "admin-token"); -const tokenScopesPath = resolve(credentialsRoot, "admin-token-scopes"); -const tokenScopes = "write:repository,write:issue,write:user"; -const baseUrl = "http://127.0.0.1:3300"; -const container = "tabellio-forgejo"; -const command = process.argv[2]; - -try { - let result; - if (command === "up") result = await up(); - else if (command === "down") result = await down(); - else if (command === "status") result = await status(); - else if (command === "bootstrap") result = await bootstrap(); - else if (command === "seed") result = await seed(); - else throw new Error("Expected command: up, down, status, bootstrap, or seed."); - console.log(JSON.stringify({ ok: true, ...result }, null, 2)); -} catch (error) { - process.exitCode = 1; - console.error(JSON.stringify({ - ok: false, - error: error instanceof Error ? error.message : String(error), - name: error instanceof Error ? error.name : "Error", - }, null, 2)); -} - -async function up() { - await run("docker", ["compose", "-f", composePath, "up", "-d"]); - await waitForHealth(); - return { status: "running", baseUrl }; -} - -async function down() { - await run("docker", ["compose", "-f", composePath, "down"]); - return { status: "stopped" }; -} - -async function status() { - const health = await fetch(`${baseUrl}/api/healthz`).then(async (response) => ({ - ok: response.ok, - status: response.status, - body: await response.text(), - })).catch(() => ({ ok: false, status: 0, body: "" })); - const version = health.ok - ? await fetch(`${baseUrl}/api/v1/version`).then(async (response) => response.ok ? response.json() : null) - : null; - return { status: health.ok ? "running" : "unavailable", baseUrl, health, version }; -} - -async function bootstrap() { - await waitForHealth(); - await mkdir(credentialsRoot, { recursive: true, mode: 0o700 }); - await chmod(credentialsRoot, 0o700); - const password = await readSecret(passwordPath).catch(async () => { - const generated = randomBytes(24).toString("base64url"); - await writeSecret(passwordPath, generated); - return generated; - }); - const users = await forgejo(["admin", "user", "list", "--admin"]); - if (!users.stdout.split(/\r?\n/).some((line) => line.includes("tabellio-admin"))) { - await forgejo([ - "admin", "user", "create", - "--username", "tabellio-admin", - "--password", password, - "--email", "tabellio@example.invalid", - "--admin", - "--must-change-password=false", - ]); - } - const storedScopes = await readSecret(tokenScopesPath).catch(() => null); - const token = storedScopes === tokenScopes ? await readSecret(tokenPath).catch(() => null) : null; - const activeToken = token ?? await (async () => { - const tokenResult = await forgejo([ - "admin", "user", "generate-access-token", - "--username", "tabellio-admin", - "--token-name", `tabellio-dev-${Date.now()}`, - "--scopes", tokenScopes, - "--raw", - ]); - const generated = tokenResult.stdout.trim(); - if (!generated) throw new Error("Forgejo did not return an access token."); - await writeSecret(tokenPath, generated); - await writeSecret(tokenScopesPath, tokenScopes); - return generated; - })(); - if (!activeToken) throw new Error("Forgejo access token is unavailable."); - return { - status: "bootstrapped", - baseUrl, - username: "tabellio-admin", - passwordPath, - tokenPath, - }; -} - -async function seed() { - await waitForHealth(); - const token = await readSecret(tokenPath); - const owner = "tabellio-admin"; - const repo = "tabellio-lab"; - let repository = await apiRequest(token, `/api/v1/repos/${owner}/${repo}`, { expected: [200, 404] }); - if (repository.status === 404) { - repository = await apiRequest(token, "/api/v1/user/repos", { - method: "POST", - body: { - name: repo, - private: true, - auto_init: true, - default_branch: "main", - description: "Disposable Tabellio Forgejo provider fixture", - }, - expected: [201], - }); - } - let pulls = await apiRequest(token, `/api/v1/repos/${owner}/${repo}/pulls?state=open`, { expected: [200] }); - if (!Array.isArray(pulls.value) || pulls.value.length === 0) { - await apiRequest(token, `/api/v1/repos/${owner}/${repo}/contents/provider-proof.txt`, { - method: "POST", - body: { - branch: "main", - new_branch: "agent/provider-proof", - message: "Add provider proof", - content: Buffer.from("Forgejo provider proof\n", "utf8").toString("base64"), - }, - expected: [201, 409], - }); - await apiRequest(token, `/api/v1/repos/${owner}/${repo}/pulls`, { - method: "POST", - body: { - base: "main", - head: "agent/provider-proof", - title: "Prove Forgejo provider reads", - body: "Disposable pull request for provider integration checks.", - }, - expected: [201, 409], - }); - pulls = await apiRequest(token, `/api/v1/repos/${owner}/${repo}/pulls?state=open`, { expected: [200] }); - } - const pull = Array.isArray(pulls.value) ? pulls.value[0] : null; - return { - status: "seeded", - owner, - repo, - repositoryUrl: repository.value?.html_url ?? `${baseUrl}/${owner}/${repo}`, - changeRequest: pull ? { number: pull.number, url: pull.html_url } : null, - }; -} - -async function waitForHealth() { - for (let attempt = 0; attempt < 60; attempt += 1) { - const ready = await fetch(`${baseUrl}/api/healthz`).then((response) => response.ok).catch(() => false); - if (ready) return; - await new Promise((resolvePromise) => setTimeout(resolvePromise, 1_000)); - } - throw new Error(`Forgejo did not become healthy at ${baseUrl}.`); -} - -function forgejo(args) { - return run("docker", ["exec", "-u", "git", container, "forgejo", ...args]); -} - -async function apiRequest(token, path, { method = "GET", body, expected = [200] } = {}) { - const response = await fetch(`${baseUrl}${path}`, { - method, - headers: { - Accept: "application/json", - Authorization: `token ${token}`, - ...(body === undefined ? {} : { "Content-Type": "application/json" }), - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - const text = await response.text(); - let value = null; - if (text.trim() !== "") { - try { - value = JSON.parse(text); - } catch { - value = text; - } - } - if (!expected.includes(response.status)) { - const safeBody = String(text).split(token).join("[REDACTED]").slice(0, 500); - throw new Error(`${method} ${path} failed with HTTP ${response.status}${safeBody ? `: ${safeBody}` : "."}`); - } - return { status: response.status, value }; -} - -function run(binary, args) { - return new Promise((resolvePromise, reject) => { - execFile(binary, args, { - cwd: projectRoot, - encoding: "utf8", - maxBuffer: 10 * 1024 * 1024, - timeout: 120_000, - env: { ...process.env, LC_ALL: "C" }, - }, (error, stdout = "", stderr = "") => { - if (!error) resolvePromise({ stdout, stderr }); - else reject(new Error(`${binary} failed: ${stderr.trim() || error.message}`)); - }); - }); -} - -async function readSecret(path) { - const value = (await readFile(path, "utf8")).trim(); - if (!value) throw new Error(`Secret file is empty: ${path}`); - return value; -} - -async function writeSecret(path, value) { - await writeFile(path, `${value}\n`, { mode: 0o600 }); - await chmod(path, 0o600); -} diff --git a/scripts/lib/change-request-provider.mjs b/scripts/lib/change-request-provider.mjs new file mode 100644 index 0000000..88a611f --- /dev/null +++ b/scripts/lib/change-request-provider.mjs @@ -0,0 +1,29 @@ +export class ChangeRequestProvider { + async repository(_options) { + throw new Error("ChangeRequestProvider.repository must be implemented."); + } + + async listChangeRequests(_options) { + throw new Error("ChangeRequestProvider.listChangeRequests must be implemented."); + } + + async changeRequest(_options) { + throw new Error("ChangeRequestProvider.changeRequest must be implemented."); + } + + async listReviews(_options) { + throw new Error("ChangeRequestProvider.listReviews must be implemented."); + } + + async listReviewComments(_options) { + throw new Error("ChangeRequestProvider.listReviewComments must be implemented."); + } + + async listIssueComments(_options) { + throw new Error("ChangeRequestProvider.listIssueComments must be implemented."); + } + + async commitStatus(_options) { + throw new Error("ChangeRequestProvider.commitStatus must be implemented."); + } +} diff --git a/scripts/lib/forge-provider.mjs b/scripts/lib/forge-provider.mjs deleted file mode 100644 index cbcd435..0000000 --- a/scripts/lib/forge-provider.mjs +++ /dev/null @@ -1,33 +0,0 @@ -export class ForgeProvider { - async version() { - throw new Error("ForgeProvider.version must be implemented."); - } - - async repository(_options) { - throw new Error("ForgeProvider.repository must be implemented."); - } - - async listChangeRequests(_options) { - throw new Error("ForgeProvider.listChangeRequests must be implemented."); - } - - async changeRequest(_options) { - throw new Error("ForgeProvider.changeRequest must be implemented."); - } - - async listReviews(_options) { - throw new Error("ForgeProvider.listReviews must be implemented."); - } - - async listReviewComments(_options) { - throw new Error("ForgeProvider.listReviewComments must be implemented."); - } - - async listIssueComments(_options) { - throw new Error("ForgeProvider.listIssueComments must be implemented."); - } - - async commitStatus(_options) { - throw new Error("ForgeProvider.commitStatus must be implemented."); - } -} diff --git a/scripts/lib/review-cycle.mjs b/scripts/lib/review-cycle.mjs index 5e1a1e7..da549f3 100644 --- a/scripts/lib/review-cycle.mjs +++ b/scripts/lib/review-cycle.mjs @@ -4,7 +4,7 @@ import { runGit } from "./git-process.mjs"; import { digestObject } from "./stack-operation.mjs"; import { latestValidationResult } from "./validation-runner.mjs"; -export const REVIEW_CYCLE_SCHEMA_VERSION = "tabellio-review-cycle/v0.1"; +export const REVIEW_CYCLE_SCHEMA_VERSION = "tabellio-review-cycle/v0.2"; export const AGENT_REVIEW_SCHEMA_VERSION = "tabellio-agent-review/v0.1"; const MAX_FEEDBACK_ITEMS = 5_000; const MAX_AGENT_FINDINGS = 1_000; @@ -29,7 +29,7 @@ export class ReviewCycleManager { this.provider.listReviews({ owner: this.owner, repo: this.repo, number }), this.provider.listIssueComments({ owner: this.owner, repo: this.repo, number }), ]); - const [reviewComments, forgeChecks, localValidation] = await Promise.all([ + const [reviewComments, providerChecks, localValidation] = await Promise.all([ this.provider.listReviewComments({ owner: this.owner, repo: this.repo, number, reviews }), this.provider.commitStatus({ owner: this.owner, @@ -41,7 +41,7 @@ export class ReviewCycleManager { if (localValidation && localValidation.repository.id !== this.repositoryId) { throw new Error("Local validation repository does not match the review cycle."); } - const checks = mergeChecks(forgeChecks, localValidation); + const checks = mergeChecks(providerChecks, localValidation); const record = await this.#read(number); const existing = record.value; if (existing) validateReviewCycle(existing); @@ -62,7 +62,7 @@ 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: "github", owner: this.owner, repo: this.repo }, changeRequest: { id: changeRequest.id, number: changeRequest.number, @@ -215,7 +215,7 @@ export class ReviewCycleManager { } #requireProvider() { - if (!this.provider) throw new Error("Review sync requires a forge provider."); + if (!this.provider) throw new Error("Review sync requires a change-request provider."); } async #requireCheckpoint(baseCommit, fixCommit, checkpointId) { @@ -267,7 +267,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"); + equals(value.provider.id, "github", "provider.id"); requiredString(value.provider.owner, "provider.owner"); requiredString(value.provider.repo, "provider.repo"); validateChangeRequest(value.changeRequest); @@ -455,7 +455,7 @@ function feedback({ }) { return { id, - source: "forgejo", + source: "github", providerId, kind, author, @@ -493,8 +493,8 @@ function cycleDigest(value) { return digestObject(unsigned); } -function mergeChecks(forgeChecks, localValidation) { - if (!localValidation) return forgeChecks; +function mergeChecks(providerChecks, localValidation) { + if (!localValidation) return providerChecks; const localStatus = { id: `validation:${localValidation.runId}`, context: `tabellio/${localValidation.suite.id}`, @@ -504,12 +504,12 @@ function mergeChecks(forgeChecks, localValidation) { createdAt: localValidation.startedAt, updatedAt: localValidation.completedAt, }; - const statuses = [...forgeChecks.statuses, localStatus]; - let state = forgeChecks.state; - if (localStatus.state === "failure" || ["error", "failure", "failed"].includes(forgeChecks.state)) state = "failure"; - else if (["pending", "running"].includes(forgeChecks.state)) state = forgeChecks.state; + const statuses = [...providerChecks.statuses, localStatus]; + let state = providerChecks.state; + if (localStatus.state === "failure" || ["error", "failure", "failed"].includes(providerChecks.state)) state = "failure"; + else if (["pending", "running"].includes(providerChecks.state)) state = providerChecks.state; else state = "success"; - return { commit: forgeChecks.commit, state, total: statuses.length, statuses }; + return { commit: providerChecks.commit, state, total: statuses.length, statuses }; } function cycleId(repositoryId, owner, repo, number) { @@ -517,7 +517,7 @@ function cycleId(repositoryId, owner, repo, number) { } function cyclePath(repositoryId, owner, repo, number) { - return `cycles/forgejo-${number}-${createHash("sha256").update(`${repositoryId}\0${owner}\0${repo}`).digest("hex").slice(0, 16)}.json`; + return `cycles/github-${number}-${createHash("sha256").update(`${repositoryId}\0${owner}\0${repo}`).digest("hex").slice(0, 16)}.json`; } function event(type, actor, at, detail) { @@ -550,7 +550,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`); + member(value.source, ["github", "agent"], `${path}.source`); 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`); diff --git a/scripts/providers/git-spice-operations.mjs b/scripts/providers/git-spice-operations.mjs index fcaceb5..36e7664 100644 --- a/scripts/providers/git-spice-operations.mjs +++ b/scripts/providers/git-spice-operations.mjs @@ -99,7 +99,7 @@ export class ApprovedGitSpiceOperations { return { ...receipt, receiptPath }; } catch (error) { const safeError = new StackOperationExecutionError(intent.operation, error, [ - this.#env.FORGEJO_TOKEN, + this.#env.GITHUB_TOKEN, intent.parameters.title, intent.parameters.body, ]); diff --git a/scripts/providers/forgejo-provider.mjs b/scripts/providers/github-provider.mjs similarity index 57% rename from scripts/providers/forgejo-provider.mjs rename to scripts/providers/github-provider.mjs index 1ffdbeb..200a611 100644 --- a/scripts/providers/forgejo-provider.mjs +++ b/scripts/providers/github-provider.mjs @@ -1,20 +1,23 @@ -import { ForgeProvider } from "../lib/forge-provider.mjs"; +import { ChangeRequestProvider } from "../lib/change-request-provider.mjs"; +const DEFAULT_BASE_URL = "https://api.github.com"; const DEFAULT_TIMEOUT_MS = 30_000; -const DEFAULT_PAGE_LIMIT = 50; -const MAX_PAGES = 20; +const DEFAULT_PAGE_LIMIT = 100; +const MAX_PAGES = 10; -export class ForgejoProvider extends ForgeProvider { +export class GitHubProvider extends ChangeRequestProvider { #token; - constructor({ baseUrl, token, timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = globalThis.fetch }) { + constructor({ baseUrl = DEFAULT_BASE_URL, token, timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = globalThis.fetch }) { super(); requiredString(baseUrl, "baseUrl"); requiredString(token, "token"); if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) throw new TypeError("timeoutMs must be a positive integer."); if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be a function."); const parsed = new URL(baseUrl); - if (!["http:", "https:"].includes(parsed.protocol)) throw new TypeError("baseUrl must use HTTP or HTTPS."); + if (parsed.protocol !== "https:" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") { + throw new TypeError("baseUrl must use HTTPS unless it targets localhost."); + } 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(/\/+$/, ""); @@ -24,15 +27,8 @@ export class ForgejoProvider extends ForgeProvider { this.fetchImpl = fetchImpl; } - async version() { - const value = await this.#request("/api/v1/version"); - requiredString(value?.version, "Forgejo version"); - return value.version; - } - async repository({ owner, repo }) { - const value = await this.#request(repoPath(owner, repo)); - return normalizeRepository(value); + return normalizeRepository(await this.#request(repoPath(owner, repo))); } async listChangeRequests({ owner, repo, state = "open" }) { @@ -52,15 +48,10 @@ export class ForgejoProvider extends ForgeProvider { return values.map(normalizeReview); } - async listReviewComments({ owner, repo, number, reviews = null }) { - const reviewList = reviews ?? await this.listReviews({ owner, repo, number }); - if (!Array.isArray(reviewList)) throw new TypeError("reviews must be an array when provided."); - const comments = []; - for (const review of reviewList) { - const values = await this.#paginate(`${repoPath(owner, repo)}/pulls/${number}/reviews/${review.id}/comments`); - comments.push(...values.map((value) => normalizeReviewComment(value, review.id))); - } - return comments.sort(compareByCreatedAtThenId); + async listReviewComments({ owner, repo, number }) { + positiveInteger(number, "number"); + const values = await this.#paginate(`${repoPath(owner, repo)}/pulls/${number}/comments`); + return values.map(normalizeReviewComment).sort(compareByCreatedAtThenId); } async listIssueComments({ owner, repo, number }) { @@ -71,21 +62,34 @@ export class ForgejoProvider extends ForgeProvider { async commitStatus({ owner, repo, commit }) { requiredString(commit, "commit"); - return normalizeCommitStatus( - await this.#request(`${repoPath(owner, repo)}/commits/${encodeURIComponent(commit)}/status`), - commit, - ); + const [combinedStatus, checkRuns] = await Promise.all([ + this.#request(`${repoPath(owner, repo)}/commits/${encodeURIComponent(commit)}/status`), + this.#paginateObject(`${repoPath(owner, repo)}/commits/${encodeURIComponent(commit)}/check-runs`, "check_runs"), + ]); + return normalizeCommitStatus(combinedStatus, checkRuns, commit); } async #paginate(path, query = {}) { const values = []; for (let page = 1; page <= MAX_PAGES; page += 1) { - const batch = await this.#request(path, { ...query, page, limit: DEFAULT_PAGE_LIMIT }); - if (!Array.isArray(batch)) throw new ForgejoResponseError(`${path} must return an array.`); + const batch = await this.#request(path, { ...query, page, per_page: DEFAULT_PAGE_LIMIT }); + if (!Array.isArray(batch)) throw new GitHubResponseError(`${path} must return an array.`); + values.push(...batch); + if (batch.length < DEFAULT_PAGE_LIMIT) return values; + } + throw new GitHubResponseError(`${path} exceeded the ${MAX_PAGES}-page safety limit.`); + } + + async #paginateObject(path, key) { + const values = []; + for (let page = 1; page <= MAX_PAGES; page += 1) { + const response = await this.#request(path, { page, per_page: DEFAULT_PAGE_LIMIT }); + const batch = response?.[key]; + if (!Array.isArray(batch)) throw new GitHubResponseError(`${path}.${key} must be an array.`); values.push(...batch); if (batch.length < DEFAULT_PAGE_LIMIT) return values; } - throw new ForgejoResponseError(`${path} exceeded the ${MAX_PAGES}-page safety limit.`); + throw new GitHubResponseError(`${path}.${key} exceeded the ${MAX_PAGES}-page safety limit.`); } async #request(path, query = {}) { @@ -97,14 +101,15 @@ export class ForgejoProvider extends ForgeProvider { const response = await this.fetchImpl(url, { method: "GET", headers: { - Accept: "application/json", - Authorization: `token ${this.#token}`, + Accept: "application/vnd.github+json", + Authorization: `Bearer ${this.#token}`, + "X-GitHub-Api-Version": "2022-11-28", }, signal: controller.signal, }); const body = await response.text(); if (!response.ok) { - throw new ForgejoHttpError({ + throw new GitHubHttpError({ status: response.status, method: "GET", url: url.toString(), @@ -115,10 +120,10 @@ export class ForgejoProvider extends ForgeProvider { try { return JSON.parse(body); } catch { - throw new ForgejoResponseError(`GET ${url} returned invalid JSON.`); + throw new GitHubResponseError(`GET ${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 GitHubResponseError(`GET ${url} timed out after ${this.timeoutMs}ms.`); throw error; } finally { clearTimeout(timeout); @@ -126,10 +131,10 @@ export class ForgejoProvider extends ForgeProvider { } } -export class ForgejoHttpError extends Error { +export class GitHubHttpError extends Error { constructor({ status, method, url, body }) { super(`${method} ${url} failed with HTTP ${status}${body ? `: ${body.slice(0, 500)}` : "."}`); - this.name = "ForgejoHttpError"; + this.name = "GitHubHttpError"; this.status = status; this.method = method; this.url = url; @@ -137,10 +142,10 @@ export class ForgejoHttpError extends Error { } } -export class ForgejoResponseError extends Error { +class GitHubResponseError extends Error { constructor(message) { super(message); - this.name = "ForgejoResponseError"; + this.name = "GitHubResponseError"; } } @@ -159,7 +164,7 @@ function normalizeRepository(value) { } function normalizeChangeRequest(value) { - const merged = value?.merged === true; + const merged = value?.merged === true || value?.merged_at !== null && value?.merged_at !== undefined; return { id: String(requiredInteger(value?.id, "changeRequest.id")), number: requiredInteger(value?.number, "changeRequest.number"), @@ -184,30 +189,32 @@ function normalizeBranch(value, path) { } function normalizeReview(value) { + const state = requiredValue(value?.state, "review.state").toLowerCase(); + const submittedAt = nullableDate(value?.submitted_at, "review.submitted_at"); return { id: String(requiredInteger(value?.id, "review.id")), - state: requiredValue(value?.state, "review.state").toLowerCase(), + state, body: typeof value?.body === "string" ? value.body : "", commit: nullableString(value?.commit_id), - dismissed: value?.dismissed === true, - stale: value?.stale === true, + dismissed: state === "dismissed", + stale: false, author: nullableString(value?.user?.login), - submittedAt: nullableDate(value?.submitted_at, "review.submitted_at"), - updatedAt: nullableDate(value?.updated_at, "review.updated_at"), + submittedAt, + updatedAt: submittedAt, webUrl: nullableHttpUrl(value?.html_url, "review.html_url"), }; } -function normalizeReviewComment(value, reviewId) { +function normalizeReviewComment(value) { return { id: String(requiredInteger(value?.id, "reviewComment.id")), - reviewId, + reviewId: String(requiredInteger(value?.pull_request_review_id, "reviewComment.pull_request_review_id")), body: typeof value?.body === "string" ? value.body : "", path: nullableString(value?.path), - line: Number.isInteger(value?.position) ? value.position : null, - commit: nullableString(value?.commit_id), + line: firstInteger(value?.line, value?.original_line, value?.position), + commit: nullableString(value?.commit_id ?? value?.original_commit_id), author: nullableString(value?.user?.login), - resolvedBy: nullableString(value?.resolver?.login), + resolvedBy: null, createdAt: requiredDate(value?.created_at, "reviewComment.created_at"), updatedAt: requiredDate(value?.updated_at, "reviewComment.updated_at"), webUrl: nullableHttpUrl(value?.html_url, "reviewComment.html_url"), @@ -225,28 +232,55 @@ function normalizeIssueComment(value) { }; } -function normalizeCommitStatus(value, requestedCommit) { - const statuses = Array.isArray(value?.statuses) ? value.statuses : []; +function normalizeCommitStatus(value, checkRuns, requestedCommit) { + const statuses = Array.isArray(value?.statuses) ? value.statuses.map(normalizeStatus) : []; + const checks = checkRuns.map(normalizeCheckRun); + const combined = [...statuses, ...checks]; + let state = combined.length === 0 ? "none" : "success"; + if (combined.some((status) => ["error", "failure", "failed"].includes(status.state))) state = "failure"; + else if (combined.some((status) => ["pending", "queued", "running", "in_progress"].includes(status.state))) state = "pending"; return { commit: nullableString(value?.sha) ?? requestedCommit, - state: nullableString(value?.state)?.toLowerCase() ?? "none", - total: Number.isInteger(value?.total_count) ? value.total_count : statuses.length, - statuses: statuses.map((status) => ({ - id: String(requiredInteger(status?.id, "commitStatus.status.id")), - context: requiredValue(status?.context, "commitStatus.status.context"), - state: requiredValue(status?.status, "commitStatus.status.status").toLowerCase(), - description: nullableString(status?.description), - targetUrl: nullableHttpUrl(status?.target_url, "commitStatus.status.target_url"), - createdAt: nullableDate(status?.created_at, "commitStatus.status.created_at"), - updatedAt: nullableDate(status?.updated_at, "commitStatus.status.updated_at"), - })), + state, + total: combined.length, + statuses: combined, + }; +} + +function normalizeStatus(value) { + return { + id: `status:${requiredInteger(value?.id, "commitStatus.status.id")}`, + context: requiredValue(value?.context, "commitStatus.status.context"), + state: requiredValue(value?.state, "commitStatus.status.state").toLowerCase(), + description: nullableString(value?.description), + targetUrl: nullableHttpUrl(value?.target_url, "commitStatus.status.target_url"), + createdAt: nullableDate(value?.created_at, "commitStatus.status.created_at"), + updatedAt: nullableDate(value?.updated_at, "commitStatus.status.updated_at"), + }; +} + +function normalizeCheckRun(value) { + const status = requiredValue(value?.status, "checkRun.status").toLowerCase(); + const conclusion = nullableString(value?.conclusion)?.toLowerCase() ?? null; + let state = "pending"; + if (status === "completed") { + state = ["success", "neutral", "skipped"].includes(conclusion) ? "success" : "failure"; + } + return { + id: `check-run:${requiredInteger(value?.id, "checkRun.id")}`, + context: requiredValue(value?.name, "checkRun.name"), + state, + description: nullableString(value?.output?.title) ?? conclusion ?? status, + targetUrl: nullableHttpUrl(value?.details_url ?? value?.html_url, "checkRun.details_url"), + createdAt: nullableDate(value?.started_at, "checkRun.started_at"), + updatedAt: nullableDate(value?.completed_at ?? value?.started_at, "checkRun.completed_at"), }; } function repoPath(owner, repo) { requiredString(owner, "owner"); requiredString(repo, "repo"); - return `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; } function compareByCreatedAtThenId(left, right) { @@ -257,6 +291,10 @@ function redact(value, token) { return String(value).split(token).join("[REDACTED]"); } +function firstInteger(...values) { + return values.find((value) => Number.isInteger(value)) ?? null; +} + function requiredString(value, path) { if (typeof value !== "string" || value.trim() === "") throw new TypeError(`${path} must be a non-empty string.`); } @@ -267,7 +305,7 @@ function requiredValue(value, path) { } function requiredInteger(value, path) { - if (!Number.isInteger(value) || value < 0) throw new ForgejoResponseError(`${path} must be a non-negative integer.`); + if (!Number.isInteger(value) || value < 0) throw new GitHubResponseError(`${path} must be a non-negative integer.`); return value; } @@ -276,7 +314,7 @@ function positiveInteger(value, path) { } function requiredEnum(value, values, path) { - if (!values.includes(value)) throw new ForgejoResponseError(`${path} must be ${values.join(" or ")}.`); + if (!values.includes(value)) throw new GitHubResponseError(`${path} must be ${values.join(" or ")}.`); return value; } @@ -286,7 +324,7 @@ function nullableString(value) { function requiredDate(value, path) { requiredString(value, path); - if (Number.isNaN(Date.parse(value))) throw new ForgejoResponseError(`${path} must be an ISO date-time.`); + if (Number.isNaN(Date.parse(value))) throw new GitHubResponseError(`${path} must be an ISO date-time.`); return value; } @@ -297,7 +335,7 @@ function nullableDate(value, path) { function requiredHttpUrl(value, path) { const normalized = nullableHttpUrl(value, path); - if (normalized === null) throw new ForgejoResponseError(`${path} must be an absolute HTTP URL.`); + if (normalized === null) throw new GitHubResponseError(`${path} must be an absolute HTTP URL.`); return normalized; } @@ -307,8 +345,8 @@ function nullableHttpUrl(value, path) { try { parsed = new URL(value); } catch { - throw new ForgejoResponseError(`${path} must be an absolute HTTP URL.`); + throw new GitHubResponseError(`${path} must be an absolute HTTP URL.`); } - if (!["http:", "https:"].includes(parsed.protocol)) throw new ForgejoResponseError(`${path} must be an absolute HTTP URL.`); + if (!["http:", "https:"].includes(parsed.protocol)) throw new GitHubResponseError(`${path} must be an absolute HTTP URL.`); return value; } diff --git a/scripts/tabellio-forge.mjs b/scripts/tabellio-forge.mjs deleted file mode 100755 index 2e87138..0000000 --- a/scripts/tabellio-forge.mjs +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env node - -import { readFile } from "node:fs/promises"; - -import { ForgejoProvider } from "./providers/forgejo-provider.mjs"; - -try { - const options = parseArgs(process.argv.slice(2)); - const token = await loadToken(options.tokenFile); - const provider = new ForgejoProvider({ - baseUrl: options.baseUrl, - token, - timeoutMs: options.timeoutMs, - }); - const target = { owner: options.owner, repo: options.repo }; - let result; - if (options.command === "version") result = { provider: "forgejo", version: await provider.version() }; - else if (options.command === "repository") result = await provider.repository(target); - else if (options.command === "pulls") result = await provider.listChangeRequests({ ...target, state: options.state }); - else if (options.command === "pull") result = await provider.changeRequest({ ...target, number: options.number }); - else if (options.command === "reviews") result = await provider.listReviews({ ...target, number: options.number }); - else if (options.command === "review-comments") result = await provider.listReviewComments({ ...target, number: options.number }); - else if (options.command === "issue-comments") result = await provider.listIssueComments({ ...target, number: options.number }); - else result = await provider.commitStatus({ ...target, commit: options.commit }); - console.log(JSON.stringify(result, null, 2)); -} catch (error) { - process.exitCode = 1; - console.error(JSON.stringify({ - ok: false, - error: error instanceof Error ? error.message : String(error), - name: error instanceof Error ? error.name : "Error", - }, null, 2)); -} -function parseArgs(args) { - const command = args[0]; - const commands = new Set([ - "version", - "repository", - "pulls", - "pull", - "reviews", - "review-comments", - "issue-comments", - "status", - ]); - if (!commands.has(command)) { - throw new Error("Expected command: version, repository, pulls, pull, reviews, review-comments, issue-comments, or status."); - } - const values = new Map(); - for (let index = 1; index < args.length; index += 2) { - const flag = args[index]; - const value = args[index + 1]; - if (!flag?.startsWith("--") || value === undefined) throw new Error(`Expected a value after ${flag ?? "command"}.`); - if (values.has(flag)) throw new Error(`Duplicate option: ${flag}.`); - values.set(flag, value); - } - 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 needsRepository = command !== "version"; - const owner = values.get("--owner") ?? process.env.TABELLIO_FORGE_OWNER; - const repo = values.get("--repo") ?? process.env.TABELLIO_FORGE_REPO; - if (needsRepository) { - requiredString(owner, "--owner or TABELLIO_FORGE_OWNER"); - requiredString(repo, "--repo or TABELLIO_FORGE_REPO"); - } - const number = values.has("--number") ? Number(values.get("--number")) : null; - if (["pull", "reviews", "review-comments", "issue-comments"].includes(command) && (!Number.isInteger(number) || number <= 0)) { - throw new Error(`${command} requires a positive --number.`); - } - const commit = values.get("--commit") ?? null; - if (command === "status") requiredString(commit, "--commit"); - const timeoutMs = values.has("--timeout-ms") ? Number(values.get("--timeout-ms")) : 30_000; - if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) throw new Error("--timeout-ms must be a positive integer."); - const state = values.get("--state") ?? "open"; - if (!["open", "closed", "all"].includes(state)) throw new Error("--state must be open, closed, or all."); - return { - command, - baseUrl, - owner, - repo, - tokenFile: values.get("--token-file") ?? process.env.TABELLIO_FORGE_TOKEN_FILE ?? null, - timeoutMs, - state, - number, - commit, - }; -} - -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"); - return token; -} - -function requiredString(value, path) { - if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`); -} diff --git a/scripts/tabellio-review.mjs b/scripts/tabellio-review.mjs index a090873..0380399 100755 --- a/scripts/tabellio-review.mjs +++ b/scripts/tabellio-review.mjs @@ -6,7 +6,7 @@ import { resolve } from "node:path"; import { GitJsonLedger } from "./lib/git-json-ledger.mjs"; import { repositoryIdentity } from "./lib/repository-identity.mjs"; import { ReviewCycleManager } from "./lib/review-cycle.mjs"; -import { ForgejoProvider } from "./providers/forgejo-provider.mjs"; +import { GitHubProvider } from "./providers/github-provider.mjs"; import { NativeGitStore } from "./providers/native-git-store.mjs"; try { @@ -23,9 +23,14 @@ try { }); let provider = null; if (options.command === "sync") { - const token = (await readFile(resolve(options.tokenFile), "utf8")).trim(); - if (!token) throw new Error("--token-file is empty."); - provider = new ForgejoProvider({ baseUrl: options.baseUrl, token }); + const token = options.tokenFile + ? (await readFile(resolve(options.tokenFile), "utf8")).trim() + : process.env.GITHUB_TOKEN?.trim(); + if (!token) throw new Error("--token-file or GITHUB_TOKEN is required for sync."); + provider = new GitHubProvider({ + baseUrl: options.apiUrl ?? process.env.GITHUB_API_URL, + token, + }); } const manager = new ReviewCycleManager({ store, @@ -34,7 +39,7 @@ try { provider, repositoryId, owner: options.owner, - repo: options.forgeRepo, + repo: options.remoteRepo, }); let result; if (options.command === "sync") { @@ -89,20 +94,19 @@ 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", "owner", "remoteRepo", "number", "actor", "ledgerRef", "validationLedgerRef"]; const allowed = { - sync: [...common, "baseUrl", "tokenFile"], + sync: [...common, "apiUrl", "tokenFile"], status: common, triage: [...common, "feedbackId", "disposition", "reason"], fix: [...common, "feedbackIds", "commit", "checkpoint", "summary"], import: [...common, "input"], }[command]; for (const key of Object.keys(values)) if (!allowed.includes(key)) throw new Error(`Unsupported option: --${key}.`); - for (const key of ["owner", "forgeRepo", "number"]) if (!values[key]) throw new Error(`--${key} is required.`); + for (const key of ["owner", "remoteRepo", "number"]) if (!values[key]) throw new Error(`--${key} is required.`); const number = Number(values.number); if (!Number.isInteger(number) || number <= 0) throw new Error("--number must be a positive integer."); const actor = values.actor ?? process.env.USER ?? "local-agent"; - if (command === "sync") for (const key of ["baseUrl", "tokenFile"]) if (!values[key]) throw new Error(`--${key} is required for sync.`); if (command === "triage") for (const key of ["feedbackId", "disposition", "reason"]) if (!values[key]) throw new Error(`--${key} is required for triage.`); if (command === "fix") for (const key of ["feedbackIds", "commit", "checkpoint", "summary"]) if (!values[key]) throw new Error(`--${key} is required for fix.`); if (command === "import" && !values.input) throw new Error("--input is required for import."); diff --git a/scripts/tabellio-stack-operation.mjs b/scripts/tabellio-stack-operation.mjs index f96a635..0fd1e09 100755 --- a/scripts/tabellio-stack-operation.mjs +++ b/scripts/tabellio-stack-operation.mjs @@ -62,8 +62,8 @@ async function execute(options) { const env = {}; if (options.tokenFile) { const tokenFile = resolve(options.tokenFile); - env.FORGEJO_TOKEN = (await readFile(tokenFile, "utf8")).trim(); - if (!env.FORGEJO_TOKEN) throw new Error("--token-file is empty."); + env.GITHUB_TOKEN = (await readFile(tokenFile, "utf8")).trim(); + if (!env.GITHUB_TOKEN) throw new Error("--token-file is empty."); if (options.gitUsername) { env.GIT_ASKPASS = askpassPath; env.GIT_ASKPASS_REQUIRE = "force"; diff --git a/tests/git-spice-operations.test.mjs b/tests/git-spice-operations.test.mjs index 18f1db4..8795bff 100644 --- a/tests/git-spice-operations.test.mjs +++ b/tests/git-spice-operations.test.mjs @@ -35,7 +35,7 @@ test("approved git-spice operations execute exact safe argument sets and consume repoPath: fixture.seed, stateRoot: join(toolRoot, "receipts"), binary, - env: { FORGEJO_TOKEN: "secret-token", CAPTURE_PATH: capturePath }, + env: { GITHUB_TOKEN: "secret-token", CAPTURE_PATH: capturePath }, }); assert.ok(!JSON.stringify(operations).includes("secret-token")); const refsDigest = await repositoryRefsDigest(fixture.seed); @@ -83,7 +83,7 @@ test("approved git-spice operations execute exact safe argument sets and consume assert.equal(result.status, "succeeded"); const captured = JSON.parse(await readFile(capturePath, "utf8")); assert.deepEqual(captured.args, item.expected); - assert.equal(captured.hasForgejoToken, true); + assert.equal(captured.hasGitHubToken, true); assert.equal(captured.hasTargetLock, true); assert.deepEqual(operationArgs(intent), item.expected); await assert.rejects( @@ -162,7 +162,7 @@ test("failed execution consumes approval and stores only a safe error", async (t repoPath: fixture.seed, stateRoot, binary, - env: { FORGEJO_TOKEN: "secret-token" }, + env: { GITHUB_TOKEN: "secret-token" }, }); const refsDigest = await repositoryRefsDigest(fixture.seed); const intent = createStackOperationIntent({ @@ -211,7 +211,7 @@ import { execFileSync } from "node:child_process"; const capture = process.env.CAPTURE_PATH; let hasTargetLock = false; try { execFileSync("git", ["rev-parse", "--verify", "refs/tabellio/locks/stack-write-operation"], { stdio: "ignore" }); hasTargetLock = true; } catch {} -if (capture) writeFileSync(capture, JSON.stringify({ args: process.argv.slice(2), hasForgejoToken: Boolean(process.env.FORGEJO_TOKEN), hasTargetLock })); +if (capture) writeFileSync(capture, JSON.stringify({ args: process.argv.slice(2), hasGitHubToken: Boolean(process.env.GITHUB_TOKEN), hasTargetLock })); ${fail ? 'process.stderr.write("simulated failure private review context secret-token\\n"); process.exit(2);' : "process.exit(0);"} `; await writeFile(binary, source); diff --git a/tests/git-spice-stack-manager.test.mjs b/tests/git-spice-stack-manager.test.mjs index 99470ff..bbb5c58 100644 --- a/tests/git-spice-stack-manager.test.mjs +++ b/tests/git-spice-stack-manager.test.mjs @@ -29,7 +29,7 @@ test("git-spice adapter produces provider-neutral stack snapshots", async (t) => down: { name: "main" }, ups: [{ name: "feature-2" }], worktree: "/private/worktree/path", - change: { id: "#12", url: "https://forgejo.example.test/org/repo/pulls/12" }, + change: { id: "#12", url: "https://github.com/org/repo/pull/12" }, push: { ahead: 1, behind: 0, needsPush: true }, }, ]); diff --git a/tests/forgejo-provider.test.mjs b/tests/github-provider.test.mjs similarity index 53% rename from tests/forgejo-provider.test.mjs rename to tests/github-provider.test.mjs index 38039f0..f6692f2 100644 --- a/tests/forgejo-provider.test.mjs +++ b/tests/github-provider.test.mjs @@ -3,33 +3,38 @@ import { createServer } from "node:http"; import test from "node:test"; import { - ForgejoHttpError, - ForgejoProvider, -} from "../scripts/providers/forgejo-provider.mjs"; + GitHubHttpError, + GitHubProvider, +} from "../scripts/providers/github-provider.mjs"; const timestamp = "2026-07-10T20:00:00Z"; const commit = "a".repeat(40); -test("Forgejo provider normalizes repositories, change requests, reviews, comments, and checks", async (t) => { +test("GitHub provider normalizes repositories, pull requests, reviews, comments, statuses, and checks", async (t) => { const requests = []; const fixture = await startServer((request, response) => { const url = new URL(request.url, "http://localhost"); - requests.push({ path: url.pathname, query: Object.fromEntries(url.searchParams), authorization: request.headers.authorization }); - if (url.pathname === "/api/v1/version") return json(response, { version: "15.0.3+gitea-1.22.0" }); - if (url.pathname === "/api/v1/repos/acme/project") return json(response, repository()); - if (url.pathname === "/api/v1/repos/acme/project/pulls") return json(response, [pullRequest()]); - if (url.pathname === "/api/v1/repos/acme/project/pulls/7") return json(response, pullRequest()); - if (url.pathname === "/api/v1/repos/acme/project/pulls/7/reviews") return json(response, [review()]); - if (url.pathname === "/api/v1/repos/acme/project/pulls/7/reviews/31/comments") return json(response, [reviewComment()]); - if (url.pathname === "/api/v1/repos/acme/project/issues/7/comments") return json(response, [issueComment()]); - if (url.pathname === `/api/v1/repos/acme/project/commits/${commit}/status`) return json(response, combinedStatus()); + const baseUrl = `http://${request.headers.host}`; + requests.push({ + path: url.pathname, + query: Object.fromEntries(url.searchParams), + authorization: request.headers.authorization, + accept: request.headers.accept, + }); + if (url.pathname === "/repos/acme/project") return json(response, repository(baseUrl)); + if (url.pathname === "/repos/acme/project/pulls") return json(response, [pullRequest(baseUrl)]); + if (url.pathname === "/repos/acme/project/pulls/7") return json(response, pullRequest(baseUrl)); + if (url.pathname === "/repos/acme/project/pulls/7/reviews") return json(response, [review(baseUrl)]); + if (url.pathname === "/repos/acme/project/pulls/7/comments") return json(response, [reviewComment(baseUrl)]); + if (url.pathname === "/repos/acme/project/issues/7/comments") return json(response, [issueComment(baseUrl)]); + if (url.pathname === `/repos/acme/project/commits/${commit}/status`) return json(response, combinedStatus(baseUrl)); + if (url.pathname === `/repos/acme/project/commits/${commit}/check-runs`) return json(response, checkRuns(baseUrl)); return json(response, { message: "not found" }, 404); }); t.after(fixture.close); - const provider = new ForgejoProvider({ baseUrl: fixture.baseUrl, token: "secret-token" }); + const provider = new GitHubProvider({ baseUrl: fixture.baseUrl, token: "secret-token" }); assert.ok(!JSON.stringify(provider).includes("secret-token")); - assert.equal(await provider.version(), "15.0.3+gitea-1.22.0"); assert.deepEqual(await provider.repository({ owner: "acme", repo: "project" }), { id: "11", owner: "acme", @@ -53,7 +58,7 @@ test("Forgejo provider normalizes repositories, change requests, reviews, commen source: { branch: "agent/change", commit }, target: { branch: "main", commit: "b".repeat(40) }, author: "agent", - webUrl: `${fixture.baseUrl}/acme/project/pulls/7`, + webUrl: `${fixture.baseUrl}/acme/project/pull/7`, createdAt: timestamp, updatedAt: timestamp, }); @@ -68,50 +73,59 @@ test("Forgejo provider normalizes repositories, change requests, reviews, commen author: "reviewer", submittedAt: timestamp, updatedAt: timestamp, - webUrl: `${fixture.baseUrl}/acme/project/pulls/7#issuecomment-31`, + webUrl: `${fixture.baseUrl}/acme/project/pull/7#pullrequestreview-31`, }]); assert.equal((await provider.listReviewComments({ owner: "acme", repo: "project", number: 7 }))[0].path, "src/index.js"); assert.equal((await provider.listIssueComments({ owner: "acme", repo: "project", number: 7 }))[0].body, "Run the checks."); assert.deepEqual(await provider.commitStatus({ owner: "acme", repo: "project", commit }), { commit, state: "success", - total: 1, + total: 2, statuses: [{ - id: "51", + id: "status:51", context: "tests", state: "success", - description: "All checks passed", + description: "All statuses passed", targetUrl: `${fixture.baseUrl}/checks/51`, createdAt: timestamp, updatedAt: timestamp, + }, { + id: "check-run:61", + context: "Tabellio Evidence", + state: "success", + description: "Evidence verified", + targetUrl: `${fixture.baseUrl}/checks/61`, + createdAt: timestamp, + updatedAt: timestamp, }], }); - assert.ok(requests.every((request) => request.authorization === "token secret-token")); - assert.equal(requests.find((request) => request.path.endsWith("/pulls")).query.limit, "50"); + assert.ok(requests.every((request) => request.authorization === "Bearer secret-token")); + assert.ok(requests.every((request) => request.accept === "application/vnd.github+json")); + assert.equal(requests.find((request) => request.path.endsWith("/pulls")).query.per_page, "100"); }); -test("Forgejo provider rejects credential-bearing URLs and redacts tokens from failures", async (t) => { +test("GitHub provider enforces safe API URLs and redacts tokens from failures", async (t) => { assert.throws( - () => new ForgejoProvider({ baseUrl: "https://user:password@example.com", token: "token" }), + () => new GitHubProvider({ baseUrl: "https://user:password@example.com", token: "token" }), /must not contain credentials/, ); assert.throws( - () => new ForgejoProvider({ baseUrl: "https://example.com?token=secret", token: "token" }), - /must not contain a query or fragment/, + () => new GitHubProvider({ baseUrl: "http://example.com", token: "token" }), + /must use HTTPS/, ); const fixture = await startServer((_request, response) => json(response, { message: "bad secret-token" }, 500)); t.after(fixture.close); - const provider = new ForgejoProvider({ baseUrl: fixture.baseUrl, token: "secret-token" }); + const provider = new GitHubProvider({ baseUrl: fixture.baseUrl, token: "secret-token" }); await assert.rejects( - () => provider.version(), - (error) => error instanceof ForgejoHttpError + () => provider.repository({ owner: "acme", repo: "project" }), + (error) => error instanceof GitHubHttpError && error.status === 500 && !error.message.includes("secret-token") && error.message.includes("[REDACTED]"), ); }); -function repository() { +function repository(baseUrl) { return { id: 11, owner: { login: "acme" }, @@ -120,71 +134,69 @@ function repository() { private: true, archived: false, default_branch: "main", - html_url: "__BASE__/acme/project", - clone_url: "__BASE__/acme/project.git", + html_url: `${baseUrl}/acme/project`, + clone_url: `${baseUrl}/acme/project.git`, }; } -function pullRequest() { +function pullRequest(baseUrl) { return { id: 21, number: 7, title: "Agent change", state: "open", + merged: false, + merged_at: null, draft: false, mergeable: true, head: { ref: "agent/change", sha: commit }, base: { ref: "main", sha: "b".repeat(40) }, user: { login: "agent" }, - html_url: "__BASE__/acme/project/pulls/7", + html_url: `${baseUrl}/acme/project/pull/7`, created_at: timestamp, updated_at: timestamp, }; } -function review() { +function review(baseUrl) { return { id: 31, state: "APPROVED", body: "Looks good.", commit_id: commit, - dismissed: false, - stale: false, user: { login: "reviewer" }, submitted_at: timestamp, - updated_at: timestamp, - html_url: "__BASE__/acme/project/pulls/7#issuecomment-31", + html_url: `${baseUrl}/acme/project/pull/7#pullrequestreview-31`, }; } -function reviewComment() { +function reviewComment(baseUrl) { return { id: 41, pull_request_review_id: 31, body: "Prefer a constant.", path: "src/index.js", - position: 4, + line: 4, commit_id: commit, user: { login: "reviewer" }, - resolver: null, created_at: timestamp, updated_at: timestamp, - html_url: "__BASE__/acme/project/pulls/7#issuecomment-41", + html_url: `${baseUrl}/acme/project/pull/7#discussion_r41`, }; } -function issueComment() { +function issueComment(baseUrl) { return { id: 42, body: "Run the checks.", user: { login: "reviewer" }, created_at: timestamp, updated_at: timestamp, - html_url: "__BASE__/acme/project/pulls/7#issuecomment-42", + html_url: `${baseUrl}/acme/project/pull/7#issuecomment-42`, }; } -function combinedStatus() { +function combinedStatus(baseUrl) { return { sha: commit, state: "success", @@ -192,27 +204,40 @@ function combinedStatus() { statuses: [{ id: 51, context: "tests", - status: "success", - description: "All checks passed", - target_url: "__BASE__/checks/51", + state: "success", + description: "All statuses passed", + target_url: `${baseUrl}/checks/51`, created_at: timestamp, updated_at: timestamp, }], }; } +function checkRuns(baseUrl) { + return { + total_count: 1, + check_runs: [{ + id: 61, + name: "Tabellio Evidence", + status: "completed", + conclusion: "success", + output: { title: "Evidence verified" }, + details_url: `${baseUrl}/checks/61`, + started_at: timestamp, + completed_at: timestamp, + }], + }; +} + async function startServer(handler) { - let baseUrl; - const server = createServer((request, response) => { - const originalEnd = response.end.bind(response); - response.end = (chunk, ...args) => originalEnd(typeof chunk === "string" ? chunk.replaceAll("__BASE__", baseUrl) : chunk, ...args); - handler(request, response); + const server = createServer(handler); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); - baseUrl = `http://127.0.0.1:${address.port}`; return { - baseUrl, + baseUrl: `http://127.0.0.1:${address.port}`, close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())), }; } diff --git a/tests/platform-config.test.mjs b/tests/platform-config.test.mjs index 79b90dc..74872eb 100644 --- a/tests/platform-config.test.mjs +++ b/tests/platform-config.test.mjs @@ -26,7 +26,7 @@ test("platform v0.3 makes GitHub code-only storage and external control state ex test("platform v0.3 rejects provider drift and private-state publication", async () => { const config = JSON.parse(await readFile(`${projectRoot}/tabellio.platform.json`, "utf8")); assert.throws( - () => validatePlatformConfig({ ...config, codeStorage: { ...config.codeStorage, provider: "forgejo" } }), + () => validatePlatformConfig({ ...config, codeStorage: { ...config.codeStorage, provider: "gitlab" } }), /platform.codeStorage.provider must be "github"/, ); assert.throws( diff --git a/tests/review-cycle.test.mjs b/tests/review-cycle.test.mjs index b734c7f..09ce1b3 100644 --- a/tests/review-cycle.test.mjs +++ b/tests/review-cycle.test.mjs @@ -15,7 +15,7 @@ import { createFixture, identityEnv } from "./helpers/git-fixture.mjs"; const timestamp = "2026-07-10T20:00:00.000Z"; -test("review cycle persists forge and agent feedback through triage and checkpoint-bound fixes", async (t) => { +test("review cycle persists GitHub and agent feedback through triage and checkpoint-bound fixes", async (t) => { const fixture = await createFixture(); t.after(() => rm(fixture.root, { recursive: true, force: true })); const store = await NativeGitStore.open(fixture.seed); @@ -218,7 +218,7 @@ function fakeProvider(fixture) { source: { branch: "feature", commit: headCommit }, target: { branch: "main", commit: fixture.mainCommit }, author: "agent", - webUrl: "https://forgejo.example.test/acme/project/pulls/7", + webUrl: "https://github.com/acme/project/pull/7", createdAt: timestamp, updatedAt: timestamp, }; @@ -234,7 +234,7 @@ function fakeProvider(fixture) { author: "reviewer", submittedAt: timestamp, updatedAt: timestamp, - webUrl: "https://forgejo.example.test/acme/project/pulls/7#review-31", + webUrl: "https://github.com/acme/project/pull/7#pullrequestreview-31", }]; }, async listReviewComments() { @@ -249,7 +249,7 @@ function fakeProvider(fixture) { resolvedBy: null, createdAt: timestamp, updatedAt: timestamp, - webUrl: "https://forgejo.example.test/acme/project/pulls/7#comment-41", + webUrl: "https://github.com/acme/project/pull/7#discussion_r41", }]; }, async listIssueComments() { @@ -259,7 +259,7 @@ function fakeProvider(fixture) { author: "reviewer", createdAt: timestamp, updatedAt: timestamp, - webUrl: "https://forgejo.example.test/acme/project/pulls/7#comment-42", + webUrl: "https://github.com/acme/project/pull/7#issuecomment-42", }]; }, async commitStatus() { @@ -273,7 +273,7 @@ function fakeProvider(fixture) { context: "tests", state: "failure", description: "Tests failed", - targetUrl: "https://forgejo.example.test/checks/51", + targetUrl: "https://github.com/acme/project/actions/runs/51", createdAt: timestamp, updatedAt: timestamp, }] : [{ @@ -281,7 +281,7 @@ function fakeProvider(fixture) { context: "tests", state: "success", description: "Tests passed", - targetUrl: "https://forgejo.example.test/checks/52", + targetUrl: "https://github.com/acme/project/actions/runs/52", createdAt: timestamp, updatedAt: timestamp, }], @@ -303,7 +303,7 @@ function emptyProvider(fixture) { source: { branch: "feature", commit: fixture.featureCommit }, target: { branch: "main", commit: fixture.mainCommit }, author: "agent", - webUrl: "https://forgejo.example.test/acme/project/pulls/7", + webUrl: "https://github.com/acme/project/pull/7", createdAt: timestamp, updatedAt: timestamp, }; From 383b1bac35ddd6a8130b72833bc59fde29d0938a Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Tue, 14 Jul 2026 22:34:54 -0700 Subject: [PATCH 3/6] Resolve Fallow findings and enforce GitHub-only workflow --- CHANGELOG.md | 14 +- README.md | 14 +- docs/codex-review.md | 2 +- docs/getting-started.md | 8 +- docs/github-code-storage-boundary.md | 14 +- docs/operations-hardening.md | 6 +- docs/research-grounding.md | 8 +- docs/review-loop.md | 6 +- docs/tooling-stack.md | 12 +- docs/validation-runner.md | 2 +- docs/workflow-model.md | 10 +- .../tabellio-context/minimal-context.json | 4 +- examples/tabellio-ledger/minimal-ledger.json | 2 +- package.json | 2 +- schemas/platform.schema.json | 4 +- scripts/check-tabellio-platform.mjs | 2 + scripts/lib/change-request-provider.mjs | 29 -- scripts/lib/cli-options.mjs | 63 +++ scripts/lib/platform-config.mjs | 2 + scripts/lib/review-cycle.mjs | 2 +- scripts/lib/snapshot-command.mjs | 10 + scripts/providers/github-provider.mjs | 417 ++++++++++++------ scripts/tabellio-ledger.mjs | 64 ++- scripts/tabellio-review.mjs | 191 ++++---- scripts/tabellio-run.mjs | 8 +- scripts/tabellio-stack-operation.mjs | 8 +- scripts/tabellio-stack.mjs | 56 +-- scripts/tabellio-validate.mjs | 8 +- tabellio.platform.json | 2 + tests/context-and-evidence.test.mjs | 2 +- tests/entire-ledger-provider.test.mjs | 21 +- tests/git-spice-operations.test.mjs | 64 +-- tests/git-spice-stack-manager.test.mjs | 44 +- tests/github-provider.test.mjs | 24 +- tests/platform-config.test.mjs | 10 +- 35 files changed, 638 insertions(+), 497 deletions(-) delete mode 100644 scripts/lib/change-request-provider.mjs create mode 100644 scripts/lib/cli-options.mjs create mode 100644 scripts/lib/snapshot-command.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 54bfdbc..82b5a83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,23 +6,23 @@ All notable changes to Tabellio are recorded here. ### Added -- Provider-neutral exact-commit validation runner with committed argv manifests and no shell execution. +- GitHub-bound 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. - Git-native JSON ledger on compare-and-swap refs for durable control-plane state. -- Durable review cycles covering GitHub feedback, provider-neutral agent findings, triage, checks, fixes, and readiness. +- Durable review cycles covering GitHub feedback, agent findings, triage, checks, fixes, and readiness. - Entire-checkpoint-bound fix records with commit remapping across git-spice restacks. - Approval-gated git-spice submit, update, sync, restack, and merge operations. - Integrity-bound operation intents, short-lived approvals, one-use receipts, and branch-set race checks. - File-backed GitHub API and Git HTTPS authentication without credentials in remote URLs or command arguments. - Read-only GitHub provider for repository, pull request, review, comment, commit-status, and check-run reads. - Mandatory-by-default Entire ledger provider with metadata-only checkpoint export and context binding. -- Provider-neutral `tabellio-ledger/v0.1` schema, CLI, validator, example, and tests. -- Read-only git-spice stack adapter with provider-neutral `tabellio-stack/v0.1` snapshots. +- GitHub workflow `tabellio-ledger/v0.1` schema, CLI, validator, example, and tests. +- Read-only git-spice stack adapter with GitHub workflow `tabellio-stack/v0.1` snapshots. - Stack snapshot schema, validator, CLI, example, and adapter tests. - Local agent-run CLI with start, checkpoint, finish, status, and safe promotion commands. - Mutable `tabellio-run/v0.1` local state contract and end-to-end lifecycle artifacts. -- Provider-neutral `RepositoryStore` contract and standard Git provider. +- `RepositoryStore` contract and standard Git implementation for the GitHub workflow. - Bare-repository, contained-worktree, Git-note, merge-preview, and compare-and-swap primitives. - Integrity-protected `tabellio-context/v0.1` packet and CLI capture/check commands. - Optional evidence-to-context commit binding. @@ -44,7 +44,7 @@ All notable changes to Tabellio are recorded here. ### Changed -- Replaced Graphite as the planned stacked-review integration with host-agnostic git-spice. +- Standardized stacked GitHub pull requests on git-spice. - Made GitHub `origin` the canonical code store and thin pull-request shell while keeping agent control state external. - Rejected publication or fetching of review, validation, and Entire control refs through the code-storage remote. @@ -62,7 +62,7 @@ Initial public release. - Minimal valid evidence fixture. - OpenSSF Scorecard workflow. - Research grounding, schema reference, and workflow model docs. -- Agentic tooling stack guide covering Code Storage, Entire, Graphite, GitHub, Codex, and Tabellio boundaries. +- Agentic tooling stack guide covering Entire, GitHub, Codex, and Tabellio boundaries. ### Release Gates diff --git a/README.md b/README.md index 37b7db0..646b8e1 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![Entire](https://img.shields.io/badge/Entire-checkpoint%20ledger-111827)](https://entire.io/) [![License](https://img.shields.io/badge/license-Apache--2.0-green)](LICENSE) -Provider-neutral Git context and evidence for agentic development. +GitHub-native 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. GitHub stores code and provides a thin pull-request shell. Tabellio keeps agent transcripts, review state, validation results, and control refs outside that public code-storage boundary. @@ -36,7 +36,7 @@ The native engine runs through the installed `git` executable. It never construc | Component | Role | | --- | --- | | `GitProcess` | Executes argument arrays with prompts disabled and typed failures | -| `RepositoryStore` | Provider-neutral repository contract | +| `RepositoryStore` | Repository contract for Tabellio's GitHub-backed workflow | | `NativeGitStore` | Reads commits and diffs, manages worktrees, previews merges, and updates refs safely | | `WorkspaceManager` | Gives each agent run a contained worktree path | | Context packet | Binds task, actor, exact commits, changed files, checkpoints, and merge status | @@ -61,10 +61,10 @@ AI-assisted pull requests should not depend on reviewer trust alone. Tabellio gi | 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 | | Code storage | [GitHub](https://github.com/) | Stores code refs and tags; provides a thin pull-request shell | -| Stacked review | [git-spice](https://abhinav.github.io/git-spice/) | Host-agnostic stack engine for small dependent change requests | +| Stacked review | [git-spice](https://abhinav.github.io/git-spice/) | Stack engine for small dependent GitHub pull 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 | +| Agent review | [OpenAI Codex](https://openai.com/codex/) | Produces findings imported into the durable GitHub 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 | `origin` is the canonical GitHub code remote. Entire is the required checkpoint ledger; git-spice manages stacks; Tabellio owns validation and durable review state. Private control refs are external state and are rejected when their destination is `origin`. @@ -101,7 +101,7 @@ npm run tabellio:platform:check node scripts/tabellio-validate.mjs run --repo . --commit HEAD --manifest tabellio.validation.json ``` -Keep `origin` limited to ordinary code branches and tags. Configure a separate authenticated Git remote before publishing control refs. The transport refuses to target `origin`. +Keep `origin` limited to ordinary code branches and tags. Configure a separate private GitHub repository under another remote name before publishing control refs. The transport refuses to target `origin`. Validate the bundled fixture: @@ -118,7 +118,7 @@ node scripts/check-tabellio-evidence-envelope.mjs --evidence tabellio-pr-evidenc node scripts/check-tabellio-external-actions.mjs --evidence tabellio-pr-evidence.json ``` -Capture provider-neutral context first, then bind evidence to it: +Capture GitHub-bound context first, then bind evidence to it: ```bash node scripts/capture-tabellio-context.mjs \ @@ -195,7 +195,7 @@ The external-action checker fails when an action is marked `attempted: true` wit - [Agent run lifecycle](docs/agent-run-lifecycle.md) - [Approved stack operations](docs/stack-operations.md) - [Durable review and fix loop](docs/review-loop.md) -- [Provider-neutral validation](docs/validation-runner.md) +- [Exact-commit validation](docs/validation-runner.md) - [Operations hardening](docs/operations-hardening.md) - [Workflow model](docs/workflow-model.md) - [Native Git foundation](docs/native-git-foundation.md) diff --git a/docs/codex-review.md b/docs/codex-review.md index 1883dd8..7edc9af 100644 --- a/docs/codex-review.md +++ b/docs/codex-review.md @@ -1,6 +1,6 @@ # Codex Review -Codex review is a provider-neutral review producer. It inspects the diff and evidence, then emits `tabellio-agent-review/v0.1` for the durable review cycle. It does not replace deterministic checks. +Codex review inspects the GitHub pull-request diff and evidence, then emits `tabellio-agent-review/v0.1` for the durable review cycle. It does not replace deterministic checks. ## Local Preflight diff --git a/docs/getting-started.md b/docs/getting-started.md index 003e03a..6d8a1f5 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,6 +1,6 @@ # Getting Started -Tabellio captures provider-neutral Git context and can attach a machine-readable evidence packet to pull requests. Humans, CI, and coding agents use the same contract. +Tabellio captures GitHub-bound context and can attach a machine-readable evidence packet to pull requests. Humans, CI, and coding agents use the same contract. ## Requirements @@ -37,7 +37,7 @@ The snapshot adapter uses documented JSON output and disables change-request sta ## Configure The Platform -`tabellio.platform.json` makes the operating model explicit: GitHub code storage, git-spice stacks, external Entire checkpoints, external validation, and external review state. +`tabellio.platform.json` makes the operating model explicit: GitHub code storage, git-spice stacks, and a private GitHub `control` remote for Entire checkpoints, validation, and review state. ```bash npm run tabellio:platform:check @@ -67,7 +67,7 @@ node scripts/tabellio-control-ref.mjs plan \ --out /tmp/control-ref-intent.json ``` -`TABELLIO_CONTROL_REMOTE` must name a separately configured external Git destination. It cannot be `origin`. Create a matching `tabellio-control-ref-approval/v0.1` document after reviewing the exact local and remote OIDs, then execute it once with `tabellio-control-ref.mjs execute`. Multi-ref publication is atomic. Non-fast-forward publication, divergence, changed refs, expired approvals, and reused approvals fail closed. +`TABELLIO_CONTROL_REMOTE` must name a separately configured private GitHub repository remote. It cannot be `origin`. Create a matching `tabellio-control-ref-approval/v0.1` document after reviewing the exact local and remote OIDs, then execute it once with `tabellio-control-ref.mjs execute`. Multi-ref publication is atomic. Non-fast-forward publication, divergence, changed refs, expired approvals, and reused approvals fail closed. ## Local Validation @@ -101,7 +101,7 @@ Add the Tabellio checklist to the repository PR template: - [ ] No protected side effect attempted without explicit approval ``` -The full provider-neutral template lives at `templates/pull_request_template.md`. +The full GitHub pull-request template lives at `templates/pull_request_template.md`. ## Protected Side Effects diff --git a/docs/github-code-storage-boundary.md b/docs/github-code-storage-boundary.md index 5fb2128..fa6d495 100644 --- a/docs/github-code-storage-boundary.md +++ b/docs/github-code-storage-boundary.md @@ -9,16 +9,16 @@ GitHub has one narrow job in the Tabellio platform: store code and expose a thin | `refs/heads/*` code branches | GitHub `origin` | Shared source history and pull-request heads | | `refs/tags/*` release tags | GitHub `origin` | Shared code release markers | | Pull-request title, description, checks summary, and review decision | GitHub | Minimum human accountability surface | -| Entire transcript and checkpoint state | External control state | Private agent context stays outside code storage | -| `refs/tabellio/reviews` | External control state | Full machine review ledger may contain internal context | -| `refs/tabellio/validations` | External control state | Full validation evidence and logs remain independently governed | -| `refs/heads/entire/checkpoints/v1` | External control state | Agent-session checkpoints do not become ordinary code branches | +| Entire transcript and checkpoint state | Private GitHub control repository | Private agent context stays outside the public code repository | +| `refs/tabellio/reviews` | Private GitHub control repository | Full machine review ledger may contain internal context | +| `refs/tabellio/validations` | Private GitHub control repository | Full validation evidence and logs remain independently governed | +| `refs/heads/entire/checkpoints/v1` | Private GitHub control repository | Agent-session checkpoints do not become ordinary code branches | ## Enforced Contract -`tabellio.platform.json` declares GitHub `origin` as code storage, sets control state to external, and sets `publishControlRefsToCodeStorage` to `false`. The JSON Schema and runtime validator reject drift from those values. +`tabellio.platform.json` declares GitHub `origin` as code storage, a private GitHub repository under the `control` remote for external control state, and `publishControlRefsToCodeStorage` as `false`. The JSON Schema and runtime validator reject drift from those values. -The control-ref transport also rejects `origin` before reading or writing remote state. Callers must name a separate configured remote for review, validation, and Entire refs. This makes a mistaken private-state push fail closed instead of relying on operator memory. +The control-ref transport also rejects `origin` before reading or writing remote state. Callers must name a separate private GitHub repository remote for review, validation, and Entire refs. This makes a mistaken private-state push fail closed instead of relying on operator memory. ## Pull-Request Boundary @@ -26,4 +26,4 @@ The pull request remains useful but thin. It carries the code diff, a concise ch ## Migration State -Legacy self-hosted collaboration code and local lab infrastructure have been removed. GitHub `origin` is the only code-storage remote in the supported platform contract. Private control state still requires a separately configured external destination. +Legacy self-hosted collaboration code and local lab infrastructure have been removed. GitHub is the only supported hosted Git and review service: public code uses `origin`, while private control state uses a separately configured private GitHub repository. diff --git a/docs/operations-hardening.md b/docs/operations-hardening.md index 13bbb6a..8eb596a 100644 --- a/docs/operations-hardening.md +++ b/docs/operations-hardening.md @@ -32,12 +32,12 @@ Do not maintain a second merge authority. Independent squash or rebase merges cr Review cycles, validation results, and Entire checkpoints are published together with `git push --atomic`, explicit force-with-lease expectations, and a one-use approval. Publication rejects non-fast-forward updates, divergence, changed local or remote object IDs, expired approvals, and reused approval IDs. -Automatic Entire session pushes to `origin` remain disabled. The approved control-ref transport publishes `refs/heads/entire/checkpoints/v1` with the review and validation refs only to a separately configured external destination. Planning and execution reject `origin`. +Automatic Entire session pushes to `origin` remain disabled. The approved control-ref transport publishes `refs/heads/entire/checkpoints/v1` with the review and validation refs only to a separately configured private GitHub repository. Planning and execution reject `origin`. ## Production Checklist -- Back up the selected external control-state service and test restore drills. +- Back up the private GitHub control repository and test restore drills. - Isolate validation workers for untrusted code; detached worktrees are not sandboxes. -- Scope code-storage and external Git credentials per repository and keep them out of URLs, arguments, and logs. +- Scope code-storage and private-control GitHub credentials per repository and keep them out of URLs, arguments, and logs. - Monitor failed receipts, stale cross-host locks, validation duration, queue depth, and ref divergence. - Reconcile and republish control refs before retrying any divergence failure. diff --git a/docs/research-grounding.md b/docs/research-grounding.md index 92ecbba..8dfab74 100644 --- a/docs/research-grounding.md +++ b/docs/research-grounding.md @@ -12,7 +12,7 @@ Agentic development should stay Git-native: 2. Record machine-readable evidence. 3. Run deterministic checks. 4. Default-deny external side effects. -5. Review through a forge or another explicit review surface. +5. Review through GitHub pull requests. 6. Merge only after current checks pass. The agent is optional. The evidence contract is the product. @@ -43,8 +43,8 @@ The agent is optional. The evidence contract is the product. | External action policy | Security review, deployment controls | Prevent hidden deploys, migrations, secret reads, provider reads, and destructive actions | | Validation runner | Hermetic build workers, argv manifests | Run exact-commit checks on any trusted worker | | Change-request template | Code review practice | Put evidence summary in front of reviewers | -| Control refs | Git refs, compare-and-swap updates | Share durable review and validation state without a forge database dependency | -| Tooling stack guide | Standard Git, Entire, git-spice | Keep the core provider-neutral while allowing host-agnostic review and ledger adapters | +| Control refs | Git refs, compare-and-swap updates | Share durable review and validation state without storing private workflow data in the public code repository | +| Tooling stack guide | GitHub, Standard Git, Entire, git-spice | Keep one GitHub code and review surface while separating private control state | ## Claims We Can Make @@ -54,7 +54,7 @@ The agent is optional. The evidence contract is the product. - Agent-agnostic Git governance - Default-deny external action policy - Worker-validated evidence envelope -- Provider-neutral native Git context engine +- GitHub-native context and evidence engine ## Claims We Should Avoid diff --git a/docs/review-loop.md b/docs/review-loop.md index 36bc599..2f5fb5e 100644 --- a/docs/review-loop.md +++ b/docs/review-loop.md @@ -40,7 +40,7 @@ node scripts/tabellio-review.mjs sync \ --actor review-sync-agent ``` -Sync imports GitHub reviews, inline review comments, issue comments, commit statuses, check runs, and the newest provider-neutral Tabellio validation for the PR head. `GITHUB_TOKEN` may replace `--token-file`; `GITHUB_API_URL` or `--api-url` may target GitHub Enterprise Server. Missing provider items are retained as stale evidence rather than silently deleted. A PR with no validation remains `validating`. +Sync imports GitHub reviews, inline review comments, issue comments, commit statuses, check runs, and the newest Tabellio validation for the PR head. `GITHUB_TOKEN` may replace `--token-file`; `GITHUB_API_URL` or `--api-url` may target GitHub Enterprise Server. Missing GitHub items are retained as stale evidence rather than silently deleted. A PR with no validation remains `validating`. ## Import A Codex Review @@ -85,11 +85,11 @@ git-spice restacks rewrite commit IDs. Tabellio retains `originalCommit` and rem Ledger writes create normal Git blobs, trees, and commits without changing the working tree. Concurrent writers use compare-and-swap on `refs/tabellio/reviews`; stale writers fail instead of overwriting newer state. The same implementation works in normal and bare repositories. The latest cycle retains the newest 100 audit events; older versions remain recoverable from the ledger's Git commit history. -To share the ledger, configure a separate external control-state remote, for example: +To share the ledger, configure a separate private GitHub repository as the control-state remote, for example: ```bash git push "$TABELLIO_CONTROL_REMOTE" refs/tabellio/reviews:refs/tabellio/reviews git fetch "$TABELLIO_CONTROL_REMOTE" refs/tabellio/reviews:refs/tabellio/reviews ``` -The control-state remote must not be `origin`. Publishing that ref is a remote write and uses the same explicit approval boundary as other Git transport mutations. +The private GitHub control remote must not be `origin`. Publishing that ref is a remote write and uses the same explicit approval boundary as other Git transport mutations. diff --git a/docs/tooling-stack.md b/docs/tooling-stack.md index 935c3e7..db90461 100644 --- a/docs/tooling-stack.md +++ b/docs/tooling-stack.md @@ -23,7 +23,7 @@ The main idea: agentic Git should be built around more than a patch. It should p | Tool | Tag | Why It Matters | | --- | --- | --- | | [Entire](https://entire.io/) | `entire` | Checkpoint and session ledger for agent-assisted work, with checkpoint metadata stored in Git | -| [git-spice](https://abhinav.github.io/git-spice/) | `git-spice` | Offline-first stacked branch and change-request workflow with host-specific adapters | +| [git-spice](https://abhinav.github.io/git-spice/) | `git-spice` | Offline-first stacked branch and GitHub pull-request workflow | | [OpenAI Codex](https://openai.com/codex/) | `codex` | Coding and review agent used to produce or inspect changes | Entire is required by default for context capture. Legacy Git-note capture remains an explicit migration mode. All ledger reads stay local through the installed Entire CLI. @@ -70,11 +70,11 @@ Included: - evidence JSON envelope - default-deny external-action policy - local writer and validators -- provider-neutral change-request template and docs +- GitHub pull-request template and docs - read-only GitHub provider for repositories, pull requests, reviews, comments, commit statuses, and check runs - approval-gated git-spice submit, update, sync, restack, and merge operations with one-use receipts -- Git-native review ledger with GitHub feedback, provider-neutral agent findings, triage, fixes, and readiness state -- provider-neutral exact-commit validation runner with durable results on `refs/tabellio/validations` +- Git-native review ledger with GitHub feedback, agent findings, triage, fixes, and readiness state +- GitHub-bound exact-commit validation runner with durable results on `refs/tabellio/validations` - explicit GitHub code-storage and external control-state contract - approval-gated fast-forward transport for review, validation, and Entire refs @@ -91,7 +91,7 @@ Not included yet: | Integration | Evidence Field | | --- | --- | -| GitHub repository or branch id | adapter metadata outside the provider-neutral core | +| GitHub repository or branch id | GitHub identity bound to the context and evidence records | | Entire checkpoint id | `tabellio-ledger/v0.1` and context `checkpoints[]` | | git-spice branch parent or change-request id | `tabellio-stack/v0.1` snapshot | | Codex review result | `checks[]` and `artifacts[]` | @@ -117,7 +117,7 @@ This repository disables automatic checkpoint pushes to `origin`. Commit trailer GitHub receives ordinary code branches, tags, and the minimum pull-request metadata needed for human accountability. `refs/tabellio/reviews`, `refs/tabellio/validations`, and `refs/heads/entire/checkpoints/v1` remain external. The control-ref transport rejects `origin` even when a caller supplies it explicitly. -The boundary is contractual, not only documentary: `tabellio.platform.json`, its JSON Schema, runtime validation, and transport tests all fail closed on provider or publication-policy drift. See [GitHub code-storage boundary](github-code-storage-boundary.md). +The boundary is contractual, not only documentary: `tabellio.platform.json`, its JSON Schema, runtime validation, and transport tests all fail closed on GitHub or publication-policy drift. See [GitHub code-storage boundary](github-code-storage-boundary.md). ## GitHub Review Adapter diff --git a/docs/validation-runner.md b/docs/validation-runner.md index 82b7b56..c7b68d9 100644 --- a/docs/validation-runner.md +++ b/docs/validation-runner.md @@ -1,4 +1,4 @@ -# Provider-Neutral Validation +# Exact-Commit Validation Tabellio validation replaces the GitHub Actions runtime dependency with a small local contract. Any trusted worker that has Git, Node.js, the repository, and its declared tools can run the same manifest. Results live in standard Git objects on `refs/tabellio/validations`. diff --git a/docs/workflow-model.md b/docs/workflow-model.md index 106d4e0..19bef23 100644 --- a/docs/workflow-model.md +++ b/docs/workflow-model.md @@ -1,6 +1,6 @@ # Tabellio Workflow Model -Tabellio turns an agentic coding run into a provider-neutral Git context packet and, when wanted, a reviewable pull request packet. +Tabellio turns an agentic coding run into a GitHub-bound context packet and, when wanted, a reviewable pull request packet. ## Core Objects @@ -24,7 +24,7 @@ Tabellio turns an agentic coding run into a provider-neutral Git context packet | Git substrate | Standard Git CLI and bare repositories | Stores repositories, branches, commits, and patch state | | Session ledger | Entire; Git notes only for migration | Preserves checkpoint and agent-run context for later review | | Evidence gate | Tabellio | Validates commands, checks, changed files, approvals, and side-effect policy | -| Stacked review | git-spice | Keeps related change requests small, ordered, and reviewable across supported forges | +| Stacked review | git-spice | Keeps related GitHub pull requests small, ordered, and reviewable | | Agent review | Codex review | Adds optional diff and evidence review by an agent | ## Standard Flow @@ -58,7 +58,7 @@ Each PR should expose: - external actions attempted or blocked - evidence artifact path -The context packet is usable without a pull request. A forge is a review and distribution adapter, not Tabellio's source of truth. +The context packet is usable without a pull request. GitHub is the review and code-distribution surface; Tabellio's evidence remains independently verifiable. Evidence is not a claim that the work is correct. Evidence is the record reviewers inspect before trusting the work. @@ -113,5 +113,5 @@ Future versions can add: - in-toto link metadata - OpenTelemetry spans - model/tool eval suites -- remote git-spice submission and merge orchestration -- forge-specific merge queue metadata +- automated git-spice submission and merge orchestration for GitHub +- GitHub merge queue metadata diff --git a/examples/tabellio-context/minimal-context.json b/examples/tabellio-context/minimal-context.json index fd95845..10cba95 100644 --- a/examples/tabellio-context/minimal-context.json +++ b/examples/tabellio-context/minimal-context.json @@ -10,7 +10,7 @@ "id": "example-agent" }, "task": { - "summary": "Demonstrate a provider-neutral Tabellio context packet." + "summary": "Demonstrate a GitHub-bound Tabellio context packet." }, "refs": { "base": { @@ -44,6 +44,6 @@ "integrity": { "algorithm": "sha256", "scope": "canonical-json-without-integrity", - "digest": "a39aec4befed8f2e992aa6c2e2d55dce689d913ba99f4a3e0f68e52545d46a2d" + "digest": "26a70c751e06ac87ed338467a614d855cc58a569e9d930db5a98f793a2e2dfd3" } } diff --git a/examples/tabellio-ledger/minimal-ledger.json b/examples/tabellio-ledger/minimal-ledger.json index 3242421..380d195 100644 --- a/examples/tabellio-ledger/minimal-ledger.json +++ b/examples/tabellio-ledger/minimal-ledger.json @@ -37,7 +37,7 @@ "cacheCreation": 0 }, "summary": { - "intent": "Add provider-neutral checkpoint capture.", + "intent": "Add GitHub-bound checkpoint capture.", "outcome": "Checkpoint metadata captured successfully." }, "error": null diff --git a/package.json b/package.json index 374d86a..99a7a86 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@intelip/tabellio", "version": "0.1.0", - "description": "Tabellio: provider-neutral Git context and evidence for agentic development.", + "description": "Tabellio: GitHub-native context and evidence for agentic development.", "type": "module", "license": "Apache-2.0", "author": "IntelIP", diff --git a/schemas/platform.schema.json b/schemas/platform.schema.json index 3638386..c0eee66 100644 --- a/schemas/platform.schema.json +++ b/schemas/platform.schema.json @@ -32,10 +32,12 @@ "workflow": { "type": "object", "additionalProperties": false, - "required": ["stackManager", "controlState", "publishControlRefsToCodeStorage", "controlRefs"], + "required": ["stackManager", "controlState", "controlProvider", "controlRemoteName", "publishControlRefsToCodeStorage", "controlRefs"], "properties": { "stackManager": { "const": "git-spice" }, "controlState": { "const": "external" }, + "controlProvider": { "const": "github" }, + "controlRemoteName": { "const": "control" }, "publishControlRefsToCodeStorage": { "const": false }, "controlRefs": { "type": "array", diff --git a/scripts/check-tabellio-platform.mjs b/scripts/check-tabellio-platform.mjs index a58670f..9015e42 100644 --- a/scripts/check-tabellio-platform.mjs +++ b/scripts/check-tabellio-platform.mjs @@ -18,6 +18,8 @@ try { codeRemote: config.codeStorage.remoteName, publicSurface: config.codeStorage.publicSurface, controlState: config.workflow.controlState, + controlProvider: config.workflow.controlProvider, + controlRemote: config.workflow.controlRemoteName, publishesControlRefsToCodeStorage: config.workflow.publishControlRefsToCodeStorage, }, null, 2)); } catch (error) { diff --git a/scripts/lib/change-request-provider.mjs b/scripts/lib/change-request-provider.mjs deleted file mode 100644 index 88a611f..0000000 --- a/scripts/lib/change-request-provider.mjs +++ /dev/null @@ -1,29 +0,0 @@ -export class ChangeRequestProvider { - async repository(_options) { - throw new Error("ChangeRequestProvider.repository must be implemented."); - } - - async listChangeRequests(_options) { - throw new Error("ChangeRequestProvider.listChangeRequests must be implemented."); - } - - async changeRequest(_options) { - throw new Error("ChangeRequestProvider.changeRequest must be implemented."); - } - - async listReviews(_options) { - throw new Error("ChangeRequestProvider.listReviews must be implemented."); - } - - async listReviewComments(_options) { - throw new Error("ChangeRequestProvider.listReviewComments must be implemented."); - } - - async listIssueComments(_options) { - throw new Error("ChangeRequestProvider.listIssueComments must be implemented."); - } - - async commitStatus(_options) { - throw new Error("ChangeRequestProvider.commitStatus must be implemented."); - } -} diff --git a/scripts/lib/cli-options.mjs b/scripts/lib/cli-options.mjs new file mode 100644 index 0000000..7b75445 --- /dev/null +++ b/scripts/lib/cli-options.mjs @@ -0,0 +1,63 @@ +export function parseOptionPairs(args, context) { + if (args.length % 2 !== 0) throw new Error(`Expected a value after ${args.at(-1) ?? context}.`); + const values = {}; + for (let index = 0; index < args.length; index += 2) { + const flag = args[index]; + const key = optionKey(flag); + addOption(values, key, flag, args[index + 1]); + } + return values; +} + +export function assertAllowedOptions(values, allowed) { + for (const key of Object.keys(values)) { + if (!allowed.includes(key)) throw new Error(`Unsupported option: --${toKebabCase(key)}.`); + } +} + +export function requireOptions(values, keys, command) { + for (const key of keys) { + if (!values[key]) throw new Error(`--${toKebabCase(key)} is required${command ? ` for ${command}` : ""}.`); + } +} + +export function positiveNumberOption(value, flag) { + const number = Number(value); + if (!Number.isInteger(number) || number <= 0) throw new Error(`${flag} must be a positive integer.`); + return number; +} + +export function reportCliError(error) { + process.exitCode = 1; + console.error(JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : String(error), + name: error instanceof Error ? error.name : "Error", + }, null, 2)); +} + +export async function writeJsonOutput(value, out) { + const output = `${JSON.stringify(value, null, 2)}\n`; + if (out) { + const outPath = resolve(out); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, output); + } + process.stdout.write(output); +} + +function optionKey(flag) { + if (typeof flag !== "string" || !flag.startsWith("--")) throw new Error(`Expected an option, received ${flag}.`); + return flag.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); +} + +function addOption(values, key, flag, value) { + if (Object.hasOwn(values, key)) throw new Error(`Duplicate option: ${flag}.`); + values[key] = value; +} + +function toKebabCase(value) { + return value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); +} +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; diff --git a/scripts/lib/platform-config.mjs b/scripts/lib/platform-config.mjs index 786102c..6ad9990 100644 --- a/scripts/lib/platform-config.mjs +++ b/scripts/lib/platform-config.mjs @@ -20,6 +20,8 @@ export function validatePlatformConfig(value) { exactObject(value.workflow, { stackManager: "git-spice", controlState: "external", + controlProvider: "github", + controlRemoteName: "control", publishControlRefsToCodeStorage: false, }, "platform.workflow", ["controlRefs"]); exactSet(value.workflow.controlRefs, CONTROL_REFS, "platform.workflow.controlRefs"); diff --git a/scripts/lib/review-cycle.mjs b/scripts/lib/review-cycle.mjs index da549f3..3fe194e 100644 --- a/scripts/lib/review-cycle.mjs +++ b/scripts/lib/review-cycle.mjs @@ -499,7 +499,7 @@ function mergeChecks(providerChecks, localValidation) { id: `validation:${localValidation.runId}`, context: `tabellio/${localValidation.suite.id}`, state: localValidation.status === "passed" ? "success" : "failure", - description: `Provider-neutral validation ${localValidation.status}.`, + description: `Tabellio validation ${localValidation.status}.`, targetUrl: null, createdAt: localValidation.startedAt, updatedAt: localValidation.completedAt, diff --git a/scripts/lib/snapshot-command.mjs b/scripts/lib/snapshot-command.mjs new file mode 100644 index 0000000..d45c9c0 --- /dev/null +++ b/scripts/lib/snapshot-command.mjs @@ -0,0 +1,10 @@ +import { resolve } from "node:path"; + +import { NativeGitStore } from "../providers/native-git-store.mjs"; +import { writeJsonOutput } from "./cli-options.mjs"; + +export async function runSnapshotCommand({ repo, out, capture }) { + const store = await NativeGitStore.open(resolve(repo ?? process.cwd())); + const snapshot = await capture(store); + await writeJsonOutput(snapshot, out); +} diff --git a/scripts/providers/github-provider.mjs b/scripts/providers/github-provider.mjs index 200a611..d1d332d 100644 --- a/scripts/providers/github-provider.mjs +++ b/scripts/providers/github-provider.mjs @@ -1,27 +1,20 @@ -import { ChangeRequestProvider } from "../lib/change-request-provider.mjs"; - const DEFAULT_BASE_URL = "https://api.github.com"; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_PAGE_LIMIT = 100; const MAX_PAGES = 10; +const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost"]); +const FAILURE_STATES = new Set(["error", "failure", "failed"]); +const PENDING_STATES = new Set(["pending", "queued", "running", "in_progress"]); +const SUCCESSFUL_CHECK_CONCLUSIONS = new Set(["success", "neutral", "skipped"]); -export class GitHubProvider extends ChangeRequestProvider { +export class GitHubProvider { #token; constructor({ baseUrl = DEFAULT_BASE_URL, token, timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = globalThis.fetch }) { - super(); - requiredString(baseUrl, "baseUrl"); requiredString(token, "token"); - if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) throw new TypeError("timeoutMs must be a positive integer."); - if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be a function."); - const parsed = new URL(baseUrl); - if (parsed.protocol !== "https:" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") { - throw new TypeError("baseUrl must use HTTPS unless it targets localhost."); - } - 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(/\/+$/, ""); - this.baseUrl = parsed.toString().replace(/\/$/, ""); + positiveInteger(timeoutMs, "timeoutMs"); + requiredFunction(fetchImpl, "fetchImpl"); + this.baseUrl = normalizeBaseUrl(baseUrl); this.#token = token; this.timeoutMs = timeoutMs; this.fetchImpl = fetchImpl; @@ -32,7 +25,7 @@ export class GitHubProvider extends ChangeRequestProvider { } async listChangeRequests({ owner, repo, state = "open" }) { - if (!["open", "closed", "all"].includes(state)) throw new TypeError("state must be open, closed, or all."); + requiredEnum(state, ["open", "closed", "all"], "state", TypeError); const values = await this.#paginate(`${repoPath(owner, repo)}/pulls`, { state }); return values.map(normalizeChangeRequest); } @@ -62,9 +55,10 @@ export class GitHubProvider extends ChangeRequestProvider { async commitStatus({ owner, repo, commit }) { requiredString(commit, "commit"); + const encodedCommit = encodeURIComponent(commit); const [combinedStatus, checkRuns] = await Promise.all([ - this.#request(`${repoPath(owner, repo)}/commits/${encodeURIComponent(commit)}/status`), - this.#paginateObject(`${repoPath(owner, repo)}/commits/${encodeURIComponent(commit)}/check-runs`, "check_runs"), + this.#request(`${repoPath(owner, repo)}/commits/${encodedCommit}/status`), + this.#paginateObject(`${repoPath(owner, repo)}/commits/${encodedCommit}/check-runs`, "check_runs"), ]); return normalizeCommitStatus(combinedStatus, checkRuns, commit); } @@ -73,7 +67,7 @@ export class GitHubProvider extends ChangeRequestProvider { const values = []; for (let page = 1; page <= MAX_PAGES; page += 1) { const batch = await this.#request(path, { ...query, page, per_page: DEFAULT_PAGE_LIMIT }); - if (!Array.isArray(batch)) throw new GitHubResponseError(`${path} must return an array.`); + requiredArray(batch, path); values.push(...batch); if (batch.length < DEFAULT_PAGE_LIMIT) return values; } @@ -83,9 +77,9 @@ export class GitHubProvider extends ChangeRequestProvider { async #paginateObject(path, key) { const values = []; for (let page = 1; page <= MAX_PAGES; page += 1) { - const response = await this.#request(path, { page, per_page: DEFAULT_PAGE_LIMIT }); - const batch = response?.[key]; - if (!Array.isArray(batch)) throw new GitHubResponseError(`${path}.${key} must be an array.`); + const response = requiredObject(await this.#request(path, { page, per_page: DEFAULT_PAGE_LIMIT }), path); + const batch = response[key]; + requiredArray(batch, `${path}.${key}`); values.push(...batch); if (batch.length < DEFAULT_PAGE_LIMIT) return values; } @@ -93,41 +87,16 @@ export class GitHubProvider extends ChangeRequestProvider { } async #request(path, query = {}) { - 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 response = await this.fetchImpl(url, { - method: "GET", - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${this.#token}`, - "X-GitHub-Api-Version": "2022-11-28", - }, - signal: controller.signal, - }); - const body = await response.text(); - if (!response.ok) { - throw new GitHubHttpError({ - status: response.status, - method: "GET", - url: url.toString(), - body: redact(body, this.#token), - }); - } - if (body.trim() === "") return null; - try { - return JSON.parse(body); - } catch { - throw new GitHubResponseError(`GET ${url} returned invalid JSON.`); - } - } catch (error) { - if (error?.name === "AbortError") throw new GitHubResponseError(`GET ${url} timed out after ${this.timeoutMs}ms.`); - throw error; - } finally { - clearTimeout(timeout); - } + const url = buildUrl(this.baseUrl, path, query); + const response = await fetchWithTimeout({ + fetchImpl: this.fetchImpl, + url, + token: this.#token, + timeoutMs: this.timeoutMs, + }); + const body = await response.text(); + if (!response.ok) throw httpError(response.status, url, body, this.#token); + return parseResponseBody(body, url); } } @@ -150,133 +119,236 @@ class GitHubResponseError extends Error { } function normalizeRepository(value) { + const repository = requiredObject(value, "repository"); + const owner = requiredObject(repository.owner, "repository.owner"); return { - id: String(requiredInteger(value?.id, "repository.id")), - owner: requiredValue(value?.owner?.login, "repository.owner.login"), - name: requiredValue(value?.name, "repository.name"), - fullName: requiredValue(value?.full_name, "repository.full_name"), - private: value?.private === true, - archived: value?.archived === true, - defaultBranch: nullableString(value?.default_branch), - webUrl: requiredHttpUrl(value?.html_url, "repository.html_url"), - cloneUrl: requiredHttpUrl(value?.clone_url, "repository.clone_url"), + id: String(requiredInteger(repository.id, "repository.id")), + owner: requiredValue(owner.login, "repository.owner.login"), + name: requiredValue(repository.name, "repository.name"), + fullName: requiredValue(repository.full_name, "repository.full_name"), + private: repository.private === true, + archived: repository.archived === true, + defaultBranch: nullableString(repository.default_branch), + webUrl: requiredHttpUrl(repository.html_url, "repository.html_url"), + cloneUrl: requiredHttpUrl(repository.clone_url, "repository.clone_url"), }; } function normalizeChangeRequest(value) { - const merged = value?.merged === true || value?.merged_at !== null && value?.merged_at !== undefined; + const changeRequest = requiredObject(value, "changeRequest"); return { - id: String(requiredInteger(value?.id, "changeRequest.id")), - number: requiredInteger(value?.number, "changeRequest.number"), - title: requiredValue(value?.title, "changeRequest.title"), - state: merged ? "merged" : requiredEnum(value?.state, ["open", "closed"], "changeRequest.state"), - draft: value?.draft === true, - mergeable: typeof value?.mergeable === "boolean" ? value.mergeable : null, - source: normalizeBranch(value?.head, "changeRequest.head"), - target: normalizeBranch(value?.base, "changeRequest.base"), - author: nullableString(value?.user?.login), - webUrl: requiredHttpUrl(value?.html_url, "changeRequest.html_url"), - createdAt: requiredDate(value?.created_at, "changeRequest.created_at"), - updatedAt: requiredDate(value?.updated_at, "changeRequest.updated_at"), + id: String(requiredInteger(changeRequest.id, "changeRequest.id")), + number: requiredInteger(changeRequest.number, "changeRequest.number"), + title: requiredValue(changeRequest.title, "changeRequest.title"), + state: changeRequestState(changeRequest), + draft: changeRequest.draft === true, + mergeable: nullableBoolean(changeRequest.mergeable), + source: normalizeBranch(changeRequest.head, "changeRequest.head"), + target: normalizeBranch(changeRequest.base, "changeRequest.base"), + author: nullableLogin(changeRequest.user, "changeRequest.user"), + webUrl: requiredHttpUrl(changeRequest.html_url, "changeRequest.html_url"), + createdAt: requiredDate(changeRequest.created_at, "changeRequest.created_at"), + updatedAt: requiredDate(changeRequest.updated_at, "changeRequest.updated_at"), }; } +function changeRequestState(value) { + if (value.merged === true) return "merged"; + if (!isEmptyOptional(value.merged_at)) return "merged"; + return requiredEnum(value.state, ["open", "closed"], "changeRequest.state"); +} + function normalizeBranch(value, path) { + const branch = requiredObject(value, path); return { - branch: requiredValue(value?.ref, `${path}.ref`), - commit: requiredValue(value?.sha, `${path}.sha`), + branch: requiredValue(branch.ref, `${path}.ref`), + commit: requiredValue(branch.sha, `${path}.sha`), }; } function normalizeReview(value) { - const state = requiredValue(value?.state, "review.state").toLowerCase(); - const submittedAt = nullableDate(value?.submitted_at, "review.submitted_at"); + const review = requiredObject(value, "review"); + const state = requiredValue(review.state, "review.state").toLowerCase(); + const submittedAt = nullableDate(review.submitted_at, "review.submitted_at"); return { - id: String(requiredInteger(value?.id, "review.id")), + id: String(requiredInteger(review.id, "review.id")), state, - body: typeof value?.body === "string" ? value.body : "", - commit: nullableString(value?.commit_id), + body: optionalText(review.body), + commit: nullableString(review.commit_id), dismissed: state === "dismissed", stale: false, - author: nullableString(value?.user?.login), + author: nullableLogin(review.user, "review.user"), submittedAt, updatedAt: submittedAt, - webUrl: nullableHttpUrl(value?.html_url, "review.html_url"), + webUrl: nullableHttpUrl(review.html_url, "review.html_url"), }; } function normalizeReviewComment(value) { + const comment = requiredObject(value, "reviewComment"); return { - id: String(requiredInteger(value?.id, "reviewComment.id")), - reviewId: String(requiredInteger(value?.pull_request_review_id, "reviewComment.pull_request_review_id")), - body: typeof value?.body === "string" ? value.body : "", - path: nullableString(value?.path), - line: firstInteger(value?.line, value?.original_line, value?.position), - commit: nullableString(value?.commit_id ?? value?.original_commit_id), - author: nullableString(value?.user?.login), + id: String(requiredInteger(comment.id, "reviewComment.id")), + reviewId: String(requiredInteger(comment.pull_request_review_id, "reviewComment.pull_request_review_id")), + body: optionalText(comment.body), + path: nullableString(comment.path), + line: firstInteger(comment.line, comment.original_line, comment.position), + commit: nullableString(firstPresent(comment.commit_id, comment.original_commit_id)), + author: nullableLogin(comment.user, "reviewComment.user"), resolvedBy: null, - createdAt: requiredDate(value?.created_at, "reviewComment.created_at"), - updatedAt: requiredDate(value?.updated_at, "reviewComment.updated_at"), - webUrl: nullableHttpUrl(value?.html_url, "reviewComment.html_url"), + createdAt: requiredDate(comment.created_at, "reviewComment.created_at"), + updatedAt: requiredDate(comment.updated_at, "reviewComment.updated_at"), + webUrl: nullableHttpUrl(comment.html_url, "reviewComment.html_url"), }; } function normalizeIssueComment(value) { + const comment = requiredObject(value, "issueComment"); return { - id: String(requiredInteger(value?.id, "issueComment.id")), - body: typeof value?.body === "string" ? value.body : "", - author: nullableString(value?.user?.login), - createdAt: requiredDate(value?.created_at, "issueComment.created_at"), - updatedAt: requiredDate(value?.updated_at, "issueComment.updated_at"), - webUrl: nullableHttpUrl(value?.html_url, "issueComment.html_url"), + id: String(requiredInteger(comment.id, "issueComment.id")), + body: optionalText(comment.body), + author: nullableLogin(comment.user, "issueComment.user"), + createdAt: requiredDate(comment.created_at, "issueComment.created_at"), + updatedAt: requiredDate(comment.updated_at, "issueComment.updated_at"), + webUrl: nullableHttpUrl(comment.html_url, "issueComment.html_url"), }; } function normalizeCommitStatus(value, checkRuns, requestedCommit) { - const statuses = Array.isArray(value?.statuses) ? value.statuses.map(normalizeStatus) : []; + const response = requiredObject(value, "commitStatus"); + const statuses = optionalArray(response.statuses, "commitStatus.statuses").map(normalizeStatus); const checks = checkRuns.map(normalizeCheckRun); const combined = [...statuses, ...checks]; - let state = combined.length === 0 ? "none" : "success"; - if (combined.some((status) => ["error", "failure", "failed"].includes(status.state))) state = "failure"; - else if (combined.some((status) => ["pending", "queued", "running", "in_progress"].includes(status.state))) state = "pending"; return { - commit: nullableString(value?.sha) ?? requestedCommit, - state, + commit: nullableString(response.sha) ?? requestedCommit, + state: combinedState(combined), total: combined.length, statuses: combined, }; } +function combinedState(statuses) { + if (statuses.length === 0) return "none"; + const states = new Set(statuses.map((status) => status.state)); + if (setsOverlap(states, FAILURE_STATES)) return "failure"; + if (setsOverlap(states, PENDING_STATES)) return "pending"; + return "success"; +} + +function setsOverlap(left, right) { + for (const value of left) if (right.has(value)) return true; + return false; +} + function normalizeStatus(value) { + const status = requiredObject(value, "commitStatus.status"); return { - id: `status:${requiredInteger(value?.id, "commitStatus.status.id")}`, - context: requiredValue(value?.context, "commitStatus.status.context"), - state: requiredValue(value?.state, "commitStatus.status.state").toLowerCase(), - description: nullableString(value?.description), - targetUrl: nullableHttpUrl(value?.target_url, "commitStatus.status.target_url"), - createdAt: nullableDate(value?.created_at, "commitStatus.status.created_at"), - updatedAt: nullableDate(value?.updated_at, "commitStatus.status.updated_at"), + id: `status:${requiredInteger(status.id, "commitStatus.status.id")}`, + context: requiredValue(status.context, "commitStatus.status.context"), + state: requiredValue(status.state, "commitStatus.status.state").toLowerCase(), + description: nullableString(status.description), + targetUrl: nullableHttpUrl(status.target_url, "commitStatus.status.target_url"), + createdAt: nullableDate(status.created_at, "commitStatus.status.created_at"), + updatedAt: nullableDate(status.updated_at, "commitStatus.status.updated_at"), }; } function normalizeCheckRun(value) { - const status = requiredValue(value?.status, "checkRun.status").toLowerCase(); - const conclusion = nullableString(value?.conclusion)?.toLowerCase() ?? null; - let state = "pending"; - if (status === "completed") { - state = ["success", "neutral", "skipped"].includes(conclusion) ? "success" : "failure"; + const checkRun = requiredObject(value, "checkRun"); + const output = optionalObject(checkRun.output, "checkRun.output"); + const status = requiredValue(checkRun.status, "checkRun.status").toLowerCase(); + const conclusion = nullableLowercase(checkRun.conclusion); + return { + id: `check-run:${requiredInteger(checkRun.id, "checkRun.id")}`, + context: requiredValue(checkRun.name, "checkRun.name"), + state: checkRunState(status, conclusion), + description: firstString(output.title, conclusion, status), + targetUrl: nullableHttpUrl(firstPresent(checkRun.details_url, checkRun.html_url), "checkRun.details_url"), + createdAt: nullableDate(checkRun.started_at, "checkRun.started_at"), + updatedAt: nullableDate(firstPresent(checkRun.completed_at, checkRun.started_at), "checkRun.completed_at"), + }; +} + +function checkRunState(status, conclusion) { + if (status !== "completed") return "pending"; + return SUCCESSFUL_CHECK_CONCLUSIONS.has(conclusion) ? "success" : "failure"; +} + +function buildUrl(baseUrl, path, query) { + const url = new URL(`${baseUrl}${path}`); + for (const [key, value] of Object.entries(query)) url.searchParams.set(key, String(value)); + return url; +} + +async function fetchWithTimeout({ fetchImpl, url, token, timeoutMs }) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetchImpl(url, { + method: "GET", + headers: githubHeaders(token), + signal: controller.signal, + }); + } catch (error) { + if (error?.name === "AbortError") throw new GitHubResponseError(`GET ${url} timed out after ${timeoutMs}ms.`); + throw error; + } finally { + clearTimeout(timeout); } +} + +function githubHeaders(token) { return { - id: `check-run:${requiredInteger(value?.id, "checkRun.id")}`, - context: requiredValue(value?.name, "checkRun.name"), - state, - description: nullableString(value?.output?.title) ?? conclusion ?? status, - targetUrl: nullableHttpUrl(value?.details_url ?? value?.html_url, "checkRun.details_url"), - createdAt: nullableDate(value?.started_at, "checkRun.started_at"), - updatedAt: nullableDate(value?.completed_at ?? value?.started_at, "checkRun.completed_at"), + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", }; } +function httpError(status, url, body, token) { + return new GitHubHttpError({ + status, + method: "GET", + url: url.toString(), + body: redact(body, token), + }); +} + +function parseResponseBody(body, url) { + if (body.trim() === "") return null; + try { + return JSON.parse(body); + } catch { + throw new GitHubResponseError(`GET ${url} returned invalid JSON.`); + } +} + +function normalizeBaseUrl(value) { + requiredString(value, "baseUrl"); + if (!URL.canParse(value)) throw new TypeError("baseUrl must be an absolute URL."); + const parsed = new URL(value); + requireSecureApiUrl(parsed); + rejectCredentials(parsed); + rejectQueryAndFragment(parsed); + parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + return parsed.toString().replace(/\/$/, ""); +} + +function requireSecureApiUrl(url) { + if (url.protocol === "https:") return; + if (LOCAL_HOSTS.has(url.hostname)) return; + throw new TypeError("baseUrl must use HTTPS unless it targets localhost."); +} + +function rejectCredentials(url) { + if (url.username) throw new TypeError("baseUrl must not contain credentials."); + if (url.password) throw new TypeError("baseUrl must not contain credentials."); +} + +function rejectQueryAndFragment(url) { + if (url.search) throw new TypeError("baseUrl must not contain a query or fragment."); + if (url.hash) throw new TypeError("baseUrl must not contain a query or fragment."); +} + function repoPath(owner, repo) { requiredString(owner, "owner"); requiredString(repo, "repo"); @@ -295,6 +367,36 @@ function firstInteger(...values) { return values.find((value) => Number.isInteger(value)) ?? null; } +function firstPresent(value, fallback) { + return isEmptyOptional(value) ? fallback : value; +} + +function firstString(...values) { + for (const value of values) { + const normalized = nullableString(value); + if (normalized !== null) return normalized; + } + return null; +} + +function optionalText(value) { + return typeof value === "string" ? value : ""; +} + +function nullableBoolean(value) { + return typeof value === "boolean" ? value : null; +} + +function nullableLowercase(value) { + const normalized = nullableString(value); + return normalized === null ? null : normalized.toLowerCase(); +} + +function nullableLogin(value, path) { + if (isEmptyOptional(value)) return null; + return nullableString(requiredObject(value, path).login); +} + function requiredString(value, path) { if (typeof value !== "string" || value.trim() === "") throw new TypeError(`${path} must be a non-empty string.`); } @@ -304,6 +406,10 @@ function requiredValue(value, path) { return value; } +function requiredFunction(value, path) { + if (typeof value !== "function") throw new TypeError(`${path} must be a function.`); +} + function requiredInteger(value, path) { if (!Number.isInteger(value) || value < 0) throw new GitHubResponseError(`${path} must be a non-negative integer.`); return value; @@ -313,11 +419,37 @@ function positiveInteger(value, path) { if (!Number.isInteger(value) || value <= 0) throw new TypeError(`${path} must be a positive integer.`); } -function requiredEnum(value, values, path) { - if (!values.includes(value)) throw new GitHubResponseError(`${path} must be ${values.join(" or ")}.`); +function requiredEnum(value, values, path, ErrorType = GitHubResponseError) { + if (!values.includes(value)) throw new ErrorType(`${path} must be ${values.join(" or ")}.`); return value; } +function requiredObject(value, path) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new GitHubResponseError(`${path} must be an object.`); + } + return value; +} + +function optionalObject(value, path) { + if (isEmptyOptional(value)) return {}; + return requiredObject(value, path); +} + +function requiredArray(value, path) { + if (!Array.isArray(value)) throw new GitHubResponseError(`${path} must be an array.`); + return value; +} + +function optionalArray(value, path) { + if (isEmptyOptional(value)) return []; + return requiredArray(value, path); +} + +function isEmptyOptional(value) { + return value === undefined || value === null || value === ""; +} + function nullableString(value) { return typeof value === "string" && value !== "" ? value : null; } @@ -329,24 +461,23 @@ function requiredDate(value, path) { } function nullableDate(value, path) { - if (value === undefined || value === null || value === "") return null; + if (isEmptyOptional(value)) return null; return requiredDate(value, path); } function requiredHttpUrl(value, path) { - const normalized = nullableHttpUrl(value, path); - if (normalized === null) throw new GitHubResponseError(`${path} must be an absolute HTTP URL.`); - return normalized; + if (isEmptyOptional(value)) throw new GitHubResponseError(`${path} must be an absolute HTTP URL.`); + return parseHttpUrl(value, path); } function nullableHttpUrl(value, path) { - if (value === undefined || value === null || value === "") return null; - let parsed; - try { - parsed = new URL(value); - } catch { - throw new GitHubResponseError(`${path} must be an absolute HTTP URL.`); - } - if (!["http:", "https:"].includes(parsed.protocol)) throw new GitHubResponseError(`${path} must be an absolute HTTP URL.`); + if (isEmptyOptional(value)) return null; + return parseHttpUrl(value, path); +} + +function parseHttpUrl(value, path) { + if (!URL.canParse(value)) throw new GitHubResponseError(`${path} must be an absolute HTTP URL.`); + const parsed = new URL(value); + if (!['http:', 'https:'].includes(parsed.protocol)) throw new GitHubResponseError(`${path} must be an absolute HTTP URL.`); return value; } diff --git a/scripts/tabellio-ledger.mjs b/scripts/tabellio-ledger.mjs index 4ebbb80..da836af 100755 --- a/scripts/tabellio-ledger.mjs +++ b/scripts/tabellio-ledger.mjs @@ -1,52 +1,42 @@ #!/usr/bin/env node -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; - +import { assertAllowedOptions, parseOptionPairs, reportCliError } from "./lib/cli-options.mjs"; import { repositoryIdentity } from "./lib/repository-identity.mjs"; +import { runSnapshotCommand } from "./lib/snapshot-command.mjs"; import { EntireLedgerProvider } from "./providers/entire-ledger-provider.mjs"; -import { NativeGitStore } from "./providers/native-git-store.mjs"; try { const options = parseArgs(process.argv.slice(2)); - const repoPath = resolve(options.repo ?? process.cwd()); - const store = await NativeGitStore.open(repoPath); + await runSnapshotCommand({ + repo: options.repo, + out: options.out, + capture: (store) => captureLedgerSnapshot(store, options), + }); +} catch (error) { + reportCliError(error); +} + +async function captureLedgerSnapshot(store, options) { const provider = await EntireLedgerProvider.open(store.repoPath, { - binary: options.binary ?? process.env.TABELLIO_ENTIRE_BIN ?? "entire", + binary: entireBinary(options.binary), }); - const snapshot = await provider.snapshot({ + return provider.snapshot({ repositoryId: await repositoryIdentity(store, options.repoId), - baseRevision: options.base ?? "main", - headRevision: options.head ?? "HEAD", + baseRevision: defaultValue(options.base, "main"), + headRevision: defaultValue(options.head, "HEAD"), }); - const output = `${JSON.stringify(snapshot, null, 2)}\n`; - if (options.out) { - const outPath = resolve(options.out); - await mkdir(dirname(outPath), { recursive: true }); - await writeFile(outPath, output); - } - process.stdout.write(output); -} catch (error) { - process.exitCode = 1; - console.error(JSON.stringify({ - ok: false, - error: error instanceof Error ? error.message : String(error), - name: error instanceof Error ? error.name : "Error", - }, null, 2)); +} + +function entireBinary(binary) { + return binary ?? process.env.TABELLIO_ENTIRE_BIN ?? "entire"; +} + +function defaultValue(value, fallback) { + return value ?? fallback; } function parseArgs(argv) { - const parsed = {}; - const aliases = new Map([ - ["--repo", "repo"], ["--repo-id", "repoId"], ["--base", "base"], - ["--head", "head"], ["--out", "out"], ["--binary", "binary"], - ]); - for (let index = 0; index < argv.length; index += 1) { - const key = aliases.get(argv[index]); - if (!key) throw new Error(`Unknown argument: ${argv[index]}`); - const value = argv[++index]; - if (!value) throw new Error(`${argv[index - 1]} requires a value.`); - parsed[key] = value; - } - return parsed; + const values = parseOptionPairs(argv, "tabellio-ledger"); + assertAllowedOptions(values, ["repo", "repoId", "base", "head", "out", "binary"]); + return values; } diff --git a/scripts/tabellio-review.mjs b/scripts/tabellio-review.mjs index 0380399..6c76238 100755 --- a/scripts/tabellio-review.mjs +++ b/scripts/tabellio-review.mjs @@ -3,112 +3,131 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; +import { + assertAllowedOptions, + parseOptionPairs, + positiveNumberOption, + reportCliError, + requireOptions, +} from "./lib/cli-options.mjs"; import { GitJsonLedger } from "./lib/git-json-ledger.mjs"; import { repositoryIdentity } from "./lib/repository-identity.mjs"; import { ReviewCycleManager } from "./lib/review-cycle.mjs"; import { GitHubProvider } from "./providers/github-provider.mjs"; import { NativeGitStore } from "./providers/native-git-store.mjs"; -try { +const COMMANDS = new Set(["sync", "status", "triage", "fix", "import"]); +const COMMON_OPTIONS = ["repo", "repoId", "owner", "remoteRepo", "number", "actor", "ledgerRef", "validationLedgerRef"]; +const ALLOWED_OPTIONS = { + sync: [...COMMON_OPTIONS, "apiUrl", "tokenFile"], + status: COMMON_OPTIONS, + triage: [...COMMON_OPTIONS, "feedbackId", "disposition", "reason"], + fix: [...COMMON_OPTIONS, "feedbackIds", "commit", "checkpoint", "summary"], + import: [...COMMON_OPTIONS, "input"], +}; +const REQUIRED_OPTIONS = { + sync: [], + status: [], + triage: ["feedbackId", "disposition", "reason"], + fix: ["feedbackIds", "commit", "checkpoint", "summary"], + import: ["input"], +}; +const COMMAND_HANDLERS = { + sync: syncReview, + status: reviewStatus, + triage: triageReview, + fix: recordReviewFix, + import: importAgentReview, +}; + +main().catch(reportCliError); + +async function main() { const options = parseArgs(process.argv.slice(2)); const store = await NativeGitStore.open(resolve(options.repo ?? process.cwd())); const repositoryId = await repositoryIdentity(store, options.repoId); - const ledger = await GitJsonLedger.open({ - repoPath: store.repoPath, - ref: options.ledgerRef ?? "refs/tabellio/reviews", - }); - const validationLedger = await GitJsonLedger.open({ - repoPath: store.repoPath, - ref: options.validationLedgerRef ?? "refs/tabellio/validations", - }); - let provider = null; - if (options.command === "sync") { - const token = options.tokenFile - ? (await readFile(resolve(options.tokenFile), "utf8")).trim() - : process.env.GITHUB_TOKEN?.trim(); - if (!token) throw new Error("--token-file or GITHUB_TOKEN is required for sync."); - provider = new GitHubProvider({ - baseUrl: options.apiUrl ?? process.env.GITHUB_API_URL, - token, - }); - } + const ledger = await openLedger(store.repoPath, options.ledgerRef ?? "refs/tabellio/reviews"); + const validationLedger = await openLedger(store.repoPath, options.validationLedgerRef ?? "refs/tabellio/validations"); const manager = new ReviewCycleManager({ store, ledger, validationLedger, - provider, + provider: await githubClient(options), repositoryId, owner: options.owner, repo: options.remoteRepo, }); - let result; - if (options.command === "sync") { - result = await manager.sync({ number: options.number, actor: options.actor }); - } else if (options.command === "status") { - result = await manager.status({ number: options.number }); - } else if (options.command === "triage") { - result = await manager.triage({ - number: options.number, - feedbackId: options.feedbackId, - disposition: options.disposition, - reason: options.reason, - actor: options.actor, - }); - } else if (options.command === "fix") { - result = await manager.recordFix({ - number: options.number, - feedbackIds: options.feedbackIds.split(",").map((value) => value.trim()).filter(Boolean), - commit: options.commit, - checkpointId: options.checkpoint, - summary: options.summary, - actor: options.actor, - }); - } else { - result = await manager.importAgentReview({ - number: options.number, - input: JSON.parse(await readFile(resolve(options.input), "utf8")), - actor: options.actor, - }); - } + const result = await COMMAND_HANDLERS[options.command](manager, options); console.log(JSON.stringify({ ok: true, ...result }, null, 2)); -} catch (error) { - process.exitCode = 1; - console.error(JSON.stringify({ - ok: false, - error: error instanceof Error ? error.message : String(error), - name: error instanceof Error ? error.name : "Error", - }, null, 2)); +} + +function openLedger(repoPath, ref) { + return GitJsonLedger.open({ repoPath, ref }); +} + +async function githubClient(options) { + if (options.command !== "sync") return null; + const token = await githubToken(options.tokenFile); + if (!token) throw new Error("--token-file or GITHUB_TOKEN is required for sync."); + return new GitHubProvider({ + baseUrl: options.apiUrl ?? process.env.GITHUB_API_URL, + token, + }); +} + +async function githubToken(tokenFile) { + if (tokenFile) return (await readFile(resolve(tokenFile), "utf8")).trim(); + return process.env.GITHUB_TOKEN?.trim(); +} + +function syncReview(manager, options) { + return manager.sync({ number: options.number, actor: options.actor }); +} + +function reviewStatus(manager, options) { + return manager.status({ number: options.number }); +} + +function triageReview(manager, options) { + return manager.triage({ + number: options.number, + feedbackId: options.feedbackId, + disposition: options.disposition, + reason: options.reason, + actor: options.actor, + }); +} + +function recordReviewFix(manager, options) { + return manager.recordFix({ + number: options.number, + feedbackIds: options.feedbackIds.split(",").map((value) => value.trim()).filter(Boolean), + commit: options.commit, + checkpointId: options.checkpoint, + summary: options.summary, + actor: options.actor, + }); +} + +async function importAgentReview(manager, options) { + return manager.importAgentReview({ + number: options.number, + input: JSON.parse(await readFile(resolve(options.input), "utf8")), + actor: options.actor, + }); } function parseArgs(args) { const command = args[0]; - if (!["sync", "status", "triage", "fix", "import"].includes(command)) { - throw new Error("Expected command: sync, status, triage, fix, or import."); - } - const values = {}; - for (let index = 1; index < args.length; index += 2) { - const flag = args[index]; - const value = args[index + 1]; - if (!flag?.startsWith("--") || value === undefined) throw new Error(`Expected a value after ${flag ?? command}.`); - const key = flag.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); - if (Object.hasOwn(values, key)) throw new Error(`Duplicate option: ${flag}.`); - values[key] = value; - } - const common = ["repo", "repoId", "owner", "remoteRepo", "number", "actor", "ledgerRef", "validationLedgerRef"]; - const allowed = { - sync: [...common, "apiUrl", "tokenFile"], - status: common, - triage: [...common, "feedbackId", "disposition", "reason"], - fix: [...common, "feedbackIds", "commit", "checkpoint", "summary"], - import: [...common, "input"], - }[command]; - for (const key of Object.keys(values)) if (!allowed.includes(key)) throw new Error(`Unsupported option: --${key}.`); - for (const key of ["owner", "remoteRepo", "number"]) if (!values[key]) throw new Error(`--${key} is required.`); - const number = Number(values.number); - if (!Number.isInteger(number) || number <= 0) throw new Error("--number must be a positive integer."); - const actor = values.actor ?? process.env.USER ?? "local-agent"; - if (command === "triage") for (const key of ["feedbackId", "disposition", "reason"]) if (!values[key]) throw new Error(`--${key} is required for triage.`); - if (command === "fix") for (const key of ["feedbackIds", "commit", "checkpoint", "summary"]) if (!values[key]) throw new Error(`--${key} is required for fix.`); - if (command === "import" && !values.input) throw new Error("--input is required for import."); - return { command, ...values, number, actor }; + if (!COMMANDS.has(command)) throw new Error("Expected command: sync, status, triage, fix, or import."); + const values = parseOptionPairs(args.slice(1), command); + assertAllowedOptions(values, ALLOWED_OPTIONS[command]); + requireOptions(values, ["owner", "remoteRepo", "number"]); + requireOptions(values, REQUIRED_OPTIONS[command], command); + return { + command, + ...values, + number: positiveNumberOption(values.number, "--number"), + actor: values.actor ?? process.env.USER ?? "local-agent", + }; } diff --git a/scripts/tabellio-run.mjs b/scripts/tabellio-run.mjs index fd7f8d7..ebc4589 100644 --- a/scripts/tabellio-run.mjs +++ b/scripts/tabellio-run.mjs @@ -3,6 +3,7 @@ import { resolve } from "node:path"; import { AgentRunManager } from "./lib/agent-run.mjs"; +import { reportCliError } from "./lib/cli-options.mjs"; try { const { command, options, trailing } = parseCommand(process.argv.slice(2)); @@ -52,12 +53,7 @@ try { if (!result.ok) process.exitCode = 1; console.log(JSON.stringify(result, null, 2)); } catch (error) { - process.exitCode = 1; - console.error(JSON.stringify({ - ok: false, - error: error instanceof Error ? error.message : String(error), - name: error instanceof Error ? error.name : "Error", - }, null, 2)); + reportCliError(error); } function parseCommand(argv) { diff --git a/scripts/tabellio-stack-operation.mjs b/scripts/tabellio-stack-operation.mjs index 0fd1e09..f182e27 100755 --- a/scripts/tabellio-stack-operation.mjs +++ b/scripts/tabellio-stack-operation.mjs @@ -4,6 +4,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { reportCliError } from "./lib/cli-options.mjs"; import { runGit } from "./lib/git-process.mjs"; import { repositoryIdentity } from "./lib/repository-identity.mjs"; import { @@ -24,12 +25,7 @@ try { if (options.command === "plan") await plan(options); else await execute(options); } catch (error) { - process.exitCode = 1; - console.error(JSON.stringify({ - ok: false, - error: error instanceof Error ? error.message : String(error), - name: error instanceof Error ? error.name : "Error", - }, null, 2)); + reportCliError(error); } async function plan(options) { diff --git a/scripts/tabellio-stack.mjs b/scripts/tabellio-stack.mjs index 40cdc82..f9187db 100755 --- a/scripts/tabellio-stack.mjs +++ b/scripts/tabellio-stack.mjs @@ -1,52 +1,30 @@ #!/usr/bin/env node -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; - +import { assertAllowedOptions, parseOptionPairs, reportCliError } from "./lib/cli-options.mjs"; import { repositoryIdentity } from "./lib/repository-identity.mjs"; +import { runSnapshotCommand } from "./lib/snapshot-command.mjs"; import { GitSpiceStackManager } from "./providers/git-spice-stack-manager.mjs"; -import { NativeGitStore } from "./providers/native-git-store.mjs"; try { const options = parseArgs(process.argv.slice(2)); - const repoPath = resolve(options.repo ?? process.cwd()); - const store = await NativeGitStore.open(repoPath); - const manager = await GitSpiceStackManager.open(store.repoPath, { - binary: options.binary ?? process.env.TABELLIO_GIT_SPICE_BIN ?? "git-spice", - }); - const snapshot = await manager.snapshot({ - repositoryId: await repositoryIdentity(store, options.repoId), + await runSnapshotCommand({ + repo: options.repo, + out: options.out, + capture: async (store) => { + const manager = await GitSpiceStackManager.open(store.repoPath, { + binary: options.binary ?? process.env.TABELLIO_GIT_SPICE_BIN ?? "git-spice", + }); + return manager.snapshot({ + repositoryId: await repositoryIdentity(store, options.repoId), + }); + }, }); - const output = `${JSON.stringify(snapshot, null, 2)}\n`; - if (options.out) { - const outPath = resolve(options.out); - await mkdir(dirname(outPath), { recursive: true }); - await writeFile(outPath, output); - } - process.stdout.write(output); } catch (error) { - process.exitCode = 1; - console.error(JSON.stringify({ - ok: false, - error: error instanceof Error ? error.message : String(error), - name: error instanceof Error ? error.name : "Error", - }, null, 2)); + reportCliError(error); } function parseArgs(argv) { - const parsed = {}; - const aliases = new Map([ - ["--repo", "repo"], - ["--repo-id", "repoId"], - ["--out", "out"], - ["--binary", "binary"], - ]); - for (let index = 0; index < argv.length; index += 1) { - const key = aliases.get(argv[index]); - if (!key) throw new Error(`Unknown argument: ${argv[index]}`); - const value = argv[++index]; - if (!value) throw new Error(`${argv[index - 1]} requires a value.`); - parsed[key] = value; - } - return parsed; + const values = parseOptionPairs(argv, "tabellio-stack"); + assertAllowedOptions(values, ["repo", "repoId", "out", "binary"]); + return values; } diff --git a/scripts/tabellio-validate.mjs b/scripts/tabellio-validate.mjs index 3c8e0f1..595d0ee 100755 --- a/scripts/tabellio-validate.mjs +++ b/scripts/tabellio-validate.mjs @@ -2,6 +2,7 @@ import { resolve } from "node:path"; +import { reportCliError } from "./lib/cli-options.mjs"; import { GitJsonLedger } from "./lib/git-json-ledger.mjs"; import { repositoryIdentity } from "./lib/repository-identity.mjs"; import { latestValidationResult, ValidationRunner } from "./lib/validation-runner.mjs"; @@ -31,12 +32,7 @@ try { if (result === null) process.exitCode = 1; } } catch (error) { - process.exitCode = 1; - console.error(JSON.stringify({ - ok: false, - error: error instanceof Error ? error.message : String(error), - name: error instanceof Error ? error.name : "Error", - }, null, 2)); + reportCliError(error); } function parseArgs(args) { diff --git a/tabellio.platform.json b/tabellio.platform.json index e8b56ba..cf06477 100644 --- a/tabellio.platform.json +++ b/tabellio.platform.json @@ -13,6 +13,8 @@ "workflow": { "stackManager": "git-spice", "controlState": "external", + "controlProvider": "github", + "controlRemoteName": "control", "publishControlRefsToCodeStorage": false, "controlRefs": [ "refs/tabellio/reviews", diff --git a/tests/context-and-evidence.test.mjs b/tests/context-and-evidence.test.mjs index da9838a..801e0eb 100644 --- a/tests/context-and-evidence.test.mjs +++ b/tests/context-and-evidence.test.mjs @@ -261,7 +261,7 @@ test("core runtime and adoption docs do not require GitHub Actions", async () => } }); -test("provider-neutral schema identifiers keep external references resolvable", async () => { +test("stable schema identifiers keep external references resolvable", async () => { const [evidenceSchema, policySchema] = await Promise.all([ readFile(`${projectRoot}/schemas/evidence-envelope.schema.json`, "utf8").then(JSON.parse), readFile(`${projectRoot}/schemas/external-action-policy.schema.json`, "utf8").then(JSON.parse), diff --git a/tests/entire-ledger-provider.test.mjs b/tests/entire-ledger-provider.test.mjs index 897e397..a3b1d53 100644 --- a/tests/entire-ledger-provider.test.mjs +++ b/tests/entire-ledger-provider.test.mjs @@ -14,10 +14,7 @@ import { runGit } from "../scripts/lib/git-process.mjs"; test("Entire adapter captures metadata-only checkpoints and binds context", async (t) => { const fixture = await createFixture(); const toolRoot = await mkdtemp(join(tmpdir(), "tabellio-entire-")); - t.after(() => Promise.all([ - rm(fixture.root, { recursive: true, force: true }), - rm(toolRoot, { recursive: true, force: true }), - ])); + t.after(() => removeTestRoots(fixture.root, toolRoot)); const checkpointId = "abcdef123456"; await runGit({ args: ["switch", "-c", "agent/entire", "main"], cwd: fixture.seed }); await writeFile(join(fixture.seed, "ENTIRE.md"), "ledger\n"); @@ -71,10 +68,7 @@ test("Entire adapter captures metadata-only checkpoints and binds context", asyn test("mandatory Entire mode rejects a change without checkpoint trailers", async (t) => { const fixture = await createFixture(); const toolRoot = await mkdtemp(join(tmpdir(), "tabellio-entire-empty-")); - t.after(() => Promise.all([ - rm(fixture.root, { recursive: true, force: true }), - rm(toolRoot, { recursive: true, force: true }), - ])); + t.after(() => removeTestRoots(fixture.root, toolRoot)); const binary = await fakeEntire(toolRoot, checkpointEnvelope("abcdef123456")); const provider = await EntireLedgerProvider.open(fixture.seed, { binary }); const store = await NativeGitStore.open(fixture.seed); @@ -98,10 +92,7 @@ test("mandatory Entire mode rejects a change without checkpoint trailers", async test("Entire adapter rejects partial checkpoint metadata", async (t) => { const fixture = await createFixture(); const toolRoot = await mkdtemp(join(tmpdir(), "tabellio-entire-partial-")); - t.after(() => Promise.all([ - rm(fixture.root, { recursive: true, force: true }), - rm(toolRoot, { recursive: true, force: true }), - ])); + t.after(() => removeTestRoots(fixture.root, toolRoot)); const checkpointId = "abcdef123456"; const binary = await fakeEntire(toolRoot, { ...checkpointEnvelope(checkpointId), partial: true }); const provider = await EntireLedgerProvider.open(fixture.seed, { binary }); @@ -131,13 +122,17 @@ function checkpointEnvelope(checkpointId) { cache_read_tokens: 20, }, summary: { - intent: "Add provider-neutral checkpoint capture.", + intent: "Add GitHub-bound checkpoint capture.", outcome: "Checkpoint metadata captured successfully.", }, }], }; } +function removeTestRoots(...roots) { + return Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); +} + async function fakeEntire(root, envelope) { const binary = join(root, "entire"); const source = `#!/usr/bin/env node diff --git a/tests/git-spice-operations.test.mjs b/tests/git-spice-operations.test.mjs index 8795bff..76121d3 100644 --- a/tests/git-spice-operations.test.mjs +++ b/tests/git-spice-operations.test.mjs @@ -23,20 +23,7 @@ const expiresAt = "2026-07-10T21:01:00.000Z"; const now = new Date("2026-07-10T20:02:00.000Z"); test("approved git-spice operations execute exact safe argument sets and consume approvals", async (t) => { - const fixture = await createFixture(); - const toolRoot = await mkdtemp(join(tmpdir(), "tabellio-git-spice-write-")); - const capturePath = join(toolRoot, "capture.json"); - const binary = await fakeGitSpice(toolRoot); - t.after(() => Promise.all([ - rm(fixture.root, { recursive: true, force: true }), - rm(toolRoot, { recursive: true, force: true }), - ])); - const operations = await ApprovedGitSpiceOperations.open({ - repoPath: fixture.seed, - stateRoot: join(toolRoot, "receipts"), - binary, - env: { GITHUB_TOKEN: "secret-token", CAPTURE_PATH: capturePath }, - }); + const { capturePath, fixture, operations } = await operationsTestFixture(t, { token: "secret-token" }); assert.ok(!JSON.stringify(operations).includes("secret-token")); const refsDigest = await repositoryRefsDigest(fixture.seed); @@ -94,19 +81,7 @@ test("approved git-spice operations execute exact safe argument sets and consume }); test("stack operations fail closed on tampering, expiry, stale heads, and dirty worktrees", async (t) => { - const fixture = await createFixture(); - const toolRoot = await mkdtemp(join(tmpdir(), "tabellio-git-spice-block-")); - const binary = await fakeGitSpice(toolRoot); - t.after(() => Promise.all([ - rm(fixture.root, { recursive: true, force: true }), - rm(toolRoot, { recursive: true, force: true }), - ])); - const operations = await ApprovedGitSpiceOperations.open({ - repoPath: fixture.seed, - stateRoot: join(toolRoot, "receipts"), - binary, - env: { CAPTURE_PATH: join(toolRoot, "capture.json") }, - }); + const { fixture, operations } = await operationsTestFixture(t); const refsDigest = await repositoryRefsDigest(fixture.seed); const intent = createStackOperationIntent({ operation: "update", @@ -150,20 +125,7 @@ test("stack operations fail closed on tampering, expiry, stale heads, and dirty }); test("failed execution consumes approval and stores only a safe error", async (t) => { - const fixture = await createFixture(); - const toolRoot = await mkdtemp(join(tmpdir(), "tabellio-git-spice-fail-")); - const binary = await fakeGitSpice(toolRoot, { fail: true }); - t.after(() => Promise.all([ - rm(fixture.root, { recursive: true, force: true }), - rm(toolRoot, { recursive: true, force: true }), - ])); - const stateRoot = join(toolRoot, "receipts"); - const operations = await ApprovedGitSpiceOperations.open({ - repoPath: fixture.seed, - stateRoot, - binary, - env: { GITHUB_TOKEN: "secret-token" }, - }); + const { fixture, operations, stateRoot } = await operationsTestFixture(t, { fail: true, token: "secret-token" }); const refsDigest = await repositoryRefsDigest(fixture.seed); const intent = createStackOperationIntent({ operation: "submit", @@ -203,6 +165,26 @@ function approvalFor(intent, id) { }; } +async function operationsTestFixture(t, { fail = false, token = null } = {}) { + const fixture = await createFixture(); + const toolRoot = await mkdtemp(join(tmpdir(), "tabellio-git-spice-operation-")); + const capturePath = join(toolRoot, "capture.json"); + const stateRoot = join(toolRoot, "receipts"); + const binary = await fakeGitSpice(toolRoot, { fail }); + t.after(() => Promise.all([ + rm(fixture.root, { recursive: true, force: true }), + rm(toolRoot, { recursive: true, force: true }), + ])); + const env = { CAPTURE_PATH: capturePath }; + if (token) env.GITHUB_TOKEN = token; + return { + capturePath, + fixture, + stateRoot, + operations: await ApprovedGitSpiceOperations.open({ repoPath: fixture.seed, stateRoot, binary, env }), + }; +} + async function fakeGitSpice(root, { fail = false } = {}) { const binary = join(root, "git-spice"); const source = `#!/usr/bin/env node diff --git a/tests/git-spice-stack-manager.test.mjs b/tests/git-spice-stack-manager.test.mjs index bbb5c58..5f02d57 100644 --- a/tests/git-spice-stack-manager.test.mjs +++ b/tests/git-spice-stack-manager.test.mjs @@ -11,14 +11,8 @@ import { } from "../scripts/providers/git-spice-stack-manager.mjs"; import { createFixture } from "./helpers/git-fixture.mjs"; -test("git-spice adapter produces provider-neutral stack snapshots", async (t) => { - const fixture = await createFixture(); - const toolRoot = await mkdtemp(join(tmpdir(), "tabellio-git-spice-")); - t.after(() => Promise.all([ - rm(fixture.root, { recursive: true, force: true }), - rm(toolRoot, { recursive: true, force: true }), - ])); - const binary = await fakeGitSpice(toolRoot, [ +test("git-spice adapter produces GitHub stack snapshots", async (t) => { + const { manager } = await stackTestFixture(t, [ { name: "feature-2", current: true, down: { name: "feature-1", needsRestack: true } }, { name: "main", @@ -33,7 +27,6 @@ test("git-spice adapter produces provider-neutral stack snapshots", async (t) => push: { ahead: 1, behind: 0, needsPush: true }, }, ]); - const manager = await GitSpiceStackManager.open(fixture.seed, { binary }); const snapshot = await manager.snapshot({ repositoryId: "example/repository", capturedAt: "2026-07-10T12:00:00.000Z", @@ -51,14 +44,7 @@ test("git-spice adapter produces provider-neutral stack snapshots", async (t) => }); test("git-spice adapter rejects malformed JSON output", async (t) => { - const fixture = await createFixture(); - const toolRoot = await mkdtemp(join(tmpdir(), "tabellio-git-spice-invalid-")); - t.after(() => Promise.all([ - rm(fixture.root, { recursive: true, force: true }), - rm(toolRoot, { recursive: true, force: true }), - ])); - const binary = await fakeGitSpice(toolRoot, null); - const manager = await GitSpiceStackManager.open(fixture.seed, { binary }); + const { manager } = await stackTestFixture(t, null); await assert.rejects( manager.snapshot({ repositoryId: "example/repository" }), @@ -67,14 +53,7 @@ test("git-spice adapter rejects malformed JSON output", async (t) => { }); test("git-spice adapter rejects upstream JSON shape drift", async (t) => { - const fixture = await createFixture(); - const toolRoot = await mkdtemp(join(tmpdir(), "tabellio-git-spice-shape-")); - t.after(() => Promise.all([ - rm(fixture.root, { recursive: true, force: true }), - rm(toolRoot, { recursive: true, force: true }), - ])); - const binary = await fakeGitSpice(toolRoot, [{ name: "main", ups: "feature" }]); - const manager = await GitSpiceStackManager.open(fixture.seed, { binary }); + const { manager } = await stackTestFixture(t, [{ name: "main", ups: "feature" }]); await assert.rejects( manager.snapshot({ repositoryId: "example/repository" }), @@ -126,6 +105,21 @@ function branch(overrides) { }; } +async function stackTestFixture(t, branches) { + const fixture = await createFixture(); + const toolRoot = await mkdtemp(join(tmpdir(), "tabellio-git-spice-")); + t.after(() => removeTestRoots(fixture.root, toolRoot)); + const binary = await fakeGitSpice(toolRoot, branches); + return { + fixture, + manager: await GitSpiceStackManager.open(fixture.seed, { binary }), + }; +} + +function removeTestRoots(...roots) { + return Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); +} + async function fakeGitSpice(root, branches) { const binary = join(root, "git-spice"); const source = `#!/usr/bin/env node diff --git a/tests/github-provider.test.mjs b/tests/github-provider.test.mjs index f6692f2..234f702 100644 --- a/tests/github-provider.test.mjs +++ b/tests/github-provider.test.mjs @@ -21,15 +21,8 @@ test("GitHub provider normalizes repositories, pull requests, reviews, comments, authorization: request.headers.authorization, accept: request.headers.accept, }); - if (url.pathname === "/repos/acme/project") return json(response, repository(baseUrl)); - if (url.pathname === "/repos/acme/project/pulls") return json(response, [pullRequest(baseUrl)]); - if (url.pathname === "/repos/acme/project/pulls/7") return json(response, pullRequest(baseUrl)); - if (url.pathname === "/repos/acme/project/pulls/7/reviews") return json(response, [review(baseUrl)]); - if (url.pathname === "/repos/acme/project/pulls/7/comments") return json(response, [reviewComment(baseUrl)]); - if (url.pathname === "/repos/acme/project/issues/7/comments") return json(response, [issueComment(baseUrl)]); - if (url.pathname === `/repos/acme/project/commits/${commit}/status`) return json(response, combinedStatus(baseUrl)); - if (url.pathname === `/repos/acme/project/commits/${commit}/check-runs`) return json(response, checkRuns(baseUrl)); - return json(response, { message: "not found" }, 404); + const route = githubFixtureRoutes(baseUrl).get(url.pathname); + return route ? json(response, route()) : json(response, { message: "not found" }, 404); }); t.after(fixture.close); @@ -229,6 +222,19 @@ function checkRuns(baseUrl) { }; } +function githubFixtureRoutes(baseUrl) { + return new Map([ + ["/repos/acme/project", () => repository(baseUrl)], + ["/repos/acme/project/pulls", () => [pullRequest(baseUrl)]], + ["/repos/acme/project/pulls/7", () => pullRequest(baseUrl)], + ["/repos/acme/project/pulls/7/reviews", () => [review(baseUrl)]], + ["/repos/acme/project/pulls/7/comments", () => [reviewComment(baseUrl)]], + ["/repos/acme/project/issues/7/comments", () => [issueComment(baseUrl)]], + [`/repos/acme/project/commits/${commit}/status`, () => combinedStatus(baseUrl)], + [`/repos/acme/project/commits/${commit}/check-runs`, () => checkRuns(baseUrl)], + ]); +} + async function startServer(handler) { const server = createServer(handler); await new Promise((resolve, reject) => { diff --git a/tests/platform-config.test.mjs b/tests/platform-config.test.mjs index 74872eb..abd7c71 100644 --- a/tests/platform-config.test.mjs +++ b/tests/platform-config.test.mjs @@ -6,7 +6,7 @@ import { validatePlatformConfig } from "../scripts/lib/platform-config.mjs"; const projectRoot = new URL("../", import.meta.url).pathname; -test("platform v0.3 makes GitHub code-only storage and external control state explicit", async () => { +test("platform v0.3 makes GitHub code-only storage and private GitHub control state explicit", async () => { const config = JSON.parse(await readFile(`${projectRoot}/tabellio.platform.json`, "utf8")); assert.equal(validatePlatformConfig(config), config); assert.deepEqual(config.codeStorage, { @@ -17,6 +17,8 @@ test("platform v0.3 makes GitHub code-only storage and external control state ex allowedRefPrefixes: ["refs/heads/", "refs/tags/"], }); assert.equal(config.workflow.controlState, "external"); + assert.equal(config.workflow.controlProvider, "github"); + assert.equal(config.workflow.controlRemoteName, "control"); assert.equal(config.workflow.publishControlRefsToCodeStorage, false); assert.equal(config.ledger.storage, "external"); assert.equal(config.validation.storage, "external"); @@ -26,13 +28,17 @@ test("platform v0.3 makes GitHub code-only storage and external control state ex test("platform v0.3 rejects provider drift and private-state publication", async () => { const config = JSON.parse(await readFile(`${projectRoot}/tabellio.platform.json`, "utf8")); assert.throws( - () => validatePlatformConfig({ ...config, codeStorage: { ...config.codeStorage, provider: "gitlab" } }), + () => validatePlatformConfig({ ...config, codeStorage: { ...config.codeStorage, provider: "unsupported" } }), /platform.codeStorage.provider must be "github"/, ); assert.throws( () => validatePlatformConfig({ ...config, workflow: { ...config.workflow, publishControlRefsToCodeStorage: true } }), /platform.workflow.publishControlRefsToCodeStorage must be false/, ); + assert.throws( + () => validatePlatformConfig({ ...config, workflow: { ...config.workflow, controlProvider: "unsupported" } }), + /platform.workflow.controlProvider must be "github"/, + ); assert.throws( () => validatePlatformConfig({ ...config, codeStorage: { ...config.codeStorage, allowedRefPrefixes: ["refs/heads/", "refs/tabellio/"] } }), /allowedRefPrefixes must contain each required value exactly once/, From 3546d541328161ec4133c80ea607b208b46fd25b Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Wed, 15 Jul 2026 07:50:59 -0700 Subject: [PATCH 4/6] Add required GitHub quality checks --- .github/workflows/quality.yml | 81 +++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 .github/workflows/quality.yml diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..ea40c9e --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,81 @@ +name: Quality + +on: + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: quality-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 20 + package-manager-cache: false + - name: Run repository checks + run: npm run check + + fallow: + name: Fallow changed-code + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out full history + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 20 + package-manager-cache: false + - name: Install pinned Fallow + run: npm install --global fallow@2.89.0 + - name: Enforce changed-code quality gate + env: + FALLOW_AGENT_SOURCE: codex + run: | + fallow audit \ + --base "origin/${{ github.base_ref }}" \ + --gate new-only \ + --format json \ + --quiet \ + --explain \ + > fallow-audit.json 2>/dev/null || true + jq '{verdict, attribution, summary}' fallow-audit.json + jq -e '.kind == "audit" and .verdict == "pass"' fallow-audit.json >/dev/null + + package: + name: Package dry-run + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 20 + package-manager-cache: false + - name: Validate package contents + run: | + npm pack --dry-run --json > package-dry-run.json + jq '.[0] | {id, size, unpackedSize, entryCount}' package-dry-run.json + jq -e ' + length == 1 and + ([.[0].files[].path | select(test("forgejo|change-request-provider"; "i"))] | length == 0) + ' package-dry-run.json >/dev/null From 99d6a69f70a3b3c9fa128efb393473ba5e1889ab Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Wed, 15 Jul 2026 08:17:55 -0700 Subject: [PATCH 5/6] Migrate durable reviews to GitHub v0.2 Add a dry-run-first atomic migration command with source-to-target identity mapping, migrate the live review ledger, and reduce the inherited Fallow baseline without deleting runtime interface methods. --- CHANGELOG.md | 2 + docs/review-loop.md | 33 ++++ scripts/lib/context-packet.mjs | 4 +- scripts/lib/control-ref-transport.mjs | 4 +- scripts/lib/external-command.mjs | 90 +++++++++ scripts/lib/git-json-ledger.mjs | 147 ++++++++++---- scripts/lib/repository-identity.mjs | 2 +- scripts/lib/review-cycle.mjs | 186 ++++++++++++++++- scripts/lib/stack-operation.mjs | 4 +- scripts/lib/validation-runner.mjs | 4 +- scripts/providers/entire-ledger-provider.mjs | 56 ++---- scripts/providers/git-spice-operations.mjs | 2 +- scripts/providers/git-spice-stack-manager.mjs | 59 ++---- scripts/tabellio-review.mjs | 38 +++- tests/git-json-ledger.test.mjs | 23 +++ tests/review-cycle.test.mjs | 187 +++++++++++++++++- 16 files changed, 687 insertions(+), 154 deletions(-) create mode 100644 scripts/lib/external-command.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 82b5a83..6b058f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to Tabellio are recorded here. - Local validation results integrated into durable review readiness. - Git-native JSON ledger on compare-and-swap refs for durable control-plane state. - Durable review cycles covering GitHub feedback, agent findings, triage, checks, fixes, and readiness. +- Dry-run-first, atomic review-cycle migration from the legacy v0.1 provider contract to GitHub-only v0.2 state. - Entire-checkpoint-bound fix records with commit remapping across git-spice restacks. - Approval-gated git-spice submit, update, sync, restack, and merge operations. - Integrity-bound operation intents, short-lived approvals, one-use receipts, and branch-set race checks. @@ -32,6 +33,7 @@ All notable changes to Tabellio are recorded here. ### Fixed +- Removed 15 unused public exports and consolidated provider subprocess handling; inherited Fallow dead-code findings dropped from 49 to 34 without deleting runtime interface methods. - Evidence artifact self-integrity now has an explicit canonical hash scope. - Handwritten policy validators now require approval booleans, unique action IDs, and non-empty forbidden side effects. - Runtime context validation now matches schema property boundaries. diff --git a/docs/review-loop.md b/docs/review-loop.md index 2f5fb5e..8af3ded 100644 --- a/docs/review-loop.md +++ b/docs/review-loop.md @@ -42,6 +42,39 @@ node scripts/tabellio-review.mjs sync \ Sync imports GitHub reviews, inline review comments, issue comments, commit statuses, check runs, and the newest Tabellio validation for the PR head. `GITHUB_TOKEN` may replace `--token-file`; `GITHUB_API_URL` or `--api-url` may target GitHub Enterprise Server. Missing GitHub items are retained as stale evidence rather than silently deleted. A PR with no validation remains `validating`. +## Migrate A v0.1 Cycle + +Review cycles created before the GitHub-only boundary use `tabellio-review-cycle/v0.1` and a legacy ledger path. Preview the deterministic migration first: + +```bash +node scripts/tabellio-review.mjs migrate \ + --repo . \ + --repo-id IntelIP/Tabellio \ + --owner IntelIP \ + --remote-repo Tabellio \ + --number 7 \ + --legacy-repo-id old-owner/old-repository \ + --legacy-owner old-owner \ + --legacy-remote-repo old-repository +``` + +Apply the verified plan explicitly: + +```bash +node scripts/tabellio-review.mjs migrate \ + --repo . \ + --repo-id IntelIP/Tabellio \ + --owner IntelIP \ + --remote-repo Tabellio \ + --number 7 \ + --legacy-repo-id old-owner/old-repository \ + --legacy-owner old-owner \ + --legacy-remote-repo old-repository \ + --apply true +``` + +Legacy identity options default to the target coordinates, so they are needed only when the repository moved or was renamed. The migration verifies the v0.1 integrity digest and source identity, maps repository and provider identity to the GitHub target, rewrites feedback sources, replaces the pull-request URL with its canonical GitHub URL, clears obsolete provider check links, recomputes integrity, and atomically moves the ledger entry to its v0.2 path. The current ledger tree contains only the v0.2 entry; the original remains recoverable from Git history. Repeating the command reports `current` without writing another commit. + ## Import A Codex Review Codex and other agents emit `tabellio-agent-review/v0.1`: diff --git a/scripts/lib/context-packet.mjs b/scripts/lib/context-packet.mjs index 2cd1238..e16c336 100644 --- a/scripts/lib/context-packet.mjs +++ b/scripts/lib/context-packet.mjs @@ -1,8 +1,8 @@ import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; -export const CONTEXT_SCHEMA_VERSION = "tabellio-context/v0.1"; -export const CONTEXT_INTEGRITY_SCOPE = "canonical-json-without-integrity"; +const CONTEXT_SCHEMA_VERSION = "tabellio-context/v0.1"; +const CONTEXT_INTEGRITY_SCOPE = "canonical-json-without-integrity"; export function createContextPacket(input) { const packet = { diff --git a/scripts/lib/control-ref-transport.mjs b/scripts/lib/control-ref-transport.mjs index 508ad0a..eb58388 100644 --- a/scripts/lib/control-ref-transport.mjs +++ b/scripts/lib/control-ref-transport.mjs @@ -7,8 +7,8 @@ import { runGit } from "./git-process.mjs"; import { withOperationLock } from "./operation-lock.mjs"; import { digestObject } from "./stack-operation.mjs"; -export const CONTROL_REF_INTENT_VERSION = "tabellio-control-ref-operation/v0.1"; -export const CONTROL_REF_APPROVAL_VERSION = "tabellio-control-ref-approval/v0.1"; +const CONTROL_REF_INTENT_VERSION = "tabellio-control-ref-operation/v0.1"; +const CONTROL_REF_APPROVAL_VERSION = "tabellio-control-ref-approval/v0.1"; const DEFAULT_REMOTE_TIMEOUT_MS = 15 * 60 * 1000; const CODE_STORAGE_REMOTE = "origin"; export const CONTROL_REFS = [ diff --git a/scripts/lib/external-command.mjs b/scripts/lib/external-command.mjs new file mode 100644 index 0000000..b002e5e --- /dev/null +++ b/scripts/lib/external-command.mjs @@ -0,0 +1,90 @@ +import { execFile } from "node:child_process"; + +export class ExternalCommandError extends Error { + constructor({ binary, args, cwd, exitCode, signal, stdout, stderr, cause }) { + const unavailable = cause?.code === "ENOENT"; + super(unavailable + ? `${binary} is not installed or not executable.` + : `${[binary, ...args].join(" ")} failed with exit code ${exitCode ?? "unknown"}.`); + this.command = [binary, ...args].join(" "); + this.args = args; + this.cwd = cwd; + this.exitCode = exitCode; + this.signal = signal; + this.stdout = stdout; + this.stderr = stderr; + this.cause = cause; + } +} + +export function runExternalCommand({ + binary, + args, + cwd = process.cwd(), + timeoutMs, + env = {}, + ErrorType = ExternalCommandError, + argumentLabel = binary, +}) { + requiredString(binary, "binary"); + validateArguments(args, argumentLabel); + return new Promise((resolve, reject) => { + execFile(binary, args, commandOptions(cwd, timeoutMs, env), (error, stdout = "", stderr = "") => { + finishCommand({ error, stdout, stderr, binary, args, cwd, ErrorType, resolve, reject }); + }); + }); +} + +function commandOptions(cwd, timeoutMs, env) { + return { + cwd, + encoding: "utf8", + timeout: timeoutMs, + maxBuffer: 10 * 1024 * 1024, + env: { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + LC_ALL: "C", + NO_COLOR: "1", + ...env, + }, + }; +} + +function finishCommand({ error, stdout, stderr, binary, args, cwd, ErrorType, resolve, reject }) { + const result = { + binary, + args, + cwd, + exitCode: commandExitCode(error), + signal: commandSignal(error), + stdout, + stderr, + cause: commandCause(error), + }; + if (error) reject(new ErrorType(result)); + else resolve(result); +} + +function commandSignal(error) { + return error?.signal ?? null; +} + +function commandCause(error) { + return error ?? null; +} + +function commandExitCode(error) { + if (!error) return 0; + return typeof error.code === "number" ? error.code : null; +} + +function validateArguments(args, label) { + if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string")) { + throw new TypeError(`${label} arguments must be an array of strings.`); + } +} + +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/git-json-ledger.mjs b/scripts/lib/git-json-ledger.mjs index d57f4e0..c429488 100644 --- a/scripts/lib/git-json-ledger.mjs +++ b/scripts/lib/git-json-ledger.mjs @@ -65,32 +65,27 @@ export class GitJsonLedger { }; } - async write(path, value, { expectedVersion }) { + async write(path, value, { expectedVersion, replacePath = null }) { validateLedgerPath(path); - if (expectedVersion !== null && !isOid(expectedVersion)) { - throw new TypeError("expectedVersion must be null or a Git object ID."); + if (replacePath !== null) { + validateLedgerPath(replacePath); + if (replacePath === path) throw new Error("ledger replacement and destination paths must differ."); } + return this.#commit({ path, value, expectedVersion, sourcePath: replacePath }); + } + + async #commit({ path, value, expectedVersion, sourcePath = null }) { + validateExpectedVersion(expectedVersion); const currentVersion = await this.version(); - if (currentVersion !== expectedVersion) { - throw new LedgerConflictError({ ref: this.ref, expected: expectedVersion, actual: currentVersion }); - } - const serialized = JSON.stringify(value, null, 2); - if (serialized === undefined) throw new TypeError("ledger value must be JSON-serializable."); - - const common = await runGit({ args: ["rev-parse", "--git-common-dir"], cwd: this.repoPath }); - const temporaryRoot = resolve(this.repoPath, common.stdout.trim(), "tabellio", "tmp"); - await mkdir(temporaryRoot, { recursive: true }); - const nonce = randomUUID(); - const indexPath = resolve(temporaryRoot, `${nonce}.index`); - const valuePath = resolve(temporaryRoot, `${nonce}.json`); + assertExpectedVersion(this.ref, expectedVersion, currentVersion); + const serialized = serializeLedgerValue(value); + await validateReplacement(this.repoPath, currentVersion, sourcePath, path); + const { indexPath, valuePath } = await temporaryLedgerPaths(this.repoPath); const env = { GIT_INDEX_FILE: indexPath }; try { await writeFile(valuePath, `${serialized}\n`, { flag: "wx" }); - await runGit({ - args: currentVersion === null ? ["read-tree", "--empty"] : ["read-tree", currentVersion], - cwd: this.repoPath, - env, - }); + await initializeLedgerIndex(this.repoPath, currentVersion, env); + await removeLedgerSource(this.repoPath, sourcePath, env); const blob = await runGit({ args: ["hash-object", "-w", "--", valuePath], cwd: this.repoPath }); await runGit({ args: ["update-index", "--add", "--cacheinfo", `100644,${blob.stdout.trim()},${path}`], @@ -98,27 +93,10 @@ export class GitJsonLedger { env, }); const tree = await runGit({ args: ["write-tree"], cwd: this.repoPath, env }); - const commitArgs = ["commit-tree", tree.stdout.trim(), "-m", `tabellio ledger: ${path}`]; - if (currentVersion !== null) commitArgs.push("-p", currentVersion); - const commit = await runGit({ - args: commitArgs, - cwd: this.repoPath, - env: { - GIT_AUTHOR_NAME: "Tabellio", - GIT_AUTHOR_EMAIL: "tabellio@example.invalid", - GIT_COMMITTER_NAME: "Tabellio", - GIT_COMMITTER_EMAIL: "tabellio@example.invalid", - }, - }); + const commit = await createLedgerCommit(this.repoPath, tree.stdout.trim(), currentVersion, sourcePath, path); const newVersion = commit.stdout.trim(); - const expected = currentVersion ?? "0".repeat(newVersion.length); - try { - await runGit({ args: ["update-ref", this.ref, newVersion, expected], cwd: this.repoPath }); - } catch (error) { - const actual = await this.version(); - throw new LedgerConflictError({ ref: this.ref, expected: currentVersion, actual }); - } - return { path, version: newVersion, previousVersion: currentVersion }; + await updateLedgerRef(this, currentVersion, newVersion); + return { path, sourcePath, version: newVersion, previousVersion: currentVersion }; } finally { await Promise.all([ rm(indexPath, { force: true }), @@ -128,6 +106,95 @@ export class GitJsonLedger { } } +function validateExpectedVersion(expectedVersion) { + if (expectedVersion !== null && !isOid(expectedVersion)) { + throw new TypeError("expectedVersion must be null or a Git object ID."); + } +} + +function assertExpectedVersion(ref, expectedVersion, currentVersion) { + if (currentVersion !== expectedVersion) { + throw new LedgerConflictError({ ref, expected: expectedVersion, actual: currentVersion }); + } +} + +function serializeLedgerValue(value) { + const serialized = JSON.stringify(value, null, 2); + if (serialized === undefined) throw new TypeError("ledger value must be JSON-serializable."); + return serialized; +} + +async function validateReplacement(repoPath, currentVersion, sourcePath, path) { + if (sourcePath === null) return; + if (currentVersion === null) throw new Error(`Ledger entry ${sourcePath} does not exist.`); + await requireTreeEntry(repoPath, currentVersion, sourcePath); + await requireMissingTreeEntry(repoPath, currentVersion, path); +} + +async function requireTreeEntry(repoPath, version, path) { + if (!(await treeEntryExists(repoPath, version, path))) throw new Error(`Ledger entry ${path} does not exist.`); +} + +async function requireMissingTreeEntry(repoPath, version, path) { + if (await treeEntryExists(repoPath, version, path)) throw new Error(`Ledger entry ${path} already exists.`); +} + +async function temporaryLedgerPaths(repoPath) { + const common = await runGit({ args: ["rev-parse", "--git-common-dir"], cwd: repoPath }); + const root = resolve(repoPath, common.stdout.trim(), "tabellio", "tmp"); + await mkdir(root, { recursive: true }); + const nonce = randomUUID(); + return { + indexPath: resolve(root, `${nonce}.index`), + valuePath: resolve(root, `${nonce}.json`), + }; +} + +function initializeLedgerIndex(repoPath, currentVersion, env) { + const args = currentVersion === null ? ["read-tree", "--empty"] : ["read-tree", currentVersion]; + return runGit({ args, cwd: repoPath, env }); +} + +function removeLedgerSource(repoPath, sourcePath, env) { + if (sourcePath === null) return Promise.resolve(); + return runGit({ args: ["update-index", "--force-remove", "--", sourcePath], cwd: repoPath, env }); +} + +function createLedgerCommit(repoPath, tree, currentVersion, sourcePath, path) { + const detail = sourcePath === null ? path : `${sourcePath} -> ${path}`; + const args = ["commit-tree", tree, "-m", `tabellio ledger: ${detail}`]; + if (currentVersion !== null) args.push("-p", currentVersion); + return runGit({ + args, + cwd: repoPath, + env: { + GIT_AUTHOR_NAME: "Tabellio", + GIT_AUTHOR_EMAIL: "tabellio@example.invalid", + GIT_COMMITTER_NAME: "Tabellio", + GIT_COMMITTER_EMAIL: "tabellio@example.invalid", + }, + }); +} + +async function updateLedgerRef(ledger, currentVersion, newVersion) { + const expected = currentVersion ?? "0".repeat(newVersion.length); + try { + await runGit({ args: ["update-ref", ledger.ref, newVersion, expected], cwd: ledger.repoPath }); + } catch { + const actual = await ledger.version(); + throw new LedgerConflictError({ ref: ledger.ref, expected: currentVersion, actual }); + } +} + +async function treeEntryExists(repoPath, version, path) { + const result = await runGit({ + args: ["cat-file", "-e", `${version}:${path}`], + cwd: repoPath, + acceptableExitCodes: [0, 128], + }); + return result.exitCode === 0; +} + export class LedgerConflictError extends Error { constructor({ ref, expected, actual }) { super(`Ledger ${ref} changed concurrently; expected ${expected ?? "missing"}, found ${actual ?? "missing"}.`); diff --git a/scripts/lib/repository-identity.mjs b/scripts/lib/repository-identity.mjs index f9d5f4c..f8661a9 100644 --- a/scripts/lib/repository-identity.mjs +++ b/scripts/lib/repository-identity.mjs @@ -6,7 +6,7 @@ export async function repositoryIdentity(store, explicitId = null) { return remote ? normalizeRepositoryRemote(remote) : localRepositoryId(store.repoPath); } -export function normalizeRepositoryRemote(remote) { +function normalizeRepositoryRemote(remote) { if (/^[A-Za-z]:[\\/]/.test(remote) || remote.startsWith("/") || remote.startsWith("\\\\")) { return hashedRemote(remote); } diff --git a/scripts/lib/review-cycle.mjs b/scripts/lib/review-cycle.mjs index 3fe194e..12673ec 100644 --- a/scripts/lib/review-cycle.mjs +++ b/scripts/lib/review-cycle.mjs @@ -4,8 +4,9 @@ import { runGit } from "./git-process.mjs"; import { digestObject } from "./stack-operation.mjs"; import { latestValidationResult } from "./validation-runner.mjs"; -export const REVIEW_CYCLE_SCHEMA_VERSION = "tabellio-review-cycle/v0.2"; -export const AGENT_REVIEW_SCHEMA_VERSION = "tabellio-agent-review/v0.1"; +const REVIEW_CYCLE_SCHEMA_VERSION = "tabellio-review-cycle/v0.2"; +const AGENT_REVIEW_SCHEMA_VERSION = "tabellio-agent-review/v0.1"; +const LEGACY_REVIEW_CYCLE_SCHEMA_VERSION = "tabellio-review-cycle/v0.1"; const MAX_FEEDBACK_ITEMS = 5_000; const MAX_AGENT_FINDINGS = 1_000; const MAX_TEXT_BODY = 64 * 1024; @@ -193,6 +194,35 @@ export class ReviewCycleManager { return this.#required(number); } + async migrate({ + number, + apply = false, + legacyRepositoryId = null, + legacyOwner = null, + legacyRepo = null, + }) { + positiveInteger(number, "number"); + boolean(apply, "apply"); + const target = { + repositoryId: this.repositoryId, + owner: this.owner, + repo: this.repo, + number, + }; + const source = legacyCycleIdentity(target, { legacyRepositoryId, legacyOwner, legacyRepo }); + const paths = { + legacy: reviewCyclePaths(source).legacy, + current: reviewCyclePaths(target).current, + }; + const [legacy, current] = await Promise.all([ + this.ledger.read(paths.legacy), + this.ledger.read(paths.current), + ]); + assertStableMigrationRead(legacy, current); + if (current.value) return currentMigrationResult({ legacy, current, paths, identity: target }); + return legacyMigrationResult({ ledger: this.ledger, legacy, paths, source, target, apply }); + } + async #read(number) { positiveInteger(number, "number"); return this.ledger.read(cyclePath(this.repositoryId, this.owner, this.repo, number)); @@ -347,6 +377,42 @@ export function validateAgentReview(value) { return value; } +export function migrateReviewCycleV1ToV2(value, { source, target }) { + if (value?.schemaVersion === REVIEW_CYCLE_SCHEMA_VERSION) { + validateReviewCycle(value); + assertCycleIdentity(value, target); + return { cycle: structuredClone(value), changed: false, clearedProviderUrls: 0 }; + } + validateLegacyReviewCycle(value, source); + const cycle = structuredClone(value); + const clearedProviderUrls = cycle.checks.statuses.filter((status) => status.targetUrl !== null).length; + cycle.schemaVersion = REVIEW_CYCLE_SCHEMA_VERSION; + cycle.repository.id = target.repositoryId; + cycle.provider = { id: "github", owner: target.owner, repo: target.repo }; + cycle.changeRequest.url = githubPullRequestUrl(target.owner, target.repo, target.number); + cycle.feedback = cycle.feedback.map((item) => ({ + ...item, + source: item.source === "forgejo" ? "github" : item.source, + })); + cycle.checks.statuses = cycle.checks.statuses.map((status) => ({ ...status, targetUrl: null })); + cycle.integrity = { algorithm: "sha256", digest: cycleDigest(cycle) }; + validateReviewCycle(cycle); + assertCycleIdentity(cycle, target); + return { cycle, changed: true, clearedProviderUrls }; +} + +export function reviewCyclePaths({ repositoryId, owner, repo, number }) { + requiredString(repositoryId, "repositoryId"); + requiredString(owner, "owner"); + requiredString(repo, "repo"); + positiveInteger(number, "number"); + const suffix = `${number}-${createHash("sha256").update(`${repositoryId}\0${owner}\0${repo}`).digest("hex").slice(0, 16)}.json`; + return { + legacy: `cycles/forgejo-${suffix}`, + current: `cycles/github-${suffix}`, + }; +} + function mergeProviderFeedback(existing, incoming, timestamp) { const prior = new Map(existing.map((item) => [item.id, item])); const incomingIds = new Set(incoming.map((item) => item.id)); @@ -517,7 +583,109 @@ function cycleId(repositoryId, owner, repo, number) { } function cyclePath(repositoryId, owner, repo, number) { - return `cycles/github-${number}-${createHash("sha256").update(`${repositoryId}\0${owner}\0${repo}`).digest("hex").slice(0, 16)}.json`; + return reviewCyclePaths({ repositoryId, owner, repo, number }).current; +} + +function validateLegacyReviewCycle(value, identity) { + object(value, "legacy review cycle"); + exactKeys(value, ["schemaVersion", "id", "repository", "provider", "changeRequest", "status", "round", "feedback", "fixes", "checks", "events", "createdAt", "updatedAt", "integrity"], "legacy review cycle"); + equals(value.schemaVersion, LEGACY_REVIEW_CYCLE_SCHEMA_VERSION, "legacy review cycle.schemaVersion"); + object(value.provider, "legacy review cycle.provider"); + exactKeys(value.provider, ["id", "owner", "repo"], "legacy review cycle.provider"); + equals(value.provider.id, "forgejo", "legacy review cycle.provider.id"); + if (!Array.isArray(value.feedback)) throw new Error("legacy review cycle.feedback must be an array."); + value.feedback.forEach((item, index) => member(item?.source, ["forgejo", "agent"], `legacy review cycle.feedback[${index}].source`)); + object(value.integrity, "legacy review cycle.integrity"); + exactKeys(value.integrity, ["algorithm", "digest"], "legacy review cycle.integrity"); + equals(value.integrity.algorithm, "sha256", "legacy review cycle.integrity.algorithm"); + if (!/^[0-9a-f]{64}$/.test(value.integrity.digest)) throw new Error("legacy review cycle.integrity.digest must be a SHA-256 digest."); + if (cycleDigest(value) !== value.integrity.digest) throw new Error("legacy review cycle integrity digest does not match."); + validateChangeRequest(value.changeRequest); + validateChecks(value.checks); + assertCycleIdentity(value, identity); +} + +function assertCycleIdentity(cycle, { repositoryId, owner, repo, number }) { + equals(cycle.repository.id, repositoryId, "review cycle.repository.id"); + equals(cycle.provider.owner, owner, "review cycle.provider.owner"); + equals(cycle.provider.repo, repo, "review cycle.provider.repo"); + equals(cycle.changeRequest.number, number, "review cycle.changeRequest.number"); +} + +function assertStableMigrationRead(legacy, current) { + if (legacy.version !== current.version) { + throw new Error("Review ledger changed while migration state was being read; retry."); + } +} + +function currentMigrationResult({ legacy, current, paths, identity }) { + validateReviewCycle(current.value); + assertCycleIdentity(current.value, identity); + if (legacy.value) throw new Error(`Both ${paths.legacy} and ${paths.current} exist; refusing ambiguous migration.`); + return { + state: "current", + changed: false, + applied: false, + sourcePath: null, + path: paths.current, + version: current.version, + clearedProviderUrls: 0, + cycle: current.value, + }; +} + +async function legacyMigrationResult({ ledger, legacy, paths, source, target, apply }) { + if (!legacy.value) throw new Error(`Legacy review cycle ${source.number} does not exist at ${paths.legacy}.`); + const migrated = migrateReviewCycleV1ToV2(legacy.value, { source, target }); + if (!apply) return previewMigrationResult(legacy, paths, migrated); + const written = await ledger.write(paths.current, migrated.cycle, { + expectedVersion: legacy.version, + replacePath: paths.legacy, + }); + return appliedMigrationResult(written, paths, migrated); +} + +function legacyCycleIdentity(target, { legacyRepositoryId, legacyOwner, legacyRepo }) { + return { + repositoryId: legacyRepositoryId ?? target.repositoryId, + owner: legacyOwner ?? target.owner, + repo: legacyRepo ?? target.repo, + number: target.number, + }; +} + +function previewMigrationResult(legacy, paths, migrated) { + return { + state: "preview", + changed: true, + applied: false, + sourcePath: paths.legacy, + path: paths.current, + version: legacy.version, + clearedProviderUrls: migrated.clearedProviderUrls, + cycle: migrated.cycle, + }; +} + +function appliedMigrationResult(written, paths, migrated) { + return { + state: "migrated", + changed: true, + applied: true, + sourcePath: paths.legacy, + path: paths.current, + version: written.version, + previousVersion: written.previousVersion, + clearedProviderUrls: migrated.clearedProviderUrls, + cycle: migrated.cycle, + }; +} + +function githubPullRequestUrl(owner, repo, number) { + requiredString(owner, "owner"); + requiredString(repo, "repo"); + positiveInteger(number, "number"); + return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pull/${number}`; } function event(type, actor, at, detail) { @@ -594,6 +762,18 @@ function validateChecks(value) { if (!Number.isInteger(value.total) || value.total < 0) throw new Error("checks.total must be a non-negative integer."); if (!Array.isArray(value.statuses)) throw new Error("checks.statuses must be an array."); if (value.statuses.length > 1_000) throw new Error("checks.statuses must contain at most 1000 entries."); + for (const [index, status] of value.statuses.entries()) { + const path = `checks.statuses[${index}]`; + object(status, path); + exactKeys(status, ["id", "context", "state", "description", "targetUrl", "createdAt", "updatedAt"], path); + requiredString(status.id, `${path}.id`); + requiredString(status.context, `${path}.context`); + requiredString(status.state, `${path}.state`); + if (status.description !== null) requiredString(status.description, `${path}.description`); + if (status.targetUrl !== null) httpUrl(status.targetUrl, `${path}.targetUrl`); + if (status.createdAt !== null) date(status.createdAt, `${path}.createdAt`); + if (status.updatedAt !== null) date(status.updatedAt, `${path}.updatedAt`); + } } function validateEvent(value, path) { diff --git a/scripts/lib/stack-operation.mjs b/scripts/lib/stack-operation.mjs index ead8a8e..0fe21e9 100644 --- a/scripts/lib/stack-operation.mjs +++ b/scripts/lib/stack-operation.mjs @@ -2,8 +2,8 @@ import { createHash } from "node:crypto"; import { validateOperationApproval } from "./approval-validation.mjs"; -export const STACK_OPERATION_SCHEMA_VERSION = "tabellio-stack-operation/v0.1"; -export const STACK_APPROVAL_SCHEMA_VERSION = "tabellio-stack-approval/v0.1"; +const STACK_OPERATION_SCHEMA_VERSION = "tabellio-stack-operation/v0.1"; +const STACK_APPROVAL_SCHEMA_VERSION = "tabellio-stack-approval/v0.1"; export const STACK_OPERATIONS = ["submit", "update", "sync", "restack", "merge"]; export function createStackOperationIntent({ diff --git a/scripts/lib/validation-runner.mjs b/scripts/lib/validation-runner.mjs index ddf46c7..faa8714 100644 --- a/scripts/lib/validation-runner.mjs +++ b/scripts/lib/validation-runner.mjs @@ -7,8 +7,8 @@ import { LedgerConflictError } from "./git-json-ledger.mjs"; import { runGit } from "./git-process.mjs"; import { digestObject } from "./stack-operation.mjs"; -export const VALIDATION_MANIFEST_SCHEMA_VERSION = "tabellio-validation/v0.1"; -export const VALIDATION_RESULT_SCHEMA_VERSION = "tabellio-validation-result/v0.1"; +const VALIDATION_MANIFEST_SCHEMA_VERSION = "tabellio-validation/v0.1"; +const VALIDATION_RESULT_SCHEMA_VERSION = "tabellio-validation-result/v0.1"; const MAX_OUTPUT_TAIL_BYTES = 16 * 1024; export class ValidationRunner { diff --git a/scripts/providers/entire-ledger-provider.mjs b/scripts/providers/entire-ledger-provider.mjs index 9438a87..fcb8447 100644 --- a/scripts/providers/entire-ledger-provider.mjs +++ b/scripts/providers/entire-ledger-provider.mjs @@ -1,8 +1,8 @@ import { createHash } from "node:crypto"; -import { execFile } from "node:child_process"; import { realpath } from "node:fs/promises"; import { canonicalJson } from "../lib/context-packet.mjs"; +import { ExternalCommandError, runExternalCommand } from "../lib/external-command.mjs"; import { runGit } from "../lib/git-process.mjs"; import { LEDGER_SCHEMA_VERSION, LedgerProvider, validateLedgerSnapshot } from "../lib/ledger-provider.mjs"; @@ -116,60 +116,32 @@ export class EntireLedgerProvider extends LedgerProvider { } } -export class EntireCommandError extends Error { - constructor({ binary, args, cwd, exitCode, signal, stdout, stderr, cause }) { - const unavailable = cause?.code === "ENOENT"; - super(unavailable - ? `${binary} is not installed or not executable.` - : `${[binary, ...args].join(" ")} failed with exit code ${exitCode ?? "unknown"}.`); +class EntireCommandError extends ExternalCommandError { + constructor(result) { + super(result); this.name = "EntireCommandError"; - this.command = [binary, ...args].join(" "); - this.args = args; - this.cwd = cwd; - this.exitCode = exitCode; - this.signal = signal; - this.stdout = stdout; - this.stderr = stderr; - this.cause = cause; } } -export function runEntire({ +function runEntire({ binary = "entire", args, cwd = process.cwd(), timeoutMs = DEFAULT_TIMEOUT_MS, env = {}, }) { - requiredString(binary, "binary"); - if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string")) { - throw new TypeError("Entire arguments must be an array of strings."); - } - return new Promise((resolve, reject) => { - execFile(binary, args, { - cwd, - encoding: "utf8", - timeout: timeoutMs, - maxBuffer: 10 * 1024 * 1024, - env: { - ...process.env, - GIT_TERMINAL_PROMPT: "0", - LC_ALL: "C", - NO_COLOR: "1", - ...env, - }, - }, (error, stdout = "", stderr = "") => { - const exitCode = typeof error?.code === "number" ? error.code : error ? null : 0; - const result = { - binary, args, cwd, exitCode, signal: error?.signal ?? null, stdout, stderr, cause: error ?? null, - }; - if (!error) resolve(result); - else reject(new EntireCommandError(result)); - }); + return runExternalCommand({ + binary, + args, + cwd, + timeoutMs, + env, + ErrorType: EntireCommandError, + argumentLabel: "Entire", }); } -export function checkpointReferences(snapshot) { +function checkpointReferences(snapshot) { validateLedgerSnapshot(snapshot); return snapshot.checkpoints.flatMap((checkpoint) => checkpoint.commits.map((commit) => ({ provider: "entire", diff --git a/scripts/providers/git-spice-operations.mjs b/scripts/providers/git-spice-operations.mjs index 36e7664..8145673 100644 --- a/scripts/providers/git-spice-operations.mjs +++ b/scripts/providers/git-spice-operations.mjs @@ -135,7 +135,7 @@ export class ApprovedGitSpiceOperations { } } -export class StackOperationExecutionError extends Error { +class StackOperationExecutionError extends Error { constructor(operation, cause, secrets = []) { const detail = sanitize(cause?.stderr, secrets); super(`git-spice ${operation} operation failed${detail ? `: ${detail}` : "."}`); diff --git a/scripts/providers/git-spice-stack-manager.mjs b/scripts/providers/git-spice-stack-manager.mjs index 3681d1d..de1d73d 100644 --- a/scripts/providers/git-spice-stack-manager.mjs +++ b/scripts/providers/git-spice-stack-manager.mjs @@ -1,6 +1,6 @@ -import { execFile } from "node:child_process"; import { realpath } from "node:fs/promises"; +import { ExternalCommandError, runExternalCommand } from "../lib/external-command.mjs"; import { runGit } from "../lib/git-process.mjs"; import { STACK_SCHEMA_VERSION, StackManager, validateStackSnapshot } from "../lib/stack-manager.mjs"; @@ -71,21 +71,10 @@ export class GitSpiceStackManager extends StackManager { } } -export class GitSpiceCommandError extends Error { - constructor({ binary, args, cwd, exitCode, signal, stdout, stderr, cause }) { - const unavailable = cause?.code === "ENOENT"; - super(unavailable - ? `${binary} is not installed or not executable.` - : `${[binary, ...args].join(" ")} failed with exit code ${exitCode ?? "unknown"}.`); +export class GitSpiceCommandError extends ExternalCommandError { + constructor(result) { + super(result); this.name = "GitSpiceCommandError"; - this.command = [binary, ...args].join(" "); - this.args = args; - this.cwd = cwd; - this.exitCode = exitCode; - this.signal = signal; - this.stdout = stdout; - this.stderr = stderr; - this.cause = cause; } } @@ -96,38 +85,14 @@ export function runGitSpice({ timeoutMs = DEFAULT_TIMEOUT_MS, env = {}, }) { - requiredString(binary, "binary"); - if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string")) { - throw new TypeError("git-spice arguments must be an array of strings."); - } - return new Promise((resolve, reject) => { - execFile(binary, args, { - cwd, - encoding: "utf8", - timeout: timeoutMs, - maxBuffer: 10 * 1024 * 1024, - env: { - ...process.env, - GIT_TERMINAL_PROMPT: "0", - LC_ALL: "C", - NO_COLOR: "1", - ...env, - }, - }, (error, stdout = "", stderr = "") => { - const exitCode = typeof error?.code === "number" ? error.code : error ? null : 0; - const result = { - binary, - args, - cwd, - exitCode, - signal: error?.signal ?? null, - stdout, - stderr, - cause: error ?? null, - }; - if (!error) resolve(result); - else reject(new GitSpiceCommandError(result)); - }); + return runExternalCommand({ + binary, + args, + cwd, + timeoutMs, + env, + ErrorType: GitSpiceCommandError, + argumentLabel: "git-spice", }); } diff --git a/scripts/tabellio-review.mjs b/scripts/tabellio-review.mjs index 6c76238..62382be 100755 --- a/scripts/tabellio-review.mjs +++ b/scripts/tabellio-review.mjs @@ -16,7 +16,7 @@ import { ReviewCycleManager } from "./lib/review-cycle.mjs"; import { GitHubProvider } from "./providers/github-provider.mjs"; import { NativeGitStore } from "./providers/native-git-store.mjs"; -const COMMANDS = new Set(["sync", "status", "triage", "fix", "import"]); +const COMMANDS = new Set(["sync", "status", "triage", "fix", "import", "migrate"]); const COMMON_OPTIONS = ["repo", "repoId", "owner", "remoteRepo", "number", "actor", "ledgerRef", "validationLedgerRef"]; const ALLOWED_OPTIONS = { sync: [...COMMON_OPTIONS, "apiUrl", "tokenFile"], @@ -24,6 +24,7 @@ const ALLOWED_OPTIONS = { triage: [...COMMON_OPTIONS, "feedbackId", "disposition", "reason"], fix: [...COMMON_OPTIONS, "feedbackIds", "commit", "checkpoint", "summary"], import: [...COMMON_OPTIONS, "input"], + migrate: [...COMMON_OPTIONS, "apply", "legacyRepoId", "legacyOwner", "legacyRemoteRepo"], }; const REQUIRED_OPTIONS = { sync: [], @@ -31,6 +32,7 @@ const REQUIRED_OPTIONS = { triage: ["feedbackId", "disposition", "reason"], fix: ["feedbackIds", "commit", "checkpoint", "summary"], import: ["input"], + migrate: [], }; const COMMAND_HANDLERS = { sync: syncReview, @@ -38,6 +40,7 @@ const COMMAND_HANDLERS = { triage: triageReview, fix: recordReviewFix, import: importAgentReview, + migrate: migrateReview, }; main().catch(reportCliError); @@ -117,9 +120,19 @@ async function importAgentReview(manager, options) { }); } +function migrateReview(manager, options) { + return manager.migrate({ + number: options.number, + apply: options.apply, + legacyRepositoryId: options.legacyRepoId, + legacyOwner: options.legacyOwner, + legacyRepo: options.legacyRemoteRepo, + }); +} + function parseArgs(args) { const command = args[0]; - if (!COMMANDS.has(command)) throw new Error("Expected command: sync, status, triage, fix, or import."); + requireCommand(command); const values = parseOptionPairs(args.slice(1), command); assertAllowedOptions(values, ALLOWED_OPTIONS[command]); requireOptions(values, ["owner", "remoteRepo", "number"]); @@ -128,6 +141,25 @@ function parseArgs(args) { command, ...values, number: positiveNumberOption(values.number, "--number"), - actor: values.actor ?? process.env.USER ?? "local-agent", + actor: defaultActor(values.actor), + ...migrationOptions(command, values), }; } + +function requireCommand(command) { + if (!COMMANDS.has(command)) throw new Error("Expected command: sync, status, triage, fix, import, or migrate."); +} + +function defaultActor(actor) { + return actor ?? process.env.USER ?? "local-agent"; +} + +function migrationOptions(command, values) { + if (command !== "migrate") return {}; + return { apply: booleanOption(values.apply ?? "false", "--apply") }; +} + +function booleanOption(value, flag) { + if (!["true", "false"].includes(value)) throw new Error(`${flag} must be true or false.`); + return value === "true"; +} diff --git a/tests/git-json-ledger.test.mjs b/tests/git-json-ledger.test.mjs index d9ad61c..7955103 100644 --- a/tests/git-json-ledger.test.mjs +++ b/tests/git-json-ledger.test.mjs @@ -41,6 +41,29 @@ test("Git JSON ledger works in a bare repository", async (t) => { assert.deepEqual(await ledger.read("cycles/bare.json"), { value: { durable: true }, version: written.version }); }); +test("Git JSON ledger atomically moves and rewrites an entry", async (t) => { + const fixture = await createFixture(); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + const ledger = await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/reviews" }); + const first = await ledger.write("cycles/legacy.json", { schema: "v1" }, { expectedVersion: null }); + const moved = await ledger.write( + "cycles/current.json", + { schema: "v2" }, + { expectedVersion: first.version, replacePath: "cycles/legacy.json" }, + ); + + assert.equal((await ledger.read("cycles/legacy.json")).value, null); + assert.deepEqual(await ledger.read("cycles/current.json"), { + value: { schema: "v2" }, + version: moved.version, + }); + assert.deepEqual((await ledger.list("cycles")).paths, ["cycles/current.json"]); + await assert.rejects( + ledger.write("cycles/next.json", {}, { expectedVersion: moved.version, replacePath: "cycles/missing.json" }), + /does not exist/, + ); +}); + test("concurrent Git JSON ledger writers allow exactly one compare-and-swap winner", async (t) => { const fixture = await createFixture(); t.after(() => rm(fixture.root, { recursive: true, force: true })); diff --git a/tests/review-cycle.test.mjs b/tests/review-cycle.test.mjs index 09ce1b3..697091f 100644 --- a/tests/review-cycle.test.mjs +++ b/tests/review-cycle.test.mjs @@ -4,7 +4,9 @@ import test from "node:test"; import { GitJsonLedger } from "../scripts/lib/git-json-ledger.mjs"; import { + migrateReviewCycleV1ToV2, ReviewCycleManager, + reviewCyclePaths, validateAgentReview, validateReviewCycle, } from "../scripts/lib/review-cycle.mjs"; @@ -16,10 +18,7 @@ import { createFixture, identityEnv } from "./helpers/git-fixture.mjs"; const timestamp = "2026-07-10T20:00:00.000Z"; test("review cycle persists GitHub and agent feedback through triage and checkpoint-bound fixes", async (t) => { - const fixture = await createFixture(); - t.after(() => rm(fixture.root, { recursive: true, force: true })); - const store = await NativeGitStore.open(fixture.seed); - const ledger = await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/reviews" }); + const { fixture, store, ledger } = await createReviewFixture(t); const provider = fakeProvider(fixture); const manager = new ReviewCycleManager({ store, @@ -148,11 +147,7 @@ test("review cycle persists GitHub and agent feedback through triage and checkpo }); test("review readiness consumes only the latest validation for the exact PR head", async (t) => { - const fixture = await createFixture(); - t.after(() => rm(fixture.root, { recursive: true, force: true })); - const store = await NativeGitStore.open(fixture.seed); - const ledger = await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/reviews" }); - const validationLedger = await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/validations" }); + const { fixture, store, ledger, validationLedger } = await createReviewFixture(t); const provider = emptyProvider(fixture); const manager = new ReviewCycleManager({ store, @@ -197,6 +192,89 @@ test("agent review contract bounds finding count and text size", () => { assert.throws(() => validateAgentReview(input), /at most 65536/); }); +test("review-cycle v0.1 migration is deterministic and rejects tampering", async (t) => { + const fixture = await createFixture(); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + const identity = { + source: legacyIdentity(), + target: githubIdentity(), + }; + const legacy = legacyReviewCycle(fixture); + const migrated = migrateReviewCycleV1ToV2(legacy, identity); + + assert.equal(migrated.changed, true); + assert.equal(migrated.clearedProviderUrls, 1); + assert.equal(migrated.cycle.schemaVersion, "tabellio-review-cycle/v0.2"); + assert.equal(migrated.cycle.repository.id, "IntelIP/Tabellio"); + assert.equal(migrated.cycle.provider.id, "github"); + assert.deepEqual(migrated.cycle.provider, { id: "github", owner: "IntelIP", repo: "Tabellio" }); + assert.equal(migrated.cycle.changeRequest.url, "https://github.com/IntelIP/Tabellio/pull/7"); + assert.equal(migrated.cycle.feedback[0].source, "github"); + assert.equal(migrated.cycle.checks.statuses[0].targetUrl, null); + assert.equal(validateReviewCycle(migrated.cycle), migrated.cycle); + + const repeated = migrateReviewCycleV1ToV2(migrated.cycle, identity); + assert.equal(repeated.changed, false); + assert.deepEqual(repeated.cycle, migrated.cycle); + + const tampered = structuredClone(legacy); + tampered.changeRequest.title = "Tampered"; + assert.throws(() => migrateReviewCycleV1ToV2(tampered, identity), /integrity digest does not match/); +}); + +test("review-cycle migration previews, atomically moves, and becomes idempotent", async (t) => { + const { fixture, store, ledger } = await createReviewFixture(t); + const source = legacyIdentity(); + const target = githubIdentity(); + const paths = { + legacy: reviewCyclePaths(source).legacy, + current: reviewCyclePaths(target).current, + }; + const seeded = await ledger.write(paths.legacy, legacyReviewCycle(fixture), { expectedVersion: null }); + const manager = new ReviewCycleManager({ + store, + ledger, + provider: null, + repositoryId: target.repositoryId, + owner: target.owner, + repo: target.repo, + }); + + const migrationOptions = { + number: 7, + legacyRepositoryId: source.repositoryId, + legacyOwner: source.owner, + legacyRepo: source.repo, + }; + const preview = await manager.migrate(migrationOptions); + assert.equal(preview.state, "preview"); + assert.equal(preview.applied, false); + assert.equal(await ledger.version(), seeded.version); + + const applied = await manager.migrate({ ...migrationOptions, apply: true }); + assert.equal(applied.state, "migrated"); + assert.equal(applied.applied, true); + assert.equal((await ledger.read(paths.legacy)).value, null); + assert.equal((await ledger.read(paths.current)).value.schemaVersion, "tabellio-review-cycle/v0.2"); + assert.deepEqual((await ledger.list("cycles")).paths, [paths.current]); + + const repeated = await manager.migrate({ ...migrationOptions, apply: true }); + assert.equal(repeated.state, "current"); + assert.equal(repeated.changed, false); + assert.equal(repeated.version, applied.version); +}); + +async function createReviewFixture(t) { + const fixture = await createFixture(); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + return { + fixture, + store: await NativeGitStore.open(fixture.seed), + ledger: await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/reviews" }), + validationLedger: await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/validations" }), + }; +} + function fakeProvider(fixture) { let checkState = "failure"; let headCommit = fixture.featureCommit; @@ -349,3 +427,94 @@ function validationResult(commit, runId, status, completedAt) { value.integrity = { algorithm: "sha256", digest: digestObject(value) }; return value; } + +function legacyReviewCycle(fixture) { + const value = { + schemaVersion: "tabellio-review-cycle/v0.1", + id: "review-legacy-001", + repository: { id: "example/repository" }, + provider: { id: "forgejo", owner: "acme", repo: "project" }, + changeRequest: { + id: "21", + number: 7, + url: "https://forgejo.example.test/acme/project/pulls/7", + title: "Legacy agent change", + state: "open", + draft: false, + mergeable: true, + headBranch: "feature", + headCommit: fixture.featureCommit, + baseBranch: "main", + baseCommit: fixture.mainCommit, + updatedAt: timestamp, + }, + status: "needs_triage", + round: 2, + feedback: [{ + id: "review-comment:41", + source: "forgejo", + providerId: "41", + kind: "review-comment", + author: "reviewer", + title: "Legacy feedback", + body: "Preserve this feedback.", + path: "README.md", + line: 1, + commit: fixture.featureCommit, + severity: null, + providerState: "open", + disposition: "pending", + resolution: "open", + fixId: null, + createdAt: timestamp, + updatedAt: timestamp, + }], + fixes: [], + checks: { + commit: fixture.featureCommit, + state: "success", + total: 1, + statuses: [{ + id: "51", + context: "tests", + state: "success", + description: "Checks passed", + targetUrl: "https://forgejo.example.test/checks/51", + createdAt: timestamp, + updatedAt: timestamp, + }], + }, + events: [{ + id: "event-legacy-001", + type: "synced", + actor: "legacy-sync", + at: timestamp, + detail: "Synced legacy feedback.", + }], + createdAt: timestamp, + updatedAt: timestamp, + integrity: { algorithm: "sha256", digest: "0".repeat(64) }, + }; + value.integrity.digest = digestObject(Object.fromEntries( + Object.entries(value).filter(([key]) => key !== "integrity"), + )); + return value; +} + +function legacyIdentity() { + return { + repositoryId: "example/repository", + owner: "acme", + repo: "project", + number: 7, + }; +} + +function githubIdentity() { + return { + repositoryId: "IntelIP/Tabellio", + owner: "IntelIP", + repo: "Tabellio", + number: 7, + }; +} From 53d9e0aed0e583e1c99569267fc21cf92b2420b4 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Wed, 15 Jul 2026 08:47:06 -0700 Subject: [PATCH 6/6] Correct GitHub review cycle mappings --- CHANGELOG.md | 3 +- docs/review-loop.md | 6 +- scripts/lib/review-cycle.mjs | 127 +++++++++++++++++++++++------- scripts/lib/validation-runner.mjs | 4 +- scripts/tabellio-review.mjs | 19 ++++- tests/review-cycle.test.mjs | 50 +++++++++++- tests/validation-runner.test.mjs | 10 +++ 7 files changed, 183 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b058f5..e77bd8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ All notable changes to Tabellio are recorded here. - Local validation results integrated into durable review readiness. - Git-native JSON ledger on compare-and-swap refs for durable control-plane state. - Durable review cycles covering GitHub feedback, agent findings, triage, checks, fixes, and readiness. -- Dry-run-first, atomic review-cycle migration from the legacy v0.1 provider contract to GitHub-only v0.2 state. +- Dry-run-first, atomic review-cycle migration from legacy v0.1 identities and PR numbers to GitHub-only v0.2 state. - Entire-checkpoint-bound fix records with commit remapping across git-spice restacks. - Approval-gated git-spice submit, update, sync, restack, and merge operations. - Integrity-bound operation intents, short-lived approvals, one-use receipts, and branch-set race checks. @@ -37,6 +37,7 @@ All notable changes to Tabellio are recorded here. - Evidence artifact self-integrity now has an explicit canonical hash scope. - Handwritten policy validators now require approval booleans, unique action IDs, and non-empty forbidden side effects. - Runtime context validation now matches schema property boundaries. +- Review sync ignores exact-commit validations belonging to a different repository identity. - Pull request CI now runs the Tabellio unit suite and records the source head commit. - Merge preview uses structured output so diagnostic words in file paths remain intact. - Context serialization rejects undefined fields and impossible object-ID lengths. diff --git a/docs/review-loop.md b/docs/review-loop.md index 8af3ded..2fe55fb 100644 --- a/docs/review-loop.md +++ b/docs/review-loop.md @@ -53,6 +53,7 @@ node scripts/tabellio-review.mjs migrate \ --owner IntelIP \ --remote-repo Tabellio \ --number 7 \ + --target-number 14 \ --legacy-repo-id old-owner/old-repository \ --legacy-owner old-owner \ --legacy-remote-repo old-repository @@ -67,13 +68,16 @@ node scripts/tabellio-review.mjs migrate \ --owner IntelIP \ --remote-repo Tabellio \ --number 7 \ + --target-number 14 \ --legacy-repo-id old-owner/old-repository \ --legacy-owner old-owner \ --legacy-remote-repo old-repository \ --apply true ``` -Legacy identity options default to the target coordinates, so they are needed only when the repository moved or was renamed. The migration verifies the v0.1 integrity digest and source identity, maps repository and provider identity to the GitHub target, rewrites feedback sources, replaces the pull-request URL with its canonical GitHub URL, clears obsolete provider check links, recomputes integrity, and atomically moves the ledger entry to its v0.2 path. The current ledger tree contains only the v0.2 entry; the original remains recoverable from Git history. Repeating the command reports `current` without writing another commit. +`--number` identifies the legacy cycle. `--target-number` identifies the corresponding GitHub pull request and defaults to `--number`. Legacy identity options default to the target coordinates, so they are needed only when the repository moved or was renamed. The migration verifies the v0.1 integrity digest and source identity, maps repository and provider identity to the GitHub target, rewrites feedback sources, replaces the pull-request URL with its canonical GitHub URL, clears obsolete provider check links, recomputes integrity, and atomically moves the ledger entry to its v0.2 path. It can also atomically correct a previously migrated cycle whose GitHub PR number was mapped incorrectly; that recovery path additionally requires `--remap-current true`. The current ledger tree contains only the v0.2 entry; prior forms remain recoverable from Git history. Repeating the command reports `current` without writing another commit. + +Migration marks the provider-owned change-request ID as pending. Run `sync` for the target GitHub PR immediately after applying so GitHub refreshes the ID, title, state, head/base commits, feedback, and checks. ## Import A Codex Review diff --git a/scripts/lib/review-cycle.mjs b/scripts/lib/review-cycle.mjs index 12673ec..272cd8e 100644 --- a/scripts/lib/review-cycle.mjs +++ b/scripts/lib/review-cycle.mjs @@ -37,11 +37,10 @@ export class ReviewCycleManager { repo: this.repo, commit: changeRequest.source.commit, }), - this.validationLedger ? latestValidationResult(this.validationLedger, changeRequest.source.commit) : null, + this.validationLedger + ? latestValidationResult(this.validationLedger, changeRequest.source.commit, this.repositoryId) + : null, ]); - if (localValidation && localValidation.repository.id !== this.repositoryId) { - throw new Error("Local validation repository does not match the review cycle."); - } const checks = mergeChecks(providerChecks, localValidation); const record = await this.#read(number); const existing = record.value; @@ -196,31 +195,43 @@ export class ReviewCycleManager { async migrate({ number, + targetNumber = number, + remapCurrent = false, apply = false, legacyRepositoryId = null, legacyOwner = null, legacyRepo = null, }) { positiveInteger(number, "number"); + positiveInteger(targetNumber, "targetNumber"); + boolean(remapCurrent, "remapCurrent"); boolean(apply, "apply"); const target = { repositoryId: this.repositoryId, owner: this.owner, repo: this.repo, - number, + number: targetNumber, }; - const source = legacyCycleIdentity(target, { legacyRepositoryId, legacyOwner, legacyRepo }); + const source = legacyCycleIdentity({ ...target, number }, { legacyRepositoryId, legacyOwner, legacyRepo }); + const currentSource = { ...target, number }; const paths = { legacy: reviewCyclePaths(source).legacy, + sourceCurrent: reviewCyclePaths(currentSource).current, current: reviewCyclePaths(target).current, }; - const [legacy, current] = await Promise.all([ - this.ledger.read(paths.legacy), - this.ledger.read(paths.current), - ]); - assertStableMigrationRead(legacy, current); - if (current.value) return currentMigrationResult({ legacy, current, paths, identity: target }); - return legacyMigrationResult({ ledger: this.ledger, legacy, paths, source, target, apply }); + const { legacy, current, migratedSource } = await readMigrationState(this.ledger, paths); + assertStableMigrationRead(legacy, current, migratedSource); + if (current.value) return currentMigrationResult({ legacy, migratedSource, current, paths, identity: target }); + const selected = selectMigrationSource({ legacy, migratedSource, paths, source, currentSource, remapCurrent }); + return migrationResult({ + ledger: this.ledger, + record: selected.record, + sourcePath: selected.path, + targetPath: paths.current, + source: selected.identity, + target, + apply, + }); } async #read(number) { @@ -380,15 +391,20 @@ export function validateAgentReview(value) { export function migrateReviewCycleV1ToV2(value, { source, target }) { if (value?.schemaVersion === REVIEW_CYCLE_SCHEMA_VERSION) { validateReviewCycle(value); - assertCycleIdentity(value, target); - return { cycle: structuredClone(value), changed: false, clearedProviderUrls: 0 }; + if (matchesCycleIdentity(value, target)) { + return { cycle: structuredClone(value), changed: false, clearedProviderUrls: 0 }; + } + assertCycleIdentity(value, source); + } else { + validateLegacyReviewCycle(value, source); } - validateLegacyReviewCycle(value, source); const cycle = structuredClone(value); const clearedProviderUrls = cycle.checks.statuses.filter((status) => status.targetUrl !== null).length; cycle.schemaVersion = REVIEW_CYCLE_SCHEMA_VERSION; cycle.repository.id = target.repositoryId; cycle.provider = { id: "github", owner: target.owner, repo: target.repo }; + cycle.changeRequest.id = pendingGitHubSyncId(value); + cycle.changeRequest.number = target.number; cycle.changeRequest.url = githubPullRequestUrl(target.owner, target.repo, target.number); cycle.feedback = cycle.feedback.map((item) => ({ ...item, @@ -612,16 +628,65 @@ function assertCycleIdentity(cycle, { repositoryId, owner, repo, number }) { equals(cycle.changeRequest.number, number, "review cycle.changeRequest.number"); } -function assertStableMigrationRead(legacy, current) { - if (legacy.version !== current.version) { +function matchesCycleIdentity(cycle, identity) { + try { + assertCycleIdentity(cycle, identity); + return true; + } catch { + return false; + } +} + +function assertStableMigrationRead(...records) { + if (new Set(records.map((record) => record.version)).size !== 1) { throw new Error("Review ledger changed while migration state was being read; retry."); } } -function currentMigrationResult({ legacy, current, paths, identity }) { +async function readMigrationState(ledger, paths) { + const [legacy, current, source] = await Promise.all([ + ledger.read(paths.legacy), + ledger.read(paths.current), + readMigratedSource(ledger, paths), + ]); + return { + legacy, + current, + migratedSource: source ?? { value: null, version: current.version }, + }; +} + +function readMigratedSource(ledger, paths) { + if (paths.sourceCurrent === paths.current) return null; + return ledger.read(paths.sourceCurrent); +} + +function selectMigrationSource({ legacy, migratedSource, paths, source, currentSource, remapCurrent }) { + assertUnambiguousMigrationSources(legacy, migratedSource, paths); + if (migratedSource.value) { + requireCurrentRemap(remapCurrent, paths.sourceCurrent); + return { record: migratedSource, path: paths.sourceCurrent, identity: currentSource }; + } + if (!legacy.value) throw new Error(`Legacy review cycle ${source.number} does not exist at ${paths.legacy}.`); + return { record: legacy, path: paths.legacy, identity: source }; +} + +function requireCurrentRemap(remapCurrent, path) { + if (remapCurrent) return; + throw new Error(`Current v0.2 cycle ${path} requires --remap-current true before it can move.`); +} + +function assertUnambiguousMigrationSources(legacy, migratedSource, paths) { + if (!legacy.value || !migratedSource.value) return; + throw new Error(`Both ${paths.legacy} and ${paths.sourceCurrent} exist; refusing ambiguous migration.`); +} + +function currentMigrationResult({ legacy, migratedSource, current, paths, identity }) { validateReviewCycle(current.value); assertCycleIdentity(current.value, identity); - if (legacy.value) throw new Error(`Both ${paths.legacy} and ${paths.current} exist; refusing ambiguous migration.`); + if (legacy.value || migratedSource.value) { + throw new Error(`A source cycle and ${paths.current} both exist; refusing ambiguous migration.`); + } return { state: "current", changed: false, @@ -629,18 +694,19 @@ function currentMigrationResult({ legacy, current, paths, identity }) { sourcePath: null, path: paths.current, version: current.version, + requiresSync: current.value.changeRequest.id.startsWith("pending-github-sync:"), clearedProviderUrls: 0, cycle: current.value, }; } -async function legacyMigrationResult({ ledger, legacy, paths, source, target, apply }) { - if (!legacy.value) throw new Error(`Legacy review cycle ${source.number} does not exist at ${paths.legacy}.`); - const migrated = migrateReviewCycleV1ToV2(legacy.value, { source, target }); - if (!apply) return previewMigrationResult(legacy, paths, migrated); - const written = await ledger.write(paths.current, migrated.cycle, { - expectedVersion: legacy.version, - replacePath: paths.legacy, +async function migrationResult({ ledger, record, sourcePath, targetPath, source, target, apply }) { + const migrated = migrateReviewCycleV1ToV2(record.value, { source, target }); + const paths = { legacy: sourcePath, current: targetPath }; + if (!apply) return previewMigrationResult(record, paths, migrated); + const written = await ledger.write(targetPath, migrated.cycle, { + expectedVersion: record.version, + replacePath: sourcePath, }); return appliedMigrationResult(written, paths, migrated); } @@ -662,6 +728,7 @@ function previewMigrationResult(legacy, paths, migrated) { sourcePath: paths.legacy, path: paths.current, version: legacy.version, + requiresSync: true, clearedProviderUrls: migrated.clearedProviderUrls, cycle: migrated.cycle, }; @@ -676,6 +743,7 @@ function appliedMigrationResult(written, paths, migrated) { path: paths.current, version: written.version, previousVersion: written.previousVersion, + requiresSync: true, clearedProviderUrls: migrated.clearedProviderUrls, cycle: migrated.cycle, }; @@ -688,6 +756,11 @@ function githubPullRequestUrl(owner, repo, number) { return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pull/${number}`; } +function pendingGitHubSyncId(value) { + if (value.changeRequest.id.startsWith("pending-github-sync:")) return value.changeRequest.id; + return `pending-github-sync:${value.provider.id}:${value.changeRequest.id}`; +} + function event(type, actor, at, detail) { return { id: `event-${randomUUID()}`, type, actor, at, detail }; } diff --git a/scripts/lib/validation-runner.mjs b/scripts/lib/validation-runner.mjs index faa8714..5f1841f 100644 --- a/scripts/lib/validation-runner.mjs +++ b/scripts/lib/validation-runner.mjs @@ -106,8 +106,9 @@ export class ValidationRunner { } } -export async function latestValidationResult(ledger, commit) { +export async function latestValidationResult(ledger, commit, repositoryId = null) { oid(commit, "commit"); + if (repositoryId !== null) requiredString(repositoryId, "repositoryId"); const prefix = `commits/${commit}`; const listed = await ledger.list(prefix); let latest = null; @@ -116,6 +117,7 @@ export async function latestValidationResult(ledger, commit) { if (!record.value) continue; validateValidationResult(record.value); if (record.value.revision.headCommit !== commit) throw new Error(`Validation result ${path} is stored under the wrong commit.`); + if (repositoryId !== null && record.value.repository.id !== repositoryId) continue; if (!latest || Date.parse(record.value.completedAt) > Date.parse(latest.completedAt)) latest = record.value; } return latest; diff --git a/scripts/tabellio-review.mjs b/scripts/tabellio-review.mjs index 62382be..f2b352d 100755 --- a/scripts/tabellio-review.mjs +++ b/scripts/tabellio-review.mjs @@ -24,7 +24,7 @@ const ALLOWED_OPTIONS = { triage: [...COMMON_OPTIONS, "feedbackId", "disposition", "reason"], fix: [...COMMON_OPTIONS, "feedbackIds", "commit", "checkpoint", "summary"], import: [...COMMON_OPTIONS, "input"], - migrate: [...COMMON_OPTIONS, "apply", "legacyRepoId", "legacyOwner", "legacyRemoteRepo"], + migrate: [...COMMON_OPTIONS, "apply", "targetNumber", "remapCurrent", "legacyRepoId", "legacyOwner", "legacyRemoteRepo"], }; const REQUIRED_OPTIONS = { sync: [], @@ -123,6 +123,8 @@ async function importAgentReview(manager, options) { function migrateReview(manager, options) { return manager.migrate({ number: options.number, + targetNumber: options.targetNumber, + remapCurrent: options.remapCurrent, apply: options.apply, legacyRepositoryId: options.legacyRepoId, legacyOwner: options.legacyOwner, @@ -156,7 +158,20 @@ function defaultActor(actor) { function migrationOptions(command, values) { if (command !== "migrate") return {}; - return { apply: booleanOption(values.apply ?? "false", "--apply") }; + return { + apply: optionalBooleanOption(values.apply, "--apply"), + remapCurrent: optionalBooleanOption(values.remapCurrent, "--remap-current"), + targetNumber: migrationTargetNumber(values), + }; +} + +function optionalBooleanOption(value, flag) { + return booleanOption(value === undefined ? "false" : value, flag); +} + +function migrationTargetNumber(values) { + if (values.targetNumber === undefined) return positiveNumberOption(values.number, "--number"); + return positiveNumberOption(values.targetNumber, "--target-number"); } function booleanOption(value, flag) { diff --git a/tests/review-cycle.test.mjs b/tests/review-cycle.test.mjs index 697091f..06ee467 100644 --- a/tests/review-cycle.test.mjs +++ b/tests/review-cycle.test.mjs @@ -197,7 +197,7 @@ test("review-cycle v0.1 migration is deterministic and rejects tampering", async t.after(() => rm(fixture.root, { recursive: true, force: true })); const identity = { source: legacyIdentity(), - target: githubIdentity(), + target: { ...githubIdentity(), number: 14 }, }; const legacy = legacyReviewCycle(fixture); const migrated = migrateReviewCycleV1ToV2(legacy, identity); @@ -208,12 +208,14 @@ test("review-cycle v0.1 migration is deterministic and rejects tampering", async assert.equal(migrated.cycle.repository.id, "IntelIP/Tabellio"); assert.equal(migrated.cycle.provider.id, "github"); assert.deepEqual(migrated.cycle.provider, { id: "github", owner: "IntelIP", repo: "Tabellio" }); - assert.equal(migrated.cycle.changeRequest.url, "https://github.com/IntelIP/Tabellio/pull/7"); + assert.equal(migrated.cycle.changeRequest.id, "pending-github-sync:forgejo:21"); + assert.equal(migrated.cycle.changeRequest.number, 14); + assert.equal(migrated.cycle.changeRequest.url, "https://github.com/IntelIP/Tabellio/pull/14"); assert.equal(migrated.cycle.feedback[0].source, "github"); assert.equal(migrated.cycle.checks.statuses[0].targetUrl, null); assert.equal(validateReviewCycle(migrated.cycle), migrated.cycle); - const repeated = migrateReviewCycleV1ToV2(migrated.cycle, identity); + const repeated = migrateReviewCycleV1ToV2(migrated.cycle, { source: identity.target, target: identity.target }); assert.equal(repeated.changed, false); assert.deepEqual(repeated.cycle, migrated.cycle); @@ -225,7 +227,7 @@ test("review-cycle v0.1 migration is deterministic and rejects tampering", async test("review-cycle migration previews, atomically moves, and becomes idempotent", async (t) => { const { fixture, store, ledger } = await createReviewFixture(t); const source = legacyIdentity(); - const target = githubIdentity(); + const target = { ...githubIdentity(), number: 14 }; const paths = { legacy: reviewCyclePaths(source).legacy, current: reviewCyclePaths(target).current, @@ -242,6 +244,7 @@ test("review-cycle migration previews, atomically moves, and becomes idempotent" const migrationOptions = { number: 7, + targetNumber: 14, legacyRepositoryId: source.repositoryId, legacyOwner: source.owner, legacyRepo: source.repo, @@ -249,6 +252,7 @@ test("review-cycle migration previews, atomically moves, and becomes idempotent" const preview = await manager.migrate(migrationOptions); assert.equal(preview.state, "preview"); assert.equal(preview.applied, false); + assert.equal(preview.requiresSync, true); assert.equal(await ledger.version(), seeded.version); const applied = await manager.migrate({ ...migrationOptions, apply: true }); @@ -256,14 +260,52 @@ test("review-cycle migration previews, atomically moves, and becomes idempotent" assert.equal(applied.applied, true); assert.equal((await ledger.read(paths.legacy)).value, null); assert.equal((await ledger.read(paths.current)).value.schemaVersion, "tabellio-review-cycle/v0.2"); + assert.equal((await ledger.read(paths.current)).value.changeRequest.number, 14); assert.deepEqual((await ledger.list("cycles")).paths, [paths.current]); const repeated = await manager.migrate({ ...migrationOptions, apply: true }); assert.equal(repeated.state, "current"); assert.equal(repeated.changed, false); + assert.equal(repeated.requiresSync, true); assert.equal(repeated.version, applied.version); }); +test("review-cycle migration atomically corrects an already migrated PR number", async (t) => { + const { fixture, store, ledger } = await createReviewFixture(t); + const wrong = githubIdentity(); + const target = { ...wrong, number: 14 }; + const wrongPath = reviewCyclePaths(wrong).current; + const targetPath = reviewCyclePaths(target).current; + const migrated = migrateReviewCycleV1ToV2(legacyReviewCycle(fixture), { + source: legacyIdentity(), + target: wrong, + }).cycle; + await ledger.write(wrongPath, migrated, { expectedVersion: null }); + const manager = new ReviewCycleManager({ + store, + ledger, + provider: null, + repositoryId: target.repositoryId, + owner: target.owner, + repo: target.repo, + }); + + await assert.rejects( + manager.migrate({ number: 7, targetNumber: 14 }), + /--remap-current true/, + ); + const preview = await manager.migrate({ number: 7, targetNumber: 14, remapCurrent: true }); + assert.equal(preview.state, "preview"); + assert.equal(preview.sourcePath, wrongPath); + assert.equal(preview.path, targetPath); + assert.equal(preview.cycle.changeRequest.number, 14); + + const applied = await manager.migrate({ number: 7, targetNumber: 14, remapCurrent: true, apply: true }); + assert.equal(applied.state, "migrated"); + assert.equal((await ledger.read(wrongPath)).value, null); + assert.equal((await ledger.read(targetPath)).value.changeRequest.number, 14); +}); + async function createReviewFixture(t) { const fixture = await createFixture(); t.after(() => rm(fixture.root, { recursive: true, force: true })); diff --git a/tests/validation-runner.test.mjs b/tests/validation-runner.test.mjs index 9d8ee9a..dfdc17c 100644 --- a/tests/validation-runner.test.mjs +++ b/tests/validation-runner.test.mjs @@ -45,6 +45,16 @@ test("validation runner executes exact committed manifests and stores bounded re assert.equal(passed.result.commands[1].status, "passed"); assert.equal(validateValidationResult(passed.result), passed.result); assert.deepEqual(await latestValidationResult(ledger, passingHead), passed.result); + const otherRepository = await runner.run({ + repositoryId: "other/repository", + commit: passingHead, + base: "main", + runnerId: "other-runner", + now: new Date("2026-07-10T21:00:00.000Z"), + }); + assert.deepEqual(await latestValidationResult(ledger, passingHead), otherRepository.result); + assert.deepEqual(await latestValidationResult(ledger, passingHead, repositoryId), passed.result); + assert.equal(await latestValidationResult(ledger, passingHead, "missing/repository"), null); await writeFile(manifestPath, JSON.stringify(manifest([ command("fails", [process.execPath, "-e", "process.exit(3)"]),