Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:<content>` | 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;
Expand Down
4 changes: 2 additions & 2 deletions assets/skills/complete.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pr>`, 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.
Expand Down
1 change: 1 addition & 0 deletions assets/skills/iterate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pr>` 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.
Expand Down
3 changes: 2 additions & 1 deletion assets/skills/merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pr>`, 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.
Expand Down Expand Up @@ -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 <pr>`, 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.
2 changes: 1 addition & 1 deletion assets/skills/ready.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pr>`, 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.
Expand Down
6 changes: 2 additions & 4 deletions quest/m0/helpers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
33 changes: 0 additions & 33 deletions quest/m0/helpers/status.md

This file was deleted.

192 changes: 192 additions & 0 deletions src/cli/github.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
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<string, unknown>;

/** 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<void> {
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<Response> {
// 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<ObjectValue> {
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;
}
Loading
Loading