Skip to Content
SimulatorsGitHub Pulls

Simulators

GitHub Pulls simulator

GitHub Pulls simulator

github-pulls-twin is a local, stateful simulator of GitHub’s pull-request review surface — the review state machine, the permission rules and the conflict paths, not stubs.

Quickstart

No github-pulls-twin build has been cut yet. Request access for the first one.

curl -fsSL "https://www.rystic.ai/api/install?l=…" | sh   # CLI + license
rystic run github-pulls-twin                              # pulls it, serves :8080
rystic run github-pulls-twin -d --port 9000 --seed scenario.json
rystic run github-pulls-twin --runner docker -d

Point your client at it — base URL only, code unchanged:

from github import Github, Auth
gh = Github(auth=Auth.Token("ghp_twin"), base_url="http://localhost:8080")
const { Octokit } = require("@octokit/rest");
const octokit = new Octokit({ auth: "ghp_twin", baseUrl: "http://localhost:8080" });
Coverage

36 routes, 13 resources. Everything is scoped under /repos/{owner}/{repo}/.

ResourceOperations
Pull requestslist, create, get, update
Files & commitslist files, list commits
Reviewslist, create, get, update, delete, submit event, dismiss
Review commentslist on repo, list on PR, create, get, update, delete, reply
Reactionslist, create, delete — on review comments
Requested reviewerslist, request, remove
Mergecheck merged, merge
Branchupdate branch from base
Codespacescreate from a pull request
Check runscreate, get, update, list annotations, rerequest

Issues and repository administration are the separate github simulator. Creating repositories, branches and teams is out of scope in both — they arrive through the seed.

Auth

Any Authorization header works, and so does none at all — the twin never validates a credential. Send a placeholder ghp_... so your client builds well-formed requests.

Identity is separate from auth, because the review rules depend on who is calling. X-Rystic-Actor names the caller; without it every request is member.

curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls/1            # acts as member
curl -s -H 'X-Rystic-Actor: actor_b' localhost:8080/…/pulls/1      # acts as a second collaborator

That is what makes the review flow testable in one process: member opens the pull request, actor_b reviews it. See Cookbook.

Seeding state
EndpointWhat it does
GET /_rystic/versionhealth check + baked model hash
POST /_rystic/resetwipe all state
POST /_rystic/seedload a state snapshot
POST /_rystic/clockset or advance the clock
POST /_rystic/seed-rngfix the RNG
GET /_rystic/inspectdump the entire state
GET /_rystic/egresswebhook events your requests triggered, in order — ?tail=N for the last N
POST /_rystic/egress-retainbound the egress log (0 = unbounded)
GET /_rystic/registerslist seedable families and fields
GET /_rystic/uithe state browser (browser only)

Thirteen registers, one per resource:

state_branch            state_check_annotation  state_check_run
state_codespace         state_commit            state_pull_request
state_pull_request_file state_pull_request_review
state_pull_request_review_comment                state_reaction
state_repository        state_team              state_user

GET /_rystic/registers returns each one’s key fields and its schema. Most are keyed by id; the ones that hang off a repository are keyed by path — state_repository by owner.login + name, state_pull_request by base.repo.full_name + number.

A pull request needs a world to live in. Seeding state_repository alone gets you a repository whose pull-request list answers 200 [], but POST /pulls will answer 422 Validation Failed until the head and base branches exist and both point at seeded commits. Seed state_repository, state_user, state_branch and state_commit together.

Two seed shapes:

  1. Scenario file, booted via RYSTIC_SEED — a seed wrapper plus determinism knobs. Abbreviated; every register’s required fields are in GET /_rystic/registers.

    {
      "seed": {
        "state_repository": { "octo-org/demo-repo": { "name": "Demo-Repo",
          "full_name": "octo-org/Demo-Repo", "default_branch": "main",
          "collaborators": ["member", "actor_b"] } },
        "state_user":   { "member": { "login": "member", "id": 1 } },
        "state_branch": { "main":    { "name": "main",    "sha": "aaa…" },
                          "feature": { "name": "feature", "sha": "bbb…" } },
        "state_commit": { "aaa…": { "sha": "aaa…" }, "bbb…": { "sha": "bbb…" } }
      },
      "clock": "2026-01-01T00:00:00Z",
      "rng_seed": 42
    }
    
  2. POST /_rystic/seed — the register map only, no wrapper.

    jq .seed scenario.json | curl -X POST localhost:8080/_rystic/seed -d @-
    

Success returns 204; rejection names every valid register. unknown register "seed" means you posted the wrapped file.

Egress log

Nothing is POSTed to an endpoint. Every webhook event your requests trigger renders into one ordered log, and GET /_rystic/egress returns it. This is the webhook surface.

Opening a pull request, approving it and merging it produces three:

