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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ merge-state dirty
review-decision -
auto-merge -
merge-queue -
landing-stopped -
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
Expand All @@ -360,6 +361,7 @@ comes last, and a missing value prints as `-`:
| `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. |
| `landing-stopped` | GitHub's reason the latest merge-queue entry or auto-merge 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` | `current` or `stale` against the head, state, reviewer, time, and URL of each reviewer's latest review. |
Expand All @@ -371,7 +373,7 @@ 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`,
`checks` (`state`, `passed`, `failing`, `pending`), `reviews`, `reactions`, and
`landingStopped`, `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.
Expand Down
9 changes: 6 additions & 3 deletions assets/skills/complete.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Ask synchronously with one question per PR, never grouping PRs into one question

Include a brief summary of each PR.
Act on a PR only after the user picks its action in this session, including PRs found on a later refresh.
GitHub auto-merge is that pick: enabling it takes write access, and GitHub disables it when anyone without write access pushes.
Do not ask about a PR with auto-merge enabled; watch it like an enqueued PR, and dispatch `/quest-merge` only when a conflict, failing check, or review finding stalls it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the required-review gate for auto-merge picks

For a nontrivial PR with auto-merge enabled but no qualifying review, this instruction only watches it and never dispatches /quest-merge unless another problem appears. In repositories where GitHub does not enforce the guide's review requirement through branch protection, the PR can therefore merge as soon as CI passes without the required review. Treat auto-merge as the user's pick, but still verify the review prerequisite and handle its absence before allowing landing; enabling auto-merge does not satisfy the explicit review requirement in assets/AGENTS.md.

AGENTS.md reference: assets/AGENTS.md:L157-L159

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disagree. This trade-off is intentional and listed under Limitations: when a maintainer enables auto-merge by hand, they're accepting the PR as it stands. The guide's review rule covers when an agent lands a PR. It doesn't cover a maintainer's own merge action. To hold the PR for a review, quest-complete would have to disable the auto-merge the maintainer enabled, which overrides their explicit choice. Repositories that want GitHub to enforce reviews can require them in branch protection.

(Written by Claude Opus 5.5)

A pick covers the PR until it lands or needs the user: when its push draws new review findings, send the agent back without asking, and apply the merge skill's design checkpoint across those rounds.
Review comments are input, not approval, even a merge verdict on the maintainer's account.
Consider ordering, queuing any merges if they would result in conflicts.
Expand All @@ -33,9 +35,10 @@ Limit the number of active agents to the CPU core count.
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
`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.
If it leaves the queue unmerged or auto-merge is disabled, read `landing-stopped` from
`quest status <pr>`. After `merge_conflict` or `failed_checks`, the pick still holds:
dispatch `/quest-merge` again. Any other reason, such as a person removing it, is the
user's call, so ask about the PR again.

Keep going until all PRs have been decided, then wait for all spawned sub-agents and enqueued PRs to finish.
Before finishing, refresh the open PR list and process any new PRs in scope.
Expand Down
5 changes: 3 additions & 2 deletions assets/skills/merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ It makes a merge commit; add `--squash` or `--rebase` when the repository's root
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>`, release the PR, then report why
instead of landing it again; a new head needs the user's approval.
is disabled, read `landing-stopped` from `quest status <pr>`. After `merge_conflict`
or `failed_checks`, take the PR again, fix it, and land it; any other reason, such as
a person removing it, is the user's call, so report why instead of landing it again.
Never close a PR to unstick it, and never work around a refused merge; ask instead.
6 changes: 3 additions & 3 deletions src/cli/hold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,8 +317,8 @@ class Labels {
}

