From 6e2b00e465f63e0f07c6f888288ff484612eeb2c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 13 Nov 2026 13:24:25 -0800 Subject: [PATCH 1/6] chore(quest): claim quest/m0/github-readers.md Quest-Claim: cc2a209e2c9e70ac From fe642d6374d3f7debef8511762a1eaa02a67137c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 13 Nov 2026 13:26:52 -0800 Subject: [PATCH 2/6] wip: split the GitHub client and claims into src/github --- src/cli/claims.ts | 203 ++---- src/cli/github.ts | 587 +---------------- src/cli/hold.ts | 4 +- src/cli/land-apply.ts | 11 +- src/cli/land-bump.ts | 2 +- src/cli/land-checks.ts | 2 +- src/cli/land-fixer.ts | 2 +- src/cli/land-halt.ts | 2 +- src/cli/land-head.ts | 2 +- src/cli/land-queue.ts | 2 +- src/cli/land-serve.ts | 2 +- src/cli/land-watch-fix.ts | 10 +- src/cli/land-watch.ts | 2 +- src/cli/land.ts | 4 +- src/cli/main.ts | 11 +- .../models.ts => cli/models-record.ts} | 2 +- src/cli/online-review.ts | 2 +- src/cli/review-record.ts | 8 +- src/cli/review.ts | 2 +- src/cli/start.ts | 9 +- src/cli/status.ts | 2 +- src/cli/usage.ts | 8 +- src/cli/wait.ts | 4 +- src/github/claims.ts | 148 +++++ src/github/client.ts | 594 ++++++++++++++++++ test/github.test.ts | 2 +- test/hold.test.ts | 2 +- test/land-bump.test.ts | 5 +- test/land-fixer-diagnostics.test.ts | 2 +- test/land-watch.test.ts | 2 +- test/land.test.ts | 2 +- test/move.test.ts | 4 +- test/review.test.ts | 2 +- test/start.test.ts | 9 +- test/status.test.ts | 2 +- test/usage.test.ts | 2 +- test/wait.test.ts | 3 +- test/worktree.test.ts | 2 +- 38 files changed, 848 insertions(+), 816 deletions(-) rename src/{github/models.ts => cli/models-record.ts} (98%) create mode 100644 src/github/claims.ts create mode 100644 src/github/client.ts diff --git a/src/cli/claims.ts b/src/cli/claims.ts index f6012182..92bce918 100644 --- a/src/cli/claims.ts +++ b/src/cli/claims.ts @@ -1,47 +1,19 @@ -// Who holds a quest: its claim branch upstream, unless that branch is a merged -// PR's head, or an open PR from a branch of that name in any repository. -// Commands that must not disturb held work look here first, and `quest start` -// records a claim the same way, judging readiness by the upstream tree. -import { handedBack, landed } from "../core"; +// Finding where a checkout's claims live, through its git remotes, and reading them there as +// `src/github/claims.ts` decides them. Commands that must not disturb held work look here first, +// and `quest start` records a claim the same way, judging readiness by the upstream tree. import { git } from "./git"; +import { type Remote, readRemote, remoteRepository, requireRemote } from "./github"; +import { type GitHubDependencies, type Repository, GitHub, object, repositoryPath, string } from "../github/client"; import { - type GitHubDependencies, - type Repository, - type Remote, - GitHub, - object, - readRemote, - remoteRepository, - requireRemote, - string, -} from "./github"; + type Claim, + type Holdings, + type OpenPull, + held as githubHeld, + openPullRequests, + questBranches, +} from "../github/claims"; import { type Snapshot, snapshot } from "./tree"; -/** An open PR, from any head repository, as the claim checks read it. */ -export interface OpenPull { - number: number; - url: string; - branch: string; - /** The head repository as `owner/repo`, or `null` once a fork is deleted. */ - head: string | null; - draft: boolean; - /** Epoch milliseconds. The earliest open PR on a branch holds the claim. */ - created: number; - /** Epoch milliseconds of the last push, comment, or edit. */ - updated: number; - /** Its body has a `## Waiting` section, so it waits on the maintainer. */ - handedBack: boolean; - body: string; -} - -/** A held quest branch, and the open PR from it if there is one. */ -export interface Claim { - branch: string; - /** The PR holding the claim: the earliest open one. */ - url: string | null; - pull: OpenPull | null; -} - export interface ClaimDependencies { /** Called only when the remote is on GitHub, so other forges need no token. */ github(): GitHubDependencies; @@ -141,11 +113,6 @@ function same(a: Repository, b: Repository): boolean { return a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase(); } -/** The REST path of a repository. */ -export function repositoryPath(repository: Repository): string { - return `/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}`; -} - /** The `quest/*` branches on `remote` (a name or URL), with their tips. Plain git, so it works with any forge. */ export function remoteBranches(root: string, remote: string): Map { return questBranches(listRemote(root, remote, ["refs/heads/quest/*"])); @@ -162,39 +129,6 @@ function listRemote(root: string, remote: string, patterns: string[]): Map): Map { - const branches = new Map(); - for (const [ref, sha] of refs) - if (ref.startsWith("refs/heads/quest/")) branches.set(ref.slice("refs/heads/".length), sha); - return branches; -} - -/** Every open PR in `repository`, from any head repository. */ -export async function openPullRequests(github: GitHub, repository: Repository): Promise { - const rows = await github.all(`${repositoryPath(repository)}/pulls?state=open&per_page=100`); - return rows.map((row) => { - const head = object(row.head); - const repo = head.repo === null || head.repo === undefined ? null : object(head.repo); - return { - number: Number(row.number), - url: string(row.html_url), - branch: string(head.ref), - head: repo === null ? null : string(repo.full_name), - draft: row.draft === true, - created: Date.parse(string(row.created_at)), - updated: Date.parse(string(row.updated_at)), - handedBack: typeof row.body === "string" && handedBack(row.body), - body: typeof row.body === "string" ? row.body : "", - }; - }); -} - -/** The open PRs from `branch`, earliest first: the first one holds the claim. */ -export function pullsFrom(pulls: OpenPull[], branch: string): OpenPull[] { - return pulls.filter((pull) => pull.branch === branch).sort((a, b) => a.created - b.created || a.number - b.number); -} - /** * Which of `branches` someone holds: pushed to `remote` or its upstream, or the * head of an open PR in either. Without the remote it warns and checks nothing; @@ -225,6 +159,36 @@ export async function reach(root: string, name: string, deps: ClaimDependencies) return up; } +/** + * Which of `branches` someone holds, as {@link githubHeld} decides it on GitHub. `tips` are the + * upstream's `quest/*` branches, when already read; the fork's are read through git too. Off GitHub, + * every branch pushed upstream holds its quest. + */ +export async function held( + root: string, + up: Upstream, + branches: string[], + tips: Map = remoteBranches(root, up.git), +): Promise { + if (up.github === null) + return { + claims: [...new Set(branches)] + .sort() + .filter((branch) => tips.has(branch)) + .map((branch) => ({ branch, url: null, pull: null })), + landed: new Map(), + }; + const { client, repository, head } = up.github; + return githubHeld( + client, + { + upstream: { repository, tips }, + ...(same(head, repository) ? {} : { fork: { repository: head, tips: remoteBranches(root, up.push) } }), + }, + branches, + ); +} + /** * The open drafts in the upstream of the remote `name`, from any head * repository. Reading them needs GitHub, so without the remote, off GitHub, @@ -251,89 +215,6 @@ export async function openDrafts(root: string, name: string, deps: ClaimDependen } } -/** Which branches someone holds, and which landed branches a new claim replaces. */ -export interface Holdings { - /** Sorted by branch. */ - claims: Claim[]; - /** - * Branches where the remote pushes whose tip is the head of a merged PR, - * by that tip. Their work landed, so they hold nothing and a claim may replace them. - */ - landed: Map; -} - -/** - * Which of `branches` someone holds. `tips` are the upstream's `quest/*` - * branches, when already read. A fork's own branches and PRs count too, - * so a claim pushed there is seen before its PR opens. On GitHub, a branch - * whose tip is a merged PR's head holds nothing: its work landed. A closed, - * unmerged PR's branch still holds its quest, since that work may be wanted back. - */ -export async function held( - root: string, - up: Upstream, - branches: string[], - tips: Map = remoteBranches(root, up.git), -): Promise { - // Where claim branches live: upstream, then the fork the remote pushes to. - const sources: { repository: Repository | null; tips: Map }[] = [ - { repository: up.github?.repository ?? null, tips }, - ]; - const pulls: OpenPull[] = []; - if (up.github) { - pulls.push(...(await openPullRequests(up.github.client, up.github.repository))); - const { head, repository } = up.github; - if (head.owner !== repository.owner || head.repo !== repository.repo) { - sources.push({ repository: head, tips: remoteBranches(root, up.push) }); - pulls.push(...(await openPullRequests(up.github.client, head))); - } - } - const pushTo = sources[sources.length - 1]; - const landed = new Map(); - const holds = async (branch: string): Promise => { - const pull = pullsFrom(pulls, branch)[0] ?? null; - if (pull !== null) return { branch, url: pull.url, pull }; - let holding = false; - for (const source of sources) { - const tip = source.tips.get(branch); - if (tip === undefined) continue; - if ( - up.github && - source.repository && - (await mergedPull(up.github.client, up.github.repository, source.repository, branch, tip)) !== null - ) { - if (source === pushTo) landed.set(branch, tip); - } else { - holding = true; - } - } - return holding ? { branch, url: null, pull: null } : null; - }; - const found = await Promise.all([...new Set(branches)].sort().map(holds)); - return { claims: found.filter((claim): claim is Claim => claim !== null), landed }; -} - -/** The URL of the PR from `head`'s `branch` that merged into `repository` with `tip` as its head, or `null`. */ -export async function mergedPull( - github: GitHub, - repository: Repository, - head: Repository, - branch: string, - tip: string, -): Promise { - const query = new URLSearchParams({ state: "closed", head: `${head.owner}:${branch}`, per_page: "100" }); - const rows = await github.all(`${repositoryPath(repository)}/pulls?${query}`); - const pull = landed( - tip, - rows.map((row) => ({ - head: string(object(row.head).sha), - merged: typeof row.merged_at === "string", - url: string(row.html_url), - })), - ); - return pull?.url ?? null; -} - /** Where a claim lands: the remote, its upstream, and the upstream default branch's tip and quest tree. */ export interface Target extends UpstreamRefs { remote: string; diff --git a/src/cli/github.ts b/src/cli/github.ts index 993a486a..e2e60ecc 100644 --- a/src/cli/github.ts +++ b/src/cli/github.ts @@ -1,78 +1,12 @@ -// The GitHub client shared by the commands that talk to GitHub, outside the repository-independent core. +// The CLI's side of the GitHub client: PR arguments, git remotes, and the token, which need a checkout or Node. import { execFileSync } from "node:child_process"; import { setTimeout as sleep } from "node:timers/promises"; -import { cancelled, currentContexts, latestContexts, type RollupContext, type WorkflowRun } from "../github/checks"; +import type { GitHubDependencies, PullRequest, Repository } from "../github/client"; import { git } from "./git"; -const API = "https://api.github.com"; -const MAX_SLEEP = 2_147_483_647; - -/** A repository on github.com. */ -export interface Repository { - owner: string; - repo: string; -} - -export interface PullRequest extends Repository { - number: number; -} - -export interface GitHubDependencies { - fetch: typeof fetch; - now(): number; - sleep(milliseconds: number): Promise; - token: string; - warn(message: string): void; -} - /** A bad PR argument or option, reported as a usage error. */ export class GitHubInputError extends Error {} -/** A request or pause reached the client's deadline; the caller decides what that means. */ -export class GitHubDeadline extends Error { - constructor() { - super("deadline reached"); - } -} - -/** GitHub answered a GraphQL operation with errors; `types` are their types, `undefined` for an untyped one. */ -export class GitHubGraphQLError extends Error { - constructor( - readonly types: (string | undefined)[], - messages: string[], - ) { - super(`GitHub GraphQL error: ${messages.join("; ")}`); - } -} - -export type ObjectValue = Record; - -/** Retries of a 5xx or a dropped connection, waiting 1, 2, 4, then 8 seconds unless GitHub says how long. */ -const TRANSIENT_RETRIES = 4; -const backoff = (failures: number) => 1000 * 2 ** failures; -/** How far GitHub's clock may run behind ours when looking for what a lost request created. */ -const CLOCK_SKEW = 60_000; -/** Statuses whose response has no body. */ -const NULL_BODY = [204, 205, 304]; - -/** - * Whether a request may repeat after a 5xx or a dropped connection, after which GitHub may have - * acted anyway: `true` when repeating leaves the same state, or a check, run before each repeat, - * for the response of an earlier attempt that took effect (undefined if none did). - */ -export type Repeat = boolean | ((started: number) => Promise); - -export interface RequestOptions { - etag?: string; - body?: unknown; - method?: "POST" | "PUT" | "PATCH" | "DELETE"; - allow?: number[]; - /** A media type other than JSON, such as `application/vnd.github.diff`. */ - accept?: string; - /** Overrides how the request repeats; the default follows its method and endpoint. */ - repeat?: Repeat; -} - /** Accept a GitHub PR URL, or a number in the origin repository. */ export function pullRequest(value: string, root: string): PullRequest { if (/^[1-9]\d*$/.test(value)) { @@ -201,520 +135,3 @@ export function gitHubDependencies(warn: (message: string) => void): GitHubDepen if (!token) throw new Error("GitHub authentication returned an empty token"); return { fetch, now: Date.now, sleep, token, warn }; } - -/** Whether GitHub turned a request away for a rate limit, which any request may wait out and repeat. */ -function limited(response: Response, body: string): boolean { - return ( - response.status === 429 || - (response.status === 403 && - (response.headers.has("retry-after") || - response.headers.get("x-ratelimit-remaining") === "0" || - /rate limit/i.test(body))) - ); -} - -/** Authenticated REST and GraphQL requests that wait out rate limits, bounded by an optional deadline. */ -export class GitHub { - /** The longest X-Poll-Interval GitHub asked for since the caller last set this, in milliseconds. */ - pollInterval = 0; - - constructor( - private readonly deps: GitHubDependencies, - private readonly deadline = Infinity, - ) {} - - /** Sleep, cut short by the deadline. */ - async pause(milliseconds: number): Promise { - const remaining = this.deadline - this.deps.now(); - if (remaining <= 0) throw new GitHubDeadline(); - await this.deps.sleep(Math.min(milliseconds, remaining, MAX_SLEEP)); - if (this.deps.now() >= this.deadline) throw new GitHubDeadline(); - } - - /** - * A 2xx or 304 response, its body already read; anything else fails, with GitHub's reason when - * it gives one, unless `allow` lists its status. Rate limits are waited out. A 5xx or a - * connection dropped before the body is in, after which GitHub may or may not have acted, is - * retried with bounded backoff as `repeat` allows, then fails with the last error. `body` makes - * it a POST unless `method` says otherwise. - */ - async request(path: string, options: RequestOptions = {}): Promise { - // Follow only same-origin pagination links; never send a token to another host. - const url = new URL(path, API); - if (url.origin !== API || url.username || url.password) throw new Error("invalid GitHub pagination URL"); - const started = this.deps.now(); - const repeat = options.repeat ?? (await this.repeatable(url, options, started)); - let limits = 0; - let failures = 0; - for (;;) { - const remaining = this.deadline - this.deps.now(); - if (remaining <= 0) throw new GitHubDeadline(); - let response: Response | undefined; - let failure: unknown; - try { - response = await this.deps.fetch(url, { - ...(options.body === undefined - ? options.method && { method: options.method } - : { method: options.method ?? "POST", body: JSON.stringify(options.body) }), - headers: { - Accept: options.accept ?? "application/vnd.github+json", - Authorization: `Bearer ${this.deps.token}`, - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "quest", - ...(options.etag ? { "If-None-Match": options.etag } : {}), - }, - ...(Number.isFinite(remaining) ? { signal: AbortSignal.timeout(Math.ceil(remaining)) } : {}), - }); - } catch (error) { - if (this.deps.now() >= this.deadline) throw new GitHubDeadline(); - failure = error; - } - let retry: number | undefined; - if (response) { - this.pollInterval = Math.max(this.pollInterval, seconds(response.headers.get("x-poll-interval")) ?? 0); - // Read the body here, so a connection that drops mid-body is retried like one that drops before it. - const read = await response.text().then( - (text) => ({ text }), - (error: unknown) => ({ error }), - ); - // A rate limit is waited out even when the caller allows its status, such as a 403 refusal. - if ( - response.ok || - response.status === 304 || - (options.allow?.includes(response.status) && !limited(response, "text" in read ? read.text : "")) - ) { - const { status, statusText, headers } = response; - if ("text" in read) - return new Response(NULL_BODY.includes(status) ? null : read.text, { - status, - statusText, - headers, - }); - if (this.deps.now() >= this.deadline) throw new GitHubDeadline(); - failure = read.error; - } else { - // A refusal's status still says what to do when its body was cut off. - const body = "text" in read ? read.text : ""; - if (limited(response, body)) { - // GitHub turned the request away unprocessed, so any request repeats. - await this.waitOut( - response.headers, - limits++, - `GitHub HTTP ${response.status} for ${url.pathname}; rate limit persisted`, - ); - continue; - } - const reason = explanation(body); - if (response.status < 500) - throw new Error( - `GitHub HTTP ${response.status} for ${url.pathname}; ${reason ?? "check repository access and token permissions"}`, - ); - failure = new Error( - `GitHub HTTP ${response.status} for ${url.pathname}; ${reason ?? "GitHub server error"}`, - ); - retry = seconds(response.headers.get("retry-after")); - } - } - if (repeat === false || failures >= TRANSIENT_RETRIES) throw failure; - const delay = retry ?? backoff(failures); - failures++; - this.deps.warn(`${message(failure)}; retrying in ${Math.ceil(delay / 1000)} seconds`); - await this.pause(delay); - if (typeof repeat === "function") { - const landed = await repeat(started); - if (landed) return landed; - } - } - } - - /** - * Run `attempt` again while `transient` names its result a passing GitHub failure, with the bounded - * backoff of a failed request, and return its last result. - */ - async retry(attempt: () => Promise, transient: (result: T) => string | undefined): Promise { - for (let failures = 0; ; failures++) { - const result = await attempt(); - const reason = transient(result); - if (reason === undefined || failures >= TRANSIENT_RETRIES) return result; - const delay = backoff(failures); - this.deps.warn(`${reason}; retrying in ${delay / 1000} seconds`); - await this.pause(delay); - } - } - - /** - * Wait out a rate limit, the `limits`th in a row, as its headers say: Retry-After, else the - * primary limit's reset, else a minute doubling each time. Fails with `persisted` after six. - */ - private async waitOut(headers: Headers, limits: number, persisted: string): Promise { - if (limits >= 6) throw new Error(persisted); - const retry = seconds(headers.get("retry-after")); - const reset = - headers.get("x-ratelimit-remaining") === "0" ? seconds(headers.get("x-ratelimit-reset")) : undefined; - const delay = retry ?? (reset === undefined ? 60_000 * 2 ** limits : Math.max(1000, reset - this.deps.now())); - this.deps.warn(`GitHub rate limited this request; waiting ${Math.ceil(delay / 1000)} seconds`); - await this.pause(delay); - } - - /** - * How a request may repeat after a 5xx or a dropped connection. A GET reads and a PUT, PATCH, or - * DELETE sets state, so each repeats; so does adding labels. A merge is confirmed first by its - * pinned head, and a POST that creates something by what it created: a comment by its body, among - * those not already there before it, a pull request by its branch. Any other POST never repeats. - */ - private async repeatable(url: URL, options: RequestOptions, started: number): Promise { - const method = options.method ?? (options.body === undefined ? "GET" : "POST"); - const body = typeof options.body === "object" && options.body !== null ? (options.body as ObjectValue) : {}; - const merge = /^(\/repos\/[^/]+\/[^/]+\/pulls\/\d+)\/merge$/.exec(url.pathname); - if (method === "PUT" && merge) { - return async () => { - const pull = object(await (await this.request(merge[1])).json()); - // Merged at another head is not this merge; repeating it gets GitHub's refusal for the caller. - if (pull.merged !== true || typeof pull.merge_commit_sha !== "string") return; - if (body.sha !== undefined && object(pull.head).sha !== body.sha) return; - return Response.json({ - merged: true, - sha: pull.merge_commit_sha, - message: "Pull Request successfully merged", - }); - }; - } - if (method !== "POST") return true; - const issue = /^\/repos\/[^/]+\/[^/]+\/issues\/\d+\/(labels|comments)$/.exec(url.pathname); - if (issue?.[1] === "labels") return true; - if (issue?.[1] === "comments" && typeof body.body === "string") { - const since = new Date(started - CLOCK_SKEW).toISOString(); - const identical = async () => - (await this.all(`${url.pathname}?since=${since}&per_page=100`)).filter( - (comment) => comment.body === body.body, - ); - // An identical comment already there, such as one from a requeued PR's earlier ejection, is not this one. - const earlier = new Set((await identical()).map((comment) => comment.id)); - return async () => { - const found = (await identical()).find((comment) => !earlier.has(comment.id)); - return found && Response.json(found, { status: 201 }); - }; - } - const pulls = /^\/repos\/([^/]+)\/[^/]+\/pulls$/.exec(url.pathname); - if (pulls && typeof body.head === "string") { - const head = body.head.includes(":") ? body.head : `${pulls[1]}:${body.head}`; - return async (started) => { - const open = await this.list( - `${url.pathname}?state=open&head=${encodeURIComponent(head)}&per_page=100`, - ); - const found = open.rows.find( - (pull) => - typeof pull.created_at === "string" && Date.parse(pull.created_at) >= started - CLOCK_SKEW, - ); - return found && Response.json(found, { status: 201 }); - }; - } - return false; - } - - /** One page of a REST listing, and its response for the next link. */ - async list(path: string): Promise<{ rows: ObjectValue[]; response: Response }> { - const response = await this.request(path); - if (response.status !== 200) throw new Error("unexpected GitHub 304 without a cached response"); - return { rows: array(await response.json()), response }; - } - - /** Every page of a REST listing. */ - async all(path: string): Promise { - const rows: ObjectValue[] = []; - for (let next: string | undefined = path; next;) { - const page = await this.list(next); - rows.push(...page.rows); - next = link(page.response, "next"); - } - return rows; - } - - /** - * Run a GraphQL query and return its data, failing on any reported error. A query repeats after - * a 5xx or a dropped connection; a mutation only once its effect is confirmed missing, and one - * confirmed to have taken effect returns no data. GitHub can report a rate limit as a 200 whose - * errors say `RATE_LIMITED`; that is waited out like a REST one. - */ - async graphql(query: string, variables: ObjectValue): Promise { - const repeat = this.mutationRepeat(query, variables); - for (let limits = 0; ; limits++) { - const response = await this.request("/graphql", { body: { query, variables }, repeat }); - const body = object(await response.json()); - if (body.errors === undefined) return object(body.data); - const errors = array(body.errors); - if (!errors.some((error) => error.type === "RATE_LIMITED")) - throw new GitHubGraphQLError( - errors.map((error) => (typeof error.type === "string" ? error.type : undefined)), - errors.map((error) => String(error.message)), - ); - // GitHub turned the operation away unprocessed, so any operation repeats. - await this.waitOut(response.headers, limits, "GitHub GraphQL rate limit persisted"); - } - } - - /** - * Repeat a query; confirm a pull request mutation `PULL_MUTATIONS` knows; never repeat another. - * One pinned to `expectedHeadOid` is confirmed only at that head; at any other, its repeat gets - * GitHub's refusal for the caller. - */ - private mutationRepeat(query: string, variables: ObjectValue): Repeat { - if (!/^\s*mutation\b/.test(query)) return true; - const call = /^\s*mutation\b[^{]*\{\s*(\w+)\s*\(\s*input:\s*\{\s*pullRequestId:\s*\$(\w+)\b/.exec(query); - const effect = call && Object.hasOwn(PULL_MUTATIONS, call[1]) ? PULL_MUTATIONS[call[1]] : undefined; - const id = call && variables[call[2]]; - if (!effect || typeof id !== "string") return false; - const pinned = /\bexpectedHeadOid:\s*\$(\w+)\b/.exec(query); - const head = pinned ? variables[pinned[1]] : undefined; - const read = `query($id: ID!) { node(id: $id) { ... on PullRequest { headRefOid ${effect.fields} } } }`; - return async () => { - const pull = object((await this.graphql(read, { id })).node); - const landed = (head === undefined || pull.headRefOid === head) && effect.landed(pull); - return landed ? Response.json({ data: {} }) : undefined; - }; - } -} - -/** - * Pull request mutations whose effect can be read back: the fields that show it, and whether - * they do. Each takes its pull request as `pullRequestId`. - */ -const PULL_MUTATIONS: Record = { - mergePullRequest: { fields: "merged", landed: (pull) => pull.merged === true }, - enqueuePullRequest: { - fields: "merged mergeQueueEntry { state }", - landed: (pull) => pull.merged === true || pull.mergeQueueEntry != null, - }, - enablePullRequestAutoMerge: { - fields: "merged autoMergeRequest { enabledAt } mergeQueueEntry { state }", - landed: (pull) => pull.merged === true || pull.autoMergeRequest != null || pull.mergeQueueEntry != null, - }, - disablePullRequestAutoMerge: { - fields: "autoMergeRequest { enabledAt }", - landed: (pull) => pull.autoMergeRequest === null, - }, - markPullRequestReadyForReview: { fields: "isDraft", landed: (pull) => pull.isDraft === false }, -}; - -/** 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, from {@link workflowKey}. */ - 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; -} - -/** - * 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"}`, - ); -} - -/** - * A workflow file as requirements name it: its repository's `owner/name` and its path, since a ruleset may - * require a workflow from another repository at the same path as a local one. - */ -export function workflowKey(repository: string, path: string): string { - return `${repository.toLowerCase()}:${path}`; -} - -/** The workflow files `rules` require, each keyed by {@link workflowKey} from its rule's `repository_id`. */ -async function requiredWorkflows(github: GitHub, rules: ObjectValue[]): Promise { - const names = new Map(); - const keys: string[] = []; - for (const row of rules - .filter((rule) => rule.type === "workflows") - .flatMap((rule) => array(object(rule.parameters).workflows))) { - const id = Number(row.repository_id); - if (!names.has(id)) { - const response = await github.request(`/repositories/${id}`, { allow: [404] }); - // A repository the token cannot see is still required; nothing on the head can match its name. - names.set(id, response.ok ? string(object(await response.json()).full_name) : `repository ${id}`); - } - keys.push(workflowKey(names.get(id)!, string(row.path))); - } - return [...new Set(keys)]; -} - -/** 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 branchRules(github, `${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: await requiredWorkflows(github, rules), - rules, - // Unknown counts as on, so a reader that needs the settings reads them rather than assume none. - classic: protection.enabled !== false, - }; -} - -/** Every GitHub Actions workflow run on `head`. Reading them needs Actions read access. */ -export async function workflowRuns(github: GitHub, repository: Repository, head: string): Promise { - const runs: WorkflowRun[] = []; - let path: string | undefined = - `/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}/actions/runs?head_sha=${head}&per_page=100`; - while (path) { - const response: Response = await github.request(path); - for (const row of array(object(await response.json()).workflow_runs)) - runs.push({ - ...row, - id: Number(row.id), - workflow_id: Number(row.workflow_id), - check_suite_id: Number(row.check_suite_id), - // A run not yet started, such as one awaiting approval, has no start time, so its creation stands in. - run_started_at: string(row.run_started_at ?? row.created_at), - }); - path = link(response, "next"); - } - return runs; -} - -/** - * Each check's latest report on `head` that still counts, as {@link currentContexts} judges it, and the - * head's workflow runs when they were read: only when a latest report is a cancelled check run, the only - * report they can drop. - */ -export async function currentChecks( - github: GitHub, - repository: Repository, - head: string, - contexts: T[], -): Promise<{ checks: T[]; runs?: WorkflowRun[] }> { - const latest = latestContexts(contexts); - if (!latest.some(cancelled)) return { checks: latest }; - const runs = await workflowRuns(github, repository, head); - return { checks: currentContexts(contexts, runs), runs }; -} - -/** - * Whether a merged PR in `repository` had `commit` as its head or below it, as when `quest land` merged - * the base into the head before squashing; false for a commit GitHub does not have. - */ -export async function mergedHolds(github: GitHub, repository: Repository, commit: string): Promise { - const data = await github.graphql( - "query($owner: String!, $name: String!, $oid: GitObjectID!) { repository(owner: $owner, name: $name) { " + - "object(oid: $oid) { ... on Commit { associatedPullRequests(first: 100) { nodes { merged headRefOid } } } } } }", - { owner: repository.owner, name: repository.repo, oid: commit }, - ); - const found = object(data.repository).object; - const pulls = found === null || found === undefined ? undefined : object(found).associatedPullRequests; - if (pulls === null || pulls === undefined) return false; // Unknown, or not a commit. - for (const pull of array(object(pulls).nodes)) { - if (pull.merged !== true) continue; - const head = string(pull.headRefOid); - if (head === commit) return true; - // Association alone is no proof the merged head still reaches the commit, such as after a force push. - // Comparing from the head leaves no commits or files to send when it does. - const response = await github.request( - `/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}/compare/${head}...${commit}`, - { - allow: [404], - }, - ); - if (response.ok && Number(object(await response.json()).ahead_by) === 0) return true; - } - return false; -} - -/** A login, a committer name without spaces, or `-` for a deleted account. */ -export function actor(value: unknown): string { - if (value === null || value === undefined) return "-"; - const record = object(value); - return typeof record.login === "string" - ? record.login - : typeof record.name === "string" - ? record.name.replace(/\s/g, "_") - : "-"; -} - -export function object(value: unknown): ObjectValue { - if (typeof value !== "object" || value === null || Array.isArray(value)) - throw new Error("invalid GitHub response: expected an object"); - return value as ObjectValue; -} - -export function array(value: unknown): ObjectValue[] { - if (!Array.isArray(value)) throw new Error("invalid GitHub response: expected an array"); - return value.map(object); -} - -export function string(value: unknown): string { - if (typeof value !== "string" || !value) throw new Error("invalid GitHub response: expected a nonempty string"); - return value; -} - -export function link(response: Response, relation: string): string | undefined { - return response.headers - .get("link") - ?.split(",") - .map((entry) => /<([^>]+)>;\s*rel="([^"]+)"/.exec(entry.trim())) - .find((match) => match?.[2] === relation)?.[1]; -} - -/** GitHub's reason in an error body, such as why it refused to create a pull request. */ -function explanation(body: string): string | undefined { - try { - const parsed = object(JSON.parse(body)); - const errors = Array.isArray(parsed.errors) ? (parsed.errors as unknown[]) : []; - const parts = [parsed.message, ...errors.map((error) => (error as ObjectValue | null)?.message)]; - return parts.filter((part): part is string => typeof part === "string" && part !== "").join(": ") || undefined; - } catch { - return undefined; - } -} - -function message(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function seconds(value: string | null): number | undefined { - if (value === null || !/^\d+(?:\.\d+)?$/.test(value)) return undefined; - const result = Number(value) * 1000; - return Number.isFinite(result) ? result : undefined; -} diff --git a/src/cli/hold.ts b/src/cli/hold.ts index 0c3fa2de..d0873e90 100644 --- a/src/cli/hold.ts +++ b/src/cli/hold.ts @@ -4,7 +4,6 @@ // `quest merge` takes no label: the pinned auto-merge or merge-queue entry it creates is the queue signal. import { ACTIVE_LABEL, formatFinding, handedBack, isHoldLabel, latestHold, heldToken, withoutHandBack } from "../core"; import { cleanAutoMergeRefusal, immediateMerge, lacksApproval, queueHead } from "../github/merge"; -import { repositoryPath } from "./claims"; import { array, currentChecks, @@ -16,7 +15,8 @@ import { type PullRequest, requirements, string, -} from "./github"; + repositoryPath, +} from "../github/client"; import { ALLOWS, LAND_CHECK, METHOD_NAMES, contexts, enqueue } from "./land"; import { QUEUE_CHECK } from "./land-queue"; import type { LandingCheck } from "./landed"; diff --git a/src/cli/land-apply.ts b/src/cli/land-apply.ts index 4cc260f3..4c5bca81 100644 --- a/src/cli/land-apply.ts +++ b/src/cli/land-apply.ts @@ -3,15 +3,8 @@ import { spawnSync } from "node:child_process"; import { rmSync } from "node:fs"; import { resolve } from "node:path"; import { gunzipSync, gzipSync } from "node:zlib"; -import { - GitHub, - type GitHubDependencies, - type PullRequest, - object, - remoteRepository, - requireRemote, - string, -} from "./github"; +import { remoteRepository, requireRemote } from "./github"; +import { GitHub, type GitHubDependencies, type PullRequest, object, string } from "../github/client"; import { discoveryEnv } from "./git"; import { contains, git, gitEnvironment, tryGit } from "./land"; import { Refusal } from "./start"; diff --git a/src/cli/land-bump.ts b/src/cli/land-bump.ts index fae7c546..228cf450 100644 --- a/src/cli/land-bump.ts +++ b/src/cli/land-bump.ts @@ -14,7 +14,7 @@ import { requirements, string, workflowRuns, -} from "./github"; +} from "../github/client"; import { land } from "./hold"; import { commandEnvironment, contexts, git, type RemoteRead } from "./land"; import { push, remoteHead } from "./land-head"; diff --git a/src/cli/land-checks.ts b/src/cli/land-checks.ts index 49b3f11c..fe797472 100644 --- a/src/cli/land-checks.ts +++ b/src/cli/land-checks.ts @@ -1,5 +1,5 @@ // GitHub Actions job logs, and a PR's own checks on a head as the land fixer's context: a signal, never a gate. -import { array, type GitHub, object, type ObjectValue, string } from "./github"; +import { array, type GitHub, object, type ObjectValue, string } from "../github/client"; import { redactTokens } from "./land-fixer"; import { classifyCheck } from "./status"; diff --git a/src/cli/land-fixer.ts b/src/cli/land-fixer.ts index 51610d11..a9bbd104 100644 --- a/src/cli/land-fixer.ts +++ b/src/cli/land-fixer.ts @@ -17,7 +17,7 @@ import { import { constants as osConstants } from "node:os"; import { basename, join } from "node:path"; import { reviewSection } from "../core"; -import { GitHub, type GitHubDependencies, object, string } from "./github"; +import { GitHub, type GitHubDependencies, object, string } from "../github/client"; import { baseRemote, commandEnvironment, diff --git a/src/cli/land-halt.ts b/src/cli/land-halt.ts index 903adade..a280dae0 100644 --- a/src/cli/land-halt.ts +++ b/src/cli/land-halt.ts @@ -1,7 +1,7 @@ // A halted queue: the base fails its check alone, so nothing lands until a check of it passes. import { existsSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { GitHub, object } from "./github"; +import { GitHub, object } from "../github/client"; import type { Plan } from "./land"; /** diff --git a/src/cli/land-head.ts b/src/cli/land-head.ts index a7d682d0..a9f37813 100644 --- a/src/cli/land-head.ts +++ b/src/cli/land-head.ts @@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { gitAuthentication } from "./land-git"; -import { GitHub, type GitHubDependencies, type Repository, object, string } from "./github"; +import { GitHub, type GitHubDependencies, type Repository, object, string } from "../github/client"; import { baseRemote, type LandMethod, diff --git a/src/cli/land-queue.ts b/src/cli/land-queue.ts index 0f5929e0..37643683 100644 --- a/src/cli/land-queue.ts +++ b/src/cli/land-queue.ts @@ -12,7 +12,7 @@ import { type Repository, requirements, string, -} from "./github"; +} from "../github/client"; import { baseRemote, eject, diff --git a/src/cli/land-serve.ts b/src/cli/land-serve.ts index 6dfb9957..8449f1f9 100644 --- a/src/cli/land-serve.ts +++ b/src/cli/land-serve.ts @@ -21,7 +21,7 @@ import { import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; -import { GitHub, type GitHubDependencies, type Repository, object, string } from "./github"; +import { GitHub, type GitHubDependencies, type Repository, object, string } from "../github/client"; import { finishFallback, forgetHeads, diff --git a/src/cli/land-watch-fix.ts b/src/cli/land-watch-fix.ts index baf66b21..3fddc4d6 100644 --- a/src/cli/land-watch-fix.ts +++ b/src/cli/land-watch-fix.ts @@ -3,7 +3,15 @@ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { basename, join } from "node:path"; import { isHoldLabel } from "../core"; -import { array, GitHub, type GitHubDependencies, GitHubGraphQLError, object, type ObjectValue, string } from "./github"; +import { + array, + GitHub, + type GitHubDependencies, + GitHubGraphQLError, + object, + type ObjectValue, + string, +} from "../github/client"; import { type Queued, lacksApproval, queueHead } from "../github/merge"; import { UNREFUSED } from "./hold"; import { baseRemote, enqueue, type Entry, type LandMethod, mergeBase, type RemoteRead } from "./land"; diff --git a/src/cli/land-watch.ts b/src/cli/land-watch.ts index 98816a65..593d2ea8 100644 --- a/src/cli/land-watch.ts +++ b/src/cli/land-watch.ts @@ -13,7 +13,7 @@ import { type ObjectValue, type Repository, string, -} from "./github"; +} from "../github/client"; import { lacksApproval } from "../github/merge"; import type { Entry, LandMethod, Plan } from "./land"; import { authenticatedRead } from "./land-git"; diff --git a/src/cli/land.ts b/src/cli/land.ts index 73f63b18..bd50931a 100644 --- a/src/cli/land.ts +++ b/src/cli/land.ts @@ -2,7 +2,6 @@ import { spawnSync } from "node:child_process"; import { resolve } from "node:path"; import { type Finding, check, formatFinding, isQuest, remove } from "../core"; -import { repositoryPath } from "./claims"; import { programEnvironment } from "./program"; import { GitHub, @@ -16,7 +15,8 @@ import { currentChecks, requirements, string, -} from "./github"; + repositoryPath, +} from "../github/client"; import type { WorkflowRun } from "../github/checks"; import { appOf } from "../github/checks"; import { classifyCheck, reported, type Needs } from "./status"; diff --git a/src/cli/main.ts b/src/cli/main.ts index 743bbe23..8a95be65 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -37,15 +37,8 @@ import { import { held, openDrafts, reach, target, upstream, upstreamRefs } from "./claims"; import { PENDING_MS } from "./admission"; import { gate, QUICK_MS } from "./gate"; -import { - gitHubDependencies, - GitHubInputError, - mergedHolds, - pullRequest, - readRemote, - remoteRepository, - type Repository, -} from "./github"; +import { gitHubDependencies, GitHubInputError, pullRequest, readRemote, remoteRepository } from "./github"; +import { mergedHolds, type Repository } from "../github/client"; import { type Hold, finish, land, release, take } from "./hold"; import { LAND_CHECK } from "./land"; import { apply, pushCommand } from "./land-apply"; diff --git a/src/github/models.ts b/src/cli/models-record.ts similarity index 98% rename from src/github/models.ts rename to src/cli/models-record.ts index 24e0efe3..3a31b9d9 100644 --- a/src/github/models.ts +++ b/src/cli/models-record.ts @@ -1,7 +1,7 @@ // A pull request body's `## Models` record, which names the PR's size and each session that implemented it. import { SIZES } from "../core"; -import { REVIEWERS, WRITERS } from "../cli/models"; +import { REVIEWERS, WRITERS } from "./models"; const SECTION = /^## Models[ \t]*\r?$/m; diff --git a/src/cli/online-review.ts b/src/cli/online-review.ts index 3f64eb12..22a0a432 100644 --- a/src/cli/online-review.ts +++ b/src/cli/online-review.ts @@ -2,7 +2,7 @@ // thread, and any review whose // body opens with `Automated review by `, as a reviewer posting under a person's account does. A finding is a // review thread one opens, or a `[P]` line in its review body; whoever answers it says whether it was taken. -import { type GitHub, type ObjectValue, type Repository, array, object } from "./github"; +import { type GitHub, type ObjectValue, type Repository, array, object } from "../github/client"; /** One online reviewer's record on one PR. */ export interface OnlineReview { diff --git a/src/cli/review-record.ts b/src/cli/review-record.ts index 076076de..d24c3c02 100644 --- a/src/cli/review-record.ts +++ b/src/cli/review-record.ts @@ -1,10 +1,12 @@ // `quest review` rewrites the `## Review` section of its branch's open PR after each round, so the head // it reviewed counts without an agent copying the round into the body by hand. import { reviewSection } from "../core"; -import { recorded } from "../github/models"; -import { type ClaimDependencies, repositoryPath, upstream } from "./claims"; +import { recorded } from "./models-record"; +import { type ClaimDependencies, upstream } from "./claims"; +import { repositoryPath } from "../github/client"; import { git } from "./git"; -import { requireRemote, string } from "./github"; +import { requireRemote } from "./github"; +import { string } from "../github/client"; import { type PullModels, type Round, SEVERITIES, type Tally, located, parseBase, sameSeat } from "./review"; const HARNESSES: Record = { claude: "Claude Code", codex: "Codex" }; diff --git a/src/cli/review.ts b/src/cli/review.ts index 0ab88765..2576899d 100644 --- a/src/cli/review.ts +++ b/src/cli/review.ts @@ -21,7 +21,7 @@ import { discoveryEnv, git } from "./git"; import { LIMIT, codexReset } from "./limit"; import { DEFAULT_SIZE, type Model, REVIEWERS } from "./models"; import { programEnvironment, requireProgram } from "./program"; -import type { recorded } from "../github/models"; +import type { recorded } from "./models-record"; import { save } from "./worktree"; /** A mistake in the arguments, checkout, or environment, reported as a usage error. */ diff --git a/src/cli/start.ts b/src/cli/start.ts index bd1b287c..9529b0f1 100644 --- a/src/cli/start.ts +++ b/src/cli/start.ts @@ -6,21 +6,18 @@ import { randomBytes } from "node:crypto"; import { questBranch } from "../core"; import { type ClaimDependencies, - type OpenPull, type Target, type Upstream, ensure, held, - mergedPull, - openPullRequests, - pullsFrom, remoteBranches, - repositoryPath, upstream, upstreamRefs, } from "./claims"; import { GitError, git } from "./git"; -import { object, requireRemote, string } from "./github"; +import { requireRemote } from "./github"; +import { object, repositoryPath, string } from "../github/client"; +import { type OpenPull, mergedPull, openPullRequests, pullsFrom } from "../github/claims"; /** Someone else holds the quest, or the claim cannot be released; distinct from a failure. */ export class Refusal extends Error {} diff --git a/src/cli/status.ts b/src/cli/status.ts index 9668769e..828ea172 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -14,7 +14,7 @@ import { requirements, string, workflowKey, -} from "./github"; +} from "../github/client"; import { appOf, checksState, contextOutcome } from "../github/checks"; import { EJECTED, LAND_CHECK, pinned } from "./land"; import { reviewSection } from "../core"; diff --git a/src/cli/usage.ts b/src/cli/usage.ts index 30f0511e..4df8d847 100644 --- a/src/cli/usage.ts +++ b/src/cli/usage.ts @@ -6,12 +6,14 @@ import { closeSync, existsSync, openSync, readdirSync, readSync } from "node:fs" import { homedir } from "node:os"; import { join } from "node:path"; import { type Size, SIZES, handedBack, questBranch } from "../core"; -import { recorded } from "../github/models"; -import { type ClaimDependencies, repositoryPath, upstream } from "./claims"; +import { recorded } from "./models-record"; +import { type ClaimDependencies, upstream } from "./claims"; +import { repositoryPath } from "../github/client"; import { discoveryEnv, git } from "./git"; import { fixes } from "./land-fixer"; import { MERGER } from "./land-watch-fix"; -import { type GitHub, type ObjectValue, type Repository, githubRepository, link, requireRemote } from "./github"; +import { githubRepository, requireRemote } from "./github"; +import { type GitHub, type ObjectValue, type Repository, link } from "../github/client"; import { type OnlineReview, readOnlineReviews } from "./online-review"; import { type Counts, type ReviewSummary, findingsText, parseReview } from "./review-record"; import { SEVERITIES } from "./review"; diff --git a/src/cli/wait.ts b/src/cli/wait.ts index 39bb8efa..9c665b15 100644 --- a/src/cli/wait.ts +++ b/src/cli/wait.ts @@ -1,19 +1,19 @@ // GitHub-specific PR activity waiting stays outside the repository-independent core. import { createHash } from "node:crypto"; +import { GitHubInputError } from "./github"; import { actor, array, currentChecks, GitHub, type GitHubDependencies, - GitHubInputError, link, object, type ObjectValue, type PullRequest, string, -} from "./github"; +} from "../github/client"; import { BRANCH_UPDATES, classifyCheck, diff --git a/src/github/claims.ts b/src/github/claims.ts new file mode 100644 index 00000000..617cb267 --- /dev/null +++ b/src/github/claims.ts @@ -0,0 +1,148 @@ +// Who holds a quest on GitHub: its claim branch upstream or in the head fork, unless that branch is a +// merged PR's head, or the earliest open PR from a branch of that name in either. +import { handedBack, landed } from "../core"; +import { type GitHub, type Repository, object, repositoryPath, string } from "./client"; + +/** An open PR, from any head repository, as the claim checks read it. */ +export interface OpenPull { + number: number; + url: string; + branch: string; + /** The head repository as `owner/repo`, or `null` once a fork is deleted. */ + head: string | null; + draft: boolean; + /** Epoch milliseconds. The earliest open PR on a branch holds the claim. */ + created: number; + /** Epoch milliseconds of the last push, comment, or edit. */ + updated: number; + /** Its body has a `## Waiting` section, so it waits on the maintainer. */ + handedBack: boolean; + body: string; +} + +/** A held quest branch, and the open PR from it if there is one. */ +export interface Claim { + branch: string; + /** The PR holding the claim: the earliest open one. */ + url: string | null; + pull: OpenPull | null; +} + +/** Which branches someone holds, and which landed branches a new claim replaces. */ +export interface Holdings { + /** Sorted by branch. */ + claims: Claim[]; + /** + * Branches in the head fork, or upstream without one, whose tip is the head of a merged PR, by + * that tip. Their work landed, so they hold nothing and a claim may replace them. + */ + landed: Map; +} + +/** A repository claim branches live in, and its `quest/*` branch tips when the caller already read them. */ +export interface Source { + repository: Repository; + tips?: Map; +} + +/** Where claims live: the upstream repository, and the fork a contributor pushes claims to, if any. */ +export interface Sources { + upstream: Source; + /** Absent when claims are pushed upstream. */ + fork?: Source; +} + +/** Every open PR in `repository`, from any head repository. */ +export async function openPullRequests(github: GitHub, repository: Repository): Promise { + const rows = await github.all(`${repositoryPath(repository)}/pulls?state=open&per_page=100`); + return rows.map((row) => { + const head = object(row.head); + const repo = head.repo === null || head.repo === undefined ? null : object(head.repo); + return { + number: Number(row.number), + url: string(row.html_url), + branch: string(head.ref), + head: repo === null ? null : string(repo.full_name), + draft: row.draft === true, + created: Date.parse(string(row.created_at)), + updated: Date.parse(string(row.updated_at)), + handedBack: typeof row.body === "string" && handedBack(row.body), + body: typeof row.body === "string" ? row.body : "", + }; + }); +} + +/** The open PRs from `branch`, earliest first: the first one holds the claim. */ +export function pullsFrom(pulls: OpenPull[], branch: string): OpenPull[] { + return pulls.filter((pull) => pull.branch === branch).sort((a, b) => a.created - b.created || a.number - b.number); +} + +/** The `quest/*` branches among `refs`, full ref names with their tips, by branch name. */ +export function questBranches(refs: Map): Map { + const branches = new Map(); + for (const [ref, sha] of refs) + if (ref.startsWith("refs/heads/quest/")) branches.set(ref.slice("refs/heads/".length), sha); + return branches; +} + +/** The `quest/*` branches of `repository`, with their tips. */ +export async function claimBranches(github: GitHub, repository: Repository): Promise> { + const rows = await github.all(`${repositoryPath(repository)}/git/matching-refs/heads/quest/`); + return questBranches(new Map(rows.map((row) => [string(row.ref), string(object(row.object).sha)]))); +} + +/** + * Which of `branches` someone holds: the earliest open PR from it in either source, else its branch in + * either. A branch whose tip is a merged PR's head holds nothing: its work landed. A closed, unmerged + * PR's branch still holds its quest, since that work may be wanted back. A fork's own branches and PRs + * count, so a claim pushed there is seen before its PR opens. Tips a source leaves out are read here. + */ +export async function held(github: GitHub, sources: Sources, branches: string[]): Promise { + const { upstream, fork } = sources; + const read = async (source: Source) => ({ + repository: source.repository, + tips: source.tips ?? (await claimBranches(github, source.repository)), + }); + const all = [await read(upstream), ...(fork ? [await read(fork)] : [])]; + const pulls = (await Promise.all(all.map((source) => openPullRequests(github, source.repository)))).flat(); + const pushTo = all[all.length - 1]; + const landed = new Map(); + const holds = async (branch: string): Promise => { + const pull = pullsFrom(pulls, branch)[0] ?? null; + if (pull !== null) return { branch, url: pull.url, pull }; + let holding = false; + for (const source of all) { + const tip = source.tips.get(branch); + if (tip === undefined) continue; + if ((await mergedPull(github, upstream.repository, source.repository, branch, tip)) !== null) { + if (source === pushTo) landed.set(branch, tip); + } else { + holding = true; + } + } + return holding ? { branch, url: null, pull: null } : null; + }; + const found = await Promise.all([...new Set(branches)].sort().map(holds)); + return { claims: found.filter((claim): claim is Claim => claim !== null), landed }; +} + +/** The URL of the PR from `head`'s `branch` that merged into `repository` with `tip` as its head, or `null`. */ +export async function mergedPull( + github: GitHub, + repository: Repository, + head: Repository, + branch: string, + tip: string, +): Promise { + const query = new URLSearchParams({ state: "closed", head: `${head.owner}:${branch}`, per_page: "100" }); + const rows = await github.all(`${repositoryPath(repository)}/pulls?${query}`); + const pull = landed( + tip, + rows.map((row) => ({ + head: string(object(row.head).sha), + merged: typeof row.merged_at === "string", + url: string(row.html_url), + })), + ); + return pull?.url ?? null; +} diff --git a/src/github/client.ts b/src/github/client.ts new file mode 100644 index 00000000..f1c56c9c --- /dev/null +++ b/src/github/client.ts @@ -0,0 +1,594 @@ +// The GitHub client: authenticated REST and GraphQL requests with paging, retries, rate limits, and a deadline. +// No Node APIs, so it runs wherever `fetch` does; the caller supplies the token and clock. +import { cancelled, currentContexts, latestContexts, type RollupContext, type WorkflowRun } from "./checks"; + +const API = "https://api.github.com"; +const MAX_SLEEP = 2_147_483_647; + +/** A repository on github.com. */ +export interface Repository { + owner: string; + repo: string; +} + +/** A pull request by its repository and number. */ +export interface PullRequest extends Repository { + number: number; +} + +/** What the client needs from its host: `fetch`, a clock, a way to pause, the token, and where warnings go. */ +export interface GitHubDependencies { + fetch: typeof fetch; + now(): number; + sleep(milliseconds: number): Promise; + token: string; + warn(message: string): void; +} + +/** A request or pause reached the client's deadline; the caller decides what that means. */ +export class GitHubDeadline extends Error { + constructor() { + super("deadline reached"); + } +} + +/** GitHub answered a GraphQL operation with errors; `types` are their types, `undefined` for an untyped one. */ +export class GitHubGraphQLError extends Error { + constructor( + readonly types: (string | undefined)[], + messages: string[], + ) { + super(`GitHub GraphQL error: ${messages.join("; ")}`); + } +} + +/** A JSON object from a GitHub response. */ +export type ObjectValue = Record; + +/** Retries of a 5xx or a dropped connection, waiting 1, 2, 4, then 8 seconds unless GitHub says how long. */ +const TRANSIENT_RETRIES = 4; +const backoff = (failures: number) => 1000 * 2 ** failures; +/** How far GitHub's clock may run behind ours when looking for what a lost request created. */ +const CLOCK_SKEW = 60_000; +/** Statuses whose response has no body. */ +const NULL_BODY = [204, 205, 304]; + +/** + * Whether a request may repeat after a 5xx or a dropped connection, after which GitHub may have + * acted anyway: `true` when repeating leaves the same state, or a check, run before each repeat, + * for the response of an earlier attempt that took effect (undefined if none did). + */ +export type Repeat = boolean | ((started: number) => Promise); + +export interface RequestOptions { + etag?: string; + body?: unknown; + method?: "POST" | "PUT" | "PATCH" | "DELETE"; + allow?: number[]; + /** A media type other than JSON, such as `application/vnd.github.diff`. */ + accept?: string; + /** Overrides how the request repeats; the default follows its method and endpoint. */ + repeat?: Repeat; +} + +/** Whether GitHub turned a request away for a rate limit, which any request may wait out and repeat. */ +function limited(response: Response, body: string): boolean { + return ( + response.status === 429 || + (response.status === 403 && + (response.headers.has("retry-after") || + response.headers.get("x-ratelimit-remaining") === "0" || + /rate limit/i.test(body))) + ); +} + +/** Authenticated REST and GraphQL requests that wait out rate limits, bounded by an optional deadline. */ +export class GitHub { + /** The longest X-Poll-Interval GitHub asked for since the caller last set this, in milliseconds. */ + pollInterval = 0; + + constructor( + private readonly deps: GitHubDependencies, + private readonly deadline = Infinity, + ) {} + + /** Sleep, cut short by the deadline. */ + async pause(milliseconds: number): Promise { + const remaining = this.deadline - this.deps.now(); + if (remaining <= 0) throw new GitHubDeadline(); + await this.deps.sleep(Math.min(milliseconds, remaining, MAX_SLEEP)); + if (this.deps.now() >= this.deadline) throw new GitHubDeadline(); + } + + /** + * A 2xx or 304 response, its body already read; anything else fails, with GitHub's reason when + * it gives one, unless `allow` lists its status. Rate limits are waited out. A 5xx or a + * connection dropped before the body is in, after which GitHub may or may not have acted, is + * retried with bounded backoff as `repeat` allows, then fails with the last error. `body` makes + * it a POST unless `method` says otherwise. + */ + async request(path: string, options: RequestOptions = {}): Promise { + // Follow only same-origin pagination links; never send a token to another host. + const url = new URL(path, API); + if (url.origin !== API || url.username || url.password) throw new Error("invalid GitHub pagination URL"); + const started = this.deps.now(); + const repeat = options.repeat ?? (await this.repeatable(url, options, started)); + let limits = 0; + let failures = 0; + for (;;) { + const remaining = this.deadline - this.deps.now(); + if (remaining <= 0) throw new GitHubDeadline(); + let response: Response | undefined; + let failure: unknown; + try { + response = await this.deps.fetch(url, { + ...(options.body === undefined + ? options.method && { method: options.method } + : { method: options.method ?? "POST", body: JSON.stringify(options.body) }), + headers: { + Accept: options.accept ?? "application/vnd.github+json", + Authorization: `Bearer ${this.deps.token}`, + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "quest", + ...(options.etag ? { "If-None-Match": options.etag } : {}), + }, + ...(Number.isFinite(remaining) ? { signal: AbortSignal.timeout(Math.ceil(remaining)) } : {}), + }); + } catch (error) { + if (this.deps.now() >= this.deadline) throw new GitHubDeadline(); + failure = error; + } + let retry: number | undefined; + if (response) { + this.pollInterval = Math.max(this.pollInterval, seconds(response.headers.get("x-poll-interval")) ?? 0); + // Read the body here, so a connection that drops mid-body is retried like one that drops before it. + const read = await response.text().then( + (text) => ({ text }), + (error: unknown) => ({ error }), + ); + // A rate limit is waited out even when the caller allows its status, such as a 403 refusal. + if ( + response.ok || + response.status === 304 || + (options.allow?.includes(response.status) && !limited(response, "text" in read ? read.text : "")) + ) { + const { status, statusText, headers } = response; + if ("text" in read) + return new Response(NULL_BODY.includes(status) ? null : read.text, { + status, + statusText, + headers, + }); + if (this.deps.now() >= this.deadline) throw new GitHubDeadline(); + failure = read.error; + } else { + // A refusal's status still says what to do when its body was cut off. + const body = "text" in read ? read.text : ""; + if (limited(response, body)) { + // GitHub turned the request away unprocessed, so any request repeats. + await this.waitOut( + response.headers, + limits++, + `GitHub HTTP ${response.status} for ${url.pathname}; rate limit persisted`, + ); + continue; + } + const reason = explanation(body); + if (response.status < 500) + throw new Error( + `GitHub HTTP ${response.status} for ${url.pathname}; ${reason ?? "check repository access and token permissions"}`, + ); + failure = new Error( + `GitHub HTTP ${response.status} for ${url.pathname}; ${reason ?? "GitHub server error"}`, + ); + retry = seconds(response.headers.get("retry-after")); + } + } + if (repeat === false || failures >= TRANSIENT_RETRIES) throw failure; + const delay = retry ?? backoff(failures); + failures++; + this.deps.warn(`${message(failure)}; retrying in ${Math.ceil(delay / 1000)} seconds`); + await this.pause(delay); + if (typeof repeat === "function") { + const landed = await repeat(started); + if (landed) return landed; + } + } + } + + /** + * Run `attempt` again while `transient` names its result a passing GitHub failure, with the bounded + * backoff of a failed request, and return its last result. + */ + async retry(attempt: () => Promise, transient: (result: T) => string | undefined): Promise { + for (let failures = 0; ; failures++) { + const result = await attempt(); + const reason = transient(result); + if (reason === undefined || failures >= TRANSIENT_RETRIES) return result; + const delay = backoff(failures); + this.deps.warn(`${reason}; retrying in ${delay / 1000} seconds`); + await this.pause(delay); + } + } + + /** + * Wait out a rate limit, the `limits`th in a row, as its headers say: Retry-After, else the + * primary limit's reset, else a minute doubling each time. Fails with `persisted` after six. + */ + private async waitOut(headers: Headers, limits: number, persisted: string): Promise { + if (limits >= 6) throw new Error(persisted); + const retry = seconds(headers.get("retry-after")); + const reset = + headers.get("x-ratelimit-remaining") === "0" ? seconds(headers.get("x-ratelimit-reset")) : undefined; + const delay = retry ?? (reset === undefined ? 60_000 * 2 ** limits : Math.max(1000, reset - this.deps.now())); + this.deps.warn(`GitHub rate limited this request; waiting ${Math.ceil(delay / 1000)} seconds`); + await this.pause(delay); + } + + /** + * How a request may repeat after a 5xx or a dropped connection. A GET reads and a PUT, PATCH, or + * DELETE sets state, so each repeats; so does adding labels. A merge is confirmed first by its + * pinned head, and a POST that creates something by what it created: a comment by its body, among + * those not already there before it, a pull request by its branch. Any other POST never repeats. + */ + private async repeatable(url: URL, options: RequestOptions, started: number): Promise { + const method = options.method ?? (options.body === undefined ? "GET" : "POST"); + const body = typeof options.body === "object" && options.body !== null ? (options.body as ObjectValue) : {}; + const merge = /^(\/repos\/[^/]+\/[^/]+\/pulls\/\d+)\/merge$/.exec(url.pathname); + if (method === "PUT" && merge) { + return async () => { + const pull = object(await (await this.request(merge[1])).json()); + // Merged at another head is not this merge; repeating it gets GitHub's refusal for the caller. + if (pull.merged !== true || typeof pull.merge_commit_sha !== "string") return; + if (body.sha !== undefined && object(pull.head).sha !== body.sha) return; + return Response.json({ + merged: true, + sha: pull.merge_commit_sha, + message: "Pull Request successfully merged", + }); + }; + } + if (method !== "POST") return true; + const issue = /^\/repos\/[^/]+\/[^/]+\/issues\/\d+\/(labels|comments)$/.exec(url.pathname); + if (issue?.[1] === "labels") return true; + if (issue?.[1] === "comments" && typeof body.body === "string") { + const since = new Date(started - CLOCK_SKEW).toISOString(); + const identical = async () => + (await this.all(`${url.pathname}?since=${since}&per_page=100`)).filter( + (comment) => comment.body === body.body, + ); + // An identical comment already there, such as one from a requeued PR's earlier ejection, is not this one. + const earlier = new Set((await identical()).map((comment) => comment.id)); + return async () => { + const found = (await identical()).find((comment) => !earlier.has(comment.id)); + return found && Response.json(found, { status: 201 }); + }; + } + const pulls = /^\/repos\/([^/]+)\/[^/]+\/pulls$/.exec(url.pathname); + if (pulls && typeof body.head === "string") { + const head = body.head.includes(":") ? body.head : `${pulls[1]}:${body.head}`; + return async (started) => { + const open = await this.list( + `${url.pathname}?state=open&head=${encodeURIComponent(head)}&per_page=100`, + ); + const found = open.rows.find( + (pull) => + typeof pull.created_at === "string" && Date.parse(pull.created_at) >= started - CLOCK_SKEW, + ); + return found && Response.json(found, { status: 201 }); + }; + } + return false; + } + + /** One page of a REST listing, and its response for the next link. */ + async list(path: string): Promise<{ rows: ObjectValue[]; response: Response }> { + const response = await this.request(path); + if (response.status !== 200) throw new Error("unexpected GitHub 304 without a cached response"); + return { rows: array(await response.json()), response }; + } + + /** Every page of a REST listing. */ + async all(path: string): Promise { + const rows: ObjectValue[] = []; + for (let next: string | undefined = path; next;) { + const page = await this.list(next); + rows.push(...page.rows); + next = link(page.response, "next"); + } + return rows; + } + + /** + * Run a GraphQL query and return its data, failing on any reported error. A query repeats after + * a 5xx or a dropped connection; a mutation only once its effect is confirmed missing, and one + * confirmed to have taken effect returns no data. GitHub can report a rate limit as a 200 whose + * errors say `RATE_LIMITED`; that is waited out like a REST one. + */ + async graphql(query: string, variables: ObjectValue): Promise { + const repeat = this.mutationRepeat(query, variables); + for (let limits = 0; ; limits++) { + const response = await this.request("/graphql", { body: { query, variables }, repeat }); + const body = object(await response.json()); + if (body.errors === undefined) return object(body.data); + const errors = array(body.errors); + if (!errors.some((error) => error.type === "RATE_LIMITED")) + throw new GitHubGraphQLError( + errors.map((error) => (typeof error.type === "string" ? error.type : undefined)), + errors.map((error) => String(error.message)), + ); + // GitHub turned the operation away unprocessed, so any operation repeats. + await this.waitOut(response.headers, limits, "GitHub GraphQL rate limit persisted"); + } + } + + /** + * Repeat a query; confirm a pull request mutation `PULL_MUTATIONS` knows; never repeat another. + * One pinned to `expectedHeadOid` is confirmed only at that head; at any other, its repeat gets + * GitHub's refusal for the caller. + */ + private mutationRepeat(query: string, variables: ObjectValue): Repeat { + if (!/^\s*mutation\b/.test(query)) return true; + const call = /^\s*mutation\b[^{]*\{\s*(\w+)\s*\(\s*input:\s*\{\s*pullRequestId:\s*\$(\w+)\b/.exec(query); + const effect = call && Object.hasOwn(PULL_MUTATIONS, call[1]) ? PULL_MUTATIONS[call[1]] : undefined; + const id = call && variables[call[2]]; + if (!effect || typeof id !== "string") return false; + const pinned = /\bexpectedHeadOid:\s*\$(\w+)\b/.exec(query); + const head = pinned ? variables[pinned[1]] : undefined; + const read = `query($id: ID!) { node(id: $id) { ... on PullRequest { headRefOid ${effect.fields} } } }`; + return async () => { + const pull = object((await this.graphql(read, { id })).node); + const landed = (head === undefined || pull.headRefOid === head) && effect.landed(pull); + return landed ? Response.json({ data: {} }) : undefined; + }; + } +} + +/** + * Pull request mutations whose effect can be read back: the fields that show it, and whether + * they do. Each takes its pull request as `pullRequestId`. + */ +const PULL_MUTATIONS: Record = { + mergePullRequest: { fields: "merged", landed: (pull) => pull.merged === true }, + enqueuePullRequest: { + fields: "merged mergeQueueEntry { state }", + landed: (pull) => pull.merged === true || pull.mergeQueueEntry != null, + }, + enablePullRequestAutoMerge: { + fields: "merged autoMergeRequest { enabledAt } mergeQueueEntry { state }", + landed: (pull) => pull.merged === true || pull.autoMergeRequest != null || pull.mergeQueueEntry != null, + }, + disablePullRequestAutoMerge: { + fields: "autoMergeRequest { enabledAt }", + landed: (pull) => pull.autoMergeRequest === null, + }, + markPullRequestReadyForReview: { fields: "isDraft", landed: (pull) => pull.isDraft === false }, +}; + +/** The REST path of a repository. */ +export function repositoryPath(repository: Repository): string { + return `/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}`; +} + +/** 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, from {@link workflowKey}. */ + 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; +} + +/** + * 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"}`, + ); +} + +/** + * A workflow file as requirements name it: its repository's `owner/name` and its path, since a ruleset may + * require a workflow from another repository at the same path as a local one. + */ +export function workflowKey(repository: string, path: string): string { + return `${repository.toLowerCase()}:${path}`; +} + +/** The workflow files `rules` require, each keyed by {@link workflowKey} from its rule's `repository_id`. */ +async function requiredWorkflows(github: GitHub, rules: ObjectValue[]): Promise { + const names = new Map(); + const keys: string[] = []; + for (const row of rules + .filter((rule) => rule.type === "workflows") + .flatMap((rule) => array(object(rule.parameters).workflows))) { + const id = Number(row.repository_id); + if (!names.has(id)) { + const response = await github.request(`/repositories/${id}`, { allow: [404] }); + // A repository the token cannot see is still required; nothing on the head can match its name. + names.set(id, response.ok ? string(object(await response.json()).full_name) : `repository ${id}`); + } + keys.push(workflowKey(names.get(id)!, string(row.path))); + } + return [...new Set(keys)]; +} + +/** 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 branchRules(github, `${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: await requiredWorkflows(github, rules), + rules, + // Unknown counts as on, so a reader that needs the settings reads them rather than assume none. + classic: protection.enabled !== false, + }; +} + +/** Every GitHub Actions workflow run on `head`. Reading them needs Actions read access. */ +export async function workflowRuns(github: GitHub, repository: Repository, head: string): Promise { + const runs: WorkflowRun[] = []; + let path: string | undefined = + `/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}/actions/runs?head_sha=${head}&per_page=100`; + while (path) { + const response: Response = await github.request(path); + for (const row of array(object(await response.json()).workflow_runs)) + runs.push({ + ...row, + id: Number(row.id), + workflow_id: Number(row.workflow_id), + check_suite_id: Number(row.check_suite_id), + // A run not yet started, such as one awaiting approval, has no start time, so its creation stands in. + run_started_at: string(row.run_started_at ?? row.created_at), + }); + path = link(response, "next"); + } + return runs; +} + +/** + * Each check's latest report on `head` that still counts, as {@link currentContexts} judges it, and the + * head's workflow runs when they were read: only when a latest report is a cancelled check run, the only + * report they can drop. + */ +export async function currentChecks( + github: GitHub, + repository: Repository, + head: string, + contexts: T[], +): Promise<{ checks: T[]; runs?: WorkflowRun[] }> { + const latest = latestContexts(contexts); + if (!latest.some(cancelled)) return { checks: latest }; + const runs = await workflowRuns(github, repository, head); + return { checks: currentContexts(contexts, runs), runs }; +} + +/** + * Whether a merged PR in `repository` had `commit` as its head or below it, as when `quest land` merged + * the base into the head before squashing; false for a commit GitHub does not have. + */ +export async function mergedHolds(github: GitHub, repository: Repository, commit: string): Promise { + const data = await github.graphql( + "query($owner: String!, $name: String!, $oid: GitObjectID!) { repository(owner: $owner, name: $name) { " + + "object(oid: $oid) { ... on Commit { associatedPullRequests(first: 100) { nodes { merged headRefOid } } } } } }", + { owner: repository.owner, name: repository.repo, oid: commit }, + ); + const found = object(data.repository).object; + const pulls = found === null || found === undefined ? undefined : object(found).associatedPullRequests; + if (pulls === null || pulls === undefined) return false; // Unknown, or not a commit. + for (const pull of array(object(pulls).nodes)) { + if (pull.merged !== true) continue; + const head = string(pull.headRefOid); + if (head === commit) return true; + // Association alone is no proof the merged head still reaches the commit, such as after a force push. + // Comparing from the head leaves no commits or files to send when it does. + const response = await github.request( + `/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}/compare/${head}...${commit}`, + { + allow: [404], + }, + ); + if (response.ok && Number(object(await response.json()).ahead_by) === 0) return true; + } + return false; +} + +/** A login, a committer name without spaces, or `-` for a deleted account. */ +export function actor(value: unknown): string { + if (value === null || value === undefined) return "-"; + const record = object(value); + return typeof record.login === "string" + ? record.login + : typeof record.name === "string" + ? record.name.replace(/\s/g, "_") + : "-"; +} + +export function object(value: unknown): ObjectValue { + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new Error("invalid GitHub response: expected an object"); + return value as ObjectValue; +} + +export function array(value: unknown): ObjectValue[] { + if (!Array.isArray(value)) throw new Error("invalid GitHub response: expected an array"); + return value.map(object); +} + +export function string(value: unknown): string { + if (typeof value !== "string" || !value) throw new Error("invalid GitHub response: expected a nonempty string"); + return value; +} + +export function link(response: Response, relation: string): string | undefined { + return response.headers + .get("link") + ?.split(",") + .map((entry) => /<([^>]+)>;\s*rel="([^"]+)"/.exec(entry.trim())) + .find((match) => match?.[2] === relation)?.[1]; +} + +/** GitHub's reason in an error body, such as why it refused to create a pull request. */ +function explanation(body: string): string | undefined { + try { + const parsed = object(JSON.parse(body)); + const errors = Array.isArray(parsed.errors) ? (parsed.errors as unknown[]) : []; + const parts = [parsed.message, ...errors.map((error) => (error as ObjectValue | null)?.message)]; + return parts.filter((part): part is string => typeof part === "string" && part !== "").join(": ") || undefined; + } catch { + return undefined; + } +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function seconds(value: string | null): number | undefined { + if (value === null || !/^\d+(?:\.\d+)?$/.test(value)) return undefined; + const result = Number(value) * 1000; + return Number.isFinite(result) ? result : undefined; +} diff --git a/test/github.test.ts b/test/github.test.ts index 41278a00..754014c7 100644 --- a/test/github.test.ts +++ b/test/github.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { GitHub, GitHubDeadline, type GitHubDependencies, GitHubGraphQLError } from "../src/cli/github"; +import { GitHub, GitHubDeadline, type GitHubDependencies, GitHubGraphQLError } from "../src/github/client"; const repo = "/repos/example/project"; const HEAD = "a".repeat(40); diff --git a/test/hold.test.ts b/test/hold.test.ts index 03d2998f..52bc9d53 100644 --- a/test/hold.test.ts +++ b/test/hold.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, vi } from "vitest"; -import type { GitHubDependencies, ObjectValue } from "../src/cli/github"; +import type { GitHubDependencies, ObjectValue } from "../src/github/client"; import { finish, land, release, take } from "../src/cli/hold"; import { withoutHandBack } from "../src/core"; import { type Output, main } from "../src/cli/main"; diff --git a/test/land-bump.test.ts b/test/land-bump.test.ts index 4896339d..d3027334 100644 --- a/test/land-bump.test.ts +++ b/test/land-bump.test.ts @@ -6,7 +6,7 @@ import { delimiter, join } from "node:path"; import { beforeEach, describe, expect, test, vi } from "vitest"; -import { type GitHubDependencies, requirements } from "../src/cli/github"; +import { type GitHubDependencies, requirements } from "../src/github/client"; import { land } from "../src/cli/hold"; import { readRemote } from "../src/cli/land"; import { @@ -299,7 +299,8 @@ 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, + (await requirements(new (await import("../src/github/client")).GitHub(deps(free)), free.target, "main")) + .rules, ).toEqual([]); }); }); diff --git a/test/land-fixer-diagnostics.test.ts b/test/land-fixer-diagnostics.test.ts index 56205606..7b95b3b7 100644 --- a/test/land-fixer-diagnostics.test.ts +++ b/test/land-fixer-diagnostics.test.ts @@ -14,7 +14,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { startFixer, collectFixers } from "../src/cli/land-fixer"; -import type { GitHubDependencies } from "../src/cli/github"; +import type { GitHubDependencies } from "../src/github/client"; import type { Plan } from "../src/cli/land"; import { created } from "./fixture"; diff --git a/test/land-watch.test.ts b/test/land-watch.test.ts index 02116825..3ca54e7a 100644 --- a/test/land-watch.test.ts +++ b/test/land-watch.test.ts @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"; import { beforeEach, describe, expect, test } from "vitest"; -import type { GitHubDependencies } from "../src/cli/github"; +import type { GitHubDependencies } from "../src/github/client"; import { readRemote } from "../src/cli/land"; import { parseResolution } from "../src/cli/land-apply"; import { CHECK_LOG } from "../src/cli/land-checks"; diff --git a/test/land.test.ts b/test/land.test.ts index 7a9de696..c83b26c4 100644 --- a/test/land.test.ts +++ b/test/land.test.ts @@ -43,7 +43,7 @@ import { apply, parseResolution, pushCommand, resolutionSection } from "../src/c import fixerPrompt from "../assets/land-fix.md"; import { COMMENT_LIMIT, FLAKY_INPUT, fix, parseBaseFix, parseFixResult } from "../src/cli/land-fix"; import { resolvePreparedQueue, prepareHead, finishFallback } from "../src/cli/land-head"; -import { GitHub, type GitHubDependencies } from "../src/cli/github"; +import { GitHub, type GitHubDependencies } from "../src/github/client"; import { type Plan, type Target, diff --git a/test/move.test.ts b/test/move.test.ts index 3573199f..65681df4 100644 --- a/test/move.test.ts +++ b/test/move.test.ts @@ -7,8 +7,8 @@ import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { move, remove } from "../src/core"; -import { openPullRequests } from "../src/cli/claims"; -import { GitHub, type GitHubDependencies } from "../src/cli/github"; +import { openPullRequests } from "../src/github/claims"; +import { GitHub, type GitHubDependencies } from "../src/github/client"; import { main } from "../src/cli/main"; import { EPIC_README, M0_README, ROOT_README, TWO, Tree, initRepository, tempDir } from "./fixture"; diff --git a/test/review.test.ts b/test/review.test.ts index edd78467..135b11e6 100644 --- a/test/review.test.ts +++ b/test/review.test.ts @@ -24,7 +24,7 @@ import { verdict, } from "../src/cli/review"; import { withReview } from "../src/cli/review-record"; -import { implementer, recorded } from "../src/github/models"; +import { implementer, recorded } from "../src/cli/models-record"; import { tempDir } from "./fixture"; afterEach(() => { diff --git a/test/start.test.ts b/test/start.test.ts index 76c0eee3..b248227c 100644 --- a/test/start.test.ts +++ b/test/start.test.ts @@ -9,13 +9,8 @@ import { join } from "node:path"; import { describe, expect, test, vi } from "vitest"; import { claims, openDrafts, target } from "../src/cli/claims"; -import { - type GitHubDependencies, - type ObjectValue, - githubRepository, - readRemote, - remoteRepository, -} from "../src/cli/github"; +import { githubRepository, readRemote, remoteRepository } from "../src/cli/github"; +import { type GitHubDependencies, type ObjectValue } from "../src/github/client"; import { type Output, main } from "../src/cli/main"; import { Refusal, ago, releaseQuest, startQuest } from "../src/cli/start"; import { EPIC_README, GIT_SETTINGS, TWO, Tree, configure, initRepository, template, tempDir } from "./fixture"; diff --git a/test/status.test.ts b/test/status.test.ts index c38be319..009d4faf 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import type { GitHubDependencies } from "../src/cli/github"; +import type { GitHubDependencies } from "../src/github/client"; import { main } from "../src/cli/main"; import { pullRequestStatus, statusLines } from "../src/cli/status"; diff --git a/test/usage.test.ts b/test/usage.test.ts index f04208a5..61e48312 100644 --- a/test/usage.test.ts +++ b/test/usage.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { describe, expect, test } from "vitest"; -import { implementer } from "../src/github/models"; +import { implementer } from "../src/cli/models-record"; import { fixes } from "../src/cli/land-fixer"; import type { Round, Tally } from "../src/cli/review"; import { parseReview, reviewRecord } from "../src/cli/review-record"; diff --git a/test/wait.test.ts b/test/wait.test.ts index 79fcac48..c3f36b09 100644 --- a/test/wait.test.ts +++ b/test/wait.test.ts @@ -5,7 +5,8 @@ import { join } from "node:path"; import { expect, test } from "vitest"; import { main } from "../src/cli/main"; -import { type GitHubDependencies, GitHubInputError, pullRequest } from "../src/cli/github"; +import { GitHubInputError, pullRequest } from "../src/cli/github"; +import { type GitHubDependencies } from "../src/github/client"; import { waitForActivity, WaitTimeout } from "../src/cli/wait"; const A = "a".repeat(40); diff --git a/test/worktree.test.ts b/test/worktree.test.ts index 94cceb71..c05a7cdb 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -18,7 +18,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { type Owner, Retained, type Scopes, currentOwner, kill, killable } from "../src/cli/supervise"; -import { GitHub, mergedHolds } from "../src/cli/github"; +import { GitHub, mergedHolds } from "../src/github/client"; import { REFERENCE_LINE } from "../src/core"; import { main } from "../src/cli/main"; import { Worktrees, canonical, derivedDefaults, diskUsage } from "../src/cli/worktree"; From 6663190a8b471dfae85f7c09eab96f96ac689597 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 13 Nov 2026 13:28:30 -0800 Subject: [PATCH 3/6] wip: move PR status and hold reads into src/github, export ./github --- package.json | 5 +- src/cli/hold.ts | 162 +---------- src/cli/land-checks.ts | 2 +- src/cli/land-fixer.ts | 13 +- src/cli/land-queue.ts | 2 +- src/cli/land-serve.ts | 2 +- src/cli/land-watch.ts | 2 +- src/cli/land.ts | 34 +-- src/cli/main.ts | 7 +- src/cli/start.ts | 14 +- src/cli/status.ts | 579 +-------------------------------------- src/cli/wait.ts | 2 +- src/core/index.ts | 2 + src/github/hold.ts | 183 +++++++++++++ src/github/index.ts | 32 +++ src/github/land.ts | 32 +++ src/github/status.ts | 577 ++++++++++++++++++++++++++++++++++++++ src/github/tsconfig.json | 10 + test/land.test.ts | 3 +- test/start.test.ts | 3 +- test/status.test.ts | 3 +- 21 files changed, 869 insertions(+), 800 deletions(-) create mode 100644 src/github/hold.ts create mode 100644 src/github/index.ts create mode 100644 src/github/land.ts create mode 100644 src/github/status.ts create mode 100644 src/github/tsconfig.json diff --git a/package.json b/package.json index 73bed944..f319c2d3 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "private": true, "type": "module", "exports": { - "./core": "./src/core/index.ts" + "./core": "./src/core/index.ts", + "./github": "./src/github/index.ts" }, "bin": { "quest": "dist/quest.js" @@ -18,7 +19,7 @@ "build": "bun build src/cli/bin.ts --target=bun --banner \"#!/usr/bin/env -S bun --no-env-file\" --outfile dist/quest.js && bun scripts/executable.mjs dist/quest.js", "compile": "bun build src/cli/bin.ts --compile --no-compile-autoload-dotenv --outfile dist/quest", "quest": "bun src/cli/bin.ts", - "check": "tsc && tsc -p src/core && prettier --check .", + "check": "tsc && tsc -p src/core && tsc -p src/github && prettier --check .", "test": "vitest run", "fix": "prettier --write ." }, diff --git a/src/cli/hold.ts b/src/cli/hold.ts index d0873e90..7bf17eaa 100644 --- a/src/cli/hold.ts +++ b/src/cli/hold.ts @@ -2,10 +2,9 @@ // a time. It holds the `quest:active` label, and the ID of the timeline event // that added the label is its hold token, since sessions often share an account. // `quest merge` takes no label: the pinned auto-merge or merge-queue entry it creates is the queue signal. -import { ACTIVE_LABEL, formatFinding, handedBack, isHoldLabel, latestHold, heldToken, withoutHandBack } from "../core"; -import { cleanAutoMergeRefusal, immediateMerge, lacksApproval, queueHead } from "../github/merge"; +import { ACTIVE_LABEL, formatFinding, handedBack, latestHold, heldToken, withoutHandBack } from "../core"; +import { cleanAutoMergeRefusal, immediateMerge, queueHead } from "../github/merge"; import { - array, currentChecks, GitHub, type GitHubDependencies, @@ -17,125 +16,22 @@ import { string, repositoryPath, } from "../github/client"; -import { ALLOWS, LAND_CHECK, METHOD_NAMES, contexts, enqueue } from "./land"; +import { ALLOWS, METHOD_NAMES, contexts, enqueue } from "./land"; +import { LAND_CHECK } from "../github/land"; import { QUEUE_CHECK } from "./land-queue"; import type { LandingCheck } from "./landed"; -import { classifyCheck } from "./status"; -import { Refusal, ago } from "./start"; +import { classifyCheck } from "../github/status"; +import { Refusal } from "./start"; +import { type Snapshot, ago, readSnapshot, starterHeld } from "../github/hold"; export type Method = "merge" | "squash" | "rebase"; -/** The PR, its `quest:*` labels, and the timeline events that add a label. */ -const READ = `query HoldOwnership($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - id url state isDraft body updatedAt baseRefName baseRefOid headRefName headRefOid mergeStateStatus reviewDecision isMergeQueueEnabled isInMergeQueue - labels(first: 100) { nodes { name } } - timelineItems(last: 100, itemTypes: [LABELED_EVENT]) { - nodes { __typename ... on LabeledEvent { id createdAt label { name } } } - } - } - } -}`; - -interface Event { - id: string; - time: number; - /** The label it added; `null` for any other event. */ - label: string | null; -} - -interface Snapshot { - id: string; - url: string; - state: "OPEN" | "CLOSED" | "MERGED"; - draft: boolean; - body: string; - updatedAt: string; - base: string; - /** The commit the base branch points at. */ - baseSha: string; - /** The head branch's name. */ - branch: string; - head: string; - mergeState: string; - /** - * The base requires an approval the PR lacks, such as an online reviewer's that a ruleset requires. An admin's - * token may merge or enqueue past it, so only auto-merge, which waits for it, may pick such a head. - */ - unapproved: boolean; - queue: boolean; - /** Whether the PR is in its base's merge queue. */ - queued: boolean; - /** The hold labels on the PR. */ - labels: string[]; - /** Oldest first. */ - events: Event[]; -} - /** A session's hold: its label and the token naming the event that added it. */ export interface Hold { label: string; token: string; } -export interface HoldFact { - label: string; - /** Null when the acquisition is older than the available timeline. */ - token: string | null; - takenAt: string | null; - age: string | null; - /** GitHub's verified event URL; the opaque GraphQL token is not a browser anchor. */ - eventUrl: string | null; -} -export interface HoldReport { - head: string; - holds: HoldFact[]; - starter: { lastActive: string; age: string; url: string } | null; -} - -/** Read ownership facts through the state commands' reader, without changing labels. */ -export async function readHoldStatus(pr: PullRequest, deps: GitHubDependencies): Promise { - const labels = new Labels(pr, deps); - const snapshot = await labels.read(); - const holds = - snapshot.state !== "OPEN" - ? [] - : snapshot.labels.map((label): HoldFact => { - const event = latestHold(snapshot.events, label); - return { - label, - token: event?.id ?? null, - takenAt: event ? new Date(event.time).toISOString() : null, - age: event ? ago(deps.now(), event.time) : null, - eventUrl: null, - }; - }); - if (holds.some((hold) => hold.token !== null)) { - // REST publishes event URLs and the same node IDs the ownership tokens use. - for (const event of await labels.github.all(`${repositoryPath(pr)}/issues/${pr.number}/events?per_page=100`)) { - const hold = holds.find((hold) => hold.token === event.node_id); - if (hold && event.event === "labeled" && object(event.label).name === hold.label) - hold.eventUrl = string(event.url); - } - } - return { - head: snapshot.head, - holds, - starter: starterHeld(snapshot) - ? { - lastActive: snapshot.updatedAt, - age: ago(deps.now(), Date.parse(snapshot.updatedAt)), - url: snapshot.url, - } - : null, - }; -} - -function starterHeld(snapshot: Snapshot): boolean { - return snapshot.state === "OPEN" && snapshot.draft && !handedBack(snapshot.body); -} - /** Take `pr`, or keep the hold `token` names. Prints nothing; returns the hold. */ export async function take(pr: PullRequest, token: string | undefined, deps: GitHubDependencies): Promise { const { hold } = await acquire(new Labels(pr, deps), token); @@ -532,39 +428,8 @@ class Labels { this.issue = `${repositoryPath(pr)}/issues/${pr.number}`; } - async read(): Promise { - const data = await this.github.graphql(READ, { - owner: this.pr.owner, - repo: this.pr.repo, - number: this.pr.number, - }); - const repository = data.repository; - if (repository === null) { - throw new Error(`cannot read ${this.pr.owner}/${this.pr.repo}; check the repository and token`); - } - const pull = object(repository).pullRequest; - if (pull === null) throw new Error(`${this.pr.owner}/${this.pr.repo}#${this.pr.number} is not a pull request`); - const row = object(pull); - return { - id: string(row.id), - url: string(row.url), - state: string(row.state) as Snapshot["state"], - draft: row.isDraft === true, - body: typeof row.body === "string" ? row.body : "", - updatedAt: string(row.updatedAt), - base: string(row.baseRefName), - baseSha: string(row.baseRefOid), - branch: string(row.headRefName), - head: string(row.headRefOid), - mergeState: string(row.mergeStateStatus), - unapproved: lacksApproval(row.reviewDecision), - queue: row.isMergeQueueEnabled === true, - queued: row.isInMergeQueue === true, - labels: array(object(row.labels).nodes) - .map((label) => string(label.name)) - .filter(isHoldLabel), - events: array(object(row.timelineItems).nodes).map(event), - }; + read(): Promise { + return readSnapshot(this.github, this.pr); } async add(label: string): Promise { @@ -579,12 +444,3 @@ class Labels { }); } } - -function event(row: ObjectValue): Event { - const labeled = row.__typename === "LabeledEvent"; - return { - id: string(row.id), - time: Date.parse(string(row.createdAt)), - label: labeled ? string(object(row.label).name) : null, - }; -} diff --git a/src/cli/land-checks.ts b/src/cli/land-checks.ts index fe797472..d606e27c 100644 --- a/src/cli/land-checks.ts +++ b/src/cli/land-checks.ts @@ -1,7 +1,7 @@ // GitHub Actions job logs, and a PR's own checks on a head as the land fixer's context: a signal, never a gate. import { array, type GitHub, object, type ObjectValue, string } from "../github/client"; import { redactTokens } from "./land-fixer"; -import { classifyCheck } from "./status"; +import { classifyCheck } from "../github/status"; /** How much of the end of each failing check's log the fixer reads beside the queue's own failure log. */ export const CHECK_LOG = 20_000; diff --git a/src/cli/land-fixer.ts b/src/cli/land-fixer.ts index a9bbd104..e2f259e7 100644 --- a/src/cli/land-fixer.ts +++ b/src/cli/land-fixer.ts @@ -18,17 +18,8 @@ import { constants as osConstants } from "node:os"; import { basename, join } from "node:path"; import { reviewSection } from "../core"; import { GitHub, type GitHubDependencies, object, string } from "../github/client"; -import { - baseRemote, - commandEnvironment, - contains, - git, - tryGit, - enqueue, - type Entry, - type Plan, - type Reason, -} from "./land"; +import { baseRemote, commandEnvironment, contains, git, tryGit, enqueue, type Entry, type Plan } from "./land"; +import { type Reason } from "../github/land"; import { COMMENT_LIMIT, resolution, resolutionSection, type Resolution } from "./land-apply"; import { CAUSES, diff --git a/src/cli/land-queue.ts b/src/cli/land-queue.ts index 37643683..9eaf37e3 100644 --- a/src/cli/land-queue.ts +++ b/src/cli/land-queue.ts @@ -19,7 +19,6 @@ import { ejection, type Entry, git, - LAND_CHECK, type LandMethod, mergeBase, type Plan, @@ -27,6 +26,7 @@ import { requiredChecks, tryGit, } from "./land"; +import { LAND_CHECK } from "../github/land"; import { post } from "./land-fixer"; import { gitAuthentication } from "./land-git"; import { diff --git a/src/cli/land-serve.ts b/src/cli/land-serve.ts index 8449f1f9..3d8548c2 100644 --- a/src/cli/land-serve.ts +++ b/src/cli/land-serve.ts @@ -45,8 +45,8 @@ import { type Overlap, type RemoteRead, type Plan, - type Reason, } from "./land"; +import { type Reason } from "../github/land"; export interface ServeOptions { base?: string; diff --git a/src/cli/land-watch.ts b/src/cli/land-watch.ts index 593d2ea8..b6c2adbd 100644 --- a/src/cli/land-watch.ts +++ b/src/cli/land-watch.ts @@ -44,7 +44,7 @@ import { 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"; +import { enabledHead, headBefore } from "../github/status"; export { readRemoval } from "./land-watch-fix"; diff --git a/src/cli/land.ts b/src/cli/land.ts index bd50931a..8a329061 100644 --- a/src/cli/land.ts +++ b/src/cli/land.ts @@ -19,20 +19,10 @@ import { } from "../github/client"; import type { WorkflowRun } from "../github/checks"; import { appOf } from "../github/checks"; -import { classifyCheck, reported, type Needs } from "./status"; +import { classifyCheck, reported, type Needs } from "../github/status"; import type { Method } from "./hold"; import { committedSources, snapshot } from "./tree"; - -/** 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}`; -} +import { LAND_CHECK, type Reason, pin, pinned } from "../github/land"; /** How a `quest land serve` instance lands each PR: as a squash commit, or as a merge commit keeping its ancestry. */ export type LandMethod = "squash" | "merge"; @@ -94,26 +84,6 @@ export async function enqueue(github: GitHub, id: string, head: string, method: ); } -/** The head an auto-merge commit body pins, if any. */ -export 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" - | "changes_requested" - | "unresolved_conversations" - | "head_moved" - | "not_pinned" - | "tree_mismatch" - | "merge_refused" - | "fork_head"; - -/** 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. */ diff --git a/src/cli/main.ts b/src/cli/main.ts index 8a95be65..5fc12eb7 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -13,6 +13,7 @@ import { blockers, check, formatFinding, + hosts, label, lookup, questBranch, @@ -40,7 +41,7 @@ import { gate, QUICK_MS } from "./gate"; import { gitHubDependencies, GitHubInputError, pullRequest, readRemote, remoteRepository } from "./github"; import { mergedHolds, type Repository } from "../github/client"; import { type Hold, finish, land, release, take } from "./hold"; -import { LAND_CHECK } from "./land"; +import { LAND_CHECK } from "../github/land"; import { apply, pushCommand } from "./land-apply"; import { EFFORT_VARIABLE, EFFORTS, fix, FixerAgentError } from "./land-fix"; import { serve, type ServeOptions } from "./land-serve"; @@ -48,7 +49,6 @@ import { watch, type WatchOptions } from "./land-watch"; import { landed, landingCheck } from "./landed"; import { type Report, fixHeadings, fixLinks, moveQuest, removeQuest } from "./move"; import { type ResolveOptions, resolveFile } from "./resolve"; -import { hosts } from "../core/doc"; import { DEFAULT_SIZE, REVIEWERS, WRITERS, questWriters, writerAt } from "./models"; import { ReviewInputError, @@ -76,7 +76,8 @@ import { Refusal, describe, notUpstream, releaseQuest, startQuest } from "./star import { CONTEXT, GUIDE, GUIDE_SECTIONS, SKILLS, guideSections, sectionHeading, skill } from "./skills"; import { type Terminal, Theme } from "./theme"; import { type Snapshot, checkout, collect, load, snapshot, under } from "./tree"; -import { pullRequestStatus, statusLines } from "./status"; +import { statusLines } from "./status"; +import { pullRequestStatus } from "../github/status"; import { CCUSAGE_MISSING, daysBefore, usage, usageDependencies, usageLines } from "./usage"; import { waitForActivity, WaitTimeout } from "./wait"; import { Worktrees } from "./worktree"; diff --git a/src/cli/start.ts b/src/cli/start.ts index 9529b0f1..b4ed3278 100644 --- a/src/cli/start.ts +++ b/src/cli/start.ts @@ -17,6 +17,7 @@ import { import { GitError, git } from "./git"; import { requireRemote } from "./github"; import { object, repositoryPath, string } from "../github/client"; +import { ago } from "../github/hold"; import { type OpenPull, mergedPull, openPullRequests, pullsFrom } from "../github/claims"; /** Someone else holds the quest, or the claim cannot be released; distinct from a failure. */ @@ -265,19 +266,6 @@ export async function releaseQuest( return { branch, closed: pulls.map((pull) => pull.url) }; } -/** How long ago `time` was, roughly: just now, minutes, hours, then days. */ -export function ago(now: number, time: number): string { - const minutes = Math.max(0, Math.round((now - time) / 60_000)); - if (minutes === 0) return "just now"; - const [count, unit] = - minutes < 60 - ? [minutes, "minute"] - : minutes < 48 * 60 - ? [Math.round(minutes / 60), "hour"] - : [Math.round(minutes / 1440), "day"]; - return `${count} ${unit}${count === 1 ? "" : "s"} ago`; -} - /** A claim for a person deciding whether to wait, take it over, or pick other work. */ export function describe(branch: string, pull: OpenPull | null, now: number): string { if (pull === null) return `branch ${branch}, with no open PR`; diff --git a/src/cli/status.ts b/src/cli/status.ts index 828ea172..bbab9317 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -1,572 +1,5 @@ -// A pull request's current state on GitHub, as facts for an agent to judge. -import { - actor, - array, - currentChecks, - GitHub, - GitHubDeadline, - type GitHubDependencies, - link, - object, - type ObjectValue, - type PullRequest, - type Repository, - requirements, - string, - workflowKey, -} from "../github/client"; -import { appOf, checksState, contextOutcome } from "../github/checks"; -import { EJECTED, LAND_CHECK, pinned } from "./land"; -import { reviewSection } from "../core"; -import { readHoldStatus, type HoldFact, type HoldReport } from "./hold"; - -/** - * One query covers the PR; each connection pages by its own cursor, and a - * connection already read in full is skipped on later pages. - */ -const STATUS = `query($owner: String!, $repo: String!, $number: Int!, - $checks: String, $reviews: String, $threads: String, - $moreChecks: Boolean!, $moreReviews: Boolean!, $moreThreads: Boolean!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - url body baseRefName state isDraft headRefOid headRefName mergeable mergeStateStatus reviewDecision - headRepository { nameWithOwner } - autoMergeRequest { enabledAt mergeMethod commitBody enabledBy { login } } - mergeQueueEntry { state position enqueuedAt } - 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 { __typename - contexts(first: 100, after: $checks) @include(if: $moreChecks) { - pageInfo { hasNextPage endCursor } - nodes { - __typename - ... on CheckRun { - databaseId name status conclusion detailsUrl isRequired(pullRequestNumber: $number) - checkSuite { databaseId app { databaseId } workflowRun { file { path repositoryName } } } - } - ... on StatusContext { context state createdAt targetUrl isRequired(pullRequestNumber: $number) } - } - } - } } } } - reviews(first: 100, after: $reviews) @include(if: $moreReviews) { - pageInfo { hasNextPage endCursor } - nodes { author { __typename login } state submittedAt url commit { oid } reactions(last: 100) { nodes { ...reaction } } } - } - reviewThreads(first: 100, after: $threads) @include(if: $moreThreads) { - pageInfo { hasNextPage endCursor } - nodes { - id isResolved isOutdated path line originalLine - comments(last: 1) { nodes { author { login } url } } - } - } - } - } -} -fragment reaction on Reaction { content createdAt user { login } }`; - -export interface Check { - name: string; - required: boolean; - /** The lowercased check-run conclusion or status, or commit-status state. */ - result: string; - url: string | null; -} - -export interface Review { - reviewer: string; - /** approved, changes_requested, commented, or dismissed. */ - state: string; - /** Whether the review was submitted against the current head. */ - current: boolean; - commit: string | null; - submittedAt: string; - url: string; -} - -export interface Reaction { - content: string; - actor: string; - createdAt: string; - /** The PR or review reacted to. */ - url: string; -} - -export interface Thread { - id: string; - path: string; - /** The line on the current diff, or the original line once outdated. */ - line: number | null; - outdated: boolean; - lastAuthor: string; - /** The thread's latest comment. */ - url: string; -} - -/** - * The head the PR body's `## Review` section names last, the latest reviewed one: a round's record, or a line after - * it for low-severity fixes or a skip. `current` is the PR's head; `diff-identical` an earlier head whose diff from - * the base (`git diff ...`) matches the head's; `unknown` one GitHub cannot compare, such as a diff too - * large to render. - */ -export interface ReviewRecord { - state: "current" | "diff-identical" | "stale" | "unknown"; - head: string; -} - -export interface Status { - url: string; - state: "open" | "closed" | "merged"; - draft: boolean; - head: string; - mergeable: string; - mergeState: string; - reviewDecision: string | null; - /** `head` is the head it was enabled on, or null when that cannot be told; see {@link enabledHead}. */ - autoMerge: { method: string; enabledBy: string; enabledAt: string; head: string | null } | null; - mergeQueue: { state: string; position: number; enqueuedAt: string } | null; - /** - * The latest exit from the merge queue, auto-merge, or `quest land`'s queue, and the reason for it; - * null once the PR is queued again. - */ - 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. */ - reviews: Review[]; - /** The design checkpoints online reviewers put on the head, as {@link checkpoints} finds them. */ - checkpoints: { reviewer: string; round: number; url: string }[]; - /** Null when the `## Review` section names no full SHA. */ - reviewRecord: ReviewRecord | null; - reactions: Reaction[]; - /** Unresolved review threads, oldest first. */ - threads: Thread[]; - /** Ownership facts read by the same client as the state commands. */ - holds: HoldFact[]; - starterHold: HoldReport["starter"]; -} - -/** Read the paged PR state, then the state commands’ ownership snapshot. */ -export async function pullRequestStatus(pr: PullRequest, deps: GitHubDependencies): Promise { - const github = new GitHub(deps); - const cursors: Record<"checks" | "reviews" | "threads", string | null | undefined> = { - checks: null, - reviews: null, - threads: null, - }; - const contexts: ObjectValue[] = []; - const reviewNodes: ObjectValue[] = []; - const threadNodes: ObjectValue[] = []; - let first: ObjectValue | undefined; - let rollup: ObjectValue | null = null; - // A cursor is null to read the first page and undefined once its list is complete. - const page = (connection: unknown, key: keyof typeof cursors, into: ObjectValue[]) => { - const list = object(connection); - into.push(...array(list.nodes)); - const info = object(list.pageInfo); - cursors[key] = info.hasNextPage === true ? string(info.endCursor) : undefined; - }; - while (Object.values(cursors).some((cursor) => cursor !== undefined)) { - const data = await github.graphql(STATUS, { - owner: pr.owner, - repo: pr.repo, - number: pr.number, - checks: cursors.checks ?? null, - reviews: cursors.reviews ?? null, - threads: cursors.threads ?? null, - moreChecks: cursors.checks !== undefined, - moreReviews: cursors.reviews !== undefined, - moreThreads: cursors.threads !== undefined, - }); - const repository = data.repository; - if (repository === null) throw new Error(`cannot read ${pr.owner}/${pr.repo}; check the repository and token`); - const pull = object(repository).pullRequest; - if (pull === null) throw new Error(`${pr.owner}/${pr.repo}#${pr.number} is not a pull request`); - const current = object(pull); - if (first === undefined) first = current; - else if (current.headRefOid !== first.headRefOid) - throw new Error("the PR head changed while reading its pages; rerun quest status"); - const commit = object(object(array(object(current.commits).nodes)[0]).commit); - if (commit.oid !== current.headRefOid) - throw new Error("the PR head changed during the query; rerun quest status"); - rollup = commit.statusCheckRollup === null ? null : object(commit.statusCheckRollup); - if (cursors.checks !== undefined) { - if (rollup === null) cursors.checks = undefined; - else page(rollup.contexts, "checks", contexts); - } - if (cursors.reviews !== undefined) page(current.reviews, "reviews", reviewNodes); - if (cursors.threads !== undefined) page(current.reviewThreads, "threads", threadNodes); - } - const ownership = await readHoldStatus(pr, deps); - if (ownership.head !== first!.headRefOid) - throw new Error("the PR head changed while reading its holds; rerun quest status"); - const head = string(first!.headRefOid); - const { checks: current } = await currentChecks(github, pr, head, contexts); - // Only an open PR can still merge, and a closed one's base may be gone. - const needs: Needs = - first!.state === "OPEN" - ? await requiredOf(github, pr, string(first!.baseRefName)) - : { checks: [], workflows: [] }; - const status = summarize(first!, rollup, current, needs, reviewNodes, threadNodes); - status.reviewRecord = await reviewRecord(github, pr, first!, deps.warn); - if (status.autoMerge !== null) status.autoMerge.head = await enabledHead(github, first!, deps.warn); - return { ...status, holds: ownership.holds, starterHold: ownership.starter }; -} - -async function reviewRecord( - github: GitHub, - pr: PullRequest, - pull: ObjectValue, - warn: (message: string) => void, -): Promise { - const body = typeof pull.body === "string" ? pull.body : ""; - const section = reviewSection(body); - // Only the last SHA, so a body naming many costs no more requests than one. - const reviewed = - section && - body - .slice(section.start, section.end) - .match(/\b[0-9a-f]{40}\b/g) - ?.at(-1); - if (!reviewed) return null; - const head = string(pull.headRefOid); - if (reviewed === head) return { state: "current", head: reviewed }; - const base = encodeURIComponent(string(pull.baseRefName)); - const diffs: string[] = []; - for (const sha of [head, reviewed]) { - const unknown = (why: string): ReviewRecord => { - warn(`cannot compare ${sha} with the base, so whether the review of ${reviewed} carries is unknown${why}`); - return { state: "unknown", head: reviewed }; - }; - let response: Response; - try { - response = await github.request(`/repos/${pr.owner}/${pr.repo}/compare/${base}...${sha}`, { - accept: "application/vnd.github.diff", - allow: [404, 406, 422], - }); - } catch (error) { - // A deadline is the caller's to handle; a forbidden or failing compare is not. - if (error instanceof GitHubDeadline || !(error instanceof Error)) throw error; - return unknown(`: ${error.message}`); - } - if (!response.ok) return unknown(""); - diffs.push(await response.text()); - } - return { state: diffs[0] === diffs[1] ? "diff-identical" : "stale", head: reviewed }; -} - -/** Repository activity types that move a branch to a new head, including merges into it. */ -export const BRANCH_UPDATES = ["push", "force_push", "branch_creation", "pr_merge", "merge_queue_merge"]; - -/** - * The head a PR node's auto-merge request was enabled on, or null when that cannot be told. GitHub - * keeps auto-merge across a writer's push but does not say which head it was enabled on. A - * `quest land` pin names it; otherwise it is the last push or merge to the head branch before auto-merge - * was enabled, read from the head repository's activity once that lists the current head's push. - * A push in the same second as enabling is ambiguous. An activity listing the token cannot read - * leaves it unknown, with a warning, rather than failing the caller. The node needs `headRefOid`, - * `headRefName`, `headRepository { nameWithOwner }`, and `autoMergeRequest { enabledAt commitBody }`. - */ -export async function enabledHead( - github: GitHub, - pull: ObjectValue, - warn: (message: string) => void, -): Promise { - if (pull.autoMergeRequest == null) return null; - const request = object(pull.autoMergeRequest); - const pin = pinned(typeof request.commitBody === "string" ? request.commitBody : ""); - if (pin !== null || pull.headRepository == null) return pin; - return headBefore(github, pull, Date.parse(string(request.enabledAt)), "the head auto-merge was enabled on", warn); -} - -/** - * The PR's head just before `time`: the last push or merge to its head branch before then, read from - * the head repository's activity once that lists the current head's push. A push in the same second - * is ambiguous. An activity listing the token cannot read leaves it unknown (null), with a warning - * naming `what`, rather than failing the caller. The node needs `headRefOid`, `headRefName`, and - * `headRepository { nameWithOwner }`. - */ -export async function headBefore( - github: GitHub, - pull: ObjectValue, - time: number, - what: string, - warn: (message: string) => void, -): Promise { - if (pull.headRepository == null) return null; - const head = string(pull.headRefOid); - const pushes: { sha: string; time: number }[] = []; - const repository = string(object(pull.headRepository).nameWithOwner); - let path: string | undefined = - `/repos/${repository}/activity?ref=${encodeURIComponent(string(pull.headRefName))}&per_page=100`; - // Newest first: read until a push before `time`, which is the head then. - while (path && !pushes.some((push) => push.time < time)) { - let response: Response; - try { - response = await github.request(path, { allow: [404] }); - } catch (error) { - // A deadline is the caller's to handle; a forbidden listing, as for a token without contents access, is not. - if (error instanceof GitHubDeadline || !(error instanceof Error)) throw error; - warn(`cannot read ${repository}'s push activity, so ${what} is unknown: ${error.message}`); - return null; - } - if (response.status === 404) return null; - for (const row of array(await response.json())) - if (BRANCH_UPDATES.includes(string(row.activity_type))) - pushes.push({ sha: string(row.after), time: Date.parse(string(row.timestamp)) }); - path = link(response, "next"); - } - // GitHub lists a push seconds to minutes late; until the current head's is listed, an earlier one may be missing. - if (!pushes.some((push) => push.sha === head) || pushes.some((push) => push.time === time)) return null; - return pushes.find((push) => push.time < time)?.sha ?? null; -} - -/** The checks and workflow files a base requires of a PR head. */ -export interface Needs { - /** Required checks, each from its app, or from any when `app` is null. */ - checks: { name: string; app: number | null }[]; - /** Workflow files that must pass as a whole, from {@link workflowKey}. */ - workflows: string[]; -} - -/** What `branch` requires of a PR head. The gate's own check is never on one, so it is left out. */ -export async function requiredOf(github: GitHub, repository: Repository, branch: string): Promise { - const found = await requirements(github, repository, branch); - return { checks: found.checks.filter((check) => check.name !== LAND_CHECK), workflows: found.workflows }; -} - -/** A check as a requirement matches it: its name, its app, and the workflow file a GitHub Actions job runs in. */ -export interface Reported { - name: string; - /** The app behind a check run; null for a commit status. */ - app: number | null; - /** The workflow file a GitHub Actions job runs in, from {@link workflowKey}. */ - workflow?: string; -} - -/** A rollup context as a requirement matches it; queries ask for `checkSuite { workflowRun { file { path repositoryName } } }`. */ -export function reported(context: ObjectValue): Reported { - const run = context.__typename === "CheckRun" && context.checkSuite ? object(context.checkSuite).workflowRun : null; - const file = run ? object(run).file : null; - return { - name: string(context.__typename === "CheckRun" ? context.name : context.context), - app: appOf(context), - ...(file ? { workflow: workflowKey(string(object(file).repositoryName), string(object(file).path)) } : {}), - }; -} - -/** - * The required checks and workflows nothing on the head reports yet: a check by its name, from its app when - * the requirement names one, as GitHub and `quest land` match it, and a workflow by its repository and path. - */ -export function unreported(found: Reported[], needs: Needs): string[] { - return [ - ...needs.checks - .filter( - (need) => - !found.some((entry) => entry.name === need.name && (need.app === null || entry.app === need.app)), - ) - .map((need) => (need.app === null ? need.name : `${need.name} (from app ${need.app})`)), - ...needs.workflows.filter((key) => !found.some((entry) => entry.workflow === key)), - ]; -} - -/** - * The PR's state from its pages. `current` holds the head's checks that count, from {@link currentChecks}; a - * check is required when the base requires it or its workflow, and a requirement nothing reports is pending. - */ -function summarize( - pull: ObjectValue, - rollup: ObjectValue | null, - current: ObjectValue[], - needs: Needs, - reviewNodes: ObjectValue[], - threadNodes: ObjectValue[], -): Status { - const head = string(pull.headRefOid); - const url = string(pull.url); - const missing = unreported(current.map(reported), needs); - // The rollup's own state counts runs a newer run of the same check replaced. - const state = checksState(current); - const checks = { - state: - rollup === null && missing.length === 0 - ? null - : lower(state === "SUCCESS" && missing.length > 0 ? "PENDING" : state), - passed: 0, - failing: [], - pending: [], - } as Status["checks"]; - for (const context of current) { - const { check, outcome } = classifyCheck(context); - const workflow = reported(context).workflow; - if (workflow !== undefined && needs.workflows.includes(workflow)) check.required = true; - if (outcome === "passed") checks.passed++; - else checks[outcome].push(check); - } - for (const name of missing) checks.pending.push({ name, required: true, result: "expected", url: null }); - - const reactions: Reaction[] = []; - const reacted = (owner: ObjectValue, target: string) => { - for (const row of array(object(owner.reactions).nodes)) - reactions.push({ - content: lower(row.content), - actor: actor(row.user), - createdAt: string(row.createdAt), - url: target, - }); - }; - reacted(pull, url); - // A pending review is the viewer's unsubmitted draft, not a review. - const submitted = reviewNodes.filter((row) => row.state !== "PENDING"); - const latest = new Map(); - for (const row of submitted) { - reacted(row, string(row.url)); - const reviewer = actor(row.author); - const commit = row.commit === null ? null : string(object(row.commit).oid); - latest.delete(reviewer); - latest.set(reviewer, { - reviewer, - state: lower(row.state), - current: commit === head, - commit, - submittedAt: string(row.submittedAt), - url: string(row.url), - }); - } - - const threads = threadNodes - .filter((row) => row.isResolved !== true) - .map((row): Thread => { - const comment = array(object(row.comments).nodes).at(-1); - const line = row.line ?? row.originalLine; - return { - id: string(row.id), - path: string(row.path), - line: typeof line === "number" ? line : null, - outdated: row.isOutdated === true, - lastAuthor: comment === undefined ? "-" : actor(comment.author), - url: comment === undefined ? url : string(comment.url), - }; - }); - - const autoMerge = pull.autoMergeRequest === null ? null : object(pull.autoMergeRequest); - const queue = pull.mergeQueueEntry === null ? null : object(pull.mergeQueueEntry); - const last = array(object(pull.timelineItems).nodes) - .filter((node) => node.__typename !== "IssueComment" || ejection(node) !== null) - .at(-1); - // Entering the merge queue or auto-merge at or after the latest exit supersedes it. `quest land`'s - // enqueue disables auto-merge just before enabling it again, often within the same second. - const entered = [autoMerge?.enabledAt, queue?.enqueuedAt].flatMap((at) => - at == null ? [] : [Date.parse(string(at))], - ); - const stopped = - last !== undefined && !entered.some((at) => at >= Date.parse(string(last.createdAt))) ? last : undefined; - return { - url, - state: lower(pull.state) as Status["state"], - draft: pull.isDraft === true, - head, - mergeable: lower(pull.mergeable), - mergeState: lower(pull.mergeStateStatus), - reviewDecision: pull.reviewDecision === null ? null : lower(pull.reviewDecision), - autoMerge: - autoMerge === null - ? null - : { - method: lower(autoMerge.mergeMethod), - enabledBy: actor(autoMerge.enabledBy), - enabledAt: string(autoMerge.enabledAt), - head: null, - }, - mergeQueue: - queue === null - ? null - : { state: lower(queue.state), position: Number(queue.position), enqueuedAt: string(queue.enqueuedAt) }, - landingStopped: - stopped === undefined - ? null - : { - reason: ejection(stopped) ?? lower(stopped.reason ?? stopped.reasonCode), - actor: actor(stopped.actor ?? stopped.author), - at: string(stopped.createdAt), - }, - checks, - reviews: [...latest.values()], - checkpoints: checkpoints(head, submitted), - reviewRecord: null, - reactions, - threads, - holds: [], - starterHold: null, - }; -} - -/** - * The design checkpoint `/quest-review` takes every third round, put on the head by an online reviewer: a bot that - * approves or requests changes, such as one whose approval the base requires, whose latest review is of the head - * and whose latest verdict on the head, ignoring plain comments, is not an approval or a dismissal, in its third, - * sixth, ... round. A dismissed review was an approval or a change - * request, so it shows the bot gates too, as when a push dismisses its approval. A round is one commit it reviewed; - * a rethink at a checkpoint leaves the next one three rounds on, so nothing restarts the count. - */ -const GATING = ["APPROVED", "CHANGES_REQUESTED", "DISMISSED"]; - -function checkpoints(head: string, submitted: ObjectValue[]): Status["checkpoints"] { - const due: Status["checkpoints"] = []; - const rounds = new Map(); - for (const row of submitted) { - if (row.author == null || object(row.author).__typename !== "Bot") continue; - const reviewer = actor(row.author); - rounds.set(reviewer, [...(rounds.get(reviewer) ?? []), row]); - } - for (const [reviewer, rows] of rounds) { - if (!rows.some((row) => GATING.includes(string(row.state)))) continue; - const counted = rows.filter((row) => row.commit != null); - const last = counted.at(-1); - if (last === undefined || string(object(last.commit).oid) !== head) continue; - const verdict = counted - .filter((row) => string(object(row.commit).oid) === head && GATING.includes(string(row.state))) - .at(-1); - if (verdict?.state === "APPROVED" || verdict?.state === "DISMISSED") continue; - const round = new Set(counted.map((row) => string(object(row.commit).oid))).size; - if (round % 3 === 0) due.push({ reviewer, round, url: string(last.url) }); - } - return due; -} - -/** - * 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. - */ -export function classifyCheck(context: ObjectValue): { check: Check; outcome: "passed" | "failing" | "pending" } { - const run = context.__typename === "CheckRun"; - const check: Check = { - name: string(run ? context.name : context.context), - required: context.isRequired === true, - result: - run && context.status !== "COMPLETED" - ? lower(context.status) - : lower(run ? context.conclusion : context.state), - url: optional(run ? context.detailsUrl : context.targetUrl), - }; - return { check, outcome: contextOutcome(context) }; -} +// `quest status`: a pull request's state as stable plain lines. +import type { Check, Status } from "../github/status"; /** Stable plain lines: a record type, then space-separated fields, with any free text last. */ export function statusLines(status: Status): string[] { @@ -618,11 +51,3 @@ export function statusLines(status: Status): string[] { : []), ]; } - -function lower(value: unknown): string { - return string(value).toLowerCase(); -} - -function optional(value: unknown): string | null { - return value === null || value === undefined || value === "" ? null : string(value); -} diff --git a/src/cli/wait.ts b/src/cli/wait.ts index 9c665b15..d793500c 100644 --- a/src/cli/wait.ts +++ b/src/cli/wait.ts @@ -23,7 +23,7 @@ import { reported, requiredOf, unreported, -} from "./status"; +} from "../github/status"; const POLL = 5 * 60_000; /** diff --git a/src/core/index.ts b/src/core/index.ts index 1463e012..cf13a706 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -16,6 +16,8 @@ export { entries, has, holds, + type Host, + hosts, isQuest, isEpic, owner, diff --git a/src/github/hold.ts b/src/github/hold.ts new file mode 100644 index 00000000..710b7a57 --- /dev/null +++ b/src/github/hold.ts @@ -0,0 +1,183 @@ +// Reading who holds a PR: its `quest:*` labels, the events that added them, and a draft its starter still holds. +// Taking and releasing holds stays in the CLI. +import { handedBack, isHoldLabel, latestHold } from "../core"; +import { + GitHub, + type GitHubDependencies, + type ObjectValue, + type PullRequest, + array, + object, + repositoryPath, + string, +} from "./client"; +import { lacksApproval } from "./merge"; + +/** The PR, its `quest:*` labels, and the timeline events that add a label. */ +const READ = `query HoldOwnership($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + id url state isDraft body updatedAt baseRefName baseRefOid headRefName headRefOid mergeStateStatus reviewDecision isMergeQueueEnabled isInMergeQueue + labels(first: 100) { nodes { name } } + timelineItems(last: 100, itemTypes: [LABELED_EVENT]) { + nodes { __typename ... on LabeledEvent { id createdAt label { name } } } + } + } + } +}`; + +/** A timeline event on a PR, as the hold reader reads it: when, and the label it added. */ +export interface LabelEvent { + id: string; + time: number; + /** The label it added; `null` for any other event. */ + label: string | null; +} + +/** A PR and its hold labels, as one read sees them. */ +export interface Snapshot { + id: string; + url: string; + state: "OPEN" | "CLOSED" | "MERGED"; + draft: boolean; + body: string; + updatedAt: string; + base: string; + /** The commit the base branch points at. */ + baseSha: string; + /** The head branch's name. */ + branch: string; + head: string; + mergeState: string; + /** + * The base requires an approval the PR lacks, such as an online reviewer's that a ruleset requires. An admin's + * token may merge or enqueue past it, so only auto-merge, which waits for it, may pick such a head. + */ + unapproved: boolean; + queue: boolean; + /** Whether the PR is in its base's merge queue. */ + queued: boolean; + /** The hold labels on the PR. */ + labels: string[]; + /** Oldest first. */ + events: LabelEvent[]; +} + +/** A hold label on a PR, and the event that took it when the timeline still shows it. */ +export interface HoldFact { + label: string; + /** Null when the acquisition is older than the available timeline. */ + token: string | null; + takenAt: string | null; + age: string | null; + /** GitHub's verified event URL; the opaque GraphQL token is not a browser anchor. */ + eventUrl: string | null; +} + +/** The PR head a hold read saw, its holds, and its starter's hold on a draft not handed back. */ +export interface HoldReport { + head: string; + holds: HoldFact[]; + starter: { lastActive: string; age: string; url: string } | null; +} + +/** Read `pr` and its hold labels. */ +export async function readSnapshot(github: GitHub, pr: PullRequest): Promise { + const data = await github.graphql(READ, { + owner: pr.owner, + repo: pr.repo, + number: pr.number, + }); + const repository = data.repository; + if (repository === null) { + throw new Error(`cannot read ${pr.owner}/${pr.repo}; check the repository and token`); + } + const pull = object(repository).pullRequest; + if (pull === null) throw new Error(`${pr.owner}/${pr.repo}#${pr.number} is not a pull request`); + const row = object(pull); + return { + id: string(row.id), + url: string(row.url), + state: string(row.state) as Snapshot["state"], + draft: row.isDraft === true, + body: typeof row.body === "string" ? row.body : "", + updatedAt: string(row.updatedAt), + base: string(row.baseRefName), + baseSha: string(row.baseRefOid), + branch: string(row.headRefName), + head: string(row.headRefOid), + mergeState: string(row.mergeStateStatus), + unapproved: lacksApproval(row.reviewDecision), + queue: row.isMergeQueueEnabled === true, + queued: row.isInMergeQueue === true, + labels: array(object(row.labels).nodes) + .map((label) => string(label.name)) + .filter(isHoldLabel), + events: array(object(row.timelineItems).nodes).map(event), + }; +} + +/** Read `pr`'s holds without changing its labels. */ +export async function readHoldStatus(pr: PullRequest, deps: GitHubDependencies): Promise { + const github = new GitHub(deps); + const snapshot = await readSnapshot(github, pr); + const holds = + snapshot.state !== "OPEN" + ? [] + : snapshot.labels.map((label): HoldFact => { + const event = latestHold(snapshot.events, label); + return { + label, + token: event?.id ?? null, + takenAt: event ? new Date(event.time).toISOString() : null, + age: event ? ago(deps.now(), event.time) : null, + eventUrl: null, + }; + }); + if (holds.some((hold) => hold.token !== null)) { + // REST publishes event URLs and the same node IDs the ownership tokens use. + for (const event of await github.all(`${repositoryPath(pr)}/issues/${pr.number}/events?per_page=100`)) { + const hold = holds.find((hold) => hold.token === event.node_id); + if (hold && event.event === "labeled" && object(event.label).name === hold.label) + hold.eventUrl = string(event.url); + } + } + return { + head: snapshot.head, + holds, + starter: starterHeld(snapshot) + ? { + lastActive: snapshot.updatedAt, + age: ago(deps.now(), Date.parse(snapshot.updatedAt)), + url: snapshot.url, + } + : null, + }; +} + +/** Whether a PR is an open draft its starter still holds: one not handed back with `## Waiting`. */ +export function starterHeld(snapshot: Snapshot): boolean { + return snapshot.state === "OPEN" && snapshot.draft && !handedBack(snapshot.body); +} + +function event(row: ObjectValue): LabelEvent { + const labeled = row.__typename === "LabeledEvent"; + return { + id: string(row.id), + time: Date.parse(string(row.createdAt)), + label: labeled ? string(object(row.label).name) : null, + }; +} + +/** How long ago `time` was, roughly: just now, minutes, hours, then days. */ +export function ago(now: number, time: number): string { + const minutes = Math.max(0, Math.round((now - time) / 60_000)); + if (minutes === 0) return "just now"; + const [count, unit] = + minutes < 60 + ? [minutes, "minute"] + : minutes < 48 * 60 + ? [Math.round(minutes / 60), "hour"] + : [Math.round(minutes / 1440), "day"]; + return `${count} ${unit}${count === 1 ? "" : "s"} ago`; +} diff --git a/src/github/index.ts b/src/github/index.ts new file mode 100644 index 00000000..1ecbdf3d --- /dev/null +++ b/src/github/index.ts @@ -0,0 +1,32 @@ +// Quest's GitHub readers for a host with no checkout, git, or Node APIs, such as a Worker: a PR's +// status as `quest status` reads it, and the claims on quest branches as `quest start` decides them. +// The caller supplies `fetch` and the token through `GitHubDependencies`. + +export { + GitHub, + GitHubDeadline, + type GitHubDependencies, + GitHubGraphQLError, + type PullRequest, + type Repository, +} from "./client"; +export { + type Check, + type Reaction, + type Review, + type ReviewRecord, + type Status, + type Thread, + pullRequestStatus, +} from "./status"; +export { type HoldFact, type HoldReport } from "./hold"; +export { + type Claim, + type Holdings, + type OpenPull, + type Source, + type Sources, + claimBranches, + held, + openPullRequests, +} from "./claims"; diff --git a/src/github/land.ts b/src/github/land.ts new file mode 100644 index 00000000..107ee25b --- /dev/null +++ b/src/github/land.ts @@ -0,0 +1,32 @@ +// The markers `quest land` leaves on GitHub, which `quest status` reads back. + +/** 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. */ +export 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" + | "changes_requested" + | "unresolved_conversations" + | "head_moved" + | "not_pinned" + | "tree_mismatch" + | "merge_refused" + | "fork_head"; + +/** The hidden line an ejection comment carries, which `quest status` reads. */ +export const EJECTED = /^$/m; diff --git a/src/github/status.ts b/src/github/status.ts new file mode 100644 index 00000000..a9c78a23 --- /dev/null +++ b/src/github/status.ts @@ -0,0 +1,577 @@ +// A pull request's current state on GitHub, as facts for an agent to judge: what `quest status` prints. +import { + actor, + array, + currentChecks, + GitHub, + GitHubDeadline, + type GitHubDependencies, + link, + object, + type ObjectValue, + type PullRequest, + type Repository, + requirements, + string, + workflowKey, +} from "./client"; +import { appOf, checksState, contextOutcome } from "./checks"; +import { EJECTED, LAND_CHECK, pinned } from "./land"; +import { reviewSection } from "../core"; +import { readHoldStatus, type HoldFact, type HoldReport } from "./hold"; + +/** + * One query covers the PR; each connection pages by its own cursor, and a + * connection already read in full is skipped on later pages. + */ +const STATUS = `query($owner: String!, $repo: String!, $number: Int!, + $checks: String, $reviews: String, $threads: String, + $moreChecks: Boolean!, $moreReviews: Boolean!, $moreThreads: Boolean!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + url body baseRefName state isDraft headRefOid headRefName mergeable mergeStateStatus reviewDecision + headRepository { nameWithOwner } + autoMergeRequest { enabledAt mergeMethod commitBody enabledBy { login } } + mergeQueueEntry { state position enqueuedAt } + 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 { __typename + contexts(first: 100, after: $checks) @include(if: $moreChecks) { + pageInfo { hasNextPage endCursor } + nodes { + __typename + ... on CheckRun { + databaseId name status conclusion detailsUrl isRequired(pullRequestNumber: $number) + checkSuite { databaseId app { databaseId } workflowRun { file { path repositoryName } } } + } + ... on StatusContext { context state createdAt targetUrl isRequired(pullRequestNumber: $number) } + } + } + } } } } + reviews(first: 100, after: $reviews) @include(if: $moreReviews) { + pageInfo { hasNextPage endCursor } + nodes { author { __typename login } state submittedAt url commit { oid } reactions(last: 100) { nodes { ...reaction } } } + } + reviewThreads(first: 100, after: $threads) @include(if: $moreThreads) { + pageInfo { hasNextPage endCursor } + nodes { + id isResolved isOutdated path line originalLine + comments(last: 1) { nodes { author { login } url } } + } + } + } + } +} +fragment reaction on Reaction { content createdAt user { login } }`; + +export interface Check { + name: string; + required: boolean; + /** The lowercased check-run conclusion or status, or commit-status state. */ + result: string; + url: string | null; +} + +export interface Review { + reviewer: string; + /** approved, changes_requested, commented, or dismissed. */ + state: string; + /** Whether the review was submitted against the current head. */ + current: boolean; + commit: string | null; + submittedAt: string; + url: string; +} + +export interface Reaction { + content: string; + actor: string; + createdAt: string; + /** The PR or review reacted to. */ + url: string; +} + +export interface Thread { + id: string; + path: string; + /** The line on the current diff, or the original line once outdated. */ + line: number | null; + outdated: boolean; + lastAuthor: string; + /** The thread's latest comment. */ + url: string; +} + +/** + * The head the PR body's `## Review` section names last, the latest reviewed one: a round's record, or a line after + * it for low-severity fixes or a skip. `current` is the PR's head; `diff-identical` an earlier head whose diff from + * the base (`git diff ...`) matches the head's; `unknown` one GitHub cannot compare, such as a diff too + * large to render. + */ +export interface ReviewRecord { + state: "current" | "diff-identical" | "stale" | "unknown"; + head: string; +} + +export interface Status { + url: string; + state: "open" | "closed" | "merged"; + draft: boolean; + head: string; + mergeable: string; + mergeState: string; + reviewDecision: string | null; + /** `head` is the head it was enabled on, or null when that cannot be told; see {@link enabledHead}. */ + autoMerge: { method: string; enabledBy: string; enabledAt: string; head: string | null } | null; + mergeQueue: { state: string; position: number; enqueuedAt: string } | null; + /** + * The latest exit from the merge queue, auto-merge, or `quest land`'s queue, and the reason for it; + * null once the PR is queued again. + */ + 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. */ + reviews: Review[]; + /** The design checkpoints online reviewers put on the head, as {@link checkpoints} finds them. */ + checkpoints: { reviewer: string; round: number; url: string }[]; + /** Null when the `## Review` section names no full SHA. */ + reviewRecord: ReviewRecord | null; + reactions: Reaction[]; + /** Unresolved review threads, oldest first. */ + threads: Thread[]; + /** Ownership facts read by the same client as the state commands. */ + holds: HoldFact[]; + starterHold: HoldReport["starter"]; +} + +/** Read the paged PR state, then the state commands’ ownership snapshot. */ +export async function pullRequestStatus(pr: PullRequest, deps: GitHubDependencies): Promise { + const github = new GitHub(deps); + const cursors: Record<"checks" | "reviews" | "threads", string | null | undefined> = { + checks: null, + reviews: null, + threads: null, + }; + const contexts: ObjectValue[] = []; + const reviewNodes: ObjectValue[] = []; + const threadNodes: ObjectValue[] = []; + let first: ObjectValue | undefined; + let rollup: ObjectValue | null = null; + // A cursor is null to read the first page and undefined once its list is complete. + const page = (connection: unknown, key: keyof typeof cursors, into: ObjectValue[]) => { + const list = object(connection); + into.push(...array(list.nodes)); + const info = object(list.pageInfo); + cursors[key] = info.hasNextPage === true ? string(info.endCursor) : undefined; + }; + while (Object.values(cursors).some((cursor) => cursor !== undefined)) { + const data = await github.graphql(STATUS, { + owner: pr.owner, + repo: pr.repo, + number: pr.number, + checks: cursors.checks ?? null, + reviews: cursors.reviews ?? null, + threads: cursors.threads ?? null, + moreChecks: cursors.checks !== undefined, + moreReviews: cursors.reviews !== undefined, + moreThreads: cursors.threads !== undefined, + }); + const repository = data.repository; + if (repository === null) throw new Error(`cannot read ${pr.owner}/${pr.repo}; check the repository and token`); + const pull = object(repository).pullRequest; + if (pull === null) throw new Error(`${pr.owner}/${pr.repo}#${pr.number} is not a pull request`); + const current = object(pull); + if (first === undefined) first = current; + else if (current.headRefOid !== first.headRefOid) + throw new Error("the PR head changed while reading its pages; rerun quest status"); + const commit = object(object(array(object(current.commits).nodes)[0]).commit); + if (commit.oid !== current.headRefOid) + throw new Error("the PR head changed during the query; rerun quest status"); + rollup = commit.statusCheckRollup === null ? null : object(commit.statusCheckRollup); + if (cursors.checks !== undefined) { + if (rollup === null) cursors.checks = undefined; + else page(rollup.contexts, "checks", contexts); + } + if (cursors.reviews !== undefined) page(current.reviews, "reviews", reviewNodes); + if (cursors.threads !== undefined) page(current.reviewThreads, "threads", threadNodes); + } + const ownership = await readHoldStatus(pr, deps); + if (ownership.head !== first!.headRefOid) + throw new Error("the PR head changed while reading its holds; rerun quest status"); + const head = string(first!.headRefOid); + const { checks: current } = await currentChecks(github, pr, head, contexts); + // Only an open PR can still merge, and a closed one's base may be gone. + const needs: Needs = + first!.state === "OPEN" + ? await requiredOf(github, pr, string(first!.baseRefName)) + : { checks: [], workflows: [] }; + const status = summarize(first!, rollup, current, needs, reviewNodes, threadNodes); + status.reviewRecord = await reviewRecord(github, pr, first!, deps.warn); + if (status.autoMerge !== null) status.autoMerge.head = await enabledHead(github, first!, deps.warn); + return { ...status, holds: ownership.holds, starterHold: ownership.starter }; +} + +async function reviewRecord( + github: GitHub, + pr: PullRequest, + pull: ObjectValue, + warn: (message: string) => void, +): Promise { + const body = typeof pull.body === "string" ? pull.body : ""; + const section = reviewSection(body); + // Only the last SHA, so a body naming many costs no more requests than one. + const reviewed = + section && + body + .slice(section.start, section.end) + .match(/\b[0-9a-f]{40}\b/g) + ?.at(-1); + if (!reviewed) return null; + const head = string(pull.headRefOid); + if (reviewed === head) return { state: "current", head: reviewed }; + const base = encodeURIComponent(string(pull.baseRefName)); + const diffs: string[] = []; + for (const sha of [head, reviewed]) { + const unknown = (why: string): ReviewRecord => { + warn(`cannot compare ${sha} with the base, so whether the review of ${reviewed} carries is unknown${why}`); + return { state: "unknown", head: reviewed }; + }; + let response: Response; + try { + response = await github.request(`/repos/${pr.owner}/${pr.repo}/compare/${base}...${sha}`, { + accept: "application/vnd.github.diff", + allow: [404, 406, 422], + }); + } catch (error) { + // A deadline is the caller's to handle; a forbidden or failing compare is not. + if (error instanceof GitHubDeadline || !(error instanceof Error)) throw error; + return unknown(`: ${error.message}`); + } + if (!response.ok) return unknown(""); + diffs.push(await response.text()); + } + return { state: diffs[0] === diffs[1] ? "diff-identical" : "stale", head: reviewed }; +} + +/** Repository activity types that move a branch to a new head, including merges into it. */ +export const BRANCH_UPDATES = ["push", "force_push", "branch_creation", "pr_merge", "merge_queue_merge"]; + +/** + * The head a PR node's auto-merge request was enabled on, or null when that cannot be told. GitHub + * keeps auto-merge across a writer's push but does not say which head it was enabled on. A + * `quest land` pin names it; otherwise it is the last push or merge to the head branch before auto-merge + * was enabled, read from the head repository's activity once that lists the current head's push. + * A push in the same second as enabling is ambiguous. An activity listing the token cannot read + * leaves it unknown, with a warning, rather than failing the caller. The node needs `headRefOid`, + * `headRefName`, `headRepository { nameWithOwner }`, and `autoMergeRequest { enabledAt commitBody }`. + */ +export async function enabledHead( + github: GitHub, + pull: ObjectValue, + warn: (message: string) => void, +): Promise { + if (pull.autoMergeRequest == null) return null; + const request = object(pull.autoMergeRequest); + const pin = pinned(typeof request.commitBody === "string" ? request.commitBody : ""); + if (pin !== null || pull.headRepository == null) return pin; + return headBefore(github, pull, Date.parse(string(request.enabledAt)), "the head auto-merge was enabled on", warn); +} + +/** + * The PR's head just before `time`: the last push or merge to its head branch before then, read from + * the head repository's activity once that lists the current head's push. A push in the same second + * is ambiguous. An activity listing the token cannot read leaves it unknown (null), with a warning + * naming `what`, rather than failing the caller. The node needs `headRefOid`, `headRefName`, and + * `headRepository { nameWithOwner }`. + */ +export async function headBefore( + github: GitHub, + pull: ObjectValue, + time: number, + what: string, + warn: (message: string) => void, +): Promise { + if (pull.headRepository == null) return null; + const head = string(pull.headRefOid); + const pushes: { sha: string; time: number }[] = []; + const repository = string(object(pull.headRepository).nameWithOwner); + let path: string | undefined = + `/repos/${repository}/activity?ref=${encodeURIComponent(string(pull.headRefName))}&per_page=100`; + // Newest first: read until a push before `time`, which is the head then. + while (path && !pushes.some((push) => push.time < time)) { + let response: Response; + try { + response = await github.request(path, { allow: [404] }); + } catch (error) { + // A deadline is the caller's to handle; a forbidden listing, as for a token without contents access, is not. + if (error instanceof GitHubDeadline || !(error instanceof Error)) throw error; + warn(`cannot read ${repository}'s push activity, so ${what} is unknown: ${error.message}`); + return null; + } + if (response.status === 404) return null; + for (const row of array(await response.json())) + if (BRANCH_UPDATES.includes(string(row.activity_type))) + pushes.push({ sha: string(row.after), time: Date.parse(string(row.timestamp)) }); + path = link(response, "next"); + } + // GitHub lists a push seconds to minutes late; until the current head's is listed, an earlier one may be missing. + if (!pushes.some((push) => push.sha === head) || pushes.some((push) => push.time === time)) return null; + return pushes.find((push) => push.time < time)?.sha ?? null; +} + +/** The checks and workflow files a base requires of a PR head. */ +export interface Needs { + /** Required checks, each from its app, or from any when `app` is null. */ + checks: { name: string; app: number | null }[]; + /** Workflow files that must pass as a whole, from {@link workflowKey}. */ + workflows: string[]; +} + +/** What `branch` requires of a PR head. The gate's own check is never on one, so it is left out. */ +export async function requiredOf(github: GitHub, repository: Repository, branch: string): Promise { + const found = await requirements(github, repository, branch); + return { checks: found.checks.filter((check) => check.name !== LAND_CHECK), workflows: found.workflows }; +} + +/** A check as a requirement matches it: its name, its app, and the workflow file a GitHub Actions job runs in. */ +export interface Reported { + name: string; + /** The app behind a check run; null for a commit status. */ + app: number | null; + /** The workflow file a GitHub Actions job runs in, from {@link workflowKey}. */ + workflow?: string; +} + +/** A rollup context as a requirement matches it; queries ask for `checkSuite { workflowRun { file { path repositoryName } } }`. */ +export function reported(context: ObjectValue): Reported { + const run = context.__typename === "CheckRun" && context.checkSuite ? object(context.checkSuite).workflowRun : null; + const file = run ? object(run).file : null; + return { + name: string(context.__typename === "CheckRun" ? context.name : context.context), + app: appOf(context), + ...(file ? { workflow: workflowKey(string(object(file).repositoryName), string(object(file).path)) } : {}), + }; +} + +/** + * The required checks and workflows nothing on the head reports yet: a check by its name, from its app when + * the requirement names one, as GitHub and `quest land` match it, and a workflow by its repository and path. + */ +export function unreported(found: Reported[], needs: Needs): string[] { + return [ + ...needs.checks + .filter( + (need) => + !found.some((entry) => entry.name === need.name && (need.app === null || entry.app === need.app)), + ) + .map((need) => (need.app === null ? need.name : `${need.name} (from app ${need.app})`)), + ...needs.workflows.filter((key) => !found.some((entry) => entry.workflow === key)), + ]; +} + +/** + * The PR's state from its pages. `current` holds the head's checks that count, from {@link currentChecks}; a + * check is required when the base requires it or its workflow, and a requirement nothing reports is pending. + */ +function summarize( + pull: ObjectValue, + rollup: ObjectValue | null, + current: ObjectValue[], + needs: Needs, + reviewNodes: ObjectValue[], + threadNodes: ObjectValue[], +): Status { + const head = string(pull.headRefOid); + const url = string(pull.url); + const missing = unreported(current.map(reported), needs); + // The rollup's own state counts runs a newer run of the same check replaced. + const state = checksState(current); + const checks = { + state: + rollup === null && missing.length === 0 + ? null + : lower(state === "SUCCESS" && missing.length > 0 ? "PENDING" : state), + passed: 0, + failing: [], + pending: [], + } as Status["checks"]; + for (const context of current) { + const { check, outcome } = classifyCheck(context); + const workflow = reported(context).workflow; + if (workflow !== undefined && needs.workflows.includes(workflow)) check.required = true; + if (outcome === "passed") checks.passed++; + else checks[outcome].push(check); + } + for (const name of missing) checks.pending.push({ name, required: true, result: "expected", url: null }); + + const reactions: Reaction[] = []; + const reacted = (owner: ObjectValue, target: string) => { + for (const row of array(object(owner.reactions).nodes)) + reactions.push({ + content: lower(row.content), + actor: actor(row.user), + createdAt: string(row.createdAt), + url: target, + }); + }; + reacted(pull, url); + // A pending review is the viewer's unsubmitted draft, not a review. + const submitted = reviewNodes.filter((row) => row.state !== "PENDING"); + const latest = new Map(); + for (const row of submitted) { + reacted(row, string(row.url)); + const reviewer = actor(row.author); + const commit = row.commit === null ? null : string(object(row.commit).oid); + latest.delete(reviewer); + latest.set(reviewer, { + reviewer, + state: lower(row.state), + current: commit === head, + commit, + submittedAt: string(row.submittedAt), + url: string(row.url), + }); + } + + const threads = threadNodes + .filter((row) => row.isResolved !== true) + .map((row): Thread => { + const comment = array(object(row.comments).nodes).at(-1); + const line = row.line ?? row.originalLine; + return { + id: string(row.id), + path: string(row.path), + line: typeof line === "number" ? line : null, + outdated: row.isOutdated === true, + lastAuthor: comment === undefined ? "-" : actor(comment.author), + url: comment === undefined ? url : string(comment.url), + }; + }); + + const autoMerge = pull.autoMergeRequest === null ? null : object(pull.autoMergeRequest); + const queue = pull.mergeQueueEntry === null ? null : object(pull.mergeQueueEntry); + const last = array(object(pull.timelineItems).nodes) + .filter((node) => node.__typename !== "IssueComment" || ejection(node) !== null) + .at(-1); + // Entering the merge queue or auto-merge at or after the latest exit supersedes it. `quest land`'s + // enqueue disables auto-merge just before enabling it again, often within the same second. + const entered = [autoMerge?.enabledAt, queue?.enqueuedAt].flatMap((at) => + at == null ? [] : [Date.parse(string(at))], + ); + const stopped = + last !== undefined && !entered.some((at) => at >= Date.parse(string(last.createdAt))) ? last : undefined; + return { + url, + state: lower(pull.state) as Status["state"], + draft: pull.isDraft === true, + head, + mergeable: lower(pull.mergeable), + mergeState: lower(pull.mergeStateStatus), + reviewDecision: pull.reviewDecision === null ? null : lower(pull.reviewDecision), + autoMerge: + autoMerge === null + ? null + : { + method: lower(autoMerge.mergeMethod), + enabledBy: actor(autoMerge.enabledBy), + enabledAt: string(autoMerge.enabledAt), + head: null, + }, + mergeQueue: + queue === null + ? null + : { state: lower(queue.state), position: Number(queue.position), enqueuedAt: string(queue.enqueuedAt) }, + landingStopped: + stopped === undefined + ? null + : { + reason: ejection(stopped) ?? lower(stopped.reason ?? stopped.reasonCode), + actor: actor(stopped.actor ?? stopped.author), + at: string(stopped.createdAt), + }, + checks, + reviews: [...latest.values()], + checkpoints: checkpoints(head, submitted), + reviewRecord: null, + reactions, + threads, + holds: [], + starterHold: null, + }; +} + +/** + * The design checkpoint `/quest-review` takes every third round, put on the head by an online reviewer: a bot that + * approves or requests changes, such as one whose approval the base requires, whose latest review is of the head + * and whose latest verdict on the head, ignoring plain comments, is not an approval or a dismissal, in its third, + * sixth, ... round. A dismissed review was an approval or a change + * request, so it shows the bot gates too, as when a push dismisses its approval. A round is one commit it reviewed; + * a rethink at a checkpoint leaves the next one three rounds on, so nothing restarts the count. + */ +const GATING = ["APPROVED", "CHANGES_REQUESTED", "DISMISSED"]; + +function checkpoints(head: string, submitted: ObjectValue[]): Status["checkpoints"] { + const due: Status["checkpoints"] = []; + const rounds = new Map(); + for (const row of submitted) { + if (row.author == null || object(row.author).__typename !== "Bot") continue; + const reviewer = actor(row.author); + rounds.set(reviewer, [...(rounds.get(reviewer) ?? []), row]); + } + for (const [reviewer, rows] of rounds) { + if (!rows.some((row) => GATING.includes(string(row.state)))) continue; + const counted = rows.filter((row) => row.commit != null); + const last = counted.at(-1); + if (last === undefined || string(object(last.commit).oid) !== head) continue; + const verdict = counted + .filter((row) => string(object(row.commit).oid) === head && GATING.includes(string(row.state))) + .at(-1); + if (verdict?.state === "APPROVED" || verdict?.state === "DISMISSED") continue; + const round = new Set(counted.map((row) => string(object(row.commit).oid))).size; + if (round % 3 === 0) due.push({ reviewer, round, url: string(last.url) }); + } + return due; +} + +/** + * 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. + */ +export function classifyCheck(context: ObjectValue): { check: Check; outcome: "passed" | "failing" | "pending" } { + const run = context.__typename === "CheckRun"; + const check: Check = { + name: string(run ? context.name : context.context), + required: context.isRequired === true, + result: + run && context.status !== "COMPLETED" + ? lower(context.status) + : lower(run ? context.conclusion : context.state), + url: optional(run ? context.detailsUrl : context.targetUrl), + }; + return { check, outcome: contextOutcome(context) }; +} + +function lower(value: unknown): string { + return string(value).toLowerCase(); +} + +function optional(value: unknown): string | null { + return value === null || value === undefined || value === "" ? null : string(value); +} diff --git a/src/github/tsconfig.json b/src/github/tsconfig.json new file mode 100644 index 00000000..3da939d8 --- /dev/null +++ b/src/github/tsconfig.json @@ -0,0 +1,10 @@ +{ + // Like the core, these readers run in Workers: no ambient Node types here, so a stray + // `process` or `node:` import fails to type-check. The web worker library supplies `fetch`. + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["ES2022", "WebWorker"], + "types": [] + }, + "include": ["."] +} diff --git a/test/land.test.ts b/test/land.test.ts index c83b26c4..359398bf 100644 --- a/test/land.test.ts +++ b/test/land.test.ts @@ -48,15 +48,14 @@ import { type Plan, type Target, commandEnvironment, - EJECTED, UPHELD, UPHELD_CLASSIC, build, mergeBase, - pin, readRemote, resolveQueue, } from "../src/cli/land"; +import { EJECTED, pin } from "../src/github/land"; import { type OwnQueue, ownPass, QUEUE_CHECK, queueBranch } from "../src/cli/land-queue"; import { ownQueue, readRemoval, watchPermissions } from "../src/cli/land-watch"; import type { FixContext } from "../src/cli/land-watch-fix"; diff --git a/test/start.test.ts b/test/start.test.ts index b248227c..46afd8f2 100644 --- a/test/start.test.ts +++ b/test/start.test.ts @@ -12,7 +12,8 @@ import { claims, openDrafts, target } from "../src/cli/claims"; import { githubRepository, readRemote, remoteRepository } from "../src/cli/github"; import { type GitHubDependencies, type ObjectValue } from "../src/github/client"; import { type Output, main } from "../src/cli/main"; -import { Refusal, ago, releaseQuest, startQuest } from "../src/cli/start"; +import { Refusal, releaseQuest, startQuest } from "../src/cli/start"; +import { ago } from "../src/github/hold"; import { EPIC_README, GIT_SETTINGS, TWO, Tree, configure, initRepository, template, tempDir } from "./fixture"; const ONE = "quest/m0/epic/one.md"; diff --git a/test/status.test.ts b/test/status.test.ts index 009d4faf..0b878871 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -2,7 +2,8 @@ import { describe, expect, test } from "vitest"; import type { GitHubDependencies } from "../src/github/client"; import { main } from "../src/cli/main"; -import { pullRequestStatus, statusLines } from "../src/cli/status"; +import { statusLines } from "../src/cli/status"; +import { pullRequestStatus } from "../src/github/status"; const A = "a".repeat(40); const B = "b".repeat(40); From 56e6534e5f3948455310b83ac985e2692cb6d3fd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 13 Nov 2026 13:32:16 -0800 Subject: [PATCH 4/6] wip: claims test, quest done --- quest/m0/github-readers.md | 25 ------- quest/m0/routing-evidence/writer-ab.md | 2 +- test/claims.test.ts | 100 +++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 26 deletions(-) delete mode 100644 quest/m0/github-readers.md create mode 100644 test/claims.test.ts diff --git a/quest/m0/github-readers.md b/quest/m0/github-readers.md deleted file mode 100644 index 148f9304..00000000 --- a/quest/m0/github-readers.md +++ /dev/null @@ -1,25 +0,0 @@ -# [M] PR status and claims as a library - -## Goal - -A hosted service with no checkout, `git`, or Node APIs, such as a Cloudflare Worker that indexes repositories from GitHub webhooks, derives a PR's status and a quest's claim with Quest's own code instead of a second implementation that drifts. -The package exports, beside `./core`, a module that reads both through GitHub's API with the caller's `fetch` and token: - -- `pullRequestStatus`, giving what `quest status` prints: draft, holds, the hand-back (an open draft whose `starterHold` is null, since `readHoldStatus` sets it only for a draft not handed back), the review record, checks, auto-merge and the head it was enabled on, the merge queue entry, and why landing stopped. -- The claims on a list of quest branches, as `quest unblocked --unclaimed` and `quest start` decide them: a branch upstream or in the head fork, unless its tip is a merged PR's head, or the earliest open PR from it. - -`quest status`, `quest wait`, and the claim checks call the same code, so the CLI and a hosted caller cannot disagree. -`hosts` is exported from `./core` too, so a caller lists a quest's hosts as `quest unblocked --json` reports them. - -## Plan - -Agent's choices, for the implementer to revisit: - -- Split the GitHub client in `src/cli/github.ts`: requests, GraphQL, paging, retries, and the deadline move to `src/github/`, which already holds GitHub logic with no `node:` imports (`checks.ts`, `merge.ts`), and the CLI keeps reading remotes and resolving its token. `GitHubDependencies` (`fetch`, `now`, `sleep`, `token`, `warn`) already fits a Worker. -- Move `pullRequestStatus` there with what it calls: the summary, `classifyCheck`, the ejection reader and `EJECTED`, `pinned`, the review record's comparison, `enabledHead` and `headBefore`, and the read half of `readHoldStatus` with what it calls (`ago`, `repositoryPath`, and the read side of `Labels`), moving each out of its `node:`-importing module. -- Claims: `held` takes the upstream's branch tips as a parameter but reads the head fork's through `git`; the library form takes the repository and head fork as `owner/repo` and reads tips through GitHub's matching-refs API when not given, so only finding the upstream through `git` remotes stays in the CLI. -- Export the module as `./github`, type-checked with `types: []` like `src/core`, so a stray `node:` import fails `just check`. `src/github/models.ts` imports `src/cli/models.ts`, which reaches `node:` modules: move `models.ts` to `src/cli`, keeping agent routing out of `src/core`. -- The package stays private: a consumer depends on a pinned commit (`github:moq-dev/quest#`), so no release is needed. -- A webhook-driven caller re-reads a PR's status on each event about it; nothing here parses webhook payloads. - -Tests: the moved code keeps its tests, and `quest status` prints the same lines for the same GitHub responses. diff --git a/quest/m0/routing-evidence/writer-ab.md b/quest/m0/routing-evidence/writer-ab.md index 11ab9623..214c160e 100644 --- a/quest/m0/routing-evidence/writer-ab.md +++ b/quest/m0/routing-evidence/writer-ab.md @@ -48,7 +48,7 @@ Agent's choices, for the implementer to revisit: `quest unblocked --json` rather than drawing again. - Record it on the Implementer line beside the size an iterate already records, such as `(arm B)` or `(iterate, S, arm B)`, and extend - `implementer()` in `src/github/models.ts` to parse both, since today it + `implementer()` in `src/cli/models-record.ts` to parse both, since today it accepts only `(XS)` and `(iterate, XS)`. A writer that ran off its arm, such as a fallback to Sol, counts in neither arm. - `quest models` takes only a size, so an iterate's entry comes from the diff --git a/test/claims.test.ts b/test/claims.test.ts new file mode 100644 index 00000000..0a2c1a47 --- /dev/null +++ b/test/claims.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from "vitest"; +import { GitHub, type GitHubDependencies, held } from "../src/github"; + +const upstream = { owner: "example", repo: "project" }; +const fork = { owner: "someone", repo: "project" }; + +/** A GitHub whose repositories hold `refs` and `pulls`, keyed by `owner/repo`, recording each path it serves. */ +function github(world: { + refs: Record>; + open: Record; + closed: Record; +}) { + const requested: string[] = []; + const deps: GitHubDependencies = { + token: "test-token", + now: () => 0, + sleep: async () => undefined, + warn: () => undefined, + fetch: async (input) => { + const url = new URL(String(input)); + requested.push(url.pathname + url.search); + const [, , owner, repo, ...rest] = url.pathname.split("/"); + const name = `${owner}/${repo}`; + if (rest.join("/") === "git/matching-refs/heads/quest/") + return Response.json( + Object.entries(world.refs[name] ?? {}).map(([ref, sha]) => ({ ref, object: { sha } })), + ); + if (rest.join("/") === "pulls") { + const state = url.searchParams.get("state"); + if (state === "open") return Response.json(world.open[name] ?? []); + const head = url.searchParams.get("head"); + return Response.json( + (world.closed[name] ?? []).filter((pull) => (pull as { label: string }).label === head), + ); + } + return new Response("not found", { status: 404 }); + }, + }; + return { client: new GitHub(deps), requested }; +} + +const pull = (n: number, ref: string, created: string) => ({ + number: n, + head: { ref, repo: { full_name: "someone/project" } }, + draft: true, + body: null, + created_at: created, + updated_at: created, + html_url: `https://github.com/example/project/pull/${n}`, +}); + +test("held reads both sources' quest branches through GitHub when the caller passes no tips", async () => { + const { client, requested } = github({ + refs: { + "example/project": { + "refs/heads/quest/m0/pushed": "a".repeat(40), + "refs/heads/quest/m0/landed": "b".repeat(40), + }, + "someone/project": { "refs/heads/quest/m0/forked": "c".repeat(40) }, + }, + open: { + "someone/project": [pull(9, "quest/m0/opened", "2026-10-21T13:00:00Z")], + "example/project": [pull(7, "quest/m0/opened", "2026-10-21T12:00:00Z")], + }, + closed: { + "example/project": [ + { + label: "example:quest/m0/landed", + head: { sha: "b".repeat(40) }, + merged_at: "2026-10-20T00:00:00Z", + html_url: "https://github.com/example/project/pull/3", + }, + ], + }, + }); + const found = await held(client, { upstream: { repository: upstream }, fork: { repository: fork } }, [ + "quest/m0/pushed", + "quest/m0/landed", + "quest/m0/forked", + "quest/m0/opened", + "quest/m0/free", + ]); + expect(found.claims.map((claim) => [claim.branch, claim.url])).toEqual([ + ["quest/m0/forked", null], + // The earliest open PR holds the claim, whichever repository it opened in. + ["quest/m0/opened", "https://github.com/example/project/pull/7"], + ["quest/m0/pushed", null], + ]); + // A merged PR's head upstream holds nothing, and only the fork's landed branches are the claim's to replace. + expect(found.landed).toEqual(new Map()); + expect(requested).toContain("/repos/example/project/git/matching-refs/heads/quest/"); + expect(requested).toContain("/repos/someone/project/git/matching-refs/heads/quest/"); +}); + +test("held reads no branches the caller already passed", async () => { + const { client, requested } = github({ refs: {}, open: {}, closed: {} }); + const found = await held(client, { upstream: { repository: upstream, tips: new Map() } }, ["quest/m0/a"]); + expect(found.claims).toEqual([]); + expect(requested.some((path) => path.includes("matching-refs"))).toBe(false); +}); From d3c297281728a89cfe0d6c7c9942bb8e764f9f0c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 13 Nov 2026 13:33:41 -0800 Subject: [PATCH 5/6] docs: comment the exports ./github makes public --- src/cli/github.ts | 1 + src/github/client.ts | 5 +++++ src/github/status.ts | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/src/cli/github.ts b/src/cli/github.ts index e2e60ecc..00477aa4 100644 --- a/src/cli/github.ts +++ b/src/cli/github.ts @@ -120,6 +120,7 @@ export function githubRepository(url: string): Repository | null { return match ? { owner: match[1], repo: match[2] } : null; } +/** The CLI's client dependencies: global `fetch`, a token from `GH_TOKEN`, `GITHUB_TOKEN`, or `gh auth token`, and `warn`. */ export function gitHubDependencies(warn: (message: string) => void): GitHubDependencies { let token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; if (!token) { diff --git a/src/github/client.ts b/src/github/client.ts index f1c56c9c..4b5cfe80 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -60,6 +60,7 @@ const NULL_BODY = [204, 205, 304]; */ export type Repeat = boolean | ((started: number) => Promise); +/** How {@link GitHub.request} sends a request and which refusals it returns rather than throws. */ export interface RequestOptions { etag?: string; body?: unknown; @@ -547,22 +548,26 @@ export function actor(value: unknown): string { : "-"; } +/** `value` as a JSON object, or a thrown invalid-response error. */ export function object(value: unknown): ObjectValue { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("invalid GitHub response: expected an object"); return value as ObjectValue; } +/** `value` as an array of JSON objects, or a thrown invalid-response error. */ export function array(value: unknown): ObjectValue[] { if (!Array.isArray(value)) throw new Error("invalid GitHub response: expected an array"); return value.map(object); } +/** `value` as a nonempty string, or a thrown invalid-response error. */ export function string(value: unknown): string { if (typeof value !== "string" || !value) throw new Error("invalid GitHub response: expected a nonempty string"); return value; } +/** The URL a response's `Link` header gives for `relation`, such as the next page. */ export function link(response: Response, relation: string): string | undefined { return response.headers .get("link") diff --git a/src/github/status.ts b/src/github/status.ts index a9c78a23..367d63ac 100644 --- a/src/github/status.ts +++ b/src/github/status.ts @@ -69,6 +69,7 @@ const STATUS = `query($owner: String!, $repo: String!, $number: Int!, } fragment reaction on Reaction { content createdAt user { login } }`; +/** A check run or commit status on the head, as `quest status` prints it. */ export interface Check { name: string; required: boolean; @@ -77,6 +78,7 @@ export interface Check { url: string | null; } +/** A reviewer's latest submitted review. */ export interface Review { reviewer: string; /** approved, changes_requested, commented, or dismissed. */ @@ -88,6 +90,7 @@ export interface Review { url: string; } +/** A reaction on the PR or one of its reviews. */ export interface Reaction { content: string; actor: string; @@ -96,6 +99,7 @@ export interface Reaction { url: string; } +/** An unresolved review thread. */ export interface Thread { id: string; path: string; @@ -118,6 +122,7 @@ export interface ReviewRecord { head: string; } +/** Everything `quest status` reports about a PR. */ export interface Status { url: string; state: "open" | "closed" | "merged"; From c0438049694cabf2043f35b0500ae12de1701e82 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 13 Nov 2026 14:08:09 -0800 Subject: [PATCH 6/6] perf(github): read sources' tips and open PRs concurrently in held Co-Authored-By: Claude Opus 5.5 --- src/github/claims.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/github/claims.ts b/src/github/claims.ts index 617cb267..8a246df8 100644 --- a/src/github/claims.ts +++ b/src/github/claims.ts @@ -103,8 +103,11 @@ export async function held(github: GitHub, sources: Sources, branches: string[]) repository: source.repository, tips: source.tips ?? (await claimBranches(github, source.repository)), }); - const all = [await read(upstream), ...(fork ? [await read(fork)] : [])]; - const pulls = (await Promise.all(all.map((source) => openPullRequests(github, source.repository)))).flat(); + const listed = fork ? [upstream, fork] : [upstream]; + const [all, pulls] = await Promise.all([ + Promise.all(listed.map(read)), + Promise.all(listed.map((source) => openPullRequests(github, source.repository))).then((each) => each.flat()), + ]); const pushTo = all[all.length - 1]; const landed = new Map(); const holds = async (branch: string): Promise => {