Skip to content
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ quest check --base origin/main --branch quest/m0/export
```

Fetch the base first, since a stale one can predate the quest. The
`quest-merge` skill runs this before enabling auto-merge. A repository with
`quest-merge` skill runs this before landing a PR. A repository with
`quest` installed in CI can also run it on pull requests, where the checkout is
detached, so pass the head branch:

Expand Down Expand Up @@ -371,6 +371,42 @@ same facts as one object with the fields `url`, `state`, `draft`, `head`,
as in `quest wait`. Any PR state exits 0, invalid arguments exit 2, and other
failures exit 1.

## Own a pull request

Before a session pushes to an existing PR, it takes the PR, so two sessions
never iterate on or merge one PR at once:

```sh
quest iterate 128 # prints a hold token
quest ready 128 <token> # swap to quest:ready, printing a new token
quest ready 128 <token> --done # drop "Decisions needed" and mark it ready
quest merge 128 <token> # swap to quest:merge for the fix loop
quest merge 128 <token> --land <sha> # auto-merge bound to that head
quest release 128 <token> # hand it back
```

A taken PR carries one label, `quest:iterate`, `quest:ready`, or
`quest:merge`. Sessions often share a GitHub account, so taking a label prints
a hold token: the ID of the timeline event that added it. Every command refuses
while a `quest:*` label it holds no token for is on the PR, including its own
state's; a draft its starter still holds, one without a `## Decisions needed`
section; and a merged or closed PR, since a push after the merge brings the
branch back. The refusal names the holder's label, age, and token, so you can
decide whether to release a stale hold. When two sessions add different labels
at once, the first label added keeps the PR and the other backs off.

`--land` needs the full 40-character head SHA and refuses once the head moves,
before swapping labels, so the token you passed stays valid. If a step fails
after a swap, the error names the new token.
It enables auto-merge bound to that SHA, which enters GitHub's merge queue
when the base has one, or merges at once when nothing is left to wait for. It
makes a merge commit; `--squash` or `--rebase` picks another method, and a
merge queue applies its own. `quest:merge` stays on while the PR is queued or
auto-merging; once the PR leaves the queue unmerged, the next command removes
it. The commands accept the same PR argument and authentication as
`quest wait`, and exit 0 when done, 3 when refused, 2 for invalid arguments,
and 1 for other failures.

## Gate expensive commands

`quest gate <command> [args...]` holds an expensive command until the host has
Expand Down
4 changes: 3 additions & 1 deletion assets/skills/complete.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ description: Decide which open PRs to merge, then merge them in parallel.
Before starting, run `quest guide`, `quest skill ready`, `quest skill merge`, and `quest skill plan`.

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 and note whether another agent is still pushing to it.
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.
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.

Expand Down
5 changes: 4 additions & 1 deletion assets/skills/iterate.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Before you begin, run `quest guide` and `quest skill plan`, and read their outpu

You own this PR until it is landable.
Parse the arguments to determine the PR number. Any other text is the user's feedback on the PR; interpret it against the diff.
Take it with `quest iterate <pr>` before acting, and keep the hold token it prints; `quest iterate --help` explains holds.
If it refuses because another session holds the PR, stop and report what it printed; taking over a stale hold is the user's choice.
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.
Expand All @@ -24,5 +26,6 @@ One round only; never request a review, including by toggling a ready PR to draf
Push your changes to the PR and update its summary.
Summarize the changes made.

Mark the PR ready once nothing is unfinished and no decisions need the user.
Mark the PR ready with `quest ready <pr> <token> --done` once nothing is unfinished and no decisions need the user.
Hand it back with `quest release <pr> <token>`, using the latest token printed, whenever you stop, including to ask.
Never merge; that is `/quest-merge`, once the user picks it.
15 changes: 10 additions & 5 deletions assets/skills/merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ 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.
Take the PR with `quest merge <pr>` before acting, passing a token you already hold from another state to swap it, and keep the hold token it prints; `quest merge --help` explains holds.
If it refuses because another session holds the PR, stop and report what it printed; taking over a stale hold is the user's choice.
Hand it back with `quest release <pr> <token>` whenever you stop before it lands, including to ask.

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.
Expand Down Expand Up @@ -41,14 +44,16 @@ run each wait in the foreground instead, bounded by the shell's own time limit,
and re-run it with `--since` until it returns activity.

