Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

310 lines · 21.7 KBRaw History
1---
2title: Merge queue
3description: Merge pull requests in order, each tested on top of the base branch and the pull requests ahead of it, with merge_group workflows and required checks.
4---
5
6A merge queue merges pull requests into a busy branch one after another, testing each one together with the base branch and every pull request queued ahead of it before it lands. Use it when many pull requests target the same branch and you want the branch to stay green without asking authors to rebase and re-run CI over and over. Diffinery's merge queue works like GitHub's: it uses the same `gh-readonly-queue/` branches and the same `merge_group` workflow event.
7
8## How it works
9
101. You turn on **Require a merge queue** in the base branch's protection rule and list the **Required status checks**.
112. A pull request with no merge blockers is added to the queue with **Merge when ready** (or the CLI, API or MCP).
123. Diffinery builds a **merge group** for it: a commit that contains the base branch, every pull request ahead of it in the queue, and this pull request's changes. The commit goes on a branch named `gh-readonly-queue/<base>/pr-<number>-<head sha>`.
134. Creating that branch triggers `merge_group` workflows (and `push` workflows that match the branch) on the merge group commit. Their checks are reported on that commit.
145. When the required checks pass on the merge group and every pull request ahead has merged, the base branch fast-forwards to exactly that tested commit. The pull request is marked merged, and its merge group branch is deleted.
156. If a merge group fails a required check, conflicts, or takes longer than the check timeout, its pull request leaves the queue with a comment saying why, and the groups behind it are rebuilt without it.
16
17Because each group already contains everything ahead of it, the base branch only ever moves to commits that were tested exactly as they land.
18
19## Turn on the merge queue
20
21You need admin access to the repository, and you must be a person: agents can't change branch protection.
22
23### Turn it on in the web UI
24
251. Open the repository's **Settings** and choose **Branch protection** (`https://diffinery.com/acme/website/settings/branches`).
262. Open the rule for the branch (for example `main`), or fill in **Add a rule** with `main` as the **Branch name pattern**.
273. In **Required status checks**, list the checks that must pass, comma-separated and exactly as they appear on pull requests, for example `CI / test, CI / lint`.
284. Tick **Require a merge queue**, then set:
29 - **Merge method**: **Create a merge commit**, **Squash and merge** or **Rebase and merge**.
30 - **Build concurrency**: how many merge groups are built and checked at once.
31 - **Check timeout (minutes)**: how long a merge group may wait for its required checks.
325. Click **Create rule** or **Save rule**. You see `Branch protection saved.`
33
34### Turn it on with the REST API
35
36`PUT /repos/{owner}/{repo}/branches/{branch}/protection` replaces the whole rule, so send every setting you want to keep:
37
38```sh
39curl -X PUT https://diffinery.com/api/v3/repos/acme/website/branches/main/protection \
40 -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
41 -d '{
42 "requirePullRequest": true,
43 "requiredApprovals": 1,
44 "requiredChecks": ["CI / test"],
45 "mergeQueue": { "method": "squash", "buildConcurrency": 5, "checkTimeoutMinutes": 30 }
46 }'
47```
48
49`"mergeQueue": true` turns it on with the defaults. Read the rule back with `GET` on the same path. The token needs the `admin` scope. See [Branch protection](/docs/repositories/branch-protection) for the other rule settings.
50
51### Settings
52
53| Setting | API field | Default | Allowed values |
54| --- | --- | --- | --- |
55| Merge method | `mergeQueue.method` | `merge` | `merge`, `squash`, `rebase` (anything else becomes `merge`) |
56| Build concurrency | `mergeQueue.buildConcurrency` | 5 | 1 to 20 (values outside are clamped) |
57| Check timeout (minutes) | `mergeQueue.checkTimeoutMinutes` | 60 | 1 to 360 (values outside are clamped) |
58
59Turning the queue off removes every queued pull request with the comment `This pull request was removed from the merge queue: main no longer uses a merge queue.`
60
61## Required checks
62
63The queue decides on **Required status checks** from the same branch protection rule:
64
65- A pull request needs its required checks to pass on its own head commit before it can join the queue, like any merge (see [merge blockers](/docs/collaboration/pull-requests#merge-blockers)).
66- Its merge group needs the same required checks to pass on the merge group commit before it merges.
67- A check passes when its conclusion is `success`, `skipped` or `neutral`. Checks that aren't required are ignored by the queue, even when they fail.
68- A required check can be a check run or a commit status context, and the queue counts reports the same way as branch protection: when Diffinery Actions reports a check, only its result counts; a check bound in the rule's `requiredCheckSources` counts only that reporter; reports from agents never count; and any counted failure fails the group. See [Branch protection](/docs/repositories/branch-protection#required-checks).
69
70So the workflows that produce your required checks must run on both `pull_request` and `merge_group`. A job's check name is `<workflow name> / <job name>`, and it is the same for both events.
71
72An external CI system that reports a required check must also report it on the merge group commit (`github.event.merge_group.head_sha`, the head of the `gh-readonly-queue/...` branch), with `POST /repos/{owner}/{repo}/statuses/{sha}` or `POST /repos/{owner}/{repo}/check-runs`. A finished commit status, or a check run sent with `POST` as completed, makes the queue look at the group again at once. A check run completed later with `PATCH` is only noticed at the queue's next event, at the latest when the check timeout ends, so send the final result with `POST` (see [Required checks and the merge queue](/docs/collaboration/checks-and-statuses#required-checks-and-the-merge-queue)). See [Checks and statuses](/docs/collaboration/checks-and-statuses).
73
74> **Important:** With no required checks, a merge group passes as soon as it is built, so pull requests merge immediately in queue order without any CI on the combined result. List at least one required check.
75
76## Write workflows for the merge queue
77
78Add `merge_group` to the triggers of each workflow that produces a required check:
79
80```yaml
81name: CI
82on:
83 pull_request:
84 branches: [main]
85 merge_group:
86 push:
87 branches: [main]
88jobs:
89 test:
90 runs-on: ubuntu-latest
91 steps:
92 - uses: actions/checkout@v4
93 - run: npm ci
94 - run: npm test
95```
96
97With this workflow, add `CI / test` to **Required status checks**.
98
99What a `merge_group` run sees:
100
101| Value | Content |
102| --- | --- |
103| `github.event_name` | `merge_group` |
104| `github.ref` | `refs/heads/gh-readonly-queue/main/pr-12-<head sha>` |
105| `github.sha` | The merge group commit |
106| `github.event.action` | `checks_requested` |
107| `github.event.merge_group.head_sha` | The merge group commit |
108| `github.event.merge_group.head_ref` | The merge group branch, as a full ref |
109| `github.event.merge_group.base_sha` | The commit the group was built on (the base tip, or the group ahead of it) |
110| `github.event.merge_group.base_ref` | `refs/heads/main` |
111| `github.event.merge_group.head_commit` | `id`, `tree_id`, `message`, `timestamp`, `author`, `committer` |
112
113Trigger filters:
114
115- `merge_group` accepts `types` (only `checks_requested` exists) and `branches` / `branches-ignore`, which match the **base** branch (`main`), not the queue branch.
116- Creating a merge group branch is also a push, so `push` workflows whose branch filters match `gh-readonly-queue/...` run on it, as on GitHub. Restrict `push` to the branches you mean (as above), or exclude the queue with `branches-ignore: ['gh-readonly-queue/**']`.
117
118`actions/checkout` checks out the merge group commit, so the job tests exactly what would land.
119
120## Add a pull request to the queue
121
122The pull request must have no merge blockers: approvals, required checks on its head, no conflicts, not a draft (mark a draft ready first with **Ready for review** or `diffinery pr ready 12`; see [Drafts](/docs/collaboration/pull-requests#drafts)). You need write access, and through the API a token with the `write` scope. Agents can queue a pull request only into a branch whose rule has **Allow agents to merge pull requests**.
123
124### Queue in the web UI
125
126On the pull request's **Conversation** tab, the merge box says `main merges through a merge queue: the pull request is tested together with the ones ahead of it before it merges.`
127
1281. Pick the **Merge method**. It defaults to the queue's method.
1292. Leave **Delete fix-pricing-link after merge** ticked to delete the head branch when it merges.
1303. Click **Merge when ready**. You see `Added to the merge queue.`, or `Merged through the merge queue.` if it merged straight away.
131
132The merge box then shows `Queued to merge · position 2 in the main merge queue`, followed by `checking merge group abc1234` or `waiting for the pull requests ahead of it`, the required checks of the merge group, and a **Remove from queue** button.
133
134### Queue with the CLI
135
136```sh
137diffinery pr merge 12 --squash --delete-branch
138```
139
140`diffinery pr merge` first tries a direct merge. Like `gh pr merge`, when the base branch uses a merge queue it adds the pull request to the queue instead, unless you pass `--admin` (see [Bypass the queue](#bypass-the-queue)). It prints `✓ Added #12 to the merge queue (position 2)`, or `✓ Merged #12 through the merge queue` and the commit when it merged at once. A method flag (`--merge`, `--squash`, `--rebase`) overrides the queue's method; without one the queue's method is used.
141
142### Queue with the REST API
143
144```sh
145curl -X PUT https://diffinery.com/api/v3/repos/acme/website/pulls/12/merge-queue \
146 -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
147 -d '{"merge_method": "squash", "delete_branch": true}'
148```
149
150Both fields are optional; `merge_method` defaults to the queue's method. The response says where the pull request stands after the queue has been processed once:
151
152```json
153{
154 "state": "queued",
155 "entry": {
156 "number": 12,
157 "title": "Fix the Contact sales link",
158 "base_ref": "main",
159 "position": 2,
160 "state": "checking",
161 "head_sha": "3f2a9c1e...",
162 "merge_method": "squash",
163 "delete_branch": true,
164 "enqueued_by": { "login": "diffy" },
165 "enqueued_at": "2026-10-13T09:12:44.000Z",
166 "merge_group": {
167 "head_sha": "9be41d07...",
168 "head_ref": "refs/heads/gh-readonly-queue/main/pr-12-3f2a9c1e...",
169 "base_sha": "51c0e2aa...",
170 "built_at": "2026-10-13T09:12:45.000Z"
171 },
172 "checks": [{ "name": "CI / test", "state": "in_progress" }]
173 },
174 "merge_commit_sha": null,
175 "reason": null
176}
177```
178
179| `state` | HTTP status | Meaning |
180| --- | --- | --- |
181| `queued` | 201 | In the queue; `entry` describes it |
182| `merged` | 200 | Merged during this call; `merge_commit_sha` is the commit |
183| `removed` | 200 | It joined but left at once; `reason` says why |
184
185Adding a pull request that is already queued returns its current entry.
186
187### Queue over MCP
188
189`merge_pull_request` queues the pull request when its base branch uses a merge queue, with the optional `method` and `delete_branch`. If you pass `expected_head_sha` and the head has moved, it fails with `head branch was modified; review and try again`. The result has the same `state`, `entry`, `mergeCommit` and `reason` information.
190
191## Order and merge groups
192
193- **Order.** Pull requests are queued first come, first served, per base branch. `position` 1 is the front of the queue.
194- **Group contents.** The first group is built on the current tip of the base branch. Each later group is built on the group ahead of it, so it contains the base and every pull request ahead plus its own changes.
195- **Branch.** Each group lives on `gh-readonly-queue/<base>/pr-<number>-<head sha>`. Nobody can push to these branches: `merge queue branches are read-only`.
196- **Concurrency.** At most **Build concurrency** groups exist at once. Pull requests beyond that wait with the state `queued` (shown as `waiting to be built`) and are built as the ones ahead merge or leave. A built group has the state `checking`.
197- **Merging.** Only the front group merges, and only when it was built on the current base tip and its required checks passed. A group further back whose checks passed waits for those ahead.
198- **Group commits** depend on the merge method:
199
200| Method | Merge group commit |
201| --- | --- |
202| `merge` | A merge commit with parents (previous group or base, pull request head). Subject `Merge pull request #12 from fix-pricing-link`, body the pull request title. |
203| `squash` | One commit on top of the previous group. Subject `<title> (#12)`, body a `* <subject>` line per commit. Author: the pull request's author. |
204| `rebase` | Each of the pull request's commits replayed on top of the previous group, keeping its author and message. Pull requests that contain merge commits can't be rebased. |
205
206The committer is the person who queued the pull request (`<login>@users.noreply.diffinery.com`). If the base branch requires linear history, the `merge` method is refused: `main requires linear history; use squash or rebase`.
207
208## When a pull request merges
209
210When its turn comes, the base branch fast-forwards to the merge group commit. Then, as for any merge:
211
212- the pull request is closed as merged, with `merge_commit_sha` set to the merge group commit and `merged_by` set to whoever queued it;
213- `push` workflows and webhooks run for the base branch;
214- open issues referenced with closing keywords (`Fixes #12`) close;
215- the head branch is deleted if **Delete ... after merge** was ticked;
216- the merge group branch is deleted. Its runs are left to finish.
217
218## When a pull request leaves the queue
219
220The queue removes a pull request in these cases. Except where noted, it posts a comment from `diffinery-merge-queue[bot]` on the pull request: `This pull request was removed from the merge queue: <reason>.`
221
222| Reason in the comment | What happened |
223| --- | --- |
224| `the required check 'CI / test' failed on its merge group` | A required check concluded with a failure. Other wordings: `timed out`, `was cancelled`, `needs action`. |
225| `required checks did not finish within 60 minutes ('CI / test')` | The required checks hadn't all completed within **Check timeout** of the group being built. |
226| `it conflicts with main and the pull requests ahead of it (src/app.ts)` | The pull request can't be combined with the base and the groups ahead. Up to five files are listed. |
227| `rebase merges cannot include merge commits; use a merge commit or squash` | The queue uses `rebase` and the pull request contains a merge commit. |
228| `new commits were pushed to its branch` | The head branch moved after the pull request was queued. Queue it again once its checks pass. |
229| `main no longer uses a merge queue` | The merge queue was turned off for the branch. |
230| `the base branch main no longer exists` | The base branch was deleted. |
231| `the pull request was closed` | No comment is posted. |
232| `diffy removed it from the queue` | Someone removed it by hand. No comment is posted. |
233
234When a pull request leaves, its merge group branch is deleted and its unfinished runs are cancelled with `Cancelled because its merge group was rebuilt or left the queue.` Fix the problem, wait for the pull request's own checks, and add it to the queue again.
235
236Webhooks receive `pull_request` events with the actions `enqueued` and `dequeued`. Queueing and removal don't send notifications themselves; the removal comment does, like any comment.
237
238## Rebuilding groups
239
240A group is thrown away and rebuilt (and its unfinished runs cancelled) when the commit it was built on is no longer the expected one:
241
242- a pull request ahead of it left the queue, so the groups behind it are rebuilt without it;
243- the base branch moved outside the queue, for example an admin pushed to it or bypassed the queue, so every group is rebuilt on the new tip.
244
245A rebuilt group gets a new commit, runs its `merge_group` workflows again, and its check timeout starts over.
246
247## Remove a pull request from the queue
248
249- **Web UI:** click **Remove from queue** in the merge box. You see `Removed from the merge queue.`
250- **CLI:** `diffinery pr merge 12 --disable-auto`
251- **REST API:** `DELETE /repos/{owner}/{repo}/pulls/{number}/merge-queue` (returns `204`, or `404 #12 is not in the merge queue`).
252- **MCP:** the `dequeue_pull_request` tool.
253
254You need write access. Closing the pull request or pushing to its branch also removes it.
255
256## The queue page
257
258Each branch with a queue has a page at `/<owner>/<repo>/queue/<branch>`, for example `https://diffinery.com/acme/website/queue/main`. The merge box links to it. It shows the merge method and, for each queued pull request, its position, title and number, who queued it and when, and either `checking merge group abc1234` with the state of each required check, or `waiting to be built`. The page updates live. When the queue is empty it says `The queue is empty.`; for a branch without a queue it says `main does not use a merge queue. Turn it on in the branch protection settings.`
259
260The same data is available from the API (read access):
261
262```sh
263curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/merge-queue/main
264```
265
266```json
267{
268 "branch": "main",
269 "enabled": true,
270 "settings": { "method": "squash", "buildConcurrency": 5, "checkTimeoutMinutes": 30 },
271 "entries": []
272}
273```
274
275`entries` has the same shape as `entry` in the enqueue response. Each entry's `checks` lists the required checks (or, without required checks, the names of the check runs and statuses reported so far) with a state such as `expected` (no counted report yet), `queued`, `in_progress`, `success` or `failure`. A `pending` commit status shows as `in_progress`, and an `error` status as `failure`. `GET /repos/{owner}/{repo}/pulls/{number}/merge` also includes `mergeQueue` (`method` and the pull request's `entry`) when its base uses a queue.
276
277## Bypass the queue
278
279A repository admin who is a person can merge a pull request directly, skipping the queue, when it has no conflicts, isn't a draft, and the rule doesn't have **Do not allow administrators to bypass these rules**. Through a token, that needs the `admin` scope. The bypass must be asked for explicitly:
280
281- **Web UI:** **Merge now (bypass the queue)**, which asks `Merge directly, bypassing the merge queue?` first. It merges with the queue's merge method.
282- **CLI:** `diffinery pr merge 12 --admin`. It sends `bypass_merge_queue` and `bypass_rules`, so the CLI doesn't fall back to queueing the pull request.
283- **REST API:** `PUT /repos/{owner}/{repo}/pulls/{number}/merge` with `"bypass_merge_queue": true`. `"bypass_rules": true` alone doesn't skip the queue.
284
285Skipping the queue also skips the pull request's unmet branch rules (missing approvals, required checks, linear history), as with an [admin bypass](/docs/collaboration/pull-requests#admin-bypass). When the pull request had unmet blockers, the merge is recorded in the [audit log](/docs/security/audit-log#git-and-pull-requests) as `pull_request.merge_bypassed`.
286
287Everyone else gets `main requires a merge queue: add the pull request to the queue` (`405`) from a direct merge; an admin who could bypass sees `(or, as an admin, bypass the queue with bypass_merge_queue)` added to it. A bypass moves the base branch, so the queued groups are rebuilt.
288
289## Differences from GitHub
290
291- Branch names (`gh-readonly-queue/<base>/pr-<number>-<sha>`), the `merge_group` event and its `checks_requested` action match GitHub, so existing workflows and `branches-ignore` filters work unchanged.
292- GitHub manages the merge queue through GraphQL; Diffinery has REST endpoints: `GET /repos/{owner}/{repo}/merge-queue/{branch}`, and `PUT` and `DELETE /repos/{owner}/{repo}/pulls/{number}/merge-queue`.
293- Each pull request has its own merge group. The settings are the merge method, build concurrency and check timeout; there are no minimum or maximum group sizes or wait times.
294- The queue is configured inside a branch protection rule (`mergeQueue`), not a separate ruleset.
295- `rebase` in the queue replays the commits; a direct `rebase` merge outside the queue is a fast-forward only.
296- Pull requests opened by agents still need a human approval before they can be queued, and agents can queue only where agents may merge.
297
298## Troubleshooting
299
300| Message or symptom | Cause and fix |
301| --- | --- |
302| `main does not use a merge queue; merge the pull request directly` | The base branch's rule doesn't have **Require a merge queue**. Merge normally. |
303| `pull request cannot be added to the merge queue: ...` | The pull request has merge blockers, listed after the colon. Fix them first. |
304| `main requires linear history; use squash or rebase` | Pick `squash` or `rebase`, or change the queue's method. |
305| `the pull request left the merge queue: ...` (web UI) | It joined and was removed in the same step, for example because it conflicts with the groups ahead. |
306| `merge queue branches are read-only` | You pushed to a `gh-readonly-queue/` branch. Push to the pull request's branch instead. |
307| The group waits until the timeout | The required check names don't match what the workflow reports, or the workflow has no `merge_group` trigger. Compare **Required status checks** with the check names on the merge group commit. |
308| An external check passed on the merge group but the group still waits | The report doesn't count: it came from an agent, from an account other than the one bound in `requiredCheckSources`, or Diffinery Actions reports a check with the same name. It may also have been posted on the pull request's head commit instead of the merge group commit. |
309| Pull requests merge without CI | No required checks are configured. Add them to the rule. |
310| `push` workflows run on `gh-readonly-queue/...` | Restrict `on.push.branches`, or add `branches-ignore: ['gh-readonly-queue/**']`. |