@volter/twin-github
v2.0.9
Published
Local GitHub twin — PR-evidence REST; your real `@octokit/rest` talks to it unmodified. Mirror, simulate, and fork. Built on @volter/world-core.
Readme
@volter/twin-github
The GitHub twin — a local replica of the GitHub REST API (repositories, git data, issues, pull
requests, Actions, releases, organizations and teams, Apps, Pages, security alerts and more) on
the shared @volter/world-core kernel. The real @octokit/rest client
works against it unmodified.
Note: both planes carry content. Local simulator/fork writes carry full content (a created PR has a title/body/diffs), and the connector's pull path folds the CONTENT real GitHub returns — PR/issue title/body/state, review bodies, comment bodies, and the inline diff findings. The one GitHub surface still unbuilt by design is bulk pull of actual repository code/file contents (see Coverage below).
Surface
- REST API (
github-twin.ts; HTTP wrappergithub-server.ts→createGithubTwinServer): list/get PRs, issues, statuses/checks, milestones, and Actions (workflows/runs/jobs; dispatch/rerun/cancel writes), plus Gists (create/edit/list/get/delete and comments). - Writes (
applyGithubWrite): create PR, edit PR, submit review, issue comment — local transactions (R3/R5/R18); the --read-only flag rejects writes. A review or review comment the twin authors is served with an author: the twin ledgers one caller identity for a local write (actor: { kind: 'agent' }, which carries no login), so the login it serves is the stabletwinwithtype: "User"— the same name the jira twin gives a local writer. A rehearsal reviewer's REAL login rides in the review or comment body by convention; the twin invents no header for it and never guesses a human's account from an unauthenticated write. (Makingreview.userreflect a genuinely authenticated actor is still the opengithub.pulls.review_actor_identitytodo.) - Every review comment names its review. Real GitHub wraps every inline comment in a
review, and so does the twin: a comment posted under a review (
POST .../pulls/:n/reviewswith acommentsarray) carries that review's id, and a comment posted on its own carries the PR's implicit review — one deterministic id per (repo, PR). It is synthesized onGET .../pulls/:n/reviewsfrom the comments that name it and never stored as a review of its own; the pull request's review count counts it under exactly that condition, so the count and the list describe one population (real GitHub serves the wrapper on/reviewsand counts it too). The point is that the join a consumer does (pull_request_review_id→ the reviews page) is live against the twin's own data; it used to be dead, because the twin answeredpull_request_review_id: nullon every review comment it served. The wrapper's id band (0x80000000…) separates it from the twin's own local-sequence and observed-hash id spaces, and that is all it does — real GitHub's review ids reach that range too, so the band is no argument about vendor ids. The invariant that actually holds is that a vendor id is never a twin id: every pulled review and comment is rehashed before it is served, and no review id is ever sent back out (a push addresses a vendor comment by theexternal_idthe pull recorded). - Repository webhooks (
github-events.ts): register through native/repos/{owner}/{repo}/hooksCRUD. The server resolves stored subscriptions on every write, sends HTTP callbacks and stores their outcomes in the same World. Hook configuration and delivery history survive server restart; there is no separate registration helper. JSON and form payloads carryX-GitHub-Event,X-GitHub-Deliveryand, when configured, HMAC-SHA256 over the transmitted bytes. PR, review and comment events carry their source records, including deleted comment snapshots and inline comment parents. Native writes name the modeledtwinsender separately from the source author; PR IDs are stable modeled numbers and local PR update times come from their write history. Unknown imported authors and update times remain unknown. Review webhook states use lowercase, while REST states retain uppercase. Imported comments retain their stored identity on edit/delete; known vendor edit times survive the projection. Local inline comments and replies retain their target commit. PR synchronization snapshots are captured with their state writes, before callback delivery, so a later push cannot replace an earlier event's head. Other emitted event types retain partial payload coverage. Native ping, delivery list/detail and explicit redelivery routes are supported; redelivery keeps the original GUID and payload with current hook settings. Failed callbacks are recorded without undoing the vendor mutation. Callback attempts run before the mutation response, with a ten-second timeout per hook; automatic retries and crash recovery of pending attempts are not modeled. Signing secrets are masked on native reads and the mirror. Full hook PATCH removes an omitted secret; config PATCH preserves it. TLS verification remains enabled (insecure_ssl: 1is explicitly unsupported). Identical configurations with overlapping subscriptions are refused on create or update. Delivery records are local execution history and cannot be deployed to GitHub. See GitHub's repository webhook API and delivery guidance. - Conformance (
github-conformance.ts): field-name subset vs the vendored GitHub REST PR schema (PR + nested base/head/repo). - UI mirror (
github-mirror-ui.ts): a GitHub-style PR browser (React) with a per-repo selector ("multiple githubs"), a pure frontend (R3) — the shell + assets with the twin's own fetch adapter mounted beside them. It reads the world through the twin's store doorGET /twin/store/mirror(the one named projectiongithub-mirror-state.tsbuilds server-side; observed review/comment counts are surfaced honestly), and any console write goes through the REST API on the same origin. On that one host a document navigation (Accept: text/html) is the console's deep link and everything else is the vendor's wire; the git smart-HTTP plane is not carried there. Pure helpers both sides share (the store path, the PR/issue route grammar) live ingithub-shared.ts. - Connector (
github-connector.ts): the live-vendor lifecycle over an injected octokit-like executor.syncGithubFromRealpulls a repo's PRs + issues and folds their content; the kernel performs local writes throughperformGithubAction. Pull is per-row conflict tolerant: if one stored event has diverged from what a resource now folds to (aConflicting duplicate— e.g. a post-append line mutation raced the live writer), that ONE row is skipped rather than aborting the whole repo's fold, so every other resource in the same pull still folds. The result reports it —{ observed, deltasAppended, eventsAppended, issues, conflictsSkipped, conflictingIds }— so a poller can log the integrity problem loudly instead of it being swallowed. Every other append error stays fatal. A PR's conversation pulls as subjects, not as a summary: each review isreview/<owner/repo>#<n>:review:<reviewId>(author login, human-or-bot, state, body, submitted_at, commit_id, and the INLINE diff comments it wrapped — joined by each/pulls/{n}/commentsrow'spull_request_review_id), and each issue comment isissue_comment/<owner/repo>#<n>:ic:<commentId>. Two reviews between two polls are therefore two deltas, and each is dated by the PROVIDER (a review bysubmitted_at, a comment byupdated_at ?? created_at), not by the poll clock. The PR keeps its own summary fields, so a consumer reading onlypull_requestis unaffected. Budget: those three per-PR reads are skipped for a PR whoseupdated_athas not moved since the last observation (the stamp is read back from the shadow), so N unchanged PRs cost one list call; an open-only pull also sweeps a bounded page (20) of recently-updated CLOSED PRs so a merge is observed. All three conversation reads are PAGED at the vendor's maximum (100/page, bounded at 10 pages), so a busy PR's 31st review, 31st finding or 31st issue comment is observed rather than lost at a page boundary — the last of those is whatcommentCountpublishes, which used to report the truncated 30 as if it were the whole conversation tab.reviewCountandlatestRevieware drawn from exactly one set — reviews that are submitted and notPENDING— so a count and a verdict can never describe different populations of the same PR. - A review nobody showed us says so. Two shapes of inline comment name no review this
pull can resolve, and NEITHER is dropped. A comment whose
pull_request_review_idis missing entirely (a row the vendor left unattached) rides one fixed bucket per PR,<owner/repo>#<n>:review:unattached; a comment naming a review whose ROW never arrived (a review deleted between the two reads, a page boundary) rides a subject keyed by that id. Both carrypartial: trueand nothing else — no state, no author, no verdict, because nobody showed us one.partialis what tells a consumer this is an unshown review rather than one whose fields have not arrived yet, and neither is counted or eligible to belatestReview. - A finding is authored by whoever wrote it, not by the review that wraps it. Every
inline comment carries its OWN
authorLogin/authorType, read from that row'suser: a scanner App's finding inside a human's review reads asbot, a threaded reply reads as its replier, and the unattached bucket's comments keep their authors although the bucket itself has none. The wrapping review's author is the fallback only for a row that named nobody (user: null); when neither names anyone, no author is served rather than a guess. authorTypeis a one-way signal.authorType: 'bot'⇒ a machine wrote it is RELIABLE (only a GitHub App account is typedBotor suffixed[bot]). The converse is NOT: a GitHub App acting through a user token, a service/machine account, and an Organization all come back typedUserwith an ordinary login, and a review by a deleted account answersuser: null(no login and no type at all). Gate onbot; never readuseras "a person did this".- Push: a threaded reply is a reply. A local
in_reply_towrite crosses on GitHub's own reply route (POST .../pulls/{n}/comments/{comment_id}/replies), addressing the root by the id the vendor gave it — resolved from what a pull observed, or from what an earlier push in the same sweep returned. The connector NEVER forwards a twin-minted id to a real account: a reply whose root has no known GitHub id is refused. The kernel stops the deployment at the first failed action and leaves it unconfirmed; later actions wait until the failure is resolved. A successful root comment adopts the vendor ID before a subsequent reply resolves it.
CLI
world-github serve [--read-only] [--port N] [--root DIR]
world-github mirror [--port N] [--root DIR]
world-github conformance [--root DIR]Point the real @octokit/rest Octokit at it via baseUrl.
Locally created PRs use the modeled authenticated self account, octocat, matching
GET /user. Pulled PRs retain the observed author; an unknown author is omitted.
Older local PRs recover that same modeled author only when their retained history
contains an explicit agent pull_request.create, without changing stored records.
This does not add credential-specific review identity: the separate review-writer
limitation described above still applies. PR responses include base.repo.full_name
and the synthetic node ID PR_<owner/repo>#<number>, shared with the twin's GraphQL
API. These node IDs address twin state and are not real GitHub node IDs.
Interaction surfaces
- SDK/API — zero edits (preferred):
GITHUB_TWIN_URL=http://127.0.0.1:PORT node --require @volter/world-core/inject your-appredirects the real@octokit/restfromapi.github.comto the twin. Or override directly:new Octokit({ baseUrl: 'http://127.0.0.1:PORT', auth: 'twin' }). - API + CLI — run the twin in a World and inspect it with
volter world loganddiff. Use the deployment guide for changeset review and deployment. - Read-only —
world-github serve --read-only: unlimited local reads, no rate limits; writes refuse like GitHub (4xx). - UI mirror —
world-github mirrorrenders a GitHub-style view of the twin's state.
(See Getting Started → "Twin interaction surfaces", and cookbook/zero-edit-inject.)
Coverage
Goal: honest, explicitly tracked coverage of GitHub's core feature surface. The only there are no exclusions. Tracked in three buckets — anything not done or carved out is a gap to close.
Done — PRs (create/edit/merge/list/get; full PR/issue content on pull — title/body/state
and review/comment bodies fold from real GitHub, plus full content on local writes), first-class
issues (pulled from real GitHub via GET /issues, PRs excluded), shared issue/PR number space
(one per-repo counter, real GitHub behavior), reviews + diff-anchored review comments (write,
list, and reply in a thread — POST .../pulls/:n/comments/:cid/replies inherits the root
comment's anchor and answers with in_reply_to_id), issue comments, commit statuses,
check runs, requested reviewers, milestones, labels/assignees, PR
files/commits (local writes), Actions (workflows + workflow runs + jobs/steps — register/
list/get workflows, workflow_dispatch → queued run, rerun/cancel transitions, run filters
status/branch/event, run jobs; rendered in the UI mirror Actions view), Actions
secrets/variables (repo + org scope — secret values never returned, mirroring GitHub),
Actions caches (list/delete by id or key), Actions artifacts (register/list/get/delete
metadata), Actions run logs (302 redirect to a signed URL; serving the bytes is a todo), Git Data
API (refs create/get/update/delete, commits, trees, blobs with content, annotated tags),
deployments + deployment statuses (create/list/filter + status history), environments
(create/update/delete + protection-rule summaries; rendered in the UI mirror Deployments
view), org repositories (GET /orgs/:org/repos with type/sort filters), issue pin/unpin,
pagination, webhooks, UI mirror (rung-5 ✅), connector (pull content+issues + push: create/edit/
merge/review/comment/status/check/milestone/reviewers), Gists (create/edit/list/get/delete +
comments).
Draft PRs become ready through GitHub's GraphQL
markPullRequestReadyForReview
mutation and return to draft through
convertPullRequestToDraft.
Both preserve the PR number, head and reviews and emit pull_request with action
ready_for_review or converted_to_draft. Invalid selections, unknown nodes, closed PRs
and requests for the current draft state fail without changing stored state.
The payload subset is clientMutationId and
pullRequest { id number isDraft url }.
REST PR updates ignore the unsupported draft field; creation still accepts it.
Deploying a draft-state change resolves the destination PR's own node ID and uses
the matching GraphQL mutation, confirming the returned state before acknowledging it.
Git smart HTTP. Unmodified Git clients clone and push through the portable JavaScript
Git implementation in @volter/world-core/git. Git Data REST and smart HTTP share the
object store and refs. Real Git is the test oracle; serving does not spawn Git subprocesses.
Pushes update REST refs and open PR heads and emit push webhooks. Only refs backed by stored
objects are advertised.
PR merges check required statuses and check runs before updating the base ref. Three-way merges preserve non-overlapping file and text changes, reject conflicting changes, and compare the base tip again while holding the ref lock. Armed auto-merge runs on a protected branch when its declared required checks turn green. A newer failure supersedes an old success, and drafts stay open. Merge commits preserve two parents; squash and the modeled rebase method fold the result into one commit. Rename detection, binary conflict resolution, and general Git rebase semantics are not modeled.
SDLC control plane for merge gates — commit statuses at a sha, check runs, branch
protection that persists required_status_checks (contexts/strict) + enforce_admins and
serves them back (GET /branches/:b summary with real enforcement_level semantics, GET/PATCH
.../protection/required_status_checks, POST/DELETE .../protection/enforce_admins), PR reviews
that bind commit_id to the exact head sha at submission (a later push moves the PR head,
the review stays), and the GraphQL repository.pullRequest(number) fields gh pr view --json
reads (headRefOid/state/baseRefName/statusCheckRollup/closingIssuesReferences/changedFiles/
labels/body/comments/assignees/reviewRequests) — scoped to the exact calls this repo's own
governance scripts make (human-approval-gate, break-glass-gate, agent-propose,
finalize-agent-review, review-prerequisites), so the OA merge-gate logic can one day run
hermetically against this twin.
Planned (known-missing, will do) — Projects v2, branch protection rulesets, orgs/teams/members (membership), the rest of the GraphQL API (the modeled subset now includes the gh pr view merge-gate fields), security (code-scanning/dependabot/secret-scanning), packages/pages/codespaces, git-plane follow-ons (full protection enforcement on receive-pack and the remaining PR policies, shallow/partial clone asserts, LFS, SSH transport). (Actions log bytes stay out — CI-compute output, see Out of scope.)
Out of scope (deliberately not modeled, with reason) —
- ~~Actual repository code / file contents~~ narrowed 2026-08-20: the git plane now stores
REAL blob/tree/commit bytes for repos exercised through Git Data REST or
git push. What remains unbuilt is bulk pull of a real remote repository's full object graph through the connector (large + redundant for an evidence twin); the API objects AND locally-authored bytes are covered. - Running real CI compute (executing Actions workflows): infra, not the API — the run/job objects are covered.
Rate budget — the fail-closed backstop on live calls
liveGithubExecute is the one place this pack issues a live request, so every call it makes is charged
against a persistent, fail-closed spend ledger before the request goes out. Past the ceiling, or
while a Retry-After/429 cooldown is armed, it throws instead of calling. The ledger is keyed by
vendor and a hash of the credential (limits are per credential, so it is deliberately not
cwd-scoped) and persists across processes, so a fresh process does not get a fresh allowance; a
corrupt ledger counts as a full window rather than zero spend. There is no option to disable it,
and no value you can pass for budget that yields an unguarded client — an injected budget is
validated by method identity, so a subclass or a Proxy that replaces checkBudget is refused.
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.
The mechanism is shared and vendor-agnostic — it lives in the kernel (@volter/world-core →
packages/world-core/src/rateBudget.ts); what lives here in src/github-budget.ts is this vendor's
declaration (window, ceiling, per-endpoint weights, and a reason citing the limits above) plus
the vendor-bound GithubBudget. The rule is ratified as
../../../docs/contributing/architecture.md D8, and the kernel module's header documents what the
guard does not guarantee — read that before trusting it.
Contents API writes in Git-backed repositories create real blobs, trees and commits on the selected branch. Reads of contents and README files honor branch, tag and commit references; clone/fetch sees the same bytes. Updates and deletions require the current blob SHA, and a competing branch update refuses with 409. Repositories represented only by connector observations retain their observed-file behavior.
Discussion seeding uses GitHub GraphQL repository.discussionCategories and createDiscussion;
readback and addDiscussionComment share the same stored discussion. Category lookup supports
forward first/after pagination. The remaining GraphQL surface is partial; unsupported fields fail loudly.
See GitHub’s schema.
Category fields are limited to ID, name, slug, description and answerability; emoji is not modeled.
Creation emits the existing discussion webhook through the normal server delivery path.