Merge the PR *only* after all outstanding decisions have been confirmed.
Before enabling auto-merge, fetch the PR's base branch and head, check out the head, and run `quest --root <head-checkout> check --base <remote>/<base> --branch <head-branch>`.
Before landing it, fetch the PR's base branch and head, check out the head, and run `quest --root <head-checkout> check --base <remote>/<base> --branch <head-branch>`.
Run a `quest` the PR cannot modify, such as an installed release or a build from the base, from a directory outside the head checkout: the head's files, including runtime config such as `bunfig.toml`, are untrusted.
A stale local base can predate the quest and skip the check.
On a finding, fix the branch: delete the finished quest with `quest rm`, or rewrite it to the remainder the PR body names.
Then push, re-check, and continue; ask the user only when what remains is unclear.
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.
Leave a summary of the changes and decisions made, then land the head you checked with `quest merge <pr> <token> --land <full 40-character head SHA>`.
It makes a merge commit; add `--squash` or `--rebase` when the repository's root instructions name that method for the PR's base.
The `quest:merge` label stays on while the PR is queued or auto-merging.
If your caller said it watches the merge queue, report once it is landing.
Otherwise wait until the PR merges. If it leaves the queue unmerged or auto-merge
is disabled, confirm with `quest status <pr>`, then report why instead of
enabling auto-merge again; a new head needs the user's approval.
is disabled, confirm with `quest status <pr>`, release the PR, then report why
instead of landing it 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.
10 changes: 9 additions & 1 deletion assets/skills/ready.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ Before you begin, run `quest guide` and `quest skill merge`, and read their outp
Parse the arguments to determine the PR number, otherwise resolve it from the current context/branch.
If its body lists open decisions or unfinished work, offer `/quest-iterate` first.

Flip a draft to "Ready for Review", then wait with `quest wait`, as `quest skill merge` describes, until an automated reviewer reviews or reacts.
Take the PR with `quest ready <pr>` and keep the hold token it prints; `quest ready --help` explains holds.
If it refuses because another session holds the PR, stop and report what it printed; taking over a stale hold is the user's choice.
A draft you started in this session has no `## Decisions needed` section, so it is still yours and the command refuses it: mark it ready for review yourself, then take it.

Flip a draft to "Ready for Review" with `quest ready <pr> <token> --done`, which also drops its `## Decisions needed` section.
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 from `quest status <pr>`, then interactively prompt the user with your recommendation:
Expand All @@ -17,3 +22,6 @@ Summarize the reviews and CI from `quest status <pr>`, then interactively prompt
- `/quest-delete`: If the quest should be deleted.

A background agent that cannot prompt lists these in its report instead.

