Skip to Content
SimulatorsGitHubGraphQL and the gh CLI

Simulators

GraphQL and the gh CLI

POST /graphql serves the subset of GitHub’s GraphQL API that the gh CLI sends, and each mutation writes through the same handler as its REST route. Query the viewer and a repository’s pull requests, comment through a mutation and read the comment back over REST:

H='Authorization: Bearer ghp_anything'
curl -s -X POST "$RYSTIC_URL/graphql" -H "$H" -d '{"query":"{ viewer { login } }"}' | jq -c .
# -> {"data":{"viewer":{"login":"tb-owner"}}}
curl -s -X POST "$RYSTIC_URL/graphql" -H "$H" -d '{"query":"{ repository(owner: \"tb-owner\", name: \"tb-repo\") { pullRequests(first: 2, states: OPEN) { totalCount nodes { number title isDraft } } } }"}' | jq -c '.data.repository.pullRequests'
# -> {"nodes":[{"isDraft":false,"number":44,"title":"Bump vendored dependencies"},{"isDraft":true,"number":43,"title":"WIP: split the store package"}],"totalCount":4}
curl -s -X POST "$RYSTIC_URL/graphql" -H "$H" -d '{"query":"mutation { addComment(input: {subjectId: \"tb-pr-41-node-id\", body: \"Looks good\"}) { commentEdge { node { body } } } }"}' | jq -c .
# -> {"data":{"addComment":{"commentEdge":{"node":{"body":"Looks good"}}}}}
curl -s "$RYSTIC_URL/repos/tb-owner/tb-repo/issues/41/comments" -H "$H" | jq -c '[.[].body]'
# -> ["Looks good"]
curl -s -X POST "$RYSTIC_URL/graphql" -H "$H" -d '{"query":"{ __type(name: \"SearchType\") { kind enumValues { name } } }"}' | jq -c '[.data.__type.enumValues[].name]'
# -> ["ISSUE","ISSUE_ADVANCED","ISSUE_SEMANTIC","ISSUE_HYBRID","REPOSITORY","USER","DISCUSSION"]
curl -s -X POST "$RYSTIC_URL/graphql" -H "$H" -d '{"query":"{ repository(owner: \"tb-owner\", name: \"tb-repo\") { pullRequest(number: 41) { reviewThreads(first: 1) { totalCount } } } }"}' | jq -c '.errors[0].extensions'
# -> {"code":"undefinedField","fieldName":"reviewThreads","typeName":"PullRequest"}

A GitHub Enterprise Server client sends GraphQL to <host>/api/graphql, which the simulator serves exactly as it serves /graphql.

Queries

The simulator serves these queries, with their reviews, comments and check-run rollup, and a branch’s protection rule:

WhereQueries
top levelnode, repository, search, viewer
on a repositoryissue, issueOrPullRequest, issues, pullRequest, pullRequests

A ref’s branchProtectionRule and a repository’s branchProtectionRules read the protection the REST routes write.

A rollup context’s isRequired, asked with pullRequestId or pullRequestNumber, is whether that pull request’s base requires a context of exactly its name. So gh pr checks --required lists the checks a protected base requires.

Mutations

The simulator serves these mutations:

SubjectMutations
comments and reviewsaddComment, addPullRequestReview
issuescloseIssue, createIssue, reopenIssue, updateIssue
pull requestsclosePullRequest, convertPullRequestToDraft, createPullRequest, markPullRequestReadyForReview, mergePullRequest, reopenPullRequest

Every mutation with a REST route writes through that route’s handler, so what gh does shows on the REST surface. convertPullRequestToDraft and markPullRequestReadyForReview have no REST route and write the pull request directly.

Introspection

__type returns what gh’s feature detection reads. That is a type’s name, kind, fields and enumValues, and each field’s args.

Differs from GitHub: __type fills only those fields: description reads null, and others such as interfaces return undefinedField. The simulator doesn’t serve __schema either, which returns undefinedField too.

Outside the subset

A field outside the subset returns GitHub’s undefinedField error.

Differs from GitHub: hiding a comment (minimizeComment), resolving a review thread (resolveReviewThread), adding a thread to a pending review (addPullRequestReviewThread) and a pull request’s reviewThreads return undefinedField.

Differs from GitHub: project items return empty.

Differs from GitHub: repository administration is not modeled.

The merge queue and auto-merge fields on a pull request (autoMergeRequest, isInMergeQueue, isMergeQueueEnabled and mergeQueueEntry) are covered by Auto-merge and Merge queues.

Checks and status contexts

statusCheckRollup carries a commit’s check runs and, as StatusContext, the latest commit status per context.

The gh CLI

gh api is REST, and every gh pr and gh issue command is GraphQL. The simulator serves both. With GH_HOST=github.localhost, gh speaks plain HTTP to api.github.localhost and honors HTTP_PROXY, so the simulator’s URL is the proxy:

export GH_HOST=github.localhost GH_TOKEN=ghp_anything HTTP_PROXY=$RYSTIC_URL
gh pr list -R tb-owner/tb-repo
gh pr view 41 -R tb-owner/tb-repo --comments
gh pr review 41 -R tb-owner/tb-repo --approve
gh pr merge 41 -R tb-owner/tb-repo --squash
gh issue create -R tb-owner/tb-repo --title "An issue" --body "body"
gh pr list -R tb-owner/tb-repo --author tb-contributor --label bug
gh api repos/tb-owner/tb-repo/pulls/41 --jq '.state, .merged'

gh pr list and gh issue list turn these flags into one GraphQL search query in the advanced syntax, after reading SearchType’s values by introspection:

  • --author, --assignee and --label
  • --draft, which lists drafts only
  • --search, where "merged:2026-09-01..2026-09-07" counts that window and free text narrows the list

So they filter by the grammar in Issue and pull request search. A qualifier the simulator cannot evaluate fails the command with the error Refusals shows, which doesn’t name the qualifier.

gh acts as member, since it has no way to send X-Rystic-Actor.

Last updated on