/**
* A PR that left the merge queue unmerged waits on the maintainer again, so
* its `quest:merge` label is spent once a landing started after it was added.
* A landing that ended unmerged ends the merge session's hold, so a
* `quest:merge` label is spent once a landing started after it was added.
*/
async dequeued(snapshot: Snapshot): Promise<Snapshot> {
const added = latest(snapshot, MERGE);
Expand All @@ -327,7 +327,7 @@ class Labels {
if (!landed) return snapshot;
await this.remove(MERGE);
this.deps.warn(
`${snapshot.url} left the merge queue unmerged, so ${MERGE} was removed; it waits on the user again`,
`${snapshot.url} left the merge queue unmerged, so ${MERGE} was removed; quest status shows why under landing-stopped`,
);
return { ...snapshot, labels: snapshot.labels.filter((name) => name !== MERGE) };
}
Expand Down
7 changes: 5 additions & 2 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,8 +369,11 @@ export function main(argv: string[], out: Output): number | Promise<number> {
"Print a pull request's head, checks, merge state, reviews, reactions, and unresolved review threads " +
"in one call. It reports facts; it does not decide whether the PR is ready or whose review counts.\n\n" +
"Plain output prints one record per line, its type first and any free text (a check name or path) last: " +
"url, state, draft, head, mergeable, merge-state, review-decision, auto-merge, merge-queue, and checks; " +
"then a check line per failing or pending check, marked required or optional; a review line per " +
"url, state, draft, head, mergeable, merge-state, review-decision, auto-merge, merge-queue, " +
"landing-stopped, and checks; landing-stopped is the latest exit from the merge queue or auto-merge, " +
"with GitHub's 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 " +
"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
18 changes: 18 additions & 0 deletions src/cli/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const STATUS = `query($owner: String!, $repo: String!, $number: Int!,
url state isDraft headRefOid mergeable mergeStateStatus reviewDecision
autoMergeRequest { enabledAt mergeMethod enabledBy { login } }
mergeQueueEntry { state position enqueuedAt }
timelineItems(last: 1, itemTypes: [REMOVED_FROM_MERGE_QUEUE_EVENT, AUTO_MERGE_DISABLED_EVENT]) { nodes {
... on RemovedFromMergeQueueEvent { createdAt reason actor { login } }
... on AutoMergeDisabledEvent { createdAt reasonCode actor { login } }
} }
reactions(last: 100) { nodes { ...reaction } }
commits(last: 1) { nodes { commit { oid statusCheckRollup { state
contexts(first: 100, after: $checks) @include(if: $moreChecks) {
Expand Down Expand Up @@ -100,6 +104,8 @@ export interface Status {
reviewDecision: string | null;
autoMerge: { method: string; enabledBy: string; enabledAt: string } | null;
mergeQueue: { state: string; position: number; enqueuedAt: string } | null;
/** The latest exit from the merge queue or auto-merge, and GitHub's reason for it. */
landingStopped: { reason: string; actor: string; at: string } | null;
checks: { state: string | null; passed: number; failing: Check[]; pending: Check[] };
/** Each reviewer's latest submitted review, oldest first. */
reviews: Review[];
Expand Down Expand Up @@ -229,6 +235,7 @@ function summarize(

const autoMerge = pull.autoMergeRequest === null ? null : object(pull.autoMergeRequest);
const queue = pull.mergeQueueEntry === null ? null : object(pull.mergeQueueEntry);
const stopped = array(object(pull.timelineItems).nodes).at(-1);
return {
url,
state: lower(pull.state) as Status["state"],
Expand All @@ -249,6 +256,14 @@ function summarize(
queue === null
? null
: { state: lower(queue.state), position: Number(queue.position), enqueuedAt: string(queue.enqueuedAt) },
landingStopped:
stopped === undefined
? null
: {
reason: lower(stopped.reason ?? stopped.reasonCode),
actor: actor(stopped.actor),
at: string(stopped.createdAt),
},
checks,
reviews: [...latest.values()],
reactions,
Expand Down Expand Up @@ -298,6 +313,9 @@ export function statusLines(status: Status): string[] {
status.mergeQueue === null
? "merge-queue -"
: `merge-queue ${status.mergeQueue.state} ${status.mergeQueue.position} ${status.mergeQueue.enqueuedAt}`,
status.landingStopped === null
? "landing-stopped -"
: `landing-stopped ${status.landingStopped.reason} ${status.landingStopped.actor} ${status.landingStopped.at}`,
`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)),
Expand Down
4 changes: 2 additions & 2 deletions test/hold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ describe("merge --land", () => {
expect(pull.labels).toEqual(["quest:merge"]);
});

