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)
- .github/workflows/live-test.yml+92−4
- README.md+39−9
- apps/cli/src/commands/pr.ts+26−1
- apps/cli/test/pr.test.ts+14−0
- apps/diffinery/src/app.ts+2−1
- apps/diffinery/src/http/api.ts+41−4
- apps/diffinery/src/mcp/server.ts+19−2
- apps/diffinery/src/repo/RepoDO.ts+41−2
- apps/diffinery/src/repo/actions.ts+93−31
- apps/diffinery/src/repo/collab.ts+60−4
- apps/diffinery/src/repo/queue.ts+397−0
- apps/diffinery/src/repo/schema.ts+19−0
- apps/diffinery/src/repo/types.ts+11−0
- apps/diffinery/src/web/collab.tsx+167−2
- apps/diffinery/src/web/repo-settings.tsx+44−0
- apps/diffinery/test/e2e/e2e.test.mjs+180−2
- packages/shared/src/events.ts+1−1
- packages/workflow-yaml/src/filters.ts+8−1
- packages/workflow-yaml/src/index.ts+1−0
- packages/workflow-yaml/src/model.ts+8−0
- packages/workflow-yaml/src/parse.ts+4−0
- packages/workflow-yaml/test/filters.test.ts+11−0
modified.github/workflows/live-test.yml+92−4View file
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | # End-to-end checks against the real diffinery.com after each deploy: hosted |
| 4 | 4 | # 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 | |
| 6 | 6 | # deletes at the end. |
| 7 | 7 | # |
| 8 | 8 | # Needs the repository secret DIFFINERY_LIVE_TOKEN: a Diffinery token (Settings -> |
| @@ -23,11 +23,11 @@ | ||
| 23 | 23 | |
| 24 | 24 | jobs: |
| 25 | 25 | live: |
| 26 | - name: Containers, sandboxes, webhooks, schedules and LFS | |
| 26 | + name: Containers, sandboxes, webhooks, schedules, LFS and pull requests | |
| 27 | 27 | # Runs on the host that deploys production (see the gate in deploy.yml). |
| 28 | 28 | if: ${{ github.event_name == 'workflow_dispatch' || (github.server_url == 'https://diffinery.com' && github.event.workflow_run.conclusion == 'success') }} |
| 29 | 29 | runs-on: ubuntu-latest |
| 30 | - timeout-minutes: 30 | |
| 30 | + timeout-minutes: 45 | |
| 31 | 31 | env: |
| 32 | 32 | DIFFINERY: https://diffinery.com |
| 33 | 33 | TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }} |
| @@ -167,6 +167,93 @@ | ||
| 167 | 167 | curl -sS -X POST "$base/$id/close" -H "Authorization: Bearer $TOKEN" >/dev/null || true |
| 168 | 168 | 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; } |
| 169 | 169 | |
| 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 | + | |
| 170 | 257 | - name: Scheduled workflow |
| 171 | 258 | if: steps.token.outputs.skip != 'true' |
| 172 | 259 | run: | |
| @@ -181,7 +268,8 @@ | ||
| 181 | 268 | echo "$run" | jq '{id, event, status, conclusion}' |
| 182 | 269 | [ "$(jq -r .conclusion <<<"$run")" = success ] || { echo "::error::the scheduled run did not succeed"; exit 1; } |
| 183 | 270 | |
| 184 | - - name: Delete the throwaway repository | |
| 271 | + - name: Delete the throwaway repositories | |
| 185 | 272 | if: always() && steps.token.outputs.skip != 'true' |
| 186 | 273 | run: | |
| 187 | 274 | 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 @@ | ||
| 133 | 133 | ## Workflows |
| 134 | 134 | |
| 135 | 135 | 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. | |
| 139 | 139 | |
| 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 | + | |
| 140 | 168 | - `needs` and job outputs, `if` with `success()`, `failure()`, `always()` and |
| 141 | 169 | `cancelled()`, `strategy.matrix` with include and exclude, `fail-fast` and |
| 142 | 170 | `max-parallel`, `concurrency` with `cancel-in-progress`, and `timeout-minutes`. |
| @@ -165,9 +193,8 @@ | ||
| 165 | 193 | it and pull Docker Hub images through `mirror.gcr.io`. |
| 166 | 194 | |
| 167 | 195 | **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 | |
| 171 | 198 | with a clear message instead of misbehaving. |
| 172 | 199 | |
| 173 | 200 | ## Developing |
| @@ -187,9 +214,12 @@ | ||
| 187 | 214 | 3. Checks secret scanning and branch protection. |
| 188 | 215 | 4. Has an agent open a pull request that needs a human approval and a green |
| 189 | 216 | 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 | |
| 193 | 223 | passkey, adds and removes passkeys, signs out and in, and uses an admin |
| 194 | 224 | sign-in link. |
| 195 | 225 |
modifiedapps/cli/src/commands/pr.ts+26−1View file
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { readFile } from "node:fs/promises"; |
| 2 | 2 | import path from "node:path"; |
| 3 | 3 | import { exactlyOne, list, positiveInt, repoPath, str, type Command, type Ctx, type Group, type Values } from "../cli.js"; |
| 4 | +import { ApiError } from "../api.js"; | |
| 4 | 5 | import { currentBranch } from "../git.js"; |
| 5 | 6 | import { CliError, ExitCode, readAll } from "../io.js"; |
| 6 | 7 | import { ago, json, statusText, symbol, table, truncate } from "../output.js"; |
| @@ -268,6 +269,8 @@ | ||
| 268 | 269 | subject: { type: "string", short: "t", desc: "Commit title", arg: "TEXT" }, |
| 269 | 270 | ...bodyOpts, |
| 270 | 271 | "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" }, | |
| 271 | 274 | }, |
| 272 | 275 | repo: true, |
| 273 | 276 | json: true, |
| @@ -277,14 +280,36 @@ | ||
| 277 | 280 | const n = positiveInt(num, "pull request number"); |
| 278 | 281 | const method = exactlyOne(o, ["merge", "squash", "rebase"], "merge"); |
| 279 | 282 | 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); | |
| 280 | 290 | const payload = { |
| 281 | 291 | merge_method: method, |
| 282 | 292 | delete_branch: o["delete-branch"] === true ? true : undefined, |
| 283 | 293 | commit_title: str(o, "subject"), |
| 284 | 294 | commit_message: await bodyFrom(ctx, o), |
| 285 | 295 | sha: str(o, "match-head-commit"), |
| 296 | + bypass_merge_queue: o.admin === true ? true : undefined, | |
| 286 | 297 | }; |
| 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 | + } | |
| 288 | 313 | if (ctx.json) return json(ctx.io, r); |
| 289 | 314 | 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" : ""}`); |
| 290 | 315 | }, |
modifiedapps/cli/test/pr.test.ts+14−0View file
| @@ -78,6 +78,20 @@ | ||
| 78 | 78 | expect(bad.code).toBe(2); |
| 79 | 79 | }); |
| 80 | 80 | |
| 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 | + | |
| 81 | 95 | it("surfaces the server's { message } on errors", async () => { |
| 82 | 96 | srv.on("PUT", "/api/v3/repos/o/r/pulls/4/merge", { status: 405, body: { message: "pull request is not mergeable: 1 approval required" } }); |
| 83 | 97 | const r = await run(["pr", "merge", "4", "-R", "o/r"], { env }); |
modifiedapps/diffinery/src/app.ts+2−1View file
| @@ -152,7 +152,8 @@ | ||
| 152 | 152 | return; |
| 153 | 153 | } |
| 154 | 154 | 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; | |
| 156 | 157 | const { issue, comments, reviews } = await repoStub(env, ev.repoId).issue(ev.number); |
| 157 | 158 | const ids = new Set<string>([issue.author.id, ...comments.map((c) => c.author.id), ...reviews.map((r) => r.author.id)]); |
| 158 | 159 | for (const login of [...issue.assignees, ...(issue.pull?.requestedReviewers ?? [])]) { |
modifiedapps/diffinery/src/http/api.ts+41−4View file
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import { HttpError, b64, fromB64 } from "../lib/util.js"; |
| 9 | 9 | import type { ProtectionRules, Role } from "../repo/types.js"; |
| 10 | 10 | import type { RunInfo } from "../repo/actions.js"; |
| 11 | +import type { QueueEntry } from "../repo/queue.js"; | |
| 11 | 12 | import { loadRepo, repoStub } from "./common.js"; |
| 12 | 13 | |
| 13 | 14 | /** |
| @@ -415,9 +416,42 @@ | ||
| 415 | 416 | }); |
| 416 | 417 | api.put("/repos/:owner/:repo/pulls/:number/merge", async (c) => { |
| 417 | 418 | 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)); | |
| 420 | 421 | }); |
| 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 | +}); | |
| 421 | 455 | api.get("/repos/:owner/:repo/pulls/:number/reviews", async (c) => { |
| 422 | 456 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 423 | 457 | 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 @@ | ||
| 465 | 499 | display_title: r.title, |
| 466 | 500 | path: r.workflowPath, |
| 467 | 501 | 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, | |
| 470 | 507 | status: r.status, |
| 471 | 508 | conclusion: r.conclusion, |
| 472 | 509 | 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 @@ | ||
| 205 | 205 | }, |
| 206 | 206 | }, |
| 207 | 207 | 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.", | |
| 209 | 210 | input: { ...OWNER_REPO, number: { type: "integer" }, method: { type: "string", enum: ["merge", "squash", "rebase"] }, expected_head_sha: { type: "string" }, delete_branch: { type: "boolean" } }, |
| 210 | 211 | required: ["owner", "repo", "number"], |
| 211 | 212 | run: async (c, a) => { |
| 212 | 213 | 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); | |
| 214 | 221 | }, |
| 215 | 222 | }, |
| 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 | + }, | |
| 216 | 233 | list_issues: { |
| 217 | 234 | description: "List or search issues.", |
| 218 | 235 | 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 @@ | ||
| 40 | 40 | import { Actions } from "./actions.js"; |
| 41 | 41 | import { Collab } from "./collab.js"; |
| 42 | 42 | 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"; | |
| 44 | 45 | import { atLeast, globToRegExp, type Caller, type ProtectionRules, type RepoMeta } from "./types.js"; |
| 45 | 46 | |
| 46 | 47 | const MAX_PUSH_BYTES = 100 * 1024 * 1024; |
| @@ -74,16 +75,19 @@ | ||
| 74 | 75 | store!: RepoObjectStore; |
| 75 | 76 | readonly collab: Collab; |
| 76 | 77 | readonly actions: Actions; |
| 78 | + readonly queue: MergeQueue; | |
| 77 | 79 | private metaCache: RepoMeta | null = null; |
| 78 | 80 | |
| 79 | 81 | constructor(ctx: DurableObjectState, env: Env) { |
| 80 | 82 | super(ctx, env); |
| 81 | 83 | this.sql = ctx.storage.sql; |
| 82 | 84 | this.sql.exec(REPO_SCHEMA); |
| 85 | + migrateRepoSchema(this.sql); | |
| 83 | 86 | const meta = this.readMeta(); |
| 84 | 87 | if (meta) this.store = new RepoObjectStore(this.sql, env.BUCKET, meta.repoId); |
| 85 | 88 | this.collab = new Collab(this); |
| 86 | 89 | this.actions = new Actions(this, env); |
| 90 | + this.queue = new MergeQueue(this); | |
| 87 | 91 | } |
| 88 | 92 | |
| 89 | 93 | // ---------------------------------------------------------------- meta |
| @@ -129,6 +133,20 @@ | ||
| 129 | 133 | }; |
| 130 | 134 | } |
| 131 | 135 | |
| 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 | + | |
| 132 | 150 | emit(event: DiffineryEvent): void { |
| 133 | 151 | this.ctx.waitUntil(this.env.EVENTS.send(event).catch((e) => console.error("event send failed", e))); |
| 134 | 152 | this.broadcast("repo", { kind: "event", event }); |
| @@ -190,7 +208,8 @@ | ||
| 190 | 208 | |
| 191 | 209 | async gitInfoRefs(service: "git-upload-pack" | "git-receive-pack"): Promise<Uint8Array> { |
| 192 | 210 | 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/"))); | |
| 194 | 213 | } |
| 195 | 214 | |
| 196 | 215 | async gitUploadPack(body: Uint8Array): Promise<Uint8Array> { |
| @@ -288,6 +307,8 @@ | ||
| 288 | 307 | if (!isValidRefName(ref)) return { ref, ok: false, reason: "invalid ref name" }; |
| 289 | 308 | if (!atLeast(caller.role, "write")) return { ref, ok: false, reason: "permission denied" }; |
| 290 | 309 | 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" }; | |
| 291 | 312 | if (!deleting && !this.store.has(newOid)) return { ref, ok: false, reason: "missing objects" }; |
| 292 | 313 | if (!ref.startsWith("refs/heads/")) { |
| 293 | 314 | 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 @@ | ||
| 344 | 365 | } |
| 345 | 366 | } |
| 346 | 367 | 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)); | |
| 347 | 370 | } |
| 348 | 371 | |
| 349 | 372 | // ---------------------------------------------------------------- browsing |
| @@ -513,8 +536,11 @@ | ||
| 513 | 536 | } |
| 514 | 537 | |
| 515 | 538 | setProtection(pattern: string, rules: ProtectionRules | null): void { |
| 539 | + if (rules?.mergeQueue) rules = { ...rules, mergeQueue: normalizeMergeQueue(rules.mergeQueue) }; | |
| 516 | 540 | if (rules) this.sql.exec("INSERT OR REPLACE INTO protections (pattern, rules) VALUES (?, ?)", pattern, JSON.stringify(rules)); |
| 517 | 541 | 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(); | |
| 518 | 544 | } |
| 519 | 545 | |
| 520 | 546 | // ---------------------------------------------------------------- RPC passthroughs (collab + actions) |
| @@ -587,6 +613,19 @@ | ||
| 587 | 613 | return this.collab.recordDelivery(...a); |
| 588 | 614 | } |
| 589 | 615 | |
| 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 | + | |
| 590 | 629 | runs(...a: Parameters<Actions["listRuns"]>) { |
| 591 | 630 | return this.actions.listRuns(...a); |
| 592 | 631 | } |
modifiedapps/diffinery/src/repo/actions.ts+93−31View file
| @@ -45,6 +45,8 @@ | ||
| 45 | 45 | inputs: string; |
| 46 | 46 | concurrency_group: string | null; |
| 47 | 47 | display_title: string; |
| 48 | + /** Commit the run reports checks on, when it differs from `sha` (pull request runs). */ | |
| 49 | + head_sha: string | null; | |
| 48 | 50 | }; |
| 49 | 51 | |
| 50 | 52 | export type JobRow = { |
| @@ -79,6 +81,10 @@ | ||
| 79 | 81 | event: string; |
| 80 | 82 | ref: string; |
| 81 | 83 | 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; | |
| 82 | 88 | actor: Actor; |
| 83 | 89 | status: RunRow["status"]; |
| 84 | 90 | conclusion: RunRow["conclusion"]; |
| @@ -113,6 +119,8 @@ | ||
| 113 | 119 | event: r.event, |
| 114 | 120 | ref: r.ref, |
| 115 | 121 | 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)\//, ""), | |
| 116 | 124 | actor: JSON.parse(r.actor), |
| 117 | 125 | status: r.status, |
| 118 | 126 | conclusion: r.conclusion, |
| @@ -225,48 +233,99 @@ | ||
| 225 | 233 | } |
| 226 | 234 | } |
| 227 | 235 | |
| 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 | + */ | |
| 228 | 243 | async onPullRequest(action: string, number: number, caller: Caller): Promise<void> { |
| 229 | 244 | const { issue } = this.repo.collab.get(number); |
| 230 | 245 | 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; | |
| 234 | 250 | const payload = { |
| 235 | 251 | action, |
| 236 | 252 | number, |
| 237 | 253 | pull_request: { |
| 238 | 254 | number, |
| 255 | + id: number, | |
| 256 | + state: issue.state, | |
| 239 | 257 | title: issue.title, |
| 240 | 258 | body: issue.body, |
| 241 | 259 | 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, | |
| 242 | 264 | user: { login: issue.author.login }, |
| 265 | + labels: issue.labels.map((name) => ({ name })), | |
| 266 | + assignees: issue.assignees.map((login) => ({ login })), | |
| 243 | 267 | head: { ref: pull.head, sha: pull.headSha, repo: this.repositoryPayload() }, |
| 244 | 268 | 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}`, | |
| 246 | 271 | }, |
| 247 | 272 | repository: this.repositoryPayload(), |
| 248 | 273 | sender: { login: caller.login }, |
| 249 | 274 | }; |
| 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 }); | |
| 266 | 282 | } |
| 267 | 283 | } |
| 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 | + } | |
| 268 | 290 | } |
| 269 | 291 | |
| 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 | + | |
| 270 | 329 | async dispatch(workflowFile: string, ref: string, inputs: Record<string, unknown>, caller: Caller): Promise<RunInfo> { |
| 271 | 330 | if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to run workflows"); |
| 272 | 331 | const sha = await this.repo.resolve(ref); |
| @@ -315,7 +374,7 @@ | ||
| 315 | 374 | const m = this.repo.meta; |
| 316 | 375 | const payload = JSON.parse(run.event_payload) as Record<string, unknown>; |
| 317 | 376 | 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"); | |
| 319 | 378 | const pr = payload.pull_request as { head?: { ref?: string }; base?: { ref?: string } } | undefined; |
| 320 | 379 | return { |
| 321 | 380 | event_name: run.event, |
| @@ -348,7 +407,7 @@ | ||
| 348 | 407 | }; |
| 349 | 408 | } |
| 350 | 409 | |
| 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> { | |
| 352 | 411 | const id = newId(); |
| 353 | 412 | const t = now(); |
| 354 | 413 | this.sql.exec( |
| @@ -379,7 +438,7 @@ | ||
| 379 | 438 | |
| 380 | 439 | async createRun( |
| 381 | 440 | 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 }, | |
| 383 | 442 | ): Promise<RunInfo | null> { |
| 384 | 443 | const id = newId(); |
| 385 | 444 | const t = now(); |
| @@ -402,6 +461,7 @@ | ||
| 402 | 461 | inputs: JSON.stringify(o.inputs ?? {}), |
| 403 | 462 | concurrency_group: null, |
| 404 | 463 | display_title: o.title, |
| 464 | + head_sha: o.headSha && o.headSha !== o.sha ? o.headSha : null, | |
| 405 | 465 | }; |
| 406 | 466 | const ctx: ExprContext = { github: this.githubContext(run, "", ""), inputs: o.inputs ?? {}, vars: this.variables() }; |
| 407 | 467 | try { |
| @@ -411,7 +471,7 @@ | ||
| 411 | 471 | console.warn("run-name/concurrency evaluation failed", e); |
| 412 | 472 | } |
| 413 | 473 | 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 415 | 475 | run.id, |
| 416 | 476 | run.run_number, |
| 417 | 477 | run.workflow_path, |
| @@ -429,6 +489,7 @@ | ||
| 429 | 489 | run.inputs, |
| 430 | 490 | run.concurrency_group, |
| 431 | 491 | run.display_title, |
| 492 | + run.head_sha, | |
| 432 | 493 | ); |
| 433 | 494 | if (run.concurrency_group && wf.concurrency) { |
| 434 | 495 | const cancelInProgress = evaluateValue(wf.concurrency.cancelInProgress, ctx); |
| @@ -475,8 +536,8 @@ | ||
| 475 | 536 | args.push(opts.event); |
| 476 | 537 | } |
| 477 | 538 | 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); | |
| 480 | 541 | } |
| 481 | 542 | if (opts.prNumber) { |
| 482 | 543 | where.push("pr_number = ?"); |
| @@ -748,13 +809,13 @@ | ||
| 748 | 809 | if (job.labels) { |
| 749 | 810 | // Self-hosted: park the job until a registered runner with matching labels claims it. |
| 750 | 811 | 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}` }); | |
| 752 | 813 | return; |
| 753 | 814 | } |
| 754 | 815 | const token = `djt_${m.repoId}_${jobId}_${randomToken(24)}`; |
| 755 | 816 | const container = `job-${jobId}`; |
| 756 | 817 | 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}` }); | |
| 758 | 819 | try { |
| 759 | 820 | const stub = this.env.RUNNER.get(this.env.RUNNER.idFromName(container)); |
| 760 | 821 | await stub.startJob({ repoId: m.repoId, jobId, token, apiUrl: this.env.PUBLIC_URL }); |
| @@ -850,7 +911,7 @@ | ||
| 850 | 911 | status: run.status, |
| 851 | 912 | conclusion: run.conclusion, |
| 852 | 913 | head_branch: branch, |
| 853 | - head_sha: run.sha, | |
| 914 | + head_sha: run.head_sha ?? run.sha, | |
| 854 | 915 | actor: { login: actor.login }, |
| 855 | 916 | triggering_actor: { login: actor.login }, |
| 856 | 917 | pull_requests: run.pr_number ? [{ number: run.pr_number }] : [], |
| @@ -915,6 +976,7 @@ | ||
| 915 | 976 | event: old.event, |
| 916 | 977 | ref: old.ref, |
| 917 | 978 | sha: old.sha, |
| 979 | + headSha: old.head_sha ?? undefined, | |
| 918 | 980 | payload: JSON.parse(old.event_payload), |
| 919 | 981 | caller, |
| 920 | 982 | prNumber: old.pr_number ?? undefined, |
| @@ -984,7 +1046,7 @@ | ||
| 984 | 1046 | const m = this.repo.meta; |
| 985 | 1047 | const t = now(); |
| 986 | 1048 | 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}` }); | |
| 988 | 1050 | this.repo.broadcast(`run:${run.id}`, { action: "job", jobId }); |
| 989 | 1051 | const secrets: Record<string, string> = { ...scope.secrets, GITHUB_TOKEN: token, DIFFINERY_TOKEN: token }; |
| 990 | 1052 | 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 @@ | ||
| 1067 | 1129 | ); |
| 1068 | 1130 | const m = this.repo.meta; |
| 1069 | 1131 | this.repo.upsertCheck({ |
| 1070 | - sha: run.sha, | |
| 1132 | + sha: run.head_sha ?? run.sha, | |
| 1071 | 1133 | name: this.checkName(run, job), |
| 1072 | 1134 | status: "completed", |
| 1073 | 1135 | conclusion: conclusion === "timed_out" ? "timed_out" : conclusion, |
modifiedapps/diffinery/src/repo/collab.ts+60−4View file
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { ZERO_OID, isAncestor, mergeBase, mergeTrees, readCommit, serializeCommit, walkCommits } from "@diffinery/git-core"; |
| 2 | +import type { QueueEntry } from "./queue.js"; | |
| 2 | 3 | import { HttpError, newId, now, randomToken } from "../lib/util.js"; |
| 3 | 4 | import type { RepoDO } from "./RepoDO.js"; |
| 4 | 5 | import { toInfo } from "./RepoDO.js"; |
| @@ -113,6 +114,8 @@ | ||
| 113 | 114 | checks: { name: string; state: string }[]; |
| 114 | 115 | blockers: string[]; |
| 115 | 116 | mergeBase: string | null; |
| 117 | + /** Set when the base branch merges through a merge queue. */ | |
| 118 | + mergeQueue: { method: "merge" | "squash" | "rebase"; entry: QueueEntry | null } | null; | |
| 116 | 119 | }; |
| 117 | 120 | |
| 118 | 121 | const actorOf = (c: Caller) => ({ id: c.id, login: c.login, kind: c.kind, onBehalfOf: c.onBehalfOf }); |
| @@ -294,6 +297,8 @@ | ||
| 294 | 297 | if (r.kind === "pull") { |
| 295 | 298 | const p = this.pullRow(number); |
| 296 | 299 | 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); | |
| 297 | 302 | } else this.repo.emit({ type: "issues", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, actor: actorOf(caller), at: t }); |
| 298 | 303 | } |
| 299 | 304 | this.repo.broadcast(`pr:${number}`, { action: "updated" }); |
| @@ -372,6 +377,37 @@ | ||
| 372 | 377 | return this.toIssue(this.row(n)); |
| 373 | 378 | } |
| 374 | 379 | |
| 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 | + | |
| 375 | 411 | requestReviewers(number: number, reviewers: string[], caller: Caller): void { |
| 376 | 412 | if (!atLeast(caller.role, "triage") && this.row(number).author_id !== caller.id) throw new HttpError(403, "permission denied"); |
| 377 | 413 | const p = this.pullRow(number); |
| @@ -392,6 +428,7 @@ | ||
| 392 | 428 | if (prefs?.dismissStaleReviews) this.sql.exec("UPDATE reviews SET state = 'DISMISSED' WHERE number = ? AND state = 'APPROVED' AND commit_id != ?", p.number, sha); |
| 393 | 429 | 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() }); |
| 394 | 430 | 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); | |
| 395 | 432 | this.repo.broadcast(`pr:${p.number}`, { action: "synchronize", sha }); |
| 396 | 433 | out.push(p.number); |
| 397 | 434 | } |
| @@ -518,6 +555,10 @@ | ||
| 518 | 555 | input.status === "completed" ? t : null, |
| 519 | 556 | ); |
| 520 | 557 | 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 | + } | |
| 521 | 562 | return this.checks(input.sha).find((c) => c.id === id)!; |
| 522 | 563 | } |
| 523 | 564 | |
| @@ -526,7 +567,7 @@ | ||
| 526 | 567 | async mergeability(number: number, caller?: Caller): Promise<Mergeability> { |
| 527 | 568 | const r = this.row(number); |
| 528 | 569 | 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 }; | |
| 530 | 571 | if (p.merged) return { ...result, state: "merged", blockers: ["already merged"] }; |
| 531 | 572 | if (r.state === "closed") return { ...result, state: "closed", blockers: ["pull request is closed"] }; |
| 532 | 573 | const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`); |
| @@ -537,6 +578,7 @@ | ||
| 537 | 578 | const merged = await mergeTrees(this.repo.store, mt?.tree ?? null, bt.tree, ht.tree); |
| 538 | 579 | result.conflicts = merged.conflicts; |
| 539 | 580 | 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) }; | |
| 540 | 582 | result.requiredApprovals = rules.requiredApprovals ?? 0; |
| 541 | 583 | const latest = new Map<string, Review>(); |
| 542 | 584 | for (const rv of this.reviews(number)) if (rv.state !== "COMMENTED") latest.set(rv.author.id, rv); |
| @@ -572,13 +614,16 @@ | ||
| 572 | 614 | return result; |
| 573 | 615 | } |
| 574 | 616 | |
| 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) { | |
| 576 | 618 | const p = this.pullRow(number); |
| 577 | 619 | const r = this.row(number); |
| 578 | 620 | if (input.expectedHeadSha && input.expectedHeadSha !== p.head_sha) throw new HttpError(409, "head branch was modified; review and try again"); |
| 579 | 621 | const m = await this.mergeability(number, caller); |
| 580 | 622 | const rules = this.repo.protectionFor(p.base_ref) ?? {}; |
| 581 | 623 | 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 | + } | |
| 582 | 627 | if (!m.mergeable && !adminBypass) throw new HttpError(405, `pull request is not mergeable: ${m.blockers.join("; ")}`, { blockers: m.blockers }); |
| 583 | 628 | const baseRef = `refs/heads/${p.base_ref}`; |
| 584 | 629 | const baseSha = this.repo.getRef(baseRef)!; |
| @@ -603,8 +648,18 @@ | ||
| 603 | 648 | ); |
| 604 | 649 | } |
| 605 | 650 | 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}`; | |
| 606 | 660 | const t = now(); |
| 607 | 661 | 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); | |
| 608 | 663 | this.sql.exec("UPDATE issues SET state = 'closed', state_reason = 'merged', closed_at = ?, updated_at = ? WHERE number = ?", t, t, number); |
| 609 | 664 | const meta = this.repo.meta; |
| 610 | 665 | 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 @@ | ||
| 621 | 676 | /* unknown issue number */ |
| 622 | 677 | } |
| 623 | 678 | } |
| 624 | - if (input.deleteBranch && p.head_ref !== meta.defaultBranch) { | |
| 679 | + if (deleteBranch && p.head_ref !== meta.defaultBranch) { | |
| 625 | 680 | const headRef = `refs/heads/${p.head_ref}`; |
| 626 | 681 | if (this.repo.getRef(headRef) === p.head_sha) this.repo.casRef(headRef, p.head_sha, ZERO_OID); |
| 627 | 682 | } |
| 628 | 683 | 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))); | |
| 630 | 686 | } |
| 631 | 687 | |
| 632 | 688 | // ---------------------------------------------------------------- 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 @@ | ||
| 62 | 62 | 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)); |
| 63 | 63 | CREATE TABLE IF NOT EXISTS caches (key TEXT PRIMARY KEY, size INTEGER NOT NULL, created_at TEXT NOT NULL, last_used TEXT NOT NULL); |
| 64 | 64 | 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 | +); | |
| 65 | 70 | 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); |
| 66 | 71 | `; |
| 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 @@ | ||
| 33 | 33 | agentsMayPush?: boolean; |
| 34 | 34 | /** Agents may merge pull requests into matching branches. Default false. */ |
| 35 | 35 | agentsMayMerge?: boolean; |
| 36 | + /** Pull requests merge through a merge queue (like GitHub's "Require merge queue"). */ | |
| 37 | + mergeQueue?: MergeQueueSettings; | |
| 36 | 38 | } |
| 37 | 39 | |
| 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 | + | |
| 38 | 49 | export function globToRegExp(pattern: string): RegExp { |
| 39 | 50 | let re = ""; |
| 40 | 51 | for (let i = 0; i < pattern.length; i++) { |
modifiedapps/diffinery/src/web/collab.tsx+167−2View file
| @@ -24,6 +24,7 @@ | ||
| 24 | 24 | act, |
| 25 | 25 | duration, |
| 26 | 26 | encPath, |
| 27 | + flash, | |
| 27 | 28 | onBehalfOf, |
| 28 | 29 | pageNum, |
| 29 | 30 | post, |
| @@ -685,7 +686,9 @@ | ||
| 685 | 686 | </ul> |
| 686 | 687 | ) : null} |
| 687 | 688 | </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 ? ( | |
| 689 | 692 | <div> |
| 690 | 693 | <Form action={`${base}/pull/${i.number}/merge`} t={csrfToken(x.c)} class="row" confirm={bypass ? "Merge without meeting the branch requirements?" : undefined}> |
| 691 | 694 | <input type="hidden" name="expected_head" value={p.headSha} /> |
| @@ -725,6 +728,98 @@ | ||
| 725 | 728 | ); |
| 726 | 729 | }; |
| 727 | 730 | |
| 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 | + | |
| 728 | 823 | const ReviewForm: FC<{ x: ConvCtx }> = ({ x }) => { |
| 729 | 824 | if (x.c.get("principal").type !== "account" || x.issue.state !== "open" || x.l.repo.archived) return null; |
| 730 | 825 | const own = x.issue.author.id === x.l.caller.id; |
| @@ -1122,13 +1217,83 @@ | ||
| 1122 | 1217 | c, |
| 1123 | 1218 | `${repoUrl(l.repo)}/pull/${n}`, |
| 1124 | 1219 | 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); | |
| 1126 | 1221 | c.executionCtx.waitUntil(c.get("dir").touchPushed(l.repo.id)); |
| 1127 | 1222 | }, |
| 1128 | 1223 | "Pull request merged.", |
| 1129 | 1224 | ); |
| 1130 | 1225 | }); |
| 1131 | 1226 | |
| 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 | + | |
| 1132 | 1297 | // Labels |
| 1133 | 1298 | web.get("/:owner/:repo/labels", async (c) => { |
| 1134 | 1299 | 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 @@ | ||
| 120 | 120 | ))} |
| 121 | 121 | </div> |
| 122 | 122 | </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> | |
| 123 | 158 | <button class="btn primary">{pattern ? "Save rule" : "Create rule"}</button> |
| 124 | 159 | </Form> |
| 125 | 160 | ); |
| @@ -135,6 +170,14 @@ | ||
| 135 | 170 | .map((s) => s.trim()) |
| 136 | 171 | .filter(Boolean); |
| 137 | 172 | 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 | + } | |
| 138 | 181 | return rules; |
| 139 | 182 | } |
| 140 | 183 | |
| @@ -342,6 +385,7 @@ | ||
| 342 | 385 | r.rules.requiredApprovals ? `${r.rules.requiredApprovals} approval${r.rules.requiredApprovals === 1 ? "" : "s"}` : "", |
| 343 | 386 | r.rules.requiredChecks?.length ? `${r.rules.requiredChecks.length} required check${r.rules.requiredChecks.length === 1 ? "" : "s"}` : "", |
| 344 | 387 | r.rules.requireLinearHistory ? "linear history" : "", |
| 388 | + r.rules.mergeQueue ? "merge queue" : "", | |
| 345 | 389 | r.rules.enforceAdmins ? "admins included" : "", |
| 346 | 390 | ] |
| 347 | 391 | .filter(Boolean) |
modifiedapps/diffinery/test/e2e/e2e.test.mjs+180−2View file
| @@ -103,7 +103,7 @@ | ||
| 103 | 103 | } |
| 104 | 104 | |
| 105 | 105 | /** 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 } = {}) { | |
| 107 | 107 | const claim = await until("a job to claim", async () => { |
| 108 | 108 | const r = await api("POST", `/repos/${owner}/${repo}/actions/runners/claim`, { name: "e2e", labels: ["linux"] }); |
| 109 | 109 | return r.status === 200 ? r.json : null; |
| @@ -116,7 +116,7 @@ | ||
| 116 | 116 | ? await spawnAsync("docker", ["run", "--rm", "--network", "host", ...Object.entries(jobEnv).flatMap(([k, v]) => ["-e", `${k}=${v}`]), image], {}, 300_000) |
| 117 | 117 | : await spawnAsync(process.execPath, [AGENT], { ...process.env, ...jobEnv, DIFFINERY_WORK_ROOT: join(work, `job-${claim.job_id}`) }, 180_000); |
| 118 | 118 | const out = `${r.stdout}${r.stderr}`; |
| 119 | - if (r.status !== 0) { | |
| 119 | + if (r.status !== 0 && !allowFailure) { | |
| 120 | 120 | 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)); |
| 121 | 121 | const job = await api("GET", `/repos/${owner}/${repo}/actions/jobs/${claim.job_id}`).catch((e) => ({ json: String(e) })); |
| 122 | 122 | 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 @@ | ||
| 509 | 509 | assert.equal((await runs()).length, 2, "the follower does not trigger itself"); |
| 510 | 510 | }); |
| 511 | 511 | |
| 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 | + | |
| 512 | 690 | const hasDocker = spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 }).status === 0; |
| 513 | 691 | |
| 514 | 692 | test("containers: a job container and service containers on a self-hosted runner", { skip: !hasDocker && "Docker is not available" }, async () => { |
modifiedpackages/workflow-yaml/src/filters.ts+8−1View file
| @@ -2,11 +2,12 @@ | ||
| 2 | 2 | * Event filters: GitHub filter-pattern globbing and `on:` trigger matching. |
| 3 | 3 | * https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet |
| 4 | 4 | */ |
| 5 | -import type { PullRequestTrigger, PushTrigger, Workflow, WorkflowRunTrigger } from "./model.js"; | |
| 5 | +import type { MergeGroupTrigger, PullRequestTrigger, PushTrigger, Workflow, WorkflowRunTrigger } from "./model.js"; | |
| 6 | 6 | |
| 7 | 7 | export type TriggerEvent = |
| 8 | 8 | | { name: "push"; ref: string; changedFiles?: string[] } |
| 9 | 9 | | { name: "pull_request" | "pull_request_target"; action: string; baseRef: string; changedFiles?: string[] } |
| 10 | + | { name: "merge_group"; action: string; baseRef: string } | |
| 10 | 11 | | { name: "schedule"; cron: string } |
| 11 | 12 | | { name: "workflow_dispatch" } |
| 12 | 13 | | { |
| @@ -168,6 +169,12 @@ | ||
| 168 | 169 | const e = ev as Extract<TriggerEvent, { name: "pull_request" | "pull_request_target" }>; |
| 169 | 170 | return matchesPullRequest(on[ev.name] ?? {}, e.action, e.baseRef, e.changedFiles); |
| 170 | 171 | } |
| 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 | + } | |
| 171 | 178 | case "schedule": { |
| 172 | 179 | const e = ev as Extract<TriggerEvent, { name: "schedule" }>; |
| 173 | 180 | return (on.schedule ?? []).some((s) => normalizeCron(s.cron) === normalizeCron(e.cron)); |
modifiedpackages/workflow-yaml/src/index.ts+1−0View file
| @@ -19,6 +19,7 @@ | ||
| 19 | 19 | WorkflowError, |
| 20 | 20 | WorkflowOn, |
| 21 | 21 | WorkflowRunTrigger, |
| 22 | + MergeGroupTrigger, | |
| 22 | 23 | } from "./model.js"; |
| 23 | 24 | export { parseWorkflow, isWarning, formatPath, WARNING_PREFIX, KNOWN_EVENTS } from "./parse.js"; |
| 24 | 25 | export { |
modifiedpackages/workflow-yaml/src/model.ts+8−0View file
| @@ -30,6 +30,13 @@ | ||
| 30 | 30 | pathsIgnore?: string[]; |
| 31 | 31 | } |
| 32 | 32 | |
| 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 | + | |
| 33 | 40 | export interface ScheduleTrigger { |
| 34 | 41 | cron: string; |
| 35 | 42 | } |
| @@ -71,6 +78,7 @@ | ||
| 71 | 78 | push?: PushTrigger; |
| 72 | 79 | pull_request?: PullRequestTrigger; |
| 73 | 80 | pull_request_target?: PullRequestTrigger; |
| 81 | + merge_group?: MergeGroupTrigger; | |
| 74 | 82 | schedule?: ScheduleTrigger[]; |
| 75 | 83 | workflow_dispatch?: WorkflowDispatchTrigger; |
| 76 | 84 | workflow_call?: WorkflowCallTrigger; |
modifiedpackages/workflow-yaml/src/parse.ts+4−0View file
| @@ -291,6 +291,10 @@ | ||
| 291 | 291 | on.workflow_call = call; |
| 292 | 292 | return; |
| 293 | 293 | } |
| 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; | |
| 294 | 298 | case "workflow_run": |
| 295 | 299 | exclusive(c, o, path, "branches", "branches-ignore"); |
| 296 | 300 | on.workflow_run = pick({ |
modifiedpackages/workflow-yaml/test/filters.test.ts+11−0View file
| @@ -227,4 +227,15 @@ | ||
| 227 | 227 | expect(matchesTrigger(w, { name: "workflow_run", action: "completed", workflow: "Deploy", branch: "main" })).toBe(false); |
| 228 | 228 | expect(matchesTrigger(w, { name: "workflow_run", action: "completed", workflow: "CI", branch: "dev" })).toBe(false); |
| 229 | 229 | }); |
| 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 | + }); | |
| 230 | 241 | }); |