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.

Add pull_request_target, merge_group and a merge queue; run PRs on test merge commits (#17)

- pull_request runs check out refs/pull/<n>/merge (the head merged into the
  current base) and skip while the two conflict, like GitHub. refs/pull/<n>/head
  and /merge are kept by the server, fetchable, hidden from and closed to pushes.
- pull_request_target runs the base branch's own workflow on the base commit,
  never the pull request's. Both report checks on the PR head (runs.head_sha).
- closed and reopened pull request events now trigger workflows.
- Merge queue per protected branch (Require a merge queue): merge groups on
  gh-readonly-queue/<base>/pr-<n>-<head>, merge_group and push workflows on
  them, required checks with a timeout, in-order fast-forward to the tested
  commit, failures dequeued with a comment and the rest rebuilt.
- Queue in the PR merge box, a queue page, branch protection settings, REST
  (PUT/DELETE pulls/{n}/merge-queue, GET merge-queue/{branch}), MCP and
  `diffinery pr merge` (queues automatically, --admin, --disable-auto).
- e2e test and a live-test step on hosted runners cover all of it.


Claude-Session: https://claude.ai/code/session_01XqJMYZ6hCW3JuQXvS4hL6L

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent bcf1e7fcommit 7099aa35a151ac3671aa057f59a5dd879422b363Browse files
22 files changed+1278−64
Files (22)
modified.github/workflows/live-test.yml+92−4View file
@@ -2,7 +2,7 @@
22
33 # End-to-end checks against the real diffinery.com after each deploy: hosted
44 # Actions jobs in Cloudflare Containers (with Docker builds and services), agent sandboxes, webhooks, scheduled
5-# workflows and Git LFS. Each run works in a throwaway private repository that it
5+# workflows, Git LFS, and pull requests (test merge commits, pull_request_target and the merge queue). Each run works in a throwaway private repository that it
66 # deletes at the end.
77 #
88 # Needs the repository secret DIFFINERY_LIVE_TOKEN: a Diffinery token (Settings ->
@@ -23,11 +23,11 @@
2323
2424 jobs:
2525 live:
26- name: Containers, sandboxes, webhooks, schedules and LFS
26+ name: Containers, sandboxes, webhooks, schedules, LFS and pull requests
2727 # Runs on the host that deploys production (see the gate in deploy.yml).
2828 if: ${{ github.event_name == 'workflow_dispatch' || (github.server_url == 'https://diffinery.com' && github.event.workflow_run.conclusion == 'success') }}
2929 runs-on: ubuntu-latest
30- timeout-minutes: 30
30+ timeout-minutes: 45
3131 env:
3232 DIFFINERY: https://diffinery.com
3333 TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }}
@@ -167,6 +167,93 @@
167167 curl -sS -X POST "$base/$id/close" -H "Authorization: Bearer $TOKEN" >/dev/null || true
168168 jq -e --arg r "# $REPO" '.exitCode == 0 and (.stdout | contains($r))' >/dev/null <<<"$out" || { echo "::error::the sandbox did not run the command in the repository"; exit 1; }
169169
170+ - name: Pull requests and the merge queue
171+ if: steps.token.outputs.skip != 'true'
172+ run: |
173+ set -euo pipefail
174+ Q="$REPO-pr"
175+ api() { curl -fsS -X "$1" "$DIFFINERY/api/v3$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" ${3:+--data "$3"}; }
176+ api POST /user/repos "{\"name\":\"$Q\",\"private\":true}" >/dev/null
177+ echo "PR_REPO=$Q" >> "$GITHUB_ENV"
178+ api PUT "/repos/$OWNER/$Q/actions/secrets/TOPSECRET" '{"value":"s3cret"}' >/dev/null
179+ remote="https://x-token:${TOKEN}@${DIFFINERY#https://}/$OWNER/$Q.git"
180+ mkdir -p pr/.github/workflows && cd pr
181+ git init -q -b main
182+ git config user.name "Live test" && git config user.email "live-test@diffinery.com"
183+ printf 'a\nb\nc\n' > app.txt
184+ cat > .github/workflows/ci.yml <<'EOF'
185+ name: CI
186+ on:
187+ pull_request:
188+ merge_group:
189+ jobs:
190+ test:
191+ runs-on: ubuntu-latest
192+ steps:
193+ - uses: actions/checkout@v4
194+ - run: echo "pr ok event=${{ '${{' }} github.event_name }} ref=${{ '${{' }} github.ref }} sha=${{ '${{' }} github.sha }} head=$(git rev-parse HEAD) app=$(tr '\n' ' ' < app.txt)"
195+ EOF
196+ cat > .github/workflows/target.yml <<'EOF'
197+ name: Target
198+ on: pull_request_target
199+ jobs:
200+ t:
201+ runs-on: ubuntu-latest
202+ steps:
203+ - run: echo "target ok secret=${{ '${{' }} secrets.TOPSECRET == 's3cret' }} ref=${{ '${{' }} github.ref }}"
204+ EOF
205+ git add -A && git commit -q -m "PR test" && git push -q "$remote" main
206+ # The pull request changes app.txt and tries to rewrite the pull_request_target workflow.
207+ git checkout -q -b change
208+ printf 'A\nb\nc\n' > app.txt
209+ sed -i 's/target ok/PWNED/' .github/workflows/target.yml
210+ git commit -q -am "Change" && git push -q "$remote" change
211+ head=$(git rev-parse HEAD)
212+ # Main moves on, so the test merge commit holds both sides.
213+ git checkout -q main
214+ printf 'a\nb\nC\n' > app.txt
215+ git commit -q -am "Main moves" && git push -q "$remote" main
216+ n=$(api POST "/repos/$OWNER/$Q/pulls" '{"title":"Change","head":"change","base":"main"}' | jq -r .number)
217+ echo "opened pull request #$n (head $head)"
218+
219+ wait_run() { # event -> the completed run JSON
220+ for i in $(seq 1 60); do
221+ run=$(api GET "/repos/$OWNER/$Q/actions/runs?event=$1" | jq -c '.workflow_runs[0] // empty')
222+ [ -n "$run" ] && [ "$(jq -r .status <<<"$run")" = completed ] && { echo "$run"; return; }
223+ sleep 10
224+ done
225+ echo "::error::no completed $1 run" >&2; exit 1
226+ }
227+ log_of() { for job in $(api GET "/repos/$OWNER/$Q/actions/runs/$1/jobs" | jq -r '.jobs[].id'); do api GET "/repos/$OWNER/$Q/actions/jobs/$job/logs"; done; }
228+ pr=$(wait_run pull_request)
229+ echo "$pr" | jq '{event, ref, sha, head_sha, conclusion}'
230+ [ "$(jq -r .conclusion <<<"$pr")" = success ] || { echo "::error::the pull_request run failed"; exit 1; }
231+ [ "$(jq -r .ref <<<"$pr")" = "refs/pull/$n/merge" ] && [ "$(jq -r .head_sha <<<"$pr")" = "$head" ] && [ "$(jq -r .sha <<<"$pr")" != "$head" ] || { echo "::error::the pull_request run did not use the test merge commit"; exit 1; }
232+ log_of "$(jq -r .id <<<"$pr")" | grep -F "pr ok event=pull_request ref=refs/pull/$n/merge" | grep -F "app=A b C" || { echo "::error::the pull_request job did not check out the merged code"; exit 1; }
233+ target=$(wait_run pull_request_target)
234+ echo "$target" | jq '{event, ref, sha, head_sha, conclusion}'
235+ tlog=$(log_of "$(jq -r .id <<<"$target")")
236+ grep -F "target ok secret=true ref=refs/heads/main" <<<"$tlog" || { echo "::error::pull_request_target did not run the base workflow with secrets"; exit 1; }
237+ ! grep -q PWNED <<<"$tlog" || { echo "::error::pull_request_target ran the pull request's workflow"; exit 1; }
238+
239+ api PUT "/repos/$OWNER/$Q/branches/main/protection" '{"requirePullRequest":true,"requiredChecks":["CI / test"],"mergeQueue":{"method":"squash","checkTimeoutMinutes":20}}' >/dev/null
240+ code=$(curl -sS -o /dev/null -w '%{http_code}' -X PUT "$DIFFINERY/api/v3/repos/$OWNER/$Q/pulls/$n/merge" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data '{}')
241+ [ "$code" = 405 ] || { echo "::error::a direct merge into a merge queue branch returned $code"; exit 1; }
242+ api PUT "/repos/$OWNER/$Q/pulls/$n/merge-queue" '{"delete_branch":true}' | jq '{state, position: .entry.position, merge_group: .entry.merge_group}'
243+ for i in $(seq 1 60); do
244+ merged=$(api GET "/repos/$OWNER/$Q/pulls/$n" | jq -r .merged)
245+ [ "$merged" = true ] && break
246+ sleep 10
247+ done
248+ [ "$merged" = true ] || { echo "::error::the merge queue did not merge #$n"; api GET "/repos/$OWNER/$Q/merge-queue/main" | jq .; exit 1; }
249+ group=$(wait_run merge_group)
250+ echo "$group" | jq '{event, ref, sha, conclusion}'
251+ [[ "$(jq -r .ref <<<"$group")" == refs/heads/gh-readonly-queue/main/pr-$n-* ]] || { echo "::error::the merge_group run is not on a queue branch"; exit 1; }
252+ sha=$(api GET "/repos/$OWNER/$Q/branches/main" | jq -r .commit.sha)
253+ [ "$sha" = "$(jq -r .sha <<<"$group")" ] || { echo "::error::main ($sha) is not the tested merge group"; exit 1; }
254+ git fetch -q "$remote" main && [ "$(git show FETCH_HEAD:app.txt | tr '\n' ' ')" = "A b C " ] || { echo "::error::main does not hold the merged change"; exit 1; }
255+ echo "pull request #$n merged through the queue as $sha"
256+
170257 - name: Scheduled workflow
171258 if: steps.token.outputs.skip != 'true'
172259 run: |
@@ -181,7 +268,8 @@
181268 echo "$run" | jq '{id, event, status, conclusion}'
182269 [ "$(jq -r .conclusion <<<"$run")" = success ] || { echo "::error::the scheduled run did not succeed"; exit 1; }
183270
184- - name: Delete the throwaway repository
271+ - name: Delete the throwaway repositories
185272 if: always() && steps.token.outputs.skip != 'true'
186273 run: |
187274 curl -sS -o /dev/null -w '%{http_code}\n' -X DELETE "$DIFFINERY/api/v3/repos/$OWNER/$REPO" -H "Authorization: Bearer $TOKEN"
275+ [ -z "${PR_REPO:-}" ] || curl -sS -o /dev/null -w '%{http_code}\n' -X DELETE "$DIFFINERY/api/v3/repos/$OWNER/$PR_REPO" -H "Authorization: Bearer $TOKEN"
modifiedREADME.md+39−9View file
@@ -133,10 +133,38 @@
133133 ## Workflows
134134
135135 Put workflow files in `.github/workflows/` (or `.diffinery/workflows/`).
136-Supported triggers are `push`, `pull_request`, `workflow_dispatch` (with
137-inputs), `schedule` and `workflow_run` (up to three levels deep, like GitHub),
138-with branch, tag and path filters. Supported workflow features:
136+Supported triggers are `push`, `pull_request`, `pull_request_target`,
137+`merge_group`, `workflow_dispatch` (with inputs), `schedule` and `workflow_run`
138+(up to three levels deep, like GitHub), with branch, tag and path filters.
139139
140+Pull requests behave as on GitHub:
141+
142+- `pull_request` runs check out the test merge commit, `refs/pull/<n>/merge`
143+ (the head merged into the current base), with `GITHUB_REF` set to that ref.
144+ While a pull request conflicts with its base, its `pull_request` workflows
145+ don't run. `refs/pull/<n>/head` and `refs/pull/<n>/merge` can be fetched but
146+ not pushed.
147+- `pull_request_target` runs the base branch's own workflow on the base commit,
148+ with the repository's secrets, so a pull request can't change what runs.
149+ Check out the pull request's code yourself only if you trust it.
150+- Both report their checks on the pull request's head commit. `opened`,
151+ `synchronize`, `reopened` and `closed` (with `pull_request.merged`) fire.
152+- Merge queue: turn on **Require a merge queue** in a branch protection rule
153+ (or `"mergeQueue": {"method": "squash"}` through the API). "Merge when ready"
154+ then adds a pull request to the queue. Each entry gets a merge group branch,
155+ `gh-readonly-queue/<base>/pr-<n>-<head sha>`, holding the base, every pull
156+ request ahead of it and its own changes, and `merge_group` (and `push`)
157+ workflows run on it. Once its required checks pass and the entries ahead have
158+ merged, the base fast-forwards to exactly the tested commit. A group that
159+ fails, conflicts or exceeds the check timeout leaves the queue with a comment
160+ saying why, and the groups behind it are rebuilt. The queue is at
161+ `/<owner>/<repo>/queue/<branch>`; the API is
162+ `PUT`/`DELETE /repos/{owner}/{repo}/pulls/{n}/merge-queue` and
163+ `GET /repos/{owner}/{repo}/merge-queue/{branch}`, `diffinery pr merge` queues
164+ automatically, and the MCP `merge_pull_request` tool does too.
165+
166+Supported workflow features:
167+
140168 - `needs` and job outputs, `if` with `success()`, `failure()`, `always()` and
141169 `cancelled()`, `strategy.matrix` with include and exclude, `fail-fast` and
142170 `max-parallel`, `concurrency` with `cancel-in-progress`, and `timeout-minutes`.
@@ -165,9 +193,8 @@
165193 it and pull Docker Hub images through `mirror.gcr.io`.
166194
167195 **Not supported yet:** `actions/github-script`,
168-Windows and macOS hosted runners, `pull_request_target`, `merge_group`, and
169-`deepen-not` / partial clone filters. Pull request runs use the head commit
170-rather than a test merge commit. A job that uses an unsupported feature fails
196+Windows and macOS hosted runners, and
197+`deepen-not` / partial clone filters. A job that uses an unsupported feature fails
171198 with a clear message instead of misbehaving.
172199
173200 ## Developing
@@ -187,9 +214,12 @@
187214 3. Checks secret scanning and branch protection.
188215 4. Has an agent open a pull request that needs a human approval and a green
189216 check, then merges it.
190-5. Clones shallow, then deepens the clone.
191-6. Drives the MCP server.
192-7. In Chromium with a virtual authenticator, joins from an invite with a new
217+5. Runs pull request workflows on test merge commits, `pull_request_target`
218+ from the base branch, and a merge queue with `merge_group` runs, including a
219+ group that fails and leaves the queue.
220+6. Clones shallow, then deepens the clone.
221+7. Drives the MCP server.
222+8. In Chromium with a virtual authenticator, joins from an invite with a new
193223 passkey, adds and removes passkeys, signs out and in, and uses an admin
194224 sign-in link.
195225
modifiedapps/cli/src/commands/pr.ts+26−1View file
@@ -1,6 +1,7 @@
11 import { readFile } from "node:fs/promises";
22 import path from "node:path";
33 import { exactlyOne, list, positiveInt, repoPath, str, type Command, type Ctx, type Group, type Values } from "../cli.js";
4+import { ApiError } from "../api.js";
45 import { currentBranch } from "../git.js";
56 import { CliError, ExitCode, readAll } from "../io.js";
67 import { ago, json, statusText, symbol, table, truncate } from "../output.js";
@@ -268,6 +269,8 @@
268269 subject: { type: "string", short: "t", desc: "Commit title", arg: "TEXT" },
269270 ...bodyOpts,
270271 "match-head-commit": { type: "string", desc: "Only merge if the head is still at this SHA", arg: "SHA" },
272+ admin: { type: "boolean", desc: "Merge directly even if the base branch uses a merge queue (repository admins)" },
273+ "disable-auto": { type: "boolean", desc: "Remove the pull request from the merge queue" },
271274 },
272275 repo: true,
273276 json: true,
@@ -277,14 +280,36 @@
277280 const n = positiveInt(num, "pull request number");
278281 const method = exactlyOne(o, ["merge", "squash", "rebase"], "merge");
279282 const repo = await ctx.repo();
283+ const api = await ctx.authedApi();
284+ if (o["disable-auto"] === true) {
285+ await api.delete(`${repoPath(repo)}/pulls/${n}/merge-queue`);
286+ if (ctx.json) return json(ctx.io, { removed: true });
287+ return ctx.out(`${ctx.c.green("✓")} Removed #${n} from the merge queue`);
288+ }
289+ const explicit = ["merge", "squash", "rebase"].some((k) => o[k] === true);
280290 const payload = {
281291 merge_method: method,
282292 delete_branch: o["delete-branch"] === true ? true : undefined,
283293 commit_title: str(o, "subject"),
284294 commit_message: await bodyFrom(ctx, o),
285295 sha: str(o, "match-head-commit"),
296+ bypass_merge_queue: o.admin === true ? true : undefined,
286297 };
287- const r = await (await ctx.authedApi()).put<{ merged: boolean; sha: string; method: string }>(`${repoPath(repo)}/pulls/${n}/merge`, payload);
298+ let r: { merged: boolean; sha: string; method: string };
299+ try {
300+ r = await api.put(`${repoPath(repo)}/pulls/${n}/merge`, payload);
301+ } catch (e) {
302+ // Like `gh pr merge`: a base branch with a merge queue gets the pull request queued instead.
303+ if (!(e instanceof ApiError) || e.status !== 405 || !/merge queue/.test(e.message) || o.admin === true) throw e;
304+ const q = await api.put<{ state: string; entry: { position: number } | null; merge_commit_sha: string | null; reason: string | null }>(`${repoPath(repo)}/pulls/${n}/merge-queue`, {
305+ merge_method: explicit ? method : undefined,
306+ delete_branch: payload.delete_branch,
307+ });
308+ if (ctx.json) return json(ctx.io, q);
309+ if (q.state === "queued") return ctx.out(`${ctx.c.green("✓")} Added #${n} to the merge queue (position ${q.entry?.position ?? "?"})`);
310+ if (q.state === "merged") return ctx.out(`${ctx.c.green("✓")} Merged #${n} through the merge queue ${ctx.c.dim(String(q.merge_commit_sha ?? "").slice(0, 12))}`);
311+ throw new CliError(`#${n} left the merge queue: ${q.reason ?? "see the pull request"}`);
312+ }
288313 if (ctx.json) return json(ctx.io, r);
289314 ctx.out(`${ctx.c.green("✓")} Merged #${n} (${r.method ?? method}) ${ctx.c.dim(String(r.sha ?? "").slice(0, 12))}${payload.delete_branch ? " and deleted the branch" : ""}`);
290315 },
modifiedapps/cli/test/pr.test.ts+14−0View file
@@ -78,6 +78,20 @@
7878 expect(bad.code).toBe(2);
7979 });
8080
81+ it("pr merge queues the pull request when the base branch uses a merge queue", async () => {
82+ srv.on("PUT", "/api/v3/repos/o/r/pulls/6/merge", { status: 405, body: { message: "main requires a merge queue: add the pull request to the queue" } });
83+ srv.on("PUT", "/api/v3/repos/o/r/pulls/6/merge-queue", { status: 201, body: { state: "queued", entry: { position: 2 }, merge_commit_sha: null, reason: null } });
84+ const r = await run(["pr", "merge", "6", "-R", "o/r", "-d"], { env });
85+ expect(r.code).toBe(0);
86+ expect(r.stdout).toContain("Added #6 to the merge queue (position 2)");
87+ expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/6/merge-queue")[0]!.body).toEqual({ delete_branch: true });
88+ srv.on("DELETE", "/api/v3/repos/o/r/pulls/6/merge-queue", { status: 204 });
89+ expect((await run(["pr", "merge", "6", "-R", "o/r", "--disable-auto"], { env })).stdout).toContain("Removed #6 from the merge queue");
90+ const admin = await run(["pr", "merge", "6", "-R", "o/r", "--admin"], { env });
91+ expect(admin.code).toBe(1);
92+ expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/6/merge").at(-1)!.body).toEqual({ merge_method: "merge", bypass_merge_queue: true });
93+ });
94+
8195 it("surfaces the server's { message } on errors", async () => {
8296 srv.on("PUT", "/api/v3/repos/o/r/pulls/4/merge", { status: 405, body: { message: "pull request is not mergeable: 1 approval required" } });
8397 const r = await run(["pr", "merge", "4", "-R", "o/r"], { env });
modifiedapps/diffinery/src/app.ts+2−1View file
@@ -152,7 +152,8 @@
152152 return;
153153 }
154154 if (ev.type !== "pull_request" && ev.type !== "issues" && ev.type !== "issue_comment" && ev.type !== "pull_request_review") return;
155- if (ev.type === "pull_request" && ev.action === "synchronize") return;
155+ // Queue removals already notify through the merge queue's comment on the pull request.
156+ if (ev.type === "pull_request" && (ev.action === "synchronize" || ev.action === "enqueued" || ev.action === "dequeued")) return;
156157 const { issue, comments, reviews } = await repoStub(env, ev.repoId).issue(ev.number);
157158 const ids = new Set<string>([issue.author.id, ...comments.map((c) => c.author.id), ...reviews.map((r) => r.author.id)]);
158159 for (const login of [...issue.assignees, ...(issue.pull?.requestedReviewers ?? [])]) {
modifiedapps/diffinery/src/http/api.ts+41−4View file
@@ -8,6 +8,7 @@
88 import { HttpError, b64, fromB64 } from "../lib/util.js";
99 import type { ProtectionRules, Role } from "../repo/types.js";
1010 import type { RunInfo } from "../repo/actions.js";
11+import type { QueueEntry } from "../repo/queue.js";
1112 import { loadRepo, repoStub } from "./common.js";
1213
1314 /**
@@ -415,9 +416,42 @@
415416 });
416417 api.put("/repos/:owner/:repo/pulls/:number/merge", async (c) => {
417418 const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
418- const b = await body<{ merge_method?: "merge" | "squash" | "rebase"; commit_title?: string; commit_message?: string; sha?: string; delete_branch?: boolean }>(c).catch(() => ({}) as Record<string, undefined>);
419- return c.json(await stub.mergePull(num(c), { method: b.merge_method, title: b.commit_title, message: b.commit_message, expectedHeadSha: b.sha, deleteBranch: b.delete_branch }, caller));
419+ const b = await body<{ merge_method?: "merge" | "squash" | "rebase"; commit_title?: string; commit_message?: string; sha?: string; delete_branch?: boolean; bypass_merge_queue?: boolean }>(c).catch(() => ({}) as Record<string, undefined>);
420+ return c.json(await stub.mergePull(num(c), { method: b.merge_method, title: b.commit_title, message: b.commit_message, expectedHeadSha: b.sha, deleteBranch: b.delete_branch, bypassMergeQueue: b.bypass_merge_queue }, caller));
420421 });
422+
423+// Merge queue (GitHub exposes it through GraphQL only; these follow its REST conventions).
424+const queueJson = (e: QueueEntry) => ({
425+ number: e.number,
426+ title: e.title,
427+ base_ref: e.base,
428+ position: e.position,
429+ state: e.state,
430+ head_sha: e.headSha,
431+ merge_method: e.method,
432+ delete_branch: e.deleteBranch,
433+ enqueued_by: { login: e.enqueuedBy.login },
434+ enqueued_at: e.enqueuedAt,
435+ merge_group: e.groupSha ? { head_sha: e.groupSha, head_ref: e.groupRef, base_sha: e.parentSha, built_at: e.builtAt } : null,
436+ checks: e.checks,
437+});
438+api.get("/repos/:owner/:repo/merge-queue/:branch{.+}", async (c) => {
439+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
440+ const branch = c.req.param("branch");
441+ const settings = await stub.mergeQueueSettings(branch);
442+ return c.json({ branch, enabled: !!settings, settings, entries: (await stub.mergeQueue(branch)).map(queueJson) });
443+});
444+api.put("/repos/:owner/:repo/pulls/:number/merge-queue", async (c) => {
445+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
446+ const b = await body<{ merge_method?: "merge" | "squash" | "rebase"; delete_branch?: boolean }>(c).catch(() => ({}) as Record<string, undefined>);
447+ const r = await stub.enqueuePull(num(c), { method: b.merge_method, deleteBranch: b.delete_branch }, caller);
448+ return c.json({ state: r.state, entry: r.entry ? queueJson(r.entry) : null, merge_commit_sha: r.mergeCommit ?? null, reason: r.reason ?? null }, r.state === "queued" ? 201 : 200);
449+});
450+api.delete("/repos/:owner/:repo/pulls/:number/merge-queue", async (c) => {
451+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
452+ await stub.dequeuePull(num(c), caller);
453+ return c.body(null, 204);
454+});
421455 api.get("/repos/:owner/:repo/pulls/:number/reviews", async (c) => {
422456 const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
423457 return c.json((await stub.issue(num(c))).reviews.map((r) => ({ id: r.id, state: r.state, body: r.body, commit_id: r.commitId, submitted_at: r.submittedAt, user: { login: r.author.login, type: r.author.kind === "agent" ? "Bot" : "User" } })));
@@ -465,8 +499,11 @@
465499 display_title: r.title,
466500 path: r.workflowPath,
467501 event: r.event,
468- head_branch: r.ref.replace(/^refs\/heads\//, ""),
469- head_sha: r.sha,
502+ head_branch: r.headBranch,
503+ head_sha: r.headSha,
504+ // The commit the run checked out (GITHUB_SHA): the test merge commit for pull_request runs.
505+ sha: r.sha,
506+ ref: r.ref,
470507 status: r.status,
471508 conclusion: r.conclusion,
472509 actor: { login: r.actor.login, type: r.actor.kind === "agent" ? "Bot" : "User" },
modifiedapps/diffinery/src/mcp/server.ts+19−2View file
@@ -205,14 +205,31 @@
205205 },
206206 },
207207 merge_pull_request: {
208- description: "Merge a pull request when it is mergeable (checks, approvals and branch protection are enforced).",
208+ description:
209+ "Merge a pull request when it is mergeable (checks, approvals and branch protection are enforced). When the base branch uses a merge queue, this adds the pull request to the queue instead; it merges once its merge group passes the required checks.",
209210 input: { ...OWNER_REPO, number: { type: "integer" }, method: { type: "string", enum: ["merge", "squash", "rebase"] }, expected_head_sha: { type: "string" }, delete_branch: { type: "boolean" } },
210211 required: ["owner", "repo", "number"],
211212 run: async (c, a) => {
212213 const { stub, caller } = await repoOf(c, a, "write");
213- return stub.mergePull(int(a, "number"), { method: (optStr(a, "method") as "merge") ?? "merge", expectedHeadSha: optStr(a, "expected_head_sha"), deleteBranch: !!a.delete_branch }, caller);
214+ const number = int(a, "number");
215+ const { issue } = await stub.issue(number);
216+ if (issue.pull && (await stub.mergeQueueSettings(issue.pull.base))) {
217+ if (optStr(a, "expected_head_sha") && optStr(a, "expected_head_sha") !== issue.pull.headSha) throw new HttpError(409, "head branch was modified; review and try again");
218+ return stub.enqueuePull(number, { method: optStr(a, "method") as "merge" | undefined, deleteBranch: !!a.delete_branch }, caller);
219+ }
220+ return stub.mergePull(number, { method: (optStr(a, "method") as "merge") ?? "merge", expectedHeadSha: optStr(a, "expected_head_sha"), deleteBranch: !!a.delete_branch }, caller);
214221 },
215222 },
223+ dequeue_pull_request: {
224+ description: "Remove a pull request from its base branch's merge queue.",
225+ input: { ...OWNER_REPO, number: { type: "integer" } },
226+ required: ["owner", "repo", "number"],
227+ run: async (c, a) => {
228+ const { stub, caller } = await repoOf(c, a, "write");
229+ await stub.dequeuePull(int(a, "number"), caller);
230+ return { removed: true };
231+ },
232+ },
216233 list_issues: {
217234 description: "List or search issues.",
218235 input: { ...OWNER_REPO, state: { type: "string", enum: ["open", "closed", "all"] }, q: { type: "string" }, label: { type: "string" } },
modifiedapps/diffinery/src/repo/RepoDO.ts+41−2View file
@@ -40,7 +40,8 @@
4040 import { Actions } from "./actions.js";
4141 import { Collab } from "./collab.js";
4242 import { RepoObjectStore } from "./objects.js";
43-import { REPO_SCHEMA } from "./schema.js";
43+import { MergeQueue, QUEUE_BRANCH_PREFIX, normalizeMergeQueue } from "./queue.js";
44+import { REPO_SCHEMA, migrateRepoSchema } from "./schema.js";
4445 import { atLeast, globToRegExp, type Caller, type ProtectionRules, type RepoMeta } from "./types.js";
4546
4647 const MAX_PUSH_BYTES = 100 * 1024 * 1024;
@@ -74,16 +75,19 @@
7475 store!: RepoObjectStore;
7576 readonly collab: Collab;
7677 readonly actions: Actions;
78+ readonly queue: MergeQueue;
7779 private metaCache: RepoMeta | null = null;
7880
7981 constructor(ctx: DurableObjectState, env: Env) {
8082 super(ctx, env);
8183 this.sql = ctx.storage.sql;
8284 this.sql.exec(REPO_SCHEMA);
85+ migrateRepoSchema(this.sql);
8386 const meta = this.readMeta();
8487 if (meta) this.store = new RepoObjectStore(this.sql, env.BUCKET, meta.repoId);
8588 this.collab = new Collab(this);
8689 this.actions = new Actions(this, env);
90+ this.queue = new MergeQueue(this);
8791 }
8892
8993 // ---------------------------------------------------------------- meta
@@ -129,6 +133,20 @@
129133 };
130134 }
131135
136+ /** Keep the object alive for work that finishes after the current request. */
137+ background(p: Promise<unknown>): void {
138+ this.ctx.waitUntil(p.catch((e) => console.error("background task failed", e)));
139+ }
140+
141+ /** Set (or with null, clear) the object's single alarm; the merge queue uses it for check timeouts. */
142+ armAlarm(at: number | null): void {
143+ this.ctx.waitUntil(at === null ? this.ctx.storage.deleteAlarm() : this.ctx.storage.setAlarm(at));
144+ }
145+
146+ override async alarm(): Promise<void> {
147+ await this.queue.process();
148+ }
149+
132150 emit(event: DiffineryEvent): void {
133151 this.ctx.waitUntil(this.env.EVENTS.send(event).catch((e) => console.error("event send failed", e)));
134152 this.broadcast("repo", { kind: "event", event });
@@ -190,7 +208,8 @@
190208
191209 async gitInfoRefs(service: "git-upload-pack" | "git-receive-pack"): Promise<Uint8Array> {
192210 if (service === "git-upload-pack") return advertiseUploadPackV2();
193- return advertiseReceivePack(this.refs());
211+ // Like GitHub, pull request refs are hidden from pushes (so `git push --mirror` leaves them alone).
212+ return advertiseReceivePack(this.refs().filter((r) => !r.name.startsWith("refs/pull/")));
194213 }
195214
196215 async gitUploadPack(body: Uint8Array): Promise<Uint8Array> {
@@ -288,6 +307,8 @@
288307 if (!isValidRefName(ref)) return { ref, ok: false, reason: "invalid ref name" };
289308 if (!atLeast(caller.role, "write")) return { ref, ok: false, reason: "permission denied" };
290309 const deleting = newOid === ZERO_OID;
310+ if (ref.startsWith("refs/pull/")) return { ref, ok: false, reason: "refs/pull/ is managed by Diffinery for pull requests" };
311+ if (ref.startsWith(`refs/heads/${QUEUE_BRANCH_PREFIX}`)) return { ref, ok: false, reason: "merge queue branches are read-only" };
291312 if (!deleting && !this.store.has(newOid)) return { ref, ok: false, reason: "missing objects" };
292313 if (!ref.startsWith("refs/heads/")) {
293314 if (ref.startsWith("refs/tags/") && oldOid !== ZERO_OID && !atLeast(caller.role, "maintain")) return { ref, ok: false, reason: "only maintainers may move tags" };
@@ -344,6 +365,8 @@
344365 }
345366 }
346367 if (newOid !== ZERO_OID) await this.actions.onPush(ev, caller).catch((e) => messages.push(`Workflow trigger error: ${(e as Error).message}`));
368+ // A base branch that moved (outside the queue, or by it) needs its merge groups checked.
369+ if (ref.startsWith("refs/heads/") && this.queue.hasEntries(ref.slice(11))) this.queue.schedule(ref.slice(11));
347370 }
348371
349372 // ---------------------------------------------------------------- browsing
@@ -513,8 +536,11 @@
513536 }
514537
515538 setProtection(pattern: string, rules: ProtectionRules | null): void {
539+ if (rules?.mergeQueue) rules = { ...rules, mergeQueue: normalizeMergeQueue(rules.mergeQueue) };
516540 if (rules) this.sql.exec("INSERT OR REPLACE INTO protections (pattern, rules) VALUES (?, ?)", pattern, JSON.stringify(rules));
517541 else this.sql.exec("DELETE FROM protections WHERE pattern = ?", pattern);
542+ // Turning a queue off (or changing its checks) affects what is queued.
543+ this.queue.schedule();
518544 }
519545
520546 // ---------------------------------------------------------------- RPC passthroughs (collab + actions)
@@ -587,6 +613,19 @@
587613 return this.collab.recordDelivery(...a);
588614 }
589615
616+ enqueuePull(...a: Parameters<MergeQueue["enqueue"]>) {
617+ return this.queue.enqueue(...a);
618+ }
619+ dequeuePull(...a: Parameters<MergeQueue["dequeue"]>) {
620+ return this.queue.dequeue(...a);
621+ }
622+ mergeQueue(...a: Parameters<MergeQueue["list"]>) {
623+ return this.queue.list(...a);
624+ }
625+ mergeQueueSettings(...a: Parameters<MergeQueue["settings"]>) {
626+ return this.queue.settings(...a);
627+ }
628+
590629 runs(...a: Parameters<Actions["listRuns"]>) {
591630 return this.actions.listRuns(...a);
592631 }
modifiedapps/diffinery/src/repo/actions.ts+93−31View file
@@ -45,6 +45,8 @@
4545 inputs: string;
4646 concurrency_group: string | null;
4747 display_title: string;
48+ /** Commit the run reports checks on, when it differs from `sha` (pull request runs). */
49+ head_sha: string | null;
4850 };
4951
5052 export type JobRow = {
@@ -79,6 +81,10 @@
7981 event: string;
8082 ref: string;
8183 sha: string;
84+ /** The commit checks are reported on: the pull request head for pull_request runs. */
85+ headSha: string;
86+ /** Branch the run is about: the pull request's head branch for pull request runs. */
87+ headBranch: string;
8288 actor: Actor;
8389 status: RunRow["status"];
8490 conclusion: RunRow["conclusion"];
@@ -113,6 +119,8 @@
113119 event: r.event,
114120 ref: r.ref,
115121 sha: r.sha,
122+ headSha: r.head_sha ?? r.sha,
123+ headBranch: (r.pr_number ? (JSON.parse(r.event_payload) as { pull_request?: { head?: { ref?: string } } }).pull_request?.head?.ref : undefined) ?? r.ref.replace(/^refs\/(heads|tags)\//, ""),
116124 actor: JSON.parse(r.actor),
117125 status: r.status,
118126 conclusion: r.conclusion,
@@ -225,48 +233,99 @@
225233 }
226234 }
227235
236+ /**
237+ * Pull request workflows, like GitHub's: `pull_request` runs the workflow from the test
238+ * merge commit (`refs/pull/<n>/merge`, the head merged into the base; nothing runs while
239+ * they conflict), and `pull_request_target` runs the base branch's own workflow on the
240+ * base commit, so a pull request can never change what runs with the base's secrets.
241+ * Both report their checks on the pull request's head commit.
242+ */
228243 async onPullRequest(action: string, number: number, caller: Caller): Promise<void> {
229244 const { issue } = this.repo.collab.get(number);
230245 const pull = issue.pull!;
231- const files = await this.repo.collab.pullFiles(number);
232- const workflows = await this.loadWorkflows(pull.headSha);
233- const baseSha = this.repo.getRef(`refs/heads/${pull.base}`);
246+ // Once merged, the base already holds the changes: keep the refs as they were and let path filters pass.
247+ const { baseSha, mergeSha } = pull.merged ? { baseSha: this.repo.getRef(`refs/heads/${pull.base}`), mergeSha: null } : await this.repo.collab.refreshPullRefs(number);
248+ const changedFiles = pull.merged ? undefined : (await this.repo.collab.pullFiles(number)).files.map((f) => f.path);
249+ const m = this.repo.meta;
234250 const payload = {
235251 action,
236252 number,
237253 pull_request: {
238254 number,
255+ id: number,
256+ state: issue.state,
239257 title: issue.title,
240258 body: issue.body,
241259 draft: pull.draft,
260+ merged: pull.merged,
261+ merged_at: pull.mergedAt,
262+ merged_by: pull.mergedBy ? { login: pull.mergedBy } : null,
263+ merge_commit_sha: pull.merged ? pull.mergeCommit : mergeSha,
242264 user: { login: issue.author.login },
265+ labels: issue.labels.map((name) => ({ name })),
266+ assignees: issue.assignees.map((login) => ({ login })),
243267 head: { ref: pull.head, sha: pull.headSha, repo: this.repositoryPayload() },
244268 base: { ref: pull.base, sha: baseSha, repo: this.repositoryPayload() },
245- html_url: `${this.env.PUBLIC_URL}/${this.repo.meta.owner}/${this.repo.meta.name}/pull/${number}`,
269+ html_url: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/pull/${number}`,
270+ url: `${this.env.PUBLIC_URL}/api/v3/repos/${m.owner}/${m.name}/pulls/${number}`,
246271 },
247272 repository: this.repositoryPayload(),
248273 sender: { login: caller.login },
249274 };
250- for (const w of workflows) {
251- if (!w.workflow) continue;
252- for (const name of ["pull_request", "pull_request_target"] as const) {
253- const ev: TriggerEvent = { name, action, baseRef: pull.base, changedFiles: files.files.map((f) => f.path) };
254- if (!matchesTrigger(w.workflow, ev)) continue;
255- await this.createRun(w.workflow, {
256- event: name,
257- ref: `refs/pull/${number}/head`,
258- sha: pull.headSha,
259- payload,
260- caller,
261- prNumber: number,
262- headRef: pull.head,
263- baseRef: pull.base,
264- title: issue.title,
265- });
275+ const common = { payload, caller, prNumber: number, headSha: pull.headSha, headRef: pull.head, baseRef: pull.base, title: issue.title };
276+ // A merged pull request's runs use its merge commit, as on GitHub.
277+ const prSha = pull.merged ? pull.mergeCommit : mergeSha;
278+ if (prSha) {
279+ for (const w of await this.loadWorkflows(prSha)) {
280+ if (!w.workflow || !matchesTrigger(w.workflow, { name: "pull_request", action, baseRef: pull.base, changedFiles })) continue;
281+ await this.createRun(w.workflow, { ...common, event: "pull_request", ref: `refs/pull/${number}/merge`, sha: prSha });
266282 }
267283 }
284+ if (baseSha) {
285+ for (const w of await this.loadWorkflows(baseSha)) {
286+ if (!w.workflow || !matchesTrigger(w.workflow, { name: "pull_request_target", action, baseRef: pull.base, changedFiles })) continue;
287+ await this.createRun(w.workflow, { ...common, event: "pull_request_target", ref: `refs/heads/${pull.base}`, sha: baseSha });
288+ }
289+ }
268290 }
269291
292+ /** `merge_group` workflows for a merge queue group, run from the group's commit. */
293+ async onMergeGroup(g: { number: number; base: string; ref: string; sha: string; parent: string; title: string; sender: Actor }): Promise<void> {
294+ const head = await readCommit(this.repo.store, g.sha);
295+ const payload = {
296+ action: "checks_requested",
297+ merge_group: {
298+ head_sha: g.sha,
299+ head_ref: g.ref,
300+ base_sha: g.parent,
301+ base_ref: `refs/heads/${g.base}`,
302+ head_commit: {
303+ id: g.sha,
304+ tree_id: head.tree,
305+ message: head.message,
306+ timestamp: new Date(head.committer.timestamp * 1000).toISOString(),
307+ author: { name: head.author.name, email: head.author.email },
308+ committer: { name: head.committer.name, email: head.committer.email },
309+ },
310+ },
311+ repository: this.repositoryPayload(),
312+ sender: { login: g.sender.login },
313+ };
314+ for (const w of await this.loadWorkflows(g.sha)) {
315+ if (!w.workflow) {
316+ if (w.errors.length) await this.createFailedRun(w.path, "merge_group", g.ref, g.sha, g.sender, w.errors);
317+ continue;
318+ }
319+ if (!matchesTrigger(w.workflow, { name: "merge_group", action: "checks_requested", baseRef: g.base })) continue;
320+ await this.createRun(w.workflow, { event: "merge_group", ref: g.ref, sha: g.sha, payload, caller: g.sender, title: `Merge group for #${g.number}: ${g.title}` });
321+ }
322+ }
323+
324+ /** Cancel unfinished runs on a ref (a merge group branch that is going away). */
325+ async cancelRunsOnRef(ref: string, reason: string): Promise<void> {
326+ for (const r of this.sql.exec<{ id: string }>("SELECT id FROM runs WHERE ref = ? AND status != 'completed'", ref).toArray()) await this.cancelInternal(r.id, reason);
327+ }
328+
270329 async dispatch(workflowFile: string, ref: string, inputs: Record<string, unknown>, caller: Caller): Promise<RunInfo> {
271330 if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to run workflows");
272331 const sha = await this.repo.resolve(ref);
@@ -315,7 +374,7 @@
315374 const m = this.repo.meta;
316375 const payload = JSON.parse(run.event_payload) as Record<string, unknown>;
317376 const actor = JSON.parse(run.actor) as Actor;
318- const refName = run.ref.replace(/^refs\/(heads|tags)\//, "").replace(/^refs\/pull\/(\d+)\/head$/, "$1/merge");
377+ const refName = run.ref.replace(/^refs\/(heads|tags)\//, "").replace(/^refs\/pull\/(\d+)\/(head|merge)$/, "$1/merge");
319378 const pr = payload.pull_request as { head?: { ref?: string }; base?: { ref?: string } } | undefined;
320379 return {
321380 event_name: run.event,
@@ -348,7 +407,7 @@
348407 };
349408 }
350409
351- private async createFailedRun(path: string, event: string, ref: string, sha: string, caller: Caller, errors: string[]): Promise<void> {
410+ private async createFailedRun(path: string, event: string, ref: string, sha: string, caller: Actor, errors: string[]): Promise<void> {
352411 const id = newId();
353412 const t = now();
354413 this.sql.exec(
@@ -379,7 +438,7 @@
379438
380439 async createRun(
381440 wf: Workflow,
382- o: { event: string; ref: string; sha: string; payload: Record<string, unknown>; caller: Caller | { id: string; login: string; kind: Actor["kind"] }; prNumber?: number; inputs?: Record<string, unknown>; headRef?: string; baseRef?: string; title: string },
441+ o: { event: string; ref: string; sha: string; headSha?: string; payload: Record<string, unknown>; caller: Caller | { id: string; login: string; kind: Actor["kind"] }; prNumber?: number; inputs?: Record<string, unknown>; headRef?: string; baseRef?: string; title: string },
383442 ): Promise<RunInfo | null> {
384443 const id = newId();
385444 const t = now();
@@ -402,6 +461,7 @@
402461 inputs: JSON.stringify(o.inputs ?? {}),
403462 concurrency_group: null,
404463 display_title: o.title,
464+ head_sha: o.headSha && o.headSha !== o.sha ? o.headSha : null,
405465 };
406466 const ctx: ExprContext = { github: this.githubContext(run, "", ""), inputs: o.inputs ?? {}, vars: this.variables() };
407467 try {
@@ -411,7 +471,7 @@
411471 console.warn("run-name/concurrency evaluation failed", e);
412472 }
413473 this.sql.exec(
414- "INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, pr_number, attempt, event_payload, inputs, concurrency_group, display_title) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
474+ "INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, pr_number, attempt, event_payload, inputs, concurrency_group, display_title, head_sha) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
415475 run.id,
416476 run.run_number,
417477 run.workflow_path,
@@ -429,6 +489,7 @@
429489 run.inputs,
430490 run.concurrency_group,
431491 run.display_title,
492+ run.head_sha,
432493 );
433494 if (run.concurrency_group && wf.concurrency) {
434495 const cancelInProgress = evaluateValue(wf.concurrency.cancelInProgress, ctx);
@@ -475,8 +536,8 @@
475536 args.push(opts.event);
476537 }
477538 if (opts.sha) {
478- where.push("sha = ?");
479- args.push(opts.sha);
539+ where.push("(sha = ? OR head_sha = ?)");
540+ args.push(opts.sha, opts.sha);
480541 }
481542 if (opts.prNumber) {
482543 where.push("pr_number = ?");
@@ -748,13 +809,13 @@
748809 if (job.labels) {
749810 // Self-hosted: park the job until a registered runner with matching labels claims it.
750811 this.sql.exec("UPDATE jobs SET status = 'waiting', container = NULL WHERE id = ?", jobId);
751- this.repo.upsertCheck({ sha: run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` });
812+ this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` });
752813 return;
753814 }
754815 const token = `djt_${m.repoId}_${jobId}_${randomToken(24)}`;
755816 const container = `job-${jobId}`;
756817 this.sql.exec("UPDATE jobs SET status = 'waiting', token_hash = ?, container = ? WHERE id = ?", await sha256(token), container, jobId);
757- this.repo.upsertCheck({ sha: run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` });
818+ this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` });
758819 try {
759820 const stub = this.env.RUNNER.get(this.env.RUNNER.idFromName(container));
760821 await stub.startJob({ repoId: m.repoId, jobId, token, apiUrl: this.env.PUBLIC_URL });
@@ -850,7 +911,7 @@
850911 status: run.status,
851912 conclusion: run.conclusion,
852913 head_branch: branch,
853- head_sha: run.sha,
914+ head_sha: run.head_sha ?? run.sha,
854915 actor: { login: actor.login },
855916 triggering_actor: { login: actor.login },
856917 pull_requests: run.pr_number ? [{ number: run.pr_number }] : [],
@@ -915,6 +976,7 @@
915976 event: old.event,
916977 ref: old.ref,
917978 sha: old.sha,
979+ headSha: old.head_sha ?? undefined,
918980 payload: JSON.parse(old.event_payload),
919981 caller,
920982 prNumber: old.pr_number ?? undefined,
@@ -984,7 +1046,7 @@
9841046 const m = this.repo.meta;
9851047 const t = now();
9861048 this.sql.exec("UPDATE jobs SET status = 'in_progress', started_at = ? WHERE id = ?", t, jobId);
987- this.repo.upsertCheck({ sha: run.sha, name: this.checkName(run, job), status: "in_progress", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` });
1049+ this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "in_progress", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` });
9881050 this.repo.broadcast(`run:${run.id}`, { action: "job", jobId });
9891051 const secrets: Record<string, string> = { ...scope.secrets, GITHUB_TOKEN: token, DIFFINERY_TOKEN: token };
9901052 const idToken = wf.permissions === "write-all" || (typeof wf.permissions === "object" && wf.permissions["id-token"] === "write") || (typeof def.permissions === "object" && def.permissions["id-token"] === "write") || def.permissions === "write-all";
@@ -1067,7 +1129,7 @@
10671129 );
10681130 const m = this.repo.meta;
10691131 this.repo.upsertCheck({
1070- sha: run.sha,
1132+ sha: run.head_sha ?? run.sha,
10711133 name: this.checkName(run, job),
10721134 status: "completed",
10731135 conclusion: conclusion === "timed_out" ? "timed_out" : conclusion,
modifiedapps/diffinery/src/repo/collab.ts+60−4View file
@@ -1,4 +1,5 @@
11 import { ZERO_OID, isAncestor, mergeBase, mergeTrees, readCommit, serializeCommit, walkCommits } from "@diffinery/git-core";
2+import type { QueueEntry } from "./queue.js";
23 import { HttpError, newId, now, randomToken } from "../lib/util.js";
34 import type { RepoDO } from "./RepoDO.js";
45 import { toInfo } from "./RepoDO.js";
@@ -113,6 +114,8 @@
113114 checks: { name: string; state: string }[];
114115 blockers: string[];
115116 mergeBase: string | null;
117+ /** Set when the base branch merges through a merge queue. */
118+ mergeQueue: { method: "merge" | "squash" | "rebase"; entry: QueueEntry | null } | null;
116119 };
117120
118121 const actorOf = (c: Caller) => ({ id: c.id, login: c.login, kind: c.kind, onBehalfOf: c.onBehalfOf });
@@ -294,6 +297,8 @@
294297 if (r.kind === "pull") {
295298 const p = this.pullRow(number);
296299 this.repo.emit({ type: "pull_request", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: t });
300+ if (!p.merged) this.repo.background(this.repo.actions.onPullRequest(action, number, caller).catch((e) => console.error("pr workflow trigger", e)));
301+ if (action === "closed" && this.repo.queue.isQueued(number)) this.repo.queue.schedule(p.base_ref);
297302 } else this.repo.emit({ type: "issues", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, actor: actorOf(caller), at: t });
298303 }
299304 this.repo.broadcast(`pr:${number}`, { action: "updated" });
@@ -372,6 +377,37 @@
372377 return this.toIssue(this.row(n));
373378 }
374379
380+ /**
381+ * Keep `refs/pull/<n>/head` on the pull request's head and `refs/pull/<n>/merge` on a
382+ * test merge commit of the head into the current base, like GitHub. The merge ref is
383+ * removed while the two conflict. Returns the base and merge commits.
384+ */
385+ async refreshPullRefs(number: number): Promise<{ baseSha: string | null; mergeSha: string | null }> {
386+ const p = this.pullRow(number);
387+ const headRef = `refs/pull/${number}/head`;
388+ const mergeRef = `refs/pull/${number}/merge`;
389+ if (this.repo.getRef(headRef) !== p.head_sha) this.repo.casRef(headRef, null, p.head_sha);
390+ const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`);
391+ const current = this.repo.getRef(mergeRef);
392+ if (!baseSha) return { baseSha: null, mergeSha: current };
393+ const store = this.repo.store;
394+ if (current) {
395+ const c = await readCommit(store, current).catch(() => null);
396+ if (c && c.parents.length === 2 && c.parents[0] === baseSha && c.parents[1] === p.head_sha) return { baseSha, mergeSha: current };
397+ }
398+ const mb = await mergeBase(store, baseSha, p.head_sha);
399+ const [bt, ht, mt] = await Promise.all([readCommit(store, baseSha), readCommit(store, p.head_sha), mb ? readCommit(store, mb) : null]);
400+ const merged = await mergeTrees(store, mt?.tree ?? null, bt.tree, ht.tree);
401+ if (merged.conflicts.length || !merged.tree) {
402+ if (current) this.repo.casRef(mergeRef, null, null);
403+ return { baseSha, mergeSha: null };
404+ }
405+ const sig = { name: "Diffinery", email: "noreply@diffinery.com", timestamp: Math.floor(Date.now() / 1000), timezone: "+0000" };
406+ const sha = await store.write("commit", serializeCommit({ tree: merged.tree, parents: [baseSha, p.head_sha], author: sig, committer: sig, message: `Merge ${p.head_sha} into ${baseSha}\n` }));
407+ this.repo.casRef(mergeRef, null, sha);
408+ return { baseSha, mergeSha: sha };
409+ }
410+
375411 requestReviewers(number: number, reviewers: string[], caller: Caller): void {
376412 if (!atLeast(caller.role, "triage") && this.row(number).author_id !== caller.id) throw new HttpError(403, "permission denied");
377413 const p = this.pullRow(number);
@@ -392,6 +428,7 @@
392428 if (prefs?.dismissStaleReviews) this.sql.exec("UPDATE reviews SET state = 'DISMISSED' WHERE number = ? AND state = 'APPROVED' AND commit_id != ?", p.number, sha);
393429 this.repo.emit({ type: "pull_request", action: "synchronize", repoId: m.repoId, owner: m.owner, repo: m.name, number: p.number, headRef: branch, headSha: sha, baseRef: p.base_ref, actor: actorOf(caller), at: now() });
394430 await this.repo.actions.onPullRequest("synchronize", p.number, caller).catch((e) => console.error("pr workflow trigger", e));
431+ if (this.repo.queue.isQueued(p.number)) this.repo.queue.schedule(p.base_ref);
395432 this.repo.broadcast(`pr:${p.number}`, { action: "synchronize", sha });
396433 out.push(p.number);
397434 }
@@ -518,6 +555,10 @@
518555 input.status === "completed" ? t : null,
519556 );
520557 for (const p of this.sql.exec<{ number: number }>("SELECT number FROM pulls WHERE head_sha = ?", input.sha).toArray()) this.repo.broadcast(`pr:${p.number}`, { action: "checks" });
558+ if (input.status === "completed") {
559+ const queueBase = this.repo.queue.baseForGroup(input.sha);
560+ if (queueBase) this.repo.queue.schedule(queueBase);
561+ }
521562 return this.checks(input.sha).find((c) => c.id === id)!;
522563 }
523564
@@ -526,7 +567,7 @@
526567 async mergeability(number: number, caller?: Caller): Promise<Mergeability> {
527568 const r = this.row(number);
528569 const p = this.pullRow(number);
529- const result: Mergeability = { mergeable: false, state: "unknown", conflicts: [], approvals: 0, requiredApprovals: 0, changesRequested: [], checks: [], blockers: [], mergeBase: null };
570+ const result: Mergeability = { mergeable: false, state: "unknown", conflicts: [], approvals: 0, requiredApprovals: 0, changesRequested: [], checks: [], blockers: [], mergeBase: null, mergeQueue: null };
530571 if (p.merged) return { ...result, state: "merged", blockers: ["already merged"] };
531572 if (r.state === "closed") return { ...result, state: "closed", blockers: ["pull request is closed"] };
532573 const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`);
@@ -537,6 +578,7 @@
537578 const merged = await mergeTrees(this.repo.store, mt?.tree ?? null, bt.tree, ht.tree);
538579 result.conflicts = merged.conflicts;
539580 const rules = this.repo.protectionFor(p.base_ref) ?? {};
581+ if (rules.mergeQueue) result.mergeQueue = { method: rules.mergeQueue.method ?? "merge", entry: this.repo.queue.entry(number) };
540582 result.requiredApprovals = rules.requiredApprovals ?? 0;
541583 const latest = new Map<string, Review>();
542584 for (const rv of this.reviews(number)) if (rv.state !== "COMMENTED") latest.set(rv.author.id, rv);
@@ -572,13 +614,16 @@
572614 return result;
573615 }
574616
575- async merge(number: number, input: { method?: "merge" | "squash" | "rebase"; title?: string; message?: string; expectedHeadSha?: string; deleteBranch?: boolean }, caller: Caller) {
617+ async merge(number: number, input: { method?: "merge" | "squash" | "rebase"; title?: string; message?: string; expectedHeadSha?: string; deleteBranch?: boolean; bypassMergeQueue?: boolean }, caller: Caller) {
576618 const p = this.pullRow(number);
577619 const r = this.row(number);
578620 if (input.expectedHeadSha && input.expectedHeadSha !== p.head_sha) throw new HttpError(409, "head branch was modified; review and try again");
579621 const m = await this.mergeability(number, caller);
580622 const rules = this.repo.protectionFor(p.base_ref) ?? {};
581623 const adminBypass = caller.role === "admin" && !rules.enforceAdmins && caller.kind === "user" && m.conflicts.length === 0 && !p.draft;
624+ if (rules.mergeQueue && !(adminBypass && input.bypassMergeQueue)) {
625+ throw new HttpError(405, `${p.base_ref} requires a merge queue: add the pull request to the queue${adminBypass ? " (or, as an admin, bypass the queue with bypass_merge_queue)" : ""}`, { blockers: [`${p.base_ref} merges through a merge queue`] });
626+ }
582627 if (!m.mergeable && !adminBypass) throw new HttpError(405, `pull request is not mergeable: ${m.blockers.join("; ")}`, { blockers: m.blockers });
583628 const baseRef = `refs/heads/${p.base_ref}`;
584629 const baseSha = this.repo.getRef(baseRef)!;
@@ -603,8 +648,18 @@
603648 );
604649 }
605650 if (!this.repo.casRef(baseRef, baseSha, newSha)) throw new HttpError(409, "base branch moved during merge; try again");
651+ await this.completeMerge(number, newSha, baseSha, caller, !!input.deleteBranch);
652+ return { merged: true, sha: newSha, method };
653+ }
654+
655+ /** Record a merge whose commit is already on the base branch (directly or through the merge queue). */
656+ async completeMerge(number: number, newSha: string, baseSha: string, caller: Caller, deleteBranch: boolean): Promise<void> {
657+ const p = this.pullRow(number);
658+ const r = this.row(number);
659+ const baseRef = `refs/heads/${p.base_ref}`;
606660 const t = now();
607661 this.sql.exec("UPDATE pulls SET merged = 1, merged_at = ?, merged_by = ?, merge_commit = ? WHERE number = ?", t, caller.login, newSha, number);
662+ if (this.repo.queue.isQueued(number)) this.repo.queue.schedule(p.base_ref);
608663 this.sql.exec("UPDATE issues SET state = 'closed', state_reason = 'merged', closed_at = ?, updated_at = ? WHERE number = ?", t, t, number);
609664 const meta = this.repo.meta;
610665 this.repo.emit({ type: "pull_request", action: "merged", repoId: meta.repoId, owner: meta.owner, repo: meta.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: t });
@@ -621,12 +676,13 @@
621676 /* unknown issue number */
622677 }
623678 }
624- if (input.deleteBranch && p.head_ref !== meta.defaultBranch) {
679+ if (deleteBranch && p.head_ref !== meta.defaultBranch) {
625680 const headRef = `refs/heads/${p.head_ref}`;
626681 if (this.repo.getRef(headRef) === p.head_sha) this.repo.casRef(headRef, p.head_sha, ZERO_OID);
627682 }
628683 this.repo.broadcast(`pr:${number}`, { action: "merged", sha: newSha });
629- return { merged: true, sha: newSha, method };
684+ // pull_request workflows with `types: [closed]` (merged ones see github.event.pull_request.merged).
685+ this.repo.background(this.repo.actions.onPullRequest("closed", number, caller).catch((e) => console.error("pr workflow trigger", e)));
630686 }
631687
632688 // ---------------------------------------------------------------- webhooks
addedapps/diffinery/src/repo/queue.ts+397−0View file
@@ -1,0 +1,397 @@
1+import { ZERO_OID, mergeBase, mergeTrees, readCommit, serializeCommit } from "@diffinery/git-core";
2+import type { Actor } from "@diffinery/shared";
3+import { HttpError, now } from "../lib/util.js";
4+import type { RepoDO } from "./RepoDO.js";
5+import type { Caller, MergeQueueSettings } from "./types.js";
6+
7+type QueueRow = {
8+ number: number;
9+ base_ref: string;
10+ seq: number;
11+ head_sha: string;
12+ method: "merge" | "squash" | "rebase";
13+ delete_branch: number;
14+ enqueued_by: string;
15+ enqueued_at: string;
16+ parent_sha: string | null;
17+ group_sha: string | null;
18+ group_ref: string | null;
19+ built_at: string | null;
20+};
21+
22+export type QueueEntry = {
23+ number: number;
24+ title: string;
25+ base: string;
26+ position: number;
27+ headSha: string;
28+ method: QueueRow["method"];
29+ deleteBranch: boolean;
30+ enqueuedBy: Actor;
31+ enqueuedAt: string;
32+ /** queued: waiting for its merge group to be built; checking: its merge group is running checks. */
33+ state: "queued" | "checking";
34+ groupRef: string | null;
35+ groupSha: string | null;
36+ parentSha: string | null;
37+ builtAt: string | null;
38+ checks: Array<{ name: string; state: string }>;
39+};
40+
41+export type EnqueueResult = { state: "queued" | "merged" | "removed"; entry: QueueEntry | null; mergeCommit?: string | null; reason?: string };
42+
43+/** Prefix of merge group branches, the same as GitHub's so existing `branches-ignore` filters keep working. */
44+export const QUEUE_BRANCH_PREFIX = "gh-readonly-queue/";
45+
46+export const QUEUE_BOT: Caller = { id: "system:merge-queue", login: "diffinery-merge-queue[bot]", kind: "system", role: "admin" };
47+
48+const DEFAULT_CONCURRENCY = 5;
49+const DEFAULT_TIMEOUT_MINUTES = 60;
50+const OK = new Set(["success", "skipped", "neutral"]);
51+
52+/** Validates merge queue settings from the API or the settings page. */
53+export function normalizeMergeQueue(q: MergeQueueSettings | boolean): MergeQueueSettings {
54+ const o: MergeQueueSettings = typeof q === "object" && q ? q : {};
55+ const int = (v: unknown, min: number, max: number) => (Number.isFinite(Number(v)) ? Math.max(min, Math.min(max, Math.floor(Number(v)))) : undefined);
56+ const out: MergeQueueSettings = { method: o.method === "squash" || o.method === "rebase" ? o.method : "merge" };
57+ const concurrency = int(o.buildConcurrency, 1, 20);
58+ const timeout = int(o.checkTimeoutMinutes, 1, 360);
59+ if (concurrency !== undefined) out.buildConcurrency = concurrency;
60+ if (timeout !== undefined) out.checkTimeoutMinutes = timeout;
61+ return out;
62+}
63+
64+/**
65+ * A merge queue per protected base branch, like GitHub's. Each queued pull request
66+ * gets a merge group: a commit on `gh-readonly-queue/<base>/pr-<n>-<head>` that holds
67+ * the base branch, every pull request ahead of it, and its own changes. `merge_group`
68+ * (and `push`) workflows run on that commit; once the required checks pass and every
69+ * entry ahead has merged, the base branch fast-forwards to exactly the tested commit.
70+ * A pull request whose group fails, conflicts or times out leaves the queue, and the
71+ * groups behind it are rebuilt without it.
72+ */
73+export class MergeQueue {
74+ private lock: Promise<unknown> = Promise.resolve();
75+ /** Why each pull request last left a queue (kept in memory for the enqueue response). */
76+ private removals = new Map<number, string>();
77+
78+ constructor(private repo: RepoDO) {}
79+
80+ private get sql() {
81+ return this.repo.sql;
82+ }
83+
84+ settings(base: string): MergeQueueSettings | null {
85+ return this.repo.protectionFor(base)?.mergeQueue ?? null;
86+ }
87+
88+ private rows(base?: string): QueueRow[] {
89+ return base
90+ ? this.sql.exec<QueueRow>("SELECT * FROM merge_queue WHERE base_ref = ? ORDER BY seq", base).toArray()
91+ : this.sql.exec<QueueRow>("SELECT * FROM merge_queue ORDER BY base_ref, seq").toArray();
92+ }
93+
94+ private row(number: number): QueueRow | null {
95+ return this.sql.exec<QueueRow>("SELECT * FROM merge_queue WHERE number = ?", number).toArray()[0] ?? null;
96+ }
97+
98+ isQueued(number: number): boolean {
99+ return this.row(number) !== null;
100+ }
101+
102+ /** The base branch whose queue holds the merge group on `sha`, if any. */
103+ baseForGroup(sha: string): string | null {
104+ return this.sql.exec<{ base_ref: string }>("SELECT base_ref FROM merge_queue WHERE group_sha = ?", sha).toArray()[0]?.base_ref ?? null;
105+ }
106+
107+ hasEntries(base: string): boolean {
108+ return this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM merge_queue WHERE base_ref = ?", base).one().n > 0;
109+ }
110+
111+ list(base?: string): QueueEntry[] {
112+ const out: QueueEntry[] = [];
113+ const position = new Map<string, number>();
114+ for (const r of this.rows(base)) {
115+ const pos = (position.get(r.base_ref) ?? 0) + 1;
116+ position.set(r.base_ref, pos);
117+ const title = this.sql.exec<{ title: string }>("SELECT title FROM issues WHERE number = ?", r.number).toArray()[0]?.title ?? "";
118+ const required = this.repo.protectionFor(r.base_ref)?.requiredChecks ?? [];
119+ const checks = r.group_sha ? this.repo.collab.checks(r.group_sha) : [];
120+ out.push({
121+ number: r.number,
122+ title,
123+ base: r.base_ref,
124+ position: pos,
125+ headSha: r.head_sha,
126+ method: r.method,
127+ deleteBranch: !!r.delete_branch,
128+ enqueuedBy: JSON.parse(r.enqueued_by) as Actor,
129+ enqueuedAt: r.enqueued_at,
130+ state: r.group_sha ? "checking" : "queued",
131+ groupRef: r.group_ref,
132+ groupSha: r.group_sha,
133+ parentSha: r.parent_sha,
134+ builtAt: r.built_at,
135+ checks: (required.length ? required : checks.map((c) => c.name)).map((name) => {
136+ const c = checks.find((x) => x.name === name);
137+ return { name, state: !c ? "expected" : c.status === "completed" ? (c.conclusion ?? "neutral") : c.status };
138+ }),
139+ });
140+ }
141+ return out;
142+ }
143+
144+ entry(number: number): QueueEntry | null {
145+ const r = this.row(number);
146+ return r ? (this.list(r.base_ref).find((e) => e.number === number) ?? null) : null;
147+ }
148+
149+ /** Add a pull request to its base branch's queue ("Merge when ready"). */
150+ async enqueue(number: number, input: { method?: QueueRow["method"]; deleteBranch?: boolean }, caller: Caller): Promise<EnqueueResult> {
151+ const { issue } = this.repo.collab.get(number);
152+ const pull = issue.pull;
153+ if (!pull) throw new HttpError(422, `#${number} is not a pull request`);
154+ const settings = this.settings(pull.base);
155+ if (!settings) throw new HttpError(422, `${pull.base} does not use a merge queue; merge the pull request directly`);
156+ if (this.row(number)) return this.result(number);
157+ const m = await this.repo.collab.mergeability(number, caller);
158+ if (!m.mergeable) throw new HttpError(405, `pull request cannot be added to the merge queue: ${m.blockers.join("; ")}`, { blockers: m.blockers });
159+ const method = input.method ?? settings.method ?? "merge";
160+ if (method === "merge" && this.repo.protectionFor(pull.base)?.requireLinearHistory) throw new HttpError(422, `${pull.base} requires linear history; use squash or rebase`);
161+ this.sql.exec("INSERT INTO counters (name, value) VALUES ('queue', 1) ON CONFLICT(name) DO UPDATE SET value = value + 1");
162+ const seq = this.sql.exec<{ value: number }>("SELECT value FROM counters WHERE name = 'queue'").one().value;
163+ this.sql.exec(
164+ "INSERT INTO merge_queue (number, base_ref, seq, head_sha, method, delete_branch, enqueued_by, enqueued_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
165+ number,
166+ pull.base,
167+ seq,
168+ pull.headSha,
169+ method,
170+ input.deleteBranch ? 1 : 0,
171+ JSON.stringify({ id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }),
172+ now(),
173+ );
174+ this.emit(number, "enqueued", caller);
175+ await this.process(pull.base);
176+ return this.result(number);
177+ }
178+
179+ /** Where a pull request stands after a queue pass: still queued, merged, or removed (and why). */
180+ private result(number: number): EnqueueResult {
181+ const entry = this.entry(number);
182+ if (entry) return { state: "queued", entry };
183+ const merged = this.sql.exec<{ merge_commit: string | null; merged: number }>("SELECT merged, merge_commit FROM pulls WHERE number = ?", number).toArray()[0];
184+ if (merged?.merged) return { state: "merged", entry: null, mergeCommit: merged.merge_commit };
185+ return { state: "removed", entry: null, reason: this.removals.get(number) };
186+ }
187+
188+ /** Remove a pull request from the queue at a person's request. */
189+ async dequeue(number: number, caller: Caller): Promise<void> {
190+ const r = this.row(number);
191+ if (!r) throw new HttpError(404, `#${number} is not in the merge queue`);
192+ await this.exclusive(async () => {
193+ const row = this.row(number);
194+ if (row) await this.remove(row, `${caller.login} removed it from the queue`, caller, false);
195+ });
196+ await this.process(r.base_ref);
197+ }
198+
199+ /** Re-evaluate in the background (after checks, pushes and pull request changes). */
200+ schedule(base?: string): void {
201+ this.repo.background(this.process(base));
202+ }
203+
204+ /** Advance one queue, or every queue: merge, drop failures, rebuild and build merge groups. */
205+ process(base?: string): Promise<void> {
206+ return this.exclusive(async () => {
207+ const bases = base ? [base] : [...new Set(this.rows().map((r) => r.base_ref))];
208+ for (const b of bases) {
209+ try {
210+ await this.step(b);
211+ } catch (e) {
212+ console.error(`merge queue for ${b} failed`, e);
213+ }
214+ }
215+ this.armAlarm();
216+ });
217+ }
218+
219+ private exclusive<T>(fn: () => Promise<T>): Promise<T> {
220+ const run = this.lock.then(fn, fn);
221+ this.lock = run.catch(() => undefined);
222+ return run;
223+ }
224+
225+ private async step(base: string): Promise<void> {
226+ const settings = this.settings(base);
227+ const baseRef = `refs/heads/${base}`;
228+ for (const r of this.rows(base)) {
229+ if (!settings) await this.remove(r, `${base} no longer uses a merge queue`);
230+ else if (!this.repo.getRef(baseRef)) await this.remove(r, `the base branch ${base} no longer exists`);
231+ else {
232+ const p = this.sql.exec<{ head_sha: string; merged: number; state: string }>("SELECT p.head_sha, p.merged, i.state FROM pulls p JOIN issues i ON i.number = p.number WHERE p.number = ?", r.number).toArray()[0];
233+ if (!p || p.merged || p.state !== "open") await this.remove(r, "the pull request was closed", undefined, false);
234+ else if (p.head_sha !== r.head_sha) await this.remove(r, "new commits were pushed to its branch");
235+ }
236+ }
237+ if (!settings) return;
238+
239+ // Merge from the front: the front group is built on the current base and its checks passed.
240+ for (;;) {
241+ const front = this.rows(base)[0];
242+ const baseSha = this.repo.getRef(baseRef);
243+ if (!front?.group_sha || !baseSha || front.parent_sha !== baseSha) break;
244+ const verdict = this.verdict(front, base, settings);
245+ if (verdict === "pending") break;
246+ if (verdict !== "pass") {
247+ await this.remove(front, verdict);
248+ continue;
249+ }
250+ if (!this.repo.casRef(baseRef, baseSha, front.group_sha)) break;
251+ this.sql.exec("DELETE FROM merge_queue WHERE number = ?", front.number);
252+ // The group's commit is now the base branch; its runs (on the deleted branch) may finish.
253+ await this.dropGroup(front, { cancel: false });
254+ const by = JSON.parse(front.enqueued_by) as Actor;
255+ await this.repo.collab.completeMerge(front.number, front.group_sha, baseSha, { ...by, role: "admin" }, !!front.delete_branch);
256+ }
257+
258+ // Each group must sit on the one ahead of it (the first on the base branch). Groups whose
259+ // checks failed leave the queue; anything built on a group that changed is rebuilt.
260+ let expected = this.repo.getRef(baseRef)!;
261+ let built = 0;
262+ let blocked = false;
263+ const limit = Math.max(1, Math.min(20, settings.buildConcurrency ?? DEFAULT_CONCURRENCY));
264+ for (const r of this.rows(base)) {
265+ if (!blocked && r.group_sha && r.parent_sha === expected) {
266+ const verdict = this.verdict(r, base, settings);
267+ if (verdict !== "pass" && verdict !== "pending") {
268+ await this.remove(r, verdict);
269+ continue;
270+ }
271+ expected = r.group_sha;
272+ built++;
273+ continue;
274+ }
275+ if (r.group_sha) await this.dropGroup(r, { reset: true });
276+ if (blocked || built >= limit) {
277+ blocked = true;
278+ continue;
279+ }
280+ const group = await this.build(r, expected);
281+ if ("error" in group) {
282+ await this.remove(r, group.error);
283+ continue;
284+ }
285+ expected = group.sha;
286+ built++;
287+ }
288+ }
289+
290+ /** "pass", "pending", or why the group failed. */
291+ private verdict(r: QueueRow, base: string, settings: MergeQueueSettings): string {
292+ const required = this.repo.protectionFor(base)?.requiredChecks ?? [];
293+ const checks = this.repo.collab.checks(r.group_sha!);
294+ for (const name of required) {
295+ const c = checks.find((x) => x.name === name);
296+ if (c?.status === "completed" && !OK.has(c.conclusion ?? "")) {
297+ const what = c.conclusion === "timed_out" ? "timed out" : c.conclusion === "cancelled" ? "was cancelled" : c.conclusion === "action_required" ? "needs action" : "failed";
298+ return `the required check '${name}' ${what} on its merge group`;
299+ }
300+ }
301+ const pending = required.filter((name) => {
302+ const c = checks.find((x) => x.name === name);
303+ return !c || c.status !== "completed";
304+ });
305+ if (!pending.length) return "pass";
306+ const timeout = (settings.checkTimeoutMinutes ?? DEFAULT_TIMEOUT_MINUTES) * 60_000;
307+ if (r.built_at && Date.parse(r.built_at) + timeout <= Date.now()) {
308+ return `required checks did not finish within ${settings.checkTimeoutMinutes ?? DEFAULT_TIMEOUT_MINUTES} minutes (${pending.map((n) => `'${n}'`).join(", ")})`;
309+ }
310+ return "pending";
311+ }
312+
313+ /** Build the merge group of `r` on `parent`, create its branch and start its workflows. */
314+ private async build(r: QueueRow, parent: string): Promise<{ sha: string } | { error: string }> {
315+ const store = this.repo.store;
316+ const { issue } = this.repo.collab.get(r.number);
317+ const pull = issue.pull!;
318+ const by = JSON.parse(r.enqueued_by) as Actor;
319+ const ts = Math.floor(Date.now() / 1000);
320+ const committer = { name: by.login, email: `${by.login}@users.noreply.diffinery.com`, timestamp: ts, timezone: "+0000" };
321+ let sha: string;
322+ if (r.method === "rebase") {
323+ let cur = parent;
324+ for (const c of await this.repo.collab.pullCommits(r.number)) {
325+ if (c.parents.length !== 1) return { error: "rebase merges cannot include merge commits; use a merge commit or squash" };
326+ const [p, mine, theirs] = await Promise.all([readCommit(store, c.parents[0]!), readCommit(store, cur), readCommit(store, c.sha)]);
327+ const merged = await mergeTrees(store, p.tree, mine.tree, theirs.tree);
328+ if (merged.conflicts.length || !merged.tree) return { error: `it conflicts with ${pull.base} and the pull requests ahead of it (${merged.conflicts.slice(0, 5).join(", ")})` };
329+ cur = await store.write("commit", serializeCommit({ tree: merged.tree, parents: [cur], author: theirs.author, committer, message: theirs.message, extraHeaders: theirs.extraHeaders }));
330+ }
331+ sha = cur;
332+ } else {
333+ const mb = await mergeBase(store, parent, r.head_sha);
334+ const [pt, ht, bt] = await Promise.all([readCommit(store, parent), readCommit(store, r.head_sha), mb ? readCommit(store, mb) : null]);
335+ const merged = await mergeTrees(store, bt?.tree ?? null, pt.tree, ht.tree);
336+ if (merged.conflicts.length || !merged.tree) return { error: `it conflicts with ${pull.base} and the pull requests ahead of it (${merged.conflicts.slice(0, 5).join(", ")})` };
337+ const message =
338+ r.method === "squash"
339+ ? `${issue.title} (#${r.number})\n\n${(await this.repo.collab.pullCommits(r.number)).map((c) => `* ${c.message.split("\n")[0]}`).join("\n")}\n`
340+ : `Merge pull request #${r.number} from ${pull.head}\n\n${issue.title}\n`;
341+ const author = r.method === "squash" ? { ...committer, name: issue.author.login, email: `${issue.author.login}@users.noreply.diffinery.com` } : committer;
342+ sha = await store.write("commit", serializeCommit({ tree: merged.tree, parents: r.method === "squash" ? [parent] : [parent, r.head_sha], author, committer, message }));
343+ }
344+ const ref = `refs/heads/${QUEUE_BRANCH_PREFIX}${pull.base}/pr-${r.number}-${r.head_sha}`;
345+ const old = this.repo.getRef(ref);
346+ this.repo.casRef(ref, old, sha);
347+ this.sql.exec("UPDATE merge_queue SET parent_sha = ?, group_sha = ?, group_ref = ?, built_at = ? WHERE number = ?", parent, sha, ref, now(), r.number);
348+ // GitHub runs push workflows on merge group branches too, then merge_group workflows.
349+ await this.repo.afterRefUpdate(ref, old ?? ZERO_OID, sha, QUEUE_BOT);
350+ await this.repo.actions.onMergeGroup({ number: r.number, base: pull.base, ref, sha, parent, title: issue.title, sender: by });
351+ this.repo.broadcast(`pr:${r.number}`, { action: "queue" });
352+ return { sha };
353+ }
354+
355+ /** Delete a group's branch and (unless it merged) cancel its runs; `reset` keeps the entry queued for a rebuild. */
356+ private async dropGroup(r: QueueRow, o: { reset?: boolean; cancel?: boolean } = {}): Promise<void> {
357+ if (r.group_ref) {
358+ if (o.cancel !== false) await this.repo.actions.cancelRunsOnRef(r.group_ref, "Cancelled because its merge group was rebuilt or left the queue.");
359+ const sha = this.repo.getRef(r.group_ref);
360+ if (sha && this.repo.casRef(r.group_ref, sha, null)) await this.repo.afterRefUpdate(r.group_ref, sha, ZERO_OID, QUEUE_BOT);
361+ }
362+ if (o.reset) this.sql.exec("UPDATE merge_queue SET parent_sha = NULL, group_sha = NULL, group_ref = NULL, built_at = NULL WHERE number = ?", r.number);
363+ }
364+
365+ private async remove(r: QueueRow, reason: string, by: Caller = QUEUE_BOT, comment = true): Promise<void> {
366+ this.sql.exec("DELETE FROM merge_queue WHERE number = ?", r.number);
367+ await this.dropGroup(r);
368+ this.removals.set(r.number, reason);
369+ this.emit(r.number, "dequeued", by);
370+ if (comment) {
371+ try {
372+ this.repo.collab.addComment(r.number, `This pull request was removed from the merge queue: ${reason}.`, QUEUE_BOT);
373+ } catch (e) {
374+ console.warn("merge queue comment failed", e);
375+ }
376+ }
377+ this.repo.broadcast(`pr:${r.number}`, { action: "queue" });
378+ }
379+
380+ private emit(number: number, action: "enqueued" | "dequeued", by: Caller | Actor): void {
381+ const p = this.sql.exec<{ head_ref: string; head_sha: string; base_ref: string }>("SELECT head_ref, head_sha, base_ref FROM pulls WHERE number = ?", number).toArray()[0];
382+ if (!p) return;
383+ const m = this.repo.meta;
384+ this.repo.emit({ type: "pull_request", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: { id: by.id, login: by.login, kind: by.kind, onBehalfOf: by.onBehalfOf }, at: now() });
385+ }
386+
387+ /** Wake up when the earliest check timeout of a building group runs out. */
388+ private armAlarm(): void {
389+ let next = Infinity;
390+ for (const r of this.rows()) {
391+ if (!r.built_at) continue;
392+ const minutes = this.settings(r.base_ref)?.checkTimeoutMinutes ?? DEFAULT_TIMEOUT_MINUTES;
393+ next = Math.min(next, Date.parse(r.built_at) + minutes * 60_000 + 1000);
394+ }
395+ this.repo.armAlarm(Number.isFinite(next) ? next : null);
396+ }
397+}
modifiedapps/diffinery/src/repo/schema.ts+19−0View file
@@ -62,5 +62,24 @@
6262 CREATE TABLE IF NOT EXISTS logs (job_id TEXT NOT NULL, seq INTEGER NOT NULL, t TEXT NOT NULL, step INTEGER NOT NULL, text TEXT NOT NULL, PRIMARY KEY (job_id, seq));
6363 CREATE TABLE IF NOT EXISTS caches (key TEXT PRIMARY KEY, size INTEGER NOT NULL, created_at TEXT NOT NULL, last_used TEXT NOT NULL);
6464 CREATE TABLE IF NOT EXISTS artifacts (id TEXT PRIMARY KEY, run_id TEXT NOT NULL, name TEXT NOT NULL, size INTEGER NOT NULL, created_at TEXT NOT NULL, UNIQUE(run_id, name));
65+CREATE TABLE IF NOT EXISTS merge_queue (
66+ number INTEGER PRIMARY KEY, base_ref TEXT NOT NULL, seq INTEGER NOT NULL, head_sha TEXT NOT NULL, method TEXT NOT NULL,
67+ delete_branch INTEGER NOT NULL DEFAULT 0, enqueued_by TEXT NOT NULL, enqueued_at TEXT NOT NULL,
68+ parent_sha TEXT, group_sha TEXT, group_ref TEXT, built_at TEXT
69+);
6570 CREATE TABLE IF NOT EXISTS sandboxes (id TEXT PRIMARY KEY, secret TEXT NOT NULL, created_by TEXT NOT NULL, ref TEXT NOT NULL, created_at TEXT NOT NULL, status TEXT NOT NULL);
6671 `;
72+
73+/** Columns added after a table was first created; added with ALTER TABLE when missing. */
74+const ADDED_COLUMNS: Array<[table: string, column: string, definition: string]> = [
75+ // The commit a run reports its checks on: the PR head for pull_request runs, whose
76+ // GITHUB_SHA is the test merge commit. Null means the run's own sha.
77+ ["runs", "head_sha", "TEXT"],
78+];
79+
80+export function migrateRepoSchema(sql: SqlStorage): void {
81+ for (const [table, column, definition] of ADDED_COLUMNS) {
82+ const has = sql.exec<{ n: number }>(`SELECT count(*) AS n FROM pragma_table_info('${table}') WHERE name = ?`, column).one().n > 0;
83+ if (!has) sql.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
84+ }
85+}
modifiedapps/diffinery/src/repo/types.ts+11−0View file
@@ -33,8 +33,19 @@
3333 agentsMayPush?: boolean;
3434 /** Agents may merge pull requests into matching branches. Default false. */
3535 agentsMayMerge?: boolean;
36+ /** Pull requests merge through a merge queue (like GitHub's "Require merge queue"). */
37+ mergeQueue?: MergeQueueSettings;
3638 }
3739
40+export interface MergeQueueSettings {
41+ /** How each queued pull request is merged. Default "merge". */
42+ method?: "merge" | "squash" | "rebase";
43+ /** How many queued pull requests are built and checked at once. Default 5. */
44+ buildConcurrency?: number;
45+ /** Minutes to wait for required checks on a merge group before removing it. Default 60. */
46+ checkTimeoutMinutes?: number;
47+}
48+
3849 export function globToRegExp(pattern: string): RegExp {
3950 let re = "";
4051 for (let i = 0; i < pattern.length; i++) {
modifiedapps/diffinery/src/web/collab.tsx+167−2View file
@@ -24,6 +24,7 @@
2424 act,
2525 duration,
2626 encPath,
27+ flash,
2728 onBehalfOf,
2829 pageNum,
2930 post,
@@ -685,7 +686,9 @@
685686 </ul>
686687 ) : null}
687688 </div>
688- {isAccount && atLeast(caller.role, "write") && !x.l.repo.archived ? (
689+ {m.mergeQueue ? (
690+ <QueueBox x={x} m={m} canAct={isAccount && atLeast(caller.role, "write") && !x.l.repo.archived} adminBypass={caller.role === "admin" && caller.kind === "user" && m.conflicts.length === 0 && !p.draft} />
691+ ) : isAccount && atLeast(caller.role, "write") && !x.l.repo.archived ? (
689692 <div>
690693 <Form action={`${base}/pull/${i.number}/merge`} t={csrfToken(x.c)} class="row" confirm={bypass ? "Merge without meeting the branch requirements?" : undefined}>
691694 <input type="hidden" name="expected_head" value={p.headSha} />
@@ -725,6 +728,98 @@
725728 );
726729 };
727730
731+const METHOD_LABELS = { merge: "Create a merge commit", squash: "Squash and merge", rebase: "Rebase and merge" } as const;
732+
733+/** The merge box's footer when the base branch merges through a merge queue. */
734+const QueueBox: FC<{ x: ConvCtx; m: Mergeability; canAct: boolean; adminBypass: boolean }> = ({ x, m, canAct, adminBypass }) => {
735+ const i = x.issue;
736+ const p = i.pull!;
737+ const base = repoUrl(x.l.repo);
738+ const q = m.mergeQueue!;
739+ const e = q.entry;
740+ const queueUrl = `${base}/queue/${encPath(p.base)}`;
741+ return (
742+ <div>
743+ {e ? (
744+ <div>
745+ <div class="row">
746+ <Status status="in_progress" />
747+ <span>
748+ <b>Queued to merge</b>
749+ <span class="muted">
750+ {" "}
751+ · position {e.position} in the <a href={queueUrl}>{p.base} merge queue</a> ·{" "}
752+ {e.state === "checking" && e.groupSha ? (
753+ <>
754+ checking merge group <a class="sha" href={`${base}/commit/${e.groupSha}`}>{shortSha(e.groupSha)}</a>
755+ </>
756+ ) : (
757+ "waiting for the pull requests ahead of it"
758+ )}
759+ </span>
760+ </span>
761+ </div>
762+ {e.checks.length ? (
763+ <ul class="checks" style="margin-top:6px">
764+ {e.checks.map((ch) => (
765+ <li>
766+ <Status status={ch.state === "queued" || ch.state === "in_progress" || ch.state === "expected" ? "in_progress" : "completed"} conclusion={ch.state} />
767+ <span>{ch.name}</span>
768+ <span class="muted small grow">{ch.state}</span>
769+ </li>
770+ ))}
771+ </ul>
772+ ) : null}
773+ {canAct ? (
774+ <Form action={`${base}/pull/${i.number}/dequeue`} t={csrfToken(x.c)} class="row" style="margin-top:8px">
775+ <span class="muted small grow">It merges into {p.base} once its merge group passes the required checks.</span>
776+ <button class="btn">Remove from queue</button>
777+ </Form>
778+ ) : null}
779+ </div>
780+ ) : canAct ? (
781+ <div>
782+ <Form action={`${base}/pull/${i.number}/enqueue`} t={csrfToken(x.c)} class="row">
783+ <KSelect name="method" label="Merge method" value={q.method} options={(["merge", "squash", "rebase"] as const).map((v) => ({ value: v, label: METHOD_LABELS[v] }))} />
784+ <KCheckbox
785+ name="delete_branch"
786+ checked
787+ label={
788+ <>
789+ Delete <span class="mono">{p.head}</span> after merge
790+ </>
791+ }
792+ />
793+ <span class="grow" />
794+ {m.mergeable ? (
795+ <button class="btn green">Merge when ready</button>
796+ ) : (
797+ <button class="btn" disabled>
798+ Merge when ready
799+ </button>
800+ )}
801+ </Form>
802+ <div class="row muted small" style="margin-top:6px">
803+ <span class="grow">
804+ {p.base} merges through a <a href={queueUrl}>merge queue</a>: the pull request is tested together with the ones ahead of it before it merges.
805+ </span>
806+ {adminBypass ? (
807+ <Form action={`${base}/pull/${i.number}/merge`} t={csrfToken(x.c)} confirm="Merge directly, bypassing the merge queue?">
808+ <input type="hidden" name="expected_head" value={p.headSha} />
809+ <input type="hidden" name="method" value={q.method} />
810+ <input type="hidden" name="bypass_queue" value="1" />
811+ <button class="btn sm danger">Merge now (bypass the queue)</button>
812+ </Form>
813+ ) : null}
814+ </div>
815+ </div>
816+ ) : (
817+ <div class="muted small">{p.base} merges through a merge queue.</div>
818+ )}
819+ </div>
820+ );
821+};
822+
728823 const ReviewForm: FC<{ x: ConvCtx }> = ({ x }) => {
729824 if (x.c.get("principal").type !== "account" || x.issue.state !== "open" || x.l.repo.archived) return null;
730825 const own = x.issue.author.id === x.l.caller.id;
@@ -1122,13 +1217,83 @@
11221217 c,
11231218 `${repoUrl(l.repo)}/pull/${n}`,
11241219 async () => {
1125- await l.stub.mergePull(n, { method, deleteBranch: f.b("delete_branch"), expectedHeadSha: f.s("expected_head") || undefined }, l.caller);
1220+ await l.stub.mergePull(n, { method, deleteBranch: f.b("delete_branch"), expectedHeadSha: f.s("expected_head") || undefined, bypassMergeQueue: f.b("bypass_queue") }, l.caller);
11261221 c.executionCtx.waitUntil(c.get("dir").touchPushed(l.repo.id));
11271222 },
11281223 "Pull request merged.",
11291224 );
11301225 });
11311226
1227+ post(web, "/:owner/:repo/pull/:n/enqueue", async (c, f) => {
1228+ const l = await forAction(c, "write");
1229+ const n = issueNumber(c);
1230+ const method = (["merge", "squash", "rebase"] as const).find((m) => m === f.s("method"));
1231+ return act(c, `${repoUrl(l.repo)}/pull/${n}`, async () => {
1232+ const r = await l.stub.enqueuePull(n, { method, deleteBranch: f.b("delete_branch") }, l.caller);
1233+ if (r.state === "removed") throw new HttpError(409, `the pull request left the merge queue: ${r.reason ?? "see the conversation"}`);
1234+ flash(c, "notice", r.state === "merged" ? "Merged through the merge queue." : "Added to the merge queue.");
1235+ });
1236+ });
1237+
1238+ post(web, "/:owner/:repo/pull/:n/dequeue", async (c) => {
1239+ const l = await forAction(c, "write");
1240+ const n = issueNumber(c);
1241+ return act(c, `${repoUrl(l.repo)}/pull/${n}`, () => l.stub.dequeuePull(n, l.caller), "Removed from the merge queue.");
1242+ });
1243+
1244+ // Merge queue of a branch
1245+ web.get("/:owner/:repo/queue/:branch{.+}", async (c) => {
1246+ const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
1247+ const base = repoUrl(repo);
1248+ const branch = c.req.param("branch");
1249+ const [settings, entries] = await Promise.all([stub.mergeQueueSettings(branch), stub.mergeQueue(branch)]);
1250+ return render(
1251+ c,
1252+ { title: `Merge queue · ${branch}`, repo: { repo, caller, tab: "pulls" }, live: "repo" },
1253+ <div>
1254+ <h1>
1255+ Merge queue <span class="mono muted">{branch}</span>
1256+ </h1>
1257+ <p class="muted">
1258+ {settings
1259+ ? `Pull requests merge into ${branch} in order once their merge group (the branch, everything ahead of them and their own changes) passes the required checks. Merge method: ${METHOD_LABELS[settings.method ?? "merge"].toLowerCase()}.`
1260+ : `${branch} does not use a merge queue. Turn it on in the branch protection settings.`}
1261+ </p>
1262+ {entries.length === 0 ? (
1263+ <div class="blank mb">The queue is empty.</div>
1264+ ) : (
1265+ <div class="box">
1266+ <ul class="list">
1267+ {entries.map((e) => (
1268+ <li>
1269+ <span class="muted mono">{e.position}</span>
1270+ <Status status="in_progress" />
1271+ <div class="grow">
1272+ <a class="title" href={`${base}/pull/${e.number}`}>
1273+ {e.title}
1274+ </a>{" "}
1275+ <span class="muted">#{e.number}</span>
1276+ <div class="meta">
1277+ queued by {e.enqueuedBy.login} <Time at={e.enqueuedAt} /> ·{" "}
1278+ {e.groupSha ? (
1279+ <>
1280+ checking merge group <a class="sha" href={`${base}/commit/${e.groupSha}`}>{shortSha(e.groupSha)}</a>
1281+ {e.checks.length ? ` · ${e.checks.map((ch) => `${ch.name}: ${ch.state}`).join(", ")}` : ""}
1282+ </>
1283+ ) : (
1284+ "waiting to be built"
1285+ )}
1286+ </div>
1287+ </div>
1288+ </li>
1289+ ))}
1290+ </ul>
1291+ </div>
1292+ )}
1293+ </div>,
1294+ );
1295+ });
1296+
11321297 // Labels
11331298 web.get("/:owner/:repo/labels", async (c) => {
11341299 const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
modifiedapps/diffinery/src/web/repo-settings.tsx+44−0View file
@@ -120,6 +120,41 @@
120120 ))}
121121 </div>
122122 </div>
123+ <div class="field">
124+ <KCheckbox
125+ name="mergeQueue"
126+ checked={!!rules.mergeQueue}
127+ label={
128+ <>
129+ Require a merge queue
130+ <span class="muted small"> — pull requests merge in order, each tested on top of the base and the ones ahead of it. Workflows run on the queue through the <code>merge_group</code> event.</span>
131+ </>
132+ }
133+ />
134+ <div class="row" style="align-items:flex-start;margin-top:8px">
135+ <div class="field grow">
136+ <KSelect
137+ name="mqMethod"
138+ label="Merge method"
139+ block
140+ value={rules.mergeQueue?.method ?? "merge"}
141+ options={[
142+ { value: "merge", label: "Create a merge commit" },
143+ { value: "squash", label: "Squash and merge" },
144+ { value: "rebase", label: "Rebase and merge" },
145+ ]}
146+ />
147+ </div>
148+ <div class="field" style="width:180px">
149+ <label>Build concurrency</label>
150+ <input name="mqConcurrency" type="number" min={1} max={20} value={String(rules.mergeQueue?.buildConcurrency ?? 5)} />
151+ </div>
152+ <div class="field" style="width:180px">
153+ <label>Check timeout (minutes)</label>
154+ <input name="mqTimeout" type="number" min={1} max={360} value={String(rules.mergeQueue?.checkTimeoutMinutes ?? 60)} />
155+ </div>
156+ </div>
157+ </div>
123158 <button class="btn primary">{pattern ? "Save rule" : "Create rule"}</button>
124159 </Form>
125160 );
@@ -135,6 +170,14 @@
135170 .map((s) => s.trim())
136171 .filter(Boolean);
137172 if (checks.length) rules.requiredChecks = checks;
173+ if (f.b("mergeQueue")) {
174+ const method = f.s("mqMethod");
175+ rules.mergeQueue = {
176+ method: method === "squash" || method === "rebase" ? method : "merge",
177+ buildConcurrency: parseInt(f.s("mqConcurrency"), 10) || 5,
178+ checkTimeoutMinutes: parseInt(f.s("mqTimeout"), 10) || 60,
179+ };
180+ }
138181 return rules;
139182 }
140183
@@ -342,6 +385,7 @@
342385 r.rules.requiredApprovals ? `${r.rules.requiredApprovals} approval${r.rules.requiredApprovals === 1 ? "" : "s"}` : "",
343386 r.rules.requiredChecks?.length ? `${r.rules.requiredChecks.length} required check${r.rules.requiredChecks.length === 1 ? "" : "s"}` : "",
344387 r.rules.requireLinearHistory ? "linear history" : "",
388+ r.rules.mergeQueue ? "merge queue" : "",
345389 r.rules.enforceAdmins ? "admins included" : "",
346390 ]
347391 .filter(Boolean)
modifiedapps/diffinery/test/e2e/e2e.test.mjs+180−2View file
@@ -103,7 +103,7 @@
103103 }
104104
105105 /** Claim one waiting self-hosted job and run it with the real agent. */
106-async function runOneJob(owner, repo) {
106+async function runOneJob(owner, repo, { allowFailure = false } = {}) {
107107 const claim = await until("a job to claim", async () => {
108108 const r = await api("POST", `/repos/${owner}/${repo}/actions/runners/claim`, { name: "e2e", labels: ["linux"] });
109109 return r.status === 200 ? r.json : null;
@@ -116,7 +116,7 @@
116116 ? await spawnAsync("docker", ["run", "--rm", "--network", "host", ...Object.entries(jobEnv).flatMap(([k, v]) => ["-e", `${k}=${v}`]), image], {}, 300_000)
117117 : await spawnAsync(process.execPath, [AGENT], { ...process.env, ...jobEnv, DIFFINERY_WORK_ROOT: join(work, `job-${claim.job_id}`) }, 180_000);
118118 const out = `${r.stdout}${r.stderr}`;
119- if (r.status !== 0) {
119+ if (r.status !== 0 && !allowFailure) {
120120 const log = await fetch(`${base}/api/v3/repos/${owner}/${repo}/actions/jobs/${claim.job_id}/logs`, { headers: { authorization: `Bearer ${token}` } }).then((x) => x.text(), (e) => String(e));
121121 const job = await api("GET", `/repos/${owner}/${repo}/actions/jobs/${claim.job_id}`).catch((e) => ({ json: String(e) }));
122122 assert.fail(`runner agent exited ${r.status}:\n${out}\n--- job ---\n${JSON.stringify(job.json, null, 2)}\n--- job log ---\n${log}\n--- wrangler log tail ---\n${devLog.slice(-3000)}`);
@@ -509,6 +509,184 @@
509509 assert.equal((await runs()).length, 2, "the follower does not trigger itself");
510510 });
511511
512+test("pull requests: test merge commits, pull_request_target and the merge queue", async () => {
513+ const owner = (await ok("GET", "/user")).login;
514+ await ok("POST", "/user/repos", { name: "queue" });
515+ await api("PUT", `/repos/${owner}/queue/actions/secrets/TOPSECRET`, { value: "s3cret" });
516+ const src = join(work, "queue");
517+ mkdirSync(join(src, ".github/workflows"), { recursive: true });
518+ writeFileSync(join(src, "app.txt"), "a\nb\nc\n");
519+ writeFileSync(
520+ join(src, ".github/workflows/ci.yml"),
521+ `name: CI
522+on:
523+ pull_request:
524+ merge_group:
525+jobs:
526+ test:
527+ runs-on: [self-hosted, linux]
528+ steps:
529+ - uses: actions/checkout@v4
530+ - run: |
531+ echo "event=\${{ github.event_name }} ref=\${{ github.ref }} sha=\${{ github.sha }} head=$(git rev-parse HEAD)"
532+ echo "parents=$(git rev-list --parents -n1 HEAD)"
533+ echo "app=$(tr '\\n' ' ' < app.txt)"
534+ if [ "\${{ github.event_name }}" = merge_group ] && grep -qs fail notes.txt; then echo "merge group failed"; exit 1; fi
535+`,
536+ );
537+ writeFileSync(
538+ join(src, ".github/workflows/target.yml"),
539+ `name: Target
540+on: pull_request_target
541+jobs:
542+ t:
543+ runs-on: [self-hosted, linux]
544+ steps:
545+ - run: echo "target from base secret=\${{ secrets.TOPSECRET == 's3cret' }} ref=\${{ github.ref }} head=\${{ github.event.pull_request.head.sha }}"
546+`,
547+ );
548+ git(src, ["init", "-q", "-b", "main"]);
549+ git(src, ["add", "."]);
550+ git(src, ["commit", "-q", "-m", "queue"]);
551+ git(src, ["remote", "add", "origin", `${base}/${owner}/queue.git`]);
552+ git(src, ["push", "-q", "origin", "main"]);
553+ const root = git(src, ["rev-parse", "HEAD"]).trim();
554+
555+ // f1 changes the first line and tries to rewrite the pull_request_target workflow.
556+ git(src, ["checkout", "-q", "-b", "f1"]);
557+ writeFileSync(join(src, "app.txt"), "A\nb\nc\n");
558+ writeFileSync(join(src, ".github/workflows/target.yml"), `name: Target\non: pull_request_target\njobs:\n t:\n runs-on: [self-hosted, linux]\n steps:\n - run: echo "PWNED \${{ secrets.TOPSECRET }}"\n`);
559+ git(src, ["commit", "-q", "-am", "f1"]);
560+ git(src, ["push", "-q", "origin", "f1"]);
561+ const f1 = git(src, ["rev-parse", "HEAD"]).trim();
562+ // main moves on, so the test merge commit differs from both sides.
563+ git(src, ["checkout", "-q", "main"]);
564+ writeFileSync(join(src, "app.txt"), "a\nb\nC\n");
565+ git(src, ["commit", "-q", "-am", "main moves"]);
566+ git(src, ["push", "-q", "origin", "main"]);
567+ const main1 = git(src, ["rev-parse", "HEAD"]).trim();
568+
569+ const pr1 = await ok("POST", `/repos/${owner}/queue/pulls`, { title: "Capitalise a", head: "f1", base: "main" });
570+ const runs = async (q = "") => (await ok("GET", `/repos/${owner}/queue/actions/runs${q}`)).workflow_runs;
571+ const prRun = await until("pull_request run", async () => (await runs("?event=pull_request"))[0]);
572+ assert.equal(prRun.ref, "refs/pull/1/merge");
573+ assert.equal(prRun.head_sha, f1, "pull_request runs report on the PR head");
574+ assert.notEqual(prRun.sha, f1, "pull_request runs check out the test merge commit");
575+ assert.equal(prRun.head_branch, "f1");
576+ const targetRun = (await runs("?event=pull_request_target"))[0];
577+ assert.equal(targetRun.ref, "refs/heads/main");
578+ assert.equal(targetRun.sha, main1, "pull_request_target runs on the base commit");
579+ assert.equal(targetRun.head_sha, f1);
580+ // The merge ref is fetchable and its parents are [base, head]; refs/pull is read-only.
581+ git(src, ["fetch", "-q", "origin", "+refs/pull/1/merge:refs/remotes/pr/1"]);
582+ assert.equal(git(src, ["rev-parse", "refs/remotes/pr/1"]).trim(), prRun.sha);
583+ assert.equal(git(src, ["rev-list", "--parents", "-n1", "refs/remotes/pr/1"]).trim(), `${prRun.sha} ${main1} ${f1}`);
584+ assert.match(gitFails(src, ["push", "origin", `${root}:refs/pull/1/head`]), /refs\/pull|managed/);
585+
586+ const logOf = async (id) => (await fetch(`${base}/api/v3/repos/${owner}/queue/actions/jobs/${id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text();
587+ const ran = {};
588+ for (let i = 0; i < 2; i++) {
589+ const j = await runOneJob(owner, "queue");
590+ ran[j.claim.name] = await logOf(j.claim.job_id);
591+ }
592+ assert.match(ran.test, new RegExp(`event=pull_request ref=refs/pull/1/merge sha=${prRun.sha} head=${prRun.sha}`));
593+ assert.match(ran.test, /app=A b C/);
594+ assert.match(ran.t, /target from base secret=true ref=refs\/heads\/main/);
595+ assert.match(ran.t, new RegExp(`head=${f1}`));
596+ assert.doesNotMatch(ran.t, /PWNED/);
597+ await until("PR #1 checks", async () => {
598+ const c = (await ok("GET", `/repos/${owner}/queue/commits/${f1}/check-runs`)).check_runs;
599+ return c.length === 2 && c.every((x) => x.conclusion === "success") ? c : null;
600+ });
601+
602+ // ------------------------------------------------------------ merge queue
603+ await ok("PUT", `/repos/${owner}/queue/branches/main/protection`, { requirePullRequest: true, requiredChecks: ["CI / test"], mergeQueue: { method: "merge", buildConcurrency: 5, checkTimeoutMinutes: 30 } });
604+ const direct = await api("PUT", `/repos/${owner}/queue/pulls/1/merge`, {});
605+ assert.equal(direct.status, 405);
606+ assert.match(JSON.stringify(direct.json), /merge queue/);
607+ assert.equal((await ok("GET", `/repos/${owner}/queue/pulls/1/merge`)).mergeQueue.method, "merge");
608+
609+ // Two more pull requests: one that merges cleanly and one that fails only in its merge group.
610+ const branch = async (name, file, content) => {
611+ git(src, ["checkout", "-q", "-b", name, main1]);
612+ writeFileSync(join(src, file), content);
613+ git(src, ["add", "."]);
614+ git(src, ["commit", "-q", "-m", name]);
615+ git(src, ["push", "-q", "origin", name]);
616+ const sha = git(src, ["rev-parse", "HEAD"]).trim();
617+ const pr = await ok("POST", `/repos/${owner}/queue/pulls`, { title: name, head: name, base: "main" });
618+ for (let i = 0; i < 2; i++) await runOneJob(owner, "queue");
619+ await until(`${name} checks`, async () => (await ok("GET", `/repos/${owner}/queue/commits/${sha}/check-runs`)).check_runs.filter((x) => x.conclusion === "success").length === 2);
620+ return { pr, sha };
621+ };
622+ const f2 = await branch("f2", "two.txt", "two\n");
623+ const f3 = await branch("f3", "notes.txt", "fail\n");
624+
625+ const q1 = await ok("PUT", `/repos/${owner}/queue/pulls/1/merge-queue`, { delete_branch: true });
626+ assert.equal(q1.state, "queued");
627+ assert.equal(q1.entry.position, 1);
628+ assert.equal(q1.entry.state, "checking");
629+ const q2 = await ok("PUT", `/repos/${owner}/queue/pulls/${f2.pr.number}/merge-queue`, {});
630+ const q3 = await ok("PUT", `/repos/${owner}/queue/pulls/${f3.pr.number}/merge-queue`, {});
631+ assert.equal(q2.entry.merge_group.base_sha, q1.entry.merge_group.head_sha, "each group builds on the one ahead");
632+ assert.equal(q3.entry.merge_group.base_sha, q2.entry.merge_group.head_sha);
633+ assert.equal(q1.entry.merge_group.head_ref, `refs/heads/gh-readonly-queue/main/pr-1-${f1}`);
634+ const queue = await ok("GET", `/repos/${owner}/queue/merge-queue/main`);
635+ assert.deepEqual(queue.entries.map((e) => e.number), [1, f2.pr.number, f3.pr.number]);
636+ assert.match(gitFails(src, ["push", "origin", `${root}:refs/heads/gh-readonly-queue/main/x`]), /read-only/);
637+
638+ // Merge groups run merge_group workflows, oldest first.
639+ const groupLogs = [];
640+ for (let i = 0; i < 3; i++) {
641+ const j = await runOneJob(owner, "queue", { allowFailure: i === 2 });
642+ groupLogs.push(await logOf(j.claim.job_id));
643+ }
644+ assert.match(groupLogs[0], new RegExp(`event=merge_group ref=refs/heads/gh-readonly-queue/main/pr-1-${f1} sha=${q1.entry.merge_group.head_sha}`));
645+ assert.match(groupLogs[2], /merge group failed/);
646+ const mainNow = await until("the queue to merge #1 and #2", async () => {
647+ const b = await ok("GET", `/repos/${owner}/queue/branches/main`);
648+ return b.commit.sha === q2.entry.merge_group.head_sha ? b : null;
649+ });
650+ assert.ok(mainNow);
651+ assert.equal((await ok("GET", `/repos/${owner}/queue/pulls/1`)).merged, true);
652+ assert.equal((await ok("GET", `/repos/${owner}/queue/pulls/${f2.pr.number}`)).merged, true);
653+ assert.equal((await ok("GET", `/repos/${owner}/queue/pulls/1`)).merge_commit_sha, q1.entry.merge_group.head_sha, "the tested commit is what merged");
654+ const f3After = await until("#3 to leave the queue", async () => {
655+ const q = await ok("GET", `/repos/${owner}/queue/merge-queue/main`);
656+ return q.entries.length === 0 ? q : null;
657+ });
658+ assert.ok(f3After);
659+ assert.equal((await ok("GET", `/repos/${owner}/queue/pulls/${f3.pr.number}`)).merged, false);
660+ const comments = await ok("GET", `/repos/${owner}/queue/issues/${f3.pr.number}/comments`);
661+ assert.match(JSON.stringify(comments), /removed from the merge queue: the required check 'CI \/ test' failed on its merge group/);
662+ git(src, ["fetch", "-q", "origin"]);
663+ assert.equal(git(src, ["show", "origin/main:app.txt"]), "A\nb\nC\n");
664+ assert.equal(git(src, ["show", "origin/main:two.txt"]), "two\n");
665+ assert.ok(!(await ok("GET", `/repos/${owner}/queue/branches`)).some((b) => b.name.startsWith("gh-readonly-queue/") || b.name === "f1"), "group branches and f1 are gone");
666+
667+ // A pull request that conflicts with its base runs no pull_request workflows (like GitHub), only pull_request_target.
668+ git(src, ["checkout", "-q", "-b", "f4", root]);
669+ writeFileSync(join(src, "app.txt"), "x\nb\nc\n");
670+ git(src, ["commit", "-q", "-am", "f4"]);
671+ git(src, ["push", "-q", "origin", "f4"]);
672+ const pr4 = await ok("POST", `/repos/${owner}/queue/pulls`, { title: "conflicts", head: "f4", base: "main" });
673+ const t4 = await until("pull_request_target run for #4", async () => (await runs("?event=pull_request_target")).find((r) => r.pull_request_number === pr4.number));
674+ assert.ok(t4);
675+ assert.equal((await runs("?event=pull_request")).filter((r) => r.pull_request_number === pr4.number).length, 0);
676+ await runOneJob(owner, "queue");
677+
678+ // The web UI shows the queue.
679+ const page = async (path) => {
680+ const res = await fetch(`${base}${path}`);
681+ assert.equal(res.status, 200, path);
682+ return res.text();
683+ };
684+ assert.match(await page(`/${owner}/queue/queue/main`), /The queue is empty/);
685+ assert.match(await page(`/${owner}/queue/pull/${pr4.number}`), /Merge when ready/);
686+ assert.match(await page(`/${owner}/queue/pull/${f3.pr.number}`), /removed from the merge queue/);
687+ assert.match(await page(`/${owner}/queue/settings/branches`), /merge queue/);
688+});
689+
512690 const hasDocker = spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 }).status === 0;
513691
514692 test("containers: a job container and service containers on a self-hosted runner", { skip: !hasDocker && "Docker is not available" }, async () => {
modifiedpackages/shared/src/events.ts+1−1View file
@@ -2,7 +2,7 @@
22 /** Events flowing through the `events` queue. Payload shapes follow GitHub webhooks where practical. */
33 export type DiffineryEvent =
44 | { type: "push"; repoId: string; owner: string; repo: string; ref: string; before: string; after: string; actor: Actor; forced: boolean; created: boolean; deleted: boolean; at: string }
5- | { type: "pull_request"; action: "opened" | "synchronize" | "closed" | "reopened" | "edited" | "merged"; repoId: string; owner: string; repo: string; number: number; headRef: string; headSha: string; baseRef: string; actor: Actor; at: string }
5+ | { type: "pull_request"; action: "opened" | "synchronize" | "closed" | "reopened" | "edited" | "merged" | "enqueued" | "dequeued"; repoId: string; owner: string; repo: string; number: number; headRef: string; headSha: string; baseRef: string; actor: Actor; at: string }
66 | { type: "issues"; action: "opened" | "closed" | "reopened" | "edited"; repoId: string; owner: string; repo: string; number: number; actor: Actor; at: string }
77 | { type: "issue_comment"; action: "created"; repoId: string; owner: string; repo: string; number: number; commentId: string; actor: Actor; at: string }
88 | { type: "pull_request_review"; action: "submitted"; repoId: string; owner: string; repo: string; number: number; state: string; actor: Actor; at: string }
modifiedpackages/workflow-yaml/src/filters.ts+8−1View file
@@ -2,11 +2,12 @@
22 * Event filters: GitHub filter-pattern globbing and `on:` trigger matching.
33 * https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
44 */
5-import type { PullRequestTrigger, PushTrigger, Workflow, WorkflowRunTrigger } from "./model.js";
5+import type { MergeGroupTrigger, PullRequestTrigger, PushTrigger, Workflow, WorkflowRunTrigger } from "./model.js";
66
77 export type TriggerEvent =
88 | { name: "push"; ref: string; changedFiles?: string[] }
99 | { name: "pull_request" | "pull_request_target"; action: string; baseRef: string; changedFiles?: string[] }
10+ | { name: "merge_group"; action: string; baseRef: string }
1011 | { name: "schedule"; cron: string }
1112 | { name: "workflow_dispatch" }
1213 | {
@@ -168,6 +169,12 @@
168169 const e = ev as Extract<TriggerEvent, { name: "pull_request" | "pull_request_target" }>;
169170 return matchesPullRequest(on[ev.name] ?? {}, e.action, e.baseRef, e.changedFiles);
170171 }
172+ case "merge_group": {
173+ const e = ev as Extract<TriggerEvent, { name: "merge_group" }>;
174+ const cfg: MergeGroupTrigger = on.merge_group ?? {};
175+ if (!(cfg.types ?? ["checks_requested"]).includes(e.action)) return false;
176+ return passesFilter(stripPrefix(e.baseRef, "refs/heads/"), cfg.branches, cfg.branchesIgnore);
177+ }
171178 case "schedule": {
172179 const e = ev as Extract<TriggerEvent, { name: "schedule" }>;
173180 return (on.schedule ?? []).some((s) => normalizeCron(s.cron) === normalizeCron(e.cron));
modifiedpackages/workflow-yaml/src/index.ts+1−0View file
@@ -19,6 +19,7 @@
1919 WorkflowError,
2020 WorkflowOn,
2121 WorkflowRunTrigger,
22+ MergeGroupTrigger,
2223 } from "./model.js";
2324 export { parseWorkflow, isWarning, formatPath, WARNING_PREFIX, KNOWN_EVENTS } from "./parse.js";
2425 export {
modifiedpackages/workflow-yaml/src/model.ts+8−0View file
@@ -30,6 +30,13 @@
3030 pathsIgnore?: string[];
3131 }
3232
33+/** `merge_group`: runs on a merge queue's temporary branch; `branches` filters on the queue's base branch. */
34+export interface MergeGroupTrigger {
35+ types?: string[];
36+ branches?: string[];
37+ branchesIgnore?: string[];
38+}
39+
3340 export interface ScheduleTrigger {
3441 cron: string;
3542 }
@@ -71,6 +78,7 @@
7178 push?: PushTrigger;
7279 pull_request?: PullRequestTrigger;
7380 pull_request_target?: PullRequestTrigger;
81+ merge_group?: MergeGroupTrigger;
7482 schedule?: ScheduleTrigger[];
7583 workflow_dispatch?: WorkflowDispatchTrigger;
7684 workflow_call?: WorkflowCallTrigger;
modifiedpackages/workflow-yaml/src/parse.ts+4−0View file
@@ -291,6 +291,10 @@
291291 on.workflow_call = call;
292292 return;
293293 }
294+ case "merge_group":
295+ exclusive(c, o, path, "branches", "branches-ignore");
296+ on.merge_group = pick({ types: list("types"), branches: list("branches"), branchesIgnore: list("branches-ignore") });
297+ return;
294298 case "workflow_run":
295299 exclusive(c, o, path, "branches", "branches-ignore");
296300 on.workflow_run = pick({
modifiedpackages/workflow-yaml/test/filters.test.ts+11−0View file
@@ -227,4 +227,15 @@
227227 expect(matchesTrigger(w, { name: "workflow_run", action: "completed", workflow: "Deploy", branch: "main" })).toBe(false);
228228 expect(matchesTrigger(w, { name: "workflow_run", action: "completed", workflow: "CI", branch: "dev" })).toBe(false);
229229 });
230+
231+ it("merge_group defaults to checks_requested and filters on the base branch", () => {
232+ const any = wf(" [pull_request, merge_group]");
233+ expect(matchesTrigger(any, { name: "merge_group", action: "checks_requested", baseRef: "refs/heads/main" })).toBe(true);
234+ expect(matchesTrigger(any, { name: "merge_group", action: "destroyed", baseRef: "main" })).toBe(false);
235+ const w = wf(`\n merge_group:\n types: [checks_requested]\n branches: [main, 'release/**']`);
236+ expect(w.on.merge_group).toEqual({ types: ["checks_requested"], branches: ["main", "release/**"] });
237+ expect(matchesTrigger(w, { name: "merge_group", action: "checks_requested", baseRef: "refs/heads/release/1.x" })).toBe(true);
238+ expect(matchesTrigger(w, { name: "merge_group", action: "checks_requested", baseRef: "dev" })).toBe(false);
239+ expect(matchesTrigger(wf(" push"), { name: "merge_group", action: "checks_requested", baseRef: "main" })).toBe(false);
240+ });
230241 });