diff --git a/README.md b/README.md index 98b10599..14f60cc8 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,8 @@ CI results are read for the current head. The first required check to fail prints `checks:failed` right away. `checks:passed` prints once nothing on the head is still going (no pending check and no unfinished GitHub Actions run, which may still create jobs), every check and workflow the base branch names is -present, and every required check passed; it names the last check to finish, +present (except `quest land`'s, which no head carries), and every required +check passed; it names the last check to finish, when the head went idle. Each prints once per head, with the check's name (spaces as `_`) in the actor field and its details URL. Results count as `quest status` counts them, so neutral and skipped results pass. Each job of a @@ -382,7 +383,7 @@ comes last, and a missing value prints as `-`: | `mergeable`, `merge-state`, `review-decision` | GitHub's values, lowercased. | | `auto-merge` | Method, who enabled it, and when. | | `merge-queue` | Queue state, position, and when it was enqueued. | -| `landing-stopped` | GitHub's reason the latest merge-queue entry or auto-merge ended (such as `merge_conflict`, `failed_checks`, `manual`, `manually_disabled`, or `merged`), who ended it, and when. | +| `landing-stopped` | The reason the latest merge-queue entry, auto-merge, or [`quest land`](#land-a-batch-with-quest-land) queue entry ended (such as `merge_conflict`, `failed_checks`, `manual`, `manually_disabled`, or `merged`), who ended it, and when. | | `checks` | The head's rollup state and `passed=`, `failing=`, `pending=` counts. | | `check` | `failing` or `pending`, `required` or `optional`, the result, details URL, and name. | | `review` | `current` or `stale` against the head, state, reviewer, time, and URL of each reviewer's latest review. | @@ -429,12 +430,181 @@ after a swap, the error names the new token. It enables auto-merge bound to that SHA, which enters GitHub's merge queue when the base has one, or merges at once when nothing is left to wait for. It makes a merge commit; `--squash` or `--rebase` picks another method, and a -merge queue applies its own. `quest:merge` stays on while the PR is queued or +merge queue applies its own. On a base that requires the `quest land` check, it +queues the PR for [`quest land`](#land-a-batch-with-quest-land) instead. `quest:merge` stays on while the PR is queued or auto-merging; once the PR leaves the queue unmerged, the next command removes it. The commands accept the same PR argument and authentication as `quest wait`, and exit 0 when done, 3 when refused, 2 for invalid arguments, and 1 for other failures. +## Land a batch with quest land + +`quest land` is a batched merge gate that a GitHub repository runs from its own +CI, including a user-owned one that cannot use GitHub's merge queue. It lands +every queued PR as one tested batch: it builds them, in order, into a chain of +squash commits on the base, resolving quest files with `quest resolve`, runs +the repository's check once on the chain's tip, and fast-forwards the base to +the longest passing prefix. The base only ever holds a tree the gate tested, +and each landed PR shows as merged. + +A gated base requires the `quest land` status check, which nothing reports on +a PR head, so GitHub's auto-merge only waits and its merge button cannot land +around the gate. Only the gate's push credential bypasses that rule; pin the +check's source to an app that never reports it, so no one can satisfy it with +a status. There, +`quest merge --land ` queues a PR: it enables squash +auto-merge whose commit body pins the head it approved +(`Quest-Land-Head: `). A PR is queued while its auto-merge is enabled and +the pin is its current head, so a push by anyone but the gate dequeues it. +The queue is in the order auto-merge was enabled. + +Each run of the gate has three phases, so PR code never sees a push credential: + +```sh +quest land resolve > plan.json # read the queue, with a read-only token +quest land check plan.json -- just check > result.json # build and test, with no push credential +quest land push plan.json result.json # land what passed, eject what cannot land +``` + +`check` runs the command on the chain's tip. When it fails, it runs it on the +first half, and so on, until a prefix passes or one PR fails alone, with no +retries and no batch-size limit. `push` rebuilds the chain itself and refuses a +result that did not test exactly its prefixes. It reads the queue again and +lands only PRs still queued as they were when checked, and prints a PR that +left the queue as `withdrawn` so the workflow runs again for the rest. It moves each PR's branch, with +a lease on the head that was tested, to that PR's squash commit: a fork's +branch first, then every other branch in one atomic push. Once GitHub shows +every PR at its new head, it fast-forwards the base to the tip. GitHub marks a +PR merged only when the base reaches a head it has already seen, so moving +both in one push would close the PRs unmerged. A moved head stops the landing, +and the prefix before it lands only if a check passed on exactly that prefix, +since a later PR can mask an earlier one's failure. A failed landing moves the +branches back, so they stay queued. The rest of the queue waits for the next run. + +An ejected PR loses its auto-merge and `quest:merge` label and gets a comment +with the reason, which `quest status` prints under `landing-stopped`: + +| Reason | Why | +| --- | --- | +| `merge_conflict` | It conflicts with the base or the PRs before it, beyond what `quest resolve` settles. | +| `failed_checks` | The check failed with it alone on the base, or a required check failed on its head. | +| `checks_pending` | A required check had not passed on its head. | +| `review_required`, `changes_requested` | Its review decision blocks it. | +| `unresolved_conversations` | The base requires conversation resolution, and it has unresolved review threads. | +| `no_maintainer_edits` | It comes from a fork the gate cannot push to. | +| `head_moved` | Its head moved after it was queued or while it was checked. | +| `not_pinned` | Auto-merge was enabled without `quest merge --land`. | +| `push_failed` | The gate could not move its fork's branch. | + +The gate's push bypasses the base's rulesets and classic branch protection, so +the gate upholds them itself. It requires each PR's review decision to allow +it, each of the base's other required checks to pass on its head, counting +every check and only those from the app a rule names, and, when the base +requires conversation resolution, each of its review threads to be resolved. +Its push satisfies `creation`, `update`, `deletion`, `non_fast_forward`, and +`required_linear_history`, and `copilot_code_review` blocks nothing. `resolve` +fails, before anything lands, on any other rule type and on `pull_request` +settings the review decision does not cover: last-push approval, merge methods +without squash, and required reviewers. + +On a base with classic protection, `resolve` reads its settings, which needs +administration read (a GitHub App's "Administration: read", or a token with +admin rights), and fails if it cannot. It upholds required status checks (strict +included), required reviews (approving count, code owners, and dismissing stale +reviews), conversation resolution, linear history, and blocked creations, and +allows force pushes, deletions, and fork syncing. It fails on any other setting +that is on, such as signed commits, push restrictions, a locked branch, +last-push approval, or `enforce_admins`: only an admin's push bypasses classic +protection, and only while it does not apply to admins. + +A workflow runs the phases in three jobs, with `quest` on PATH in each, +installed from a source the PR cannot modify: + +```yaml +name: Land +on: + pull_request_target: + types: [auto_merge_enabled] + workflow_dispatch: +# One run at a time; waiting triggers collapse into one run that reads the whole queue. +concurrency: + group: land +permissions: {} +jobs: + resolve: + runs-on: ubuntu-latest + permissions: { contents: read, pull-requests: read, checks: read, statuses: read } + outputs: + plan: ${{ steps.resolve.outputs.plan }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + # Install quest here. + - id: resolve + env: + # Under classic protection, a token with administration read instead. + GH_TOKEN: ${{ github.token }} + run: echo "plan=$(quest land resolve)" >> "$GITHUB_OUTPUT" + check: + needs: resolve + runs-on: ubuntu-latest + permissions: { contents: read } + outputs: + result: ${{ steps.check.outputs.result }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + # Install quest here, outside the checkout. + - id: check + env: + PLAN: ${{ needs.resolve.outputs.plan }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + printf '%s' "$PLAN" > "$RUNNER_TEMP/plan.json" + echo "result=$(quest land check "$RUNNER_TEMP/plan.json" -- just check)" >> "$GITHUB_OUTPUT" + land: + needs: [resolve, check] + runs-on: ubuntu-latest + permissions: { contents: write, pull-requests: write, issues: write, actions: write, checks: read, statuses: read } + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + # Install quest here. + - env: + GH_TOKEN: ${{ github.token }} + PLAN: ${{ needs.resolve.outputs.plan }} + RESULT: ${{ needs.check.outputs.result }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + printf '%s' "$PLAN" > "$RUNNER_TEMP/plan.json" + printf '%s' "$RESULT" > "$RUNNER_TEMP/result.json" + quest land push "$RUNNER_TEMP/plan.json" "$RUNNER_TEMP/result.json" > "$RUNNER_TEMP/landing" + cat "$RUNNER_TEMP/landing" + # The queue changed, so run again for the rest. + if [ -s "$RUNNER_TEMP/landing" ]; then gh workflow run land.yml --ref "$GITHUB_REF_NAME"; fi +``` + +The land job's token pushes the base and PR branches; give it the bypass, +such as GitHub Actions in the ruleset's bypass list or a GitHub App's token +from an environment only the base branch can use. Where the ruleset +refuses GitHub Actions as a bypass actor, as an organization's can, use an App +installed on the repository. Ejecting a PR disables its auto-merge, which needs +`contents: write` on the token in `GH_TOKEN` even when another credential +pushes. The check job holds only a read token, so it can run on a self-hosted +runner; make it ephemeral, since it runs the queued PRs' code. Under classic protection, +the land job needs a token with admin rights, and the resolve job one with +administration read, which `github.token` cannot have. Pushing to a fork's +branch needs a credential the fork accepts maintainer edits from. `resolve` reads +the repository from `origin` and fails unless its base requires the +`quest land` check. Ejection comments link the run. `push` merges PR files +with its credential in reach, so it runs `quest resolve` from the clone's git +directory, where a queued PR's `bunfig.toml` or `.env` cannot configure it. + ## Gate expensive commands `quest gate [args...]` holds an expensive command until the host has diff --git a/assets/skills/merge.md b/assets/skills/merge.md index c06220f5..e2a61f62 100644 --- a/assets/skills/merge.md +++ b/assets/skills/merge.md @@ -54,9 +54,11 @@ On a finding, fix the branch: delete the finished quest with `quest rm`, or rewr Then push, re-check, and continue; ask the user only when what remains is unclear. Leave a summary of the changes and decisions made, then land the head you checked with `quest merge --land `. It makes a merge commit; add `--squash` or `--rebase` when the repository's root instructions name that method for the PR's base. +On a base that requires the `quest land` check, it queues the PR for `quest land`, the repository's batched merge gate, which squashes. The `quest:merge` label stays on while the PR is queued or auto-merging. Report once it is landing; do not wait for it to merge, since the queue re-checks it without you. A PR that left the queue unmerged or lost auto-merge shows why under `landing-stopped` in `quest status `. After `merge_conflict` or `failed_checks`, the pick still holds: fix it and land it again. +After `checks_pending`, wait for its required checks to pass and land it again. Any other reason, such as a person removing it, is the user's call, so report why instead of landing it again. Never close a PR to unstick it, and never work around a refused merge; ask instead. diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md index 5868b700..30a88729 100644 --- a/cloudflare/GITHUB.md +++ b/cloudflare/GITHUB.md @@ -189,8 +189,10 @@ on a decision. the job's hold, then enables auto-merge bound to that head, entering the merge queue when the base has one, or merges it at once when GitHub would refuse auto-merge because nothing is left to wait for. It uses the method the - job names, else the first the repository allows. A refusal pauses the job on - a decision, in GitHub's words. + job names, else the first the repository allows. A base that requires the + `quest land` check queues the PR for that gate instead, as `quest merge +--land` does: squash auto-merge whose commit body pins the head. A refusal + pauses the job on a decision, in GitHub's words. - The label stays while the job runs or waits, and while the PR is queued or auto-merging. When the job ends without a landing in progress, or a `pull_request` webhook arrives and GitHub shows the landing ended unmerged, diff --git a/cloudflare/dev/github.ts b/cloudflare/dev/github.ts index 73ddf3e3..b28a4f7e 100644 --- a/cloudflare/dev/github.ts +++ b/cloudflare/dev/github.ts @@ -22,7 +22,7 @@ export type Pull = Issue & { branch: string; checks: "SUCCESS" | "FAILURE" | "PENDING" | null; /** The check runs behind `checks`. */ - runs?: { name: string; conclusion: string; url: string }[]; + runs?: { name: string; conclusion: string; url: string; app?: number; context?: boolean }[]; review: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null; reviews?: { state: string; author: string; body?: string }[]; threads?: Thread[]; @@ -46,6 +46,10 @@ export type Pull = Issue & { timeline?: { id: string; label: string | null }[]; /** Auto-merge is enabled, so it lands once its requirements pass. */ landing?: boolean; + /** The enabled auto-merge's time and commit body; `quest land` reads its queue from them. */ + autoMerge?: { enabledAt: string; commitBody: string }; + /** A fork's head branch accepts pushes from the upstream's maintainers. */ + maintainerCanModify?: boolean; }; export type Commit = { parents: string[]; tree: string; message: string }; /** A tree written through the API: its base tree and the entries changed on it. */ @@ -77,6 +81,12 @@ export type Repo = { commits: Record; /** Trees written through the API. */ trees: Record; + /** Check names the default branch's rulesets require. */ + required?: string[]; + /** The default branch's other ruleset rules, as `GET /rules/branches/main` lists them. */ + rules?: Record[]; + /** The default branch's classic protection, as `GET /branches/main/protection` returns it; none when absent. */ + protection?: Record; }; export type World = { app: { id: number; slug: string }; @@ -221,6 +231,7 @@ function holdsTarget(repo: Repo, pull: Pull) { return { id: nodeId(repo, pull), state: pull.merged ? "MERGED" : pull.closed ? "CLOSED" : "OPEN", + baseRefName: "main", headRefOid: headOf(repo, pull), mergeStateStatus: mergeState(pull), isMergeQueueEnabled: false, @@ -239,7 +250,65 @@ function holdsTarget(repo: Repo, pull: Pull) { const page = (nodes: T[]) => ({ nodes, pageInfo: { hasNextPage: false, endCursor: null } }); -function graphql(repo: Repo, query: string, variables: Record) { +/** The repository holding a pull request's head branch, as `owner/name`. */ +const holderOf = (repo: Repo, pull: Pull, forks: string) => + `${pull.fork ? forks : (pull.contributor ?? repo.owner)}/${repo.name}`; + +/** A pull request as `quest land` reads its queue: auto-merge, head, reviews, and checks. */ +function queueTarget(repo: Repo, pull: Pull, forks: string) { + return { + id: nodeId(repo, pull), + number: pull.number, + url: `https://github.com/${repo.owner}/${repo.name}/pull/${pull.number}`, + title: pull.title, + isDraft: !!pull.draft, + headRefName: pull.branch, + headRefOid: headOf(repo, pull), + maintainerCanModify: !!pull.maintainerCanModify, + reviewDecision: pull.review, + headRepository: { nameWithOwner: holderOf(repo, pull, forks) }, + autoMergeRequest: pull.landing + ? { enabledAt: pull.autoMerge?.enabledAt ?? updatedAt, commitBody: pull.autoMerge?.commitBody ?? "" } + : null, + commits: { + nodes: [ + { + commit: { + oid: headOf(repo, pull), + statusCheckRollup: pull.checks && { contexts: contextPage(pull, null) }, + }, + }, + ], + }, + }; +} + +/** The fake's GitHub Actions app, which reports a run unless it names another. */ +export const ACTIONS_APP = 15368; + +/** One page of a head's check runs and commit statuses, as `statusCheckRollup.contexts` pages them. */ +function contextPage(pull: Pull, after: unknown) { + const start = typeof after === "string" ? Number(after) : 0; + const all = (pull.runs ?? []).map((run) => + run.context + ? { __typename: "StatusContext", context: run.name, state: run.conclusion, targetUrl: run.url } + : { + __typename: "CheckRun", + name: run.name, + status: "COMPLETED", + conclusion: run.conclusion, + detailsUrl: run.url, + checkSuite: { app: { databaseId: run.app ?? ACTIONS_APP } }, + }, + ); + const end = start + 100; + return { + nodes: all.slice(start, end), + pageInfo: { hasNextPage: end < all.length, endCursor: end < all.length ? String(end) : null }, + }; +} + +function graphql(repo: Repo, query: string, variables: Record, forks: string) { const blobs = [...query.matchAll(/(q\d+): object\(oid: "([0-9a-f]{40})"\)/g)]; if (blobs.length) { const files = new Map(repo.files.map((file) => [blob(file.content), file.content])); @@ -255,6 +324,33 @@ function graphql(repo: Repo, query: string, variables: Record) const pull = repo.pulls.find((p) => p.number === variables.number); return { data: { repository: { pullRequest: pull ? holdsTarget(repo, pull) : null } } }; } + if (query.includes("object(oid: $oid)")) { + const pull = repo.pulls.find((p) => headOf(repo, p) === variables.oid); + return { + data: { + repository: { + object: pull + ? { statusCheckRollup: pull.checks && { contexts: contextPage(pull, variables.after) } } + : null, + }, + }, + }; + } + if (query.includes("defaultBranchRef")) return { data: { repository: { defaultBranchRef: { name: "main" } } } }; + if (query.includes("queue:pullRequests(")) { + const queued = repo.pulls.filter((pull) => !pull.closed && variables.base === "main"); + return { + data: { + repository: { + ref: + variables.qualified === "refs/heads/main" + ? { target: { oid: repo.branches.main ?? commitOf(repo) } } + : null, + queue: page(queued.map((pull) => queueTarget(repo, pull, forks))), + }, + }, + }; + } const open = repo.pulls.filter((pull) => !pull.closed); const result: Record = {}; const errors: unknown[] = []; @@ -354,7 +450,7 @@ function workflowsOf(repo: Repo, sha: string): Record { return files; } -type Permission = "metadata" | "contents" | "issues" | "pull_requests" | "checks" | "statuses"; +type Permission = "metadata" | "contents" | "issues" | "pull_requests" | "checks" | "statuses" | "administration"; /** What an installation token may do: the repositories (`owner/name`) and permissions it was minted for. */ type Grant = { repositories: Set; permissions: Partial> }; @@ -413,7 +509,7 @@ export function fakeGitHub(world: World) { return all.length && all.every(Boolean) ? new Set(all as string[]) : null; } - return async function (this: unknown, input: RequestInfo | URL, init?: RequestInit): Promise { + return async function (this: unknown, input: Parameters[0], init?: RequestInit): Promise { // Called like fetch, with the runtime's checks: workerd's fetch throws when // called as another object's method, and building the Request validates // RequestInit as the real fetch does, refusing `redirect: "error"`. @@ -495,6 +591,41 @@ export function fakeGitHub(world: World) { } return json({ errors: [{ type: "NOT_FOUND" }] }); } + // A pull request by node ID, or turning its auto-merge off. + if (query.includes("node(id:") || query.includes("disablePullRequestAutoMerge(")) { + for (const repo of world.repos) + for (const pull of repo.pulls) + if (nodeId(repo, pull) === variables.id) { + if (!scope.repositories.has(`${repo.owner}/${repo.name}`)) + return json({ errors: [{ type: "NOT_FOUND" }] }); + if (query.includes("node(id:")) + return json({ + data: { + node: { + state: pull.merged ? "MERGED" : pull.closed ? "CLOSED" : "OPEN", + headRefOid: headOf(repo, pull), + autoMergeRequest: pull.landing + ? { enabledAt: pull.autoMerge?.enabledAt ?? updatedAt } + : null, + reviewThreads: page( + (pull.threads ?? []).map((thread) => ({ + isResolved: !!thread.resolved, + })), + ), + }, + }, + }); + if (!writes("pull_requests")) return json({ errors: [{ type: "FORBIDDEN" }] }); + if (!pull.landing) + return json({ + errors: [{ message: "Auto-merge is not enabled for this pull request" }], + }); + pull.landing = false; + delete pull.autoMerge; + return json({ data: { disablePullRequestAutoMerge: { clientMutationId: null } } }); + } + return json({ errors: [{ type: "NOT_FOUND" }] }); + } const landing = query.match(/\b(enablePullRequestAutoMerge|mergePullRequest)\(/)?.[1]; if (landing) { for (const repo of world.repos) @@ -515,6 +646,10 @@ export function fakeGitHub(world: World) { pull.closed = pull.merged = true; } else { pull.landing = true; + pull.autoMerge = { + enabledAt: new Date().toISOString(), + commitBody: typeof variables.body === "string" ? variables.body : "", + }; pull.timeline = [...(pull.timeline ?? []), { id: `AME_${nextEvent()}`, label: null }]; } return json({ data: { [landing]: { clientMutationId: null } } }); @@ -524,7 +659,7 @@ export function fakeGitHub(world: World) { const repo = world.repos.find((r) => r.owner === variables.owner && r.name === variables.name); if (!repo || !scope.repositories.has(`${repo.owner}/${repo.name}`)) return json({ errors: [{ type: "NOT_FOUND", message: "Not Found" }] }); - return json(graphql(repo, query, variables)); + return json(graphql(repo, query, variables, world.forks)); } if (!repo || !scope.repositories.has(fullName!)) return notFound(); if (service) return serviceFork(repo, tail, method, body, writes) ?? missing(method, request.url); @@ -554,6 +689,36 @@ export function fakeGitHub(world: World) { }); } if (tail === "/commits/main") return json({ sha: commitOf(repo), commit: { tree: { sha: treeOf(repo) } } }); + // Read access sees whether classic protection is on and its required checks; the rest needs administration. + if (tail === "/branches/main" && method === "GET") { + const checks = repo.protection?.required_status_checks; + return json({ + name: "main", + protection: repo.protection + ? { + enabled: true, + required_status_checks: { + enforcement_level: checks ? "non_admins" : "off", + contexts: checks?.contexts ?? [], + checks: checks?.checks ?? [], + }, + } + : { enabled: false }, + }); + } + if (tail === "/branches/main/protection" && method === "GET") { + if (!scope.permissions.administration) return forbidden(); + return repo.protection ? json(repo.protection) : json({ message: "Branch not protected" }, 404); + } + if (tail.startsWith("/rules/branches/main") && method === "GET") { + const contexts = (repo.required ?? []).map((context) => ({ context })); + return json([ + ...(contexts.length + ? [{ type: "required_status_checks", parameters: { required_status_checks: contexts } }] + : []), + ...(repo.rules ?? []), + ]); + } if (tail.startsWith(`/git/trees/${treeOf(repo)}`) && method === "GET") return json(tree(repo)); // Upstream branches: those the world lists, and those of its pull requests that are not on a fork. const branches = { @@ -629,6 +794,19 @@ export function fakeGitHub(world: World) { pull.merged = true; return json({ sha: commitOf(repo), merged: true, message: "Pull Request successfully merged" }); } + const comment = tail.match(/^\/issues\/(\d+)\/comments$/); + if (comment && method === "POST") { + if (!writes("issues") && !writes("pull_requests")) return forbidden(); + const item = [...repo.pulls, ...repo.issues].find((i) => String(i.number) === comment[1]); + if (!item) return notFound(); + const added: Comment = { + author: `${world.app.slug}[bot]`, + body: String(body.body), + at: new Date().toISOString(), + }; + item.comments = [...(item.comments ?? []), added]; + return json({ body: added.body, user: { login: added.author } }, 201); + } const labels = tail.match(/^\/issues\/(\d+)\/labels(?:\/([^/]+))?$/); if (labels && (method === "POST" || method === "DELETE")) { if (!writes("issues")) return forbidden(); diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts index db412c34..94fbb16c 100644 --- a/cloudflare/src/github/service.ts +++ b/cloudflare/src/github/service.ts @@ -1348,6 +1348,7 @@ export class GitHubService { return { id: row.id, state: row.state, + base: row.baseRefName, head: row.headRefOid, mergeState: row.mergeStateStatus, queue: row.isMergeQueueEnabled === true, @@ -1427,12 +1428,40 @@ export class GitHubService { }); } + /** Whether `branch` requires the `quest land` check, read from branch protection and rulesets. */ + async #gated(token: string, repo: GitHubRepository, branch: string): Promise { + const path = repositoryPath(repo); + const ref = encodeURIComponent(branch); + type Contexts = { context: string }[]; + const { protection } = await this.#api<{ + protection?: { + required_status_checks?: { enforcement_level?: string; contexts?: string[]; checks?: Contexts }; + }; + }>(token, `${path}/branches/${ref}`); + const legacy = protection?.required_status_checks; + const contexts = + legacy && legacy.enforcement_level !== "off" + ? (legacy.checks?.map((check) => check.context) ?? legacy.contexts ?? []) + : []; + const rules = await this.#api<{ type: string; parameters?: { required_status_checks?: Contexts } }[]>( + token, + `${path}/rules/branches/${ref}?per_page=100`, + ); + const ruled = rules.flatMap((rule) => + rule.type === "required_status_checks" ? (rule.parameters?.required_status_checks ?? []) : [], + ); + return [...contexts, ...ruled.map((check) => check.context)].includes(LAND_CHECK); + } + /** * Land a merge job's pull request at exactly `head`, while the job still * holds it: enable auto-merge bound to that head, which enters the merge * queue when the base has one, or merge at once when GitHub would refuse * auto-merge because nothing is left to wait for. `method` is the one the * repository's instructions name, or else the first the repository allows. + * A base that requires the `quest land` check queues it for that gate + * instead, as `quest merge --land` does: squash auto-merge whose commit body + * pins the head. */ landPullRequest( repo: GitHubRepository, @@ -1456,16 +1485,18 @@ export class GitHubService { ); if (holds.head !== sha(input.head)) throw new PublicationBlocked("The pull request's head moved outside this job."); + const gated = await this.#gated(token, repo, holds.base); const mutation = - !holds.queue && IMMEDIATE.includes(holds.mergeState) + !gated && !holds.queue && IMMEDIATE.includes(holds.mergeState) ? "mergePullRequest" : "enablePullRequestAutoMerge"; const result = await this.#api<{ errors?: { message?: unknown }[] }>(token, "/graphql", "POST", { - query: `mutation($id: ID!, $sha: GitObjectID!, $method: PullRequestMergeMethod!) { ${mutation}(input: { pullRequestId: $id, expectedHeadOid: $sha, mergeMethod: $method }) { clientMutationId } }`, + query: `mutation($id: ID!, $sha: GitObjectID!, $method: PullRequestMergeMethod!, $body: String) { ${mutation}(input: { pullRequestId: $id, expectedHeadOid: $sha, mergeMethod: $method, commitBody: $body }) { clientMutationId } }`, variables: { id: holds.id, sha: input.head, - method: (input.method ?? mergeMethod(current)).toUpperCase(), + method: gated ? "SQUASH" : (input.method ?? mergeMethod(current)).toUpperCase(), + body: gated ? `${LAND_PIN}: ${input.head}` : null, }, }); if (result.errors?.length) { @@ -1496,14 +1527,18 @@ const HOLD_PREFIX = "quest:"; export const MERGE_LABEL = "quest:merge"; /** Merge states GitHub merges at once, where it refuses to enable auto-merge. */ const IMMEDIATE = ["CLEAN", "HAS_HOOKS", "UNSTABLE"]; +/** The check a base gated by `quest land` requires, and the commit body trailer that queues a head for it; see `quest land --help`. */ +const LAND_CHECK = "quest land"; +const LAND_PIN = "Quest-Land-Head"; const HOLDS = `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { - id state headRefOid mergeStateStatus isMergeQueueEnabled autoMergeRequest { enabledAt } mergeQueueEntry { state } + id state baseRefName headRefOid mergeStateStatus isMergeQueueEnabled autoMergeRequest { enabledAt } mergeQueueEntry { state } labels(first: 100) { nodes { name } } timelineItems(last: 100, itemTypes: [LABELED_EVENT, AUTO_MERGE_ENABLED_EVENT, ADDED_TO_MERGE_QUEUE_EVENT]) { nodes { __typename ... on LabeledEvent { id label { name } } ... on AutoMergeEnabledEvent { id } ... on AddedToMergeQueueEvent { id } } } } } }`; type HoldRow = { id: string; state: string; + baseRefName: string; headRefOid: string; mergeStateStatus: string; isMergeQueueEnabled: boolean | null; @@ -1515,6 +1550,7 @@ type HoldRow = { type Holds = { id: string; state: string; + base: string; head: string; mergeState: string; queue: boolean; diff --git a/cloudflare/test/publish.test.ts b/cloudflare/test/publish.test.ts index 3da1c825..2ef282a4 100644 --- a/cloudflare/test/publish.test.ts +++ b/cloudflare/test/publish.test.ts @@ -1131,7 +1131,10 @@ describe("merge jobs", () => { expect(project.branches["contributor/fix"]).toBe(head); expect(landed).toEqual({ merged: false }); expect(lands()).toEqual([ - { mutation: "enablePullRequestAutoMerge", variables: { id: "PR_10002", sha: head, method: "MERGE" } }, + { + mutation: "enablePullRequestAutoMerge", + variables: { id: "PR_10002", sha: head, method: "MERGE", body: null }, + }, ]); // Its landing is queued when the job ends, so it keeps holding the pull request. @@ -1145,6 +1148,21 @@ describe("merge jobs", () => { expect(await job(id)).toMatchObject({ hold: null }); }); + it("queues for quest land on a base that requires its check, even when nothing else is left to wait for", async () => { + const id = await merging(); + pull().merge = "CLEAN"; + project.required = ["quest land"]; + const response = await publish({ job: id, attempt: 1, head: prHead, land: { method: "merge" } }); + expect(await response.json()).toEqual({ head: prHead, landed: { merged: false } }); + expect(lands()).toEqual([ + { + mutation: "enablePullRequestAutoMerge", + variables: { id: "PR_10002", sha: prHead, method: "SQUASH", body: `Quest-Land-Head: ${prHead}` }, + }, + ]); + expect(pull().autoMerge?.commitBody).toBe(`Quest-Land-Head: ${prHead}`); + }); + it("merges at once when nothing is left to wait for, unless the project is paused", async () => { const id = await merging(); pull().merge = "CLEAN"; diff --git a/quest/m0/helpers/README.md b/quest/m0/helpers/README.md index 5e26f6d5..72aad975 100644 --- a/quest/m0/helpers/README.md +++ b/quest/m0/helpers/README.md @@ -68,7 +68,6 @@ commands from Sep-Oct. Session counts in each child come from that window. - [Status holds](/quest/m0/helpers/status-holds.md) - `quest status` shows a PR's `quest:*` hold - [Headless agents](/quest/m0/helpers/headless-agents.md) - `/quest-spawn api` launches, resumes, and stops billed agents through one command auto mode allows - [Sibling PRs through a server-side merge](/quest/m0/helpers/queue-siblings.md) - children of one epic land back to back through a merge queue without conflicting on its link list -- [Batched merge gate: quest land](/quest/m0/helpers/land.md) - a repository's CI lands every queued PR as one tested squash chain, fast-forwarding the base to the longest passing prefix - [kixelated/quest lands through quest land](/quest/m0/helpers/land-adopt.md) - this repository's `main` changes only through its own gate ## Related diff --git a/quest/m0/helpers/land-adopt.md b/quest/m0/helpers/land-adopt.md index 0bf0de31..ece98fb3 100644 --- a/quest/m0/helpers/land-adopt.md +++ b/quest/m0/helpers/land-adopt.md @@ -3,7 +3,7 @@ ## Goal PRs to kixelated/quest's `main` land only through -[`quest land`](/quest/m0/helpers/land.md), on a hosted runner, running +`quest land`, on a hosted runner, running `just check` and `just test` once per batch. GitHub's merge button cannot land around it. @@ -17,11 +17,7 @@ user-owned, it cannot use GitHub's merge queue guarded by a test that refuses `contents: write` anywhere but the land job, as moq.pro's `queue.test.ts` does. The admin role keeps its emergency bypass. Ruleset writes are the user's. -- The ruleset requires a check that PR heads never carry, so auto-merge only - waits for the gate. +- The ruleset requires the `quest land` check, which PR heads never carry, so + auto-merge only waits for the gate. - Update `CONTRIBUTING.md`'s landing section; the user asked for it through this plan. - -## Required - -- [Batched merge gate: quest land](/quest/m0/helpers/land.md) - the engine diff --git a/quest/m0/helpers/land.md b/quest/m0/helpers/land.md deleted file mode 100644 index 99e7207f..00000000 --- a/quest/m0/helpers/land.md +++ /dev/null @@ -1,76 +0,0 @@ -# [L] Batched merge gate: quest land - -## Goal - -`quest land`, run from a repository's own CI, lands every PR whose landing -was requested as one tested batch. It builds the queued PRs, in enqueue -order, into a chain of squash commits on the base branch, resolving quest -files with `quest resolve`. It runs the repository's check command once on -the chain's tip and fast-forwards the base to the longest passing prefix. -Each landed PR shows as merged. The base only ever holds a tree the gate -tested, and a PR that fails alone or conflicts outside quest files is ejected -with a reason `quest status` reports. It works on any GitHub repository, -including user-owned ones that cannot use GitHub's merge queue. - -## Plan - -Decided 2026-10-26 (user), planning moq-dev/moq's merge throughput -([moq-dev/moq#6854](https://github.com/moq-dev/moq/pull/6854)). Over 414 -queue merges, the serial GitHub merge queue landed 5.5 PRs per hour. -Waiting behind other entries was 98% of the time, with a median of 18 PRs -ahead of each new entry. GitHub's merge queue cannot test several PRs in one -run ("merge limits do not combine `merge_group` builds"), and Mergify merges -through the API, so neither can guarantee the tested tree or run the merge -driver. - -- **Host.** A CLI subcommand in `src/cli/`, run by a thin workflow in each - repository. It needs no kix. moq.pro's `gate.yml` and `queue.yml` are the - serial precedent. kix's merge jobs feed the same queue through their - `deliver` landing. -- **Trust split.** Three phases, so PR code never sees a push credential: - a trusted hosted job resolves the queue and the chain's inputs, an - untrusted job builds the chain and runs the repository's check command - with a read-only token, and a trusted hosted job lands. The push token is - the repository's choice: moq uses its moq-bot App with the key in a - main-only Environment; moq.pro, kixelated/quest, and kixelated/rust2 put - GitHub Actions in the bypass list, behind a source-policy test. -- **Landing.** Write each PR's squash commit onto the chain. Force-push each - PR's branch, with a lease on the head that was tested, to its commit. Then - fast-forward the base to the tip in one push. GitHub marks a PR merged - once its head is on the base, so history stays linear and each PR shows as - merged. Verify that GitHub behavior against a scratch repository first. If - it does not hold, stop and report rather than fall back to closing PRs. - A failed lease stops the landing. The prefix before it lands only if a - check passed on exactly that prefix, since a later PR can mask an earlier - one's failure; otherwise nothing lands. The gate's own head pushes must not - dequeue the PR they move. -- **Batches.** Every queued PR, with no size constant. On failure, test the - first half; a passing prefix lands at once and the rest continues. A PR - that fails alone is ejected with a comment linking the run. No retries. - Order is enqueue order, with no priority classes. -- **Queue membership.** This quest decides, with a recommendation to the - user, how a repository without GitHub's merge queue marks a PR queued and - pins the head that was approved. `quest merge --land ` already holds - the PR with `quest:merge` and enables auto-merge bound to that head. In a - gated repository the base requires a check that PR heads never carry, so - that auto-merge only waits. A push by anyone but the gate dequeues the PR. -- **Ejection.** Remove `quest:merge`, comment with the reason, and make it - readable through `quest status`'s `landing-stopped`, alongside GitHub's - queue reasons (`merge_conflict`, `failed_checks`). -- **Forks.** A fork PR without maintainer edits cannot have its head moved, - so it is ejected with a comment asking for them. -- Update `--help`, the README, and the merge skill's landing prose. The user - asked for those skill edits through this plan. -- Test chain building, prefix bisection, ejection, a failed lease (including - A failing alone and A+B passing when B's lease fails, which lands nothing), - and a quest-file conflict resolved by the driver, against the shared fake - GitHub (`cloudflare/dev/github.ts`) and a local git repository. - -Adopters, each through its own plan: [kixelated/quest](/quest/m0/helpers/land-adopt.md), -[moq-dev/moq](https://github.com/moq-dev/moq/blob/main/quest/m0/ci/land.md), -moq-dev/moq.pro, and kixelated/rust2. - -## Related - -- [Sibling PRs through a server-side merge](/quest/m0/helpers/queue-siblings.md) - the same conflicts for repositories that still merge on GitHub -- [Land only heads tested against the current base](/quest/m0/helpers/landing.md) - the single-PR landing rule this generalizes diff --git a/quest/m0/helpers/merge-state-race.md b/quest/m0/helpers/merge-state-race.md index bee0a3b5..2be22888 100644 --- a/quest/m0/helpers/merge-state-race.md +++ b/quest/m0/helpers/merge-state-race.md @@ -32,11 +32,10 @@ GitHub has not computed yet reads `UNKNOWN` and falls through to - Test each path: an `UNKNOWN` state that GitHub refuses as clean merges at once, an `UNKNOWN` state that accepts auto-merge stays on auto-merge, and a refusal for any other reason is reported unchanged. -- [kixelated/quest#229](https://github.com/kixelated/quest/pull/229) adds a - gated branch to `land()` ahead of this choice; whichever lands second merges - the other in. +- `land()` sends a base gated by `quest land` to auto-merge ahead of this + choice ([kixelated/quest#229](https://github.com/kixelated/quest/pull/229)), + so it applies only to ungated bases. ## Related - [Landing rules](/quest/m0/helpers/landing.md) - also changes what `quest merge --land` refuses -- [Batched merge gate: quest land](/quest/m0/helpers/land.md) - gated bases always enable auto-merge, so this choice applies only to ungated ones diff --git a/quest/m0/helpers/queue-siblings.md b/quest/m0/helpers/queue-siblings.md index fba56f82..5d924a25 100644 --- a/quest/m0/helpers/queue-siblings.md +++ b/quest/m0/helpers/queue-siblings.md @@ -26,7 +26,7 @@ and a tolerant check must tell a finished quest from a mistyped link. The fix belongs in quest, not in each repository's process. Rejected: landing one sibling at a time, which serializes batches. -Narrowed 2026-10-26 (user): [`quest land`](/quest/m0/helpers/land.md) runs +Narrowed 2026-10-26 (user): `quest land` runs the merge driver while it builds its chain, so a repository on that gate no longer hits the link-list conflict. This quest keeps the dead-link `quest check` failure on every gate, which caused 6 of moq's queue failures @@ -50,5 +50,4 @@ landing through a merge that does not run the driver. ## Related -- [Batched merge gate: quest land](/quest/m0/helpers/land.md) - resolves the link-list conflict for gated repositories - [Land only heads tested against the current base](/quest/m0/helpers/landing.md) - the same landing path diff --git a/src/cli/github.ts b/src/cli/github.ts index 6baed243..13bd49a7 100644 --- a/src/cli/github.ts +++ b/src/cli/github.ts @@ -224,6 +224,64 @@ export class GitHub { } } +/** A check a base branch requires. */ +export interface RequiredCheck { + name: string; + /** The GitHub App whose check run alone satisfies it, or null for any source. */ + app: number | null; +} + +/** What a base branch requires, read from branch protection and rulesets. */ +export interface Requirements { + checks: RequiredCheck[]; + /** Workflow files that must pass as a whole, so each of their jobs is required. */ + workflows: string[]; + /** Every rule the branch's active rulesets apply: `type`, `parameters`, and the ruleset's source. */ + rules: ObjectValue[]; + /** Whether classic branch protection may be on; its settings beyond required checks need admin rights to read. */ + classic: boolean; +} + +/** What `branch` of `repository` requires, read with read access from branch protection and rulesets. */ +export async function requirements(github: GitHub, repository: Repository, branch: string): Promise { + const base = `/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}`; + const ref = encodeURIComponent(branch); + const protection = object(object(await (await github.request(`${base}/branches/${ref}`)).json()).protection ?? {}); + const legacy = object(protection.required_status_checks ?? {}); + const source = (app: unknown) => (typeof app === "number" && app > 0 ? app : null); + // Classic protection may list only `contexts`, without the newer `checks`. + const classic: RequiredCheck[] = + legacy.enforcement_level === "off" + ? [] + : legacy.checks === undefined + ? (Array.isArray(legacy.contexts) ? legacy.contexts : []).map((name) => ({ + name: string(name), + app: null, + })) + : array(legacy.checks).map((row) => ({ name: string(row.context), app: source(row.app_id) })); + const rules = await github.all(`${base}/rules/branches/${ref}?per_page=100`); + const checks = [ + ...classic, + ...rules + .filter((rule) => rule.type === "required_status_checks") + .flatMap((rule) => array(object(rule.parameters).required_status_checks)) + .map((row) => ({ name: string(row.context), app: source(row.integration_id) })), + ]; + return { + checks: checks.filter( + (check, index) => + checks.findIndex((other) => other.name === check.name && other.app === check.app) === index, + ), + workflows: rules + .filter((rule) => rule.type === "workflows") + .flatMap((rule) => array(object(rule.parameters).workflows)) + .map((row) => string(row.path)), + rules, + // Unknown counts as on, so a reader that needs the settings reads them rather than assume none. + classic: protection.enabled !== false, + }; +} + /** * Whether a merged PR in `repository` had exactly `commit` as its head, as when it was squash-merged; * false for a commit GitHub does not have. diff --git a/src/cli/hold.ts b/src/cli/hold.ts index acf95fc8..4d035f1b 100644 --- a/src/cli/hold.ts +++ b/src/cli/hold.ts @@ -3,7 +3,17 @@ // that added the label is its hold token, since sessions often share an account. import { handedBack } from "../core"; import { repositoryPath } from "./claims"; -import { array, GitHub, type GitHubDependencies, object, type ObjectValue, type PullRequest, string } from "./github"; +import { + array, + GitHub, + type GitHubDependencies, + object, + type ObjectValue, + type PullRequest, + requirements, + string, +} from "./github"; +import { LAND_CHECK, pin } from "./land"; import { Refusal, ago } from "./start"; export type State = "iterate" | "ready" | "merge"; @@ -18,7 +28,7 @@ const IMMEDIATE = ["CLEAN", "HAS_HOOKS", "UNSTABLE"]; const READ = `query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { - id url state isDraft body headRefOid mergeStateStatus isMergeQueueEnabled + id url state isDraft body baseRefName headRefOid mergeStateStatus isMergeQueueEnabled autoMergeRequest { enabledAt } mergeQueueEntry { state } labels(first: 100) { nodes { name } } @@ -47,6 +57,7 @@ interface Snapshot { state: "OPEN" | "CLOSED" | "MERGED"; draft: boolean; body: string; + base: string; head: string; mergeState: string; queue: boolean; @@ -102,6 +113,8 @@ export async function finish(pr: PullRequest, token: string, deps: GitHubDepende * Take `pr` for merge, then land `sha`, which must still be its head: enable * auto-merge bound to it, which enters the merge queue when the base has one, * or merge at once when GitHub would refuse auto-merge because nothing is left to wait for. + * A base that requires the `quest land` check queues it for `quest land` instead: + * squash auto-merge whose commit body pins `sha`. */ export async function land( pr: PullRequest, @@ -120,6 +133,19 @@ export async function land( const { hold, snapshot } = await acquire(labels, "merge", token, current); return holding(hold, async () => { current(snapshot); + const gated = (await requirements(labels.github, pr, snapshot.base)).checks.some( + (check) => check.name === LAND_CHECK, + ); + if (gated) { + if (method !== "squash") deps.warn(`${snapshot.url} lands through quest land, which squashes`); + await labels.github.graphql( + `mutation($id: ID!, $sha: GitObjectID!, $body: String!) { + enablePullRequestAutoMerge(input: { pullRequestId: $id, expectedHeadOid: $sha, mergeMethod: SQUASH, commitBody: $body }) { clientMutationId } + }`, + { id: snapshot.id, sha: snapshot.head, body: pin(snapshot.head) }, + ); + return; + } if (snapshot.queue && method !== "merge") { deps.warn(`${snapshot.url} lands through a merge queue, which applies its own merge method`); } @@ -293,6 +319,7 @@ class Labels { state: string(row.state) as Snapshot["state"], draft: row.isDraft === true, body: typeof row.body === "string" ? row.body : "", + base: string(row.baseRefName), head: string(row.headRefOid), mergeState: string(row.mergeStateStatus), queue: row.isMergeQueueEnabled === true, diff --git a/src/cli/land.ts b/src/cli/land.ts new file mode 100644 index 00000000..dc62a17e --- /dev/null +++ b/src/cli/land.ts @@ -0,0 +1,955 @@ +// `quest land`: a batched merge gate a repository runs from its own CI. Three +// phases keep the push credential away from PR code. `resolve` reads the queue +// from GitHub; `check` builds the queue into a chain of squash commits on the +// base and runs the repository's check on it, with no push credential; `push` +// rebuilds the same chain, lands the prefix that passed, and ejects what cannot +// land. +import { spawn, spawnSync } from "node:child_process"; +import { resolve } from "node:path"; + +import { repositoryPath } from "./claims"; +import { + GitHub, + GitHubDeadline, + type GitHubDependencies, + object, + type ObjectValue, + type Repository, + type RequiredCheck, + array, + requirements, + string, +} from "./github"; +import { classifyCheck } from "./status"; + +/** The check a gated base requires. Nothing reports it on a PR head, so GitHub's auto-merge only waits. */ +export const LAND_CHECK = "quest land"; + +/** The auto-merge commit body trailer that pins the head `quest merge --land` approved. */ +const PIN = "Quest-Land-Head"; + +/** The auto-merge commit body that queues `sha`. */ +export function pin(sha: string): string { + return `${PIN}: ${sha}`; +} + +/** The head an auto-merge commit body pins, if any. */ +function pinned(body: string): string | null { + return new RegExp(`^${PIN}: ([0-9a-f]{40})$`, "m").exec(body)?.[1] ?? null; +} + +/** Why the gate removed a PR from the queue; `quest status` prints it under `landing-stopped`. */ +export type Reason = + | "merge_conflict" + | "failed_checks" + | "checks_pending" + | "review_required" + | "changes_requested" + | "unresolved_conversations" + | "no_maintainer_edits" + | "head_moved" + | "not_pinned" + | "push_failed"; + +/** The hidden line an ejection comment carries, which `quest status` reads. */ +export const EJECTED = /^$/m; + +/** A queued PR, as the queue resolved it. */ +export interface Entry { + /** GraphQL node ID. */ + id: string; + number: number; + url: string; + title: string; + /** When auto-merge was enabled: the queue order, and how a later ejection tells a re-queued PR apart. */ + enabledAt: string; + /** The pinned head, which the chain builds and the landing leases. */ + head: string; + /** `owner/name` of the repository holding the head branch, which the landing moves. */ + repository: string; + branch: string; +} + +export interface Ejection { + id: string; + number: number; + url: string; + enabledAt: string; + reason: Reason; + /** Why, for the PR's comment. */ + detail: string; +} + +/** What `resolve` hands the other phases. */ +export interface Plan { + /** `owner/name`. */ + repository: string; + base: { ref: string; sha: string }; + /** Queued PRs in enqueue order. */ + queue: Entry[]; + eject: Ejection[]; +} + +/** One run of the check command, on the chain prefix holding these PRs. */ +export interface Tested { + numbers: number[]; + tree: string; + passed: boolean; +} + +/** What `check` hands `push`: each prefix it tested, in order. */ +export interface Result { + base: string; + checks: Tested[]; +} + +/** A page of a commit's check runs and commit statuses, with the app behind each run. */ +const CONTEXTS = `pageInfo { hasNextPage endCursor } + nodes { + __typename + ... on CheckRun { name status conclusion detailsUrl checkSuite { app { databaseId } } } + ... on StatusContext { context state targetUrl } + }`; + +const QUEUE = `query($owner: String!, $name: String!, $base: String!, $qualified: String!, $after: String) { + repository(owner: $owner, name: $name) { + ref(qualifiedName: $qualified) { target { oid } } + queue:pullRequests(states: OPEN, baseRefName: $base, first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + id number url title isDraft headRefName headRefOid maintainerCanModify reviewDecision + headRepository { nameWithOwner } + autoMergeRequest { enabledAt commitBody } + commits(last: 1) { nodes { commit { oid statusCheckRollup { contexts(first: 100) { ${CONTEXTS} } } } } } + } + } + } +}`; + +/** The pages of a commit's checks after the first. */ +const MORE_CONTEXTS = `query($owner: String!, $name: String!, $oid: GitObjectID!, $after: String) { + repository(owner: $owner, name: $name) { + object(oid: $oid) { ... on Commit { statusCheckRollup { contexts(first: 100, after: $after) { ${CONTEXTS} } } } } + } +}`; + +/** + * The ruleset rule types the gate upholds although its push bypasses them, and how: each PR's + * review decision, review threads, and required checks are its own to enforce, and the rest its + * push satisfies. + */ +export const UPHELD: Record = { + creation: "the gate never creates the base", + update: "only the gate's push, which the rule must let bypass, updates the base", + deletion: "the gate never deletes the base", + non_fast_forward: "the gate only fast-forwards the base", + required_linear_history: "the gate lands squash commits in a line", + pull_request: + "each PR's review decision must not block it, and its review threads must be resolved if the rule says so", + required_status_checks: "each required check must pass on each PR's head, from the app the rule names", + copilot_code_review: "it requests a review and blocks nothing", +}; + +/** What `rule` sets that the gate does not uphold, or null when it upholds all of it. */ +function unheld(rule: ObjectValue): string | null { + const type = string(rule.type); + if (!(type in UPHELD)) return type; + if (type !== "pull_request") return null; + // The review decision covers approvals, code owners, and stale reviews, but not these. + const parameters = rule.parameters == null ? {} : object(rule.parameters); + const methods = parameters.allowed_merge_methods; + const reviewers = parameters.required_reviewers; + const settings = [ + parameters.require_last_push_approval === true && "require_last_push_approval", + Array.isArray(methods) && !methods.includes("squash") && "allowed_merge_methods without squash", + Array.isArray(reviewers) && reviewers.length > 0 && "required_reviewers", + ].filter((setting) => setting !== false); + return settings.length > 0 ? `${type} (${settings.join(", ")})` : null; +} + +/** + * The classic branch protection settings the gate upholds although its push bypasses them, and + * how. Only an admin's push bypasses classic protection, and `enforce_admins` stops even that one. + */ +export const UPHELD_CLASSIC: Record = { + required_status_checks: + "each required check must pass on each PR's head, from the app it names, and the gate checks the chain on the current base", + required_pull_request_reviews: "each PR's review decision must not block it", + required_conversation_resolution: "each PR's review threads must be resolved", + required_linear_history: "the gate lands squash commits in a line", + block_creations: "the gate never creates the base", + allow_force_pushes: "it only loosens the base", + allow_deletions: "it only loosens the base", + allow_fork_syncing: "it only loosens a locked base", +}; + +/** The settings of a base's classic protection that the gate does not uphold. */ +function unheldClassic(protection: ObjectValue): string[] { + return Object.entries(protection).flatMap(([setting, value]) => { + if (setting === "url") return []; + // A setting is on unless it says it is off; one without `enabled`, such as `restrictions`, is on when present. + if (value == null || object(value).enabled === false) return []; + if (!(setting in UPHELD_CLASSIC)) return [setting]; + // As for rulesets, the review decision does not cover approval of the last push. + return setting === "required_pull_request_reviews" && object(value).require_last_push_approval === true + ? [`${setting} (require_last_push_approval)`] + : []; + }); +} + +/** + * The base's classic protection, or null when it has none. Reading it needs administration read, + * and an unreadable protection is never taken as none. + */ +async function classicProtection(github: GitHub, repository: Repository, ref: string): Promise { + const path = `${repositoryPath(repository)}/branches/${encodeURIComponent(ref)}/protection`; + const unreadable = (why: string) => + new Error( + `cannot read ${repository.owner}/${repository.repo}'s classic branch protection on ${ref}, which needs administration read (a GitHub App's "Administration: read", or a token with admin rights): ${why}`, + ); + let response: Response; + try { + response = await github.request(path, { allow: [404] }); + } catch (error) { + if (error instanceof GitHubDeadline || !(error instanceof Error)) throw error; + throw unreadable(error.message); + } + const body = object(await response.json()); + if (response.status === 200) return body; + if (body.message === "Branch not protected") return null; + throw unreadable(`GitHub HTTP 404; ${String(body.message)}`); +} + +/** A page of a PR's review threads, by its node ID. */ +const THREADS = `query($id: ID!, $after: String) { + node(id: $id) { ... on PullRequest { reviewThreads(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { isResolved } } } } +}`; + +/** How many of a PR's review threads are unresolved. */ +async function unresolved(github: GitHub, id: string): Promise { + let count = 0; + for (let after: string | null = null; ;) { + const threads = object(object(object((await github.graphql(THREADS, { id, after })).node).reviewThreads)); + count += array(threads.nodes).filter((thread) => thread.isResolved !== true).length; + const info = object(threads.pageInfo); + if (info.hasNextPage !== true) return count; + after = string(info.endCursor); + } +} + +/** + * Read the queue: open PRs to `base` with auto-merge enabled, in the order it + * was enabled. A PR is queued while its auto-merge commit body pins its current + * head; the rest of the auto-merging PRs are ejected with a reason. + */ +export async function resolveQueue( + repository: Repository, + base: string | undefined, + deps: GitHubDependencies, +): Promise { + const github = new GitHub(deps); + const name = `${repository.owner}/${repository.repo}`; + const ref = + base ?? + string( + object( + object( + ( + await github.graphql( + "query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { defaultBranchRef { name } } }", + { owner: repository.owner, name: repository.repo }, + ) + ).repository, + ).defaultBranchRef, + ).name, + ); + const needs = await requirements(github, repository, ref); + if (!needs.checks.some((check) => check.name === LAND_CHECK)) { + throw new Error( + `${name}'s ${ref} does not require the ${LAND_CHECK} check, so GitHub could merge a queued PR around the gate`, + ); + } + const refused = needs.rules.flatMap((rule) => { + const what = unheld(rule); + return what === null ? [] : [`${what} from ruleset ${String(rule.ruleset_id)}`]; + }); + if (refused.length > 0) { + throw new Error( + `${name}'s ${ref} has ruleset rules the gate does not uphold, and its push bypasses them: ${refused.join("; ")}. It upholds only ${Object.keys(UPHELD).join(", ")}`, + ); + } + // Classic protection's required checks are already among `needs.checks`. + const classic = needs.classic ? await classicProtection(github, repository, ref) : null; + const unmodeled = classic === null ? [] : unheldClassic(classic); + if (unmodeled.length > 0) { + throw new Error( + `${name}'s ${ref} has classic branch protection settings the gate does not uphold, and its push bypasses them: ${unmodeled.join(", ")}. It upholds only ${Object.keys(UPHELD_CLASSIC).join(", ")}`, + ); + } + const resolution = + (classic?.required_conversation_resolution != null && + object(classic.required_conversation_resolution).enabled === true) || + needs.rules.some( + (rule) => + rule.type === "pull_request" && + rule.parameters != null && + object(rule.parameters).required_review_thread_resolution === true, + ); + const required = needs.checks.filter((check) => check.name !== LAND_CHECK); + + let sha: string | undefined; + const nodes: ObjectValue[] = []; + let after: string | null = null; + do { + const data = await github.graphql(QUEUE, { + owner: repository.owner, + name: repository.repo, + base: ref, + qualified: `refs/heads/${ref}`, + after, + }); + if (data.repository === null) throw new Error(`cannot read ${name}; check the repository and token`); + const repo = object(data.repository); + if (repo.ref === null) throw new Error(`${name} has no branch ${ref}`); + sha ??= string(object(object(repo.ref).target).oid); + const page = object(repo.queue); + nodes.push(...array(page.nodes)); + const info = object(page.pageInfo); + after = info.hasNextPage === true ? string(info.endCursor) : null; + } while (after !== null); + + const plan: Plan = { repository: name, base: { ref, sha: sha! }, queue: [], eject: [] }; + const landing = nodes.filter((node) => node.autoMergeRequest !== null && node.isDraft !== true); + const order = (node: ObjectValue) => string(object(node.autoMergeRequest).enabledAt); + landing.sort((a, b) => order(a).localeCompare(order(b)) || Number(a.number) - Number(b.number)); + for (const node of landing) { + const request = object(node.autoMergeRequest); + const head = string(node.headRefOid); + const holder = node.headRepository === null ? null : string(object(node.headRepository).nameWithOwner); + const entry: Entry = { + id: string(node.id), + number: Number(node.number), + url: string(node.url), + title: string(node.title), + enabledAt: string(request.enabledAt), + head, + repository: holder ?? "", + branch: string(node.headRefName), + }; + const eject = (reason: Reason, detail: string) => + plan.eject.push({ + id: entry.id, + number: entry.number, + url: entry.url, + enabledAt: entry.enabledAt, + reason, + detail, + }); + const approved = pinned(typeof request.commitBody === "string" ? request.commitBody : ""); + if (approved === null) { + eject("not_pinned", "auto-merge was enabled without `quest merge --land`, which pins the head to land"); + } else if (approved !== head) { + eject("head_moved", `its head moved from ${approved} to ${head} after it was queued`); + } else if (holder === null) { + eject("no_maintainer_edits", "its head repository was deleted, so the gate cannot move its head"); + } else if (holder.toLowerCase() !== name.toLowerCase() && node.maintainerCanModify !== true) { + eject( + "no_maintainer_edits", + "it comes from a fork that does not allow edits by maintainers, so the gate cannot move its head; allow them and land it again", + ); + } else if (node.reviewDecision === "REVIEW_REQUIRED") { + eject("review_required", "it still needs an approving review"); + } else if (node.reviewDecision === "CHANGES_REQUESTED") { + eject("changes_requested", "a reviewer requested changes"); + } else if (resolution && (await unresolved(github, entry.id)) > 0) { + eject("unresolved_conversations", "it has unresolved review threads; resolve them and land it again"); + } else { + const unmet = checks(await contexts(github, repository, node), required); + if (unmet.failing.length > 0) { + eject("failed_checks", `required checks failed on its head: ${unmet.failing.join(", ")}`); + } else if (unmet.pending.length > 0) { + eject( + "checks_pending", + `required checks had not passed on its head when the gate ran: ${unmet.pending.join(", ")}; land it again once they pass`, + ); + } else { + plan.queue.push(entry); + } + } + } + return plan; +} + +/** + * Every check run and commit status on a PR node's head: the queue's first page, then the rest by + * the head's SHA, which also reads them all when the head moved between the queue's fields. + */ +async function contexts(github: GitHub, repository: Repository, node: ObjectValue): Promise { + const head = string(node.headRefOid); + const commit = object(object(array(object(node.commits).nodes)[0]).commit); + const found: ObjectValue[] = []; + let rollup = commit.oid === head ? commit.statusCheckRollup : undefined; + let after: string | null = null; + for (;;) { + if (rollup === undefined) { + const data = await github.graphql(MORE_CONTEXTS, { + owner: repository.owner, + name: repository.repo, + oid: head, + after, + }); + const target = object(data.repository).object; + if (target === null) throw new Error(`${string(node.url)}'s head ${head} is missing`); + rollup = object(target).statusCheckRollup; + } + if (rollup === null) return found; + const page = object(object(rollup).contexts); + found.push(...array(page.nodes)); + const info = object(page.pageInfo); + if (info.hasNextPage !== true) return found; + after = string(info.endCursor); + rollup = undefined; + } +} + +/** The required checks a head failed, and those pending or not yet reported, each from its app. */ +function checks(contexts: ObjectValue[], required: RequiredCheck[]): { failing: string[]; pending: string[] } { + const failing: string[] = []; + const pending: string[] = []; + for (const need of required) { + let result: "passed" | "failing" | "pending" = "pending"; + let reported = false; + for (const context of contexts) { + const { check, outcome } = classifyCheck(context); + if (check.name !== need.name || (need.app !== null && appOf(context) !== need.app)) continue; + // A name reported twice, as by a rerun, counts only once every report passes. + if (!reported || result === "passed") result = outcome; + reported = true; + } + const name = need.app === null ? need.name : `${need.name} (from app ${need.app})`; + if (result === "failing") failing.push(name); + else if (result === "pending") pending.push(name); + } + return { failing: failing.sort(), pending: pending.sort() }; +} + +/** The GitHub App behind a check run; a commit status has none the gate can verify. */ +function appOf(context: ObjectValue): number | null { + if (context.__typename !== "CheckRun" || context.checkSuite == null) return null; + const app = object(context.checkSuite).app; + return app == null ? null : Number(object(app).databaseId); +} + +/** A queued PR's squash commit on the chain. */ +interface Link { + entry: Entry; + commit: string; + tree: string; +} + +/** The queue built onto the base, in order, without the PRs that conflict. */ +interface Chain { + links: Link[]; + conflicts: { entry: Entry; files: string[] }[]; +} + +/** + * Fetch the base and every queued head, then squash each head in turn onto the + * chain in a scratch worktree inside the git directory, resolving quest files + * with `quest resolve`. A PR that still conflicts is left out. The same plan + * builds the same trees in every phase. + */ +function build(root: string, plan: Plan): Chain { + const probe = spawnSync("quest", ["help", "resolve"], { stdio: "ignore" }); + if (probe.status !== 0) { + throw new Error("quest land merges quest files with `quest resolve`, so `quest` must be on PATH"); + } + git(root, ["fetch", "--quiet", "--no-tags", "origin", plan.base.sha, ...plan.queue.map((entry) => entry.head)]); + const dir = resolve(root, git(root, ["rev-parse", "--git-path", "quest-land"])); + // The driver runs from the git directory, not the chain's worktree, where a + // queued PR's `bunfig.toml` or `.env` would configure the trusted `quest`. + const trusted = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]); + const driver = `d=$PWD && cd '${trusted.replaceAll("'", "'\\''")}' && quest resolve "$d"/%A "$d"/%O "$d"/%B`; + // A crashed run can leave its worktree registered. + tryGit(root, ["worktree", "remove", "--force", dir]); + git(root, ["worktree", "prune"]); + git(root, ["worktree", "add", "--quiet", "--detach", dir, plan.base.sha]); + const chain: Chain = { links: [], conflicts: [] }; + try { + for (const entry of plan.queue) { + const merged = tryGit(dir, ["-c", `merge.quest.driver=${driver}`, "merge", "--squash", entry.head]); + if (merged.status !== 0) { + const files = lines(git(dir, ["diff", "--name-only", "--diff-filter=U"])); + if (files.length === 0) + throw new Error(`git merge --squash ${entry.head} failed: ${merged.stderr.trim()}`); + chain.conflicts.push({ entry, files }); + git(dir, ["reset", "--quiet", "--hard", "HEAD"]); + continue; + } + const author = authorOf(dir, plan.base.sha, entry.head); + git(dir, ["commit", "--quiet", "--allow-empty", "-F", "-"], { + input: message(dir, plan.base.sha, entry), + env: { GIT_AUTHOR_NAME: author.name, GIT_AUTHOR_EMAIL: author.email }, + }); + chain.links.push({ + entry, + commit: git(dir, ["rev-parse", "HEAD"]), + tree: git(dir, ["rev-parse", "HEAD^{tree}"]), + }); + } + } finally { + git(root, ["worktree", "remove", "--force", dir]); + } + return chain; +} + +/** The squash's author: the author of the PR's first commit, as git recorded it. */ +function authorOf(dir: string, base: string, head: string): { name: string; email: string } { + const own = lines(git(dir, ["log", "--reverse", "--no-merges", "--format=%an%x00%ae", `${base}..${head}`])); + const [name, email] = (own[0] ?? git(dir, ["log", "-1", "--format=%an%x00%ae", head])).split("\0"); + return { name, email }; +} + +/** + * GitHub's squash message: the title and number, each commit's message as a + * bullet, and a Co-authored-by trailer for every other author. + */ +function message(dir: string, base: string, entry: Entry): string { + const range = `${base}..${entry.head}`; + const bodies = git(dir, ["log", "--reverse", "--no-merges", "--format=%B%x00", range]) + .split("\0") + .map((body) => body.trim()) + .filter((body) => body !== ""); + const author = authorOf(dir, base, entry.head); + const others = lines(git(dir, ["log", "--no-merges", "--format=%an <%ae>", range])); + const trailers = lines( + git(dir, ["log", "--no-merges", "--format=%(trailers:key=Co-authored-by,valueonly,separator=%x0A)", range]), + ); + const coauthors = [...new Set([...others, ...trailers])].filter( + (person) => person !== `${author.name} <${author.email}>`, + ); + // Trailers already in a commit's message would repeat at the end. + const kept = bodies.map((body) => + body + .split("\n") + .filter((line) => !/^co-authored-by:/i.test(line)) + .join("\n") + .trim(), + ); + return [ + `${entry.title} (#${entry.number})`, + ...kept.map((body) => `* ${body}`), + ...(coauthors.length > 0 ? [coauthors.map((person) => `Co-authored-by: ${person}`).join("\n")] : []), + ].join("\n\n"); +} + +/** + * Build the chain and run `command` in `root` on its tip. When it fails, run it + * on the first half of the chain, and so on, until a prefix passes or a single + * PR fails alone. The command's output goes to `stderr`. + */ +export async function checkQueue( + root: string, + plan: Plan, + command: string[], + stderr: (text: string) => void, +): Promise { + const chain = build(root, plan); + for (const { entry, files } of chain.conflicts) { + stderr(`quest land: #${entry.number} conflicts with the PRs before it in ${files.join(", ")}\n`); + } + const checks: Tested[] = []; + for (let count = chain.links.length; count > 0; count = Math.ceil(count / 2)) { + const tip = chain.links[count - 1]; + const numbers = chain.links.slice(0, count).map((link) => link.entry.number); + git(root, ["checkout", "--quiet", "--force", "--detach", tip.commit]); + stderr(`quest land: checking ${numbers.map((number) => `#${number}`).join(" ")} at ${tip.commit}\n`); + const passed = (await execute(root, command, stderr)) === 0; + checks.push({ numbers, tree: tip.tree, passed }); + if (passed || count === 1) break; + } + return { base: plan.base.sha, checks }; +} + +/** Run `command` in `cwd` with both of its output streams sent to `stderr`, resolving its exit code. */ +function execute(cwd: string, command: string[], stderr: (text: string) => void): Promise { + return new Promise((done, fail) => { + const child = spawn(command[0], command.slice(1), { cwd, stdio: ["ignore", "pipe", "pipe"] }); + child.stdout.on("data", (chunk: Buffer) => stderr(chunk.toString())); + child.stderr.on("data", (chunk: Buffer) => stderr(chunk.toString())); + child.once("error", fail); + child.once("close", (code) => done(code ?? 1)); + }); +} + +/** + * Rebuild the chain, check that `result` tested exactly its prefixes, eject + * what cannot land, then land the prefix that passed: move each PR's branch to + * its squash commit with a lease on the head that was tested, and fast-forward + * the base to the last one. A head that moved stops the landing; the prefix + * before it lands only if a check passed on exactly that prefix. + */ +export async function pushQueue( + root: string, + plan: Plan, + result: Result, + run: string | undefined, + deps: GitHubDependencies, +): Promise { + const chain = build(root, plan); + validate(chain, plan, result); + const ejections = [...plan.eject]; + const ejectLink = (entry: Entry, reason: Reason, detail: string) => ejections.push(ejection(entry, reason, detail)); + for (const { entry, files } of chain.conflicts) { + ejectLink( + entry, + "merge_conflict", + `it conflicts with the base or the PRs queued before it in ${files.join(", ")}; merge the base, push, and land it again`, + ); + } + const last = result.checks.at(-1); + if (last !== undefined && !last.passed && last.numbers.length === 1) { + ejectLink( + chain.links[0].entry, + "failed_checks", + "the repository's check failed with it alone on the base; fix it and land it again", + ); + } + const passed = new Set(result.checks.filter((tested) => tested.passed).map((tested) => tested.numbers.length)); + let count = last?.passed ? last.numbers.length : 0; + if (count > 0) { + const base = remoteHead(root, "origin", plan.base.ref); + if (base !== plan.base.sha) + throw new Error(`${plan.base.ref} moved from ${plan.base.sha} to ${base}; nothing landed`); + for (let index = 0; index < count; index++) { + const { entry } = chain.links[index]; + const head = remoteHead(root, remoteOf(plan, entry), entry.branch); + if (head === entry.head) continue; + ejectLink( + entry, + "head_moved", + `its head moved from ${entry.head} to ${head ?? "nothing"} while the gate checked it`, + ); + // A later PR can mask an earlier one's failure, so only a prefix checked on its own lands. + count = passed.has(index) ? index : 0; + break; + } + } + // One line per PR: `landed `, `ejected `, or `withdrawn `. + const output: string[] = []; + if (count > 0) { + // The queue can change while the gate checks it: land only PRs still queued as they were. + const [owner, repo] = plan.repository.split("/"); + const fresh = await resolveQueue({ owner, repo }, plan.base.ref, deps); + for (let index = 0; index < count; index++) { + const { entry } = chain.links[index]; + const now = fresh.queue.find((queued) => queued.number === entry.number); + if (now?.head === entry.head && now.enabledAt === entry.enabledAt) continue; + const ejected = fresh.eject.find((ejected) => ejected.number === entry.number); + if (ejected !== undefined) ejections.push(ejected); + // Leave it unqueued, but report it so the rest of the queue gets another run. + else output.push(`withdrawn ${entry.number}`); + count = passed.has(index) ? index : 0; + break; + } + } + + const github = new GitHub(deps); + for (const ejection of ejections) { + if (await eject(github, plan, ejection, run)) output.push(`ejected ${ejection.number} ${ejection.reason}`); + } + if (count === 0) return output; + + const links = chain.links.slice(0, count); + const refused = await landLinks(root, plan, links, deps); + if (refused === null) { + for (const link of links) output.push(`landed ${link.entry.number} ${link.commit}`); + return output; + } + // A refused push stops the landing; it lands again on the next run, without the PR that stopped it. + const stop = async (entry: Entry, reason: Reason, detail: string) => { + if (await eject(github, plan, ejection(entry, reason, detail), run)) { + output.push(`ejected ${entry.number} ${reason}`); + } + return output; + }; + for (const { entry } of refused.links) { + const head = remoteHead(root, remoteOf(plan, entry), entry.branch); + if (head !== entry.head) { + return stop( + entry, + "head_moved", + `its head moved from ${entry.head} to ${head ?? "nothing"} while the gate checked it`, + ); + } + } + const [first] = refused.links; + if (refused.links.length === 1 && !local(plan, first.entry)) { + return stop(first.entry, "push_failed", `the gate could not move its head: ${refused.stderr}`); + } + throw new Error(`the landing push failed, so nothing landed: ${refused.stderr}`); +} + +/** How long `push` waits for GitHub to show the moved heads before it backs out. */ +const SYNC_MS = 60_000; + +/** + * Move each PR's branch to its squash commit, then fast-forward the base, all or + * nothing. GitHub marks a PR merged only when the base reaches a head it has + * already synced: a head that moves in the base's push, or syncs after it, + * leaves the PR closed or open instead, so the base waits for every head. + * Heads that moved go back when a later step fails: the gate's own head pushes + * must not dequeue a PR that did not land. Returns the PRs whose head push git + * refused, none when the base push failed, with its error, or null once + * everything landed. + */ +async function landLinks( + root: string, + plan: Plan, + links: Link[], + deps: GitHubDependencies, +): Promise<{ links: Link[]; stderr: string } | null> { + const moved: Link[] = []; + let refused: { links: Link[]; stderr: string } | null = null; + for (const link of links.filter((link) => !local(plan, link.entry))) { + const pushed = tryGit(root, [ + "push", + "--quiet", + remoteOf(plan, link.entry), + ...move(link, link.commit, link.entry.head), + ]); + if (pushed.status !== 0) { + refused = { links: [link], stderr: pushed.stderr.trim() }; + break; + } + moved.push(link); + } + const own = links.filter((link) => local(plan, link.entry)); + if (refused === null && own.length > 0) { + const pushed = tryGit(root, [ + "push", + "--quiet", + "--atomic", + "origin", + ...own.flatMap((link) => move(link, link.commit, link.entry.head)), + ]); + if (pushed.status === 0) moved.push(...own); + else refused = { links: own, stderr: pushed.stderr.trim() }; + } + if (refused === null) { + const unsynced = await synced(deps, links); + if (unsynced !== null) refused = { links: [], stderr: unsynced }; + } + if (refused === null) { + const pushed = tryGit(root, [ + "push", + "--quiet", + "origin", + `${links[links.length - 1].commit}:refs/heads/${plan.base.ref}`, + ]); + if (pushed.status === 0) return null; + refused = { links: [], stderr: pushed.stderr.trim() }; + } + const stuck = moved.filter( + (link) => + tryGit(root, ["push", "--quiet", remoteOf(plan, link.entry), ...move(link, link.entry.head, link.commit)]) + .status !== 0, + ); + if (stuck.length > 0) { + throw new Error( + `the landing push failed (${refused.stderr}), and ${stuck.map((link) => `#${link.entry.number}`).join(", ")} could not move back to the head it had`, + ); + } + return refused; +} + +/** Wait until GitHub shows each PR's head at its squash commit; otherwise, return why not. */ +async function synced(deps: GitHubDependencies, links: Link[]): Promise { + const github = new GitHub(deps, deps.now() + SYNC_MS); + try { + for (const link of links) { + while (true) { + const data = await github.graphql( + "query($id: ID!) { node(id: $id) { ... on PullRequest { headRefOid } } }", + { id: link.entry.id }, + ); + if (object(data.node).headRefOid === link.commit) break; + await github.pause(1000); + } + } + return null; + } catch (error) { + if (error instanceof GitHubDeadline) + return `GitHub did not show the moved heads within ${SYNC_MS / 1000} seconds`; + return `cannot read the moved heads: ${error instanceof Error ? error.message : String(error)}`; + } +} + +/** Remove `entry` from the queue for `reason`. */ +function ejection(entry: Entry, reason: Reason, detail: string): Ejection { + return { id: entry.id, number: entry.number, url: entry.url, enabledAt: entry.enabledAt, reason, detail }; +} + +/** A refspec that moves a PR's branch to `to`, with a lease on it being at `from`. */ +function move(link: Link, to: string, from: string): string[] { + const ref = `refs/heads/${link.entry.branch}`; + return [`${to}:${ref}`, `--force-with-lease=${ref}:${from}`]; +} + +/** Fail unless `result` tested prefixes of this chain on this base, shrinking, with only the last passing. */ +function validate(chain: Chain, plan: Plan, result: Result): void { + const bad = (why: string) => new Error(`the check result does not match the chain: ${why}`); + if (result.base !== plan.base.sha) throw bad(`it was built on ${result.base}, not ${plan.base.sha}`); + let previous = Infinity; + result.checks.forEach((tested, index) => { + const count = tested.numbers.length; + if (count === 0 || count >= previous || count > chain.links.length) + throw bad(`check ${index + 1} is not a shorter prefix`); + previous = count; + const prefix = chain.links.slice(0, count); + if (prefix.some((link, at) => link.entry.number !== tested.numbers[at])) { + throw bad(`check ${index + 1} lists other PRs`); + } + if (prefix[count - 1].tree !== tested.tree) throw bad(`check ${index + 1} tested another tree`); + if (tested.passed && index !== result.checks.length - 1) + throw bad(`check ${index + 1} passed but was not the last`); + }); + if (result.checks.length === 0 && chain.links.length > 0) throw bad("nothing was checked"); + if (result.checks.length > 0 && result.checks[0].numbers.length !== chain.links.length) { + throw bad("the first check did not cover the whole chain"); + } +} + +/** + * Remove `ejection` from the queue: disable its auto-merge, drop `quest:merge`, + * and comment with the reason. A PR whose auto-merge was enabled again since + * the queue was read is a new entry and is left alone. Returns whether it ejected. + */ +async function eject(github: GitHub, plan: Plan, ejection: Ejection, run: string | undefined): Promise { + const data = await github.graphql( + "query($id: ID!) { node(id: $id) { ... on PullRequest { state autoMergeRequest { enabledAt } } } }", + { id: ejection.id }, + ); + const pull = object(data.node); + if (pull.state !== "OPEN") return false; + const request = pull.autoMergeRequest === null ? null : object(pull.autoMergeRequest); + if (request !== null && string(request.enabledAt) !== ejection.enabledAt) return false; + if (request !== null) { + await github.graphql( + "mutation($id: ID!) { disablePullRequestAutoMerge(input: { pullRequestId: $id }) { clientMutationId } }", + { id: ejection.id }, + ); + } + const [owner, repo] = plan.repository.split("/"); + const issue = `${repositoryPath({ owner, repo })}/issues/${ejection.number}`; + await github.request(`${issue}/labels/${encodeURIComponent("quest:merge")}`, { method: "DELETE", allow: [404] }); + const link = run === undefined ? "" : ` ([run](${run}))`; + await github.request(`${issue}/comments`, { + body: { + body: `quest land removed this pull request from the queue: ${ejection.detail}${link}.\n\n`, + }, + }); + return true; +} + +/** Whether the PR's head branch is in the base repository, which the landing pushes atomically. */ +function local(plan: Plan, entry: Entry): boolean { + return entry.repository.toLowerCase() === plan.repository.toLowerCase(); +} + +/** Where to push a PR's head branch: origin, or its fork on GitHub. */ +function remoteOf(plan: Plan, entry: Entry): string { + return local(plan, entry) ? "origin" : `https://github.com/${entry.repository}.git`; +} + +/** The commit `branch` is at on `remote`, or null when it is gone. */ +function remoteHead(root: string, remote: string, branch: string): string | null { + const ref = `refs/heads/${branch}`; + const found = lines(git(root, ["ls-remote", remote, ref])).map((line) => line.split("\t")); + return found.find(([, name]) => name === ref)?.[0] ?? null; +} + +/** Read a plan written by `resolve`, failing on anything malformed. */ +export function parsePlan(text: string): Plan { + const row = object(JSON.parse(text)); + const base = object(row.base); + const entry = (value: ObjectValue): Entry => ({ + id: string(value.id), + number: positive(value.number), + url: string(value.url), + title: string(value.title), + enabledAt: string(value.enabledAt), + head: sha(value.head), + repository: string(value.repository), + branch: string(value.branch), + }); + return { + repository: string(row.repository), + base: { ref: string(base.ref), sha: sha(base.sha) }, + queue: array(row.queue).map(entry), + eject: array(row.eject).map((value) => ({ + id: string(value.id), + number: positive(value.number), + url: string(value.url), + enabledAt: string(value.enabledAt), + reason: string(value.reason) as Reason, + detail: string(value.detail), + })), + }; +} + +/** Read a check result, which the untrusted check job wrote, failing on anything malformed. */ +export function parseResult(text: string): Result { + const row = object(JSON.parse(text)); + return { + base: sha(row.base), + checks: array(row.checks).map((value) => { + if (!Array.isArray(value.numbers) || typeof value.passed !== "boolean") { + throw new Error("invalid check result: expected numbers and passed"); + } + return { numbers: value.numbers.map(positive), tree: sha(value.tree), passed: value.passed }; + }), + }; +} + +function positive(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new Error("invalid quest land input: expected a positive integer"); + } + return value; +} + +function sha(value: unknown): string { + if (typeof value !== "string" || !/^[0-9a-f]{40}$/.test(value)) { + throw new Error("invalid quest land input: expected a full SHA"); + } + return value; +} + +function lines(text: string): string[] { + return text.split("\n").filter((line) => line !== ""); +} + +/** Run git without the repository's hooks, returning its status and output. */ +function tryGit( + cwd: string, + args: string[], + options: { input?: string; env?: Record } = {}, +): { status: number | null; stdout: string; stderr: string } { + const result = spawnSync("git", ["-c", "core.hooksPath=/dev/null", ...args], { + cwd, + encoding: "utf8", + input: options.input, + env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...options.env }, + maxBuffer: 1 << 30, + }); + if (result.error) throw result.error; + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +/** Run git, failing loudly; returns its trimmed output. */ +function git(cwd: string, args: string[], options: { input?: string; env?: Record } = {}): string { + const result = tryGit(cwd, args, options); + if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.trim()}`); + return result.stdout.trim(); +} diff --git a/src/cli/main.ts b/src/cli/main.ts index 3cbabc16..d32e46b4 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -1,6 +1,6 @@ // The `quest` command line: argument parsing and output around the core. -import { realpathSync } from "node:fs"; +import { readFileSync, realpathSync } from "node:fs"; import { relative, resolve, sep } from "node:path"; import { Argument, Command, CommanderError, InvalidArgumentError, Option } from "commander"; @@ -31,8 +31,9 @@ import { import type { AdapterName } from "../runner/adapter"; import { held, reach, target, upstream, upstreamHead } from "./claims"; import { gate } from "./gate"; -import { gitHubDependencies, GitHubInputError, mergedHead, pullRequest, remoteUrl } from "./github"; +import { gitHubDependencies, GitHubInputError, mergedHead, pullRequest, remoteRepository, remoteUrl } from "./github"; import { type Hold, type State, finish, land, release, take } from "./hold"; +import { LAND_CHECK, checkQueue, parsePlan, parseResult, pushQueue, resolveQueue } from "./land"; import { landed } from "./landed"; import { type Report, moveQuest, removeQuest } from "./move"; import { resolveFile } from "./resolve"; @@ -375,8 +376,8 @@ export function main(argv: string[], out: Output): number | Promise { "in one call. It reports facts; it does not decide whether the PR is ready or whose review counts.\n\n" + "Plain output prints one record per line, its type first and any free text (a check name or path) last: " + "url, state, draft, head, mergeable, merge-state, review-decision, auto-merge, merge-queue, " + - "landing-stopped, and checks; landing-stopped is the latest exit from the merge queue or auto-merge, " + - "with GitHub's reason (such as merge_conflict, failed_checks, manual, or manually_disabled), " + + "landing-stopped, and checks; landing-stopped is the latest exit from the merge queue, auto-merge, or " + + "quest land's queue, with the reason (such as merge_conflict, failed_checks, manual, or manually_disabled), " + "actor, and time. Then " + "a check line per failing or pending check, marked required or optional; a review line per " + "reviewer's latest review, marked current or stale against the head; a reacted: line per " + @@ -450,8 +451,10 @@ export function main(argv: string[], out: Output): number | Promise { "Take a PR to fix it until it lands", "--land enables auto-merge bound to that full head SHA, which enters the base's merge queue when it has " + "one, or merges at once when nothing is left to wait for. It refuses once the head has moved. It makes a " + - "merge commit unless --squash or --rebase picks another method; a merge queue applies its own. The label " + - "stays on while the PR is queued or auto-merging, until it merges.", + "merge commit unless --squash or --rebase picks another method; a merge queue applies its own. On a base " + + `that requires the \`${LAND_CHECK}\` check, it queues the PR for \`quest land\` instead: squash ` + + "auto-merge whose commit body pins the head. The label stays on while the PR is queued or auto-merging, " + + "until it merges.", ) .option("--land ", "Land this full 40-character head SHA; needs the token") .addOption(new Option("--squash", "Squash and merge").conflicts("rebase")) @@ -474,6 +477,121 @@ export function main(argv: string[], out: Output): number | Promise { }, ); + const landing = program + .command("land") + .summary("Land every queued PR as one tested batch, from the repository's CI") + .description( + "A batched merge gate for a GitHub repository, run from its own CI in three phases so PR code never " + + "sees a push credential. `resolve` reads the queue, `check` builds and tests it with a read-only " + + "token, and `push` lands what passed.\n\n" + + `The base branch requires the \`${LAND_CHECK}\` check, which nothing reports on a PR head, so ` + + "GitHub's auto-merge only waits and its merge button cannot land around the gate. On such a base, " + + "`quest merge --land ` queues a PR: it enables squash auto-merge whose commit body pins that head. " + + "A PR is queued while auto-merge is enabled and the pin is its current head, so a push by anyone " + + "but the gate dequeues it. The queue is in the order auto-merge was enabled.\n\n" + + "The gate builds the queued PRs, in order, into a chain of squash commits on the base, resolving " + + "quest files with `quest resolve`, and runs the check command once on the chain's tip. When that " + + "fails, it checks the first half, and so on, until a prefix passes, which lands at once, or one PR " + + "fails alone, which is ejected; the rest waits for the next run. Landing moves each PR's branch, " + + "with a lease on the head that was tested, to its squash commit, then fast-forwards the base to the " + + "tip, so GitHub shows each PR as merged and the base only ever holds a tested tree.\n\n" + + "An ejected PR loses auto-merge and `quest:merge`, and gets a comment with the reason, which " + + "`quest status` prints under landing-stopped: merge_conflict (a conflict `quest resolve` cannot " + + "settle), failed_checks (the check failed with it alone on the base, or a required check failed on " + + "its head), checks_pending, review_required, changes_requested, unresolved_conversations, " + + "no_maintainer_edits (a fork the " + + "gate cannot push to), head_moved, not_pinned (auto-merge enabled without `quest merge --land`), " + + "or push_failed. Required reviews, conversation resolution, and the base's other required checks " + + "still apply.\n\n" + + "Uses GH_TOKEN, GITHUB_TOKEN, or gh auth token, and origin for the repository. Exits 0 when done, " + + "2 for invalid arguments, and 1 for other failures.", + ); + landing + .command("resolve") + .summary("Print the queue as a plan for the other phases") + .description( + "Read the queue from GitHub with a read-only token and print it as a JSON plan: the base's commit, " + + "the queued PRs in order, and the PRs to eject with their reasons. Fails unless the base requires " + + `the \`${LAND_CHECK}\` check, and on a ruleset rule or classic protection setting the gate does not ` + + "uphold. Classic protection needs a token with administration read.", + ) + .option("--base ", "The base branch (default: the repository's default branch)") + .action((options: { base?: string }) => { + const { repository } = remoteRepository(root(), "origin"); + if (!repository) throw new Error("origin must name a github.com repository"); + pending = resolveQueue(repository, options.base, gitHubDependencies(warn)).then( + (plan) => { + print(JSON.stringify(plan)); + return 0; + }, + (error: unknown) => { + warn(error instanceof Error ? error.message : String(error)); + return 1; + }, + ); + }); + landing + .command("check") + .summary("Build the queue onto the base and test it, printing the result") + .description( + "Fetch the plan's base and heads from origin, build the chain, and run in the checkout on " + + "its tip, then on the first half while it fails, until a prefix passes or one PR fails alone. " + + "Each run checks out the commit it tests; untracked files stay. Needs `quest` on PATH for the merge " + + "driver and a git identity for the throwaway commits. The command's output goes to stderr; stdout " + + "is the JSON result for `push`. Needs no GitHub token.", + ) + .argument("", "The plan file `resolve` printed") + .argument("", "Executable to run; put -- before it when any argument starts with -") + .argument("[args...]", "Arguments forwarded verbatim") + .action((file: string, command: string, args: string[]) => { + const plan = parsePlan(readFileSync(file, "utf8")); + pending = checkQueue(root(), plan, [command, ...args], out.stderr).then( + (result) => { + print(JSON.stringify(result)); + return 0; + }, + (error: unknown) => { + warn(error instanceof Error ? error.message : String(error)); + return 1; + }, + ); + }); + landing + .command("push") + .summary("Land the prefix that passed and eject what cannot land") + .description( + "Rebuild the plan's chain, refuse a result that did not test exactly its prefixes, then eject the " + + "PRs the plan names, those that conflict, and one that failed alone. Land the prefix that passed: " + + "move each PR's branch with a lease on its tested head (a fork's first, the rest in one atomic push " + + "with the base's fast-forward). A moved head stops the landing; the prefix before it lands only if " + + "a check passed on exactly that prefix. A failed landing moves fork branches back. Ejection " + + "comments link the GitHub Actions run when GITHUB_RUN_ID is set. Prints `landed ` " + + "and `ejected ` lines; any line means the queue changed, so a workflow can run " + + "the gate again. Needs push access through origin and to forks that allow maintainer edits, and " + + "`quest` on PATH.", + ) + .argument("", "The plan file `resolve` printed") + .argument("", "The result file `check` printed") + .action((planFile: string, resultFile: string) => { + const plan = parsePlan(readFileSync(planFile, "utf8")); + const result = parseResult(readFileSync(resultFile, "utf8")); + const env = process.env; + const run = + env.GITHUB_RUN_ID && env.GITHUB_REPOSITORY + ? `${env.GITHUB_SERVER_URL ?? "https://github.com"}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` + : undefined; + pending = pushQueue(root(), plan, result, run, gitHubDependencies(warn)).then( + (lines) => { + for (const line of lines) print(line); + return 0; + }, + (error: unknown) => { + warn(error instanceof Error ? error.message : String(error)); + return 1; + }, + ); + }); + program .command("release") .summary("Hand back a PR by removing the label a hold token holds") diff --git a/src/cli/status.ts b/src/cli/status.ts index 22e1fa16..17008acf 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -9,6 +9,7 @@ import { type PullRequest, string, } from "./github"; +import { EJECTED } from "./land"; /** * One query covers the PR; each connection pages by its own cursor, and a @@ -22,9 +23,11 @@ const STATUS = `query($owner: String!, $repo: String!, $number: Int!, url state isDraft headRefOid mergeable mergeStateStatus reviewDecision autoMergeRequest { enabledAt mergeMethod enabledBy { login } } mergeQueueEntry { state position enqueuedAt } - timelineItems(last: 1, itemTypes: [REMOVED_FROM_MERGE_QUEUE_EVENT, AUTO_MERGE_DISABLED_EVENT]) { nodes { + timelineItems(last: 100, itemTypes: [REMOVED_FROM_MERGE_QUEUE_EVENT, AUTO_MERGE_DISABLED_EVENT, ISSUE_COMMENT]) { nodes { + __typename ... on RemovedFromMergeQueueEvent { createdAt reason actor { login } } ... on AutoMergeDisabledEvent { createdAt reasonCode actor { login } } + ... on IssueComment { createdAt body authorAssociation author { __typename login } } } } reactions(last: 100) { nodes { ...reaction } } commits(last: 1) { nodes { commit { oid statusCheckRollup { state @@ -104,7 +107,7 @@ export interface Status { reviewDecision: string | null; autoMerge: { method: string; enabledBy: string; enabledAt: string } | null; mergeQueue: { state: string; position: number; enqueuedAt: string } | null; - /** The latest exit from the merge queue or auto-merge, and GitHub's reason for it. */ + /** The latest exit from the merge queue, auto-merge, or `quest land`'s queue, and the reason for it. */ landingStopped: { reason: string; actor: string; at: string } | null; checks: { state: string | null; passed: number; failing: Check[]; pending: Check[] }; /** Each reviewer's latest submitted review, oldest first. */ @@ -235,7 +238,9 @@ function summarize( const autoMerge = pull.autoMergeRequest === null ? null : object(pull.autoMergeRequest); const queue = pull.mergeQueueEntry === null ? null : object(pull.mergeQueueEntry); - const stopped = array(object(pull.timelineItems).nodes).at(-1); + const stopped = array(object(pull.timelineItems).nodes) + .filter((node) => node.__typename !== "IssueComment" || ejection(node) !== null) + .at(-1); return { url, state: lower(pull.state) as Status["state"], @@ -260,8 +265,8 @@ function summarize( stopped === undefined ? null : { - reason: lower(stopped.reason ?? stopped.reasonCode), - actor: actor(stopped.actor), + reason: ejection(stopped) ?? lower(stopped.reason ?? stopped.reasonCode), + actor: actor(stopped.actor ?? stopped.author), at: string(stopped.createdAt), }, checks, @@ -271,6 +276,18 @@ function summarize( }; } +/** + * The reason in a `quest land` ejection comment, or null for any other comment. Only an app or a + * person with write access speaks for the gate. + */ +function ejection(node: ObjectValue): string | null { + if (node.__typename !== "IssueComment") return null; + const author = node.author === null ? null : object(node.author); + const trusted = + author?.__typename === "Bot" || ["OWNER", "MEMBER", "COLLABORATOR"].includes(String(node.authorAssociation)); + return trusted && typeof node.body === "string" ? (EJECTED.exec(node.body)?.[1] ?? null) : null; +} + /** * A check run or commit status from a `statusCheckRollup` context, and whether it passed, failed, or * is still pending. `quest wait` shares this, so both commands agree on what passes. diff --git a/src/cli/wait.ts b/src/cli/wait.ts index 478927cb..71522e92 100644 --- a/src/cli/wait.ts +++ b/src/cli/wait.ts @@ -9,8 +9,10 @@ import { object, type ObjectValue, type PullRequest, + requirements, string, } from "./github"; +import { LAND_CHECK } from "./land"; import { classifyCheck } from "./status"; const POLL = 5 * 60_000; @@ -106,6 +108,12 @@ interface Cursor { checks: { since: number; reported: string[] }; } +/** The check names and workflow files a base branch requires. */ +interface Needs { + names: Set; + workflows: string[]; +} + /** One check run or commit status on the head, with its result once it has one. */ interface Check { name: string; @@ -118,14 +126,6 @@ interface Check { result?: { outcome: "passed" | "failed"; point: string; time: number }; } -/** What the base branch requires, read from branch protection and rulesets. */ -interface Requirements { - /** Required check names. */ - names: Set; - /** Workflow files that must pass as a whole, so each of their jobs is required. */ - workflows: string[]; -} - /** Wait until activity, returning plain output lines. Network and time are injected. */ export async function waitForActivity( pr: PullRequest, @@ -358,38 +358,20 @@ async function watch( }; /** * What the base branch requires, read once per wait. Only a requirement says whether a missing - * check has yet to start, or whether the repository requires none. + * check has yet to start, or whether the repository requires none. The gate's own check is never + * on a PR head, so a gated base waits only for the rest. */ - const requirements = new Map(); - const required = async (branch: string): Promise => { - const known = requirements.get(branch); - if (known) return known; - const ref = encodeURIComponent(branch); - const protection = object( - object(await (await github.request(`${base}/branches/${ref}`)).json()).protection ?? {}, - ); - const legacy = object(protection.required_status_checks ?? {}); - // Classic protection may list only `contexts`, without the newer `checks`. - const contexts = - legacy.checks === undefined - ? (Array.isArray(legacy.contexts) ? legacy.contexts : []).map(string) - : array(legacy.checks).map((row) => string(row.context)); - const rules = await github.all(`${base}/rules/branches/${ref}?per_page=100`); - const found: Requirements = { - names: new Set([ - ...(legacy.enforcement_level === "off" ? [] : contexts), - ...rules - .filter((rule) => rule.type === "required_status_checks") - .flatMap((rule) => array(object(rule.parameters).required_status_checks)) - .map((row) => string(row.context)), - ]), - workflows: rules - .filter((rule) => rule.type === "workflows") - .flatMap((rule) => array(object(rule.parameters).workflows)) - .map((row) => string(row.path)), + const known = new Map(); + const required = async (branch: string): Promise => { + const cached = known.get(branch); + if (cached) return cached; + const found = await requirements(github, pr, branch); + const needs = { + names: new Set(found.checks.map((check) => check.name).filter((name) => name !== LAND_CHECK)), + workflows: found.workflows, }; - requirements.set(branch, found); - return found; + known.set(branch, needs); + return needs; }; const timeline = pager(`${base}/issues/${pr.number}/timeline?per_page=${PAGE}`); @@ -543,13 +525,7 @@ function check(row: ObjectValue): Check { * reported once per head when reached at or after the starting point. A base branch that requires * no checks requires them all. */ -function checkActivities( - head: string, - all: Check[], - needs: Requirements, - state: Cursor["checks"], - prUrl: string, -): Activity[] { +function checkActivities(head: string, all: Check[], needs: Needs, state: Cursor["checks"], prUrl: string): Activity[] { const inWorkflow = (entry: Check) => entry.workflow !== undefined && needs.workflows.includes(entry.workflow); const required = needs.names.size || needs.workflows.length ? all.filter((entry) => entry.required || inWorkflow(entry)) : all; diff --git a/test/hold.test.ts b/test/hold.test.ts index 85697f19..b6b67199 100644 --- a/test/hold.test.ts +++ b/test/hold.test.ts @@ -26,6 +26,8 @@ class FakePull { head = HEAD; mergeState = "BLOCKED"; queue = false; + /** Check names the base branch's rulesets require. */ + required: string[] = []; autoMerge: ObjectValue | null = null; queueEntry: ObjectValue | null = null; labels: string[] = []; @@ -82,6 +84,16 @@ class FakePull { this.labels = this.labels.filter((label) => label !== name); return json(200, []); } + if (url.pathname === "/repos/example/project/branches/main") return json(200, { protection: {} }); + if (url.pathname === "/repos/example/project/rules/branches/main") { + const checks = this.required.map((context) => ({ context })); + return json( + 200, + checks.length + ? [{ type: "required_status_checks", parameters: { required_status_checks: checks } }] + : [], + ); + } if (url.pathname === "/repos/example/project/pulls/7" && method === "PATCH") { this.body = String(body.body); this.writes.push("edit body"); @@ -94,7 +106,9 @@ class FakePull { if (query.startsWith("mutation")) { const name = /\{\s*(\w+)\(/.exec(query)![1]; if (variables.id !== "PR_7") throw new Error("mutation on the wrong PR"); - this.writes.push(variables.method ? `${name} ${variables.sha} ${variables.method}` : name); + const method = variables.method ?? /mergeMethod: (\w+)/.exec(query)?.[1]; + const body = variables.body ? ` ${String(variables.body)}` : ""; + this.writes.push(method ? `${name} ${variables.sha} ${method}${body}` : name); if (name === "markPullRequestReadyForReview") this.draft = false; if (name === "mergePullRequest") this.state = "MERGED"; if (name === "enablePullRequestAutoMerge") { @@ -112,6 +126,7 @@ class FakePull { state: this.state, isDraft: this.draft, body: this.body, + baseRefName: "main", headRefOid: this.head, mergeStateStatus: this.mergeState, isMergeQueueEnabled: this.queue, @@ -265,6 +280,16 @@ describe("merge --land", () => { expect(warnings).toEqual([`${URL_} lands through a merge queue, which applies its own merge method`]); }); + test("queues a gated base's PR for quest land: squash auto-merge pinning the head", async () => { + const pull = new FakePull(); + pull.required = ["test", "quest land"]; + const warnings: string[] = []; + const { token } = await take(PR, "merge", undefined, pull.deps()); + await land(PR, token, HEAD, "merge", pull.deps(warnings)); + expect(pull.writes.at(-1)).toBe(`enablePullRequestAutoMerge ${HEAD} SQUASH Quest-Land-Head: ${HEAD}`); + expect(warnings).toEqual([`${URL_} lands through quest land, which squashes`]); + }); + test("refuses a head that moved", async () => { const pull = new FakePull(); const { token } = await take(PR, "merge", undefined, pull.deps()); diff --git a/test/land.test.ts b/test/land.test.ts new file mode 100644 index 00000000..a301c8ae --- /dev/null +++ b/test/land.test.ts @@ -0,0 +1,741 @@ +// `quest land` against the shared fake GitHub and local git repositories +// standing in for github.com, so every push and lease is real git. + +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { beforeEach, describe, expect, test } from "vitest"; + +import { ACTIONS_APP, type Pull, type World, fakeGitHub } from "../cloudflare/dev/github"; +import type { GitHubDependencies } from "../src/cli/github"; +import { + type Plan, + UPHELD, + UPHELD_CLASSIC, + checkQueue, + parsePlan, + parseResult, + pin, + pushQueue, + resolveQueue, +} from "../src/cli/land"; +import { type Output, main } from "../src/cli/main"; +import { tempDir } from "./fixture"; + +const bin = fileURLToPath(new URL("../src/cli/bin.ts", import.meta.url)); +const bunfig = fileURLToPath(new URL("../bunfig.toml", import.meta.url)); +const REPOSITORY = { owner: "upstream", repo: "project" }; +const RUN = "https://github.com/upstream/project/actions/runs/9"; +/** The check: a file named `broken` fails it, unless a file named `fixed` is there too. */ +const CHECK = ["sh", "-c", "test ! -e broken || test -e fixed"]; + +const PATH = process.env.PATH; +/** Each directory `quest` ran in, one per line. */ +let ran = ""; +beforeEach(() => { + // The merge driver runs `quest resolve` from PATH, as in CI. + const tools = tempDir(); + ran = join(tools, "ran"); + writeFileSync(join(tools, "quest"), `#!/bin/sh\npwd >> '${ran}'\nexec bun --config='${bunfig}' '${bin}' "$@"\n`); + chmodSync(join(tools, "quest"), 0o755); + process.env.PATH = `${tools}:${PATH}`; +}); + +function lines(text: string): string[] { + return text.split("\n").filter((line) => line !== ""); +} + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); +} + +const EPIC = (entries: string[]) => + `# Epic\n\n## Goal\n\nAn epic.\n\n## Required\n\n${entries.map((entry) => `- [${entry}](/quest/m0/${entry}.md)`).join("\n")}\n`; + +/** + * github.com as local bare repositories: `upstream/project`, a fork for each + * contributor, and a clone per CI job that reaches them through + * `https://github.com/` URLs. + */ +class Forge { + readonly dir = tempDir(); + readonly hub = join(this.dir, "github"); + readonly origin = join(this.hub, "upstream", "project.git"); + private readonly work = join(this.dir, "work"); + /** PR number to its head, as GitHub's `refs/pull//head`. */ + readonly world: World; + private nextPull = 1; + + constructor() { + mkdirSync(this.origin, { recursive: true }); + git(this.origin, "init", "--quiet", "--bare", "--initial-branch=main"); + git(this.dir, "init", "--quiet", "--initial-branch=main", this.work); + this.configure(this.work); + git(this.work, "remote", "add", "origin", "https://github.com/upstream/project.git"); + this.write({ + ".gitattributes": "quest/** merge=quest\n", + "quest/README.md": "# Quests\n\n## Goal\n\nRoot.\n\n## Required\n\n- [M0](/quest/m0/README.md)\n", + "quest/m0/README.md": EPIC(["a"]), + "quest/m0/a.md": "# [S] A\n\n## Goal\n\nA.\n", + "code.txt": "one\ntwo\nthree\n", + }); + git(this.work, "add", "."); + git(this.work, "commit", "--quiet", "-m", "base"); + git(this.work, "push", "--quiet", "origin", "HEAD:refs/heads/main"); + this.world = { + app: { id: 1, slug: "gate" }, + installation: 5, + forkInstallation: 6, + forks: "service-forks", + users: [{ id: 1, login: "maintainer", name: "Maintainer", email: "maintainer@example.test" }], + repos: [ + { + id: 10, + owner: "upstream", + name: "project", + maintainers: ["maintainer"], + files: [], + issues: [], + pulls: [], + branches: { main: this.main() }, + fork: {}, + commits: {}, + trees: {}, + required: ["quest land", "test"], + }, + ], + }; + } + + get repo() { + return this.world.repos[0]; + } + + /** Route github.com URLs to the local bare repositories, and give commits an identity. */ + configure(dir: string): void { + git(dir, "config", `url.${this.hub}/.insteadOf`, "https://github.com/"); + git(dir, "config", "user.name", "Gate"); + git(dir, "config", "user.email", "gate@example.test"); + } + + /** A fresh clone of upstream, as a CI job checks out. */ + clone(): string { + const dir = join(this.dir, `clone-${Math.random().toString(36).slice(2)}`); + git(this.dir, "clone", "--quiet", this.origin, dir); + this.configure(dir); + git(dir, "remote", "set-url", "origin", "https://github.com/upstream/project.git"); + return dir; + } + + main(): string { + return git(this.origin, "rev-parse", "refs/heads/main"); + } + + ref(repository: string, ref: string): string { + return git(join(this.hub, `${repository}.git`), "rev-parse", ref); + } + + private write(files: Record): void { + for (const [path, content] of Object.entries(files)) { + mkdirSync(join(this.work, path, ".."), { recursive: true }); + writeFileSync(join(this.work, path), content); + } + } + + /** + * Open a PR whose branch writes `files` on the current + * base, and queue it as `quest merge --land` does. A `contributor` opens it + * from their own fork. + */ + pull( + title: string, + files: Record, + options: { contributor?: string; edits?: boolean; pull?: Partial } = {}, + ): Pull { + const number = this.nextPull++; + const branch = `topic-${number}`; + git(this.work, "checkout", "--quiet", "-B", branch, this.main()); + this.write(files); + git(this.work, "add", "--all"); + git( + this.work, + "-c", + "user.name=Author", + "-c", + "user.email=author@example.test", + "commit", + "--quiet", + "--allow-empty", + "-m", + title, + "-m", + "Co-Authored-By: Helper ", + ); + const head = git(this.work, "rev-parse", "HEAD"); + if (options.contributor) { + const fork = join(this.hub, options.contributor, "project.git"); + mkdirSync(fork, { recursive: true }); + spawnSync("git", ["init", "--quiet", "--bare", fork]); + git(this.work, "push", "--quiet", fork, `${head}:refs/heads/${branch}`); + } else { + git(this.work, "push", "--quiet", "origin", `${head}:refs/heads/${branch}`); + } + // GitHub keeps every PR's head in the upstream, forks' included. + git(this.work, "push", "--quiet", "origin", `${head}:refs/pull/${number}/head`); + const pull: Pull = { + number, + title, + author: "author", + branch, + head, + checks: "SUCCESS", + runs: [{ name: "test", conclusion: "SUCCESS", url: "https://ci.test/1" }], + review: null, + labels: ["quest:merge"], + landing: true, + autoMerge: { enabledAt: `2026-10-26T00:00:0${number}Z`, commitBody: pin(head) }, + contributor: options.contributor, + maintainerCanModify: options.edits, + ...options.pull, + }; + this.repo.pulls.push(pull); + return pull; + } + + /** + * GitHub catching up on pushes, before it answers a request: each open PR's + * head is its branch's tip, unless `lag` requests remain before it syncs. + */ + lag = 0; + private sync(): void { + if (this.lag > 0) { + this.lag--; + return; + } + for (const pull of this.repo.pulls) { + if (pull.closed) continue; + const holder = pull.contributor ? `${pull.contributor}/project` : "upstream/project"; + const tip = spawnSync( + "git", + ["-C", join(this.hub, `${holder}.git`), "rev-parse", `refs/heads/${pull.branch}`], + { + encoding: "utf8", + }, + ); + if (tip.status === 0) pull.head = tip.stdout.trim(); + } + } + + /** GitHub's view after a landing: a PR whose head it had synced and that reached main merged. */ + settle(): void { + const main = this.main(); + this.repo.branches.main = main; + for (const pull of this.repo.pulls) { + if ( + !pull.closed && + spawnSync("git", ["-C", this.origin, "merge-base", "--is-ancestor", pull.head!, main]).status === 0 + ) { + pull.closed = pull.merged = true; + } + } + } + + /** A token for the gate, with `permissions` beyond reading and writing the queue. */ + async deps(permissions: Record = {}): Promise { + const fetch = fakeGitHub(this.world); + const minted = await fetch("https://api.github.com/app/installations/5/access_tokens", { + method: "POST", + headers: { Authorization: "Bearer a.b.c" }, + body: JSON.stringify({ + repositories: ["project"], + permissions: { + contents: "write", + pull_requests: "write", + issues: "write", + metadata: "read", + ...permissions, + }, + }), + }); + const { token } = (await minted.json()) as { token: string }; + // The fake refuses a method call, as workerd's fetch does; the CLI calls it as one. + let clock = 0; + return { + fetch: (input, init) => { + this.sync(); + return fetch(input, init); + }, + now: () => clock, + sleep: async (milliseconds) => { + clock += milliseconds; + }, + token, + warn: () => undefined, + }; + } + + /** One run of the gate: resolve, check in one clone, and push from another. */ + async round(): Promise<{ plan: Plan; checked: number[][]; landing: string[] }> { + const deps = await this.deps(); + const plan = await resolveQueue(REPOSITORY, undefined, deps); + const result = await checkQueue(this.clone(), plan, CHECK, () => undefined); + const landing = await pushQueue(this.clone(), plan, result, RUN, deps); + this.settle(); + return { plan, checked: result.checks.map((tested) => tested.numbers), landing }; + } + + comment(number: number): string | undefined { + return this.repo.pulls.find((pull) => pull.number === number)?.comments?.at(-1)?.body; + } +} + +// The fake `quest` on PATH is a shell script, as the merge driver tests' is. +describe.skipIf(process.platform === "win32")("quest land", () => { + test("lands the queue as one chain of squash commits, each PR's branch at its own", async () => { + const forge = new Forge(); + const base = forge.main(); + const one = forge.pull("Add b", { "b.txt": "b\n" }); + const two = forge.pull("Add c", { "c.txt": "c\n" }); + const { plan, checked, landing } = await forge.round(); + expect(plan.queue.map((entry) => entry.number)).toEqual([1, 2]); + expect(checked).toEqual([[1, 2]]); + const tip = forge.main(); + expect(landing).toEqual([`landed 1 ${forge.ref("upstream/project", "topic-1")}`, `landed 2 ${tip}`]); + expect(git(forge.origin, "rev-parse", `${tip}~2`)).toBe(base); + expect(forge.ref("upstream/project", "topic-2")).toBe(tip); + expect(git(forge.origin, "log", "--format=%an <%ae>%n%B", "-1", "topic-1")).toBe( + "Author \nAdd b (#1)\n\n* Add b\n\nCo-authored-by: Helper ", + ); + expect([one.merged, two.merged]).toEqual([true, true]); + }); + + test("resolves quest files with the merge driver and ejects a conflict elsewhere", async () => { + const forge = new Forge(); + forge.pull("Add b quest", { + "quest/m0/README.md": EPIC(["a", "b"]), + "quest/m0/b.md": "# [S] B\n\n## Goal\n\nB.\n", + }); + forge.pull("Add c quest", { + "quest/m0/README.md": EPIC(["a", "c"]), + "quest/m0/c.md": "# [S] C\n\n## Goal\n\nC.\n", + }); + forge.pull("Two is 2", { "code.txt": "one\n2\nthree\n" }); + const clash = forge.pull("Two is II", { "code.txt": "one\nII\nthree\n" }); + const { checked, landing } = await forge.round(); + expect(checked).toEqual([[1, 2, 3]]); + expect(landing.slice(0, 1)).toEqual(["ejected 4 merge_conflict"]); + expect(landing.slice(1).map((line) => line.split(" ")[1])).toEqual(["1", "2", "3"]); + expect(git(forge.origin, "show", "main:quest/m0/README.md")).toBe(EPIC(["a", "b", "c"]).trim()); + expect(clash.landing).toBe(false); + expect(clash.labels).toEqual([]); + expect(forge.comment(4)).toContain("in code.txt"); + expect(forge.comment(4)).toContain(`([run](${RUN}))`); + expect(forge.comment(4)).toMatch(/$/); + }); + + test("bisects by prefix: a passing prefix lands at once, and a PR failing alone is ejected", async () => { + const forge = new Forge(); + forge.pull("One", { "1.txt": "1\n" }); + forge.pull("Two", { "2.txt": "2\n" }); + forge.pull("Break", { broken: "\n" }); + forge.pull("Four", { "4.txt": "4\n" }); + const first = await forge.round(); + expect(first.checked).toEqual([ + [1, 2, 3, 4], + [1, 2], + ]); + expect(first.landing.map((line) => line.split(" ").slice(0, 2).join(" "))).toEqual(["landed 1", "landed 2"]); + + const second = await forge.round(); + expect(second.plan.queue.map((entry) => entry.number)).toEqual([3, 4]); + expect(second.checked).toEqual([[3, 4], [3]]); + expect(second.landing).toEqual(["ejected 3 failed_checks"]); + expect(forge.comment(3)).toContain("failed with it alone on the base"); + + const third = await forge.round(); + expect(third.checked).toEqual([[4]]); + expect(third.landing).toEqual([`landed 4 ${forge.main()}`]); + expect(git(forge.origin, "ls-tree", "--name-only", "main")).not.toContain("broken"); + }); + + test("a moved head stops the landing, and an earlier prefix never checked alone does not land", async () => { + const forge = new Forge(); + const base = forge.main(); + // A fails alone, but B fixes it, so only A+B is checked and it passes. + forge.pull("A", { broken: "\n" }); + const b = forge.pull("B", { fixed: "\n" }); + const deps = await forge.deps(); + const plan = await resolveQueue(REPOSITORY, undefined, deps); + const result = await checkQueue(forge.clone(), plan, CHECK, () => undefined); + expect(result.checks.map((tested) => [tested.numbers, tested.passed])).toEqual([[[1, 2], true]]); + const moved = git( + forge.origin, + "-c", + "user.name=Late", + "-c", + "user.email=late@example.test", + "commit-tree", + "-p", + b.head!, + "-m", + "late", + `${b.head}^{tree}`, + ); + git(forge.origin, "update-ref", "refs/heads/topic-2", moved); + expect(await pushQueue(forge.clone(), plan, result, undefined, deps)).toEqual(["ejected 2 head_moved"]); + expect(forge.main()).toBe(base); + expect(forge.ref("upstream/project", "topic-1")).toBe(forge.repo.pulls[0].head); + }); + + test("moves a fork's branch when it allows maintainer edits, and ejects one that does not", async () => { + const forge = new Forge(); + const shut = forge.pull("Shut", { "s.txt": "s\n" }, { contributor: "closed" }); + forge.pull("Open", { "o.txt": "o\n" }, { contributor: "friend", edits: true }); + const { plan, landing } = await forge.round(); + expect(plan.eject.map((ejected) => [ejected.number, ejected.reason])).toEqual([[1, "no_maintainer_edits"]]); + expect(landing).toEqual(["ejected 1 no_maintainer_edits", `landed 2 ${forge.main()}`]); + expect(forge.ref("friend/project", "topic-2")).toBe(forge.main()); + expect(forge.ref("closed/project", "topic-1")).toBe(shut.head); + }); + + test("a failed landing push moves fork branches back, so they stay queued", async () => { + const forge = new Forge(); + const open = forge.pull("Open", { "o.txt": "o\n" }, { contributor: "friend", edits: true }); + const head = open.head; + const base = forge.main(); + const hook = join(forge.origin, "hooks", "pre-receive"); + writeFileSync(hook, "#!/bin/sh\necho 'main is frozen' >&2\nexit 1\n"); + chmodSync(hook, 0o755); + const deps = await forge.deps(); + const plan = await resolveQueue(REPOSITORY, undefined, deps); + const result = await checkQueue(forge.clone(), plan, CHECK, () => undefined); + await expect(pushQueue(forge.clone(), plan, result, undefined, deps)).rejects.toThrow(/main is frozen/); + expect(forge.main()).toBe(base); + expect(forge.ref("friend/project", "topic-1")).toBe(head); + expect(open.landing).toBe(true); + }); + + test("pushes the base only once GitHub shows every moved head, so each PR merges", async () => { + const forge = new Forge(); + const one = forge.pull("Add b", { "b.txt": "b\n" }); + const two = forge.pull("Add c", { "c.txt": "c\n" }); + const deps = await forge.deps(); + const plan = await resolveQueue(REPOSITORY, undefined, deps); + const result = await checkQueue(forge.clone(), plan, CHECK, () => undefined); + forge.lag = 3; + const landing = await pushQueue(forge.clone(), plan, result, RUN, deps); + forge.settle(); + expect(landing.map((line) => line.split(" ")[0])).toEqual(["landed", "landed"]); + expect([one.merged, two.merged]).toEqual([true, true]); + }); + + test("lands nothing and moves heads back when GitHub never shows them", async () => { + const forge = new Forge(); + const one = forge.pull("Add b", { "b.txt": "b\n" }); + const head = one.head; + const base = forge.main(); + const deps = await forge.deps(); + const plan = await resolveQueue(REPOSITORY, undefined, deps); + const result = await checkQueue(forge.clone(), plan, CHECK, () => undefined); + forge.lag = Infinity; + await expect(pushQueue(forge.clone(), plan, result, RUN, deps)).rejects.toThrow( + "GitHub did not show the moved heads", + ); + expect(forge.main()).toBe(base); + expect(forge.ref("upstream/project", "topic-1")).toBe(head); + expect(one.landing).toBe(true); + }); + + test("runs the merge driver outside the chain, where a queued PR's runtime config cannot reach it", async () => { + const forge = new Forge(); + forge.pull("Preload", { + "bunfig.toml": 'preload = ["./evil.ts"]\n', + ".env": "BUN_OPTIONS=--preload=./evil.ts\n", + "evil.ts": "throw new Error('PR code ran');\n", + "quest/m0/README.md": EPIC(["a", "b"]), + "quest/m0/b.md": "# [S] B\n\n## Goal\n\nB.\n", + }); + forge.pull("Add c quest", { + "quest/m0/README.md": EPIC(["a", "c"]), + "quest/m0/c.md": "# [S] C\n\n## Goal\n\nC.\n", + }); + const { landing } = await forge.round(); + expect(landing.map((line) => line.split(" ").slice(0, 2).join(" "))).toEqual(["landed 1", "landed 2"]); + const dirs = lines(readFileSync(ran, "utf8")).filter((dir) => dir.endsWith(".git")); + // Once in check and once in push, each from its clone's git directory. + expect(dirs).toHaveLength(2); + expect(lines(readFileSync(ran, "utf8")).some((dir) => dir.includes("quest-land"))).toBe(false); + }); + + test("lands only PRs still queued as they were when checked", async () => { + const forge = new Forge(); + const one = forge.pull("Add b", { "b.txt": "b\n" }); + const two = forge.pull("Add c", { "c.txt": "c\n" }); + const base = forge.main(); + const deps = await forge.deps(); + const plan = await resolveQueue(REPOSITORY, undefined, deps); + const result = await checkQueue(forge.clone(), plan, CHECK, () => undefined); + one.review = "CHANGES_REQUESTED"; + two.landing = false; + const landing = await pushQueue(forge.clone(), plan, result, RUN, deps); + forge.settle(); + // Only the whole chain was checked, so nothing before #1 can land either. + expect(landing).toEqual(["ejected 1 changes_requested"]); + expect(forge.main()).toBe(base); + expect([one.merged, two.merged]).toEqual([undefined, undefined]); + }); + + test("reports a PR withdrawn while checked, so the rest gets another run", async () => { + const forge = new Forge(); + const one = forge.pull("Add b", { "b.txt": "b\n" }); + const two = forge.pull("Add c", { "c.txt": "c\n" }); + const base = forge.main(); + const deps = await forge.deps(); + const plan = await resolveQueue(REPOSITORY, undefined, deps); + const result = await checkQueue(forge.clone(), plan, CHECK, () => undefined); + one.landing = false; + expect(await pushQueue(forge.clone(), plan, result, RUN, deps)).toEqual(["withdrawn 1"]); + forge.settle(); + expect(forge.main()).toBe(base); + expect([one.merged, two.merged]).toEqual([undefined, undefined]); + const next = await resolveQueue(REPOSITORY, undefined, deps); + expect(next.queue.map((entry) => entry.number)).toEqual([2]); + }); + + test("moves heads back when reading them fails", async () => { + const forge = new Forge(); + const one = forge.pull("Add b", { "b.txt": "b\n" }); + const head = one.head; + const base = forge.main(); + const deps = await forge.deps(); + const plan = await resolveQueue(REPOSITORY, undefined, deps); + const result = await checkQueue(forge.clone(), plan, CHECK, () => undefined); + const offline: GitHubDependencies = { + ...deps, + fetch: (input, init) => + String(init?.body).includes("{ headRefOid }") + ? Promise.reject(new Error("offline")) + : deps.fetch(input, init), + }; + await expect(pushQueue(forge.clone(), plan, result, RUN, offline)).rejects.toThrow("offline"); + expect(forge.main()).toBe(base); + expect(forge.ref("upstream/project", "topic-1")).toBe(head); + }); + + test("ejects auto-merging PRs that are not queued or not ready, in enqueue order", async () => { + const forge = new Forge(); + forge.pull( + "Unpinned", + { "1.txt": "1\n" }, + { pull: { autoMerge: { enabledAt: "2026-10-26T00:00:09Z", commitBody: "" } } }, + ); + const moved = forge.pull("Moved", { "2.txt": "2\n" }); + moved.autoMerge = { ...moved.autoMerge!, commitBody: pin("f".repeat(40)) }; + forge.pull("Unreviewed", { "3.txt": "3\n" }, { pull: { review: "REVIEW_REQUIRED" } }); + forge.pull("Red", { "4.txt": "4\n" }, { pull: { runs: [{ name: "test", conclusion: "FAILURE", url: "u" }] } }); + forge.pull("Running", { "5.txt": "5\n" }, { pull: { runs: [] } }); + forge.pull("Ready", { "6.txt": "6\n" }); + forge.pull("Not landing", { "7.txt": "7\n" }, { pull: { landing: false } }); + const plan = await resolveQueue(REPOSITORY, "main", await forge.deps()); + expect(plan.queue.map((entry) => entry.number)).toEqual([6]); + expect(plan.eject.map((ejected) => [ejected.number, ejected.reason])).toEqual([ + [2, "head_moved"], + [3, "review_required"], + [4, "failed_checks"], + [5, "checks_pending"], + [1, "not_pinned"], + ]); + }); + + test("refuses a base that does not require the quest land check", async () => { + const forge = new Forge(); + forge.repo.required = ["test"]; + await expect(resolveQueue(REPOSITORY, undefined, await forge.deps())).rejects.toThrow( + "does not require the quest land check", + ); + }); + + test("reads every page of a head's checks", async () => { + const forge = new Forge(); + const other = (count: number) => + Array.from({ length: count }, (_, index) => ({ name: `job ${index}`, conclusion: "SUCCESS", url: "u" })); + forge.pull( + "Late pass", + { "1.txt": "1\n" }, + { pull: { runs: [...other(250), { name: "test", conclusion: "SUCCESS", url: "u" }] } }, + ); + forge.pull( + "Late fail", + { "2.txt": "2\n" }, + { + pull: { + runs: [ + { name: "test", conclusion: "SUCCESS", url: "u" }, + ...other(150), + { name: "test", conclusion: "FAILURE", url: "u" }, + ], + }, + }, + ); + forge.pull("Missing", { "3.txt": "3\n" }, { pull: { runs: other(201) } }); + const plan = await resolveQueue(REPOSITORY, "main", await forge.deps()); + expect(plan.queue.map((entry) => entry.number)).toEqual([1]); + expect(plan.eject.map((ejected) => [ejected.number, ejected.reason])).toEqual([ + [2, "failed_checks"], + [3, "checks_pending"], + ]); + }); + + test("counts a required check only from the app its rule names", async () => { + const forge = new Forge(); + forge.repo.rules = [ + { + type: "required_status_checks", + ruleset_id: 3, + parameters: { required_status_checks: [{ context: "lint", integration_id: 99 }] }, + }, + ]; + const lint = (run: Partial[number]>) => ({ + pull: { + runs: [ + { name: "test", conclusion: "SUCCESS", url: "u" }, + { name: "lint", conclusion: "SUCCESS", url: "u", ...run }, + ], + }, + }); + forge.pull("Pinned app", { "1.txt": "1\n" }, lint({ app: 99 })); + forge.pull("Other app", { "2.txt": "2\n" }, lint({ app: ACTIONS_APP })); + forge.pull("Status", { "3.txt": "3\n" }, lint({ context: true })); + const plan = await resolveQueue(REPOSITORY, "main", await forge.deps()); + expect(plan.queue.map((entry) => entry.number)).toEqual([1]); + expect(plan.eject.map((ejected) => [ejected.number, ejected.reason])).toEqual([ + [2, "checks_pending"], + [3, "checks_pending"], + ]); + expect(plan.eject[0].detail).toContain("lint (from app 99)"); + }); + + test("refuses ruleset rules it does not uphold before reading the queue", async () => { + const forge = new Forge(); + forge.pull("Ready", { "1.txt": "1\n" }); + forge.repo.rules = [ + { type: "deletion", ruleset_id: 1, parameters: null }, + { type: "non_fast_forward", ruleset_id: 1, parameters: null }, + { + type: "pull_request", + ruleset_id: 2, + parameters: { required_approving_review_count: 1, require_code_owner_review: true }, + }, + ]; + expect((await resolveQueue(REPOSITORY, "main", await forge.deps())).queue).toHaveLength(1); + forge.repo.rules.push( + { + type: "workflows", + ruleset_id: 4, + parameters: { workflows: [{ path: ".github/workflows/ci.yml", repository_id: 10 }] }, + }, + { + type: "pull_request", + ruleset_id: 5, + parameters: { required_review_thread_resolution: true, allowed_merge_methods: ["merge"] }, + }, + ); + const refused = resolveQueue(REPOSITORY, "main", await forge.deps()); + await expect(refused).rejects.toThrow( + "workflows from ruleset 4; pull_request (allowed_merge_methods without squash) from ruleset 5", + ); + await expect(refused).rejects.toThrow(`It upholds only ${Object.keys(UPHELD).join(", ")}`); + expect(forge.repo.pulls[0].landing).toBe(true); + }); + + test("enforces classic protection's checks and conversation resolution", async () => { + const forge = new Forge(); + forge.repo.protection = { + url: "https://api.github.com/repos/upstream/project/branches/main/protection", + required_status_checks: { strict: true, contexts: ["lint"], checks: [{ context: "lint", app_id: 99 }] }, + required_pull_request_reviews: { + dismiss_stale_reviews: true, + require_code_owner_reviews: true, + required_approving_review_count: 1, + require_last_push_approval: false, + }, + required_signatures: { enabled: false }, + enforce_admins: { enabled: false }, + required_linear_history: { enabled: true }, + required_conversation_resolution: { enabled: true }, + lock_branch: { enabled: false }, + }; + const runs = (lint: boolean) => [ + { name: "test", conclusion: "SUCCESS", url: "u" }, + ...(lint ? [{ name: "lint", conclusion: "SUCCESS", url: "u", app: 99 }] : []), + ]; + const thread = (resolved: boolean) => ({ path: "1.txt", line: 1, resolved, comments: [] }); + forge.pull("Ready", { "1.txt": "1\n" }, { pull: { runs: runs(true), threads: [thread(true)] } }); + forge.pull("Unchecked", { "2.txt": "2\n" }, { pull: { runs: runs(false) } }); + forge.pull("Unresolved", { "3.txt": "3\n" }, { pull: { runs: runs(true), threads: [thread(false)] } }); + forge.pull("Unreviewed", { "4.txt": "4\n" }, { pull: { runs: runs(true), review: "REVIEW_REQUIRED" } }); + const plan = await resolveQueue(REPOSITORY, "main", await forge.deps({ administration: "read" })); + expect(plan.queue.map((entry) => entry.number)).toEqual([1]); + expect(plan.eject.map((ejected) => [ejected.number, ejected.reason])).toEqual([ + [2, "checks_pending"], + [3, "unresolved_conversations"], + [4, "review_required"], + ]); + }); + + test("refuses classic protection settings it does not uphold, or cannot read", async () => { + const forge = new Forge(); + forge.pull("Ready", { "1.txt": "1\n" }); + forge.repo.protection = { + required_pull_request_reviews: { required_approving_review_count: 1, require_last_push_approval: true }, + required_signatures: { enabled: true }, + enforce_admins: { enabled: true }, + restrictions: { users: [], teams: [], apps: [] }, + allow_force_pushes: { enabled: true }, + }; + await expect(resolveQueue(REPOSITORY, "main", await forge.deps())).rejects.toThrow( + 'needs administration read (a GitHub App\'s "Administration: read", or a token with admin rights): GitHub HTTP 403', + ); + const refused = resolveQueue(REPOSITORY, "main", await forge.deps({ administration: "read" })); + await expect(refused).rejects.toThrow( + "required_pull_request_reviews (require_last_push_approval), required_signatures, enforce_admins, restrictions.", + ); + await expect(refused).rejects.toThrow(`It upholds only ${Object.keys(UPHELD_CLASSIC).join(", ")}`); + expect(forge.repo.pulls[0].landing).toBe(true); + }); + + test("refuses a check result for any other chain", async () => { + const forge = new Forge(); + forge.pull("One", { "1.txt": "1\n" }); + const deps = await forge.deps(); + const plan = await resolveQueue(REPOSITORY, undefined, deps); + const result = await checkQueue(forge.clone(), plan, CHECK, () => undefined); + const forged = { ...result, checks: [{ ...result.checks[0], tree: "0".repeat(40) }] }; + await expect(pushQueue(forge.clone(), plan, forged, undefined, deps)).rejects.toThrow("tested another tree"); + await expect(pushQueue(forge.clone(), plan, { ...result, checks: [] }, undefined, deps)).rejects.toThrow( + "nothing was checked", + ); + expect(() => parseResult(JSON.stringify({ base: plan.base.sha, checks: [{ numbers: ["1"] }] }))).toThrow(); + expect(parsePlan(JSON.stringify(plan))).toEqual(plan); + }); + + test("check runs the command after -- and prints the result as JSON", async () => { + const forge = new Forge(); + forge.pull("One", { "1.txt": "1\n" }); + const plan = await resolveQueue(REPOSITORY, undefined, await forge.deps()); + const file = join(forge.dir, "plan.json"); + writeFileSync(file, JSON.stringify(plan)); + let stdout = ""; + let stderr = ""; + const out: Output = { stdout: (text) => void (stdout += text), stderr: (text) => void (stderr += text) }; + const clone = forge.clone(); + expect( + await main(["--root", clone, "land", "check", file, "--", "sh", "-c", "echo checking; exit 3"], out), + ).toBe(0); + const result = parseResult(stdout); + expect(result.checks.map((tested) => [tested.numbers, tested.passed])).toEqual([[[1], false]]); + expect(stderr).toContain("checking\n"); + }); +}); diff --git a/test/status.test.ts b/test/status.test.ts index ad404c01..f862d4b3 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -230,6 +230,32 @@ test("reports why the latest landing stopped, from a merge queue or auto-merge", ).toBe("landing-stopped manually_disabled kixelated 2026-10-07T12:01:00Z"); }); +test("reports quest land's ejection after the auto-merge it disabled, ignoring other comments", async () => { + const comment = (body: string, author: Node, authorAssociation = "NONE") => ({ + __typename: "IssueComment", + createdAt: "2026-10-07T12:02:00Z", + body, + authorAssociation, + author, + }); + const ejected = + "quest land removed this pull request from the queue: it conflicts.\n\n"; + const nodes = [ + { + __typename: "AutoMergeDisabledEvent", + createdAt: "2026-10-07T12:01:00Z", + reasonCode: "manually_disabled", + actor: { login: "gate[bot]" }, + }, + comment(ejected, { __typename: "Bot", login: "gate" }), + comment("Thanks!", { __typename: "User", login: "kixelated" }, "OWNER"), + comment(ejected, { __typename: "User", login: "drive-by" }), + ]; + const body = pull({ checks: null, reviews: page([]), threads: page([]), timelineItems: { nodes } }); + const status = await pullRequestStatus(pr, setup([body]).deps); + expect(status.landingStopped).toEqual({ reason: "merge_conflict", actor: "gate", at: "2026-10-07T12:02:00Z" }); +}); + test("long lists page only the connections that have more, keeping the first page's PR fields", async () => { const fixture = setup([ pull({ diff --git a/test/wait.test.ts b/test/wait.test.ts index f3d6ffbe..51b806a8 100644 --- a/test/wait.test.ts +++ b/test/wait.test.ts @@ -929,6 +929,16 @@ test("classic protection that lists only contexts still names required checks", expect(lines[0]).toMatch(/^2026-10-07T12:08:00Z checks:passed gate /); }); +test("a gated base's quest land check, which no head carries, never holds back checks:passed", async () => { + const fixture = setup([pull(), response([pushed()]), response([committed(A)])], [A], { + checks: [checks([{ name: "check", required: true, result: "SUCCESS", time: LATER }])], + ruled: ["check", "quest land"], + }); + const lines = await waitForActivity(pr, {}, fixture.deps); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch(/^2026-10-07T12:05:00Z checks:passed check /); +}); + test("optional checks never stand in for required checks that have not started", async () => { const fixture = setup( [pull(), response([pushed()]), response([committed(A)]), response([committed(A)]), response([committed(A)])],