@volter/twin-github 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,26 +4,113 @@ The **GitHub twin** — a local replica of the GitHub **REST** pull-request API
4
4
  the shared [`@volter/twin`](../control-plane) kernel. The real `@octokit/rest` client
5
5
  works against it unmodified.
6
6
 
7
- > **Note:** local simulator/fork **writes** carry full content (a created PR has a
8
- > title/body/diffs). The connector's *pull* path currently folds observed events as
9
- > *metadata only* (counts/refs, `title: null`) that is a **Planned** gap (full
10
- > content on pull), not an out-of-scope carve-out. The only deliberate GitHub
11
- > out-of-scope carve-out is actual repository **code/file contents** (see Coverage below).
7
+ > **Note:** both planes carry content. Local simulator/fork **writes** carry full content
8
+ > (a created PR has a title/body/diffs), and the connector's *pull* path folds the CONTENT
9
+ > real GitHub returns — PR/issue title/body/state, review bodies, comment bodies, and the
10
+ > inline diff findings. The only deliberate GitHub out-of-scope carve-out is bulk pull of
11
+ > actual repository **code/file contents** (see Coverage below).
12
12
 
13
13
  ## Surface
14
14
 
15
15
  - **REST API** (`github-twin.ts`; HTTP wrapper `github-server.ts` →
16
16
  `createGithubTwinServer`): list/get PRs, issues, statuses/checks, milestones, and
17
- **Actions** (workflows/runs/jobs; dispatch/rerun/cancel writes).
17
+ **Actions** (workflows/runs/jobs; dispatch/rerun/cancel writes), plus **Gists**
18
+ (create/edit/list/get/delete and comments).
18
19
  - **Writes** (`applyGithubWrite`): create PR, edit PR, submit review, issue comment
19
- — local transactions (R3/R5/R18); the --read-only flag rejects writes.
20
+ — local transactions (R3/R5/R18); the --read-only flag rejects writes. A review or review
21
+ comment the twin authors is served with an author: the twin ledgers one caller identity
22
+ for a local write (`actor: { kind: 'agent' }`, which carries no login), so the login it
23
+ serves is the stable `twin` with `type: "User"` — the same name the jira twin gives a
24
+ local writer. A rehearsal reviewer's REAL login rides in the review or comment **body** by
25
+ convention; the twin invents no header for it and never guesses a human's account from an
26
+ unauthenticated write. (Making `review.user` reflect a genuinely *authenticated* actor is
27
+ still the open `github.pulls.review_actor_identity` todo.)
28
+ - **Every review comment names its review.** Real GitHub wraps every inline comment in a
29
+ review, and so does the twin: a comment posted under a review (`POST .../pulls/:n/reviews`
30
+ with a `comments` array) carries that review's id, and a comment posted on its own carries
31
+ the PR's **implicit review** — one deterministic id per (repo, PR). It is synthesized on
32
+ `GET .../pulls/:n/reviews` *from the comments that name it* and never stored as a review of
33
+ its own; `_twin.review_count` counts it under exactly that condition, so the count and the
34
+ list describe one population (real GitHub serves the wrapper on `/reviews` and counts it
35
+ too). The point is that the join a consumer does (`pull_request_review_id` → the reviews
36
+ page) is live against the twin's own data; it used to be dead, because the twin answered
37
+ `pull_request_review_id: null` on every review comment it served. The wrapper's id band
38
+ (`0x80000000…`) separates it from the twin's own local-sequence and observed-hash id
39
+ spaces, and **that is all it does** — real GitHub's review ids reach that range too, so the
40
+ band is no argument about *vendor* ids. The invariant that actually holds is that a vendor
41
+ id is never a twin id: every pulled review and comment is rehashed before it is served, and
42
+ no review id is ever sent back out (a push addresses a vendor comment by the `external_id`
43
+ the pull recorded).
20
44
  - **Webhooks** (`github-events.ts`): `pull_request` / `pull_request_review` /
21
45
  `issue_comment` with the `X-GitHub-Event` header.
22
46
  - **Conformance** (`github-conformance.ts`): field-name subset vs the vendored
23
47
  GitHub REST PR schema (PR + nested base/head/repo; `_twin` extras excluded).
24
48
  - **UI mirror** (`github-mirror-ui.ts`): a GitHub-style PR browser (React) with a
