diff --git a/README.md b/README.md index ee484e46..4670c6a3 100644 --- a/README.md +++ b/README.md @@ -1036,6 +1036,55 @@ cancel runs, `checks: write` for its check runs, `statuses: read`, and does. Its pushes ask for `contents: write`, and `workflows: write` for one that carries a workflow change. +### Pin bumps + +With `--bump `, repeatable, the watcher keeps each named +repository's flake input on the watched repository at the watched base, so +every push to the base reaches those repositories with no one touching them. +It needs `nix` on `PATH`. Once a pass sees the base at a commit a target does +not pin yet, it opens or updates that target's one `quest-bump` PR as the App: + +1. It reads the target's `flake.lock` on its default branch for the one root + input that locks the watched repository on GitHub; a target already at the + commit gets nothing, and an open bump PR there is closed. +2. In a fresh shallow checkout, which it refuses when a tracked symlink leaves + the checkout or dangles, since the tools write through symlinks, it drops a + revision from that input's URL in + `flake.nix`, so `flake.lock` alone pins it from then on, and locks the input + at the commit with `nix flake lock --override-input`. +3. It builds the watched repository's `quest` at that commit with `nix build`, + and runs its `quest init` and `quest check --fix` on the checkout with + `--root`, from a directory of its own, so the target's `bunfig.toml` never + loads. +4. It commits the result as moq-bot and lease-pushes it to `quest-bump`. An open + bump PR's head becomes the new commit's first parent, beside the target's + base, so the branch never rewinds; any other `quest-bump` branch, left by a + merged or closed PR, is replaced. Auto-merge on the open PR is disarmed + before the push. The PR body links the comparison and records the review as + skipped, since a pin bump changes no repository code and the target's CI is + the gate. +5. Findings `quest check --fix` cannot resolve leave the PR a draft with a + `## Waiting` item quoting them. Otherwise it lands like any PR there, through + `quest merge`'s path: into GitHub's merge queue, the target's own queue, or + auto-merge. A base that requires no check would merge at once, so there the + bump first waits for every check and workflow run on its head to pass, and + for every other app's check suite that ran a check to complete, then merges + directly + when the repository has no auto-merge, as on a private repository on GitHub + Free, whose rules GitHub does not serve. A failed check, or a landing + `quest merge` refuses, is commented on and left for a maintainer. + +From there the target's own gate and fixer take the PR, as any other; a newer +commit on the base replaces it with a fresh bump. `bump.json` in the state +directory records each target's last bump, so a restart repeats nothing and a +closed bump is not reopened for the same commit. A target's failure is logged +and retried on the next pass without holding up the others. Each target gets +its own tokens: `contents: write`, `pull_requests: write`, `merge_queues: +write`, and reads of `actions`, `administration`, `checks`, and `statuses` for its API +calls, and `workflows: write` for a push whose diff from the branch's old head +carries a workflow change. Nix reads GitHub with the watched repository's +`contents: read` token. + ## Gate expensive commands `quest gate [args...]` holds an expensive command until the host has diff --git a/quest/m0/pin-sync.md b/quest/m0/pin-sync.md index b94882c2..4753f526 100644 --- a/quest/m0/pin-sync.md +++ b/quest/m0/pin-sync.md @@ -1,58 +1,45 @@ -# [L] Repositories and hosts follow quest's main +# [S] Hosts run quest's pin bumps and upgrade on their own ## Goal -Every push to moq-dev/quest's `main` reaches each enrolled repository and -each host without a person or an agent touching it. The maintainer's host -opens or updates one pin-bump PR per repository, as moq-bot, and queues it. -kixelated/nixos is a target too, and its hosts apply its `main` on their -own, so the queue watcher and gate run the same quest within the hour. +Every push to moq-dev/quest's `main` reaches each repository that pins quest, +and each host, without a person or an agent touching it: the quest watcher on +the maintainer's host runs `quest land watch --bump` for every such +repository, kixelated/nixos included, and each host applies kixelated/nixos's +`main` through `system.autoUpgrade`, so the queue watchers run the quest +`main` has within the hour. ## Plan -Decided 2026-11-05 (user, /quest-plan, after the -[token waste](/quest/m0/token-waste/README.md) plan merged): "it's kind of -annoying to need to update every PR and repo." - -Measured that day: moq-dev/moq, studio, and rind pinned quest at -`4be3508`, rust2 at `dc8008b`, kixel.ai at `5790eea`, and kixelated/nixos at -`91714ab`; moq.pro and quest itself track `main` through their lockfiles. Each -dev shell puts its pinned `quest` first on `PATH`, so a skill or rule change -reaches a repository's agents only after a bump PR there. The NixOS hosts -change only on a manual `nixos-rebuild switch`. Machines' dotfiles now pull -themselves hourly ([kixelated/dotfiles#42](https://github.com/kixelated/dotfiles/pull/42)), -so this quest covers only the repositories and hosts. - -Decision prompts, chosen options marked ✅: - -- How updates reach repositories: ✅ the host bot bumps (rejected: - Dependabot with auto-merge, which lags a day and needs a write token in - each repository's workflows; unpinning agent content to a user-level - `quest`, which lets two versions disagree on the format). The nixos host - already holds moq-bot's App credentials and the enrolled repositories. -- Review of a bump PR: ✅ the bot records a `## Review` skip, since a pin - bump changes no repository code and each repository's CI, running - `quest check` with the new quest, is the gate (rejected: a Codex round per - bump). -- A format change: ✅ the bot runs `quest check --fix` with the new quest and - commits the result in the bump PR; a finding `--fix` cannot resolve leaves - the PR open with a `## Waiting` item (rejected: hand the failing PR to an - agent). -- Cadence: ✅ every push to `main`, updating the one open bump PR per - repository so several merges coalesce (rejected: tagged releases only). -- Hosts: ✅ kixelated/nixos is a bump target, and each host enables - `system.autoUpgrade` from its `main` (rejected: bump with manual deploys; - leave the hosts). - -Recommendations for the implementer: - -- Pin every repository the same way, a branch URL with the revision only in - `flake.lock`, so a bump is one `nix flake update` of the quest input. -- Reuse the queue watcher's App client, enrolled-repository list, and - enqueue path rather than a second daemon's. -- The kixelated/nixos half (the bump target and `system.autoUpgrade`) is a - PR there; check that an upgrade does not restart the queue watcher in the - middle of a fixer job. +The bump pass itself is done (README, "Pin bumps"). What remains is the host +configuration in kixelated/nixos, drafted on its `quest-pin-sync` branch: + +- `modules/quest-land.nix` passes `--bump` to the quest watcher for every other + land repository plus moq-dev/studio, moq-dev/kixel.ai, and kixelated/nixos, + and puts Nix on its `PATH`. +- `modules/base.nix` enables `system.autoUpgrade` hourly on both hosts from + `git+ssh://git@github.com/kixelated/nixos?ref=main#`, fetched with + each host's SSH host key as a read-only deploy key. +- Check and fixer containers already survive a switch + (`restartIfChanged = false`), and a watcher's detached fixer supervisor + outlives its daemon's restart. The batching gate stops its check with it, so + it is not restarted by a switch until moq.pro moves onto the watcher + ([moq.pro cutover](/quest/m0/queue-watcher/pro-cutover.md)). + +Once the bump pass is on quest's `main`, bump that branch's quest input to it +(the watcher rejects `--bump` before then), land it, and switch each host by +hand once; from then on the hosts upgrade themselves. + +## Waiting + +- Maintainer: add each host's `/etc/ssh/ssh_host_ed25519_key.pub` (nixos and + nixos2) to kixelated/nixos as a read-only deploy key, since only the + repository's owner can grant access to it; check that + `sudo systemctl start nixos-upgrade` succeeds on each host. +- Maintainer: install moq-bot on the kixelated account for kixelated/nixos, + with the permissions it has on moq-dev, since an App installation needs the + account owner's consent; check that the quest watcher's journal shows + `bump kixelated/nixos` rather than a failed token request. ## Related diff --git a/src/cli/github.ts b/src/cli/github.ts index d2889432..32e39a36 100644 --- a/src/cli/github.ts +++ b/src/cli/github.ts @@ -495,6 +495,23 @@ export interface Requirements { classic: boolean; } +/** + * Every rule a branch's active rulesets apply. GitHub Free offers no rulesets on a private repository and + * answers 403 with an upgrade notice, so none apply there; any other refusal fails. + */ +async function branchRules(github: GitHub, path: string): Promise { + const first = await github.request(path, { allow: [403] }); + if (first.status !== 403) { + const next = link(first, "next"); + return [...array(await first.json()), ...(next ? await github.all(next) : [])]; + } + const reason = explanation(await first.text()); + if (reason?.startsWith("Upgrade to GitHub Pro")) return []; + throw new Error( + `GitHub HTTP 403 for ${new URL(path, API).pathname}; ${reason ?? "check repository access and token permissions"}`, + ); +} + /** 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)}`; @@ -512,7 +529,7 @@ export async function requirements(github: GitHub, repository: Repository, branc 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 rules = await branchRules(github, `${base}/rules/branches/${ref}?per_page=100`); const checks = [ ...classic, ...rules diff --git a/src/cli/land-bump.ts b/src/cli/land-bump.ts new file mode 100644 index 00000000..4c057de9 --- /dev/null +++ b/src/cli/land-bump.ts @@ -0,0 +1,530 @@ +// Pin bumps: the source repository's watcher keeps each target repository's flake input on the source at the +// source's base, through one bot PR per target that lands like any other PR there. +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { isAbsolute, join, relative, sep } from "node:path"; +import { checksState } from "../github/checks"; +import { + array, + GitHub, + type GitHubDependencies, + object, + type ObjectValue, + type Repository, + requirements, + string, +} from "./github"; +import { land } from "./hold"; +import { commandEnvironment, contexts, git, type RemoteRead } from "./land"; +import { push, remoteHead } from "./land-head"; +import { Refusal } from "./start"; +import { excerpt, save } from "./land-serve"; + +/** The one branch each target's bump PR uses. */ +export const BUMP_BRANCH = "quest-bump"; + +/** A target's bump of the source commit `sha`, in `bump.json`, so a pass repeats nothing already done. */ +interface Bump { + sha: string; + pr?: number; + /** The head the bump pushed. */ + head?: string; + /** + * `done`: the target pins `sha`, or its PR is in the target's queue and the target's own gate and fixer + * take it from here. `waiting`: findings `quest check --fix` cannot resolve, left for a maintainer. + * `checking`: no check is required on the target's base, so the bump waits for every check on its + * head to pass before landing. `failed`: such a check failed, left for a maintainer. + */ + status: "done" | "waiting" | "checking" | "failed"; +} + +/** The commands a bump runs outside this process. */ +export interface BumpTools { + /** Lock `input` in the flake at `checkout` to `url`, with `token` reading GitHub. */ + lock(checkout: string, input: string, url: string, token: string): void; + /** The `quest` executable of the source at `sha`. */ + quest(sha: string, token: string): string; + /** Run `quest` with `args` on the repository at `checkout`. */ + run(quest: string, checkout: string, args: string[]): { status: number | null; output: string }; +} + +/** One target's GitHub access: API calls, pushes, and authenticated fetches, each with a token for that target alone. */ +export interface BumpConnection { + /** The target's Git URL. */ + remote: string; + deps: GitHubDependencies; + token: (workflows: boolean) => Promise; + read: RemoteRead; +} + +export interface BumpContext { + source: Repository; + targets: Repository[]; + state: string; + connect: (target: Repository) => BumpConnection; + /** A read-only token for the source, which the tools fetch from GitHub. */ + reader: () => Promise; + tools: BumpTools; +} + +/** The installation permissions a target's bump API calls need: `land` reads the base's protection, and an ungated bump the head's checks and workflow runs. */ +export const BUMP_PERMISSIONS = { + contents: "write", + pull_requests: "write", + merge_queues: "write", + actions: "read", + administration: "read", + checks: "read", + statuses: "read", +} as const; + +/** Workflow run conclusions that do not fail. */ +const PASSING = ["success", "neutral", "skipped"]; + +const name = (repository: Repository) => `${repository.owner}/${repository.repo}`; + +/** The root input of `lock` that locks `source` on GitHub, and its locked and URL-pinned revisions. */ +export function questInput(lock: ObjectValue, source: Repository): { input: string; locked: string; pinned?: string } { + const nodes = object(lock.nodes); + const inputs = object(object(nodes[string(lock.root)]).inputs ?? {}); + const matches = Object.entries(inputs).flatMap(([input, node]) => { + // A follows path names another node's input, not a lock of its own. + if (typeof node !== "string") return []; + const entry = object(nodes[node]); + const locked = object(entry.locked ?? {}); + const original = object(entry.original ?? {}); + const same = (value: unknown, expected: string) => + typeof value === "string" && value.toLowerCase() === expected.toLowerCase(); + if (locked.type !== "github" || !same(locked.owner, source.owner) || !same(locked.repo, source.repo)) return []; + return [ + { + input, + locked: string(locked.rev), + ...(typeof original.rev === "string" ? { pinned: original.rev } : {}), + }, + ]; + }); + if (matches.length !== 1) + throw new Error( + `flake.lock has ${matches.length} root inputs locking github:${name(source)}, where a bump needs exactly one`, + ); + return matches[0]; +} + +/** + * `flake` with the revision dropped from the source's URL, so flake.lock alone pins the input and each + * bump is a lock-only change. The URL must appear exactly once. + */ +export function unpin(flake: string, source: Repository, rev: string): string { + const url = `github:${name(source)}/${rev}`; + // GitHub names are case-insensitive, as `questInput` matches them. + const pattern = new RegExp(`"${url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`, "gi"); + const found = flake.match(pattern)?.length ?? 0; + if (found !== 1) throw new Error(`flake.nix names "${url}" ${found} times, where a bump can unpin exactly one`); + return flake.replace(pattern, `"github:${name(source)}"`); +} + +/** + * Refuse a checkout with a tracked symlink that leaves it or dangles: the tools write through symlinks, + * which would let the target's tree overwrite the daemon's own files. + */ +export function contained(checkout: string): void { + const root = realpathSync(checkout); + const entries = git(checkout, ["ls-files", "--stage", "-z"]).split("\0").filter(Boolean); + for (const entry of entries) { + // The path is everything after the first tab, which a file name may itself contain. + const tab = entry.indexOf("\t"); + const meta = entry.slice(0, tab); + const file = entry.slice(tab + 1); + if (!meta.startsWith("120000 ")) continue; + let target: string; + try { + target = realpathSync(join(checkout, file)); + } catch { + throw new Error(`${file} is a symlink to nothing, which a bump refuses to write through`); + } + const outside = relative(root, target); + if (outside === ".." || outside.startsWith(`..${sep}`) || isAbsolute(outside)) + throw new Error(`${file} is a symlink out of the repository, which a bump refuses to write through`); + } +} + +/** The bump PR's body, with a `## Waiting` item for findings, and the recorded review skip. */ +export function bumpBody(options: { + source: Repository; + input: string; + from: string; + to: string; + unpinned: boolean; + changed: string[]; + findings?: string; +}): string { + const { source, input, from, to } = options; + const lines = [ + `Bumps the \`${input}\` input, github:${name(source)}, from \`${from.slice(0, 7)}\` to \`${to.slice(0, 7)}\`: https://github.com/${name(source)}/compare/${from}...${to}`, + ]; + if (options.unpinned) + lines.push("", "It drops the revision from the input's URL in flake.nix, so flake.lock alone pins it."); + const extra = options.changed.filter((path) => path !== "flake.lock" && path !== "flake.nix"); + lines.push( + "", + extra.length === 0 + ? "`quest init` and `quest check --fix` with the new quest changed nothing else." + : `\`quest init\` and \`quest check --fix\` with the new quest changed ${extra.map((path) => `\`${path}\``).join(", ")}.`, + ); + if (options.findings !== undefined) + lines.push( + "", + "## Waiting", + "", + "- Maintainer: `quest check` with the new quest reports findings `--fix` cannot resolve. Fix them on this branch and land it; the next bump replaces this branch's tree.", + "", + `
${excerpt(options.findings.trimEnd())}
`, + ); + lines.push( + "", + "## Review", + "", + "Skipped: pin bump, no repository code changed; CI's quest check is the gate.", + "", + "Opened by `quest land watch`, which replaces this branch with each newer bump.", + ); + return `${lines.join("\n")}\n`; +} + +function readBumps(state: string): Record { + const path = join(state, "bump.json"); + return existsSync(path) ? (JSON.parse(readFileSync(path, "utf8")) as Record) : {}; +} + +/** + * Bump every target to `sha`, the source's base, and land each bump. A target's failure does not stop + * the others; the pass throws once all have run, so the daemon reports it and retries. + */ +export async function bumpPass(context: BumpContext, sha: string): Promise { + const bumps = readBumps(context.state); + const output: string[] = []; + const failures: string[] = []; + for (const target of context.targets) { + const key = name(target); + if (bumps[key]?.sha === sha && bumps[key].status !== "checking") continue; + try { + const connection = context.connect(target); + // Each step is recorded before the next, so a failed landing never pushes the bump again. + if (bumps[key]?.sha !== sha) { + bumps[key] = await bump(context, target, sha, connection, output); + save(join(context.state, "bump.json"), bumps); + } + if (bumps[key].status === "checking") { + bumps[key] = await settle(target, bumps[key], connection, output); + save(join(context.state, "bump.json"), bumps); + } + } catch (error) { + failures.push(`${key}: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (failures.length > 0) throw new Error(`bump failed for ${failures.join("; ")}`); + return output; +} + +/** Bump `target` to `sha`: push the bump to its branch and open or update its PR, unqueued. */ +async function bump( + context: BumpContext, + target: Repository, + sha: string, + connection: BumpConnection, + output: string[], +): Promise { + const github = new GitHub(connection.deps); + const path = `/repos/${name(target)}`; + const repository = object(await (await github.request(path)).json()); + const base = string(repository.default_branch); + const baseSha = string( + object(object(await (await github.request(`${path}/git/ref/heads/${encodeURIComponent(base)}`)).json()).object) + .sha, + ); + const [open] = await github.all( + `${path}/pulls?state=open&head=${encodeURIComponent(`${target.owner}:${BUMP_BRANCH}`)}&per_page=100`, + ); + const lock = object(await (await github.request(`${path}/contents/flake.lock?ref=${baseSha}`)).json()); + const found = questInput( + object(JSON.parse(Buffer.from(string(lock.content), "base64").toString("utf8"))), + context.source, + ); + if (found.locked === sha) { + if (open) { + await github.request(`${path}/issues/${Number(open.number)}/comments`, { + body: { + body: `quest land watch: ${base} already pins ${name(context.source)} at ${sha}, so this bump is no longer needed.`, + }, + }); + await github.request(`${path}/pulls/${Number(open.number)}`, { + method: "PATCH", + body: { state: "closed" }, + }); + output.push(`bump closed ${name(target)} ${Number(open.number)}`); + } + return { sha, status: "done" }; + } + + const remote = connection.remote; + const ref = `refs/heads/${BUMP_BRANCH}`; + mkdirSync(join(context.state, "bump"), { recursive: true, mode: 0o700 }); + // A bare cache per target keeps the base's whole history, which `quest check --fix` reads for deleted + // quests, and makes each later bump's fetch incremental. Its refs keep the fetched commits. + const cache = join(context.state, "bump", "repos", `${target.owner}-${target.repo}.git`); + if (!existsSync(join(cache, "HEAD"))) { + mkdirSync(cache, { recursive: true, mode: 0o700 }); + git(cache, ["init", "--quiet", "--bare"]); + } + const checkout = mkdtempSync(join(context.state, "bump", "checkout-")); + try { + await connection.read(cache, ["fetch", "--quiet", "--no-tags", remote, `+${baseSha}:refs/bump/base`]); + git(checkout, ["init", "--quiet"]); + writeFileSync( + join(checkout, ".git", "objects", "info", "alternates"), + `${join(realpathSync(cache), "objects")}\n`, + ); + git(checkout, ["checkout", "--quiet", "--detach", baseSha]); + contained(checkout); + // The open PR's head stays in the branch's history; a branch left by a merged or closed PR is replaced. + const previous = open ? string(object(open.head).sha) : ""; + const lease = previous || ((await remoteHead(checkout, remote, ref, connection.read)) ?? ""); + if (lease) await connection.read(cache, ["fetch", "--quiet", "--no-tags", remote, `+${lease}:refs/bump/lease`]); + + const reader = await context.reader(); + if (found.pinned !== undefined) { + const flake = join(checkout, "flake.nix"); + writeFileSync(flake, unpin(readFileSync(flake, "utf8"), context.source, found.pinned)); + } + context.tools.lock(checkout, found.input, `github:${name(context.source)}/${sha}`, reader); + const locked = questInput( + object(JSON.parse(readFileSync(join(checkout, "flake.lock"), "utf8"))), + context.source, + ); + if (locked.locked !== sha || locked.pinned !== undefined) + throw new Error( + `locking ${found.input} left it at ${locked.locked}${locked.pinned ? ", pinned in flake.nix" : ""}`, + ); + const quest = context.tools.quest(sha, reader); + const init = context.tools.run(quest, checkout, ["init"]); + if (init.status !== 0) throw new Error(`quest init failed: ${init.output.trim()}`); + const check = context.tools.run(quest, checkout, ["check", "--fix"]); + const findings = check.status === 0 ? undefined : check.output; + + git(checkout, ["add", "--all"]); + const tree = git(checkout, ["write-tree"]); + const changed = git(checkout, ["diff", "--cached", "--name-only", baseSha]).split("\n").filter(Boolean); + const title = `chore: bump quest to ${sha.slice(0, 7)}`; + // A newer bump merges the open PR's head with the base, with the fresh bump as its tree, so the + // branch only moves forward and the squash lands just the bump. + const parents = previous ? ["-p", previous, "-p", baseSha] : ["-p", baseSha]; + const commit = git(checkout, ["commit-tree", tree, ...parents, "-m", title], { env: IDENTITY }); + + const id = open ? string(open.node_id) : undefined; + // GitHub keeps auto-merge armed across a push, which would land the new head unchecked by this pass. + if (id && open!.auto_merge != null) + await github.graphql( + "mutation($id: ID!) { disablePullRequestAutoMerge(input: { pullRequestId: $id }) { clientMutationId } }", + { id }, + ); + if (open) { + // The PR may have merged or closed during the Nix work, leaving its branch for the lease to match. + const now = object(await (await github.request(`${path}/pulls/${Number(open.number)}`)).json()); + if (now.state !== "open") + throw new Error( + `bump PR ${Number(open.number)} closed while the bump was prepared; the next pass starts over`, + ); + } + const pushed = await push(checkout, remote, baseSha, commit, ref, connection.token, lease); + if (pushed.status !== 0) throw new Error(`push failed: ${pushed.stderr.trim()}`); + + const body = bumpBody({ + source: context.source, + input: found.input, + from: found.locked, + to: sha, + unpinned: found.pinned !== undefined, + changed, + findings, + }); + const draft = findings !== undefined; + let number: number; + if (open) { + number = Number(open.number); + // A target whose default branch moved gets its open bump retargeted, since the bump is built on the new one. + const retarget = string(object(open.base).ref) === base ? {} : { base }; + await github.request(`${path}/pulls/${number}`, { method: "PATCH", body: { title, body, ...retarget } }); + if (open.draft !== draft) + await github.graphql( + `mutation($id: ID!) { ${draft ? "convertPullRequestToDraft" : "markPullRequestReadyForReview"}(input: { pullRequestId: $id }) { clientMutationId } }`, + { id: id! }, + ); + } else { + const pull = await github.request(`${path}/pulls`, { + body: { title, head: BUMP_BRANCH, base, body, draft }, + }); + number = Number(object(await pull.json()).number); + } + output.push(`bump ${name(target)} ${number} ${sha}${draft ? " waiting" : ""}`); + return { sha, pr: number, head: commit, status: draft ? "waiting" : "checking" }; + } finally { + rmSync(checkout, { recursive: true, force: true }); + } +} + +const IDENTITY = { + GIT_AUTHOR_NAME: "moq-bot", + GIT_AUTHOR_EMAIL: "moq-bot@users.noreply.github.com", + GIT_COMMITTER_NAME: "moq-bot", + GIT_COMMITTER_EMAIL: "moq-bot@users.noreply.github.com", +}; + +/** + * Land a bump whose head is `record.head`. A base that requires checks, or has a merge queue, gates the + * PR itself, so it is queued at once. Nothing gates any other base, where landing would merge at once, + * so the bump first waits for every check on its head to pass, and then merges directly when the + * repository has no auto-merge, as on a private repository on GitHub Free. + */ +async function settle(target: Repository, record: Bump, connection: BumpConnection, output: string[]): Promise { + const github = new GitHub(connection.deps); + const path = `/repos/${name(target)}`; + const pr = record.pr!; + const head = record.head!; + const pull = object(await (await github.request(`${path}/pulls/${pr}`)).json()); + if (pull.state !== "open") return { ...record, status: "done" }; + const shown = string(object(pull.head).sha); + if (shown !== head) { + // GitHub can show the old head briefly after the push; any other head is someone else's, such as the target's fixer. + const comparison = object(await (await github.request(`${path}/compare/${shown}...${head}`)).json()); + return comparison.status === "ahead" ? record : { ...record, status: "done" }; + } + const needs = await requirements(github, target, string(object(pull.base).ref)); + const gated = needs.checks.length > 0 || needs.rules.some((rule) => rule.type === "merge_queue"); + let direct = false; + const fail = async (why: string) => { + await github.request(`${path}/issues/${pr}/comments`, { + body: { body: `quest land watch: ${why}, so this bump waits for a maintainer to fix and land it.` }, + }); + output.push(`bump failed ${name(target)} ${pr}`); + return { ...record, status: "failed" as const }; + }; + if (!gated) { + // A workflow's later jobs report only once they start, and a queued workflow may have no check run yet, + // so Actions is judged by its workflow runs. Another app's suite that never ran a check stays queued + // forever, and counts for nothing. + const runs = object(await (await github.request(`${path}/actions/runs?head_sha=${head}&per_page=100`)).json()); + const all = array(runs.workflow_runs); + const suites = object(await (await github.request(`${path}/commits/${head}/check-suites?per_page=100`)).json()); + const listed = array(suites.check_suites); + if (Number(runs.total_count) > all.length || Number(suites.total_count) > listed.length) + throw new Error(`${head} has more workflow runs or check suites than one page, more than a bump reads`); + const workflows = latestRuns(all); + // The check runs of a replaced workflow run, such as one a newer run cancelled, are replaced with it. + const replaced = new Set( + all + .filter((run) => !workflows.includes(run) && run.check_suite_id != null) + .map((run) => Number(run.check_suite_id)), + ); + const found = (await contexts(github, target, head)).filter((context) => { + const suite = object(context.checkSuite ?? {}).databaseId; + return suite == null || !replaced.has(Number(suite)); + }); + const state = found.length === 0 ? "PENDING" : checksState(found); + if (state === "FAILURE") return fail(`a check failed on its head ${head}`); + if (workflows.some((run) => run.status === "completed" && !PASSING.includes(String(run.conclusion)))) + return fail(`a workflow failed on its head ${head}`); + const running = + workflows.some((run) => run.status !== "completed") || + listed.some( + (suite) => + object(suite.app ?? {}).slug !== "github-actions" && + Number(suite.latest_check_runs_count) > 0 && + suite.status !== "completed", + ); + if (state === "PENDING" || running) return record; + direct = object(await (await github.request(path)).json()).allow_auto_merge !== true; + } + try { + await land({ ...target, number: pr }, head, undefined, connection.deps, direct); + } catch (error) { + if (!(error instanceof Refusal)) throw error; + return fail(`landing it was refused: ${error.message}`); + } + output.push(`bump landing ${name(target)} ${pr}`); + return { ...record, status: "done" }; +} + +/** + * The workflow runs on a head that still count: each workflow's latest run per event. A cancelled run + * also yields to a newer run of its workflow from another event, as when push and pull_request share a + * `cancel-in-progress` concurrency group, so a run a newer one replaced counts as replaced, not failed. + */ +export function latestRuns(runs: ObjectValue[]): ObjectValue[] { + return runs.filter((run) => { + const newer = runs.filter( + (other) => other.workflow_id === run.workflow_id && Number(other.id) > Number(run.id), + ); + if (newer.some((other) => other.event === run.event)) return false; + return !(run.conclusion === "cancelled" && newer.length > 0); + }); +} + +/** The tools on the host: Nix locks the input and builds the source's quest; its commands get no credentials. */ +export function hostTools(source: Repository, state: string): BumpTools { + const environment = (token: string) => ({ + ...commandEnvironment(), + HOME: join(state, "bump"), + XDG_CACHE_HOME: join(state, "bump", "cache"), + NIX_CONFIG: `access-tokens = github.com=${token}`, + }); + const nix = (cwd: string, args: string[], token: string) => { + const result = spawnSync("nix", ["--extra-experimental-features", "nix-command flakes", ...args], { + cwd, + encoding: "utf8", + env: environment(token), + maxBuffer: 1 << 30, + }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`nix ${args.join(" ")} failed: ${result.stderr.trim()}`); + return result.stdout.trim(); + }; + const built = new Map(); + return { + lock: (checkout, input, url, token) => { + nix(checkout, ["flake", "lock", "--override-input", input, url], token); + }, + quest: (sha, token) => { + let path = built.get(sha); + // `--no-link` roots nothing, so Nix's garbage collection may remove a cached build before a retry. + if (path === undefined || !existsSync(path)) { + path = join( + nix(state, ["build", "--no-link", "--print-out-paths", `github:${name(source)}/${sha}`], token), + "bin", + "quest", + ); + built.set(sha, path); + } + return path; + }, + run: (quest, checkout, args) => { + // Bun reads bunfig.toml, whose preload runs code, from its working directory: never the target's. + const trusted = join(state, "bump", "run"); + mkdirSync(trusted, { recursive: true, mode: 0o700 }); + const result = spawnSync(quest, ["--root", checkout, ...args], { + cwd: trusted, + encoding: "utf8", + env: { ...commandEnvironment(), HOME: join(state, "bump"), NO_COLOR: "1" }, + maxBuffer: 1 << 30, + }); + if (result.error) throw result.error; + return { status: result.status, output: `${result.stdout}${result.stderr}` }; + }, + }; +} + +/** Fail at startup, not on the first bump, when the host cannot run one. */ +export function checkTools(): void { + const nix = spawnSync("nix", ["--version"], { encoding: "utf8" }); + if (nix.error || nix.status !== 0) throw new Error("quest land watch --bump requires nix on PATH"); +} diff --git a/src/cli/land-watch.ts b/src/cli/land-watch.ts index 7e5d8578..5dbec0ba 100644 --- a/src/cli/land-watch.ts +++ b/src/cli/land-watch.ts @@ -41,6 +41,7 @@ import { requeuedHeads, } from "./land-watch-fix"; import { leaveOwnQueue, type OwnQueue, ownPass } from "./land-queue"; +import { BUMP_PERMISSIONS, type BumpConnection, type BumpContext, bumpPass, checkTools, hostTools } from "./land-bump"; import { pruneLogs } from "./land-prune"; import { enabledHead, headBefore } from "./status"; @@ -61,6 +62,8 @@ export interface WatchOptions { fixerJobs: number; /** The fixer's `model_reasoning_effort`. */ fixerEffort: string; + /** Repositories whose flake input on this repository each pass bumps to this repository's base. */ + bump: Repository[]; } /** @@ -709,6 +712,9 @@ export async function ownQueue(github: GitHub, repository: Repository, base: str type Permissions = Record; +const same = (a: Repository, b: Repository) => + a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase(); + /** * The installation token permissions the watcher asks for. On GitHub's merge queue, `api` reads the * queue's events, comments, and the `merge_group` logs (`actions: read`), and enqueues the armed picks @@ -776,6 +782,9 @@ export async function watch( options.fixer = resolve(options.fixer); accessSync(options.fixer, constants.X_OK); } + if (options.bump.length > 0) checkTools(); + if (options.bump.some((target) => same(target, repository))) + throw new Error("--bump names the watched repository, which cannot bump its own pin"); const now = { fetch, now: Date.now }; const mint = (wanted: Permissions) => installationToken(repository, options, wanted, now); // Each mode's token is minted only once a pass needs it, and the mode is read with neither, so an @@ -811,17 +820,51 @@ export async function watch( token, read, }; + const connections = new Map(); + const bump: BumpContext | undefined = + options.bump.length === 0 + ? undefined + : { + source: repository, + targets: options.bump, + state: options.state, + reader: () => mint({ contents: "read" }), + tools: hostTools(repository, options.state), + connect: (target) => { + const key = `${target.owner}/${target.repo}`; + let connection = connections.get(key); + if (connection === undefined) { + const scoped = (wanted: Permissions) => installationToken(target, options, wanted, now); + const push: Permissions = { contents: "write", pull_requests: "write" }; + connection = { + remote: `https://github.com/${key}.git`, + deps: apiDependencies(() => scoped(BUMP_PERMISSIONS), { ...now, sleep, warn }), + token: (workflows) => scoped(workflows ? { ...push, workflows: "write" } : push), + read: authenticatedRead(() => scoped({ contents: "read" })), + }; + connections.set(key, connection); + } + return connection; + }, + }; await daemonLoop(async () => { pruneLogs(options.state, deps.now(), deps.warn); const api = new GitHub(probe); + let again = false; if (await ownQueue(api, repository, base)) { const pass = await ownPass(repository, base, queue, ownDeps, fix); for (const line of pass.lines) warn(line); - return pass.again; + again = pass.again; + } else { + for (const line of await leaveOwnQueue(repository, base, queue, ownDeps, fixing)) warn(line); + for (const line of await watchPass(repository, { ...options, base }, deps, fix)) warn(line); + } + if (bump) { + const ref = `/repos/${repository.owner}/${repository.repo}/git/ref/heads/${encodeURIComponent(base)}`; + const sha = string(object(object(await (await api.request(ref)).json()).object).sha); + for (const line of await bumpPass(bump, sha)) warn(line); } - for (const line of await leaveOwnQueue(repository, base, queue, ownDeps, fixing)) warn(line); - for (const line of await watchPass(repository, { ...options, base }, deps, fix)) warn(line); - return false; + return again; }, deps); } finally { unlock(); diff --git a/src/cli/land.ts b/src/cli/land.ts index 49dcc857..cd5b07ff 100644 --- a/src/cli/land.ts +++ b/src/cli/land.ts @@ -167,7 +167,7 @@ export interface Waiting { const CONTEXTS = `pageInfo { hasNextPage endCursor } nodes { __typename - ... on CheckRun { databaseId name status conclusion detailsUrl checkSuite { app { databaseId } } } + ... on CheckRun { databaseId name status conclusion detailsUrl checkSuite { databaseId app { databaseId } } } ... on StatusContext { context state createdAt targetUrl } }`; @@ -449,7 +449,7 @@ export async function resolveQueue( * Every check run and commit status on `head`: from `rollup`, the first page already read with it, * when there is one, then the rest by the SHA. */ -async function contexts( +export async function contexts( github: GitHub, repository: Repository, head: string, diff --git a/src/cli/main.ts b/src/cli/main.ts index e6921657..6dd18e76 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -44,7 +44,15 @@ import { import type { Cli } from "../runner/home"; import { held, openDrafts, reach, target, upstream, upstreamRefs } from "./claims"; import { gate, QUICK_MS } from "./gate"; -import { gitHubDependencies, GitHubInputError, mergedHead, pullRequest, readRemote, remoteRepository } from "./github"; +import { + gitHubDependencies, + GitHubInputError, + mergedHead, + pullRequest, + readRemote, + remoteRepository, + type Repository, +} from "./github"; import { type Hold, finish, land, release, take } from "./hold"; import { LAND_CHECK } from "./land"; import { apply, pushCommand } from "./land-apply"; @@ -756,6 +764,16 @@ export function main(argv: string[], out: Output): number | Promise { .choices(EFFORTS) .default("medium"), ) + .option( + "--bump ", + "Keep this repository's flake input in another repository at this repository's base (repeatable): each pass opens or updates that repository's quest-bump PR as the App and lands it", + (value: string, previous: Repository[]) => { + const match = /^([\w.-]+)\/([\w.-]+)$/.exec(value); + if (!match) throw new InvalidArgumentError("expected owner/repo"); + return [...previous, { owner: match[1], repo: match[2] }]; + }, + [] as Repository[], + ) .action((options: WatchOptions) => { const origin = readRemote(root(), "origin"); const repository = origin && remoteRepository(origin).repository; diff --git a/test/land-bump.test.ts b/test/land-bump.test.ts new file mode 100644 index 00000000..5c46f7e3 --- /dev/null +++ b/test/land-bump.test.ts @@ -0,0 +1,591 @@ +// The pin bump pass against a fake GitHub and a local target repository, with the host's Nix and quest faked. + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { delimiter, join } from "node:path"; + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { type GitHubDependencies, requirements } from "../src/cli/github"; +import { land } from "../src/cli/hold"; +import { readRemote } from "../src/cli/land"; +import { + BUMP_BRANCH, + type BumpContext, + type BumpTools, + bumpBody, + bumpPass, + hostTools, + latestRuns, + questInput, + unpin, +} from "../src/cli/land-bump"; +import { configure, GIT_SETTINGS, tempDir } from "./fixture"; + +vi.mock("../src/cli/hold", () => ({ land: vi.fn(async () => {}) })); +const landed = vi.mocked(land); + +const SOURCE = { owner: "moq-dev", repo: "quest" }; +const OLD = "1".repeat(40); +const NEW = "2".repeat(40); + +/** A flake.lock whose root input `quest` locks the source at `rev`, pinned in its URL when `pinned`. */ +function lockFile(rev: string, pinned: boolean): string { + const original = { owner: "moq-dev", repo: "quest", type: "github", ...(pinned ? { rev } : {}) }; + return `${JSON.stringify( + { + nodes: { + nixpkgs: { locked: { type: "github", owner: "NixOS", repo: "nixpkgs", rev: "9".repeat(40) } }, + quest_2: { locked: { type: "github", owner: "moq-dev", repo: "quest", rev }, original }, + root: { inputs: { nixpkgs: "nixpkgs", quest: "quest_2", follows: ["nixpkgs"] } }, + }, + root: "root", + version: 7, + }, + null, + 2, + )}\n`; +} + +const flakeNix = (rev?: string) => + `{\n inputs.quest.url = "github:moq-dev/quest${rev ? `/${rev}` : ""}";\n outputs = { ... }: { };\n}\n`; + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +} + +/** The target's repository, its `main` pinning the source at `OLD` in flake.nix's URL. */ +function target(): { remote: string; base: string } { + const work = tempDir(); + git(work, "init", "--quiet", "--initial-branch=main"); + configure(work, GIT_SETTINGS); + writeFileSync(join(work, "flake.nix"), flakeNix(OLD)); + writeFileSync(join(work, "flake.lock"), lockFile(OLD, true)); + git(work, "add", "."); + git(work, "commit", "--quiet", "-m", "init"); + const remote = tempDir(); + git(remote, "clone", "--quiet", "--bare", work, "."); + configure(remote, { "uploadpack.allowAnySHA1InWant": "true" }); + return { remote: `file://${remote}`, base: git(work, "rev-parse", "HEAD") }; +} + +interface World { + target: { owner: string; repo: string }; + remote: string; + base: string; + pulls: { + number: number; + head: string; + state: string; + draft: boolean; + auto_merge: unknown; + body?: string; + base?: string; + }[]; + rules: { type: string }[] | "free"; + allowAutoMerge: boolean; + /** Each check on a head, as `statusCheckRollup` contexts. */ + checks: Record[]>; + /** Each check suite on a head. */ + suites: Record; + /** Each workflow run on a head. */ + runs: Record; + requests: string[]; + /** The flake.lock GitHub serves on `main`, when it differs from the remote's. */ + lock?: string; +} + +function deps(world: World): GitHubDependencies { + const path = `/repos/${world.target.owner}/${world.target.repo}`; + const json = (value: unknown, status = 200) => Response.json(value, { status }); + return { + token: "test", + now: () => 0, + sleep: async () => {}, + warn: () => {}, + fetch: (async (input: URL, init?: RequestInit) => { + const url = new URL(input); + const method = init?.method ?? "GET"; + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + world.requests.push(`${method} ${url.pathname}${url.search}`); + if (url.pathname === "/graphql") { + const query = String(body.query); + if (query.includes("statusCheckRollup")) { + const nodes = world.checks[body.variables.oid] ?? []; + return json({ + data: { + repository: { + object: { + statusCheckRollup: { contexts: { nodes, pageInfo: { hasNextPage: false } } }, + }, + }, + }, + }); + } + const mutation = /mutation[^{]*\{\s*(\w+)/.exec(query)?.[1]; + const pull = world.pulls.find((candidate) => `PR_${candidate.number}` === body.variables.id)!; + if (mutation === "disablePullRequestAutoMerge") pull.auto_merge = null; + if (mutation === "convertPullRequestToDraft") pull.draft = true; + if (mutation === "markPullRequestReadyForReview") pull.draft = false; + return json({ data: {} }); + } + if (url.pathname === path) return json({ default_branch: "main", allow_auto_merge: world.allowAutoMerge }); + if (url.pathname === `${path}/git/ref/heads/main`) return json({ object: { sha: world.base } }); + if (url.pathname === `${path}/contents/flake.lock`) { + const text = + world.lock ?? + git(world.remote.slice("file://".length), "show", `${url.searchParams.get("ref")}:flake.lock`); + return json({ content: Buffer.from(text).toString("base64") }); + } + if (url.pathname === `${path}/actions/runs`) { + const listed = world.runs[url.searchParams.get("head_sha")!] ?? []; + return json({ total_count: listed.length, workflow_runs: listed }); + } + const suites = new RegExp(`^${path}/commits/(\\w+)/check-suites$`).exec(url.pathname); + if (suites) { + const listed = world.suites[suites[1]] ?? []; + return json({ total_count: listed.length, check_suites: listed }); + } + if (url.pathname === `${path}/branches/main`) return json({ protection: { enabled: false } }); + if (url.pathname === `${path}/rules/branches/main`) { + if (world.rules === "free") + return json( + { message: "Upgrade to GitHub Pro or make this repository public to enable this feature." }, + 403, + ); + return json(world.rules); + } + const view = (pull: World["pulls"][number]) => ({ + number: pull.number, + node_id: `PR_${pull.number}`, + state: pull.state, + draft: pull.draft, + auto_merge: pull.auto_merge, + head: { sha: pull.head }, + base: { ref: pull.base ?? "main" }, + }); + if (url.pathname === `${path}/pulls` && method === "GET") { + expect(url.searchParams.get("head")).toBe(`${world.target.owner}:${BUMP_BRANCH}`); + return json(world.pulls.filter((pull) => pull.state === "open").map(view)); + } + if (url.pathname === `${path}/pulls` && method === "POST") { + const head = git(world.remote.slice("file://".length), "rev-parse", body.head); + const pull = { number: 7, head, state: "open", draft: body.draft, auto_merge: null, body: body.body }; + world.pulls.push(pull); + return json(view(pull), 201); + } + const number = /\/(?:pulls|issues)\/(\d+)/.exec(url.pathname)?.[1]; + const pull = world.pulls.find((candidate) => candidate.number === Number(number)); + if (pull && url.pathname === `${path}/pulls/${number}`) { + if (method === "PATCH") { + if (body.state) pull.state = body.state; + if (body.base) pull.base = body.base; + if (body.body) pull.body = body.body; + if (body.body) pull.head = git(world.remote.slice("file://".length), "rev-parse", BUMP_BRANCH); + } + return json(view(pull)); + } + if (pull && url.pathname === `${path}/issues/${number}/comments`) + return method === "GET" ? json([]) : json({ id: 1 }, 201); + throw new Error(`unexpected request ${method} ${url.pathname}`); + }) as typeof fetch, + }; +} + +/** Tools that lock as Nix would and a quest whose `init` writes a stub and whose `check --fix` passes unless told. */ +function tools(findings?: string): BumpTools & { locks: string[] } { + const locks: string[] = []; + return { + locks, + lock: (checkout, input, url) => { + locks.push(`${input} ${url}`); + const rev = url.split("/").at(-1)!; + writeFileSync(join(checkout, "flake.lock"), lockFile(rev, false)); + }, + quest: (sha) => `quest-${sha}`, + run: (quest, checkout, args) => { + if (args[0] === "init") writeFileSync(join(checkout, "stub.md"), `${quest}\n`); + return findings !== undefined && args[0] === "check" + ? { status: 1, output: findings } + : { status: 0, output: "" }; + }, + }; +} + +function context(world: World, faked = tools()): BumpContext { + return { + source: SOURCE, + targets: [world.target], + state: tempDir(), + reader: async () => "reader", + tools: faked, + connect: () => ({ remote: world.remote, deps: deps(world), token: async () => "push", read: readRemote }), + }; +} + +function world(overrides: Partial = {}): World { + const { remote, base } = target(); + return { + target: { owner: "moq-dev", repo: "widget" }, + remote, + base, + pulls: [], + rules: [{ type: "merge_queue" }], + allowAutoMerge: true, + checks: {}, + suites: {}, + runs: {}, + requests: [], + ...overrides, + }; +} + +const bare = (world: World) => world.remote.slice("file://".length); + +beforeEach(() => landed.mockClear()); + +describe("questInput", () => { + test("finds the one root input locking the source, skipping follows and other inputs", () => { + expect(questInput(JSON.parse(lockFile(OLD, true)), SOURCE)).toEqual({ + input: "quest", + locked: OLD, + pinned: OLD, + }); + expect(questInput(JSON.parse(lockFile(OLD, false)), { owner: "Moq-Dev", repo: "Quest" })).toEqual({ + input: "quest", + locked: OLD, + }); + }); + + test("fails on a lock with no such input, or more than one", () => { + expect(() => questInput(JSON.parse(lockFile(OLD, false)), { owner: "moq-dev", repo: "other" })).toThrow( + "0 root inputs", + ); + const lock = JSON.parse(lockFile(OLD, false)); + lock.nodes.root.inputs.again = "quest_2"; + expect(() => questInput(lock, SOURCE)).toThrow("2 root inputs"); + }); +}); + +describe("unpin", () => { + test("drops the revision from the source's URL", () => { + expect(unpin(flakeNix(OLD), SOURCE, OLD)).toBe(flakeNix()); + }); + + test("matches the source's name in any case", () => { + expect(unpin(flakeNix(OLD).replace("moq-dev/quest", "Moq-Dev/Quest"), SOURCE, OLD)).toBe(flakeNix()); + }); + + test("fails unless the pinned URL appears exactly once", () => { + expect(() => unpin(flakeNix(), SOURCE, OLD)).toThrow("0 times"); + expect(() => unpin(flakeNix(OLD) + flakeNix(OLD), SOURCE, OLD)).toThrow("2 times"); + }); +}); + +test("bumpBody waits on a maintainer only for findings", () => { + const options = { source: SOURCE, input: "quest", from: OLD, to: NEW, unpinned: false, changed: ["flake.lock"] }; + expect(bumpBody(options)).not.toContain("## Waiting"); + expect(bumpBody(options)).toContain("## Review\n\nSkipped: pin bump"); + expect(bumpBody({ ...options, findings: "quest/a.md: " })).toContain( + "## Waiting\n\n- Maintainer: `quest check`", + ); + expect(bumpBody({ ...options, findings: "quest/a.md: " })).toContain("<bad>"); +}); + +describe("requirements", () => { + test("reads no rules from a private repository on GitHub Free", async () => { + const free = world({ rules: "free" }); + expect( + (await requirements(new (await import("../src/cli/github")).GitHub(deps(free)), free.target, "main")).rules, + ).toEqual([]); + }); +}); + +describe("bumpPass", () => { + test("opens a bump PR on a gated base, unpinning the URL, and queues it once", async () => { + const target = world(); + const ctx = context(target); + expect(await bumpPass(ctx, NEW)).toEqual([`bump moq-dev/widget 7 ${NEW}`, "bump landing moq-dev/widget 7"]); + + const head = git(bare(target), "rev-parse", BUMP_BRANCH); + expect(git(bare(target), "show", "-s", "--format=%P %an", head)).toBe(`${target.base} moq-bot`); + expect(git(bare(target), "show", `${head}:flake.nix`)).toBe(flakeNix().trim()); + expect(JSON.parse(git(bare(target), "show", `${head}:flake.lock`)).nodes.quest_2.locked.rev).toBe(NEW); + expect(git(bare(target), "show", `${head}:stub.md`)).toBe(`quest-${NEW}`); + expect(target.pulls[0]).toMatchObject({ draft: false }); + expect(target.pulls[0].body).toContain("drops the revision"); + expect(target.pulls[0].body).toContain("changed `stub.md`"); + expect(landed).toHaveBeenCalledExactlyOnceWith( + { ...target.target, number: 7 }, + head, + undefined, + expect.anything(), + false, + ); + + // The next pass at the same commit asks GitHub nothing. + target.requests.length = 0; + expect(await bumpPass(ctx, NEW)).toEqual([]); + expect(target.requests).toEqual([]); + }); + + test("moves an open bump PR forward with a merge, disarming its auto-merge first", async () => { + const target = world(); + const ctx = context(target); + await bumpPass(ctx, OLD.replace(/1/g, "3")); + const first = git(bare(target), "rev-parse", BUMP_BRANCH); + target.pulls[0].auto_merge = { enabled_at: "now" }; + landed.mockClear(); + + expect(await bumpPass(ctx, NEW)).toEqual([`bump moq-dev/widget 7 ${NEW}`, "bump landing moq-dev/widget 7"]); + const head = git(bare(target), "rev-parse", BUMP_BRANCH); + expect(git(bare(target), "show", "-s", "--format=%P", head)).toBe(`${first} ${target.base}`); + expect(JSON.parse(git(bare(target), "show", `${head}:flake.lock`)).nodes.quest_2.locked.rev).toBe(NEW); + expect(target.pulls[0].auto_merge).toBeNull(); + expect(target.pulls).toHaveLength(1); + expect(landed).toHaveBeenCalledOnce(); + }); + + test("leaves findings --fix cannot resolve as a draft for a maintainer", async () => { + const target = world(); + expect(await bumpPass(context(target, tools("quest/a.md: broken")), NEW)).toEqual([ + `bump moq-dev/widget 7 ${NEW} waiting`, + ]); + expect(target.pulls[0]).toMatchObject({ draft: true }); + expect(target.pulls[0].body).toContain("## Waiting"); + expect(target.pulls[0].body).toContain("quest/a.md: broken"); + expect(landed).not.toHaveBeenCalled(); + }); + + test("closes an open bump PR once the base already pins the commit", async () => { + const target = world({ lock: lockFile(NEW, false) }); + target.pulls.push({ number: 3, head: target.base, state: "open", draft: false, auto_merge: null }); + expect(await bumpPass(context(target), NEW)).toEqual(["bump closed moq-dev/widget 3"]); + expect(target.pulls[0].state).toBe("closed"); + }); + + test("on an ungated base, waits for every check to pass, then merges directly without auto-merge", async () => { + const target = world({ rules: "free", allowAutoMerge: false }); + const ctx = context(target); + expect(await bumpPass(ctx, NEW)).toEqual([`bump moq-dev/widget 7 ${NEW}`]); + const head = git(bare(target), "rev-parse", BUMP_BRANCH); + target.checks[head] = [{ __typename: "CheckRun", databaseId: 1, name: "check", status: "IN_PROGRESS" }]; + expect(await bumpPass(ctx, NEW)).toEqual([]); + expect(landed).not.toHaveBeenCalled(); + + // A workflow whose later job has not started yet shows only its passing first job. + target.checks[head] = [ + { __typename: "CheckRun", databaseId: 1, name: "check", status: "COMPLETED", conclusion: "SUCCESS" }, + ]; + target.suites[head] = [ + { status: "in_progress", latest_check_runs_count: 1, app: { slug: "ci-app" } }, + { status: "queued", latest_check_runs_count: 0, app: { slug: "never-reports" } }, + ]; + expect(await bumpPass(ctx, NEW)).toEqual([]); + target.suites[head][0].status = "completed"; + // A queued workflow with no check run yet still holds it. + target.suites[head].push({ status: "queued", latest_check_runs_count: 0, app: { slug: "github-actions" } }); + target.runs[head] = [{ status: "queued", conclusion: null }]; + expect(await bumpPass(ctx, NEW)).toEqual([]); + expect(landed).not.toHaveBeenCalled(); + + target.runs[head] = [{ status: "completed", conclusion: "success" }]; + expect(await bumpPass(ctx, NEW)).toEqual(["bump landing moq-dev/widget 7"]); + expect(landed).toHaveBeenCalledExactlyOnceWith( + { ...target.target, number: 7 }, + head, + undefined, + expect.anything(), + true, + ); + }); + + test("on an ungated base, a run a newer run cancelled counts as replaced, not failed", async () => { + const target = world({ rules: "free", allowAutoMerge: false }); + const ctx = context(target); + await bumpPass(ctx, NEW); + const head = git(bare(target), "rev-parse", BUMP_BRANCH); + // Push and pull_request share a cancel-in-progress group: the pull_request run cancelled the push run, + // whose later job reported cancelled before the newer run started it. + const cancelled = { id: 1, workflow_id: 10, event: "push", check_suite_id: 100 }; + const newer = { id: 2, workflow_id: 10, event: "pull_request", check_suite_id: 200 }; + const run = (suite: number, id: number, name: string, status: string, conclusion?: string) => ({ + __typename: "CheckRun", + databaseId: id, + name, + status, + conclusion, + checkSuite: { databaseId: suite }, + }); + target.runs[head] = [ + { ...cancelled, status: "completed", conclusion: "cancelled" }, + { ...newer, status: "in_progress", conclusion: null }, + ]; + target.checks[head] = [ + run(100, 1, "check", "COMPLETED", "CANCELLED"), + run(100, 2, "later", "COMPLETED", "CANCELLED"), + run(200, 3, "check", "IN_PROGRESS"), + ]; + expect(await bumpPass(ctx, NEW)).toEqual([]); + expect(landed).not.toHaveBeenCalled(); + + target.runs[head][1] = { ...newer, status: "completed", conclusion: "success" }; + target.checks[head] = [ + run(100, 1, "check", "COMPLETED", "CANCELLED"), + run(100, 2, "later", "COMPLETED", "CANCELLED"), + run(200, 3, "check", "COMPLETED", "SUCCESS"), + run(200, 4, "later", "COMPLETED", "SUCCESS"), + ]; + expect(await bumpPass(ctx, NEW)).toEqual(["bump landing moq-dev/widget 7"]); + }); + + test("latestRuns keeps each workflow's latest run per event, and drops a cancelled run a newer one replaced", () => { + const runs = [ + { id: 1, workflow_id: 10, event: "push", conclusion: "failure" }, + { id: 2, workflow_id: 10, event: "push", conclusion: "success" }, + { id: 3, workflow_id: 20, event: "push", conclusion: "cancelled" }, + { id: 4, workflow_id: 20, event: "pull_request", conclusion: "success" }, + { id: 5, workflow_id: 30, event: "push", conclusion: "failure" }, + { id: 6, workflow_id: 30, event: "pull_request", conclusion: "success" }, + { id: 7, workflow_id: 40, event: "push", conclusion: "cancelled" }, + ]; + // Another event's failure still counts, as does a cancellation nothing replaced. + expect(latestRuns(runs).map((run) => run.id)).toEqual([2, 4, 5, 6, 7]); + }); + + test("a failed check on an ungated base is left for a maintainer", async () => { + const target = world({ rules: [] }); + const ctx = context(target); + await bumpPass(ctx, NEW); + const head = git(bare(target), "rev-parse", BUMP_BRANCH); + target.checks[head] = [ + { __typename: "CheckRun", databaseId: 1, name: "check", status: "COMPLETED", conclusion: "FAILURE" }, + ]; + expect(await bumpPass(ctx, NEW)).toEqual(["bump failed moq-dev/widget 7"]); + expect(target.requests).toContain("POST /repos/moq-dev/widget/issues/7/comments"); + target.requests.length = 0; + expect(await bumpPass(ctx, NEW)).toEqual([]); + expect(target.requests).toEqual([]); + expect(landed).not.toHaveBeenCalled(); + }); + + test("refuses a target whose tree has a symlink out of the checkout", async () => { + const target = world(); + const work = tempDir(); + git(work, "clone", "--quiet", bare(target), "."); + configure(work, GIT_SETTINGS); + // The checkout's parent, the daemon's own directory, exists; the dangling link would be created on write. + symlinkSync("..", join(work, "AGENTS.md")); + symlinkSync("../../nothing", join(work, "CLAUDE.md")); + symlinkSync("flake.nix", join(work, "inside")); + git(work, "add", "."); + git(work, "commit", "--quiet", "-m", "link"); + git(work, "push", "--quiet", "origin", "HEAD:main"); + target.base = git(work, "rev-parse", "HEAD"); + await expect(bumpPass(context(target), NEW)).rejects.toThrow("AGENTS.md is a symlink out of the repository"); + expect(target.pulls).toEqual([]); + }); + + // Git for Windows refuses a path with a control character. + test.skipIf(process.platform === "win32")("refuses an escaping symlink whose name holds a tab", async () => { + const target = world(); + const work = tempDir(); + git(work, "clone", "--quiet", bare(target), "."); + configure(work, GIT_SETTINGS); + mkdirSync(join(work, "quest")); + symlinkSync("../..", join(work, "quest", "\tescape.md")); + git(work, "add", "."); + git(work, "commit", "--quiet", "-m", "link"); + git(work, "push", "--quiet", "origin", "HEAD:main"); + target.base = git(work, "rev-parse", "HEAD"); + await expect(bumpPass(context(target), NEW)).rejects.toThrow("escape.md is a symlink out of the repository"); + }); + + test("runs quest check --fix on the base's whole history, which names deleted quests", async () => { + const target = world(); + const work = tempDir(); + git(work, "clone", "--quiet", bare(target), "."); + configure(work, GIT_SETTINGS); + writeFileSync(join(work, "second.md"), "second\n"); + git(work, "add", "."); + git(work, "commit", "--quiet", "-m", "second"); + git(work, "push", "--quiet", "origin", "HEAD:main"); + target.base = git(work, "rev-parse", "HEAD"); + const faked = tools(); + const counts: string[] = []; + const run = faked.run; + faked.run = (quest, checkout, args) => { + if (args[0] === "check") counts.push(git(checkout, "rev-list", "--count", "HEAD")); + return run(quest, checkout, args); + }; + const ctx = context(target, faked); + await bumpPass(ctx, NEW); + expect(counts).toEqual(["2"]); + }); + + test("starts over when the open bump PR closes while the bump is prepared", async () => { + const target = world(); + const ctx = context(target); + await bumpPass(ctx, OLD.replace(/1/g, "3")); + const first = git(bare(target), "rev-parse", BUMP_BRANCH); + const lock = ctx.tools.lock; + ctx.tools.lock = (...args) => { + target.pulls[0].state = "closed"; + lock(...args); + }; + await expect(bumpPass(ctx, NEW)).rejects.toThrow("bump PR 7 closed while the bump was prepared"); + expect(git(bare(target), "rev-parse", BUMP_BRANCH)).toBe(first); + }); + + test("retargets an open bump PR at the target's current default branch", async () => { + const target = world(); + const ctx = context(target); + await bumpPass(ctx, OLD.replace(/1/g, "3")); + target.pulls[0].base = "old"; + await bumpPass(ctx, NEW); + expect(target.pulls[0].base).toBe("main"); + }); + + test.skipIf(process.platform === "win32")("rebuilds a cached quest that Nix's garbage collection removed", () => { + const dir = tempDir(); + const out = join(dir, "out"); + const calls = join(dir, "calls"); + writeFileSync( + join(dir, "nix"), + `#!/bin/sh\necho build >> "${calls}"\nmkdir -p "${out}/bin"\ntouch "${out}/bin/quest"\necho "${out}"\n`, + { mode: 0o755 }, + ); + const path = process.env.PATH; + process.env.PATH = `${dir}${delimiter}${path}`; + try { + const host = hostTools(SOURCE, dir); + const quest = host.quest(NEW, "token"); + expect(host.quest(NEW, "token")).toBe(quest); + rmSync(out, { recursive: true }); + expect(host.quest(NEW, "token")).toBe(quest); + expect(existsSync(quest)).toBe(true); + expect(readFileSync(calls, "utf8")).toBe("build\nbuild\n"); + } finally { + process.env.PATH = path; + } + }); + + test("a target that fails does not hold up the others, and the pass reports it", async () => { + const broken = world({ + target: { owner: "moq-dev", repo: "broken" }, + lock: lockFile(OLD, false).replace(/moq-dev/g, "other"), + }); + const good = world(); + const ctx = context(good); + ctx.targets = [broken.target, good.target]; + ctx.connect = (target) => + target.repo === "broken" + ? { remote: broken.remote, deps: deps(broken), token: async () => "push", read: readRemote } + : { remote: good.remote, deps: deps(good), token: async () => "push", read: readRemote }; + await expect(bumpPass(ctx, NEW)).rejects.toThrow("moq-dev/broken: flake.lock has 0 root inputs"); + expect(good.pulls).toHaveLength(1); + expect(JSON.parse(readFileSync(join(ctx.state, "bump.json"), "utf8"))).toEqual({ + "moq-dev/widget": { sha: NEW, pr: 7, head: git(bare(good), "rev-parse", BUMP_BRANCH), status: "done" }, + }); + }); +});