From 56a0ef268590d9e0101d823e6ab7dda360b945b8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 11:27:39 -0700 Subject: [PATCH 1/2] docs(skills): treat GitHub auto-merge as the merge pick in quest-complete Enabling auto-merge takes write access and GitHub disables it on a push from anyone without write access, so it carries a maintainer's approval. quest-complete stops asking about those PRs and only dispatches /quest-merge when one stalls. Co-Authored-By: Claude Opus 5.5 --- assets/skills/complete.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/assets/skills/complete.md b/assets/skills/complete.md index b3e0141c..ff207bd5 100644 --- a/assets/skills/complete.md +++ b/assets/skills/complete.md @@ -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. 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. From 9bcb663df53ffa7f46ed8b6ad7fa0abfe4862701 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 11:36:51 -0700 Subject: [PATCH 2/2] feat(status): report why a landing stopped, and re-merge after automatic kick-outs quest status prints landing-stopped: GitHub's reason the latest merge-queue entry or auto-merge ended, who ended it, and when. After merge_conflict or failed_checks, /quest-merge and /quest-complete keep the merge pick and land again; a manual removal still goes back to the user. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +++- assets/skills/complete.md | 7 ++++--- assets/skills/merge.md | 5 +++-- src/cli/hold.ts | 6 +++--- src/cli/main.ts | 7 +++++-- src/cli/status.ts | 18 ++++++++++++++++++ test/hold.test.ts | 4 ++-- test/status.test.ts | 28 +++++++++++++++++++++++++++- 8 files changed, 65 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index cdff2793..65f9531c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. | @@ -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. diff --git a/assets/skills/complete.md b/assets/skills/complete.md index ff207bd5..4bc4a60c 100644 --- a/assets/skills/complete.md +++ b/assets/skills/complete.md @@ -35,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 `, 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 `. 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. diff --git a/assets/skills/merge.md b/assets/skills/merge.md index 3c201033..f8362f0c 100644 --- a/assets/skills/merge.md +++ b/assets/skills/merge.md @@ -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 `, 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 `. 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. diff --git a/src/cli/hold.ts b/src/cli/hold.ts index f652527e..acf95fc8 100644 --- a/src/cli/hold.ts +++ b/src/cli/hold.ts @@ -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 { const added = latest(snapshot, MERGE); @@ -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) }; } diff --git a/src/cli/main.ts b/src/cli/main.ts index 19286c2d..913501db 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -369,8 +369,11 @@ export function main(argv: string[], out: Output): number | Promise { "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: 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" + diff --git a/src/cli/status.ts b/src/cli/status.ts index e9045f61..22e1fa16 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -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) { @@ -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[]; @@ -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"], @@ -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, @@ -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)), diff --git a/test/hold.test.ts b/test/hold.test.ts index 0b8eec66..85697f19 100644 --- a/test/hold.test.ts +++ b/test/hold.test.ts @@ -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()); @@ -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"); diff --git a/test/status.test.ts b/test/status.test.ts index de7ad87b..ad404c01 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -39,6 +39,7 @@ function pull( reviewDecision: "REVIEW_REQUIRED", autoMergeRequest: null, mergeQueueEntry: null, + timelineItems: { nodes: [] }, reactions: { nodes: reactions }, commits: { nodes: [ @@ -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", @@ -190,7 +192,7 @@ 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", @@ -198,12 +200,36 @@ test("a queued PR with auto-merge and no checks reports both, and JSON is determ "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({