# Merge queues

Merge queues that build groups, run their checks and land them in order.

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:

```bash
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](https://www.rystic.ai/docs/simulators/github-pulls/features/auto-merge.md).

### Which branches hold a queue

A branch holds a queue in either case:

- An active ruleset's `merge_queue` rule targets it, and an organization owns the repository.
- The scenario seeds a `state_merge_queue` row 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 --auto` into 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.
- `enqueuePullRequest` adds 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`, and `merged_by` is the enqueuer.
- The issue's events gain `removed_from_merge_queue` by `github-merge-queue[bot]`, and `merged` and `closed` by the enqueuer. GitHub logs the removal before or after the merge.
- The `pull_request` `closed` webhook and `merge_group` `destroyed` (reason `merged`) 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.

---

Source: https://www.rystic.ai/docs/simulators/github-pulls/features/merge-queue · Markdown: https://www.rystic.ai/docs/simulators/github-pulls/features/merge-queue.md
