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
33 changes: 27 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,15 +291,17 @@ quest command to register the current one.

`quest wait` blocks until a GitHub PR has a review, comment, review-thread
reply, reaction to the PR or one of its reviews, push, merge, close, removal
from the merge queue, disabled auto-merge, or required CI result:
from the merge queue, disabled auto-merge or auto-merge left on a moved head, or
required CI result:

```sh
quest wait 101
quest wait https://github.com/moq-dev/quest/pull/101 --since 2026-10-07T12:00:00Z --timeout 1800
quest wait 101 --since abcdef0123456789abcdef0123456789abcdef01
```

A number uses the repository's `origin`; a URL works from any directory.
A number uses the repository's `origin`, so it needs a checkout or `--root`
naming one; a URL works from any directory.
Authentication comes from `GH_TOKEN`, then `GITHUB_TOKEN`, then `gh auth token`.
Without `--since`, the point is the current head's push time, so an existing
review on that head is included. GitHub lists a push seconds to minutes after
Expand Down Expand Up @@ -348,6 +350,12 @@ 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.

GitHub keeps auto-merge across a push by anyone with write access, so it can
land a head nobody approved. While auto-merge stays enabled on a head other
than the current one, `auto-merge:moved` prints once per head, with the account
that enabled it as the actor and `<pr-url>/commits/<sha>` naming the head it was
enabled on, read as `quest status` reads it.

Review-thread replies and reactions never reach the PR timeline, so they are
read separately. A reply prints as `replied`; a thread's first comment is part
of a review and prints as `reviewed`. Reactions on the PR body or a review
Expand Down Expand Up @@ -406,7 +414,7 @@ comes last, and a missing value prints as `-`:
| --- | --- |
| `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. |
| `auto-merge` | Method, who enabled it, when, and the head it was enabled on. |
| `merge-queue` | Queue state, position, and when it was enqueued. |
| `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. |
Expand All @@ -427,6 +435,15 @@ 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.

GitHub keeps auto-merge enabled across a writer's push without saying which head
it was enabled on, so the `auto-merge` line reads it from the auto-merge commit
body's `Quest-Land-Head` pin when [`quest land`](#land-a-batch-with-quest-land)
queued the PR. Otherwise it is the last push or merge to the head branch before
auto-merge was enabled, from the head repository's activity; it prints `-`
until GitHub lists the current head's push, which takes seconds to minutes, and
when a push shares the second auto-merge was enabled. A head there that differs
from `head` means a push followed auto-merge: disable it, or land the new head.

## Own a pull request