Hand the PR back with `quest release <pr> <token>` before you ask or report.
To go straight on to `/quest-iterate` or `/quest-merge` in this session, pass the token to its command instead, which swaps the label.
4 changes: 2 additions & 2 deletions quest/m0/helpers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ commands from Sep-Oct. Session counts in each child come from that window.
maintainer time and change only prose. The complete loop ranks right after
the PR state commands, which also rewrite `complete`. Findings that fit an
existing child went into it: the merge method and the merged-PR refusal
into [PR state commands](/quest/m0/helpers/pr-states.md).
into the PR state commands, which landed in
[kixelated/quest#185](https://github.com/kixelated/quest/pull/185).

## Required

- [PR state commands](/quest/m0/helpers/pr-states.md) - `quest iterate`, `ready`, `merge`, and `release` label the one owner of a PR
- [Complete loop](/quest/m0/helpers/complete-loop.md) - `/quest-complete` re-scans PRs, keeps picks and holds, and counts rounds per PR
- [Headless agents](/quest/m0/helpers/headless-agents.md) - `/quest-spawn api` launches, resumes, and stops billed agents through one command auto mode allows

Expand Down
4 changes: 0 additions & 4 deletions quest/m0/helpers/complete-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,3 @@ edits through this plan.
`iterate`, which has none today. Record a design change somewhere the next
agent can read it back from the PR, such as the comment recording the
decision.

## Related

- [PR state commands](/quest/m0/helpers/pr-states.md) - also rewrites how `complete` dispatches and skips held PRs
76 changes: 0 additions & 76 deletions quest/m0/helpers/pr-states.md

This file was deleted.

9 changes: 3 additions & 6 deletions quest/m0/kix/merge-job.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ or needs them.
- Decided (user): one owner per PR, not one merge agent per repository. kix
never runs two jobs on one PR and never acts on a PR another session holds.
GitHub's merge queue, or the repository's own queue, orders landings.
- Decided (user): use the `quest:merge` label from the
[PR state commands](/quest/m0/helpers/pr-states.md). The bot adds it when
- Decided (user): use the `quest:merge` label from `quest merge`
([kixelated/quest#185](https://github.com/kixelated/quest/pull/185)), whose
`--help` states the label contract kix shares with local sessions. The bot adds it when
the job is assigned and keeps it while the PR is queued or auto-merging. If
the PR leaves the queue unmerged, the bot removes it and the PR becomes an
idle decision item again. Runners hold no GitHub credentials, so the
Expand All @@ -44,7 +45,3 @@ or needs them.
- Test a pick on a conflicted PR, a review round that the pick covers, a
head moved outside the job, the design checkpoint, a PR that leaves the
queue, and a PR already labeled by a local session.

## Required

- [PR state commands](/quest/m0/helpers/pr-states.md) - the label contract kix shares with local sessions
4 changes: 2 additions & 2 deletions src/cli/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ process.stdout.on("error", (error: NodeJS.ErrnoException) => {
});

process.exitCode = await main(process.argv.slice(2), {
stdout: (text) => process.stdout.write(text),
stderr: (text) => process.stderr.write(text),
stdout: (text) => void process.stdout.write(text),
stderr: (text) => void process.stderr.write(text),
terminal: detect(process.stdout.isTTY, process.env),
});
9 changes: 5 additions & 4 deletions src/cli/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,12 @@ export class GitHub {

/**
* A 2xx or 304 response; anything else fails after rate-limit retries, with
* GitHub's reason when it gives one. `body` makes it a POST unless `method` says otherwise.
* GitHub's reason when it gives one, unless `allow` lists its status. `body` makes it a POST
* unless `method` says otherwise.
*/
async request(
path: string,
options: { etag?: string; body?: unknown; method?: "POST" | "PATCH" } = {},
options: { etag?: string; body?: unknown; method?: "POST" | "PATCH" | "DELETE"; allow?: number[] } = {},
): Promise<Response> {
// Follow only same-origin pagination links; never send a token to another host.
const url = new URL(path, API);
Expand All @@ -150,7 +151,7 @@ export class GitHub {
try {
response = await this.deps.fetch(url, {
...(options.body === undefined
? {}
? options.method && { method: options.method }
: { method: options.method ?? "POST", body: JSON.stringify(options.body) }),
headers: {
Accept: "application/vnd.github+json",
Expand All @@ -166,7 +167,7 @@ export class GitHub {
throw error;
}
this.pollInterval = Math.max(this.pollInterval, seconds(response.headers.get("x-poll-interval")) ?? 0);
if (response.ok || response.status === 304) return response;
if (response.ok || response.status === 304 || options.allow?.includes(response.status)) return response;
const body = await response.text();
const limited =
response.status === 429 ||
Expand Down
Loading
Loading