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
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,22 +322,26 @@ failed merge. A label change prints as `labeled:<name>` or `unlabeled:<name>`
(spaces as `_`), so a queue that dequeues by removing its label wakes the
waiter; the authenticated account's own label changes are ignored. Actors are
printed without deciding
whose review counts. Edits to existing comments or reviews are outside this
command's scope.
whose review counts. Edited issue comments print as `edited`, at their update
time; edits to the authenticated account's own comments are ignored. Comment
edits have their own cursor position and are read across every page, so editing
an older bot status comment wakes a resumed wait. Review edits remain outside
this command's scope.

CI results are read for the current head. The first required check to fail
prints `checks:failed` right away. `checks:passed` prints once nothing on the
head is still going (no pending check and no unfinished GitHub Actions run, which
may still create jobs), every check and workflow the base branch names is
present (except `quest land`'s, which no head carries), and every required
check passed; it names the last check to finish,
when the head went idle. Each prints once per head, with the check's name
check passed; it names the last required check that passed and is dated when
the head went idle. Each prints once per head, with the check's name
(spaces as `_`) in the actor field and its details URL. Results count as
`quest status` counts them, so neutral and skipped results pass. Each job of a
workflow a ruleset requires is required.
Requiredness comes from the base branch's protection and rulesets, readable
with read access; optional checks never wake the wait, unless the base branch
requires none, in which case every check is required. A result counts when it
with read access. A failed optional check prints separately as `check:failed`,
with its own name and details URL; it never names the aggregate required pass.
If the base branch requires no checks, every check is required. A result counts when it
was reached at or after the starting point, so a wait started after CI finished
still reports it; a push starts over for the new head, and the cursor records
what was reported, so resuming never replays it.
Expand Down Expand Up @@ -386,6 +390,7 @@ auto-merge -
merge-queue -
landing-stopped -
checks success passed=14 failing=0 pending=0
review-rounds 2 -
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
Expand All @@ -404,6 +409,7 @@ comes last, and a missing value prints as `-`:
| `landing-stopped` | The reason the latest merge-queue entry, auto-merge, or [`quest land`](#land-a-batch-with-quest-land) queue entry ended (such as `merge_conflict`, `failed_checks`, `manual`, `manually_disabled`, or `merged`), who ended it, and when. |
| `checks` | The head's rollup state and `passed=`, `failing=`, `pending=` counts. |
| `check` | `failing` or `pending`, `required` or `optional`, the result, details URL, and name. |
| `review-rounds` | Count of distinct commits with submitted reviews since the latest comment starting `Design change:`, followed by that marker's time, author, and URL (`-` when absent). Pending reviews and reviews without a commit do not count. This reports history, not whose review satisfies repository policy. |
| `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. |
Expand All @@ -415,7 +421,8 @@ The node ID is what GraphQL's `addPullRequestReviewThreadReply` and
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`,
`landingStopped`, `checks` (`state`, `passed`, `failing`, `pending`), `reviews`, `reactions`, and
`landingStopped`, `checks` (`state`, `passed`, `failing`, `pending`), `reviewRounds`
(`count`, and `since` with the marker's `at`, `actor`, `url`, or `null`), `reviews`, `reactions`,
`threads`, `holds` (`label`, `token`, `takenAt`, `age`, `eventUrl`), and `starterHold` (`lastActive`, `age`, `url`, or null). Ownership uses the state commands' shared reader without changing labels, and event links match the opaque acquisition token against GitHub's event listing. 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.
Expand Down
2 changes: 1 addition & 1 deletion assets/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ Where it says nothing, a review by the PR's author, or an AI-attributed comment

Checkpoint the design across review rounds.
A review round is one pushed head that drew reviews, counted from the PR's full history since its last comment that starts with `Design change:`.
Read the full history with `gh pr view <pr> --json reviews,comments`.
`quest status <pr>` reports the round count and latest `Design change:` marker under `review-rounds`.
Every fourth round, stop before fixing more findings and reconsider the approach.
Report that judgment and what remains, recommending to continue, rethink the approach, merge as is, or drop the PR, and let the user decide.
Record each design change, including a rethink the user picks, in a PR comment that starts with `Design change:`; the next agent counts rounds from it.
Expand Down
1 change: 0 additions & 1 deletion quest/m0/helpers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ commands from Sep-Oct. Session counts in each child come from that window.

## Required

- [Wait and status accuracy](/quest/m0/helpers/wait-accuracy.md) - wake on edited bot comments, honest `checks:passed`, review rounds in `quest status`
- [Headless agents](/quest/m0/helpers/headless-agents.md) - `/quest-spawn api` launches, resumes, and stops billed agents through one command auto mode allows
- [Sibling PRs through a server-side merge](/quest/m0/helpers/queue-siblings.md) - children of one epic land back to back through a merge queue without conflicting on its link list
- [kixelated/quest lands through quest land](/quest/m0/helpers/land-adopt.md) - this repository's `main` changes only through its own gate
Expand Down
30 changes: 0 additions & 30 deletions quest/m0/helpers/wait-accuracy.md

This file was deleted.

15 changes: 8 additions & 7 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,20 +339,20 @@ export function main(argv: string[], out: Output): number | Promise<number> {
.command("wait")
.summary("Wait for pull request activity on GitHub")
.description(
"Wait for a review, comment, review-thread reply, reaction to the PR or a review, push, merge, close, " +
"merge-queue removal, disabled auto-merge, label change, or required CI result. Polls every 5 minutes, and once more 30 seconds before the timeout; rate limits and longer GitHub poll intervals delay requests. No timeout by default.\n\n" +
"Wait for a review, comment or comment edit, review-thread reply, reaction to the PR or a review, push, merge, close, " +
"merge-queue removal, disabled auto-merge, label change, or CI result. Polls every 5 minutes, and once more 30 seconds before the timeout; rate limits and longer GitHub poll intervals delay requests. No timeout by default.\n\n" +
"Prints one plain line per event: timestamp (or commit SHA), type, actor, URL; the last line adds a resume cursor. " +
"Reactions print as reacted:<content>, such as reacted:thumbs_up, and label changes as labeled:<name> or unlabeled:<name>. " +
"The head's first failing required check prints as checks:failed, and every required check passing as checks:passed, " +
"each once per head, with the check's name as the actor and its details URL. " +
"each once per head, with the required check's name as the actor and its details URL. " +
"Results count as quest status counts them, so neutral and skipped results pass. " +
"checks:passed waits until no check is pending, no GitHub Actions run is unfinished, and every check and workflow the base branch names is present, then names the last check to finish. " +
"Optional checks never wake the wait, unless the base branch requires none, which requires them all. " +
"checks:passed waits until no check is pending, no GitHub Actions run is unfinished, and every check and workflow the base branch names is present, then names the last required check that passed. " +
"Optional failures print separately as check:failed; a base branch requiring no checks requires them all. " +
"Pass the final field of the last line to --since to resume without replaying events. " +
"On timeout, the only line is a resume point for --since (a cursor, or the given --since or head SHA if setup did not finish), so a retry with it still reports activity from the first wait.\n\n" +
"Uses GH_TOKEN, GITHUB_TOKEN, or gh auth token. Label changes, replies, and reactions by that account are ignored. " +
"Exits 0 for activity, 3 on timeout, 2 for invalid arguments, and 1 for other failures. " +
"Edits are not activity.",
"Issue-comment edits print as edited, including updates to older bot status comments. The account's own edits are ignored.",
)
.argument("<pr>", "GitHub PR URL, or a PR number in origin")
.option("--since <point>", "ISO timestamp, pushed-tip SHA, or resume cursor (default: current head push time)")
Expand Down Expand Up @@ -383,7 +383,8 @@ export function main(argv: string[], out: Output): number | Promise<number> {
"landing-stopped, and checks; landing-stopped is the latest exit from the merge queue, auto-merge, or " +
"quest land's queue, with the reason (such as merge_conflict, failed_checks, manual, or manually_disabled), " +
"actor, and time. Then " +
"a check line per failing or pending check, marked required or optional; a review line per " +
"a check line per failing or pending check, marked required or optional; review-rounds counts distinct " +
"submitted review commits since the latest Design change: comment, with its time, author, and URL (or -); a review line per " +
"reviewer's latest review, marked current or stale against the head; a reacted:<content> 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" +
Expand Down
36 changes: 32 additions & 4 deletions src/cli/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import { readHoldStatus, type HoldFact, type HoldReport } from "./hold";
* 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!) {
$checks: String, $reviews: String, $threads: String, $comments: String,
$moreChecks: Boolean!, $moreReviews: Boolean!, $moreThreads: Boolean!, $moreComments: Boolean!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
url state isDraft headRefOid mergeable mergeStateStatus reviewDecision
Expand All @@ -45,6 +45,10 @@ const STATUS = `query($owner: String!, $repo: String!, $number: Int!,
pageInfo { hasNextPage endCursor }
nodes { author { login } state submittedAt url commit { oid } reactions(last: 100) { nodes { ...reaction } } }
}
comments(first: 100, after: $comments) @include(if: $moreComments) {
pageInfo { hasNextPage endCursor }
nodes { body createdAt url author { login } }
}
reviewThreads(first: 100, after: $threads) @include(if: $moreThreads) {
pageInfo { hasNextPage endCursor }
nodes {
Expand Down Expand Up @@ -111,6 +115,8 @@ export interface Status {
/** The latest exit from the merge queue, auto-merge, or `quest land`'s queue, and the reason for it. */
landingStopped: { reason: string; actor: string; at: string } | null;
checks: { state: string | null; passed: number; failing: Check[]; pending: Check[] };
/** Distinct commits with submitted reviews after the latest Design change marker. */
reviewRounds: { count: number; since: { at: string; actor: string; url: string } | null };
/** Each reviewer's latest submitted review, oldest first. */
reviews: Review[];
reactions: Reaction[];
Expand All @@ -124,14 +130,16 @@ export interface Status {
/** Read the paged PR state, then the state commands’ ownership snapshot. */
export async function pullRequestStatus(pr: PullRequest, deps: GitHubDependencies): Promise<Status> {
const github = new GitHub(deps);
const cursors: Record<"checks" | "reviews" | "threads", string | null | undefined> = {
const cursors: Record<"checks" | "reviews" | "threads" | "comments", string | null | undefined> = {
checks: null,
reviews: null,
threads: null,
comments: null,
};
const contexts: ObjectValue[] = [];
const reviewNodes: ObjectValue[] = [];
const threadNodes: ObjectValue[] = [];
const commentNodes: 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.
Expand All @@ -149,9 +157,11 @@ export async function pullRequestStatus(pr: PullRequest, deps: GitHubDependencie
checks: cursors.checks ?? null,
reviews: cursors.reviews ?? null,
threads: cursors.threads ?? null,
comments: cursors.comments ?? null,
moreChecks: cursors.checks !== undefined,
moreReviews: cursors.reviews !== undefined,
moreThreads: cursors.threads !== undefined,
moreComments: cursors.comments !== undefined,
});
const repository = data.repository;
if (repository === null) throw new Error(`cannot read ${pr.owner}/${pr.repo}; check the repository and token`);
Expand All @@ -171,12 +181,13 @@ export async function pullRequestStatus(pr: PullRequest, deps: GitHubDependencie
}
if (cursors.reviews !== undefined) page(current.reviews, "reviews", reviewNodes);
if (cursors.threads !== undefined) page(current.reviewThreads, "threads", threadNodes);
if (cursors.comments !== undefined) page(current.comments, "comments", commentNodes);
}
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");
return {
...summarize(first!, rollup, contexts, reviewNodes, threadNodes),
...summarize(first!, rollup, contexts, reviewNodes, threadNodes, commentNodes),
holds: ownership.holds,
starterHold: ownership.starter,
};
Expand All @@ -188,6 +199,7 @@ function summarize(
contexts: ObjectValue[],
reviewNodes: ObjectValue[],
threadNodes: ObjectValue[],
commentNodes: ObjectValue[],
): Status {
const head = string(pull.headRefOid);
const url = string(pull.url);
Expand Down Expand Up @@ -216,6 +228,20 @@ function summarize(
reacted(pull, url);
// A pending review is the viewer's unsubmitted draft, not a review.
const submitted = reviewNodes.filter((row) => row.state !== "PENDING");
const changed = commentNodes
.filter((row) => typeof row.body === "string" && row.body.startsWith("Design change:"))
.sort((a, b) => Date.parse(string(a.createdAt)) - Date.parse(string(b.createdAt)))
.at(-1);
const since = changed
? { at: string(changed.createdAt), actor: actor(changed.author), url: string(changed.url) }
: null;
const rounds = new Set(
submitted.flatMap((row) =>
row.commit !== null && (!since || Date.parse(string(row.submittedAt)) >= Date.parse(since.at))
? [string(object(row.commit).oid)]
: [],
),
);
const latest = new Map<string, Review>();
for (const row of submitted) {
reacted(row, string(row.url));
Expand Down Expand Up @@ -281,6 +307,7 @@ function summarize(
at: string(stopped.createdAt),
},
checks,
reviewRounds: { count: rounds.size, since },
reviews: [...latest.values()],
reactions,
threads,
Expand Down Expand Up @@ -349,6 +376,7 @@ export function statusLines(status: Status): string[] {
`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)),
`review-rounds ${status.reviewRounds.count} ${status.reviewRounds.since ? `${status.reviewRounds.since.at} ${status.reviewRounds.since.actor} ${status.reviewRounds.since.url}` : "-"}`,
...status.reviews.map(
(r) => `review ${r.current ? "current" : "stale"} ${r.state} ${r.reviewer} ${r.submittedAt} ${r.url}`,
),
Expand Down
Loading
Loading