@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 +162 -20
- package/client/github-mirror.tsx +10 -4
- package/package.json +2 -2
- package/src/cli.ts +3 -2
- package/src/github-a11y-snapshot.uitest.ts +117 -0
- package/src/github-budget.ts +198 -0
- package/src/github-capabilities.ts +502 -23
- package/src/github-connector.ts +832 -61
- package/src/github-events.ts +11 -1
- package/src/github-git-http.ts +248 -0
- package/src/github-git-plane.ts +511 -0
- package/src/github-graphql.ts +212 -1
- package/src/github-journey.uitest.ts +193 -0
- package/src/github-mirror-state.ts +369 -0
- package/src/github-mirror-ui.ts +53 -372
- package/src/github-server.ts +125 -21
- package/src/github-shared.ts +26 -0
- package/src/github-twin.ts +821 -53
- package/src/github-ui-conformance.ts +2 -2
- package/src/index.ts +71 -5
- package/test-fixtures/github-openapi-operations.json +467 -34
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:**
|
|
8
|
-
> title/body/diffs)
|
|
9
|
-
>
|
|
10
|
-
>
|
|
11
|
-
>
|
|
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"),
|
|
26
|
-
|
|
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
|
-
##
|
|
125
|
+
## Interaction surfaces
|
|
39
126
|
|
|
40
|
-
1. **
|
|
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
|
|
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,
|
|
57
|
-
|
|
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),
|
|
73
|
-
**GraphQL API** (
|
|
74
|
-
packages/pages/codespaces
|
|
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
|
|
78
|
-
|
|
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.
|
package/client/github-mirror.tsx
CHANGED
|
@@ -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
|
|
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
|
|
318
|
+
return githubPullPath(prRepo, number);
|
|
316
319
|
}
|
|
317
320
|
if (view === "issues" && selectedIssueKey) {
|
|
318
321
|
const { repo: issueRepo, number } = splitRepoScopedKey(selectedIssueKey);
|
|
319
|
-
return
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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;
|