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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ 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
hold quest:merge LE_example 2026-10-16T17:57:16.000Z https://api.github.com/repos/kixelated/quest/issues/events/123456 1 hour ago
```

Each line starts with its record type; any free text, a check name or a path,
Expand All @@ -395,14 +396,16 @@ comes last, and a missing value prints as `-`:
| `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. |
| `hold` | Label, acquisition token, acquisition time, verified GitHub event API URL, and age; `hold -` when no label holds it. Unknown acquisition facts print as `-` and `unknown age`. |
| `starter-hold` | A draft its starter owns: last activity time, PR URL, and `last active` age. Last activity is not when ownership began. |

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`,
`landingStopped`, `checks` (`state`, `passed`, `failing`, `pending`), `reviews`, `reactions`, and
`threads`. Long lists are paged; reactions are the newest 100 per PR or review,
`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/skills/complete.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Before starting, run `quest guide`, `quest skill ready`, `quest skill merge`, an

The goal is to evaluate the open PRs in the repository, drafts included, and decide which ones to merge.
For a draft, summarize the open decisions its body lists.
Skip each PR another session holds: one with a `quest:*` label, or a draft without a `## Decisions needed` section.
Read each PR with `quest status` and skip one another session holds, using its `hold` and `starter-hold` records instead of a separate label read. A `hold` names the label, acquisition token/time, event URL, and age. A `starter-hold` identifies a draft without a `## Decisions needed` section and reports when it was last active; that is not an acquisition time.
List those with how long ago each was last active, and offer to take over only one that looks stale.
The skill argument can be used to filter the PRs in scope.
Each merge is performed in parallel by a sub-agent, following the guide's Reviews section for the shared design checkpoint across review rounds.
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 @@ -68,7 +68,6 @@ commands from Sep-Oct. Session counts in each child come from that window.

- [Merge state race](/quest/m0/helpers/merge-state-race.md) - `--land` and merge jobs land a PR whose merge state GitHub is still computing, on the first try
- [Wait and status accuracy](/quest/m0/helpers/wait-accuracy.md) - wake on edited bot comments, honest `checks:passed`, review rounds in `quest status`
- [Status holds](/quest/m0/helpers/status-holds.md) - `quest status` shows a PR's `quest:*` hold
- [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
18 changes: 0 additions & 18 deletions quest/m0/helpers/status-holds.md

This file was deleted.

4 changes: 0 additions & 4 deletions quest/m0/helpers/wait-accuracy.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,3 @@ Decided 2026-10-24 (user) from the merge wave that landed
skill's design checkpoint counts today with `gh pr view --json`.
- Dropped (user): removing the `quest:merge` label after a PR merges; holds
only count on open PRs, so the leftover label is cosmetic.

## Related

- [Status holds](/quest/m0/helpers/status-holds.md) - also extends `quest status`
65 changes: 62 additions & 3 deletions src/cli/hold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ export type Method = "merge" | "squash" | "rebase";
const IMMEDIATE = ["CLEAN", "HAS_HOOKS", "UNSTABLE"];

/** The PR, its `quest:*` labels, and the timeline events that add a label or start a landing. */
const READ = `query($owner: String!, $repo: String!, $number: Int!) {
const READ = `query HoldOwnership($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
id url state isDraft body baseRefName headRefOid mergeStateStatus isMergeQueueEnabled
id url state isDraft body updatedAt baseRefName headRefOid mergeStateStatus isMergeQueueEnabled
autoMergeRequest { enabledAt }
mergeQueueEntry { state }
labels(first: 100) { nodes { name } }
Expand Down Expand Up @@ -55,6 +55,7 @@ interface Snapshot {
state: "OPEN" | "CLOSED" | "MERGED";
draft: boolean;
body: string;
updatedAt: string;
base: string;
head: string;
mergeState: string;
Expand All @@ -73,6 +74,63 @@ export interface Hold {
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<HoldReport> {
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` for `state`, or swap to it from the label `token` holds. Prints nothing; returns the hold. */
export async function take(
pr: PullRequest,
Expand Down Expand Up @@ -226,7 +284,7 @@ async function acquire(
if (other !== undefined) throw new Refusal(holder(snapshot, other, labels.deps.now()));
check?.(snapshot);
if (mine === label) return { hold: { label, token: token! }, snapshot };
if (mine === undefined && snapshot.draft && !handedBack(snapshot.body)) {
if (mine === undefined && starterHeld(snapshot)) {
throw new Refusal(
`${snapshot.url} is a draft its starter still holds: its body has no ## Decisions needed section, so taking it over is the user's choice`,
);
Expand Down Expand Up @@ -323,6 +381,7 @@ class Labels {
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),
head: string(row.headRefOid),
mergeState: string(row.mergeStateStatus),
Expand Down
28 changes: 26 additions & 2 deletions src/cli/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
string,
} from "./github";
import { EJECTED } from "./land";
import { readHoldStatus, type HoldFact, type HoldReport } from "./hold";

/**
* One query covers the PR; each connection pages by its own cursor, and a
Expand Down Expand Up @@ -115,9 +116,12 @@ export interface Status {
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 PR's state in one query, plus one per extra page of a long list. */
/** 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> = {
Expand Down Expand Up @@ -168,7 +172,14 @@ 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);
}
return summarize(first!, rollup, contexts, reviewNodes, 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");
return {
...summarize(first!, rollup, contexts, reviewNodes, threadNodes),
holds: ownership.holds,
starterHold: ownership.starter,
};
}

function summarize(
Expand Down Expand Up @@ -273,6 +284,8 @@ function summarize(
reviews: [...latest.values()],
reactions,
threads,
holds: [],
starterHold: null,
};
}

Expand Down Expand Up @@ -344,6 +357,17 @@ export function statusLines(status: Status): string[] {
(t) =>
`thread ${t.id} ${t.outdated ? "outdated" : "current"} ${t.lastAuthor} ${t.url} ${t.path}:${dash(t.line)}`,
),
...(status.holds.length
? status.holds.map(
(hold) =>
`hold ${hold.label} ${dash(hold.token)} ${dash(hold.takenAt)} ${dash(hold.eventUrl)} ${hold.age ?? "unknown age"}`,
)
: ["hold -"]),
...(status.starterHold
? [
`starter-hold ${status.starterHold.lastActive} ${status.starterHold.url} last active ${status.starterHold.age}`,
]
: []),
];
}

Expand Down
1 change: 1 addition & 0 deletions test/hold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ class FakePull {
state: this.state,
isDraft: this.draft,
body: this.body,
updatedAt: new Date(this.clock).toISOString(),
baseRefName: "main",
headRefOid: this.head,
mergeStateStatus: this.mergeState,
Expand Down
Loading
Loading