Before a session pushes to an existing PR, it takes the PR, so two agents
Expand Down Expand Up @@ -458,9 +475,11 @@ queue signal, so a queued PR shows no hold. It refuses while any session holds
the PR, so release your own hold first. `--land` needs the full 40-character
head SHA and refuses once the head moves.
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. On a base that requires the `quest land` check, it
when the base has one, or merges at once when nothing is left to wait for.
`--squash` or `--rebase` picks the method, and it refuses one the repository
disallows; otherwise it uses the only method the repository allows, or else a
merge commit, and refuses when merge commits are off and more than one method
remains. A merge queue applies its own. On a base that requires the `quest land` check, it
queues the PR for [`quest land`](#land-a-batch-with-quest-land) instead.
On an ungated base with auto-merge disabled, it refuses. Follow the repository’s own landing
instructions in CONTRIBUTING.md or AGENTS.md, such as its label queue; never
Expand All @@ -471,6 +490,8 @@ head that is behind or diverged: merge the base, rerun the repository's checks,
and land the new head. This is a preflight check; GitHub can still move the base
between that comparison and its head-pinned merge mutation. A PR that leaves
the queue unmerged shows why under `landing-stopped` in `quest status`.
GitHub keeps auto-merge across a writer's push, so disable it before pushing
to the PR; `quest status` and `quest wait` report a head that moved since.
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.
Expand Down
1 change: 1 addition & 0 deletions assets/skills/iterate.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Address the automated review findings already posted, without waiting on optiona
One round only; never request a review, including by toggling a ready PR to draft and back.

Before pushing to a PR that is ready for review, review the new head locally as the guide's Reviews section describes.
If `quest status <pr>` shows auto-merge enabled, disable it with `gh pr merge <pr> --disable-auto` before any push: GitHub keeps it across a writer's push and would land the new head unreviewed.
Push your changes to the PR and update its summary.
Summarize the changes made.

Expand Down
2 changes: 1 addition & 1 deletion assets/skills/merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Before queueing, fetch the PR's base branch and its exact head, check out the he
A finding means the PR leaves its quest inaccurate: recommend `/quest-iterate`.

Queue the head with `quest merge <pr> --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.
It uses the only merge method the repository allows, or else a merge commit; add `--squash` or `--rebase` when the repository's root instructions name that method for the PR's base.
On a base that requires the `quest land` check, it queues the PR for `quest land`, whose chain merges and tests against the current base.
On an ungated base with auto-merge disabled, it refuses. Follow the repository’s own landing instructions (for example, its label queue); never retry with `--direct` unless the user explicitly asks.
On an ungated base, it refuses a head that does not contain the current base: recommend `/quest-iterate` to merge the base.
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 @@ -70,6 +70,5 @@ commands from Sep-Oct. Session counts in each child come from that window.

## Required

- [quest merge --land gaps](/quest/m0/helpers/merge-land.md) - squash on squash-only repositories, PR URLs outside a checkout, labels released after direct merges, and a warning when auto-merge outlives its head
- [Headless agents](/quest/m0/helpers/headless-agents.md) - `/quest-spawn api` launches, resumes, and stops billed agents through one command auto mode allows
- [Links into an uninitialized submodule aren't broken](/quest/m0/helpers/submodule-links.md) - `quest check` passes in a fresh checkout without populating submodules
41 changes: 0 additions & 41 deletions quest/m0/helpers/merge-land.md

This file was deleted.

4 changes: 0 additions & 4 deletions quest/m0/helpers/submodule-links.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,3 @@ pinned commit for snapshots; reuse that lookup rather than writing a second one.
Decided 2026-10-29 (user, moq.pro follow-ups): plan only this fix upstream. The
label-queue gaps it was planned with retire once moq.pro lands through
`quest land`.

## Related

- [quest merge --land gaps](/quest/m0/helpers/merge-land.md) - the other merge-agent friction from these runs
1 change: 0 additions & 1 deletion quest/m0/queue-watcher/pick.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,4 @@ changes:

## Related

- [quest merge --land gaps](/quest/m0/helpers/merge-land.md) - the same command's other gaps, in [moq-dev/quest#347](https://github.com/moq-dev/quest/pull/347)
- [Bad picks](/quest/m0/queue-watcher/bad-picks.md) - what the watcher does with a refused pick
13 changes: 10 additions & 3 deletions src/cli/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,16 @@ export interface RequestOptions {
/** 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 = readRemote(root, "origin");
const repository = origin && remoteRepository(origin).repository;
if (!repository) throw new Error("origin must name a github.com repository; pass a GitHub PR URL instead");
const outside = `a bare PR number is read from origin in ${root}; pass the full GitHub PR URL, or run inside a checkout or name one with --root`;
let origin: Remote | null;
try {
origin = readRemote(root, "origin");
} catch {
origin = null;
}
if (!origin) throw new GitHubInputError(`${root} is not a checkout with an origin remote: ${outside}`);
const { repository } = remoteRepository(origin);
if (!repository) throw new GitHubInputError(`origin does not name a github.com repository: ${outside}`);
return { ...repository, number: Number(value) };
}
const match = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/([1-9]\d*)\/?(?:[?#].*)?$/.exec(value);
Expand Down
70 changes: 56 additions & 14 deletions src/cli/hold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,17 +149,54 @@ export async function finish(pr: PullRequest, token: string, deps: GitHubDepende
});
}

/** The repository setting that allows each merge method. */
const ALLOWS: Record<Method, string> = {
merge: "allow_merge_commit",
squash: "allow_squash_merge",
rebase: "allow_rebase_merge",
};

/** Each merge method as GitHub's settings name it. */
const METHOD_NAMES: Record<Method, string> = {
merge: "merge commits",
squash: "squash merges",
rebase: "rebase merges",
};

/**
* The method an ungated landing uses: the one `named`, which the repository must allow, else the
* only one it allows, else a merge commit. Settings hidden from the token leave GitHub to refuse.
*/
function mergeMethod(repository: ObjectValue, named: Method | undefined, url: string): Method {
const methods = Object.keys(ALLOWS) as Method[];
if (methods.some((method) => typeof repository[ALLOWS[method]] !== "boolean")) return named ?? "merge";
const allowed = methods.filter((method) => repository[ALLOWS[method]] === true);
if (named !== undefined) {
if (allowed.includes(named)) return named;
throw new Refusal(
`${url} repository does not allow ${METHOD_NAMES[named]}; it allows ${allowed.map((method) => METHOD_NAMES[method]).join(", ") || "none"}`,
);
}
if (allowed.length === 1) return allowed[0];
if (allowed.includes("merge")) return "merge";
if (allowed.length === 0) throw new Refusal(`${url} repository allows no merge method`);
throw new Refusal(
`${url} repository does not allow merge commits; pass --${allowed.join(" or --")}, as its landing instructions in CONTRIBUTING.md or AGENTS.md name`,
);
}

/**
* Land `sha`, which must still be the head of `pr`, while no session holds it:
* enable auto-merge bound to it, which enters the merge queue when the base has one,
* or merge at once when GitHub would refuse auto-merge because nothing is left to wait for.
* A base that requires the `quest land` check queues it for `quest land` instead:
* squash auto-merge whose commit body pins `sha`. It takes no label.
* `named` is the method the caller asked for; otherwise {@link mergeMethod} picks one.
*/
export async function land(
pr: PullRequest,
sha: string,
method: Method,
named: Method | undefined,
deps: GitHubDependencies,
direct = false,
): Promise<void> {
Expand Down Expand Up @@ -187,23 +224,28 @@ export async function land(
(check) => check.name === LAND_CHECK,
);
if (gated) {
if (method !== "squash") deps.warn(`${snapshot.url} lands through quest land, which squashes`);
if (named !== undefined && named !== "squash")
deps.warn(`${snapshot.url} lands through quest land, which squashes`);
await unchanged();
await enqueue(labels.github, snapshot.id, snapshot.head);
return;
}
// A merge queue is the repository's own gate, so it decides; otherwise auto-merge must be on.
if (!direct && !snapshot.queue) {
// A merge queue is the repository's own gate: it applies its own method and needs no auto-merge setting.
let method: Method = named ?? "merge";
if (!snapshot.queue) {
const repository = object(await (await labels.github.request(repositoryPath(pr))).json());
// GitHub shows the merge settings only to writers; fail closed, with a next step.
if (typeof repository.allow_auto_merge !== "boolean")
throw new Refusal(
`${snapshot.url} repository's auto-merge setting is not visible to this token, so a direct merge cannot be ruled out; follow its landing instructions in CONTRIBUTING.md or AGENTS.md`,
);
if (!repository.allow_auto_merge)
throw new Refusal(
`${snapshot.url} repository has auto-merge disabled; follow its landing instructions in CONTRIBUTING.md or AGENTS.md (for example, its label queue). Only use --direct if the user explicitly asks`,
);
method = mergeMethod(repository, named, snapshot.url);
if (!direct) {
// GitHub shows the merge settings only to writers; fail closed, with a next step.
if (typeof repository.allow_auto_merge !== "boolean")
throw new Refusal(
`${snapshot.url} repository's auto-merge setting is not visible to this token, so a direct merge cannot be ruled out; follow its landing instructions in CONTRIBUTING.md or AGENTS.md`,
);
if (!repository.allow_auto_merge)
throw new Refusal(
`${snapshot.url} repository has auto-merge disabled; follow its landing instructions in CONTRIBUTING.md or AGENTS.md (for example, its label queue). Only use --direct if the user explicitly asks`,
);
}
}
// The batch gate tests its own current-base chain. Ungated landings need that base in the PR head.
const comparison = object(
Expand All @@ -219,7 +261,7 @@ export async function land(
`${snapshot.url} head ${snapshot.head} does not contain ${snapshot.base} at ${base}; merge the base and rerun the repository's checks before landing`,
);
}
if (snapshot.queue && method !== "merge") {
if (snapshot.queue && named !== undefined && named !== "merge") {
deps.warn(`${snapshot.url} lands through a merge queue, which applies its own merge method`);
}
await unchanged();
Expand Down
2 changes: 1 addition & 1 deletion src/cli/land.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export async function enqueue(github: GitHub, id: string, head: string): Promise
}

/** The head an auto-merge commit body pins, if any. */
function pinned(body: string): string | null {
export function pinned(body: string): string | null {
return new RegExp(`^${PIN}: ([0-9a-f]{40})$`, "m").exec(body)?.[1] ?? null;
}

Expand Down
Loading
Loading