Simulators
Merge queues
A merge queue lands pull requests one group at a time, each group tested on top of the ones ahead of it. Seed a queue on the base branch, require a check, queue a pull request and pass its group’s check to watch it land:
H='Authorization: Bearer ghp_anything'
R="$RYSTIC_URL/repos/tb-owner/tb-repo"
curl -s -X POST "$RYSTIC_URL/_rystic/seed" -d '{"state_merge_queue":{"tb-owner/tb-repo#tb-base-branch":{"id":"MQ_demo","repository":"tb-owner/tb-repo","branch":"tb-base-branch","url":"https://github.com/tb-owner/tb-repo/queue/tb-base-branch","resourcePath":"/tb-owner/tb-repo/queue/tb-base-branch","configuration":{"mergeMethod":"MERGE","mergingStrategy":"ALLGREEN","maximumEntriesToBuild":5,"maximumEntriesToMerge":5,"minimumEntriesToMerge":1,"minimumEntriesToMergeWaitTime":5,"checkResponseTimeout":60}}}}' -o /dev/null -w '%{http_code}\n'
# -> 204
curl -s -X PUT "$R/branches/tb-base-branch/protection" -H "$H" -d '{"required_status_checks":{"strict":false,"contexts":["unit"]},"enforce_admins":false,"required_pull_request_reviews":null,"restrictions":null}' -o /dev/null -w '%{http_code}\n'
# -> 200
curl -s -X POST "$R/statuses/517a4bbdfb525691b11db3e28f466eb3d7598d19" -H "$H" -d '{"state":"success","context":"unit"}' -o /dev/null -w '%{http_code}\n'
# -> 201
curl -s -X POST "$RYSTIC_URL/graphql" -H "$H" -d '{"query":"mutation { enqueuePullRequest(input: {pullRequestId: \"tb-pr-41-node-id\"}) { mergeQueueEntry { state position } } }"}' | jq -c '.data.enqueuePullRequest.mergeQueueEntry'
# -> {"position":1,"state":"QUEUED"}
Q='{"query":"{ repository(owner: \"tb-owner\", name: \"tb-repo\") { mergeQueue(branch: \"tb-base-branch\") { entries(first: 5) { nodes { state pullRequest { number } headCommit { oid } } } } } }"}'
curl -s -X POST "$RYSTIC_URL/graphql" -H "$H" -d "$Q" | jq -c '[.data.repository.mergeQueue.entries.nodes[] | {state, number: .pullRequest.number}]'
# -> [{"state":"AWAITING_CHECKS","number":41}]
curl -s "$R/branches" -H "$H" | jq -r '.[].name | select(startswith("gh-readonly-queue/")) | sub("-[0-9a-f]{40}$"; "-<sha>")'
# -> gh-readonly-queue/tb-base-branch/pr-41-<sha>
group=$(curl -s -X POST "$RYSTIC_URL/graphql" -H "$H" -d "$Q" | jq -r '.data.repository.mergeQueue.entries.nodes[0].headCommit.oid')
curl -s -X POST "$R/statuses/$group" -H "$H" -d '{"state":"success","context":"unit"}' -o /dev/null -w '%{http_code}\n'
# -> 201
curl -s "$R/pulls/41" -H "$H" | jq -c '{state, merged, merged_by: .merged_by.login}'
# -> {"state":"closed","merged":true,"merged_by":"tb-owner"}
curl -s "$R/issues/41/events" -H "$H" | jq -c '[.[].event]'
# -> ["added_to_merge_queue","removed_from_merge_queue","merged","closed"]
“Unconfirmed” marks behavior not yet checked against GitHub. How a blocked pull request waits to merge outside a queue is on Auto-merge.
Which branches hold a queue
A branch holds a queue in either case:
- An active ruleset’s
merge_queuerule targets it, and an organization owns the repository. - The scenario seeds a
state_merge_queuerow on it, or you seed one with/_rystic/seed.
A ruleset’s queue takes the rule’s parameters as its configuration: merge_method as mergeMethod, grouping_strategy as mergingStrategy and the entry bounds. It reads min_entries_to_merge_wait_minutes as minimumEntriesToMergeWaitTime and check_response_timeout_minutes as checkResponseTimeout, in seconds. The queue’s url is the repository’s followed by /queue/<branch>, and its id stays the same for the repository and branch.
Differs from GitHub: a ruleset’s merge_queue rule holds the branch’s queue, but lets a direct merge or push to the branch through.
A ruleset that pairs a merge_queue rule with a wildcard include is refused 422 with Invalid rule 'merge_queue': Wildcard ref names are not supported when merge queue is enabled. GitHub refuses a * pattern this way. The simulator refuses a ? or [ pattern and ~ALL the same way (unconfirmed).
Joining the queue
isMergeQueueEnabled reads whether a base holds a queue. A pull request joins it in one of two ways:
gh pr merge --autointo a queue base enables auto-merge, and the request joins the queue once the pull request’s own protection is met. A pull request already met joins as auto-merge is enabled.enqueuePullRequestadds a pull request whose protection is met.
enqueuePullRequest refuses a pull request it cannot add:
| Pull request | Refused with |
|---|---|
| its protection not yet met | Pull request before the first unmet requirement, as GitHub words a missing required status: Pull request Required status check "<context>" is expected. |
| a draft | Pull request is in draft and Pull request not in mergeable state |
| already queued | Pull request is already in the queue |
| no queue on its base, in an organization’s repository | No merge queue found for branch '<base>' |
| no queue on its base, in a user’s repository | Merge queue is not enabled for this branch |
The response shows the entry as it joined: QUEUED, with no headCommit or baseCommit, and its pull request open.
Groups and their checks
From the next request on, the queue reads the entry AWAITING_CHECKS, with its merge group built.
Differs from GitHub: the simulator builds the merge group once the request that queued the entry ends. GitHub takes seconds.
An entry whose group conflicts then leaves the queue (dequeued, reason MERGE_CONFLICT).
Differs from GitHub: a group built by rebase whose replay of a commit conflicts lands that commit’s changes as an overwrite of the base. GitHub drops the pull request from the queue because it can’t be rebased.
The group is the pull request merged onto the tip ahead of it: the base tip, or the previous entry’s group. It merges with the queue’s configuration.mergeMethod, authored by the enqueuer and committed by GitHub. A git fetch reads it as the branch gh-readonly-queue/<base>/pr-<number>-<parent sha>.
No push, ref write or contents write lands on that branch, from an admin either. Each refusal gives this reason:
A pull request for this branch has been added to a merge queue. Branches that are queued for merging cannot be updated. To modify this branch, dequeue the associated pull request.
| Write | Refusal |
|---|---|
| A push | GH006, the reason as a - line. When a ruleset also refuses the push, GH013, with this reason after the ruleset’s own. |
A .../git/refs update or deletion | 422, with the reason as its message |
| A contents write | 409, with the reason after Could not create file: , Could not update file: or Could not delete file: |
Each of them wraps the reason over three lines, and an API message ends with a newline.
A client can still create and delete other branches under gh-readonly-queue/.
Merging a pull request based on a group’s branch still moves the group, and so does update-branch on a pull request headed by one. A later success can then land the stale group without that merged commit. What GitHub returns for these two writes is unconfirmed.
Building a group sends the merge_group checks_requested webhook. The repository’s merge_group workflows start on the group and report on its head. No push workflow runs on a queue branch.
The required contexts of the base’s protection and rulesets, read on the group head, decide:
| Required contexts | Result |
|---|---|
| all passing | The entry reads MERGEABLE. |
| one failing | The entry leaves the queue (pull_request dequeued, reason CI_FAILURE). The groups behind it are rebuilt: merge_group destroyed (reason invalidated), then checks_requested again. |
Landing and leaving
Entries land in position order while each is mergeable. When one lands:
- The base fast-forwards to the group head, which is the pull request’s
merge_commit_sha, andmerged_byis the enqueuer. - The issue’s events gain
removed_from_merge_queuebygithub-merge-queue[bot], andmergedandclosedby the enqueuer. GitHub logs the removal before or after the merge. - The
pull_requestclosedwebhook andmerge_groupdestroyed(reasonmerged) go out. - The queue branch disappears.
dequeuePullRequest removes an entry (dequeued, reason MANUAL) and rebuilds the groups behind it.
Differs from GitHub: a pull request not in the queue is refused Pull request is not in the merge queue. GitHub returns Failed to remove PR #<number> "<title>".
An entry whose pull request is pushed to, closed, converted to draft, retargeted or merged by hand leaves the queue too, with reason ALREADY_MERGED once merged and UNKNOWN_REMOVAL_REASON otherwise. It leaves at its pull request’s synchronize or closed notification.
Differs from GitHub: PATCH .../pulls/{pull_number}, closePullRequest and convertPullRequestToDraft send no notification, so an entry they change leaves only at the queue’s next move.
A seeded entry records its pull request’s head as the seed lands, and one a scenario loads at boot before the next request runs. Either leaves on any push to its pull request.
A push or a merge that moves the base from outside the queue rebuilds the base’s groups on the new tip. A removal clears the pull request’s auto-merge request unless it merged. Repository.mergeQueue(branch:) lists the entries in order, and is null where no queue stands.
What the queue is compared with
The queue is checked against GitHub on a public repository an organization owns: enqueuePullRequest, dequeuePullRequest, a waiting entry’s read, its group branch and a landing.