{"events":[{"seq":1,"effect":"eff_pull_request_notification","type":"pull_request",
            "payload":{"action":"opened","number":1,"pull_request":{"…":"…"}}},
           {"seq":2,"effect":"eff_pull_request_review_notification",
            "payload":{"action":"submitted","…":"…"}},
           {"seq":3,"effect":"eff_merge_notification","type":"pull_request.closed",
            "payload":{"action":"closed","…":"…"}}],
 "total":3,"dropped":0}
  1. payload is the envelope GitHub would have POSTed. effect is the model’s id for the effect.
  2. total counts everything emitted since the last reset; events may hold fewer.
  3. The log keeps 100,000 events by default. Beyond that the oldest drop and dropped counts them. RYSTIC_EGRESS_RETAIN=0 at boot or POST /_rystic/egress-retain {"retain":0} removes the bound, and GET /_rystic/version reports egress_retain. Check dropped before trusting the log as complete.
  4. POST /_rystic/reset clears it and restarts seq at 1.
Determinism & reset
curl -X POST localhost:8080/_rystic/reset
jq .seed scenario.json | curl -X POST localhost:8080/_rystic/seed -d @-
# ... run your review flow ...
curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls/1   # assert the exact record
curl -s localhost:8080/_rystic/egress                     # assert the exact events

Execution is serialized; pull-request numbers, review ids and comment ids are monotonic sequences that reset with the state, so the first pull request of a run is always 1. Timestamps come from the pinned clock, which runs forward from its pin — compare time-bearing fields by shape, not byte-equality. State is in-memory: restart = clean slate (plus RYSTIC_SEED).

Limitations
  1. The repository arrives through the seed. There is no create-repository, create-branch or create-team route on this surface — state_branch has no producer, so a flow that needs a branch must seed it. state_team is seed-only for the same reason, which is why team-based reviewer requests cannot be built up from an empty world.
  2. No webhook delivery. Events render to the ordered egress log instead of being POSTed anywhere. Don’t build a delivery-retry or signature-verification test against this twin.
  3. Nothing moves on its own. No background merge-conflict computation, no scheduled checks — mergeable and mergeable_state report what the model derives at read time, and time only advances through /_rystic/clock.
  4. Review surface only. Issues, repository settings, Actions and the GraphQL API are out of scope; issues and repo administration are the separate github simulator.
  5. One process = one world. Run multiple ports for isolation.

Thirty-nine probes in the suite are recorded as skipped rather than silently passing — most of them status codes the modelled world cannot reach (status_unrealizable), plus a handful needing an entitlement the oracle account lacks. The card above counts only what actually ran.

Cookbook

Open a pull request, review it, merge it

The permission rules are real, which is the point of the two actors.

# member opens it
curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls \
  -H 'Content-Type: application/json' \
  -d '{"title":"Add rate limiting","head":"feature","base":"main"}'
# -> 201 {"number":1,"state":"open","additions":3,"changed_files":1,...}

# member cannot approve their own work — GitHub's rule, and the twin's
curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls/1/reviews \
  -H 'Content-Type: application/json' -d '{"body":"lgtm","event":"APPROVE"}'
# -> 422 {"message":"Unprocessable Entity",
#         "errors":["Can not approve your own pull request"]}

# a second collaborator can
curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls/1/reviews \
  -H 'X-Rystic-Actor: actor_b' -H 'Content-Type: application/json' \
  -d '{"body":"Looks good.","event":"APPROVE"}'
# -> 200 {"id":1,"state":"APPROVED","author_association":"COLLABORATOR",...}

curl -s -X PUT localhost:8080/repos/octo-org/Demo-Repo/pulls/1/merge \
  -H 'Content-Type: application/json' -d '{"merge_method":"squash"}'
# -> 200 {"merged":true,"message":"Pull Request successfully merged","sha":"…"}

Assert what the merge emitted

curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls/1 | jq '{state, merged}'
# -> {"state":"closed","merged":true}

curl -s localhost:8080/_rystic/egress | jq '.events[] | {seq, effect}'
# -> the opened, submitted and closed events, in order

Drive a review-comment thread

A review comment is anchored to a commit, so it needs the pull request’s head sha:

SHA=$(curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls/1 | jq -r .head.sha)

curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls/1/comments \
  -H 'Content-Type: application/json' \
  -d "{\"body\":\"Why a token bucket here?\",\"path\":\"limits.go\",
       \"line\":1,\"side\":\"RIGHT\",\"commit_id\":\"$SHA\"}"
# -> 201 {"id":1,"path":"limits.go","line":1,...}

curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls/1/comments/1/replies \
  -H 'X-Rystic-Actor: actor_b' -H 'Content-Type: application/json' \
  -d '{"body":"Bursts."}'
# -> 201 {"id":2,"in_reply_to_id":1,...}

Byte-identical CI runs

curl -X POST localhost:8080/_rystic/reset
curl -X POST localhost:8080/_rystic/clock -d '{"time":"2026-01-01T00:00:00Z"}'
jq .seed scenario.json | curl -X POST localhost:8080/_rystic/seed -d @-
# ... run your review flow ...
curl -s localhost:8080/_rystic/inspect > state.json
# diff state.json against the last green run — numbers and ids are exact;
# normalize created_at/updated_at first, they advance from the pinned clock
Release notes

No numbered build yet. Cut builds get an entry here, newest first.

Last updated on