25
- per-repo selector ("multiple githubs"), over the twin's github world via
26
- `/api/state`; observed review/comment counts are surfaced honestly.
49
+ per-repo selector ("multiple githubs"), a pure frontend (R3) — the shell + assets with
50
+ the twin's own fetch adapter mounted beside them. It reads the world through the twin's
51
+ store door `GET /twin/store/mirror` (the one named projection `github-mirror-state.ts`
52
+ builds server-side; observed review/comment counts are surfaced honestly), and any
53
+ console write goes through the REST API on the same origin. On that one host a document
54
+ navigation (`Accept: text/html`) is the console's deep link and everything else is the
55
+ vendor's wire; the git smart-HTTP plane is not carried there. Pure helpers both sides
56
+ share (the store path, the PR/issue route grammar) live in `github-shared.ts`.
57
+ - **Connector** (`github-connector.ts`): the live-vendor lifecycle over an injected
58
+ octokit-like executor. `syncGithubFromReal` pulls a repo's PRs + issues and folds their
59
+ content; `pushPendingGithubActions` enacts local writes. Pull is **per-row conflict
60
+ tolerant**: if one stored event has diverged from what a resource now folds to (a
61
+ `Conflicting duplicate` — e.g. a post-append line mutation raced the live writer), that
62
+ ONE row is skipped rather than aborting the whole repo's fold, so every other resource in
63
+ the same pull still folds. The result reports it — `{ observed, deltasAppended,
64
+ eventsAppended, issues, conflictsSkipped, conflictingIds }` — so a poller can log the
65
+ integrity problem loudly instead of it being swallowed. Every other append error stays fatal.
66
+ A PR's **conversation pulls as subjects, not as a summary**: each review is
67
+ `review` / `<owner/repo>#<n>:review:<reviewId>` (author login, human-or-bot, state, body,
68
+ submitted_at, commit_id, and the INLINE diff comments it wrapped — joined by each
69
+ `/pulls/{n}/comments` row's `pull_request_review_id`), and each issue comment is
70
+ `issue_comment` / `<owner/repo>#<n>:ic:<commentId>`. Two reviews between two polls are
71
+ therefore two deltas, and each is dated by the PROVIDER (a review by `submitted_at`, a
72
+ comment by `updated_at ?? created_at`), not by the poll clock. The PR keeps its own
73
+ summary fields, so a consumer reading only `pull_request` is unaffected. Budget: those
74
+ three per-PR reads are skipped for a PR whose `updated_at` has not moved since the last
75
+ observation (the stamp is read back from the shadow), so N unchanged PRs cost one list
76
+ call; an open-only pull also sweeps a bounded page (20) of recently-updated CLOSED PRs so
77
+ a merge is observed. All three conversation reads are PAGED at the vendor's maximum
78
+ (100/page, bounded at 10 pages), so a busy PR's 31st review, 31st finding or 31st issue
79
+ comment is observed rather than lost at a page boundary — the last of those is what
80
+ `commentCount` publishes, which used to report the truncated 30 as if it were the whole
81
+ conversation tab. `reviewCount` and `latestReview` are drawn from exactly one set —
82
+ reviews that are submitted and not `PENDING` — so a count and a verdict can never
83
+ describe different populations of the same PR.
84
+ - **A review nobody showed us says so.** Two shapes of inline comment name no review this
85
+ pull can resolve, and NEITHER is dropped. A comment whose `pull_request_review_id` is
86
+ missing entirely (a row the vendor left unattached) rides one fixed bucket per PR,
87
+ `<owner/repo>#<n>:review:unattached`; a comment naming a review whose ROW never arrived (a
88
+ review deleted between the two reads, a page boundary) rides a subject keyed by that id.
89
+ Both carry `partial: true` and nothing else — no state, no author, no verdict, because
90
+ nobody showed us one. `partial` is what tells a consumer this is an unshown review rather
91
+ than one whose fields have not arrived yet, and neither is counted or eligible to be
92
+ `latestReview`.
93
+ - **A finding is authored by whoever wrote it, not by the review that wraps it.** Every
94
+ inline comment carries its OWN `authorLogin`/`authorType`, read from that row's `user`:
95
+ a scanner App's finding inside a human's review reads as `bot`, a threaded reply reads as
96
+ its replier, and the unattached bucket's comments keep their authors although the bucket
97
+ itself has none. The wrapping review's author is the fallback only for a row that named
98
+ nobody (`user: null`); when neither names anyone, no author is served rather than a guess.
99
+ - **`authorType` is a one-way signal.** `authorType: 'bot'` ⇒ a machine wrote it is
100
+ RELIABLE (only a GitHub App account is typed `Bot` or suffixed `[bot]`). The converse is
101
+ NOT: a GitHub App acting through a *user* token, a service/machine account, and an
102
+ Organization all come back typed `User` with an ordinary login, and a review by a deleted
103
+ account answers `user: null` (no login and no type at all). Gate on `bot`; never read
104
+ `user` as "a person did this".
105
+ - **Push: a threaded reply is a reply.** A local `in_reply_to` write crosses on GitHub's own
106
+ reply route (`POST .../pulls/{n}/comments/{comment_id}/replies`), addressing the root by
107
+ the id the *vendor* gave it — resolved from what a pull observed, or from what an earlier
108
+ push in the same sweep returned. The connector NEVER forwards a twin-minted id to a real
109
+ account: a reply whose root has no known GitHub id is **refused**, ledgered in the push
110
+ result's `skipped` (action id, operation, reason) and left pending for a later sweep. That
111
+ per-action tolerance is the push-side twin of the pull's per-row conflict skip — one
112
+ refusal or one vendor rejection never aborts the other writes in the sweep, and nothing is
113
+ confirmed on a failure.
27
114
 
28
115
  ## CLI
29
116
 
@@ -35,14 +122,14 @@ world-github conformance [--root DIR]
35
122
 
36
123
  Point the real `@octokit/rest` `Octokit` at it via `baseUrl`.
37
124
 
38
- ## Four ways to use it
125
+ ## Interaction surfaces
39
126
 
40
- 1. **Mock SDK** — *zero edits (preferred):* `GITHUB_TWIN_URL=http://127.0.0.1:PORT node --require @volter/twin/inject your-app` redirects the real `@octokit/rest` from `api.github.com` to the twin. *Or* override directly: `new Octokit({ baseUrl: 'http://127.0.0.1:PORT', auth: 'twin' })`.
127
+ 1. **SDK/API** — *zero edits (preferred):* `GITHUB_TWIN_URL=http://127.0.0.1:PORT node --require @volter/twin/inject your-app` redirects the real `@octokit/rest` from `api.github.com` to the twin. *Or* override directly: `new Octokit({ baseUrl: 'http://127.0.0.1:PORT', auth: 'twin' })`.
41
128
  2. **API + CLI** — `world-github serve` (writable) + drive with `volter-twin status|plan|refs github`, then push.
42
129
  3. **Read-only** — `world-github serve --read-only`: unlimited local reads, no rate limits; writes refuse like GitHub (4xx).
43
130
  4. **UI mirror** — `world-github mirror` renders a GitHub-style view of the twin's state.
44
131
 
45
- (See the repo's GETTING-STARTED → "Four ways to use a twin", and `cookbook/zero-edit-inject`.)
132
+ (See Getting Started → "Twin interaction surfaces", and `cookbook/zero-edit-inject`.)
46
133
 
47
134
  ## Coverage
48
135
 
@@ -53,8 +140,10 @@ done or carved out is a gap to close.
53
140
  **Done** — PRs (create/edit/merge/list/get; **full PR/issue content on pull** — title/body/state
54
141
  and review/comment bodies fold from real GitHub, plus full content on local writes), first-class
55
142
  issues (**pulled from real GitHub** via GET /issues, PRs excluded), **shared issue/PR number space**
56
- (one per-repo counter, real GitHub behavior), reviews + diff-anchored review comments, issue
57
- comments, commit statuses, check runs, requested reviewers, milestones, labels/assignees, PR
143
+ (one per-repo counter, real GitHub behavior), reviews + diff-anchored review comments (write,
144
+ list, and reply in a thread `POST .../pulls/:n/comments/:cid/replies` inherits the root
145
+ comment's anchor and answers with `in_reply_to_id`), issue comments, commit statuses,
146
+ check runs, requested reviewers, milestones, labels/assignees, PR
58
147
  files/commits (local writes), **Actions** (workflows + workflow runs + jobs/steps — register/
59
148
  list/get workflows, workflow_dispatch → queued run, rerun/cancel transitions, run filters
60
149
  status/branch/event, run jobs; **rendered in the UI mirror** Actions view), **Actions
@@ -66,15 +155,68 @@ API** (refs create/get/update/delete, commits, trees, blobs *with content*, anno
66
155
  (create/update/delete + protection-rule summaries; **rendered in the UI mirror** Deployments
67
156
  view), **org repositories** (GET /orgs/:org/repos with type/sort filters), **issue pin/unpin**,
68
157
  pagination, webhooks, UI mirror (rung-5 ✅), connector (pull content+issues + push: create/edit/
69
- merge/review/comment/status/check/milestone/reviewers).
158
+ merge/review/comment/status/check/milestone/reviewers), **Gists** (create/edit/list/get/delete +
159
+ comments).
160
+
161
+ **The REAL git plane (smart HTTP).** The twin is now *clonable and pushable by an unmodified
162
+ `git` CLI*: per-repo **real bare repositories** live under the twin instance's state dir, and
163
+ `GET /:owner/:repo/info/refs` + `POST .../git-upload-pack|git-receive-pack` bridge the wire to
164
+ real `git upload-pack`/`receive-pack --stateless-rpc` subprocesses (git is its own reference
165
+ implementation — see `src/github-git-plane.ts` / `src/github-git-http.ts`; supabase's
166
+ real-engine doctrine applied to git). The Git Data REST endpoints are **re-backed by the same
167
+ bare repos**: a REST blob/tree/commit/tag create writes a *real* git object (real sha, fetchable
168
+ by ref), a `git push` folds its ref changes into the kernel log (visible to REST refs/branches,
169
+ re-points open PR heads — GitHub's `synchronize` — and fires the `push` webhook through the
170
+ existing pipeline). One object store, not two. Refs minted on shas no object backs (a legacy
171
+ lenient surface some verifies pin) stay control-plane-only and are never advertised, so an
172
+ advertised ref is always clonable. The fidelity oracle is the **real git CLI** —
173
+ `src/github-git.integration.test.ts` clones, pushes, fetches REST-authored commits, and replays
174
+ the OA loop's verified-api signing flow (`POST git/commits` + `PATCH git/refs/heads/:branch`).
175
+
176
+ **SDLC control plane for merge gates** — commit statuses at a sha, check runs, branch
177
+ protection that **persists** `required_status_checks` (contexts/strict) + `enforce_admins` and
178
+ serves them back (`GET /branches/:b` summary with real `enforcement_level` semantics, `GET/PATCH
179
+ .../protection/required_status_checks`, `POST/DELETE .../protection/enforce_admins`), PR reviews
180
+ that **bind `commit_id` to the exact head sha at submission** (a later push moves the PR head,
181
+ the review stays), and the GraphQL `repository.pullRequest(number)` fields `gh pr view --json`
182
+ reads (headRefOid/state/baseRefName/statusCheckRollup/closingIssuesReferences/changedFiles/
183
+ labels/body/comments/assignees/reviewRequests) — scoped to the exact calls this repo's own
184
+ governance scripts make (human-approval-gate, break-glass-gate, agent-propose,
185
+ finalize-agent-review, review-prerequisites), so the OA merge-gate logic can one day run
186
+ hermetically against this twin.
70
187
 
71
188
  **Planned** (known-missing, will do) — **Projects v2**, **branch protection rulesets**,
72
- **orgs/teams/members** (membership), **issue types** (org-level) / **issue transfer**, the
73
- **GraphQL API** (only REST today), security (code-scanning/dependabot/secret-scanning),
74
- packages/pages/codespaces. (Actions **log bytes** stay out — CI-compute output, see Out of scope.)
189
+ **orgs/teams/members** (membership), the rest of the
190
+ **GraphQL API** (the modeled subset now includes the gh pr view merge-gate fields), security
191
+ (code-scanning/dependabot/secret-scanning), packages/pages/codespaces, git-plane follow-ons
192
+ (protection ENFORCED on receive-pack + PR merge, shallow/partial clone asserts, LFS, SSH
193
+ transport). (Actions **log bytes** stay out — CI-compute output, see Out of scope.)
75
194
 
76
195
  **Out of scope** (deliberately not modeled, with reason) —
77
- - Actual repository **code / file contents** (git blob bytes, raw file bodies, packfiles): large
78
- and redundant the API *objects* (trees/blobs metadata, file lists, diff stats) are still covered.
196
+ - ~~Actual repository code / file contents~~ **narrowed 2026-08-20**: the git plane now stores
197
+ REAL blob/tree/commit bytes for repos exercised through Git Data REST or `git push`. What
198
+ remains out of scope is bulk *pull* of a real remote repository's full object graph through
199
+ the connector (large + redundant for an evidence twin); the API objects AND locally-authored
200
+ bytes are covered.
79
201
  - Running real **CI compute** (executing Actions workflows): infra, not the API — the run/job
80
202
  *objects* are covered.
203
+
204
+ ## Rate budget — the fail-closed backstop on live calls
205
+
206
+ `liveGithubExecute` is the **one place** this pack issues a live request, so every call it makes is charged
207
+ against a persistent, fail-closed spend ledger **before** the request goes out. Past the ceiling, or
208
+ while a `Retry-After`/429 cooldown is armed, it **throws instead of calling**. The ledger is keyed by
209
+ vendor and a hash of the credential (limits are per credential, so it is deliberately *not*
210
+ cwd-scoped) and persists across processes, so a fresh process does not get a fresh allowance; a
211
+ corrupt ledger counts as a **full** window rather than zero spend. There is no option to disable it,
212
+ and no value you can pass for `budget` that yields an unguarded client — an injected budget is
213
+ validated by *method identity*, so a subclass or a `Proxy` that replaces `checkBudget` is refused.
214
+
215
+ The declared numbers: **600 points / hour**, using GitHub's *own* published point scheme: a `GET`/`HEAD`/`OPTIONS` is 1 point. Bounded on every published axis at once — ≤12% of the 5,000 requests/hour primary limit; under the 900-points-per-**minute** secondary limit even if the whole hour were spent instantly. Writes are charged **8** rather than GitHub's own 5, the single departure from its table and a *tightening*: at 5 the hour-long window would admit 120 writes inside one minute, 1.5× the documented 80 content-generating requests/minute.
216
+
217
+ The mechanism is **shared and vendor-agnostic** — it lives in the kernel (`@volter/twin` →
218
+ `control-plane/src/rateBudget.ts`); what lives here in [`src/github-budget.ts`](src/github-budget.ts) is this vendor's
219
+ **declaration** (window, ceiling, per-endpoint weights, and a `reason` citing the limits above) plus
220
+ the vendor-bound `GithubBudget`. The rule is ratified as
221
+ [ARCHITECTURE.md](../../../ARCHITECTURE.md) **D8**, and the kernel module's header documents what the
222
+ guard does *not* guarantee — read that before trusting it.
@@ -1,5 +1,6 @@
1
1
  import React, { useEffect, useMemo, useState } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
+ import { GITHUB_MIRROR_STORE_PATH, githubIssuePath, githubPullPath } from "../src/github-shared.ts";
3
4
 
4
5
  export type PullRequest = {
5
6
  repo: string;
@@ -220,7 +221,7 @@ type Payload = {
220
221
  insights?: RepoInsights[];
221
222
  settings?: RepoSettings[];
222
223
  };
223
- fetchedAt: string;
224
+ fetchedAt?: string;
224
225
  };
225
226
 
226
227
  type ProjectCard = { id: number; title: string; contentType: string };
@@ -310,13 +311,15 @@ function pathFor(route: RouteState): string {
310
311
  // An opened PR/issue always routes to ITS OWN repo's path, regardless of the "all repos"
311
312
  // vs single-repo tab currently selected (mirrors real GitHub: /pulls can link out to any
312
313
  // repo's /owner/repo/pull/n).
314
+ // The route grammar is real GitHub's — the same one the twin's mirror store stamps on
315
+ // each PR/issue `url` (github-shared.ts), so a rendered link and the address bar agree.
313
316
  if (view === "pulls" && selectedKey) {
314
317
  const { repo: prRepo, number } = splitRepoScopedKey(selectedKey);
315
- return `/${prRepo}/pull/${number}`;
318
+ return githubPullPath(prRepo, number);
316
319
  }
317
320
  if (view === "issues" && selectedIssueKey) {
318
321
  const { repo: issueRepo, number } = splitRepoScopedKey(selectedIssueKey);
319
- return `/${issueRepo}/issues/${number}`;
322
+ return githubIssuePath(issueRepo, number);
320
323
  }
321
324
  if (repo === "all" || !repo) return `/${view}`;
322
325
  return `/${repo}/${view}`;
@@ -360,9 +363,12 @@ function App() {
360
363
  // to the same state) skips re-pushing the entry the browser just navigated to.
361
364
  const skipNextPush = React.useRef(false);
362
365
 
366
+ // The twin's store door (`GET /twin/store/mirror` — the ONE named projection the twin
367
+ // declares for this console) is the only read of twin state; it is the twin's own
368
+ // surface on this origin, never a mirror-private endpoint.
363
369
  async function refresh() {
364
370
  try {
365
- const response = await fetch("/api/state");
371
+ const response = await fetch(GITHUB_MIRROR_STORE_PATH);
366
372
  const json = await response.json();
367
373
  if (!response.ok) throw new Error(json.error ?? "Failed to load GitHub state");
368
374
  setPayload(json);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-github",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Local GitHub twin \u2014 PR-evidence REST; your real `@octokit/rest` talks to it unmodified. Mirror, simulate, and fork. Built on @volter/twin.",
5
5
  "keywords": [
6
6
  "twin",
@@ -54,7 +54,7 @@
54
54
  "react-dom": "^19.2.7"
55
55
  },
56
56
  "peerDependencies": {
57
- "@volter/twin": "0.1.0"
57
+ "@volter/twin": "0.1.1"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@volter/twin": "0.1.0",
package/src/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { keepProcessAlive } from '@volter/twin/lifecycle';
2
3
  // world-github — the GitHub PR-evidence twin CLI (R14): serve | mirror | conformance.
3
4
  import { hasFlag, optionValue } from '@volter/twin/args';
4
5
  import { createGithubMirrorServer } from './github-mirror-ui.ts';
@@ -13,11 +14,11 @@ const readOnly = hasFlag(rest, "--read-only");
13
14
  if (cmd === 'serve') {
14
15
  const s = createGithubTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
15
16
  process.stdout.write(`github twin (GitHub REST) at http://127.0.0.1:${s.port}\n`);
16
- await new Promise(() => {});
17
+ await keepProcessAlive();
17
18
  } else if (cmd === 'mirror') {
18
19
  const s = createGithubMirrorServer({ ...(root ? { root } : {}), ...(port ? { port } : {}) });
19
20
  process.stdout.write(`github mirror UI (PR evidence) at http://127.0.0.1:${s.port}\n`);
20
- await new Promise(() => {});
21
+ await keepProcessAlive();
21
22
  } else if (cmd === 'conformance') {
22
23
  // dev-only; lazy so the bin runs without @volter/twin-tooling
23
24
  const {
@@ -0,0 +1,117 @@
1
+ // GITHUB A11Y-CONGRUENCE MIRROR CAPTURE (TWIN-51 / H5, dev/01 + dev/02) — the mirror-capture
2
+ // leg of the recorded-reality BONUS check. Captures a live body ariaSnapshot from the seeded
3
+ // PR-list mirror, parses it, and asserts the parse produced a real, non-trivial tree
4
+ // (harness-mechanics assertions — this file NEVER `expect()`s on congruence against the real
5
+ // reference). Per the fidelity-without-recording doctrine, conformance is never counted from a
6
+ // recording, and the a11y-congruence check is structurally bonus-only by design (see
7
+ // packages/twin/tooling/src/a11ySnapshot.ts — `a11yBonusOutcome` is a hardcoded zero). Since
8
+ // dev/02, a real-github reference fixture IS committed (see REFERENCE_PATH below), so this
9
+ // file now diffs it against the mirror's own capture and PRINTS the report (console.log) on
10
+ // every run; if the fixture were ever removed, it falls back to a loud advisory instead. This
11
+ // is NOT a human-required file (unlike the tooling/** harness itself) and is NOT registered as
12
+ // a ui-journeys.ts journey — it rides the existing github pack test sweep (twin-check.sh
13
+ // [1/4]) like any other src/*.test.ts.
14
+ //
15
+ // dev/02 ADJUDICATION: the dev/01 tracker comment claimed dev/02 "requires a user-authorized
16
+ // session." That premise is FALSE for this scenario — the reference is a repo-scoped PUBLIC
17
+ // PR list (https://github.com/microsoft/vscode/pulls), reachable and capturable while fully
18
+ // logged OUT (see scripts/capture/github-a11y.ts's no-auth default mode and
19
+ // test-fixtures/github-a11y-reference.pr-list.SOURCE.md for the HTTP-200-logged-out proof).
20
+ // No user step was needed.
21
+ //
22
+ // Skip convention copied from github-journey.uitest.ts:52-55 (loud console.warn, never a hard
23
+ // fail on a browserless machine). Seed block copied verbatim from
24
+ // github-journey.uitest.ts:58-86 (same PR-list scenario TWIN-47's pilot journey uses) so the
25
+ // real-github reference recording (a DIFFERENT public repo/PR-list, not this mirror's fake
26
+ // data — names are scrubbed and ignored by diffA11yShape anyway) is diffed against the SAME
27
+ // seeded shape on the mirror side.
28
+ import { describe, test, expect } from 'bun:test';
29
+ import {
30
+ browserAvailable,
31
+ runUiJourney,
32
+ captureA11ySnapshot,
33
+ parseAriaSnapshot,
34
+ diffA11yShape,
35
+ renderCongruenceReport,
36
+ } from '@volter/twin-tooling';
37
+ import type { A11yNode } from '@volter/twin-tooling';
38
+ import { applyGithubWrite } from './github-twin.ts';
39
+ import { createGithubMirrorServer } from './github-mirror-ui.ts';
40
+
41
+ // Committed real-github reference for the PR-list scenario (TWIN-51 dev/02 — captured
42
+ // no-auth, logged out, from a public repo's PR list; see the SOURCE.md sibling next to the
43
+ // fixture for full provenance).
44
+ const REFERENCE_PATH = new URL('../test-fixtures/github-a11y-reference.pr-list.json', import.meta.url);
45
+
46
+ describe('github a11y-congruence mirror capture (TWIN-51 / H5, dev/01 + dev/02)', () => {
47
+ test('captures + parses the mirror\'s body ariaSnapshot for the seeded PR-list scenario', async () => {
48
+ if (!(await browserAvailable())) {
49
+ console.warn('!! SKIP: github a11y-congruence capture — playwright chromium not installed on this machine (bunx playwright install chromium)');
50
+ return;
51
+ }
52
+
53
+ let captured: string | undefined;
54
+ let parsed: A11yNode | undefined;
55
+
56
+ await runUiJourney({
57
+ seed: async (root) => {
58
+ // Same PR-list seed as github-journey.uitest.ts:58-86 — real write path
59
+ // (applyGithubWrite), never hand-written events.jsonl.
60
+ await applyGithubWrite({
61
+ method: 'POST', path: '/repos/acme/twin-journey/pulls', root,
62
+ body: JSON.stringify({ title: 'Twin Journey Alpha PR', head: 'alpha', base: 'main', body: 'alpha-pr-body-marker' }),
63
+ occurredAt: '2026-05-01T00:00:00Z',
64
+ });
65
+ await applyGithubWrite({
66
+ method: 'POST', path: '/repos/acme/twin-journey/pulls', root,
67
+ body: JSON.stringify({ title: 'Twin Journey Beta PR', head: 'beta', base: 'main', body: 'beta-pr-body-marker' }),
68
+ occurredAt: '2026-05-02T00:00:00Z',
69
+ });
70
+ await applyGithubWrite({
71
+ method: 'POST', path: '/repos/acme/twin-journey/pulls/2/reviews', root,
72
+ body: JSON.stringify({ event: 'APPROVED', body: 'beta-review-body-marker' }),
73
+ occurredAt: '2026-05-02T01:00:00Z',
74
+ });
75
+ await applyGithubWrite({
76
+ method: 'POST', path: '/repos/acme/twin-journey/issues', root,
77
+ body: JSON.stringify({ title: 'Twin Journey Tracking Issue', body: 'tracking-issue-body-marker' }),
78
+ occurredAt: '2026-05-03T00:00:00Z',
79
+ });
80
+ },
81
+ serve: (root) => {
82
+ const server = createGithubMirrorServer({ root, port: 0 });
83
+ return { url: `http://127.0.0.1:${server.port}`, stop: () => server.stop() };
84
+ },
85
+ journey: async (page) => {
86
+ captured = await captureA11ySnapshot(page, 'body');
87
+ parsed = parseAriaSnapshot(captured);
88
+ },
89
+ });
90
+
91
+ // Harness-mechanics assertions ONLY — never congruence.
92
+ expect(captured).toBeDefined();
93
+ expect((captured ?? '').length).toBeGreaterThan(0);
94
+ expect(parsed).toBeDefined();
95
+ expect((parsed?.children.length ?? 0)).toBeGreaterThan(0);
96
+
97
+ const referenceFile = Bun.file(REFERENCE_PATH);
98
+ if (await referenceFile.exists()) {
99
+ // dev/02: a real-github reference is committed (no-auth capture, see the fixture's
100
+ // SOURCE.md). Diff + print the report — structural non-gating rule: NEVER expect() on
101
+ // congruence, only console.log it. The check's bonus-only-ness is enforced upstream in
102
+ // a11ySnapshot.ts (a11yBonusOutcome is a hardcoded zero), proven by a11ySnapshot.test.ts;
103
+ // this integration test only exercises the capture+diff+render plumbing end to end
104
+ // against the real reference. Divergences (e.g. real github wraps its PR list in an ARIA
105
+ // `group aria-label="Issues"` landmark the mirror's list-box has no equivalent for) are
106
+ // an HONEST, expected bonus signal, not a failure — never gates.
107
+ const reference = (await referenceFile.json()) as A11yNode;
108
+ const divergences = diffA11yShape(reference, parsed as A11yNode);
109
+ console.log(renderCongruenceReport(divergences, 'github PR-list (TWIN-51 dev/02)'));
110
+ } else {
111
+ // Fallback only — the reference IS committed as of dev/02. Kept in case it's ever
112
+ // regenerated/removed: no user-authorized session is required to redo the capture (see
113
+ // scripts/capture/github-a11y.ts's no-auth default mode).
114
+ console.warn('!! A11Y-CONGRUENCE (bonus-only, never gates): reference not committed — run scripts/capture/github-a11y.ts (no auth required, see its header) to record it.');
115
+ }
116
+ }, 30_000);
117
+ });
@@ -0,0 +1,198 @@
1
+ // GitHub's CLIENT-SIDE RATE BUDGET — the pack's DECLARATION (the numbers) plus the thin typed
2
+ // bindings `liveGithubExecute` uses. The MECHANISM — the durable token-keyed ledger, the rolling
3
+ // window, reserve-under-lock, the `Retry-After`/429 cooldown, fail-CLOSED on a corrupt ledger —
4
+ // lives ONCE in the vendor-agnostic kernel (`@volter/twin` → `rateBudget.ts`). Read that module's
5
+ // header for the full rationale AND for the honest list of what the guard does not guarantee (an
6
+ // injected clock or ledger path still defeats it — it guards carelessness, not malice).
7
+ //
8
+ // ── WHY THIS EXISTS ─────────────────────────────────────────────────────────────────────────
9
+ // A real ~4.5-DAY vendor lockout (Figma, 2026-07-25) happened because raw API calls were made
10
+ // outside the pack's connector — no cache, no batching, no ceiling. Discipline only binds the code
11
+ // that follows it; a BUDGET binds the code that does not.
12
+ //
13
+ // ── HOW THE CEILING WAS CHOSEN: GITHUB'S OWN POINT SYSTEM ───────────────────────────────────
14
+ // GitHub is the one vendor in this repo whose published scheme is ALREADY a weighted point budget,
15
+ // so this declaration reproduces it rather than inventing a proxy for it. From
16
+ // https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api (read 2026-07-26):
17
+ // • PRIMARY, per authenticated personal access token: 5,000 requests per HOUR
18
+ // • SECONDARY, points per minute: 900 points per minute for REST endpoints
19
+ // — "most REST API GET, HEAD and OPTIONS requests" = 1 point
20
+ // — "most REST API POST, PATCH, PUT or DELETE requests" = 5 points
21
+ // • SECONDARY, concurrency: no more than 100 concurrent requests
22
+ // • SECONDARY, content creation: 80 per minute and 500 per hour
23
+ // The read weight IS GitHub's own number — 1 point. The write weight is deliberately DEARER than
24
+ // GitHub's 5 (it is 8); see `GITHUB_CALL_WEIGHTS.write` for why that is what makes the
25
+ // 80-content-creations-per-minute leg hold under an hour-long window. Nothing here is invented: the
26
+ // only departure from the published table is in the tightening direction, and it is labelled.
27
+ //
28
+ // The window is an HOUR (the kernel's maximum, and the horizon of GitHub's primary limit) and the
29
+ // ceiling is 600 points. That is bounded on BOTH published axes at once:
30
+ // • against the primary 5,000/hour: at most 600 requests can fit in 600 points, so ≤ 12% of it;
31
+ // • against the secondary 900 points/MINUTE: even spending the entire hour's allowance in a
32
+ // single instant is 600 points, still under 900 — the burst cannot breach it either;
33
+ // • against content creation (80/MINUTE and 500/hour): writes cost 8 rather than GitHub's own 5,
34
+ // because at 5 the hour-long window would admit 120 writes inside a single minute — 1.5x the
35
+ // 80/minute leg. At 8, only 75 fit, so BOTH legs hold. See `GITHUB_CALL_WEIGHTS.write`.
36
+ // A full PR pull of a busy repo is roughly 1 + 4 per PR ≈ 80 points for 20 PRs, so this admits
37
+ // about seven complete pulls an hour — far more than an on-demand connector needs, and far below
38
+ // the runaway shape.
39
+ //
40
+ // ⚠️ HONESTLY, ON THE COMPARISON WITH THE KERNEL FALLBACK. `DEFAULT_RATE_BUDGET` is 30 calls a
41
+ // minute, i.e. 1,800 an hour sustained. This declaration is TIGHTER sustained (600/hour) but LOOSER
42
+ // in a single minute — and that looseness is now BOUNDED AND ENFORCED rather than merely argued:
43
+ // `burstCeiling` caps any 60-second sub-window at 300 points, which the kernel refuses against
44
+ // independently of the hourly ceiling. 300 sits under both of GitHub's per-minute legs (the 900-
45
+ // points/minute secondary limit, and 300/8 = 37 writes against the 80 content-generating requests
46
+ // per minute), so the burst is inside the vendor's published contract by arithmetic, not by prose.
47
+ // An hour-long window is the only way to bound GitHub's hourly PRIMARY limit at all; the burst bound
48
+ // is what stops that from silently buying an instantaneous allowance nobody asked for.
49
+ //
50
+ // The window bounds spend, not PACE (the kernel refuses, it never sleeps — see its header). GitHub
51
+ // also caps concurrency at 100, which a weighted ledger does not model at all; that is the caller's
52
+ // job. The backstop for both is the cooldown, armed from `Retry-After` / a 403-or-429 /
53
+ // `x-ratelimit-remaining: 0`.
54
+ import {
55
+ declareRateBudget,
56
+ rateBudgetPath,
57
+ rateBudgetWeight,
58
+ RateBudget,
59
+ type RateBudgetDeclaration,
60
+ type RateBudgetOptions,
61
+ type RateBudgetReservation,
62
+ type RateBudgetSnapshot,
63
+ } from '@volter/twin';
64
+
65
+ const VENDOR = 'github';
66
+
67
+ /** Rolling window, in ms. One HOUR — the horizon of GitHub's primary 5,000-requests limit. */
68
+ export const GITHUB_BUDGET_WINDOW_MS = 60 * 60 * 1_000;
69
+
70
+ /**
71
+ * Weighted POINTS allowed inside one window. 600 points/hour is ≤ 12% of the primary 5,000
72
+ * requests/hour AND stays under the secondary 900 points/minute even if spent all at once.
73
+ */
74
+ export const GITHUB_BUDGET_CEILING = 600;
75
+
76
+ /**
77
+ * Weighted points allowed in any 60-SECOND sub-window, enforced by the kernel independently of the
78
+ * hourly ceiling. Without it, an hour-long window admits its entire 600 points in one millisecond —
79
+ * twenty times what the austere fallback allows in a minute — and nothing in the kernel reached that
80
+ * (§9 round 2, 2026-07-26). 300 is deliberately HALF the hourly ceiling: a full PR pull of a busy
81
+ * repo is ~80 points and the largest realistic burst is well under it, while both of GitHub's
82
+ * per-minute legs are honoured with room to spare — 300 < the 900-points/minute secondary limit, and
83
+ * 300/8 = 37 writes < the 80 content-generating requests/minute.
84
+ */
85
+ export const GITHUB_BUDGET_BURST_CEILING = 300;
86
+
87
+ /** Seconds. A `Retry-After` above this means the token is throttled hard — fail loudly, don't sleep. */
88
+ export const GITHUB_BUDGET_MAX_RETRY_AFTER_S = 300;
89
+
90
+ /** GitHub's OWN published point costs. Not a proxy for them — the numbers themselves. */
91
+ export const GITHUB_CALL_WEIGHTS = {
92
+ /**
93
+ * GitHub's own table prices a `POST`/`PATCH`/`PUT`/`DELETE` at **5 points**. This charges **8**,
94
+ * and the extra 3 are not a re-transcription of the vendor — they are what makes the OTHER
95
+ * documented write limit hold. GitHub caps content creation at **80 per minute** as well as 500
96
+ * per hour, and with an hour-long window a 5-point price lets 600/5 = 120 writes land inside a
97
+ * single minute: 1.5x that 80. At 8 points only 75 fit, which is under it. (§9 finding,
98
+ * 2026-07-26: the declaration cited the 80/min limit in its own header and then checked only the
99
+ * hourly leg.) Reads are unaffected, and the ratio is still write-dearer-than-read as published.
100
+ */
101
+ write: 8,
102
+ /** "Most REST API GET, HEAD, and OPTIONS requests" — 1 point, per GitHub's own table. */
103
+ other: 1,
104
+ } as const;
105
+
106
+ /** THE PACK'S DECLARATION — pure data, the only GitHub-specific thing in the whole budget. */
107
+ export const GITHUB_RATE_BUDGET: RateBudgetDeclaration = {
108
+ windowMs: GITHUB_BUDGET_WINDOW_MS,
109
+ ceiling: GITHUB_BUDGET_CEILING,
110
+ burstCeiling: GITHUB_BUDGET_BURST_CEILING,
111
+ defaultWeight: GITHUB_CALL_WEIGHTS.other,
112
+ maxRetryAfterSeconds: GITHUB_BUDGET_MAX_RETRY_AFTER_S,
113
+ // Keyed by the octokit-style ROUTE (`"POST /repos/{owner}/{repo}/pulls"`), so the method is the
114
+ // first token and GitHub's own read/write split is expressible as one anchored rule.
115
+ rules: [
116
+ { match: '^(POST|PATCH|PUT|DELETE) ', weight: GITHUB_CALL_WEIGHTS.write },
117
+ ],
118
+ reason:
119
+ "GitHub's published scheme IS a weighted point budget, so these weights are its own numbers, not " +
120
+ 'a proxy (docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api, read ' +
121
+ '2026-07-26): 5,000 requests/hour primary per PAT; secondary limits of 900 POINTS per minute ' +
122
+ 'with GET/HEAD/OPTIONS = 1 point and POST/PATCH/PUT/DELETE = 5 points, 100 concurrent requests, ' +
123
+ 'and 80 content-generating requests/minute / 500 per hour. 600 points per HOUR is bounded on ' +
124
+ 'every axis at once: at most 600 requests fit in 600 points (≤12% of the 5,000/hour primary), and ' +
125
+ 'spending the entire hour instantly is still 600 < the 900/minute secondary. Writes are charged 8 ' +
126
+ "rather than GitHub's own 5 — the only departure from its table, and a TIGHTENING: at 5 points the " +
127
+ 'hour-long window would admit 120 writes inside one minute, 1.5x the documented 80/minute content-' +
128
+ 'creation leg, while at 8 only 75 fit, so both the 80/minute and 500/hour legs hold. Versus the kernel fallback (30 ' +
129
+ 'calls/min = 1,800/hour) this is TIGHTER sustained but LOOSER in one minute — and that looseness is ' +
130
+ 'BOUNDED: burstCeiling caps any 60s sub-window at 300 points, which the kernel enforces separately, ' +
131
+ 'and 300 sits under both per-minute legs (900 points/min, and 300/8 = 37 writes vs 80 content-' +
132
+ 'generating requests/min). An hour-long window is the only way to bound ' +
133
+ "GitHub's hourly primary limit at all. It bounds spend, not pace, and does not model the " +
134
+ 'concurrency cap; the Retry-After / 403-or-429 cooldown is the backstop for both.',
135
+ };
136
+
137
+ // Declared at module load, so merely importing this module (which `github-connector.ts` does) is
138
+ // enough to arm the real ceiling. `RateBudget` reads its policy live precisely so this declaration
139
+ // takes effect the moment it lands, and constructing through the subclass below (which imports this
140
+ // module) is what makes the ordering a non-issue in practice.
141
+ declareRateBudget(VENDOR, GITHUB_RATE_BUDGET);
142
+
143
+ /**
144
+ * Price one call by its octokit-style ROUTE (`"GET /repos/{owner}/{repo}/pulls"`). The route, not
145
+ * the substituted path, is the key: it is what GitHub's docs name endpoints by, and it keeps the
146
+ * ledger's classification stable across owners and repos. A route nobody classified still costs
147
+ * `defaultWeight` — an unknown endpoint must never be free.
148
+ */
149
+ export function githubCallWeight(route: string): number {
150
+ const at = route.indexOf('?');
151
+ const query: Record<string, string> = {};
152
+ if (at !== -1) for (const [k, v] of new URLSearchParams(route.slice(at + 1))) query[k] = v;
153
+ const raw = (at === -1 ? route : route.slice(0, at)).trim();
154
+ // NORMALIZED, because the method rule is otherwise trivially evaded (§9 finding, 2026-07-26):
155
+ // `fetch` upper-cases a known lowercase method before sending, so `'post /repos/…'` issues a real
156
+ // WRITE that a `^(POST|…) ` rule would price as a 1-point read; and a trailing slash makes a `$`
157
+ // anchor miss a route most routers treat as identical. Both are input variations, not attacks, and
158
+ // either one voids the read/write split that IS GitHub's own published point scheme.
159
+ const sp = raw.indexOf(' ');
160
+ const method = (sp === -1 ? raw : raw.slice(0, sp)).toUpperCase();
161
+ const path = sp === -1 ? '' : raw.slice(sp + 1);
162
+ const bare = path.length > 1 && path.endsWith('/') ? path.replace(/\/+$/, '') : path;
163
+ return rateBudgetWeight(VENDOR, bare === '' ? method : `${method} ${bare}`, query);
164
+ }
165
+
166
+ /** Where GitHub's ledger lives. Token-keyed and cwd-independent by default (the primary limit is
167
+ * per token, so a cwd-scoped ledger would hand the same PAT a fresh allowance in every checkout,
168
+ * worktree and CI matrix leg — precisely the shape a matrix build has); pass `root` to opt into
169
+ * world-scoped accounting instead. */
170
+ export function githubBudgetPath(opts: { root?: string; token?: string } | string = {}): string {
171
+ const o = typeof opts === 'string' ? { root: opts } : opts;
172
+ // VENDOR spread LAST: a loosely-typed `{ vendor: 'other', … }` slipping through (TypeScript's
173
+ // excess-property check only catches object literals) must not redirect this pack's ledger to
174
+ // another vendor's file.
175
+ return rateBudgetPath({ ...o, vendor: VENDOR });
176
+ }
177
+
178
+ /** Construction options for GitHub's budget. The vendor is fixed; everything else may only TIGHTEN. */
179
+ export type GithubBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
180
+
181
+ /**
182
+ * GitHub's budget — the shared kernel guard bound to this vendor's declaration. A real subclass,
183
+ * not an alias, so `budget instanceof GithubBudget` in `liveGithubExecute` means "a budget that
184
+ * accounts against GITHUB's ledger under GITHUB's ceiling": another vendor's `RateBudget` (with its
185
+ * own, possibly larger, ceiling) is NOT assignable there.
186
+ */
187
+ export class GithubBudget extends RateBudget {
188
+ constructor(opts: GithubBudgetOptions = {}) {
189
+ super({ ...opts, vendor: VENDOR });
190
+ }
191
+ }
192
+
193
+ /** The typed refusal. One error class shared with every other vendor's budget; `err.vendor` says
194
+ * which one refused, and `err.kind` says why. */
195
+ export { RateBudgetError as GithubBudgetError } from '@volter/twin';
196
+ export type { RateBudgetErrorKind as GithubBudgetErrorKind } from '@volter/twin';
197
+ export type GithubBudgetReservation = RateBudgetReservation;
198
+ export type GithubBudgetSnapshot = RateBudgetSnapshot;