test("a PR that left the queue unmerged drops quest:merge and waits on the user", async () => {
test("a PR that left the queue unmerged drops quest:merge", async () => {
const pull = new FakePull();
const { token } = await take(PR, "merge", undefined, pull.deps());
await land(PR, token, HEAD, "merge", pull.deps());
Expand All @@ -304,7 +304,7 @@ describe("merge --land", () => {
const warnings: string[] = [];
expect(await refused(land(PR, token, HEAD, "merge", pull.deps(warnings)))).toContain("no longer holds it");
expect(warnings).toEqual([
`${URL_} left the merge queue unmerged, so quest:merge was removed; it waits on the user again`,
`${URL_} left the merge queue unmerged, so quest:merge was removed; quest status shows why under landing-stopped`,
]);
expect(pull.labels).toEqual([]);
expect((await take(PR, "iterate", undefined, pull.deps())).label).toBe("quest:iterate");
Expand Down
28 changes: 27 additions & 1 deletion test/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function pull(
reviewDecision: "REVIEW_REQUIRED",
autoMergeRequest: null,
mergeQueueEntry: null,
timelineItems: { nodes: [] },
reactions: { nodes: reactions },
commits: {
nodes: [
Expand Down Expand Up @@ -158,6 +159,7 @@ test("reports checks by requiredness, each reviewer's latest review against the
"review-decision review_required",
"auto-merge -",
"merge-queue -",
"landing-stopped -",
"checks failure passed=2 failing=3 pending=2",
"check failing required failure https://github.com/example/project/actions/runs/1/job/lint lint",
"check failing optional cancelled https://github.com/example/project/actions/runs/1/job/docs docs",
Expand Down Expand Up @@ -190,20 +192,44 @@ test("a queued PR with auto-merge and no checks reports both, and JSON is determ
threads: page([]),
});
const status = await pullRequestStatus(pr, setup([body]).deps);
expect(statusLines(status).slice(2, 10)).toEqual([
expect(statusLines(status).slice(2, 11)).toEqual([
"draft true",
`head ${A}`,
"mergeable mergeable",
"merge-state blocked",
"review-decision -",
"auto-merge squash kixelated 2026-10-07T12:00:00Z",
"merge-queue awaiting_checks 2 2026-10-07T12:01:00Z",
"landing-stopped -",
"checks - passed=0 failing=0 pending=0",
]);
expect(JSON.stringify(await pullRequestStatus(pr, setup([body]).deps))).toBe(JSON.stringify(status));
expect(status.mergeQueue).toEqual({ state: "awaiting_checks", position: 2, enqueuedAt: "2026-10-07T12:01:00Z" });
});

test("reports why the latest landing stopped, from a merge queue or auto-merge", async () => {
const stopped = async (node: Node) => {
const body = pull({ checks: null, reviews: page([]), threads: page([]), timelineItems: { nodes: [node] } });
return statusLines(await pullRequestStatus(pr, setup([body]).deps)).find((line) =>
line.startsWith("landing-stopped "),
);
};
expect(
await stopped({
createdAt: "2026-10-07T12:00:00Z",
reason: "MERGE_CONFLICT",
actor: { login: "github-merge-queue" },
}),
).toBe("landing-stopped merge_conflict github-merge-queue 2026-10-07T12:00:00Z");
expect(
await stopped({
createdAt: "2026-10-07T12:01:00Z",
reasonCode: "manually_disabled",
actor: { login: "kixelated" },
}),
).toBe("landing-stopped manually_disabled kixelated 2026-10-07T12:01:00Z");
});

test("long lists page only the connections that have more, keeping the first page's PR fields", async () => {
const fixture = setup([
pull({
Expand Down
Loading