diff --git a/README.md b/README.md index 88686bea..337100c8 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ pushes come from repository activity; pushes observed while waiting come from ne commit SHAs, regardless of git author dates. Force pushes come from the PR timeline, excluding the one that created the starting head. A merge through the queue prints `removed_from_merge_queue` about a second before `merged`, so a -poll can catch the removal alone; check the PR's state before treating it as a +poll can catch the removal alone; check `quest status` before treating it as a failed merge. Actors are printed without deciding whose review counts. Check runs and edits to existing comments or reviews are outside this command's scope. @@ -204,6 +204,55 @@ a cursor, or the given `--since` (else the head SHA) if setup had not finished. Retry with it, not the original command, so activity that landed during the first wait is still reported. +## Read a pull request's state + +`quest status` prints a GitHub PR's current state in one call, such as after +`quest wait` wakes. It accepts the same PR argument and authentication as +`quest wait`, and reports facts without deciding whether the PR is ready or +whose review counts: + +```console +$ quest status 128 +url https://github.com/kixelated/quest/pull/128 +state open +draft false +head 0ade9d977d54c6f48152410532c58e9ea78db3d0 +mergeable conflicting +merge-state dirty +review-decision - +auto-merge - +merge-queue - +checks success passed=14 failing=0 pending=0 +review stale commented kixelated 2026-10-16T06:14:04Z https://github.com/kixelated/quest/pull/128#pullrequestreview-5543745302 +review current commented chatgpt-codex-connector 2026-10-16T17:57:16Z https://github.com/kixelated/quest/pull/128#pullrequestreview-5551703054 +thread PRRT_kwDOUhuoys6tUUV2 current chatgpt-codex-connector https://github.com/kixelated/quest/pull/128#discussion_r4297708263 src/cli/gate.ts:115 +``` + +Each line starts with its record type; any free text, a check name or a path, +comes last, and a missing value prints as `-`: + +| Line | Fields | +| --- | --- | +| `url`, `state`, `draft`, `head` | The PR URL; `open`, `closed`, or `merged`; `true` or `false`; the head SHA. | +| `mergeable`, `merge-state`, `review-decision` | GitHub's values, lowercased. | +| `auto-merge` | Method, who enabled it, and when. | +| `merge-queue` | Queue state, position, and when it was enqueued. | +| `checks` | The head's rollup state and `passed=`, `failing=`, `pending=` counts. | +| `check` | `failing` or `pending`, `required` or `optional`, the result, details URL, and name. | +| `review` | `current` or `stale` against the head, state, reviewer, time, and URL of each reviewer's latest review. | +| `reacted:` | Actor, time, and the PR or review reacted to, as `quest wait` names reactions. | +| `thread` | Node ID, `current` or `outdated`, last commenter, latest comment URL, and `path:line` of each unresolved thread. | + +The node ID is what GraphQL's `addPullRequestReviewThreadReply` and +`resolveReviewThread` take. Neutral and skipped checks pass. A required check +is one branch protection or a ruleset requires for this PR. `--json` prints the +same facts as one object with the fields `url`, `state`, `draft`, `head`, +`mergeable`, `mergeState`, `reviewDecision`, `autoMerge`, `mergeQueue`, +`checks` (`state`, `passed`, `failing`, `pending`), `reviews`, `reactions`, and +`threads`. Long lists are paged; reactions are the newest 100 per PR or review, +as in `quest wait`. Any PR state exits 0, invalid arguments exit 2, and other +failures exit 1. + ## Work with your agents Open this repository in Claude Code or Codex. The binary carries the skills; diff --git a/assets/skills/complete.md b/assets/skills/complete.md index b419934d..caa06c20 100644 --- a/assets/skills/complete.md +++ b/assets/skills/complete.md @@ -32,8 +32,8 @@ Other sessions share this machine: hold new agents while the load average exceed Tell each merge sub-agent that you watch its merge queue, so it reports once auto-merge is enabled. Wait on each such PR with `quest wait`, as `quest skill merge` describes, until it merges. -If it leaves the queue unmerged or auto-merge is disabled, confirm with the PR's -current state, then ask about the PR again; never dispatch `/quest-merge` without +If it leaves the queue unmerged or auto-merge is disabled, confirm with +`quest status `, then ask about the PR again; never dispatch `/quest-merge` without the user's choice, since a new head needs fresh approval. Keep going until all PRs have been decided, then wait for all spawned sub-agents and enqueued PRs to finish. diff --git a/assets/skills/iterate.md b/assets/skills/iterate.md index 64b4c549..a8b8fcc3 100644 --- a/assets/skills/iterate.md +++ b/assets/skills/iterate.md @@ -9,6 +9,7 @@ Parse the arguments to determine the PR number. Any other text is the user's fee If you cannot push to the PR's branch, stop and report. Read the PR, its linked issues and quest, the reviews and comments posted on it, and the surrounding code. +`quest status ` reports its checks, reviews by head, and unresolved threads in one call. Judge the approach before touching anything: would you take a different one? Scope the changes this PR needs with the plan skill's interview, starting from the user's feedback, your judgment of the approach, and any review findings that need a decision. diff --git a/assets/skills/merge.md b/assets/skills/merge.md index a79cfc69..d1ef66d8 100644 --- a/assets/skills/merge.md +++ b/assets/skills/merge.md @@ -7,6 +7,7 @@ Before you begin, run `quest guide` and read its output completely. Parse the arguments to determine the PR number, otherwise resolve it from the current context/branch. If a draft, stop and offer `/quest-ready` instead. +Read the PR's head, checks, merge state, reviews by head, and unresolved threads with `quest status `, including after each `quest wait` wakes, instead of chaining `gh` queries. Fix any mechanical issues with the PR, such as merge conflicts and failing CI checks. Address any review findings, fixing those you agree with and leaving a comment on any you disagree with. Automated reviewers often find something new on every push, so keep fixing their findings without asking, but checkpoint the design. @@ -42,6 +43,6 @@ Merge the PR *only* after all outstanding decisions have been confirmed. Leave a summary of the changes and decisions made and enable auto-merge with the full 40-character head SHA. If your caller said it watches the merge queue, report once auto-merge is enabled. Otherwise wait until the PR merges. If it leaves the queue unmerged or auto-merge -is disabled, confirm with the PR's current state, then report why instead of +is disabled, confirm with `quest status `, then report why instead of enabling auto-merge again; a new head needs the user's approval. Never close a PR to unstick it, and never work around a refused merge; ask instead. diff --git a/assets/skills/ready.md b/assets/skills/ready.md index 13986a26..faca72ec 100644 --- a/assets/skills/ready.md +++ b/assets/skills/ready.md @@ -10,7 +10,7 @@ If its body lists open decisions or unfinished work, offer `/quest-iterate` firs Flip a draft to "Ready for Review", then wait with `quest wait`, as `quest skill merge` describes, until an automated reviewer reviews or reacts. Skip the wait only if the repository has no automated reviewer. -Summarize the reviews and CI, then interactively prompt the user with your recommendation: +Summarize the reviews and CI from `quest status `, then interactively prompt the user with your recommendation: - `/quest-merge`: If the reviews found no significant issues. - `/quest-iterate`: If they raised significant issues or decisions. - skip: If the PR should not be merged yet. diff --git a/quest/m0/helpers/README.md b/quest/m0/helpers/README.md index e3e75e39..2e991ec1 100644 --- a/quest/m0/helpers/README.md +++ b/quest/m0/helpers/README.md @@ -23,9 +23,8 @@ commands from Sep-Oct. Session counts in each child come from that window. `quest status`, since that body format is a skill convention. - GitHub calls live in `src/cli/` beside `quest wait`, outside the forge-agnostic core. Pure tree edits, such as link rewriting, go in the - core. The first child to add a second GitHub caller extracts the client in - `src/cli/wait.ts` (auth, rate-limit retry, same-origin pagination) instead - of copying it. + core. GitHub commands share the client in `src/cli/github.ts` (auth, + rate-limit retry, same-origin pagination, GraphQL) instead of copying it. - Decided (user): no separate documentation page. Each child updates `--help`, README examples, and the skill prose it replaces; the user asked for those skill edits through this plan. @@ -49,7 +48,6 @@ commands from Sep-Oct. Session counts in each child come from that window. ## Required -- [PR status](/quest/m0/helpers/status.md) - one call for head, checks, merge state, reviews by head, and unresolved threads - [Move and delete quests](/quest/m0/helpers/move.md) - `quest mv` and `quest rm` keep every root-absolute link valid - [CI checks in wait](/quest/m0/helpers/wait-checks.md) - `quest wait` wakes on required check results for the head - [quest start](/quest/m0/helpers/start.md) - claim a quest with a draft PR, the `## Decisions needed` hand-back, `quest unblocked --unclaimed`, and CI on ready PRs only diff --git a/quest/m0/helpers/status.md b/quest/m0/helpers/status.md deleted file mode 100644 index a675e13e..00000000 --- a/quest/m0/helpers/status.md +++ /dev/null @@ -1,33 +0,0 @@ -# [M] Report a PR's state with quest status - -## Goal - -`quest status ` prints a pull request's current state in one call, plain -or `--json`, so agents stop chaining `gh api .../reviews|comments|reactions` -and `reviewThreads` GraphQL queries, often right after `quest wait` returns -(219 of 396 sessions). It reports facts; it never decides whether the PR is -ready to merge or whose review counts. - -## Plan - -- Decided 2026-10-19 (user): report the head SHA, draft and open state, - failing and pending checks with whether each is required, mergeability, - auto-merge, and merge-queue state. -- Decided (user): report each reviewer's latest review marked current or - stale against the head, and reactions on the PR and its reviews, as - `quest wait` reports them. -- Decided (user): report unresolved review threads with their node IDs, - path, line, last author, and URL, so an agent can reply to or resolve one - without another query. -- Accept the same PR argument as `quest wait`: a GitHub PR URL, or a number - in origin. Use one GraphQL query where it fits (`statusCheckRollup` with - `isRequired(pullRequestNumber:)`, `reviews`, `reviewThreads`, - `mergeQueueEntry`, `autoMergeRequest`), paginating long lists. -- Plain output is stable lines meant for agents; `--json` is a documented, - deterministic object. Exit 0 for any PR state; non-zero only when the - command fails, matching `quest ready`. -- Point `merge`, `iterate`, and `complete` skill prose at it for reading a - PR's state, especially after `quest wait` wakes. -- Test with recorded responses: stale and current reviews, a required and an - optional failing check, a queued PR, resolved and unresolved threads, and - pagination. diff --git a/src/cli/github.ts b/src/cli/github.ts new file mode 100644 index 00000000..7ee670eb --- /dev/null +++ b/src/cli/github.ts @@ -0,0 +1,192 @@ +// The GitHub client shared by the PR commands, outside the repository-independent core. +import { execFileSync } from "node:child_process"; +import { setTimeout as sleep } from "node:timers/promises"; + +const API = "https://api.github.com"; +const MAX_SLEEP = 2_147_483_647; + +export interface PullRequest { + owner: string; + repo: string; + 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"); + } +} + +export type ObjectValue = Record; + +/** 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)) { + const origin = execFileSync("git", ["-C", root, "remote", "get-url", "origin"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + const match = + /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?$/.exec( + origin, + ); + if (!match) throw new Error("origin must name a github.com repository; pass a GitHub PR URL instead"); + return { owner: match[1], repo: match[2], number: Number(value) }; + } + const match = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/([1-9]\d*)\/?(?:[?#].*)?$/.exec(value); + if (!match) throw new GitHubInputError("expected a GitHub pull request URL or a PR number in origin"); + return { owner: match[1], repo: match[2], number: Number(match[3]) }; +} + +export function gitHubDependencies(warn: (message: string) => void): GitHubDependencies { + let token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + if (!token) { + try { + token = execFileSync("gh", ["auth", "token", "--hostname", "github.com"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch { + throw new Error("set GH_TOKEN or GITHUB_TOKEN, or sign in with gh auth login"); + } + } + if (!token) throw new Error("GitHub authentication returned an empty token"); + return { fetch, now: Date.now, sleep, token, warn }; +} + +/** 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 200 or 304 response; anything else fails after rate-limit retries. */ + async request(path: string, etag?: string, payload?: unknown): 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"); + let attempts = 0; + for (;;) { + const remaining = this.deadline - this.deps.now(); + if (remaining <= 0) throw new GitHubDeadline(); + let response: Response; + try { + response = await this.deps.fetch(url, { + ...(payload === undefined ? {} : { method: "POST", body: JSON.stringify(payload) }), + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${this.deps.token}`, + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "quest", + ...(etag ? { "If-None-Match": etag } : {}), + }, + ...(Number.isFinite(remaining) ? { signal: AbortSignal.timeout(Math.ceil(remaining)) } : {}), + }); + } catch (error) { + if (this.deps.now() >= this.deadline) throw new GitHubDeadline(); + throw error; + } + this.pollInterval = Math.max(this.pollInterval, seconds(response.headers.get("x-poll-interval")) ?? 0); + if (response.status === 200 || response.status === 304) return response; + const body = await response.text(); + const limited = + response.status === 429 || + (response.status === 403 && + (response.headers.has("retry-after") || + response.headers.get("x-ratelimit-remaining") === "0" || + /rate limit/i.test(body))); + if (!limited || attempts >= 6) + throw new Error( + `GitHub HTTP ${response.status} for ${url.pathname}; ${limited ? "rate limit persisted" : "check repository access and token permissions"}`, + ); + const retry = seconds(response.headers.get("retry-after")); + const reset = + response.headers.get("x-ratelimit-remaining") === "0" + ? seconds(response.headers.get("x-ratelimit-reset")) + : undefined; + const delay = + retry ?? (reset === undefined ? 60_000 * 2 ** attempts : Math.max(1000, reset - this.deps.now())); + attempts++; + this.deps.warn(`GitHub rate limited this request; waiting ${Math.ceil(delay / 1000)} seconds`); + await this.pause(delay); + } + } + + /** Run a GraphQL query and return its data, failing on any reported error. */ + async graphql(query: string, variables: ObjectValue): Promise { + const response = await this.request("/graphql", undefined, { query, variables }); + const body = object(await response.json()); + if (body.errors !== undefined) + throw new Error( + `GitHub GraphQL error: ${array(body.errors) + .map((error) => String(error.message)) + .join("; ")}`, + ); + return object(body.data); + } +} + +/** 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]; +} + +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/main.ts b/src/cli/main.ts index e75c0d52..769fcba7 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -11,7 +11,9 @@ import { init, uninstall } from "./setup"; import { GUIDE, SKILLS, skill } from "./skills"; import { type Terminal, Theme } from "./theme"; import { collect, exists, load, under } from "./tree"; -import { pullRequest, waitDependencies, waitForActivity, WaitInputError, WaitTimeout } from "./wait"; +import { gitHubDependencies, GitHubInputError, pullRequest } from "./github"; +import { pullRequestStatus, statusLines } from "./status"; +import { waitForActivity, WaitTimeout } from "./wait"; /** Where the command writes; the process streams, or a buffer under test. */ export interface Output { @@ -154,9 +156,7 @@ export function main(argv: string[], out: Output): number | Promise { .option("--timeout ", "Maximum wait in seconds", positiveSeconds) .action((value: string, options: { since?: string; timeout?: number }) => { const pr = pullRequest(value, root()); - const deps = waitDependencies(); - deps.warn = warn; - pending = waitForActivity(pr, options, deps).then( + pending = waitForActivity(pr, options, gitHubDependencies(warn)).then( (lines) => { for (const line of lines) print(line); return 0; @@ -164,7 +164,39 @@ export function main(argv: string[], out: Output): number | Promise { (error: unknown) => { if (error instanceof WaitTimeout && error.cursor) print(error.cursor); warn(error instanceof Error ? error.message : String(error)); - return error instanceof WaitTimeout ? 3 : error instanceof WaitInputError ? 2 : 1; + return error instanceof WaitTimeout ? 3 : error instanceof GitHubInputError ? 2 : 1; + }, + ); + }); + + program + .command("status") + .summary("Print a pull request's current state on GitHub") + .description( + "Print a pull request's head, checks, merge state, reviews, reactions, and unresolved review threads " + + "in one call. It reports facts; it does not decide whether the PR is ready or whose review counts.\n\n" + + "Plain output prints one record per line, its type first and any free text (a check name or path) last: " + + "url, state, draft, head, mergeable, merge-state, review-decision, auto-merge, merge-queue, and checks; " + + "then a check line per failing or pending check, marked required or optional; a review line per " + + "reviewer's latest review, marked current or stale against the head; a reacted: line per " + + "reaction on the PR or a review; and a thread line per unresolved review thread, led by its node ID. " + + "A missing value prints as -. --json prints the same facts as one deterministic object.\n\n" + + "Uses GH_TOKEN, GITHUB_TOKEN, or gh auth token. Exits 0 for any PR state, 2 for invalid arguments, " + + "and 1 for other failures.", + ) + .argument("", "GitHub PR URL, or a PR number in origin") + .option("--json", "Print a deterministic JSON object") + .action((value: string, options: { json?: boolean }) => { + const pr = pullRequest(value, root()); + pending = pullRequestStatus(pr, gitHubDependencies(warn)).then( + (status) => { + if (options.json) print(JSON.stringify(status)); + else for (const line of statusLines(status)) print(line); + return 0; + }, + (error: unknown) => { + warn(error instanceof Error ? error.message : String(error)); + return 1; }, ); }); @@ -219,7 +251,7 @@ export function main(argv: string[], out: Output): number | Promise { // Help and version exit 0; any other parse failure is a usage error. return error.exitCode === 0 ? 0 : 2; } - if (error instanceof UsageError || error instanceof WaitInputError) { + if (error instanceof UsageError || error instanceof GitHubInputError) { out.stderr(`error: ${error.message}\n`); return 2; } diff --git a/src/cli/status.ts b/src/cli/status.ts new file mode 100644 index 00000000..dd428aef --- /dev/null +++ b/src/cli/status.ts @@ -0,0 +1,309 @@ +// A pull request's current state on GitHub, as facts for an agent to judge. +import { + actor, + array, + GitHub, + type GitHubDependencies, + object, + type ObjectValue, + type PullRequest, + string, +} from "./github"; + +/** + * 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 state isDraft headRefOid mergeable mergeStateStatus reviewDecision + autoMergeRequest { enabledAt mergeMethod enabledBy { login } } + mergeQueueEntry { state position enqueuedAt } + reactions(last: 100) { nodes { ...reaction } } + commits(last: 1) { nodes { commit { oid statusCheckRollup { state + contexts(first: 100, after: $checks) @include(if: $moreChecks) { + pageInfo { hasNextPage endCursor } + nodes { + __typename + ... on CheckRun { name status conclusion detailsUrl isRequired(pullRequestNumber: $number) } + ... on StatusContext { context state targetUrl isRequired(pullRequestNumber: $number) } + } + } + } } } } + reviews(first: 100, after: $reviews) @include(if: $moreReviews) { + pageInfo { hasNextPage endCursor } + nodes { author { 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 } }`; + +/** Completed check-run conclusions that do not fail; anything else completed fails. */ +const PASSING = ["success", "neutral", "skipped"]; + +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; +} + +export interface Status { + url: string; + state: "open" | "closed" | "merged"; + draft: boolean; + head: string; + mergeable: string; + mergeState: string; + reviewDecision: string | null; + autoMerge: { method: string; enabledBy: string; enabledAt: string } | null; + mergeQueue: { state: string; position: number; enqueuedAt: string } | null; + checks: { state: string | null; passed: number; failing: Check[]; pending: Check[] }; + /** Each reviewer's latest submitted review, oldest first. */ + reviews: Review[]; + reactions: Reaction[]; + /** Unresolved review threads, oldest first. */ + threads: Thread[]; +} + +/** Read the PR's state in one query, plus one per extra page of a long list. */ +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); + } + return summarize(first!, rollup, contexts, reviewNodes, threadNodes); +} + +function summarize( + pull: ObjectValue, + rollup: ObjectValue | null, + contexts: ObjectValue[], + reviewNodes: ObjectValue[], + threadNodes: ObjectValue[], +): Status { + const head = string(pull.headRefOid); + const url = string(pull.url); + const checks = { + state: rollup === null ? null : lower(rollup.state), + passed: 0, + failing: [], + pending: [], + } as Status["checks"]; + for (const context of contexts) { + 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), + }; + if (run ? context.status !== "COMPLETED" : ["pending", "expected"].includes(check.result)) + checks.pending.push(check); + else if (run ? PASSING.includes(check.result) : check.result === "success") checks.passed++; + else checks.failing.push(check); + } + + 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); + 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), + }, + mergeQueue: + queue === null + ? null + : { state: lower(queue.state), position: Number(queue.position), enqueuedAt: string(queue.enqueuedAt) }, + checks, + reviews: [...latest.values()], + reactions, + threads, + }; +} + +/** Stable plain lines: a record type, then space-separated fields, with any free text last. */ +export function statusLines(status: Status): string[] { + const dash = (value: string | number | null) => (value === null ? "-" : String(value)); + const check = (kind: string, c: Check) => + `check ${kind} ${c.required ? "required" : "optional"} ${c.result} ${dash(c.url)} ${c.name}`; + return [ + `url ${status.url}`, + `state ${status.state}`, + `draft ${status.draft}`, + `head ${status.head}`, + `mergeable ${status.mergeable}`, + `merge-state ${status.mergeState}`, + `review-decision ${dash(status.reviewDecision)}`, + status.autoMerge === null + ? "auto-merge -" + : `auto-merge ${status.autoMerge.method} ${status.autoMerge.enabledBy} ${status.autoMerge.enabledAt}`, + status.mergeQueue === null + ? "merge-queue -" + : `merge-queue ${status.mergeQueue.state} ${status.mergeQueue.position} ${status.mergeQueue.enqueuedAt}`, + `checks ${dash(status.checks.state)} passed=${status.checks.passed} failing=${status.checks.failing.length} pending=${status.checks.pending.length}`, + ...status.checks.failing.map((c) => check("failing", c)), + ...status.checks.pending.map((c) => check("pending", c)), + ...status.reviews.map( + (r) => `review ${r.current ? "current" : "stale"} ${r.state} ${r.reviewer} ${r.submittedAt} ${r.url}`, + ), + ...status.reactions.map((r) => `reacted:${r.content} ${r.actor} ${r.createdAt} ${r.url}`), + ...status.threads.map( + (t) => + `thread ${t.id} ${t.outdated ? "outdated" : "current"} ${t.lastAuthor} ${t.url} ${t.path}:${dash(t.line)}`, + ), + ]; +} + +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 cb2fe082..357d8f53 100644 --- a/src/cli/wait.ts +++ b/src/cli/wait.ts @@ -1,8 +1,17 @@ // GitHub-specific PR activity waiting stays outside the repository-independent core. -import { execFileSync } from "node:child_process"; -import { setTimeout as sleep } from "node:timers/promises"; +import { + actor, + array, + GitHub, + type GitHubDependencies, + GitHubInputError, + link, + object, + type ObjectValue, + type PullRequest, + string, +} from "./github"; -const API = "https://api.github.com"; const POLL = 5 * 60_000; const SHA = /^[a-f\d]{7,40}$/i; /** The timeline records a force push a few seconds after repository activity (1-2s observed). */ @@ -22,28 +31,12 @@ const REACTIONS = `query($owner: String!, $repo: String!, $number: Int!, $after: } fragment reaction on Reaction { id content createdAt user { login } }`; -export interface PullRequest { - owner: string; - repo: string; - number: number; -} - export interface WaitOptions { since?: string; /** Seconds; omitted means no deadline. */ timeout?: number; } -export interface WaitDependencies { - fetch: typeof fetch; - now(): number; - sleep(milliseconds: number): Promise; - token: string; - warn(message: string): void; -} - -export class WaitInputError extends Error {} - export class WaitTimeout extends Error { /** Where to resume, when the wait got far enough to know. */ constructor(readonly cursor?: string) { @@ -51,7 +44,6 @@ export class WaitTimeout extends Error { } } -type ObjectValue = Record; interface Activity { point: string; time: number; @@ -79,46 +71,11 @@ interface Cursor { pushes: { sha: string; time: number }[]; } -/** 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)) { - const origin = execFileSync("git", ["-C", root, "remote", "get-url", "origin"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }).trim(); - const match = - /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?$/.exec( - origin, - ); - if (!match) throw new Error("origin must name a github.com repository; pass a GitHub PR URL instead"); - return { owner: match[1], repo: match[2], number: Number(value) }; - } - const match = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/([1-9]\d*)\/?(?:[?#].*)?$/.exec(value); - if (!match) throw new WaitInputError("expected a GitHub pull request URL or a PR number in origin"); - return { owner: match[1], repo: match[2], number: Number(match[3]) }; -} - -export function waitDependencies(): WaitDependencies { - let token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; - if (!token) { - try { - token = execFileSync("gh", ["auth", "token", "--hostname", "github.com"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }).trim(); - } catch { - throw new Error("set GH_TOKEN or GITHUB_TOKEN, or sign in with gh auth login"); - } - } - if (!token) throw new Error("GitHub authentication returned an empty token"); - return { fetch, now: Date.now, sleep, token, warn: (message) => process.stderr.write(`quest: ${message}\n`) }; -} - /** Wait until activity, returning plain output lines. Network and time are injected. */ export async function waitForActivity( pr: PullRequest, options: WaitOptions, - deps: WaitDependencies, + deps: GitHubDependencies, ): Promise { const started = deps.now(); const progress = { resume: options.since }; @@ -135,7 +92,7 @@ export async function waitForActivity( async function watch( pr: PullRequest, options: WaitOptions, - deps: WaitDependencies, + deps: GitHubDependencies, started: number, progress: { resume?: string }, ): Promise { @@ -143,71 +100,16 @@ async function watch( try { timestamp(options.since); } catch (error) { - throw new WaitInputError((error as Error).message); + throw new GitHubInputError((error as Error).message); } } const name = `${pr.owner}/${pr.repo}#${pr.number}`; const base = `/repos/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}`; const prUrl = `https://github.com/${pr.owner}/${pr.repo}/pull/${pr.number}`; const deadline = options.timeout === undefined ? Infinity : started + options.timeout * 1000; - const pause = async (milliseconds: number) => { - const remaining = deadline - deps.now(); - if (remaining <= 0) throw new WaitTimeout(); - await deps.sleep(Math.min(milliseconds, remaining, 2_147_483_647)); - if (deps.now() >= deadline) throw new WaitTimeout(); - }; - let pollInterval = POLL; - const request = async (path: string, etag?: string, payload?: unknown): 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"); - let attempts = 0; - for (;;) { - const remaining = deadline - deps.now(); - if (remaining <= 0) throw new WaitTimeout(); - let response: Response; - try { - response = await deps.fetch(url, { - ...(payload === undefined ? {} : { method: "POST", body: JSON.stringify(payload) }), - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${deps.token}`, - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "quest", - ...(etag ? { "If-None-Match": etag } : {}), - }, - ...(Number.isFinite(remaining) ? { signal: AbortSignal.timeout(Math.ceil(remaining)) } : {}), - }); - } catch (error) { - if (deps.now() >= deadline) throw new WaitTimeout(); - throw error; - } - pollInterval = Math.max(pollInterval, seconds(response.headers.get("x-poll-interval")) ?? 0); - if (response.status === 200 || response.status === 304) return response; - const body = await response.text(); - const limited = - response.status === 429 || - (response.status === 403 && - (response.headers.has("retry-after") || - response.headers.get("x-ratelimit-remaining") === "0" || - /rate limit/i.test(body))); - if (!limited || attempts >= 6) - throw new Error( - `GitHub HTTP ${response.status} for ${url.pathname}; ${limited ? "rate limit persisted" : "check repository access and token permissions"}`, - ); - const retry = seconds(response.headers.get("retry-after")); - const reset = - response.headers.get("x-ratelimit-remaining") === "0" - ? seconds(response.headers.get("x-ratelimit-reset")) - : undefined; - const delay = retry ?? (reset === undefined ? 60_000 * 2 ** attempts : Math.max(1000, reset - deps.now())); - attempts++; - deps.warn(`GitHub rate limited this request; waiting ${Math.ceil(delay / 1000)} seconds`); - await pause(delay); - } - }; + const github = new GitHub(deps, deadline); const list = async (path: string): Promise<{ rows: ObjectValue[]; response: Response }> => { - const response = await request(path); + const response = await github.request(path); if (response.status !== 200) throw new Error("unexpected GitHub 304 without a cached response"); return { rows: array(await response.json()), response }; }; @@ -219,7 +121,7 @@ async function watch( if (options.since?.startsWith("q1.")) { cursor = decodeCursor(options.since, name); } else { - const pullResponse = await request(`${base}/pulls/${pr.number}`); + const pullResponse = await github.request(`${base}/pulls/${pr.number}`); const pull = object(await pullResponse.json()); const head = object(pull.head); const currentSha = string(head.sha); @@ -328,7 +230,7 @@ async function watch( return async (): Promise => { const rows: ObjectValue[] = []; for (;;) { - const response = await request(path, etag); + const response = await github.request(path, etag); if (response.status === 304) { if (!etag) throw new Error("unexpected GitHub 304 without a cached response"); return rows; @@ -350,18 +252,7 @@ async function watch( let viewer: string; let after: string | null = null; do { - const response = await request("/graphql", undefined, { - query: REACTIONS, - variables: { owner: pr.owner, repo: pr.repo, number: pr.number, after }, - }); - const body = object(await response.json()); - if (body.errors !== undefined) - throw new Error( - `GitHub GraphQL error: ${array(body.errors) - .map((error) => String(error.message)) - .join("; ")}`, - ); - const data = object(body.data); + const data = await github.graphql(REACTIONS, { owner: pr.owner, repo: pr.repo, number: pr.number, after }); viewer = string(object(data.viewer).login); const pull = object(object(data.repository).pullRequest); const reviews = object(pull.reviews); @@ -383,7 +274,7 @@ async function watch( ); const found: Record = { timeline: historical, replies: [], reactions: [] }; for (;;) { - pollInterval = POLL; + github.pollInterval = POLL; for (const row of await timeline()) { const type = string(row.event); if (type === "committed") { @@ -452,7 +343,7 @@ async function watch( `${event.point} ${event.type} ${event.actor} ${event.url}${index === unique.length - 1 ? ` ${nextCursor}` : ""}`, ); } - await pause(pollInterval); + await github.pause(github.pollInterval); } } @@ -502,32 +393,6 @@ function reaction(row: ObjectValue, url: string): Activity { }; } -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, "_") - : "-"; -} - -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; -} - -function array(value: unknown): ObjectValue[] { - if (!Array.isArray(value)) throw new Error("invalid GitHub response: expected an array"); - return value.map(object); -} - -function string(value: unknown): string { - if (typeof value !== "string" || !value) throw new Error("invalid GitHub response: expected a nonempty string"); - return value; -} - function timestamp(value: string): number { const result = Date.parse(value); if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(value) || !Number.isFinite(result)) @@ -535,20 +400,6 @@ function timestamp(value: string): number { return result; } -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; -} - -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]; -} - function fullSha(value: unknown): boolean { return typeof value === "string" && /^[a-f\d]{40}$/i.test(value); } @@ -580,6 +431,6 @@ function decodeCursor(value: string, pr: string): Cursor { throw new Error(); return parsed as unknown as Cursor; } catch { - throw new WaitInputError("invalid --since cursor, or cursor belongs to another pull request"); + throw new GitHubInputError("invalid --since cursor, or cursor belongs to another pull request"); } } diff --git a/test/status.test.ts b/test/status.test.ts new file mode 100644 index 00000000..de7ad87b --- /dev/null +++ b/test/status.test.ts @@ -0,0 +1,264 @@ +import { expect, test } from "vitest"; + +import type { GitHubDependencies } from "../src/cli/github"; +import { main } from "../src/cli/main"; +import { pullRequestStatus, statusLines } from "../src/cli/status"; + +const A = "a".repeat(40); +const B = "b".repeat(40); +const pr = { owner: "example", repo: "project", number: 1 }; +const URL_ = "https://github.com/example/project/pull/1"; + +type Node = Record; +const page = (nodes: Node[], next?: string) => ({ + pageInfo: { hasNextPage: next !== undefined, endCursor: next ?? null }, + nodes, +}); + +/** A recorded GraphQL response; omitted connections stand for ones the query skipped. */ +function pull( + fields: { + head?: string; + checks?: ReturnType | null; + reviews?: ReturnType; + threads?: ReturnType; + reactions?: Node[]; + } & Node = {}, +) { + const { head = A, checks, reviews, threads, reactions = [], ...rest } = fields; + return { + data: { + repository: { + pullRequest: { + url: URL_, + state: "OPEN", + isDraft: false, + headRefOid: head, + mergeable: "MERGEABLE", + mergeStateStatus: "BLOCKED", + reviewDecision: "REVIEW_REQUIRED", + autoMergeRequest: null, + mergeQueueEntry: null, + reactions: { nodes: reactions }, + commits: { + nodes: [ + { + commit: { + oid: head, + statusCheckRollup: + checks === null + ? null + : { state: "FAILURE", ...(checks ? { contexts: checks } : {}) }, + }, + }, + ], + }, + ...(reviews ? { reviews } : {}), + ...(threads ? { reviewThreads: threads } : {}), + ...rest, + }, + }, + }, + }; +} + +function run(name: string, status: string, conclusion: string | null, isRequired: boolean) { + return { + __typename: "CheckRun", + name, + status, + conclusion, + detailsUrl: `https://github.com/example/project/actions/runs/1/job/${name}`, + isRequired, + }; +} + +function review(login: string, state: string, commit: string | null, id: number, reactions: Node[] = []) { + return { + author: { login }, + state, + submittedAt: `2026-10-07T12:0${id}:00Z`, + url: `${URL_}#pullrequestreview-${id}`, + commit: commit === null ? null : { oid: commit }, + reactions: { nodes: reactions }, + }; +} + +function thread(id: string, isResolved: boolean, line: number | null = 3, author = "reviewer") { + return { + id, + isResolved, + isOutdated: line === null, + path: "src/a.ts", + line, + originalLine: 2, + comments: { nodes: [{ author: { login: author }, url: `${URL_}#discussion_r${id}` }] }, + }; +} + +function setup(bodies: unknown[]) { + const queries: Record[] = []; + const deps: GitHubDependencies = { + token: "test-token", + now: () => 0, + sleep: async () => {}, + warn: () => {}, + fetch: async (url, init) => { + expect(new URL(String(url)).pathname).toBe("/graphql"); + queries.push(JSON.parse(String(init?.body)).variables); + const body = bodies.shift(); + if (body === undefined) throw new Error("unexpected request"); + return new Response(JSON.stringify(body)); + }, + }; + return { deps, queries }; +} + +test("reports checks by requiredness, each reviewer's latest review against the head, and open threads", async () => { + const fixture = setup([ + pull({ + checks: page([ + run("lint", "COMPLETED", "FAILURE", true), + run("docs", "COMPLETED", "CANCELLED", false), + run("test", "IN_PROGRESS", null, true), + run("build", "COMPLETED", "SUCCESS", true), + run("publish", "COMPLETED", "SKIPPED", false), + { + __typename: "StatusContext", + context: "ci/legacy", + state: "ERROR", + targetUrl: null, + isRequired: false, + }, + { __typename: "StatusContext", context: "deploy", state: "EXPECTED", targetUrl: "", isRequired: true }, + ]), + reviews: page([ + review("alice", "APPROVED", B, 1), + review("bob", "CHANGES_REQUESTED", B, 2), + review("alice", "COMMENTED", A, 3, [ + { content: "THUMBS_UP", createdAt: "2026-10-07T12:04:00Z", user: { login: "codex[bot]" } }, + ]), + review("viewer", "PENDING", A, 4), + review("gone", "COMMENTED", null, 5), + ]), + threads: page([thread("T1", true), thread("T2", false), thread("T3", false, null, "author")]), + reactions: [{ content: "EYES", createdAt: "2026-10-07T12:00:00Z", user: null }], + }), + ]); + const status = await pullRequestStatus(pr, fixture.deps); + expect(fixture.queries).toHaveLength(1); + expect(fixture.queries[0]).toMatchObject({ number: 1, moreChecks: true, moreReviews: true, moreThreads: true }); + expect(statusLines(status)).toEqual([ + `url ${URL_}`, + "state open", + "draft false", + `head ${A}`, + "mergeable mergeable", + "merge-state blocked", + "review-decision review_required", + "auto-merge -", + "merge-queue -", + "checks failure passed=2 failing=3 pending=2", + "check failing required failure https://github.com/example/project/actions/runs/1/job/lint lint", + "check failing optional cancelled https://github.com/example/project/actions/runs/1/job/docs docs", + "check failing optional error - ci/legacy", + "check pending required in_progress https://github.com/example/project/actions/runs/1/job/test test", + "check pending required expected - deploy", + `review stale changes_requested bob 2026-10-07T12:02:00Z ${URL_}#pullrequestreview-2`, + `review current commented alice 2026-10-07T12:03:00Z ${URL_}#pullrequestreview-3`, + `review stale commented gone 2026-10-07T12:05:00Z ${URL_}#pullrequestreview-5`, + `reacted:eyes - 2026-10-07T12:00:00Z ${URL_}`, + `reacted:thumbs_up codex[bot] 2026-10-07T12:04:00Z ${URL_}#pullrequestreview-3`, + `thread T2 current reviewer ${URL_}#discussion_rT2 src/a.ts:3`, + `thread T3 outdated author ${URL_}#discussion_rT3 src/a.ts:2`, + ]); +}); + +test("a queued PR with auto-merge and no checks reports both, and JSON is deterministic", async () => { + const body = pull({ + state: "OPEN", + isDraft: true, + reviewDecision: null, + autoMergeRequest: { + enabledAt: "2026-10-07T12:00:00Z", + mergeMethod: "SQUASH", + enabledBy: { login: "kixelated" }, + }, + mergeQueueEntry: { state: "AWAITING_CHECKS", position: 2, enqueuedAt: "2026-10-07T12:01:00Z" }, + checks: null, + reviews: page([]), + threads: page([]), + }); + const status = await pullRequestStatus(pr, setup([body]).deps); + expect(statusLines(status).slice(2, 10)).toEqual([ + "draft true", + `head ${A}`, + "mergeable mergeable", + "merge-state blocked", + "review-decision -", + "auto-merge squash kixelated 2026-10-07T12:00:00Z", + "merge-queue awaiting_checks 2 2026-10-07T12:01:00Z", + "checks - passed=0 failing=0 pending=0", + ]); + expect(JSON.stringify(await pullRequestStatus(pr, setup([body]).deps))).toBe(JSON.stringify(status)); + expect(status.mergeQueue).toEqual({ state: "awaiting_checks", position: 2, enqueuedAt: "2026-10-07T12:01:00Z" }); +}); + +test("long lists page only the connections that have more, keeping the first page's PR fields", async () => { + const fixture = setup([ + pull({ + checks: page([run("one", "COMPLETED", "FAILURE", true)], "c1"), + reviews: page([review("alice", "APPROVED", B, 1)], "r1"), + threads: page([thread("T1", false)]), + reactions: [{ content: "HEART", createdAt: "2026-10-07T12:00:00Z", user: { login: "x" } }], + }), + pull({ + checks: page([run("two", "QUEUED", null, false)]), + reviews: page([review("alice", "APPROVED", A, 2)], "r2"), + reactions: [{ content: "HEART", createdAt: "2026-10-07T12:00:00Z", user: { login: "x" } }], + }), + pull({ reviews: page([review("bob", "COMMENTED", A, 3)]) }), + ]); + const status = await pullRequestStatus(pr, fixture.deps); + expect( + fixture.queries.map(({ checks, reviews, threads, moreChecks, moreReviews, moreThreads }) => ({ + checks, + reviews, + threads, + moreChecks, + moreReviews, + moreThreads, + })), + ).toEqual([ + { checks: null, reviews: null, threads: null, moreChecks: true, moreReviews: true, moreThreads: true }, + { checks: "c1", reviews: "r1", threads: null, moreChecks: true, moreReviews: true, moreThreads: false }, + { checks: null, reviews: "r2", threads: null, moreChecks: false, moreReviews: true, moreThreads: false }, + ]); + expect(status.checks.failing.map((check) => check.name)).toEqual(["one"]); + expect(status.checks.pending.map((check) => check.name)).toEqual(["two"]); + expect(status.reviews.map((r) => [r.reviewer, r.current])).toEqual([ + ["alice", true], + ["bob", true], + ]); + expect(status.reactions).toHaveLength(1); + expect(status.threads.map((t) => t.id)).toEqual(["T1"]); +}); + +test("a push between pages, a GraphQL error, or a missing PR fails instead of mixing heads", async () => { + const moved = setup([ + pull({ checks: page([], "c1"), reviews: page([]), threads: page([]) }), + pull({ head: B, checks: page([]) }), + ]); + await expect(pullRequestStatus(pr, moved.deps)).rejects.toThrow("head changed"); + const errored = setup([{ errors: [{ message: "Field 'isRequired' is missing" }] }]); + await expect(pullRequestStatus(pr, errored.deps)).rejects.toThrow("GitHub GraphQL error: Field 'isRequired'"); + const missing = setup([{ data: { repository: { pullRequest: null } } }]); + await expect(pullRequestStatus(pr, missing.deps)).rejects.toThrow("is not a pull request"); +}); + +test("an invalid PR argument is a usage error before authentication", () => { + const errors: string[] = []; + const output = { stdout: () => {}, stderr: (text: string) => errors.push(text) }; + expect(main(["status", "https://evil.example/example/project/pull/1"], output)).toBe(2); + expect(errors.join("")).toContain("GitHub pull request URL"); +}); diff --git a/test/wait.test.ts b/test/wait.test.ts index 4725a0ee..84bd8cce 100644 --- a/test/wait.test.ts +++ b/test/wait.test.ts @@ -1,7 +1,8 @@ import { expect, test } from "vitest"; import { main } from "../src/cli/main"; -import { pullRequest, waitForActivity, WaitTimeout, type WaitDependencies } from "../src/cli/wait"; +import { type GitHubDependencies, pullRequest } from "../src/cli/github"; +import { waitForActivity, WaitTimeout } from "../src/cli/wait"; const A = "a".repeat(40); const B = "b".repeat(40); @@ -76,7 +77,7 @@ function setup(responses: Response[], baseline = [A], side: { comments?: Respons const sideCalls: { path: string; headers: Headers; body?: unknown }[] = []; const sleeps: number[] = []; const warnings: string[] = []; - const deps: WaitDependencies = { + const deps: GitHubDependencies = { token: "test-token", now: () => now, sleep: async (ms) => {