From 862ab9fd7da560758c51d54569ace880ea296934 Mon Sep 17 00:00:00 2001 From: Soorria Saruva Date: Mon, 9 Nov 2026 16:57:16 +1100 Subject: [PATCH 1/2] feat(server): wake a watching agent when the merge queue drops its PR A PR that GitHub's merge queue removes without merging stayed open with green head checks, so the watch never woke the agent. The watch fingerprint now counts RemovedFromMergeQueueEvent timeline items, which costs no extra rate limit, and the detail read reports the latest removal and its reason. The watch records the last removal it reported and wakes once per new one, skipping removals made by the agent's own account. --- .../PullRequestWatchReactor.ts | 3 +- .../orchestration-v2/pullRequestWatch.test.ts | 26 ++++++++++ .../src/orchestration-v2/pullRequestWatch.ts | 51 +++++++++++++++++-- .../src/pullRequest/PullRequestService.ts | 3 ++ docs/user/source-control.md | 9 ++-- evals/mcp-tool-use/cases/pull-requests.ndjson | 1 + packages/contracts/src/pullRequest.ts | 22 ++++++++ packages/contracts/src/threadPullRequest.ts | 2 + .../src/server/PullRequestProvider.ts | 2 + .../server/GitHubPullRequestProvider.test.ts | 1 + .../src/server/gitHubPullRequestJson.test.ts | 38 ++++++++++++++ .../src/server/gitHubPullRequestJson.ts | 34 +++++++++++++ .../src/server/pullRequestBatches.graphql | 5 ++ .../src/server/pullRequestBatches.graphql.ts | 11 +++- .../src/server/pullRequestReads.graphql | 12 +++++ .../src/server/pullRequestReads.graphql.ts | 48 +++++++++++++---- 16 files changed, 250 insertions(+), 18 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 5050ac3272894..fff14943d3498 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -182,7 +182,8 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc left.remarkIds.join("\n") === right.remarkIds.join("\n") && left.conflicting === right.conflicting && left.wakes === right.wakes && - left.checksAwaitedSince === right.checksAwaitedSince + left.checksAwaitedSince === right.checksAwaitedSince && + left.dequeuedThrough === right.dequeuedThrough ); } diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 6ab0191649490..33ec0bd7ca6c0 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -389,6 +389,32 @@ describe("evaluatePullRequestWatch", () => { ); }); + it("reports a merge queue removal once, never the agent's own or one before the watch", () => { + const removal = { reason: "failed-checks", at: "2026-10-02T12:20:00Z", actor: null } as const; + const dequeued = detail({ mergeQueueRemoval: removal }); + const tired = watch({ headSha: "aaaaaaaaaa", wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1 }); + const first = evaluatePullRequestWatch(tired, dequeued, noRemarks); + assert.deepEqual(first.changes, [{ kind: "dequeued", removal }]); + assert.equal(first.next.wakes, 0); + assert.deepEqual(evaluatePullRequestWatch(first.next, dequeued, noRemarks).changes, []); + const { text, notification } = pullRequestWatchMessage({ + number: 1, + url: "https://github.com/o/r/pull/1", + baseBranch: "main", + headSha: "aaaaaaaaaa", + report: first, + }); + assert.include(text, "- The merge queue removed it because checks failed on the merge group."); + assert.equal(notification.outcome, "failed"); + + const older = { ...removal, at: "2026-10-02T11:00:00Z" }; + const before = evaluatePullRequestWatch(watch(), detail({ mergeQueueRemoval: older }), []); + assert.deepEqual(before.changes, []); + const own = { reason: "manual", at: removal.at, actor: "Agent-User" } as const; + const pulled = evaluatePullRequestWatch(watch(), detail({ mergeQueueRemoval: own }), []); + assert.deepEqual(pulled.changes, []); + }); + it("does not spend the comment wake limit on check results", () => { const tired = watch({ headSha: "aaaaaaaaaa", wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1 }); const result = evaluatePullRequestWatch( diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index f4c0000847236..768c04b90dcd5 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -3,6 +3,7 @@ import type { PullRequestCheck, PullRequestComment, PullRequestDetail, + PullRequestMergeQueueRemoval, ThreadPullRequestWatch, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -19,7 +20,8 @@ export type PullRequestWatchChange = | { readonly kind: "checks-passed"; readonly count: number; readonly required: boolean } | { readonly kind: "remarks"; readonly remarks: ReadonlyArray } | { readonly kind: "no-checks"; readonly minutes: number } - | { readonly kind: "conflicting" }; + | { readonly kind: "conflicting" } + | { readonly kind: "dequeued"; readonly removal: PullRequestMergeQueueRemoval }; export interface PullRequestWatchReport { readonly changes: ReadonlyArray; @@ -35,7 +37,13 @@ export function evaluatePullRequestWatch( watch: ThreadPullRequestWatch, detail: Pick< PullRequestDetail, - "capabilities" | "headSha" | "checks" | "mergeability" | "viewer" | "author" + | "capabilities" + | "headSha" + | "checks" + | "mergeability" + | "viewer" + | "author" + | "mergeQueueRemoval" >, remarks: ReadonlyArray | null, now: number, @@ -136,6 +144,16 @@ export function evaluatePullRequestWatch( const conflicting = detail.mergeability === "unknown" ? toldConflict : detail.mergeability === "conflicting"; + // Queue checks run on a merge group commit, so a removal is the only sign they failed. + const removal = detail.mergeQueueRemoval; + const dequeued = + removal != null && + Date.parse(removal.at) > Date.parse(watch.dequeuedThrough ?? watch.startedAt); + if (dequeued && (removal.actor === null || removal.actor.toLowerCase() !== own)) { + changes.push({ kind: "dequeued", removal }); + } + const dequeuedThrough = dequeued ? removal.at : watch.dequeuedThrough; + const commentsOnly = changes.length > 0 && changes.every((change) => change.kind === "remarks"); const progress = headMoved || (changes.length > 0 && !commentsOnly); const wakes = (progress ? 0 : watch.wakes) + (commentsOnly ? 1 : 0); @@ -153,6 +171,7 @@ export function evaluatePullRequestWatch( conflicting, wakes, checksAwaitedSince, + ...(dequeuedThrough === undefined ? {} : { dequeuedThrough }), }, exhausted: commentsOnly && wakes >= PULL_REQUEST_WATCH_WAKE_LIMIT, }; @@ -210,6 +229,28 @@ function changeLines( ]; case "conflicting": return [`- The branch now conflicts with ${context.baseBranch}.`]; + case "dequeued": + return [ + `- ${dequeuedLine(change.removal, context.baseBranch)} It will not merge until it is queued again.`, + ]; + } +} + +const QUEUE_CHECKS = + "Those checks ran on a temporary merge group commit, not on the pull request's head, so they are not listed here."; + +function dequeuedLine(removal: PullRequestMergeQueueRemoval, baseBranch: string): string { + switch (removal.reason) { + case "failed-checks": + return `The merge queue removed it because checks failed on the merge group. ${QUEUE_CHECKS}`; + case "checks-timed-out": + return `The merge queue removed it because checks on the merge group did not finish in time. ${QUEUE_CHECKS}`; + case "merge-conflict": + return `The merge queue removed it because it conflicts with ${baseBranch} or with pull requests ahead of it in the queue.`; + case "manual": + return `${removal.actor ?? "Someone"} removed it from the merge queue.`; + case "other": + return "The merge queue removed it."; } } @@ -219,6 +260,7 @@ const SUMMARY: Record = { remarks: "new comments", "no-checks": "no checks reported", conflicting: "merge conflict", + dequeued: "left merge queue", }; export function pullRequestWatchMessage(input: { @@ -242,7 +284,10 @@ export function pullRequestWatchMessage(input: { : "Look into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.", ].join("\n"); const failed = changes.some( - (change) => change.kind === "checks-failed" || change.kind === "conflicting", + (change) => + change.kind === "checks-failed" || + change.kind === "conflicting" || + (change.kind === "dequeued" && change.removal.reason !== "manual"), ); const summary = changes.map((change) => SUMMARY[change.kind]); if (exhausted) summary.push("stopped watching"); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 0140a3aa96e2c..da488b8b747f8 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1856,6 +1856,9 @@ export const make = Effect.gen(function* () { ...(changeRequest.workflowApprovalsRequired === undefined ? {} : { workflowApprovalsRequired: changeRequest.workflowApprovalsRequired }), + ...(changeRequest.mergeQueueRemoval === undefined + ? {} + : { mergeQueueRemoval: changeRequest.mergeQueueRemoval }), })), ), ), diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 949bddd8afbf0..7d60a86914128 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -303,10 +303,11 @@ review is terminal. An open or unsynced link keeps it active. Ask the agent to watch, monitor, or babysit a pull request and it calls `watch_pull_request`. While the thread is active, the server checks the pull request every two minutes and wakes the agent when a -check fails, the required checks pass, someone else comments or reviews, or the branch starts to -conflict. Threads in a project that watch the same pull request share one check. On GitHub, a check -first asks whether anything changed and reads the pull request only when it did, which keeps -watching inside GitHub's rate limit. Comments from your own account do not wake it. Watching ends +check fails, the required checks pass, someone else comments or reviews, the branch starts to +conflict, or GitHub's merge queue drops it without merging. Threads in a project that watch the +same pull request share one check. On GitHub, a check first asks whether anything changed and +reads the pull request only when it did, which keeps watching inside GitHub's rate limit. +Comments and merge queue removals from your own account do not wake it. Watching ends when the pull request merges or closes, after 10 wakes in a row that bring only comments, after 8 failed reads in a row, or when you press Stop on the thread. A rate limit only pauses watching. Settling or archiving a thread also ends all its watches. Unsettle the thread before starting a new diff --git a/evals/mcp-tool-use/cases/pull-requests.ndjson b/evals/mcp-tool-use/cases/pull-requests.ndjson index 2e14e45f7f250..aced0d377ddc6 100644 --- a/evals/mcp-tool-use/cases/pull-requests.ndjson +++ b/evals/mcp-tool-use/cases/pull-requests.ndjson @@ -37,3 +37,4 @@ {"id": "link-one-pr", "area": "pull-requests", "privacy": "commit-safe", "kind": "control", "pattern": "single link stays single", "experience": "Linking one named PR is ordinary work.", "setup": {"role": "top", "context": "No pull requests are linked to this thread.", "world": {"repo": "acme/app", "branch": "main"}}, "prompt": "link https://github.com/acme/app/pull/130 here", "expect": {"calls": [{"tool": "link_pull_request", "args": {"url": "~/pull/130"}}], "notCalls": ["link_pull_requests"]}, "maxSteps": 6, "source": {"thread": "Synthetic control for batch link"}} {"id": "schedule-one-time-date", "area": "pull-requests", "kind": "bad", "pattern": "one-off date scheduled as a daily task", "experience": "Theo asked whether a scheduled task can run at a specific date and time. schedule_task only had daily times, intervals, and webhooks, so a one-off request became a daily task.", "source": {"report": "maintainer request, 2026-10-31: scheduled tasks could not run at one date and time"}, "rule": "schedule: {type:'once',runAt} for one date and time", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-4", "title": "Cut 2.0 release branch", "schedule": {"type": "once", "runAt": "2026-11-03T09:00:00.000Z"}, "enabled": true, "nextRunAt": "2026-11-03T09:00:00.000Z"}}}, "prompt": "On November 3rd at 9am UTC, start a new thread that cuts the 2.0 release branch from main in acme/app. Only that one time.", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "once", "schedule.runAt": "~11-03T09:00"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"]}, "notes": "Mock result shape is an approximation."} {"id": "schedule-fresh-session-per-run", "area": "pull-requests", "kind": "guard", "pattern": "recurring task that should not accumulate context", "experience": "A recurring triage task kept one thread so the user could read its history, but each run carried every earlier run's context into the next.", "rule": "schedule_task `freshSession: true` keeps runs in one thread while each run starts a fresh session.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Monday 2026-11-02, 10:00 local time.", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-7", "title": "Triage new issues", "enabled": true, "projectId": "project-1", "boundThreadId": null, "freshSession": true, "schedule": {"type": "fixed_time", "timeOfDay": "09:00"}, "nextRunAt": "2026-11-03T09:00:00-08:00", "lastRunStatus": "never"}}}, "prompt": "Every morning at 9, triage new issues in acme/app. Keep all the runs in one thread so I can scroll back, but each run should start clean without remembering the previous days.", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "fixed_time", "freshSession": true}}], "notCalls": ["delegate_task", "run_scheduled_task_now"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-11-03: automations in one thread with separate provider sessions"}, "notes": "schedule-one-off-tomorrow is the control: an ordinary schedule must not set freshSession."} +{"id":"wake-merge-queue-removed-investigates","area":"pull-requests","kind":"guard","pattern":"on a merge queue removal wake: find the failed merge group run","experience":"A PR dropped out of the GitHub merge queue because its merge group checks failed, and the watch never woke the agent, so the thread sat idle with an open PR. The new wake says the head checks are not the ones that failed; the agent must look at the merge group run instead of reading the passing head checks as good to go.","rule":"Look into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again.","privacy":"commit-safe","setup":{"role":"top","priorTurns":[{"role":"user","text":"File PR. Babysit. Merge when you are confident it's good to go."},{"role":"assistant","text":"Opened [#124](https://github.com/acme/app/pull/124). T3 Code is watching it; I will merge once checks pass and review findings are clear.","toolCalls":[{"tool":"Bash","input":{"command":"git push -u origin fix/socket-retry"},"result":"To github.com:acme/app.git\n * [new branch] fix/socket-retry -> fix/socket-retry\nbranch 'fix/socket-retry' set up to track 'origin/fix/socket-retry'."},{"tool":"Bash","input":{"command":"gh pr create --base main --title \"fix(web): retry the socket once before showing an error\" --fill"},"result":"https://github.com/acme/app/pull/124"},{"tool":"link_pull_request","input":{"url":"https://github.com/acme/app/pull/124"},"result":{"host":"github.com","repository":"acme/app","number":124,"url":"https://github.com/acme/app/pull/124","alreadyLinked":false}},{"tool":"watch_pull_request","input":{"url":"https://github.com/acme/app/pull/124"},"result":{"host":"github.com","repository":"acme/app","number":124,"url":"https://github.com/acme/app/pull/124","watching":true,"wasWatching":false}}]}],"mocks":{"watch_pull_request":"default","list_thread_pull_requests":"default","link_pull_request":"default","unwatch_pull_request":"default"},"world":{"branch":"fix/socket-retry","commits":[{"sha":"abc1234","subject":"fix(web): retry the socket once before showing an error"}],"diff":"diff --git a/apps/web/src/socket.ts b/apps/web/src/socket.ts\n--- a/apps/web/src/socket.ts\n+++ b/apps/web/src/socket.ts\n@@ -10,1 +10,2 @@\n- if (error) return showError(error);\n+ if (error && !retried) return reconnect({ retried: true });\n+ if (error) return showError(error);\n","prs":[{"number":124,"title":"fix(web): retry the socket once before showing an error","headRefName":"fix/socket-retry","checks":[{"name":"Check","state":"pass"},{"name":"Lint","state":"pass"},{"name":"Typecheck","state":"pass"},{"name":"Test Web","state":"pass"}],"mergeStateStatus":"CLEAN","isInMergeQueue":false,"mergeQueue":{"removedReason":"failed_checks","mergeGroupRun":{"workflow":"CI","event":"merge_group","headBranch":"gh-readonly-queue/main/pr-124-9f8e7d6c5b4a39281706f5e4d3c2b1a098765432","conclusion":"failure","failedJob":"Test Web","failure":"apps/web/src/socket.test.ts > reconnects once: expected reconnect to be called 1 time, got 2. main gained a second reconnect call in #121, which this branch duplicates."}}}]}},"prompt":"Update on pull request #124 (https://github.com/acme/app/pull/124), which T3 Code is watching for you:\n- The merge queue removed it because checks failed on the merge group. Those checks ran on a temporary merge group commit, not on the pull request's head, so they are not listed here. It will not merge until it is queued again.\n\nLook into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.","expect":{"notCalls":["watch_pull_request","list_thread_pull_requests","link_pull_request","link_pull_requests","Monitor","ScheduleWakeup"],"shellMust":["gh run (list|view)|merge_group|gh-readonly-queue|actions/runs|check-runs"],"shellMustNot":["\\bsleep \\d","gh pr checks[^\\n]*--watch","gh run watch","\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b","pr-wait"]},"notes":"Source: the merge queue investigation for PullRequestWatchReactor (queue checks run on a gh-readonly-queue commit, so the head checks stay green). Wake text is rendered by pullRequestWatchMessage for a failed_checks removal. Fixing and pushing, or reporting the failure, both pass; re-queueing without looking at the merge group run fails shellMust."} diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 72199673f7880..ae03ace855270 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -5,6 +5,7 @@ import * as HttpServerResponse from "effect/http/HttpServerResponse"; import { ForwardCompatibleArray, + ForwardCompatibleOptional, IsoDateTime, NonNegativeInt, PositiveInt, @@ -595,6 +596,22 @@ export const PullRequestReportStateInput = Schema.Struct({ }); export type PullRequestReportStateInput = typeof PullRequestReportStateInput.Type; +export const PullRequestMergeQueueRemovalReason = Schema.Literals([ + "failed-checks", + "checks-timed-out", + "merge-conflict", + "manual", + "other", +]); +export type PullRequestMergeQueueRemovalReason = typeof PullRequestMergeQueueRemovalReason.Type; + +export const PullRequestMergeQueueRemoval = Schema.Struct({ + reason: PullRequestMergeQueueRemovalReason, + at: IsoDateTime, + actor: Schema.NullOr(TrimmedNonEmptyString), +}); +export type PullRequestMergeQueueRemoval = typeof PullRequestMergeQueueRemoval.Type; + export const PullRequestDetail = Schema.Struct({ provider: SourceControlProviderKind, capabilities: PullRequestCapabilities, @@ -635,6 +652,11 @@ export const PullRequestDetail = Schema.Struct({ autoMergeMethod: Schema.optional(PullRequestMergeMethod), workflowApprovalsRequired: Schema.optional(NonNegativeInt), maintainerCanModify: Schema.optional(Schema.Boolean), + /** + * The latest time the merge queue dropped the pull request without merging it. Null when it + * never has; absent where the host reports no merge queue. + */ + mergeQueueRemoval: ForwardCompatibleOptional(Schema.NullOr(PullRequestMergeQueueRemoval)), }); export type PullRequestDetail = typeof PullRequestDetail.Type; diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index 3daee9ce197e2..d35279f26e828 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -100,6 +100,8 @@ export const ThreadPullRequestWatch = Schema.Struct({ * none did. Null once either happened; absent on watches saved before it existed. */ checksAwaitedSince: Schema.optional(Schema.NullOr(IsoDateTime)), + /** The latest merge queue removal already reported; absent means none since startedAt. */ + dequeuedThrough: Schema.optional(IsoDateTime), }); export type ThreadPullRequestWatch = typeof ThreadPullRequestWatch.Type; diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 0c58f64c47a1f..400997d7e144e 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -21,6 +21,7 @@ import type { PullRequestListState, PullRequestMergeCapabilities, PullRequestMergeMethod, + PullRequestMergeQueueRemoval, PullRequestMergeability, PullRequestOmittedFileStat, PullRequestReaction, @@ -210,6 +211,7 @@ export interface ProviderChangeRequestDetail extends ProviderChangeRequest { readonly autoMergeEnabled?: boolean; readonly autoMergeMethod?: PullRequestMergeMethod; readonly workflowApprovalsRequired?: number; + readonly mergeQueueRemoval?: PullRequestMergeQueueRemoval | null; } export interface ProviderChangeRequestActivity { diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts index 48f3021d1fe38..43ab914a39ab7 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts @@ -20,6 +20,7 @@ const coreFields = { canEditMaintainerEdits: false, reviewRequestTeams: [], checksTruncated: false, + mergeQueueRemoval: null, comparison: { behindBy: 0, viewerCanUpdate: true }, viewerAccess: { canWrite: true, diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts index acccedcb40f38..3e5d23e6bce58 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -403,6 +403,7 @@ describe("pull request detail decoding", () => { headRef: null, author: null, autoMergeRequest: null, + lastMergeQueueRemoval: { nodes: [] }, viewerCanUpdate: true, viewerDidAuthor: false, viewerCanUpdateBranch: false, @@ -558,6 +559,38 @@ describe("pull request detail decoding", () => { expect(detailOf([], { autoMergeRequest: null }).autoMergeEnabled).toBe(false); }); + it.each([ + ["failed_checks", "failed-checks"], + ["checks_timed_out", "checks-timed-out"], + ["merge_conflict", "merge-conflict"], + ["manual", "manual"], + ["branch_protection", "other"], + [null, "other"], + ])("reads a merge queue removal for %s as %s", (reason, expected) => { + const removal = { + __typename: "RemovedFromMergeQueueEvent", + reason, + createdAt: "2026-07-05T00:00:00Z", + actor: { __typename: "User", login: "octocat" }, + }; + expect(detailOf([], { lastMergeQueueRemoval: { nodes: [removal] } }).mergeQueueRemoval).toEqual( + { reason: expected, at: "2026-07-05T00:00:00Z", actor: "octocat" }, + ); + }); + + it("reads a merge by the queue, or no removal at all, as no removal", () => { + const merged = { + __typename: "RemovedFromMergeQueueEvent", + reason: "merged", + createdAt: "2026-07-05T00:00:00Z", + actor: null, + }; + expect( + detailOf([], { lastMergeQueueRemoval: { nodes: [merged] } }).mergeQueueRemoval, + ).toBeNull(); + expect(detailOf([]).mergeQueueRemoval).toBeNull(); + }); + it("shows a re-running check once, as the run that is happening now", () => { const detail = detailOf([ inWorkflow({ @@ -1779,6 +1812,7 @@ describe("pull request watch fingerprints", () => { readonly failed: number; readonly commentEditedAt: string | null; readonly reviews: number; + readonly dequeued?: number; }) => ({ state: "OPEN", mergeable: "MERGEABLE", @@ -1786,6 +1820,7 @@ describe("pull request watch fingerprints", () => { comments: { totalCount: 1, nodes: [{ lastEditedAt: input.commentEditedAt }] }, reviews: { totalCount: input.reviews, nodes: [] }, reviewThreads: { totalCount: 0 }, + mergeQueueRemovals: { filteredCount: input.dequeued ?? 0 }, commits: { nodes: [ { @@ -1812,8 +1847,11 @@ describe("pull request watch fingerprints", () => { const checkFinished = fingerprint({ ...base, running: 1, failed: 1 }); const summaryEdited = fingerprint({ ...base, commentEditedAt: "2026-10-05T23:47:43Z" }); const replied = fingerprint({ ...base, reviews: 2 }); + const dequeued = fingerprint({ ...base, dequeued: 1 }); expect(checkFinished.status).not.toBe(before.status); + expect(dequeued.status).not.toBe(before.status); + expect(dequeued.remarks).toBe(before.remarks); expect(checkFinished.remarks).toBe(before.remarks); expect(summaryEdited.remarks).not.toBe(before.remarks); expect(summaryEdited.status).toBe(before.status); diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index df4756fcc0dda..134810f33cecc 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -18,6 +18,7 @@ import type { PullRequestLabel, PullRequestMergeCapabilities, PullRequestMergeMethod, + PullRequestMergeQueueRemoval, PullRequestOmittedFileStat, PullRequestMergeability, PullRequestReaction, @@ -474,6 +475,36 @@ function toMergeability(value: string | null | undefined): PullRequestMergeabili } } +// GitHub types `reason` as a free string; these cases are the values seen on real pull requests. +function toMergeQueueRemovalReason(reason: string | null): PullRequestMergeQueueRemoval["reason"] { + switch (reason) { + case "failed_checks": + return "failed-checks"; + case "checks_timed_out": + return "checks-timed-out"; + case "merge_conflict": + return "merge-conflict"; + case "manual": + return "manual"; + default: + return "other"; + } +} + +type MergeQueueRemovals = NonNullable< + NonNullable["pullRequest"] +>["lastMergeQueueRemoval"]; + +function toMergeQueueRemoval(removals: MergeQueueRemovals): PullRequestMergeQueueRemoval | null { + const node = nodesOf(removals)[0]; + if (node?.__typename !== "RemovedFromMergeQueueEvent" || node.reason === "merged") return null; + return { + reason: toMergeQueueRemovalReason(node.reason), + at: node.createdAt, + actor: trimmed(node.actor?.login), + }; +} + function toMergeMethod(value: string | null | undefined): PullRequestMergeMethod | undefined { switch (value?.trim().toUpperCase()) { case "MERGE": @@ -1010,6 +1041,7 @@ export function toPullRequestWatchFingerprint( pr.headRefOid, stateCounts(contexts?.checkRunCountsByState ?? null), stateCounts(contexts?.statusContextCountsByState ?? null), + pr.mergeQueueRemovals.filteredCount, ].join(" "), remarks: [ pr.comments.totalCount, @@ -1028,6 +1060,7 @@ export interface GitHubPullRequestCore extends GitHubPullRequestDetail { readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess; readonly comparison: GitHubBaseComparison | null; readonly checksTruncated: boolean; + readonly mergeQueueRemoval: PullRequestMergeQueueRemoval | null; } type CoreRequestedReviewer = NonNullable< @@ -1098,6 +1131,7 @@ export function toPullRequestCore(result: PullRequestCore.Result): GitHubPullReq viewerCanUpdate: pr.viewerCanUpdateBranch, }, checksTruncated: contexts?.pageInfo.hasNextPage === true, + mergeQueueRemoval: toMergeQueueRemoval(pr.lastMergeQueueRemoval), }; } diff --git a/packages/source-control-github/src/server/pullRequestBatches.graphql b/packages/source-control-github/src/server/pullRequestBatches.graphql index cb0b08ee7f1d8..cfbd1971e0aeb 100644 --- a/packages/source-control-github/src/server/pullRequestBatches.graphql +++ b/packages/source-control-github/src/server/pullRequestBatches.graphql @@ -106,6 +106,11 @@ query PullRequestWatchFingerprint($owner: String!, $name: String!, $number: Int! reviewThreads { totalCount } + # Merge queue checks run on a merge group commit, not the head, so this count is all that moves + # when the queue drops the pull request. `totalCount` ignores `itemTypes`; `filteredCount` does not. + mergeQueueRemovals: timelineItems(itemTypes: [REMOVED_FROM_MERGE_QUEUE_EVENT]) { + filteredCount + } commits(last: 1) { nodes { commit { diff --git a/packages/source-control-github/src/server/pullRequestBatches.graphql.ts b/packages/source-control-github/src/server/pullRequestBatches.graphql.ts index 31b8863f4125c..7d0ec813106bc 100644 --- a/packages/source-control-github/src/server/pullRequestBatches.graphql.ts +++ b/packages/source-control-github/src/server/pullRequestBatches.graphql.ts @@ -309,6 +309,13 @@ export const PullRequestReviewThreadConnection = Schema.Struct({ }) export interface PullRequestReviewThreadConnection extends Schema.Struct.Type {} +/** The connection type for PullRequestTimelineItems. */ +export const PullRequestTimelineItemsConnection = Schema.Struct({ + /** Identifies the count of items after applying `before` and `after` filters. */ + filteredCount: Shared.Int +}) +export interface PullRequestTimelineItemsConnection extends Schema.Struct.Type {} + /** Represents a count of the state of a check run. */ export const CheckRunStateCount = Schema.Struct({ /** The state of a check run. */ @@ -378,6 +385,8 @@ export const PullRequestWatchFingerprintRepositoryPullRequestPullRequest = Schem reviews: Schema.NullOr(ReviewsPullRequestReviewConnection), /** The list of all review threads for this pull request. */ reviewThreads: PullRequestReviewThreadConnection, + /** A list of events, comments, commits, etc. associated with the pull request. */ + mergeQueueRemovals: PullRequestTimelineItemsConnection, /** A list of commits present in this pull request's head branch not present in the base branch. */ commits: PullRequestWatchFingerprintRepositoryPullRequestCommitsPullRequestCommitConnection }) @@ -399,7 +408,7 @@ export const PullRequestWatchFingerprint = GraphQL.query("PullRequestWatchFinger { name: "number", body: "Int!" } ], selections: [ - { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){pullRequest(number:$", "number", "){state mergeable headRefOid comments(first:100 orderBy:{field:UPDATED_AT direction:DESC}){totalCount nodes{lastEditedAt}}reviews(last:100){totalCount nodes{lastEditedAt}}reviewThreads{totalCount}commits(last:1){nodes{commit{statusCheckRollup{contexts{checkRunCountsByState{state count}statusContextCountsByState{state count}}}}}}}}"] } + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){pullRequest(number:$", "number", "){state mergeable headRefOid comments(first:100 orderBy:{field:UPDATED_AT direction:DESC}){totalCount nodes{lastEditedAt}}reviews(last:100){totalCount nodes{lastEditedAt}}reviewThreads{totalCount}mergeQueueRemovals:timelineItems(itemTypes:[REMOVED_FROM_MERGE_QUEUE_EVENT]){filteredCount}commits(last:1){nodes{commit{statusCheckRollup{contexts{checkRunCountsByState{state count}statusContextCountsByState{state count}}}}}}}}"] } ], fragments: [] }, diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql b/packages/source-control-github/src/server/pullRequestReads.graphql index ef40a4ab46bcd..084d1687eba7f 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql +++ b/packages/source-control-github/src/server/pullRequestReads.graphql @@ -111,6 +111,18 @@ query PullRequestCore( autoMergeRequest { mergeMethod } + # GitHub also logs a removal with reason `merged` when the queue merges the pull request. + lastMergeQueueRemoval: timelineItems(last: 1, itemTypes: [REMOVED_FROM_MERGE_QUEUE_EVENT]) { + nodes { + ... on RemovedFromMergeQueueEvent { + reason + createdAt + actor { + login + } + } + } + } viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql.ts b/packages/source-control-github/src/server/pullRequestReads.graphql.ts index 0a0f1aec68a3a..389a90f2aa6e3 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql.ts +++ b/packages/source-control-github/src/server/pullRequestReads.graphql.ts @@ -264,6 +264,42 @@ export const AutoMergeRequest = Schema.Struct({ }) export interface AutoMergeRequest extends Schema.Struct.Type {} +/** Represents an object which can take actions on GitHub. Typically a User or Bot. */ +export const Actor = Schema.Struct({ + __typename: GraphQL.otherTypename()([]), + /** The username of the actor. */ + login: Schema.String +}) +export interface Actor extends Schema.Struct.Type {} + +/** Represents a 'removed_from_merge_queue' event on a given pull request. */ +export const RemovedFromMergeQueueEvent = Schema.Struct({ + __typename: Schema.Literal("RemovedFromMergeQueueEvent"), + /** The reason this pull request was removed from the queue. */ + reason: Schema.NullOr(Schema.String), + /** Identifies the date and time when the object was created. */ + createdAt: Shared.DateTime, + /** Identifies the actor who performed the event. */ + actor: Schema.NullOr(Actor) +}) +export interface RemovedFromMergeQueueEvent extends Schema.Struct.Type {} + +/** An item in a pull request timeline */ +export const PullRequestTimelineItemsOther = Schema.Struct({ + __typename: GraphQL.otherTypename()(["RemovedFromMergeQueueEvent"]) +}) +export interface PullRequestTimelineItemsOther extends Schema.Struct.Type {} + +/** The connection type for PullRequestTimelineItems. */ +export const PullRequestTimelineItemsConnection = Schema.Struct({ + /** A list of nodes. */ + nodes: Schema.NullOr(Schema.Array(Schema.NullOr(Schema.Union([ + RemovedFromMergeQueueEvent, + PullRequestTimelineItemsOther + ])))) +}) +export interface PullRequestTimelineItemsConnection extends Schema.Struct.Type {} + /** Represents a comparison between two commit revisions. */ export const Comparison = Schema.Struct({ /** The number of commits behind the base branch. */ @@ -454,6 +490,8 @@ export const PullRequestCoreRepositoryPullRequestPullRequest = Schema.Struct({ ])), /** Returns the auto-merge request object if one exists for this pull request. */ autoMergeRequest: Schema.NullOr(AutoMergeRequest), + /** A list of events, comments, commits, etc. associated with the pull request. */ + lastMergeQueueRemoval: PullRequestTimelineItemsConnection, /** Check if the current viewer can update this object. */ viewerCanUpdate: Schema.Boolean, /** Did the viewer author this comment. */ @@ -503,7 +541,7 @@ export const PullRequestCore = GraphQL.query("PullRequestCore", { { name: "requestedReviewers", body: "Int!" } ], selections: [ - { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed autoMergeAllowed viewerPermission pullRequest(number:$", "number", "){number title url body state isDraft mergeable mergeStateStatus reviewDecision additions deletions changedFiles createdAt updatedAt mergedAt closedAt headRefName baseRefName headRefOid isCrossRepository maintainerCanModify headRepositoryOwner{__typename login}headRepository{nameWithOwner viewerPermission}headRef{id}author{__typename login avatarUrl...on User{id name}}autoMergeRequest{mergeMethod}viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch baseRef{compare(headRef:$", "headRef", "){behindBy}}reviewRequests(first:$", "requestedReviewers", "){nodes{requestedReviewer{__typename...on User{login name}...on Bot{login}...on Team{slug combinedSlug teamName:name teamAvatarUrl:avatarUrl}}}}labels(first:100){nodes{name color}}commits(last:1){nodes{commit{statusCheckRollup{contexts(first:100){nodes{__typename...", "CheckContext", "}pageInfo{hasNextPage endCursor}}}}}}}}"] } + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed autoMergeAllowed viewerPermission pullRequest(number:$", "number", "){number title url body state isDraft mergeable mergeStateStatus reviewDecision additions deletions changedFiles createdAt updatedAt mergedAt closedAt headRefName baseRefName headRefOid isCrossRepository maintainerCanModify headRepositoryOwner{__typename login}headRepository{nameWithOwner viewerPermission}headRef{id}author{__typename login avatarUrl...on User{id name}}autoMergeRequest{mergeMethod}lastMergeQueueRemoval:timelineItems(last:1 itemTypes:[REMOVED_FROM_MERGE_QUEUE_EVENT]){nodes{__typename...on RemovedFromMergeQueueEvent{reason createdAt actor{__typename login}}}}viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch baseRef{compare(headRef:$", "headRef", "){behindBy}}reviewRequests(first:$", "requestedReviewers", "){nodes{requestedReviewer{__typename...on User{login name}...on Bot{login}...on Team{slug combinedSlug teamName:name teamAvatarUrl:avatarUrl}}}}labels(first:100){nodes{name color}}commits(last:1){nodes{commit{statusCheckRollup{contexts(first:100){nodes{__typename...", "CheckContext", "}pageInfo{hasNextPage endCursor}}}}}}}}"] } ], fragments: [ { name: "CheckContext", body: ["on StatusCheckRollupContext{__typename...on StatusContext{context state targetUrl createdAt description isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}...on CheckRun{name status conclusion startedAt completedAt detailsUrl isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")checkSuite{workflowRun{workflow{name}}}}}"] } @@ -1152,14 +1190,6 @@ export const UserConnection = Schema.Struct({ }) export interface UserConnection extends Schema.Struct.Type {} -/** Represents an object which can take actions on GitHub. Typically a User or Bot. */ -export const Actor = Schema.Struct({ - __typename: GraphQL.otherTypename()([]), - /** The username of the actor. */ - login: Schema.String -}) -export interface Actor extends Schema.Struct.Type {} - /** A request for a user to review a pull request. */ export const ReviewerCandidatesRepositoryPullRequestReviewRequestsNodesReviewRequest = Schema.Struct({ /** The reviewer that is requested. */ From c23f1874b923aa714a893064187bd067c6738a94 Mon Sep 17 00:00:00 2001 From: Soorria Saruva Date: Mon, 9 Nov 2026 21:05:20 +1100 Subject: [PATCH 2/2] test(github): add merge queue removal to the core response fixture --- .../src/server/GitHubPullRequestApi.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 8daf82277a083..b9ebb9908832f 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -78,6 +78,7 @@ const coreResponse = (pullRequest: Readonly> = {}) => ({ headRef: null, author: null, autoMergeRequest: null, + lastMergeQueueRemoval: { nodes: [] }, headRefName: "feature", baseRefName: "main", headRefOid: "abc123",