From 873a66250c8a1cf801e88e786c04efb60adf4451 Mon Sep 17 00:00:00 2001 From: Julien Saguet Date: Sun, 1 Nov 2026 22:40:05 +0100 Subject: [PATCH] fix(github): show queued pull requests across clients --- .../src/components/ComposerContextSheet.tsx | 3 +- .../pull-requests/PullRequestStackCard.tsx | 1 + .../pull-requests/PullRequestStatusRows.tsx | 7 +- .../pull-requests/pullRequestActions.test.ts | 8 + .../pull-requests/pullRequestActions.ts | 24 ++- .../pull-requests/pullRequestList.test.ts | 5 + .../features/pull-requests/pullRequestList.ts | 1 + .../pull-requests/pullRequestStack.test.ts | 51 +++++- .../pull-requests/pullRequestStack.ts | 43 ++++- .../pull-requests/pullRequestStatus.test.ts | 21 +++ .../pull-requests/pullRequestStatus.ts | 15 +- .../pull-requests/usePullRequestStack.ts | 5 +- .../pull-requests/usePullRequestThreads.ts | 1 + .../features/threads/git/GitOverviewSheet.tsx | 8 +- .../features/threads/thread-list-v2-items.tsx | 26 ++- .../threads/use-composer-command-menu.ts | 4 +- .../src/lib/composerChipPresentation.ts | 12 +- .../src/state/thread-pr-presentation.ts | 27 ++- apps/mobile/src/state/use-thread-pr.test.ts | 34 ++++ apps/server/src/git/GitManager.test.ts | 49 +++++- apps/server/src/git/GitManager.ts | 22 ++- .../toolkits/pullRequests/handlers.test.ts | 24 ++- .../src/mcp/toolkits/pullRequests/handlers.ts | 1 + .../src/mcp/toolkits/pullRequests/tools.ts | 4 + .../PullRequestSyncReactor.test.ts | 155 +++++++++++++----- .../PullRequestSyncReactor.ts | 24 ++- .../pullRequest/PullRequestService.test.ts | 30 +++- .../src/pullRequest/PullRequestService.ts | 21 ++- apps/web/src/components/ContextChip.tsx | 1 + .../components/PullRequestThreadDialog.tsx | 13 +- apps/web/src/components/RightPanelTabs.tsx | 7 +- .../src/components/ThreadStatusIndicators.tsx | 34 +++- .../chat/ThreadDetailsPrRow.browser.test.tsx | 47 +++++- .../components/chat/ThreadDetailsPrRow.tsx | 4 +- .../components/chat/useComposerMenuItems.ts | 1 + apps/web/src/components/contextChipParts.tsx | 7 +- .../pullRequest/PullRequestChecksPopover.tsx | 11 +- .../pullRequest/PullRequestDetailActions.tsx | 4 +- .../pullRequest/PullRequestDetailPanel.tsx | 21 ++- .../pullRequest/PullRequestGhosts.tsx | 2 + .../pullRequest/PullRequestLinkPreview.tsx | 6 +- .../pullRequest/PullRequestListRow.tsx | 4 +- .../components/pullRequest/PullRequestRow.tsx | 1 + .../PullRequestStackLayerContent.tsx | 1 + .../pullRequest/PullRequestStackMenu.tsx | 10 +- .../pullRequest/ThreadPullRequestsPanel.tsx | 3 + .../pullRequestDetail.logic.test.ts | 12 ++ .../pullRequest/pullRequestDetail.logic.ts | 25 ++- .../pullRequest/pullRequestIcons.tsx | 9 +- .../pullRequest/pullRequestList.logic.test.ts | 6 + .../pullRequest/pullRequestList.logic.ts | 6 +- .../pullRequestListGroups.logic.ts | 1 + .../pullRequest/pullRequestPresentation.tsx | 14 +- .../pullRequestStackSnapshot.test.ts | 32 ++++ .../pullRequest/pullRequestStackSnapshot.ts | 30 +++- .../pullRequest/usePullRequestDetail.ts | 1 + .../src/lib/composerContextRecords.test.ts | 5 +- apps/web/src/lib/composerContextRecords.ts | 6 +- apps/web/src/lib/contextChipAccents.ts | 1 + apps/web/src/state/pullRequests.ts | 2 + evals/mcp-tool-use/cases/merge-queue.ndjson | 1 + evals/mcp-tool-use/run.test.ts | 54 ++++++ .../src/state/pullRequestRouting.ts | 13 +- .../src/state/pullRequests.test.ts | 6 +- .../client-runtime/src/state/pullRequests.ts | 2 + .../src/threadPullRequests.test.ts | 23 +++ .../contracts/src/composerContext.test.ts | 7 + packages/contracts/src/composerContext.ts | 1 + packages/contracts/src/git.ts | 2 + packages/contracts/src/pullRequest.ts | 6 + packages/contracts/src/sourceControl.ts | 1 + packages/contracts/src/threadPullRequest.ts | 1 + .../src/composerPullRequestMatches.test.ts | 3 +- .../shared/src/composerPullRequestMatches.ts | 1 + .../src/server/PullRequestProvider.ts | 2 + .../src/shared/threadPullRequests.ts | 20 ++- .../src/server/GitHubPullRequestApi.test.ts | 87 +++++++++- .../src/server/GitHubPullRequestApi.ts | 49 +++++- .../GitHubSourceControlProvider.test.ts | 92 ++++++++--- .../src/server/GitHubSourceControlProvider.ts | 8 +- .../src/server/gitHubGraphQlClient.ts | 9 +- .../src/server/gitHubPullRequestJson.test.ts | 13 ++ .../src/server/gitHubPullRequestJson.ts | 10 +- .../src/server/gitHubPullRequestQueue.ts | 80 +++++++++ .../src/server/gitHubPullRequests.ts | 3 + .../src/server/githubStackActions.test.ts | 50 ++++-- .../src/server/githubStackActions.ts | 18 ++ .../src/server/pullRequestBatches.graphql | 9 + .../src/server/pullRequestBatches.graphql.ts | 48 +++++- .../src/server/pullRequestListings.graphql | 3 + .../src/server/pullRequestListings.graphql.ts | 10 +- .../src/server/pullRequestReads.graphql | 13 +- .../src/server/pullRequestReads.graphql.ts | 24 ++- .../src/server/sourceControlProvider.graphql | 5 +- .../server/sourceControlProvider.graphql.ts | 28 +++- 95 files changed, 1429 insertions(+), 226 deletions(-) create mode 100644 evals/mcp-tool-use/cases/merge-queue.ndjson create mode 100644 packages/source-control-github/src/server/gitHubPullRequestQueue.ts diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 8121bfdece763..607c74b31dd3c 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -1,3 +1,4 @@ +import { pullRequestStatus } from "../features/pull-requests/pullRequestStatus"; import { Button } from "./ui/Button"; import * as Dialog from "./ui/Dialog"; import { useOpenPullRequest } from "../native/useOpenPullRequest"; @@ -247,7 +248,7 @@ export function ComposerContextSheet(props: { <> {record.pullRequest ? ( ) : null} diff --git a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx index cbb751c74c4c7..9083fceeb0c79 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx @@ -54,6 +54,7 @@ export function PullRequestStackCard(props: { const status = pullRequestStatus({ state: layer.state, isDraft: layer.isDraft === true, + inMergeQueue: layer.inMergeQueue === true, }); return ( diff --git a/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx b/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx index 185d555cc776b..fda23e51a6fda 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx @@ -114,12 +114,13 @@ export function PullRequestChecksRow(props: RowProps) { return ( setExpanded(!expanded)} /> @@ -169,7 +170,7 @@ export function PullRequestChecksRow(props: RowProps) { No checks reported. ) : null} void Linking.openURL( diff --git a/apps/mobile/src/features/pull-requests/pullRequestActions.test.ts b/apps/mobile/src/features/pull-requests/pullRequestActions.test.ts index 2234a31081802..c71ed3d4be0c2 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestActions.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestActions.test.ts @@ -65,6 +65,14 @@ describe("mobile pull request actions", () => { expect(mergeUnavailableReason(pr, true, GITHUB)).toContain("conflicts"); }); + it("blocks manual and auto merge while queued", () => { + const pr = detail({ inMergeQueue: true }); + expect(canPerformPullRequestAction(pr, "merge")).toBe(false); + expect(canPerformPullRequestAction(pr, "draft")).toBe(false); + expect(canPerformPullRequestAction(pr, "enable-auto-merge")).toBe(false); + expect(mergeUnavailableReason(pr, true, GITHUB)).toContain("queued to merge"); + }); + it("names the change request with its host's term", () => { expect(pullRequestActionLabel("close", GITHUB)).toBe("Close pull request"); expect(pullRequestActionLabel("close", GITLAB)).toBe("Close merge request"); diff --git a/apps/mobile/src/features/pull-requests/pullRequestActions.ts b/apps/mobile/src/features/pull-requests/pullRequestActions.ts index e6c04f192b417..cfa756f772e58 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestActions.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestActions.ts @@ -13,7 +13,12 @@ import { PullRequestGlyph } from "./pullRequestGlyphs"; type ActionDetail = Pick< PullRequestDetail, - "state" | "isDraft" | "mergeability" | "autoMergeEnabled" | "workflowApprovalsRequired" + | "state" + | "isDraft" + | "inMergeQueue" + | "mergeability" + | "autoMergeEnabled" + | "workflowApprovalsRequired" > & { readonly capabilities: Pick< PullRequestDetail["capabilities"], @@ -105,11 +110,21 @@ export function canPerformPullRequestAction(pr: ActionDetail, action: PullReques case "ready": return pr.isDraft; case "draft": - return !pr.isDraft; + return !pr.isDraft && !pr.inMergeQueue; case "merge": - return !pr.isDraft && pr.mergeability !== "conflicting" && allowedMergeMethods(pr).length > 0; + return ( + !pr.isDraft && + !pr.inMergeQueue && + pr.mergeability !== "conflicting" && + allowedMergeMethods(pr).length > 0 + ); case "enable-auto-merge": - return !pr.isDraft && pr.autoMergeEnabled === false && allowedMergeMethods(pr).length > 0; + return ( + !pr.isDraft && + !pr.inMergeQueue && + pr.autoMergeEnabled === false && + allowedMergeMethods(pr).length > 0 + ); case "disable-auto-merge": return pr.autoMergeEnabled === true; default: @@ -130,6 +145,7 @@ export function mergeUnavailableReason( if (!pr.viewerPermissions.actions.includes("merge")) return `Your account cannot merge this ${terminology.singular}.`; if (pr.isDraft) return "Mark this draft ready for review before merging."; + if (pr.inMergeQueue) return "This pull request is queued to merge."; if (pr.mergeability === "conflicting") return "Resolve the branch conflicts before merging."; if (!allowedMergeMethods(pr).length) return "This repository has no available merge methods."; return null; diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.test.ts b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts index b6f47b6533ee2..a00014821a89e 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts @@ -85,6 +85,11 @@ describe("recycled PR list rows", () => { }), ).toBe(false); }); + + it("recycles a row when its merge queue status changes", () => { + const original = scopedEntry(); + expect(pullRequestListRowsAreEqual(original, { ...original, inMergeQueue: true })).toBe(false); + }); }); describe("mobile PR list pagination", () => { diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.ts b/apps/mobile/src/features/pull-requests/pullRequestList.ts index 674d3b0e24fe3..5486c5d08a921 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.ts @@ -188,6 +188,7 @@ export function pullRequestListRowsAreEqual( previous.url === next.url && previous.state === next.state && previous.isDraft === next.isDraft && + previous.inMergeQueue === next.inMergeQueue && previous.mergeability === next.mergeability && previous.baseBranch === next.baseBranch && previous.updatedAt === next.updatedAt && diff --git a/apps/mobile/src/features/pull-requests/pullRequestStack.test.ts b/apps/mobile/src/features/pull-requests/pullRequestStack.test.ts index 2a2fc9d87cdca..7de3b165f5935 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestStack.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestStack.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { pullRequestLayerParams, pullRequestStackMerge, + pullRequestStackView, savedPullRequestStack, singlePullRequestMergeAllowed, } from "./pullRequestStack"; @@ -101,6 +102,20 @@ describe("mobile stack merge scope", () => { pullRequestStackMerge(stack, 99, sourceControlClients.get("gitlab").changeRequest).reason, ).toBe("This merge request is no longer in the stack. Refresh it."); }); + it("blocks merging any queued layer in the selected stack prefix", () => { + const queued = { + ...stack, + layers: stack.layers.map((layer) => + layer.number === 2 ? { ...layer, inMergeQueue: true } : layer, + ), + }; + expect(pullRequestStackMerge(queued, 3, GITHUB).reason).toContain("merge queue"); + const exited = { + ...queued, + layers: queued.layers.map((layer) => ({ ...layer, inMergeQueue: false })), + }; + expect(pullRequestStackMerge(exited, 3, GITHUB).reason).toBeNull(); + }); it("waits for successful discovery before allowing a single PR merge on modern servers", () => { expect( singlePullRequestMergeAllowed({ @@ -147,13 +162,43 @@ describe("mobile stack layer navigation", () => { describe("mobile saved stack navigation", () => { it("keeps all saved layers and enriches titles without carrying action revisions", () => { - const saved = savedPullRequestStack([link], reference); + const queuedLink = { + ...link, + snapshot: { ...link.snapshot!, inMergeQueue: true }, + }; + const saved = savedPullRequestStack([queuedLink], reference); expect(saved?.layers).toHaveLength(3); - expect(saved?.layers[1]).toMatchObject({ number: 2, title: "Middle layer", isDraft: false }); + expect(saved?.layers[1]).toMatchObject({ + number: 2, + title: "Middle layer", + isDraft: false, + inMergeQueue: true, + }); expect(saved?.layers.every((layer) => layer.headSha === undefined)).toBe(true); - expect(pullRequestStackMerge(saved!, 2, GITHUB).reason).toContain("latest revisions"); + expect(pullRequestStackMerge(saved!, 2, GITHUB).reason).toContain("merge queue"); expect(savedPullRequestStack([link], { ...reference, number: 3 })?.number).toBe(10); }); + it("restores saved queue status only when fresh stack data omits it", () => { + const saved = savedPullRequestStack( + [{ ...link, snapshot: { ...link.snapshot!, inMergeQueue: true } }], + reference, + )!; + const fresh = { + ...stack, + layers: stack.layers.map(({ inMergeQueue: _queue, ...layer }) => layer), + }; + expect(pullRequestStackView(stack, stack)).toBe(stack); + expect(pullRequestStackView(fresh, saved)?.layers[1]?.inMergeQueue).toBe(true); + expect( + pullRequestStackView( + { + ...fresh, + layers: fresh.layers.map((layer) => ({ ...layer, inMergeQueue: false })), + }, + saved, + )?.layers[1]?.inMergeQueue, + ).toBe(false); + }); it("never borrows membership from another host or repository", () => { expect(savedPullRequestStack([link], { ...reference, host: "enterprise.example" })).toBeNull(); expect(savedPullRequestStack([link], { ...reference, repository: "other/web" })).toBeNull(); diff --git a/apps/mobile/src/features/pull-requests/pullRequestStack.ts b/apps/mobile/src/features/pull-requests/pullRequestStack.ts index eb8e3736ec995..b7331617aaf62 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestStack.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestStack.ts @@ -20,9 +20,11 @@ export function pullRequestStackMerge( ? "Reopen the closed layers before merging this stack." : layers.some((layer) => layer.isDraft) ? "Mark every layer being merged ready for review." - : heads.length !== layers.length - ? "Refresh the stack to load its latest revisions before merging." - : null; + : layers.some((layer) => layer.inMergeQueue) + ? "Wait for queued layers to leave the merge queue before merging this stack." + : heads.length !== layers.length + ? "Refresh the stack to load its latest revisions before merging." + : null; return { position: position + 1, layers, heads, reason }; } @@ -61,7 +63,13 @@ export function savedPullRequestStack( number: layer.number, headBranch: layer.headBranch, state: layer.state, - ...(snapshot ? { title: snapshot.title, isDraft: snapshot.isDraft } : {}), + ...(snapshot + ? { + title: snapshot.title, + isDraft: snapshot.isDraft, + inMergeQueue: snapshot.inMergeQueue, + } + : {}), }; }), }; @@ -76,6 +84,33 @@ export function pullRequestLayerParams + layer.inMergeQueue === undefined && + saved.layers.some( + (candidate) => candidate.number === layer.number && candidate.inMergeQueue !== undefined, + ), + ) + ) + return data; + return { + ...data, + layers: data.layers.map((layer) => { + const savedLayer = saved.layers.find((candidate) => candidate.number === layer.number); + return layer.inMergeQueue !== undefined || savedLayer?.inMergeQueue === undefined + ? layer + : { ...layer, inMergeQueue: savedLayer.inMergeQueue }; + }), + }; +} + export function singlePullRequestMergeAllowed(input: { readonly supportsStackActions: boolean; readonly hasStack: boolean; diff --git a/apps/mobile/src/features/pull-requests/pullRequestStatus.test.ts b/apps/mobile/src/features/pull-requests/pullRequestStatus.test.ts index aef40179ded64..6c5826902b31b 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestStatus.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestStatus.test.ts @@ -4,6 +4,7 @@ import { normalizedReviewState, pullRequestChecksSummary, pullRequestReviewSummary, + pullRequestStatus, } from "./pullRequestStatus"; const author = { login: "julius", name: null, avatarUrl: null }; @@ -99,3 +100,23 @@ describe("mobile overview check status", () => { ).toMatchObject({ title: "1 check failing", count: "1 of 3" }); }); }); + +describe("mobile pull request status", () => { + it("shows queued for open PRs before draft, and keeps merged and closed terminal", () => { + expect(pullRequestStatus({ state: "open", isDraft: false, inMergeQueue: true }).label).toBe( + "Queued", + ); + expect(pullRequestStatus({ state: "open", isDraft: true, inMergeQueue: true }).label).toBe( + "Queued", + ); + expect(pullRequestStatus({ state: "open", isDraft: true, inMergeQueue: false }).label).toBe( + "Draft", + ); + expect(pullRequestStatus({ state: "merged", isDraft: false, inMergeQueue: true }).label).toBe( + "Merged", + ); + expect(pullRequestStatus({ state: "closed", isDraft: false, inMergeQueue: true }).label).toBe( + "Closed", + ); + }); +}); diff --git a/apps/mobile/src/features/pull-requests/pullRequestStatus.ts b/apps/mobile/src/features/pull-requests/pullRequestStatus.ts index 5426266dc6893..629978db7a9a8 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestStatus.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestStatus.ts @@ -17,6 +17,13 @@ const STATES = { text: "text-adaptive-emerald-600-400", background: "bg-adaptive-emerald-500-a12-a16", }, + queued: { + label: "Queued", + icon: "clock", + tint: "accent-warning-foreground", + text: "text-warning-foreground", + background: "bg-warning", + }, merged: { label: "Merged", icon: PullRequestGlyph.merged, @@ -43,8 +50,12 @@ const STATES = { { label: string; icon: AppSymbolName; tint: string; text: string; background: string } >; -export function pullRequestStatus(pr: Pick) { - return STATES[pr.isDraft && pr.state === "open" ? "draft" : pr.state]; +export function pullRequestStatus( + pr: Pick, +) { + if (pr.state !== "open") return STATES[pr.state]; + if (pr.inMergeQueue) return STATES.queued; + return STATES[pr.isDraft ? "draft" : "open"]; } export function pullRequestConflict(pr: { diff --git a/apps/mobile/src/features/pull-requests/usePullRequestStack.ts b/apps/mobile/src/features/pull-requests/usePullRequestStack.ts index 74a19737ac85d..72dc6d71e0a2f 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestStack.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestStack.ts @@ -5,7 +5,7 @@ import { useMemo } from "react"; import { useEnvironmentServerConfig, useNavigationThreadShells } from "../../state/entities"; import { pullRequestStackAtom } from "../../state/pull-requests"; import { useEnvironmentQuery } from "../../state/query"; -import { savedPullRequestStack } from "./pullRequestStack"; +import { pullRequestStackView, savedPullRequestStack } from "./pullRequestStack"; import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; export function usePullRequestStack(params: PullRequestParams, pr: PullRequestDetail | null) { @@ -37,7 +37,8 @@ export function usePullRequestStack(params: PullRequestParams, pr: PullRequestDe const query = useEnvironmentQuery( supported ? pullRequestStackAtom({ ...target, input: reference }) : null, ); - const stack = query.isSuccess ? query.data : (query.data ?? saved); + const data = query.isSuccess ? query.data : (query.data ?? saved); + const stack = useMemo(() => pullRequestStackView(data, saved), [data, saved]); return { ...query, stack, diff --git a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts index e19dc333f2e3a..7025783f342af 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts @@ -137,6 +137,7 @@ export function usePullRequestThreads( baseBranch: pr.baseBranch, state: pr.state, isDraft: pr.isDraft, + inMergeQueue: pr.inMergeQueue, }, uuidv4(), filePath, diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index ac60fb66f6aac..e0c280a61ad8d 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -50,6 +50,7 @@ import { useLinkedPullRequestLines, } from "../LinkedPullRequestList"; import { useLinkedPullRequestActions } from "../useLinkedPullRequestActions"; +import { pullRequestStatus } from "../../pull-requests/pullRequestStatus"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -218,7 +219,12 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { return `${ahead} commit${ahead === 1 ? "" : "s"} ahead`; } if (item.kind === "open_pr" && status.pr?.number != null) { - return `${shortLabel} #${status.pr.number} ${status.pr.state ?? "open"}`; + const prStatus = pullRequestStatus({ + state: status.pr.state ?? "open", + isDraft: status.pr.isDraft === true, + inMergeQueue: status.pr.inMergeQueue, + }); + return `${shortLabel} #${status.pr.number} ${prStatus.label.toLowerCase()}`; } return undefined; }; diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 84e9494759cad..a8f0819a9f074 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -725,6 +725,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const providerInstance = props.providerInstance; const pr = useThreadPr(thread); + const prStatus = + pr?.state === null || !pr + ? null + : pullRequestStatus({ + state: pr.state, + isDraft: pr.isDraft, + inMergeQueue: pr.inMergeQueue, + }); const prBadgeLabel = pr ? resolveThreadPrBadgeAction(pr).accessibilityLabel : null; const handleOpenPullRequests = useCallback(() => { if (pr) onOpenThreadPullRequests(thread, pr); @@ -1293,17 +1301,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ? PullRequestGlyph.stack : pr.state === null ? PullRequestGlyph.pullRequest - : pullRequestStatus({ state: pr.state, isDraft: pr.isDraft }).icon + : (prStatus?.icon ?? PullRequestGlyph.pullRequest) } size={12} tintColorClassName={ - pr.state === null || pr.isDraft - ? rowAppearance.mutedIconTintClassName - : pr.state === "open" - ? "accent-adaptive-emerald-600-400" - : pr.state === "closed" - ? "accent-adaptive-rose-600-400" - : "accent-adaptive-violet-600-400" + pr.inMergeQueue + ? "accent-warning-foreground" + : pr.state === null || pr.isDraft + ? rowAppearance.mutedIconTintClassName + : pr.state === "open" + ? "accent-adaptive-emerald-600-400" + : pr.state === "closed" + ? "accent-adaptive-rose-600-400" + : "accent-adaptive-violet-600-400" } /> 0 ? badge.others + 1 : null; const isMultiple = badge.kind === "stack" || linkedCount !== null; const state = isMultiple - ? badge.state === "draft" + ? badge.state === "draft" || badge.state === "queued" ? "open" : badge.state : (snapshot?.state ?? null); const isDraft = isMultiple ? badge.state === "draft" : snapshot?.isDraft === true && state === "open"; + const inMergeQueue = + state === "open" && (isMultiple ? badge.state === "queued" : snapshot?.inMergeQueue === true); const label = badge.kind === "stack" ? String(badge.layers) @@ -84,18 +93,20 @@ export function presentThreadLinkedPullRequests( number: link.number, state, isDraft, + inMergeQueue, updatedAt: snapshot?.updatedAt ?? null, url: link.url, changeRequest, label, accessibilityLabel: badge.kind === "stack" - ? `${badge.layers} ${changeRequest.singular}s in stack, ${isDraft ? "draft" : (state ?? "status pending")}` + ? `${badge.layers} ${changeRequest.singular}s in stack, ${inMergeQueue ? "queued" : isDraft ? "draft" : (state ?? "status pending")}` : linkedCount !== null - ? `${linkedCount} linked ${changeRequest.plural}, overall ${badge.state}` - : `#${link.number} ${changeRequest.singular} ${state === null ? "status pending" : isDraft ? "draft" : state}`, - textClassName: - state === null || isDraft + ? `${linkedCount} linked ${changeRequest.plural}, overall ${inMergeQueue ? "queued" : badge.state}` + : `#${link.number} ${changeRequest.singular} ${state === null ? "status pending" : inMergeQueue ? "queued" : isDraft ? "draft" : state}`, + textClassName: inMergeQueue + ? "text-warning-foreground" + : state === null || isDraft ? "text-foreground-muted" : isMultiple && state === "closed" ? "text-adaptive-rose-600-400" diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index 309953ddbdc3b..a8ed591f7498b 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -61,6 +61,40 @@ describe("presentThreadLinkedPullRequests", () => { ])?.accessibilityLabel, ).toBe("#7 merge request open"); }); + + it("presents a queued open link as queued", () => { + const link = linkedPr(7, { + snapshot: { ...linkedPr(7).snapshot!, inMergeQueue: true }, + }); + expect(presentThreadLinkedPullRequests([link])).toMatchObject({ + inMergeQueue: true, + accessibilityLabel: "#7 pull request queued", + textClassName: "text-warning-foreground", + }); + }); + + it("keeps a partially queued stack open", () => { + const bottom = linkedPr(1, { + snapshot: { + ...linkedPr(1).snapshot!, + headBranch: "base", + inMergeQueue: false, + }, + }); + const top = linkedPr(2, { + snapshot: { + ...linkedPr(2).snapshot!, + baseBranch: "base", + inMergeQueue: true, + }, + }); + expect(presentThreadLinkedPullRequests([bottom, top])).toMatchObject({ + kind: "stack", + state: "open", + inMergeQueue: false, + accessibilityLabel: "2 pull requests in stack, open", + }); + }); }); describe("resolveThreadPrSource compatibility", () => { diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 350cb9e8340aa..d4860147c5d29 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1,6 +1,7 @@ import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { SourceControlProviderKind, GitResolvePullRequestResult } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; @@ -120,6 +121,8 @@ type FakeProviderCall = | { readonly operation: "getRepositoryCloneUrls"; readonly repository: string } | { readonly operation: "getDefaultBranch" }; +const decodeResolvedPullRequest = Schema.decodeEffect(GitResolvePullRequestResult); + const listed = (calls: ReadonlyArray) => calls.flatMap((call) => (call.operation === "listChangeRequests" ? [call] : [])); @@ -1020,7 +1023,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("branch PR lookup notifies linked threads when draft status changes", () => + it.effect("branch PR lookup notifies linked threads when draft or queue status changes", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -1028,7 +1031,16 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/draft-status"]); - const drafts = [false, true, true, false, false, true]; + const states = [ + ...[false, true, true, false, false, true].map((isDraft) => ({ + isDraft, + inMergeQueue: false, + })), + { isDraft: true, inMergeQueue: true }, + { isDraft: true, inMergeQueue: true }, + { isDraft: true, inMergeQueue: false }, + { isDraft: true, inMergeQueue: false }, + ]; const pullRequest: FakeChangeRequest = { number: 1, title: "Draft status", @@ -1040,7 +1052,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ providerScenario: { prListSequence: [ - ...drafts.map((isDraft) => [{ ...pullRequest, isDraft }]), + ...states.map((status) => [{ ...pullRequest, ...status }]), [ { ...pullRequest, @@ -1053,12 +1065,13 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }, }); const changes = yield* manager.subscribePullRequestStateChanges; - for (const isDraft of drafts) { + for (const { isDraft, inMergeQueue } of states) { const pr = yield* manager.branchPullRequest( { cwd: repoDir, branch: "feature/draft-status" }, { refresh: true }, ); expect(pr?.isDraft === true).toBe(isDraft); + expect(pr?.inMergeQueue).toBe(inMergeQueue); } // A different PR ends collection without waiting on a background stream consumer. yield* manager.branchPullRequest( @@ -1072,6 +1085,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { { host: "github.com", repository: "pingdotgg/codething-mvp", number: 1 }, { host: "github.com", repository: "pingdotgg/codething-mvp", number: 1 }, { host: "github.com", repository: "pingdotgg/codething-mvp", number: 1 }, + { host: "github.com", repository: "pingdotgg/codething-mvp", number: 1 }, + { host: "github.com", repository: "pingdotgg/codething-mvp", number: 1 }, ]); }), ); @@ -4540,6 +4555,30 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("preserves queue membership when resolving a PR for checkout", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["remote", "add", "origin", "https://github.com/acme/app.git"]); + const { manager } = yield* makeManager({ + providerScenario: { + pullRequest: { + number: 7, + title: "Queued change", + url: "https://github.com/acme/app/pull/7", + baseRefName: "main", + headRefName: "feature", + state: "open", + inMergeQueue: true, + }, + }, + }); + const result = yield* manager.resolvePullRequest({ cwd: repoDir, reference: "7" }); + const decoded = yield* decodeResolvedPullRequest(result); + expect(decoded.pullRequest).toMatchObject({ number: 7, state: "open", inMergeQueue: true }); + }), + ); + it.effect("prepares a worktree from the head branch when the host has no pull ref", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 6715f9fd65ed0..e9029d23f145c 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -203,6 +203,7 @@ interface OpenPrInfo { interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; isDraft?: boolean; + inMergeQueue?: boolean; closedAt?: string | null; mergedAt?: string | null; updatedAt: Option.Option; @@ -221,6 +222,7 @@ interface ResolvedPullRequest { baseBranch: string; headBranch: string; state: "open" | "closed" | "merged"; + inMergeQueue?: boolean; } interface PullRequestHeadRemoteInfo { @@ -408,6 +410,7 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { ...(summary.headSha !== undefined ? { headSha: summary.headSha } : {}), state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + ...(summary.inMergeQueue === undefined ? {} : { inMergeQueue: summary.inMergeQueue }), closedAt: summary.closedAt ?? null, mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, @@ -585,6 +588,7 @@ function toStatusPr(pr: PullRequestInfo): { headRef: string; state: "open" | "closed" | "merged"; isDraft?: boolean; + inMergeQueue?: boolean; updatedAt: string | null; } { return { @@ -595,6 +599,7 @@ function toStatusPr(pr: PullRequestInfo): { headRef: pr.headRefName, state: pr.state, ...(pr.isDraft === true ? { isDraft: true } : {}), + ...(pr.inMergeQueue === undefined ? {} : { inMergeQueue: pr.inMergeQueue }), updatedAt: Option.match(pr.updatedAt, { onNone: () => null, onSome: (updatedAt) => DateTime.formatIso(updatedAt), @@ -615,6 +620,7 @@ function toResolvedPullRequest(pr: { baseRefName: string; headRefName: string; state?: "open" | "closed" | "merged"; + inMergeQueue?: boolean | undefined; }): ResolvedPullRequest { return { number: pr.number, @@ -623,6 +629,7 @@ function toResolvedPullRequest(pr: { baseBranch: pr.baseRefName, headBranch: pr.headRefName, state: pr.state ?? "open", + ...(pr.inMergeQueue === undefined ? {} : { inMergeQueue: pr.inMergeQueue }), }; } @@ -1188,7 +1195,11 @@ export const make = Effect.gen(function* () { const pullRequestStateChanges = yield* PubSub.sliding(64); const lookupStates = new Map< string, - { readonly state: PullRequestInfo["state"]; readonly isDraft: boolean } + { + readonly state: PullRequestInfo["state"]; + readonly isDraft: boolean; + readonly inMergeQueue: boolean; + } >(); const noteLookupState = (pr: PullRequestInfo) => Effect.suspend(() => { @@ -1202,9 +1213,14 @@ export const make = Effect.gen(function* () { const oldest = lookupStates.keys().next().value; if (oldest !== undefined) lookupStates.delete(oldest); } - lookupStates.set(id, { state: pr.state, isDraft: pr.isDraft === true }); + lookupStates.set(id, { + state: pr.state, + isDraft: pr.isDraft === true, + inMergeQueue: pr.inMergeQueue === true, + }); return (previous?.state ?? "open") === pr.state && - (previous?.isDraft === true) === (pr.isDraft === true) + (previous?.isDraft === true) === (pr.isDraft === true) && + (previous?.inMergeQueue === true) === (pr.inMergeQueue === true) ? Effect.void : PubSub.publish(pullRequestStateChanges, key); }); diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 714af0312b45f..e7878ff6abb18 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -602,8 +602,24 @@ describe("pull request toolkit handlers", () => { Effect.gen(function* () { const harness = yield* makeHarness({ thread: makeThread([ - makeLink(3, { headBranch: "feat-c", baseBranch: "feat-b", source: "agent" }), - makeLink(1, { headBranch: "feat-a", baseBranch: "main", source: "created" }), + makeLink(3, { + headBranch: "feat-c", + baseBranch: "feat-b", + source: "agent", + snapshot: { + ...makeLink(3, { headBranch: "feat-c", baseBranch: "feat-b" }).snapshot!, + inMergeQueue: true, + }, + }), + makeLink(1, { + headBranch: "feat-a", + baseBranch: "main", + source: "created", + snapshot: { + ...makeLink(1, { headBranch: "feat-a", baseBranch: "main" }).snapshot!, + inMergeQueue: false, + }, + }), makeLink(2, { headBranch: "feat-b", baseBranch: "feat-a", source: "agent" }), makeLink(9, { source: "stack-dismissed" }), makeLink(10), @@ -623,15 +639,19 @@ describe("pull request toolkit handlers", () => { headBranch: "feat-c", baseBranch: "feat-b", isDraft: false, + inMergeQueue: true, stack: { kind: "derived", position: 3, size: 3 }, }); expect(result.pullRequests[3]).toMatchObject({ number: 10, state: null, + inMergeQueue: null, title: null, headBranch: null, stack: null, }); + expect(result.pullRequests[1]).toMatchObject({ number: 1, inMergeQueue: false }); + expect(result.pullRequests[2]).toMatchObject({ number: 2, inMergeQueue: null }); expect(result.chains).toEqual([ { kind: "derived", numbers: [1, 2, 3] }, { kind: "derived", numbers: [10] }, diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 7798ec11bff05..3072ab98b379c 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -142,6 +142,7 @@ function entryOf( headBranch: link.snapshot?.headBranch ?? null, baseBranch: link.snapshot?.baseBranch ?? null, isDraft: link.snapshot?.isDraft ?? null, + inMergeQueue: link.snapshot?.inMergeQueue ?? null, stack, }; } diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 789e9fe125235..6e3c96b6da967 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -278,6 +278,10 @@ export const ThreadPullRequestEntry = Schema.Struct({ headBranch: Schema.NullOr(Schema.String), baseBranch: Schema.NullOr(Schema.String), isDraft: Schema.NullOr(Schema.Boolean), + inMergeQueue: Schema.NullOr(Schema.Boolean).annotate({ + description: + "Last observed merge-queue membership. Null means unknown or unsupported; lifecycle state takes precedence after closure or merge.", + }), stack: Schema.NullOr( Schema.Struct({ kind: Schema.Literals(["native", "derived"]), diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 7739efd021ced..2d2a65d776420 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -535,45 +535,75 @@ describe("PullRequestSyncReactor", () => { }), ); - it.effect("retries a failed stack read after the summary becomes terminal", () => - Effect.scoped( - Effect.gen(function* () { - yield* TestClock.setTime(Date.parse(NOW)); - let attempts = 0; - const nativeStack: PullRequestStack = { - id: "stack", - number: 7, - url: "https://github.com/owner/repository/stacks/7", - base: "main", - layers: [{ number: 7, headBranch: "feature", state: "merged" }], - }; - const fixture = yield* makeHarness({ - snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(7)] })]), - summary: (input) => - Effect.succeed(makeSummary(input, { state: "merged", mergedAt: NOW })), - stack: () => - ++attempts === 1 - ? Effect.fail( - new PullRequestOperationError({ - operation: "stack", - detail: "temporary failure", - }), - ) - : Effect.succeed(nativeStack), - }); - yield* Effect.gen(function* () { - const reactor = yield* startAndSweep(fixture); - const commands = yield* Ref.get(fixture.syncCommands); - yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, commands)); - yield* sweepAgain(fixture, reactor); - assert.strictEqual(attempts, 2); - assert.deepStrictEqual((yield* Ref.get(fixture.syncCommands)).at(-1)?.stack, { - kind: "native", + it.effect.each([ + { change: "terminal state", state: "merged" as const, before: false, after: false }, + { change: "queue entry", state: "open" as const, before: false, after: true }, + { change: "queue exit", state: "open" as const, before: true, after: false }, + { change: "known queue membership", state: "open" as const, before: undefined, after: false }, + ])( + "preserves queue observations and retries failed stack reads for $change", + ({ state, before, after }) => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + let attempts = 0; + const nativeStack: PullRequestStack = { + id: "stack", + number: 7, + url: "https://github.com/owner/repository/stacks/7", + base: "main", + layers: [{ number: 7, headBranch: "feature", state }], + }; + const savedStack = { + kind: "native" as const, ...nativeStack, + layers: [{ number: 7, headBranch: "feature", state: "open" as const }], + }; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("one", { + pullRequests: [makeLink(7, { inMergeQueue: before }, { stack: savedStack })], + }), + ]), + summary: (input) => + Effect.succeed( + makeSummary(input, { + state, + inMergeQueue: after, + ...(state === "merged" ? { mergedAt: NOW } : {}), + }), + ), + stack: () => + ++attempts === 1 + ? Effect.fail( + new PullRequestOperationError({ + operation: "stack", + detail: "temporary failure", + }), + ) + : Effect.succeed(nativeStack), }); - }).pipe(Effect.provide(fixture.layer)); - }), - ), + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + const commands = yield* Ref.get(fixture.syncCommands); + assert.deepStrictEqual( + commands.map((command) => ({ + state: command.snapshot.state, + queued: command.snapshot.inMergeQueue, + stack: command.stack, + })), + state === "merged" ? [] : [{ state: "open", queued: after, stack: savedStack }], + ); + yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, commands)); + yield* sweepAgain(fixture, reactor); + assert.strictEqual(attempts, 2); + assert.deepStrictEqual((yield* Ref.get(fixture.syncCommands)).at(-1)?.stack, { + kind: "native", + ...nativeStack, + }); + }).pipe(Effect.provide(fixture.layer)); + }), + ), ); it.effect("explicit refresh reads a changed stack even when its PR summary is unchanged", () => @@ -605,7 +635,9 @@ describe("PullRequestSyncReactor", () => { const fixture = yield* makeHarness({ snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(42)] })]), summary: (input) => - Effect.succeed(makeSummary(input, { title: "Ship it", isDraft: true })), + Effect.succeed( + makeSummary(input, { title: "Ship it", isDraft: true, inMergeQueue: true }), + ), }); yield* Effect.gen(function* () { @@ -634,6 +666,7 @@ describe("PullRequestSyncReactor", () => { headBranch: "feature", baseBranch: "main", isDraft: true, + inMergeQueue: true, updatedAt: "2026-08-27T00:00:00.000Z", syncedAt: NOW, closedAt: null, @@ -794,7 +827,12 @@ describe("PullRequestSyncReactor", () => { yield* TestClock.setTime(Date.parse(NOW)); const fixture = yield* makeHarness({ snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(42)] })]), - summary: (input) => Effect.succeed(makeSummary(input, { updatedAt: NOW })), + summary: (input) => + Effect.sync(() => + makeSummary(input, { + updatedAt: NOW, + }), + ), }); yield* Effect.gen(function* () { @@ -1092,6 +1130,45 @@ describe("PullRequestSyncReactor", () => { }), ); + it.effect.each([ + { before: false, after: true }, + { before: true, after: false }, + { before: undefined, after: false }, + ])( + "syncs linked merge queue status $before to $after as soon as a reader reports a change", + ({ before, after }) => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const queued = yield* Ref.make(before); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("agent", { pullRequests: [makeLink(7, { inMergeQueue: before })] }), + ]), + summary: (input) => + Ref.get(queued).pipe( + Effect.map((value) => makeSummary(input, { inMergeQueue: value })), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + yield* Ref.set(queued, after); + yield* Queue.offer(fixture.stateChanges, { + host: "github.com", + repository: "owner/repository", + number: 7, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map((command) => command.snapshot.inMergeQueue), + [after], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ); + it.effect("preserves a refresh requested while an older host read is in flight", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index 2b3605e3a966e..657701d8a76cd 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -201,6 +201,7 @@ function snapshotFieldsOf(summary: PullRequestSummary): SnapshotFields { headBranch: summary.headBranch, baseBranch: summary.baseBranch, isDraft: summary.isDraft ?? false, + ...(summary.inMergeQueue === undefined ? {} : { inMergeQueue: summary.inMergeQueue }), updatedAt: summary.updatedAt, closedAt: summary.closedAt ?? null, mergedAt: summary.mergedAt ?? null, @@ -249,6 +250,7 @@ function snapshotFieldsEqual(left: SnapshotFields, right: SnapshotFields): boole left.headBranch === right.headBranch && left.baseBranch === right.baseBranch && left.isDraft === right.isDraft && + left.inMergeQueue === right.inMergeQueue && left.updatedAt === right.updatedAt && (left.closedAt ?? null) === (right.closedAt ?? null) && (left.mergedAt ?? null) === (right.mergedAt ?? null) && @@ -531,11 +533,9 @@ export const make = Effect.gen(function* () { }).pipe(Effect.as(null)), ), ); - if (needsStack) { - if (fetchedStack === null) { - retryStacks.add(key); - return; - } + if (needsStack && fetchedStack === null) { + retryStacks.add(key); + } else if (fetchedStack !== null) { const fetched = fetchedStack.stack; stacksReadThisSweep.set(key, fetched); // New upper layers leave older members' fields unchanged; differing layer lists need a stack read. @@ -563,8 +563,15 @@ export const make = Effect.gen(function* () { requested.delete(key); yield* Effect.forEach( entries, - (entry) => - syncEntry(entry, fields, fetchedStack, summary.provider).pipe( + (entry) => { + const nextFields = + needsStack && fetchedStack === null + ? entry.link.snapshot === null + ? null + : { ...entry.link.snapshot, inMergeQueue: fields.inMergeQueue } + : fields; + if (nextFields === null) return Effect.void; + return syncEntry(entry, nextFields, fetchedStack, summary.provider).pipe( persistence.withPermits(1), Effect.catchCause((cause) => { if (!Cause.hasInterruptsOnly(cause)) retryStacks.add(key); @@ -572,7 +579,8 @@ export const make = Effect.gen(function* () { cause, ); }), - ), + ); + }, { discard: true }, ); }, Effect.withSpan("PullRequestSyncReactor.syncGroup")); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 004e883784499..26148d5db496d 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2986,9 +2986,10 @@ it.effect( }), ); -it.effect("notifies linked threads when detail changes draft status in either direction", () => +it.effect("notifies linked threads when detail changes draft and queue status", () => Effect.gen(function* () { let isDraft = true; + let inMergeQueue = false; let updatedAt = "2026-07-02T00:00:00Z"; const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; const service = yield* makeService({ @@ -2996,7 +2997,12 @@ it.effect("notifies linked threads when detail changes draft status in either di providers: [ fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => - Effect.sync(() => ({ ...hostedChangeRequest("body"), isDraft, updatedAt })), + Effect.sync(() => ({ + ...hostedChangeRequest("body"), + isDraft, + inMergeQueue, + updatedAt, + })), }), ], }); @@ -3012,6 +3018,16 @@ it.effect("notifies linked threads when detail changes draft status in either di yield* service.invalidate({ reference }); yield* read(); + inMergeQueue = true; + updatedAt = "2026-07-03T00:00:00Z"; + yield* service.invalidate({ reference }); + yield* read(); + yield* read(); + inMergeQueue = false; + updatedAt = "2026-07-04T00:00:00Z"; + yield* service.invalidate({ reference }); + yield* read(); + isDraft = false; updatedAt = "2026-07-01T00:00:00Z"; yield* service.invalidate({ reference }); @@ -3026,11 +3042,13 @@ it.effect("notifies linked threads when detail changes draft status in either di { host: "github.com", repository: "acme/web", number: 1 }, { host: "github.com", repository: "acme/web", number: 1 }, { host: "github.com", repository: "acme/web", number: 1 }, + { host: "github.com", repository: "acme/web", number: 1 }, + { host: "github.com", repository: "acme/web", number: 1 }, ]); }), ); -it.effect("deduplicates routed PR reports without dropping draft changes", () => +it.effect("deduplicates routed PR reports without dropping draft or queue changes", () => Effect.gen(function* () { const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; const service = yield* makeService({ @@ -3043,13 +3061,17 @@ it.effect("deduplicates routed PR reports without dropping draft changes", () => } yield* service.reportState({ reference, state: "open" }); yield* service.reportState({ reference, state: "open" }); + yield* service.reportState({ reference, state: "open", inMergeQueue: true }); + yield* service.reportState({ reference, state: "open", inMergeQueue: true }); + yield* service.reportState({ reference, state: "open", inMergeQueue: false }); + yield* service.reportState({ reference, state: "open", inMergeQueue: false }); yield* service.reportState({ reference: { ...reference, number: 2 }, state: "open" }); const events = yield* Stream.runCollect( changes.pipe(Stream.takeUntil((event) => event.number === 2, { excludeLast: true })), ); assert.deepStrictEqual( events.map((event) => event.number), - [1, 1, 1, 1], + [1, 1, 1, 1, 1, 1], ); }), ); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index e77434c1697b3..c18be31da1c63 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -104,7 +104,7 @@ export interface PullRequestMergeEvent extends PullRequestRef { type PullRequestReading = Pick< PullRequestSummary, - "state" | "isDraft" | "updatedAt" | "observedAt" + "state" | "isDraft" | "inMergeQueue" | "updatedAt" | "observedAt" >; function supersedesReading(current: PullRequestReading | undefined, next: PullRequestReading) { @@ -1239,6 +1239,7 @@ export const make = Effect.gen(function* () { baseBranch: input.item.baseBranch, state: input.item.state, isDraft: input.item.isDraft, + ...(input.item.inMergeQueue === undefined ? {} : { inMergeQueue: input.item.inMergeQueue }), mergeability: input.item.mergeability, additions: input.item.additions, deletions: input.item.deletions, @@ -1681,6 +1682,9 @@ export const make = Effect.gen(function* () { updatedAt: changeRequest.updatedAt, observedAt, ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }), + ...(changeRequest.inMergeQueue === undefined + ? {} + : { inMergeQueue: changeRequest.inMergeQueue }), ...(changeRequest.author === undefined ? {} : { author: changeRequest.author }), ...(changeRequest.additions === undefined ? {} @@ -1770,6 +1774,9 @@ export const make = Effect.gen(function* () { author: changeRequest.author, state: changeRequest.state, isDraft: changeRequest.isDraft, + ...(changeRequest.inMergeQueue === undefined + ? {} + : { inMergeQueue: changeRequest.inMergeQueue }), mergeability: changeRequest.mergeability, additions: changeRequest.additions, deletions: changeRequest.deletions, @@ -1821,6 +1828,7 @@ export const make = Effect.gen(function* () { author: value.author, state: value.state, isDraft: value.isDraft, + ...(value.inMergeQueue === undefined ? {} : { inMergeQueue: value.inMergeQueue }), createdAt: value.createdAt, }); const previewUncached: PullRequestService["Service"]["preview"] = (input) => @@ -3147,6 +3155,7 @@ export const make = Effect.gen(function* () { url: detail.url, state: detail.state, isDraft: detail.isDraft, + ...(detail.inMergeQueue === undefined ? {} : { inMergeQueue: detail.inMergeQueue }), author: detail.author, additions: detail.additions, deletions: detail.deletions, @@ -3176,10 +3185,13 @@ export const make = Effect.gen(function* () { detailStates.set(scope, { state: next.state, isDraft: next.isDraft, + inMergeQueue: next.inMergeQueue, updatedAt: next.updatedAt, observedAt: next.observedAt, }); - return (current?.state !== next.state || current?.isDraft !== next.isDraft) && + return (current?.state !== next.state || + current?.isDraft !== next.isDraft || + current?.inMergeQueue !== next.inMergeQueue) && ref.host !== undefined ? PubSub.publish(stateChanges, { host: ref.host, @@ -3194,6 +3206,7 @@ export const make = Effect.gen(function* () { { readonly state: PullRequestState; readonly isDraft: boolean | undefined; + readonly inMergeQueue: boolean | undefined; readonly atMs: number; } >(); @@ -3201,6 +3214,7 @@ export const make = Effect.gen(function* () { reference, state, isDraft, + inMergeQueue, }) => Effect.all([canonicalRef(reference), Clock.currentTimeMillis]).pipe( Effect.flatMap(([ref, nowMs]) => @@ -3210,6 +3224,7 @@ export const make = Effect.gen(function* () { if ( reported?.state === state && reported.isDraft === isDraft && + reported.inMergeQueue === inMergeQueue && nowMs - reported.atMs < REPORTED_STATE_DEDUPE_MS ) return Effect.void; @@ -3218,7 +3233,7 @@ export const make = Effect.gen(function* () { const oldest = reportedStates.keys().next().value; if (oldest !== undefined) reportedStates.delete(oldest); } - reportedStates.set(scope, { state, isDraft, atMs: nowMs }); + reportedStates.set(scope, { state, isDraft, inMergeQueue, atMs: nowMs }); return ref.host === undefined ? Effect.void : PubSub.publish(stateChanges, { diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index a536f28ccfb12..629519aaa21cf 100644 --- a/apps/web/src/components/ContextChip.tsx +++ b/apps/web/src/components/ContextChip.tsx @@ -21,6 +21,7 @@ const contextChipVariants = cva( "preview-annotation": "[--context-chip-accent:var(--context-chip-preview-annotation)]", "review-comment": "[--context-chip-accent:var(--context-chip-review-comment)]", "pull-request": "[--context-chip-accent:var(--context-chip-pull-request)]", + "pr-queued": "[--context-chip-accent:var(--context-chip-pr-queued)]", "pr-open": "[--context-chip-accent:var(--context-chip-pr-open)]", "pr-draft": "[--context-chip-accent:var(--context-chip-pr-draft)]", "pr-merged": "[--context-chip-accent:var(--context-chip-pr-merged)]", diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 4ecce60109961..e43ad94b8f07b 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -1,3 +1,4 @@ +import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; import { AuthOrchestrationOperateScope, type EnvironmentId, @@ -119,7 +120,15 @@ export function PullRequestThreadDialog({ parsedReference !== parsedDebouncedReference || pullRequestResolution.isPending || pullRequestResolution.isFetching); - const statusTone = pullRequestStatusTone(resolvedPullRequest?.state); + const statusPresentation = + resolvedPullRequest === null + ? null + : resolvePullRequestState({ ...resolvedPullRequest, isDraft: false }); + + const statusTone = + resolvedPullRequest?.state === "open" && resolvedPullRequest.inMergeQueue + ? statusPresentation?.toneClassName + : pullRequestStatusTone(resolvedPullRequest?.state); const handleConfirm = async (mode: "local" | "worktree") => { if (!preparePullRequestThreadAction.isAllowed) return; @@ -251,7 +260,7 @@ export function PullRequestThreadDialog({

- {resolvedPullRequest.state} + {statusPresentation?.label} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 596bdee96ad1f..0067f3030fb3c 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -163,9 +163,13 @@ export interface PullRequestTabStatus { number: number; state: PullRequestState; isDraft: boolean; + inMergeQueue?: boolean | undefined; } -export type PullRequestTabStatusSeed = Pick; +export type PullRequestTabStatusSeed = Pick< + PullRequestTabStatus, + "state" | "isDraft" | "inMergeQueue" +>; function shouldOpenDefaultBrowserProfileFromMenuClick(pointerType: string | undefined): boolean { return pointerType !== "touch"; @@ -819,6 +823,7 @@ function PullRequestSurfaceIcon({ const presentation = resolvePullRequestState({ state: status.state, isDraft: status.isDraft ?? detail?.isDraft ?? seed?.isDraft ?? false, + inMergeQueue: status.inMergeQueue ?? detail?.inMergeQueue ?? seed?.inMergeQueue, }); return ; } diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index ea49b01665b64..e270f73a1fae5 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -141,6 +141,7 @@ export function linkedPullRequestSnapshotStatus( title: snapshot.title, state: snapshot.state, isDraft: snapshot.isDraft, + inMergeQueue: snapshot.inMergeQueue, headRef: snapshot.headBranch, baseRef: snapshot.baseBranch, ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }), @@ -339,7 +340,11 @@ export function ThreadPullRequestsMiniList({ const presentation = snapshot === null ? null - : resolvePullRequestState({ state: snapshot.state, isDraft: snapshot.isDraft }); + : resolvePullRequestState({ + state: snapshot.state, + isDraft: snapshot.isDraft, + inMergeQueue: snapshot.inMergeQueue, + }); return ( , "state"> & { readonly isDraft?: boolean | undefined; + readonly inMergeQueue?: boolean | undefined; readonly className?: string | undefined; }) { - const presentation = resolvePullRequestState({ state, isDraft }); + const presentation = resolvePullRequestState({ state, isDraft, inMergeQueue }); return ; } @@ -733,7 +744,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar /> } > - + @@ -817,6 +833,8 @@ function settledPrHoverColorClass(state: ThreadPullRequestBadge["state"]): strin return "group-hover/sidebar-row:text-zinc-500 dark:group-hover/sidebar-row:text-zinc-400/80"; case "open": return "group-hover/sidebar-row:text-emerald-600 dark:group-hover/sidebar-row:text-emerald-300/90"; + case "queued": + return "group-hover/sidebar-row:text-amber-600 dark:group-hover/sidebar-row:text-amber-400/80"; case "merged": return "group-hover/sidebar-row:text-violet-600 dark:group-hover/sidebar-row:text-violet-300/90"; case "closed": @@ -872,9 +890,11 @@ export function ThreadSearchPullRequestNumber({ ? badge.state : pr === null ? null - : pr.state === "open" && pr.isDraft - ? "draft" - : pr.state; + : pr.state === "open" && pr.inMergeQueue + ? "queued" + : pr.state === "open" && pr.isDraft + ? "draft" + : pr.state; return ( ({ status: "success" as PullRequestCheck["status"], extraStatus: null as PullRequestCheck["status"] | null, + lifecycle: "open", + inMergeQueue: false, mergeMethods: ["merge"] as Array<"merge" | "squash" | "rebase">, perform: vi.fn(), newThread: vi.fn(), @@ -32,7 +35,8 @@ const queryData = () => ({ url: "https://github.com/owner/repo/pull/1", number: 1, title: "Test PR", - state: "open", + state: state.lifecycle, + inMergeQueue: state.inMergeQueue, isDraft: false, mergeability: "mergeable", headBranch: "feature", @@ -177,6 +181,45 @@ it("requires a new merge click after passing checks become pending and pass agai expect(state.perform).not.toHaveBeenCalled(); }); +it("updates merge controls as mocked PR data enters and leaves the merge queue", async () => { + await using screen = await setup(); + const row = () => ( + + ); + await screen.rerender(row()); + await screen.getByRole("button", { name: "Merge", exact: true }).click(); + await expect.element(page.getByRole("alertdialog")).toBeVisible(); + + state.inMergeQueue = true; + await screen.rerender(row()); + await expect + .element(screen.getByRole("button", { name: "Merge", exact: true })) + .not.toBeInTheDocument(); + await expect.element(page.getByRole("alertdialog")).not.toBeInTheDocument(); + expect(state.perform).not.toHaveBeenCalled(); + + state.inMergeQueue = false; + await screen.rerender(row()); + await screen.getByRole("button", { name: "Merge", exact: true }).click(); + await expect.element(page.getByRole("alertdialog")).toBeVisible(); + + state.lifecycle = "merged"; + await screen.rerender(row()); + await expect + .element(screen.getByRole("button", { name: "Merge", exact: true })) + .not.toBeInTheDocument(); + await expect.element(page.getByRole("alertdialog")).not.toBeInTheDocument(); +}); + async function setup() { state.perform.mockReset(); state.checkoutError = null; @@ -190,6 +233,8 @@ async function setup() { state.status = "success"; state.extraStatus = null; + state.lifecycle = "open"; + state.inMergeQueue = false; state.mergeMethods = ["merge"]; useUiStateStore.getState().setPullRequestMergeMethod("merge"); return renderUi(null); diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx index 8c3f814427991..9f5db368502e2 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx @@ -226,6 +226,7 @@ export function ThreadDetailsPrRow({ : resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft, + inMergeQueue: detail.inMergeQueue, }); const icon = statePresentation ? ( ) : ( @@ -285,7 +287,7 @@ export function ThreadDetailsPrRow({ } />
- {describePullRequestChecks(detail.checks)} + PR checks: {describePullRequestChecks(detail.checks)}
) : null} diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 5f778ac6cac1e..9c6181e0bc147 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -363,6 +363,7 @@ export function useComposerMenuItems({ baseBranch: pullRequest.baseBranch, state: pullRequest.state, isDraft: pullRequest.isDraft, + inMergeQueue: pullRequest.inMergeQueue, }, label: `#${pullRequest.number}`, description: diff --git a/apps/web/src/components/contextChipParts.tsx b/apps/web/src/components/contextChipParts.tsx index a1112d037d908..6ccc7b646b8fb 100644 --- a/apps/web/src/components/contextChipParts.tsx +++ b/apps/web/src/components/contextChipParts.tsx @@ -4,7 +4,7 @@ import { useEffect, useState, type ComponentProps, type MouseEvent, type ReactNo import { useAssetUrlState } from "~/assets/assetUrls"; import { documentChipKindForFileName } from "~/pierre-icons"; -import { PULL_REQUEST_STATE_PRESENTATION } from "~/components/pullRequest/pullRequestIcons"; +import { resolvePullRequestState } from "~/components/pullRequest/pullRequestPresentation"; import type { PullRequestContextDisplayState } from "~/lib/composerContextRecords"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { middleTruncateAttachmentName } from "./composerInlineChip"; @@ -17,6 +17,7 @@ import { usePullRequestPreviewTarget } from "~/lib/openPullRequestLink"; export const PULL_REQUEST_CHIP_KINDS = { open: "pr-open", + queued: "pr-queued", draft: "pr-draft", merged: "pr-merged", closed: "pr-closed", @@ -95,9 +96,7 @@ export function PullRequestChip(props: { onOpen: (event: MouseEvent, url: string) => void; }) { const previewTarget = usePullRequestPreviewTarget(props.environmentId, props.metadata.url); - const displayState = - props.metadata.state === "open" && props.metadata.isDraft ? "draft" : props.metadata.state; - const StateIcon = PULL_REQUEST_STATE_PRESENTATION[displayState].Icon; + const StateIcon = resolvePullRequestState(props.metadata).Icon; const button = ( {variant === "count" ? ( <> + PR checks {checks !== undefined && incompleteCount > 0 ? ( - {checks.length - incompleteCount}/{checks.length} checks + {checks.length - incompleteCount}/{checks.length} ) : failedCount > 0 ? ( {failedCount} failed @@ -192,8 +193,8 @@ export function PullRequestChecksPopover({ ) : null} -

{presentation.label}

- {summary === null ? null :

{summary}

} +

PR checks

+

{summary ?? presentation.label}

{stale ? (

Check details are out of date. Refresh the pull request to update them. diff --git a/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx b/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx index f5a5b4ee77bd9..fc67c3e389100 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx @@ -381,7 +381,9 @@ export function PullRequestPrimaryAction({ : selectedMergeMethodLabel)} - ) : (primaryAction === "merged" || primaryAction === "closed") && + ) : (primaryAction === "queued" || + primaryAction === "merged" || + primaryAction === "closed") && statePresentation !== null ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 4c8e1e2acbcd6..630f8476e43ec 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -135,6 +135,7 @@ import { allowedPullRequestMergeMethods, allowsSinglePullRequestMerge, canDeletePullRequestBranch, + canPerformPullRequestAction, isPullRequestConflicting, resolveBaseFreshness, resolvePullRequestMergeMethod, @@ -985,6 +986,7 @@ export function PullRequestDetailPanel({ baseBranch: detail.baseBranch, state: detail.state, isDraft: detail.isDraft, + inMergeQueue: detail.inMergeQueue, }), }); }; @@ -1000,6 +1002,7 @@ export function PullRequestDetailPanel({ baseBranch: detail.baseBranch, state: detail.state, isDraft: detail.isDraft, + inMergeQueue: detail.inMergeQueue, }), }); }; @@ -1016,6 +1019,7 @@ export function PullRequestDetailPanel({ baseBranch: detail.baseBranch, state: detail.state, isDraft: detail.isDraft, + inMergeQueue: detail.inMergeQueue, stop: { index, title: stop.title, @@ -1044,6 +1048,7 @@ export function PullRequestDetailPanel({ baseBranch: detail.baseBranch, state: detail.state, isDraft: detail.isDraft, + inMergeQueue: detail.inMergeQueue, comment: selection.comment, request: selection.request, }), @@ -1128,9 +1133,7 @@ export function PullRequestDetailPanel({ detail?.state === "open" ? (detail.workflowApprovalsRequired ?? 0) : 0; const freshness = detail === null ? null : resolveBaseFreshness(detail); const visibleTabs = TABS.filter((item) => isTabAvailable(item.value)); - const can = (action: PullRequestAction) => - detail?.capabilities.actions.includes(action) === true && - detail.viewerPermissions.actions.includes(action); + const can = (action: PullRequestAction) => canPerformPullRequestAction(detail, action); const detailChecksState = detail ? pullRequestChecksState(detail.checks) : null; const latestChecksState = sharedSummary?.checksState === undefined ? detailChecksState : sharedSummary.checksState; @@ -1145,6 +1148,7 @@ export function PullRequestDetailPanel({ ? resolvePullRequestPrimaryControl({ state: detail.state, isDraft: detail.isDraft, + inMergeQueue: detail.inMergeQueue, mergeability: detail.mergeability, checksState, autoMergeEnabled: detail.autoMergeEnabled, @@ -1156,6 +1160,7 @@ export function PullRequestDetailPanel({ : null; const showsDraftToggle = detail?.state === "open" && + !detail.inMergeQueue && can(detail.isDraft ? "ready" : "draft") && !(detail.isDraft && primaryAction === "ready"); const showsAutoMerge = @@ -1183,7 +1188,11 @@ export function PullRequestDetailPanel({ !conflicting && allowedMergeMethods.length > 1; const statePresentation = detail - ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) + ? resolvePullRequestState({ + state: detail.state, + isDraft: detail.isDraft, + inMergeQueue: detail.inMergeQueue, + }) : null; const showsDeleteBranch = detail !== null && canDeletePullRequestBranch(detail); const environmentConnected = environments.some( @@ -1593,7 +1602,7 @@ export function PullRequestDetailPanel({ ) : ( ) } - label={checksSummary} + label={checksSummary ? `PR checks: ${checksSummary}` : null} /> ) ) : tab === "timeline" ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index c0f7b1959d9e0..614ee1e5157f8 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -106,6 +106,7 @@ export function PullRequestDetailGhost({ ...entry, ...summary, isDraft: summary.isDraft ?? entry?.isDraft, + inMergeQueue: summary.inMergeQueue ?? entry?.inMergeQueue, } : entry; const terminology = sourceControlClients.get(seed?.provider).changeRequest; @@ -113,6 +114,7 @@ export function PullRequestDetailGhost({ ? resolvePullRequestState({ state: seed.state, isDraft: seed.isDraft ?? false, + inMergeQueue: seed.inMergeQueue, }) : null; // Passing list rollups can omit workflows awaiting approval; wait for detail. diff --git a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx index f5ed4ea6553bf..99e28ec9b8032 100644 --- a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx +++ b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx @@ -90,7 +90,11 @@ export function PullRequestLinkPreview({ const state = preview === null ? null - : resolvePullRequestState({ state: preview.state, isDraft: preview.isDraft }); + : resolvePullRequestState({ + state: preview.state, + isDraft: preview.isDraft, + inMergeQueue: preview.inMergeQueue, + }); const authorLabel = preview?.author === null ? "ghost" diff --git a/apps/web/src/components/pullRequest/PullRequestListRow.tsx b/apps/web/src/components/pullRequest/PullRequestListRow.tsx index feec84fac0110..bde82d84ce5b1 100644 --- a/apps/web/src/components/pullRequest/PullRequestListRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListRow.tsx @@ -25,6 +25,7 @@ export const PULL_REQUEST_ROW_NUMBER_CLASS = export function PullRequestRowGlyph({ state, isDraft, + inMergeQueue, mergeability, baseBranch, below, @@ -32,6 +33,7 @@ export function PullRequestRowGlyph({ }: { state: PullRequestState; isDraft: boolean; + inMergeQueue?: boolean | undefined; mergeability?: PullRequestMergeability | undefined; baseBranch?: string | undefined; below?: ReactNode; @@ -40,7 +42,7 @@ export function PullRequestRowGlyph({ return ( - + {/* Offset the wrapper to keep the badge-sized tooltip trigger anchored. */} diff --git a/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx b/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx index 8ed8ba2d8ed33..acec5cef5390f 100644 --- a/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx +++ b/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx @@ -69,6 +69,7 @@ export function PullRequestStackMenu({ const mergeLayers = stack.layers.slice(0, position).filter((layer) => layer.state !== "merged"); const selectedLayer = stack.layers[position - 1]; const mergeHasClosed = mergeLayers.some((layer) => layer.state !== "open"); + const mergeHasQueued = mergeLayers.some((layer) => layer.inMergeQueue === true); const expectedStackHeads = unmerged.flatMap((layer) => layer.headSha ? [{ number: layer.number, headSha: layer.headSha }] : [], ); @@ -80,7 +81,8 @@ export function PullRequestStackMenu({ mergeLayers.some((layer) => !layer.headSha) || mergeHasClosed || mergeLayers.length === 0 || - mergeLayers.some((layer) => layer.isDraft); + mergeLayers.some((layer) => layer.isDraft) || + mergeHasQueued; const rebaseDisabled = pending || stale || hasUnknownHead || hasClosed || unmerged.length === 0; const run = async () => { if ( @@ -199,7 +201,11 @@ export function PullRequestStackMenu({ Rebase stack ) : null} - {mergeHasClosed || mergeLayers.some((layer) => layer.isDraft) ? ( + {mergeHasQueued ? ( +

+ A queued layer cannot be merged again. +

+ ) : mergeHasClosed || mergeLayers.some((layer) => layer.isDraft) ? (

Every layer being merged must be open and ready for review.

diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index 4b1ccae1597a0..6ac8a9ed54089 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -123,6 +123,7 @@ function LinkRowImpl({ number: link.number, state: snapshot.state, isDraft: snapshot.isDraft, + inMergeQueue: snapshot.inMergeQueue, ...(link.stack === null ? {} : { stack: link.stack }), } : null; @@ -149,6 +150,7 @@ function LinkRowImpl({ @@ -171,6 +173,7 @@ function LinkRowImpl({ baseBranch: snapshot.baseBranch, state: snapshot.state, isDraft: snapshot.isDraft, + inMergeQueue: snapshot.inMergeQueue, }), ) } diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 7c1cc47883f6f..d2447f424d4cd 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -205,6 +205,11 @@ describe("pull request primary control", () => { }), ).toBe("merge"); }); + + it("does not offer a redundant merge action while the host has queued the pull request", () => { + expect(resolvePullRequestPrimaryControl({ ...open, inMergeQueue: true })).toBe("queued"); + expect(resolvePullRequestPrimaryControl({ ...open, inMergeQueue: false })).toBe("merge"); + }); }); describe("stacked pull request classification", () => { @@ -1248,6 +1253,11 @@ describe("the compact row's single action slot", () => { openDetail({ checks: [check("failure"), check("pending")] }), ), ).toBe("fix"); + expect( + resolveThreadPanelPullRequestAction( + openDetail({ inMergeQueue: true, checks: [check("failure")] }), + ), + ).toBe("fix"); }); it("offers nothing the viewer may not do, and nothing on settled pull requests", () => { @@ -1261,6 +1271,8 @@ describe("the compact row's single action slot", () => { ), ).toBeNull(); expect(resolveThreadPanelPullRequestAction(openDetail({ state: "merged" }))).toBeNull(); + expect(resolveThreadPanelPullRequestAction(openDetail({ inMergeQueue: true }))).toBeNull(); + expect(resolveThreadPanelPullRequestAction(openDetail({ inMergeQueue: false }))).toBe("merge"); expect(resolveThreadPanelPullRequestAction(null)).toBeNull(); }); }); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 2a7dc7151b92d..ae276efab1b63 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -77,12 +77,14 @@ type PullRequestPrimaryControl = | "merge" | "enable-auto-merge" | "auto-merge-armed" + | "queued" | "merged" | "closed" | null; export function resolvePullRequestPrimaryControl(input: { readonly state: PullRequestState; + readonly inMergeQueue?: boolean | undefined; readonly isDraft: boolean; readonly mergeability: PullRequestMergeability; readonly checksState: ContractPullRequestChecksState | null; @@ -94,6 +96,7 @@ export function resolvePullRequestPrimaryControl(input: { }): PullRequestPrimaryControl { if (input.state === "merged") return "merged"; if (input.state === "closed") return "closed"; + if (input.inMergeQueue) return "queued"; if (input.mergeability === "conflicting") return "resolve"; if (input.isDraft) return input.canMarkReady ? "ready" : null; if (input.autoMergeEnabled) return "auto-merge-armed"; @@ -276,7 +279,13 @@ export function isStackedPullRequestBase( type PullRequestActionableDetail = Pick< PullRequestDetail, - "state" | "isDraft" | "mergeability" | "capabilities" | "viewerPermissions" | "mergeCapabilities" + | "state" + | "isDraft" + | "inMergeQueue" + | "mergeability" + | "capabilities" + | "viewerPermissions" + | "mergeCapabilities" >; export function allowedPullRequestMergeMethods( @@ -287,12 +296,16 @@ export function allowedPullRequestMergeMethods( : detail.capabilities.mergeMethods.filter((method) => detail.mergeCapabilities[method]); } -function canPerformPullRequestAction( - detail: Pick | null, +export function canPerformPullRequestAction( + detail: Pick< + PullRequestActionableDetail, + "capabilities" | "viewerPermissions" | "inMergeQueue" + > | null, action: PullRequestAction, ): boolean { return ( detail !== null && + !(detail.inMergeQueue && (action === "merge" || action === "enable-auto-merge")) && detail.capabilities.actions.includes(action) && detail.viewerPermissions.actions.includes(action) ); @@ -1042,6 +1055,7 @@ function pullRequestContextComment( readonly baseBranch: string; readonly state: PullRequestState; readonly isDraft: boolean; + readonly inMergeQueue?: boolean | undefined; }, instructions: ReadonlyArray, ): ReviewCommentContext { @@ -1069,6 +1083,7 @@ function pullRequestContextComment( baseBranch: boundedField(input.baseBranch), state: input.state, isDraft: input.isDraft, + inMergeQueue: input.inMergeQueue, }, }; } @@ -1094,6 +1109,7 @@ export function buildAskAboutPullRequestHandoff(input: { readonly baseBranch: string; readonly state: PullRequestState; readonly isDraft: boolean; + readonly inMergeQueue?: boolean | undefined; }): FixFindingsHandoff { return { prompt: "", @@ -1109,6 +1125,7 @@ export function buildExplainPullRequestHandoff(input: { readonly baseBranch: string; readonly state: PullRequestState; readonly isDraft: boolean; + readonly inMergeQueue?: boolean | undefined; }): FixFindingsHandoff { const term = terminologyForUrl(input.url).singular; return { @@ -1130,6 +1147,7 @@ export function buildAddSelectionToAgentHandoff(input: { readonly baseBranch: string; readonly state: PullRequestState; readonly isDraft: boolean; + readonly inMergeQueue?: boolean | undefined; readonly comment: ReviewCommentContext; readonly request: string; }): FixFindingsHandoff { @@ -1147,6 +1165,7 @@ export function buildAskAboutGuideStopHandoff(input: { readonly baseBranch: string; readonly state: PullRequestState; readonly isDraft: boolean; + readonly inMergeQueue?: boolean | undefined; readonly stop: { readonly index: number; readonly title: string; diff --git a/apps/web/src/components/pullRequest/pullRequestIcons.tsx b/apps/web/src/components/pullRequest/pullRequestIcons.tsx index 2f1a60a8c1eb2..b801a5be8b077 100644 --- a/apps/web/src/components/pullRequest/pullRequestIcons.tsx +++ b/apps/web/src/components/pullRequest/pullRequestIcons.tsx @@ -3,6 +3,7 @@ import { GitPullRequestArrowIcon, GitPullRequestClosedIcon, GitPullRequestDraftIcon, + CircleDashedIcon, LayersIcon, Link2Icon, Unlink2Icon, @@ -14,6 +15,7 @@ export const PullRequestGlyph = { pullRequest: GitPullRequestArrowIcon, reopen: GitPullRequestArrowIcon, draft: GitPullRequestDraftIcon, + queued: CircleDashedIcon, closed: GitPullRequestClosedIcon, merged: GitMergeIcon, conflicting: TriangleAlertIcon, @@ -41,6 +43,11 @@ export const PULL_REQUEST_STATE_PRESENTATION = { toneClassName: "text-zinc-500 dark:text-zinc-400/80", Icon: PullRequestGlyph.draft, }, + queued: { + label: "Queued", + toneClassName: "text-amber-600 dark:text-amber-400/80", + Icon: PullRequestGlyph.queued, + }, closed: { label: "Closed", toneClassName: "text-red-600 dark:text-red-300/90", @@ -51,4 +58,4 @@ export const PULL_REQUEST_STATE_PRESENTATION = { toneClassName: "text-violet-600 dark:text-violet-300/90", Icon: PullRequestGlyph.merged, }, -} as const satisfies Record; +} as const satisfies Record; diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 1ed83fba3dd96..b78f6918aa567 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -669,6 +669,12 @@ describe("pull request quick actions", () => { "close", "ready", ]); + expect( + pullRequestQuickActions({ state: "open", isDraft: false, inMergeQueue: true }, github), + ).toEqual(["close"]); + expect( + pullRequestQuickActions({ state: "open", isDraft: false, inMergeQueue: false }, github), + ).toContain("merge"); const gitlab = actionsOf({ host: "gitlab.com", provider: SourceControlProviderKind.make("gitlab"), diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index b3e447a82e598..201bf648219b7 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -915,7 +915,7 @@ export function pullRequestHostActions( } export function pullRequestQuickActions( - entry: Pick, + entry: Pick, hostActions: ReadonlySet, ): ReadonlyArray { const forState: ReadonlyArray = @@ -926,7 +926,9 @@ export function pullRequestQuickActions( : entry.isDraft ? ["close", "ready"] : ["close", "merge"]; - return forState.filter((action) => hostActions.has(action)); + return forState.filter( + (action) => hostActions.has(action) && !(entry.inMergeQueue && action === "merge"), + ); } export function largestPullRequestReadCount( diff --git a/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts b/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts index 9a3bda738a287..f0620b569c501 100644 --- a/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts @@ -80,6 +80,7 @@ export function pullRequestGroups({ { state: entry.state, isDraft: entry.isDraft, + inMergeQueue: entry.inMergeQueue, }, ]), ); diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 3f1b6d499ce47..9ae9b4c9426e8 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -104,8 +104,16 @@ export function PullRequestReviewDecisionGlyph({ export function resolvePullRequestState(input: { readonly state: PullRequestState; readonly isDraft: boolean; + readonly inMergeQueue?: boolean | undefined; }): PullRequestStatePresentation { - const key = input.state === "open" && input.isDraft ? "draft" : input.state; + const key = + input.state === "merged" || input.state === "closed" + ? input.state + : input.inMergeQueue + ? "queued" + : input.state === "open" && input.isDraft + ? "draft" + : input.state; return PULL_REQUEST_STATE_PRESENTATION[key]; } @@ -134,13 +142,15 @@ function resolvePullRequestConflict(input: { export function PullRequestStateGlyph({ state, isDraft, + inMergeQueue, className, }: { state: PullRequestState; isDraft: boolean; + inMergeQueue?: boolean | undefined; className?: string; }) { - const presentation = resolvePullRequestState({ state, isDraft }); + const presentation = resolvePullRequestState({ state, isDraft, inMergeQueue }); return ( }> diff --git a/apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts index 4b6f947232b38..537b557983c1a 100644 --- a/apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts @@ -79,9 +79,41 @@ describe("saved stack navigation", () => { pullRequestStackView({ ...query, isPending: false, error: "Rate limited" }, saved), ).toMatchObject({ data: saved, isFresh: false, notice: expect.stringContaining("stale") }); }); + it("keeps the linked layer's queue status on a refreshed stack and clears it after dequeue", () => { + const queued = savedPullRequestStack( + [{ ...link, snapshot: { ...link.snapshot!, inMergeQueue: true } }], + reference, + ); + if (queued === null) throw new Error("The linked pull request should have a saved stack."); + const refreshedStack = { + ...queued, + layers: queued.layers.map((layer) => { + const { inMergeQueue: _inMergeQueue, ...serverLayer } = layer; + return serverLayer; + }), + }; + expect( + pullRequestStackView( + { data: refreshedStack, isSuccess: true, isPending: false, error: null }, + queued, + ).data?.layers.find((layer) => layer.number === 2)?.inMergeQueue, + ).toBe(true); + + const dequeued = savedPullRequestStack( + [{ ...link, snapshot: { ...link.snapshot!, inMergeQueue: false } }], + reference, + ); + expect( + pullRequestStackView( + { data: refreshedStack, isSuccess: true, isPending: false, error: null }, + dequeued, + ).data?.layers.find((layer) => layer.number === 2)?.inMergeQueue, + ).toBe(false); + }); it("prefers refreshed data and honors a successful absence", () => { const saved = savedPullRequestStack([link], reference); const query = { data: saved, isSuccess: true, isPending: false, error: null }; + expect(pullRequestStackView(query, saved).data).toBe(saved); expect(pullRequestStackView(query, null)).toEqual({ data: saved, isFresh: true, notice: null }); expect(pullRequestStackView({ ...query, data: null }, saved)).toEqual({ data: null, diff --git a/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts index f2f4f8f2de9a3..5b62bf1df16fa 100644 --- a/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts +++ b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts @@ -41,7 +41,13 @@ export function savedPullRequestStack( )[0]?.snapshot; return { ...layer, - ...(snapshot ? { title: snapshot.title, isDraft: snapshot.isDraft } : {}), + ...(snapshot + ? { + title: snapshot.title, + isDraft: snapshot.isDraft, + inMergeQueue: snapshot.inMergeQueue, + } + : {}), }; }), }; @@ -57,8 +63,28 @@ export function pullRequestStackView( saved: PullRequestStack | null, ) { const data = query.isSuccess ? query.data : (query.data ?? saved); + const dataWithQueueStatus = + data === null || + saved === null || + !data.layers.some( + (layer) => + layer.inMergeQueue === undefined && + saved.layers.some( + (candidate) => candidate.number === layer.number && candidate.inMergeQueue !== undefined, + ), + ) + ? data + : { + ...data, + layers: data.layers.map((layer) => { + const savedLayer = saved.layers.find((candidate) => candidate.number === layer.number); + return layer.inMergeQueue !== undefined || savedLayer?.inMergeQueue === undefined + ? layer + : { ...layer, inMergeQueue: savedLayer.inMergeQueue }; + }), + }; return { - data, + data: dataWithQueueStatus, isFresh: query.isSuccess, notice: data === null diff --git a/apps/web/src/components/pullRequest/usePullRequestDetail.ts b/apps/web/src/components/pullRequest/usePullRequestDetail.ts index 7451ee38c5fee..bc6529753b735 100644 --- a/apps/web/src/components/pullRequest/usePullRequestDetail.ts +++ b/apps/web/src/components/pullRequest/usePullRequestDetail.ts @@ -127,6 +127,7 @@ export function usePullRequestDetail( ? resolvedCoreDetail.mergedAt : sharedSummary.mergedAt, isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft, + inMergeQueue: sharedSummary.inMergeQueue ?? resolvedCoreDetail.inMergeQueue, }; const detail = coreDetail === null diff --git a/apps/web/src/lib/composerContextRecords.test.ts b/apps/web/src/lib/composerContextRecords.test.ts index f612a302ac8c8..df1af7041dc0b 100644 --- a/apps/web/src/lib/composerContextRecords.test.ts +++ b/apps/web/src/lib/composerContextRecords.test.ts @@ -347,7 +347,7 @@ describe("composerContextRecords", () => { }); describe("pullRequestContextKindLabel", () => { - const comment = (url: string, isDraft: boolean) => ({ + const comment = (url: string, isDraft: boolean, inMergeQueue = false) => ({ id: "pull-request-context:7", sectionId: "pull-request:7", sectionTitle: "PR #7", @@ -365,6 +365,7 @@ describe("pullRequestContextKindLabel", () => { baseBranch: "main", state: "open" as const, isDraft, + inMergeQueue, }, }); @@ -375,6 +376,8 @@ describe("pullRequestContextKindLabel", () => { expect( pullRequestContextKindLabel(comment("https://gitlab.com/acme/app/-/merge_requests/7", true)), ).toBe("Draft merge request"); + const queued = comment("https://github.com/acme/app/pull/7", false, true); + expect(pullRequestContextKindLabel(queued)).toBe("Queued pull request"); }); }); diff --git a/apps/web/src/lib/composerContextRecords.ts b/apps/web/src/lib/composerContextRecords.ts index c4f123f73dcc4..bf123b7e0b753 100644 --- a/apps/web/src/lib/composerContextRecords.ts +++ b/apps/web/src/lib/composerContextRecords.ts @@ -88,14 +88,16 @@ function pullRequestContextNumber(comment: ReviewCommentPresentation): number | return legacyNumber === undefined ? null : Number(legacyNumber); } -export type PullRequestContextDisplayState = "open" | "draft" | "merged" | "closed"; +export type PullRequestContextDisplayState = "open" | "draft" | "merged" | "closed" | "queued"; export function pullRequestContextDisplayState( comment: ReviewCommentPresentation, ): PullRequestContextDisplayState | null { const pullRequest = comment.pullRequest; if (pullRequest === undefined) return null; - return pullRequest.state === "open" && pullRequest.isDraft ? "draft" : pullRequest.state; + if (pullRequest.state !== "open") return pullRequest.state; + if (pullRequest.inMergeQueue) return "queued"; + return pullRequest.isDraft ? "draft" : "open"; } export function pullRequestContextKindLabel(comment: ReviewCommentPresentation): string { diff --git a/apps/web/src/lib/contextChipAccents.ts b/apps/web/src/lib/contextChipAccents.ts index dfa0276ee08a7..aaddb09c06519 100644 --- a/apps/web/src/lib/contextChipAccents.ts +++ b/apps/web/src/lib/contextChipAccents.ts @@ -8,6 +8,7 @@ export const CONTEXT_CHIP_ACCENTS = { "preview-annotation": "oklch(0.62 0.134 70)", "review-comment": "oklch(0.62 0.16 292)", "pull-request": "oklch(0.62 0.16 277)", + "pr-queued": "oklch(0.62 0.134 70)", "pr-open": "oklch(0.62 0.134 163)", "pr-draft": "oklch(0.62 0.02 259)", "pr-merged": "oklch(0.62 0.16 292)", diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index c8ac23ced0584..47e20c10f0461 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -75,6 +75,7 @@ export function pullRequestListEntryToSummary(entry: PullRequestListEntry): Pull url: entry.url, state: entry.state, isDraft: entry.isDraft, + inMergeQueue: entry.inMergeQueue, headBranch: entry.headBranch, baseBranch: entry.baseBranch, updatedAt: entry.updatedAt, @@ -117,6 +118,7 @@ export function newestPullRequestObservation( summary: { ...incoming.summary, isDraft: incoming.summary.isDraft ?? current.summary.isDraft, + inMergeQueue: incoming.summary.inMergeQueue ?? current.summary.inMergeQueue, mergeability: incoming.summary.mergeability ?? current.summary.mergeability, reviewDecision: incoming.summary.reviewDecision === undefined diff --git a/evals/mcp-tool-use/cases/merge-queue.ndjson b/evals/mcp-tool-use/cases/merge-queue.ndjson new file mode 100644 index 0000000000000..c3f121a9d6094 --- /dev/null +++ b/evals/mcp-tool-use/cases/merge-queue.ndjson @@ -0,0 +1 @@ +{"id":"linked-pr-queue-state","area":"pull-requests","kind":"guard","pattern":"distinguish queued PRs from unknown queue membership","experience":"The linked-PR tool omitted queue membership although the UI displayed Queued.","source":{"pr":"https://github.com/pingdotgg/t3code/pull/28884"},"privacy":"commit-safe","setup":{"role":"top","mocks":{"list_thread_pull_requests":{"pullRequests":[{"host":"github.com","repository":"acme/app","number":7,"url":"https://github.com/acme/app/pull/7","source":"manual","watching":false,"state":"open","title":"Example change 7","headBranch":"feature-7","baseBranch":"main","isDraft":false,"inMergeQueue":true,"stack":null},{"host":"github.com","repository":"acme/app","number":8,"url":"https://github.com/acme/app/pull/8","source":"manual","watching":false,"state":"open","title":"Example change 8","headBranch":"feature-8","baseBranch":"main","isDraft":false,"inMergeQueue":false,"stack":null},{"host":"github.com","repository":"acme/app","number":9,"url":"https://github.com/acme/app/pull/9","source":"manual","watching":false,"state":"open","title":"Example change 9","headBranch":"feature-9","baseBranch":"main","isDraft":false,"inMergeQueue":null,"stack":null}],"chains":[{"kind":"derived","numbers":[7]},{"kind":"derived","numbers":[8]},{"kind":"derived","numbers":[9]}]}}},"prompt":"List the linked PRs and report their last known lifecycle and merge-queue membership. Use only the thread snapshots. Do not query GitHub, start watching, or change anything.","expect":{"calls":[{"tool":"list_thread_pull_requests"}],"notCalls":["watch_pull_request","link_pull_request","unlink_pull_request"],"textMust":["(?:^|\\n)[^\\w\\n]*(?:[pP][rR][ \\t]*[^\\w\\n]*)?7\\b(?:(?!\\b[nN][oO][tT]\\b)[^\\n])*?\\b(?:[qQ][uU][eE][uU][eE][dD]|[iI][nN][MM][eE][rR][gG][eE][QQ][uU][eE][uU][eE]\\b[^\\w\\n]*[tT][rR][uU][eE])\\b","(?:^|\\n)[^\\w\\n]*(?:[pP][rR][ \\t]*[^\\w\\n]*)?8\\b(?:(?!\\b[nN][oO][tT]\\b)[^\\n])*?\\b(?:[nN][oO][tT][ \\t]+(?:(?:[cC][uU][rR][rR][eE][nN][tT][lL][yY]|[iI][nN]|[tT][hH][eE]|[mM][eE][rR][gG][eE])[ \\t]+)*[qQ][uU][eE][uU][eE](?:[dD])?|[fF][aA][lL][sS][eE])\\b","(?:^|\\n)[^\\w\\n]*(?:[pP][rR][ \\t]*[^\\w\\n]*)?9\\b(?:(?!\\b[nN][oO][tT]\\b)[^\\n])*?\\b(?:[uU][nN][kK][nN][oO][wW][nN]|[iI][nN][MM][eE][rR][gG][eE][QQ][uU][eE][uU][eE]\\b[^\\w\\n]*[nN][uU][lL][lL]|[uU][nN][aA][vV][aA][iI][lL][aA][bB][lL][eE]|[nN][oO][tT][ \\t]+[kK][nN][oO][wW][nN])\\b"]},"maxSteps":5} diff --git a/evals/mcp-tool-use/run.test.ts b/evals/mcp-tool-use/run.test.ts index ff65511a9cc7d..200aea841635c 100644 --- a/evals/mcp-tool-use/run.test.ts +++ b/evals/mcp-tool-use/run.test.ts @@ -527,6 +527,60 @@ it("stops a sample only once a must-not check has failed", () => { }); const decodeDeviceCase = Schema.decodeSync(Schema.fromJsonString(McpToolUseCase)); +it.each([ + { number: 7, text: "7: queued", pass: true }, + { number: 7, text: "7: Queued", pass: true }, + { number: 7, text: "7: inMergeQueue: true", pass: true }, + { number: 7, text: "7: `inMergeQueue`: `TRUE`", pass: true }, + { number: 7, text: "7: isDraft: true, inMergeQueue: false", pass: false }, + { number: 7, text: "7: inMergeQueue: Not true", pass: false }, + { number: 7, text: "7: inMergeQueue: false\n8: inMergeQueue: true", pass: false }, + { number: 7, text: "pr #7: QUEUED", pass: true }, + { number: 7, text: "7: Not queued", pass: false }, + { number: 7, text: "7: NOT QUEUED", pass: false }, + { number: 7, text: "- **PR #7**: open, queued", pass: true }, + { number: 7, text: "| #7 | open | queued |", pass: true }, + { number: 7, text: "7: not queued\n8: queued", pass: false }, + { number: 7, text: "7: open\n8: queued", pass: false }, + { number: 7, text: "17: queued\n7: not queued", pass: false }, + { number: 8, text: "8: not queued", pass: true }, + { number: 8, text: "8: Not queued", pass: true }, + { number: 8, text: "8: NOT QUEUED", pass: true }, + { number: 8, text: "pr #8: NOT IN THE MERGE QUEUE", pass: true }, + { number: 8, text: "8: FALSE", pass: true }, + { number: 8, text: "8: Not false", pass: false }, + { number: 8, text: "- **PR #8**: open, not in the merge queue", pass: true }, + { number: 8, text: "| #8 | open | false |", pass: true }, + { number: 8, text: "7: queued\n8: queued\n9: not queued\n10: unknown", pass: false }, + { number: 8, text: "18: not queued\n8: queued", pass: false }, + { number: 9, text: "9: unknown", pass: true }, + { number: 9, text: "9: Unknown", pass: true }, + { number: 9, text: "9: inMergeQueue: null", pass: true }, + { number: 9, text: "9: `inMergeQueue`: `NULL`", pass: true }, + { number: 9, text: "9: inMergeQueue: true, stack: null", pass: false }, + { number: 9, text: "9: inMergeQueue: Not null", pass: false }, + { number: 9, text: "9: inMergeQueue: false\n10: inMergeQueue: null", pass: false }, + { number: 9, text: "pr #9: NOT KNOWN", pass: true }, + { number: 9, text: "9: Not unknown", pass: false }, + { number: 9, text: "9: NOT UNKNOWN", pass: false }, + { number: 9, text: "| #9 | open | not known |", pass: true }, + { number: 9, text: "9: not queued\n10: unknown", pass: false }, + { number: 9, text: "19: unknown\n9: queued", pass: false }, + { number: 9, text: "9: not unknown", pass: false }, +])("grades queue membership on PR $number's own line: $text", ({ number, text, pass }) => { + const testCase = decodeDeviceCase( + NodeFS.readFileSync(new URL("./cases/merge-queue.ndjson", import.meta.url), "utf8").trim(), + ); + const checks = gradeSample( + { textMust: testCase.expect.textMust?.slice(number - 7, number - 6) }, + { calls: [], text, stopReason: "end_turn" }, + ); + assert.deepEqual( + checks.map((check) => check.pass), + [pass], + ); +}); + const deviceCases = () => NodeFS.readFileSync(new URL("./cases/device-manager.ndjson", import.meta.url), "utf8") .trim() diff --git a/packages/client-runtime/src/state/pullRequestRouting.ts b/packages/client-runtime/src/state/pullRequestRouting.ts index ef146fb1f30f2..eb6fb6668c7b2 100644 --- a/packages/client-runtime/src/state/pullRequestRouting.ts +++ b/packages/client-runtime/src/state/pullRequestRouting.ts @@ -62,7 +62,11 @@ const writes = new Set([ ]); const isRef = Schema.is(PullRequestRef); const hasState = Schema.is( - Schema.Struct({ state: PullRequestState, isDraft: Schema.optional(Schema.Boolean) }), + Schema.Struct({ + state: PullRequestState, + isDraft: Schema.optional(Schema.Boolean), + inMergeQueue: Schema.optional(Schema.Boolean), + }), ); const isInvalidation = Schema.is(PullRequestInvalidateInput); const readTimeout = (environmentId: EnvironmentId) => @@ -226,12 +230,17 @@ const invalidateTarget = Effect.fn("PullRequestRouting.invalidateTarget")(functi */ const reportState = ( { allowStale: _allowStale, expectedAccountId: _expectedAccountId, ...reference }: PullRequestRef, - status: { readonly state: PullRequestState; readonly isDraft?: boolean | undefined }, + status: { + readonly state: PullRequestState; + readonly isDraft?: boolean | undefined; + readonly inMergeQueue?: boolean | undefined; + }, ) => request(WS_METHODS.pullRequestsReportState, { reference, state: status.state, ...(status.isDraft === undefined ? {} : { isDraft: status.isDraft }), + ...(status.inMergeQueue === undefined ? {} : { inMergeQueue: status.inMergeQueue }), }).pipe(Effect.timeoutOption("5 seconds"), Effect.ignore, Effect.forkDetach, Effect.asVoid); /** Credentials stay on their environments. Only a verified host and account cross the wire. */ diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 2564a3da21d0a..6bd074132e6c7 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -845,6 +845,7 @@ it.live.each([undefined, true, false])( title: "github title", state: "closed", ...(isDraft === undefined ? {} : { isDraft }), + inMergeQueue: true, updatedAt: "2026-10-10T01:52:36Z", }) : Effect.never, @@ -872,12 +873,14 @@ it.live.each([undefined, true, false])( title: "github title", state: "closed", ...(isDraft === undefined ? {} : { isDraft }), + inMergeQueue: true, updatedAt: "2026-10-10T01:52:36Z", }); expect(yield* Deferred.await(noted)).toEqual({ reference: { projectId: "project-1", repository: "acme/web", number: 7 }, state: "closed", ...(isDraft === undefined ? {} : { isDraft }), + inMergeQueue: true, }); }), ), @@ -1010,7 +1013,7 @@ it.effect("keeps concurrent diff file reads on different hosts separate", () => ), ); -it.effect.each(["closed", "draft", "permission", "stack", "method"] as const)( +it.effect.each(["closed", "draft", "queued", "permission", "stack", "method"] as const)( "rejects an unsafe quick merge with %s and keeps later actions running", (reason) => Effect.scoped( @@ -1021,6 +1024,7 @@ it.effect.each(["closed", "draft", "permission", "stack", "method"] as const)( Effect.succeed({ state: reason === "closed" ? "closed" : "open", isDraft: reason === "draft", + inMergeQueue: reason === "queued", capabilities: { actions: ["merge"], stackActions: true }, viewerPermissions: { actions: reason === "permission" ? [] : ["merge"] }, }), diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 1785af6fd384f..38e0771e87469 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -193,6 +193,7 @@ export function pullRequestDetailToVcsStatus( headRef: detail.headBranch, state: detail.state, ...(detail.isDraft === true ? { isDraft: true } : {}), + ...(detail.inMergeQueue === undefined ? {} : { inMergeQueue: detail.inMergeQueue }), updatedAt: detail.updatedAt, }; } @@ -307,6 +308,7 @@ export function createPullRequestEnvironmentAtoms( if ( detail.state !== "open" || detail.isDraft || + detail.inMergeQueue === true || !detail.capabilities.actions.includes("merge") || !detail.viewerPermissions.actions.includes("merge") ) { diff --git a/packages/client-runtime/src/threadPullRequests.test.ts b/packages/client-runtime/src/threadPullRequests.test.ts index 9867d776b0706..9b3980c33e0cb 100644 --- a/packages/client-runtime/src/threadPullRequests.test.ts +++ b/packages/client-runtime/src/threadPullRequests.test.ts @@ -406,6 +406,29 @@ describe("chain selection and badge state", () => { expect(resolveThreadPullRequestBadge([link(3, { source: "stack-dismissed" })])).toBeNull(); }); + it("shows queued only when every open stack layer is queued", () => { + const queuedBottom = link(1, { + snapshot: snapshot({ state: "open", inMergeQueue: true, headBranch: "base" }), + }); + const queuedTop = link(2, { + snapshot: snapshot({ state: "open", inMergeQueue: true, baseBranch: "base" }), + }); + expect(resolveThreadPullRequestBadge([queuedBottom, queuedTop])?.state).toBe("queued"); + expect( + resolveThreadPullRequestBadge([ + { ...queuedBottom, snapshot: snapshot({ state: "merged", headBranch: "base" }) }, + queuedTop, + ])?.state, + ).toBe("queued"); + expect( + resolveThreadPullRequestBadge([ + queuedBottom, + { ...queuedTop, snapshot: snapshot({ baseBranch: "base", inMergeQueue: false }) }, + ])?.state, + ).toBe("open"); + expect(resolveThreadPullRequestBadge([queuedBottom, link(3)])?.state).toBe("open"); + }); + it("keeps branch matching case-sensitive while ignoring repository case", () => { const bottom = link(1, { repository: "PingDotGG/T3code", diff --git a/packages/contracts/src/composerContext.test.ts b/packages/contracts/src/composerContext.test.ts index 6dd1da05ce582..63f725ee37096 100644 --- a/packages/contracts/src/composerContext.test.ts +++ b/packages/contracts/src/composerContext.test.ts @@ -100,6 +100,7 @@ const knownRecords: Record<(typeof COMPOSER_CONTEXT_KINDS)[number], Record { }); }); + it("preserves queue membership in pull request context", () => { + expect(Option.getOrThrow(decodeRecord(knownRecords["review-comment"]))).toEqual( + knownRecords["review-comment"], + ); + }); + it("does not let a malformed known kind slide through as unknown", () => { expect(Option.isNone(decodeRecord({ ...base, kind: "image", label: "x" }))).toBe(true); }); diff --git a/packages/contracts/src/composerContext.ts b/packages/contracts/src/composerContext.ts index 834dbd6821323..daa0284fe7adb 100644 --- a/packages/contracts/src/composerContext.ts +++ b/packages/contracts/src/composerContext.ts @@ -76,6 +76,7 @@ export const PullRequestContextMetadata = Schema.Struct({ baseBranch: ShortString, state: Schema.Literals(["open", "closed", "merged"]), isDraft: Schema.Boolean, + inMergeQueue: Schema.optional(Schema.Boolean), }); export type PullRequestContextMetadata = typeof PullRequestContextMetadata.Type; diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 0dc958f59c490..f2457a75d37fa 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -103,6 +103,7 @@ const GitResolvedPullRequest = Schema.Struct({ baseBranch: TrimmedNonEmptyStringSchema, headBranch: TrimmedNonEmptyStringSchema, state: GitPullRequestState, + inMergeQueue: Schema.optional(Schema.Boolean), }); export type GitResolvedPullRequest = typeof GitResolvedPullRequest.Type; @@ -210,6 +211,7 @@ const VcsStatusChangeRequest = Schema.Struct({ headRef: TrimmedNonEmptyStringSchema, state: VcsStatusChangeRequestState, isDraft: Schema.optional(Schema.Boolean), + inMergeQueue: Schema.optional(Schema.Boolean), updatedAt: Schema.optional(Schema.NullOr(Schema.String)), }); diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 64111ab4742b1..0d1a7f5ec63b4 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -330,6 +330,7 @@ export const PullRequestListEntry = Schema.Struct({ baseBranch: TrimmedNonEmptyString, state: PullRequestState, isDraft: Schema.Boolean, + inMergeQueue: Schema.optional(Schema.Boolean), mergeability: PullRequestMergeability, /** Zero until fetched through `PullRequestListStatsInput`; GitHub listings omit counts. */ additions: NonNegativeInt, @@ -447,6 +448,7 @@ export const PullRequestPreview = Schema.Struct({ author: Schema.NullOr(PullRequestActor), state: PullRequestState, isDraft: Schema.Boolean, + inMergeQueue: Schema.optional(Schema.Boolean), createdAt: IsoDateTime, }); export type PullRequestPreview = typeof PullRequestPreview.Type; @@ -460,6 +462,7 @@ export const PullRequestSummary = Schema.Struct({ url: TrimmedNonEmptyString, state: PullRequestState, isDraft: Schema.optional(Schema.Boolean), + inMergeQueue: Schema.optional(Schema.Boolean), headBranch: TrimmedNonEmptyString, baseBranch: TrimmedNonEmptyString, closedAt: Schema.optional(Schema.NullOr(Schema.String)), @@ -488,6 +491,7 @@ export const PullRequestStack = Schema.Struct({ number: PositiveInt, title: Schema.optional(Schema.String), isDraft: Schema.optional(Schema.Boolean), + inMergeQueue: Schema.optional(Schema.Boolean), headSha: Schema.optional(TrimmedNonEmptyString), headBranch: TrimmedNonEmptyString, state: PullRequestState, @@ -549,6 +553,7 @@ export const PullRequestReportStateInput = Schema.Struct({ reference: PullRequestRef, state: PullRequestState, isDraft: Schema.optional(Schema.Boolean), + inMergeQueue: Schema.optional(Schema.Boolean), }); export type PullRequestReportStateInput = typeof PullRequestReportStateInput.Type; @@ -567,6 +572,7 @@ export const PullRequestDetail = Schema.Struct({ author: Schema.NullOr(PullRequestActor), state: PullRequestState, isDraft: Schema.Boolean, + inMergeQueue: Schema.optional(Schema.Boolean), mergeability: PullRequestMergeability, additions: NonNegativeInt, deletions: NonNegativeInt, diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 3138c3985a4aa..67f166ddecf97 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -29,6 +29,7 @@ export const ChangeRequest = Schema.Struct({ headSha: Schema.optional(TrimmedNonEmptyString), state: ChangeRequestState, isDraft: Schema.optional(Schema.Boolean), + inMergeQueue: Schema.optional(Schema.Boolean), closedAt: Schema.optional(Schema.NullOr(Schema.String)), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.Option(Schema.DateTimeUtc), diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index 3daee9ce197e2..2bfc19c7a2704 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -40,6 +40,7 @@ export const ThreadPullRequestSnapshot = Schema.Struct({ headBranch: TrimmedNonEmptyString, baseBranch: TrimmedNonEmptyString, isDraft: Schema.Boolean, + inMergeQueue: Schema.optional(Schema.Boolean), updatedAt: Schema.NullOr(IsoDateTime), syncedAt: IsoDateTime, closedAt: Schema.optional(Schema.NullOr(Schema.String)), diff --git a/packages/shared/src/composerPullRequestMatches.test.ts b/packages/shared/src/composerPullRequestMatches.test.ts index 19e960721892c..f45f79c74f945 100644 --- a/packages/shared/src/composerPullRequestMatches.test.ts +++ b/packages/shared/src/composerPullRequestMatches.test.ts @@ -84,7 +84,7 @@ describe("linked pull requests", () => { const linked = composerPullRequestsFromLinks( [ link("owner/repo", 7, { - snapshot: { ...link("owner/repo", 7).snapshot!, title: "Fix login" }, + snapshot: { ...link("owner/repo", 7).snapshot!, title: "Fix login", inMergeQueue: true }, }), link("owner/repo", 8), ], @@ -97,6 +97,7 @@ describe("linked pull requests", () => { limit: 10, }); expect(searching.map((match) => match.number)).toEqual([7]); + expect(searching[0]).toMatchObject({ inMergeQueue: true }); const searched = withLinkedComposerPullRequestsFirst({ matches: [entry(9, "2026-01-05"), entry(8, "2026-01-03")], linked, diff --git a/packages/shared/src/composerPullRequestMatches.ts b/packages/shared/src/composerPullRequestMatches.ts index 4be2248a48a7e..91105d354dc7b 100644 --- a/packages/shared/src/composerPullRequestMatches.ts +++ b/packages/shared/src/composerPullRequestMatches.ts @@ -27,6 +27,7 @@ export function composerPullRequestsFromLinks( baseBranch: link.snapshot.baseBranch, state: link.snapshot.state, isDraft: link.snapshot.isDraft, + inMergeQueue: link.snapshot.inMergeQueue, updatedAt: link.snapshot.updatedAt ?? link.linkedAt, }, ], diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index a175c365d080e..953d865adb8f1 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -88,6 +88,7 @@ export interface ProviderChangeRequest { readonly baseBranch: string; readonly state: PullRequestState; readonly isDraft: boolean; + readonly inMergeQueue?: boolean; readonly mergeability: PullRequestMergeability; readonly additions: number; readonly deletions: number; @@ -109,6 +110,7 @@ export interface ProviderChangeRequestSummary { readonly baseBranch: string; readonly state: PullRequestState; readonly isDraft?: boolean; + readonly inMergeQueue?: boolean; readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt: string; diff --git a/packages/source-control-core/src/shared/threadPullRequests.ts b/packages/source-control-core/src/shared/threadPullRequests.ts index bed52f5ecb841..3e3b568ac8ace 100644 --- a/packages/source-control-core/src/shared/threadPullRequests.ts +++ b/packages/source-control-core/src/shared/threadPullRequests.ts @@ -48,7 +48,7 @@ export interface ThreadPullRequestChain { } export type ThreadPullRequestBadge = { - readonly state: "open" | "closed" | "merged" | "draft"; + readonly state: "open" | "closed" | "merged" | "draft" | "queued"; } & ( | { readonly kind: "stack"; @@ -291,13 +291,17 @@ export function makeThreadPullRequests( const visible = visibleThreadPullRequests(pullRequests ?? []); if (visible.length === 0) return null; const states = visible.map((link) => link.snapshot?.state ?? "open"); - const state = visible.every((link) => link.snapshot?.state === "open" && link.snapshot.isDraft) - ? "draft" - : states.includes("open") - ? "open" - : states.every((entry) => entry === "merged") - ? "merged" - : "closed"; + const open = visible.filter(isOpen); + const state = + open.length > 0 && open.every((link) => link.snapshot?.inMergeQueue === true) + ? "queued" + : visible.every((link) => link.snapshot?.state === "open" && link.snapshot.isDraft) + ? "draft" + : states.includes("open") + ? "open" + : states.every((entry) => entry === "merged") + ? "merged" + : "closed"; const chains = resolveThreadPullRequestChains(visible); if (visible.length > 1 && chains.length === 1) { return { kind: "stack", layers: visible.length, state }; diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 5a5c46f28d99a..f21be719a90ae 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -870,6 +870,7 @@ layer("GitHubPullRequestApi.layer", (it) => { }); expect(mockedExecute).toHaveBeenCalledTimes(1); expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.example" }); + expect(queryAt(0)).not.toContain("isInMergeQueue"); }), ); @@ -936,6 +937,7 @@ layer("GitHubPullRequestApi.layer", (it) => { headRefName: `feat/${number}`, state: "OPEN", isDraft: false, + isInMergeQueue: number === 7, mergeable: "MERGEABLE", reviewDecision: null, latestReviews: { @@ -981,6 +983,7 @@ layer("GitHubPullRequestApi.layer", (it) => { { number: seven?.number, state: seven?.state, + inMergeQueue: seven?.inMergeQueue, headBranch: seven?.headBranch, author: seven?.author?.login, changedFiles: seven?.changedFiles, @@ -991,6 +994,7 @@ layer("GitHubPullRequestApi.layer", (it) => { { number: 7, state: "open", + inMergeQueue: true, headBranch: "feat/7", author: "octocat", changedFiles: 2, @@ -1000,6 +1004,7 @@ layer("GitHubPullRequestApi.layer", (it) => { }, ); assert.strictEqual(eight?.headBranch, "feat/8"); + expect(eight?.inMergeQueue).toBe(false); expect(mockedExecute).toHaveBeenCalledOnce(); expect(queryAt(0)).toContain( "_0_repository:repository(owner:$_0_owner name:$_0_name){pullRequest(number:$_0_number)", @@ -1137,6 +1142,53 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); + it.effect.each(["available", "missing", "error"] as const)( + "reads detailed native stack layers when queue membership is %s", + (membership) => + Effect.gen(function* () { + const stack = { + id: 3, + number: 3, + url: "https://api.github.com/repos/acme/web/stacks/3", + base: { ref: "main" }, + pull_requests: [ + { number: 7, head: { ref: "middle", sha: "bbb" }, state: "open", draft: false }, + { number: 8, head: { ref: "top", sha: "ccc" }, state: "open", draft: false }, + ], + }; + mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson([stack])))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(stack)))); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson( + membership === "error" + ? { errors: [{ message: "Queue data unavailable", type: "NOT_FOUND" }] } + : { + data: { + _0_repository: { pullRequest: { isInMergeQueue: true } }, + _1_repository: { + pullRequest: membership === "missing" ? null : { isInMergeQueue: false }, + }, + }, + }, + ), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const detail = yield* cli.getPullRequestStack({ + ...pullRequestInput, + includeDetails: true, + }); + expect(detail?.number).toBe(3); + expect(detail?.layers.map((layer) => layer.inMergeQueue)).toEqual( + membership === "available" ? [true, false] : [undefined, undefined], + ); + expect(mockedExecute).toHaveBeenCalledTimes(3); + }), + ); + it.effect("revalidates a polled stack, per credential, so an unchanged one costs no quota", () => Effect.gen(function* () { const listing = encodeJson([ @@ -1545,7 +1597,14 @@ layer("GitHubPullRequestApi.layer", (it) => { Effect.succeed(output(listedPullRequests(3, 1, () => ({ isDraft: true })))), ); mockedExecute.mockReturnValueOnce( - Effect.succeed(output(listedPullRequests(6, 1, (number) => ({ isDraft: number < 4 })))), + Effect.succeed( + output( + listedPullRequests(6, 1, (number) => ({ + isDraft: number < 4, + isInMergeQueue: number === 4, + })), + ), + ), ); mockedStackMemberships.mockReturnValueOnce( Effect.succeed( @@ -1576,6 +1635,9 @@ layer("GitHubPullRequestApi.layer", (it) => { filters: { draft: "hide" }, }); expect(batch.items.map((item) => item.number)).toEqual([4, 5]); + expect(batch.items.map((item) => item.inMergeQueue)).toEqual([true, false]); + expect(queryAt(0)).toContain("isInMergeQueue"); + expect(queryAt(1)).toContain("isInMergeQueue"); expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); expect(batch.items[1]?.stack).toBeUndefined(); expect(batch.truncated).toBe(true); @@ -2230,6 +2292,29 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); + it.effect.each(["merge", "enable-auto-merge"] as const)( + "rejects %s when fresh mutation state is queued despite an earlier unqueued detail", + (action) => + Effect.gen(function* () { + route( + ["query PullRequestCore", coreResponse({ isInMergeQueue: false })], + ["query PullRequestActionState", actionState({ isInMergeQueue: true })], + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const detail = yield* cli.getPullRequestDetail(pullRequestInput); + expect(detail.inMergeQueue).toBe(false); + const result = yield* cli + .runPullRequestAction({ ...pullRequestInput, action }) + .pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubPullRequestQueuedError" }, + }); + expect(variablesOf("mergePullRequest(")).toEqual([]); + expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); + }), + ); + it.effect.each(["merge", "enable-auto-merge"] as const)( "removes agent credits from the proposed message for %s", (action) => diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index 2defbb6684919..b3ea754e1694d 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -1,3 +1,7 @@ +import { + GitHubPullRequestQueuedError, + readGitHubPullRequestQueue, +} from "./gitHubPullRequestQueue.ts"; import { isCommitSha, normalizeHost } from "@t3tools/source-control-core/server/pullRequestUtils"; import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage"; import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; @@ -1799,6 +1803,7 @@ export const make = Effect.gen(function* () { after, // Stack badges come from their own batched read below, for the rows that stay. stacks: false, + mergeQueue: stacks, stats: true, }) .pipe(Effect.map((result) => toPage(toPullRequestRows(result.search)))) @@ -1810,6 +1815,7 @@ export const make = Effect.gen(function* () { first: pageRows(rows), after, stacks: false, + mergeQueue: stacks, stats: true, }) .pipe( @@ -1921,6 +1927,7 @@ export const make = Effect.gen(function* () { q: query, first: rows, stacks: input.host.toLowerCase() === "github.com", + mergeQueue: input.host.toLowerCase() === "github.com", stats: false, }) .pipe( @@ -1981,7 +1988,12 @@ export const make = Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); const operation = "getPullRequestPreview"; return client - .PullRequestPreview({ owner, name, number: input.number }) + .PullRequestPreview({ + owner, + name, + number: input.number, + mergeQueue: asksRequired(input.host), + }) .pipe( graphql({ ...input, operation }), Effect.map(toPullRequestPreview), @@ -1991,10 +2003,10 @@ export const make = Effect.gen(function* () { ), listWorkflowRunsRequiringApproval, - getPullRequestStack: Effect.fn("GitHubPullRequestApi.getPullRequestStack")(function (input) { + getPullRequestStack: Effect.fn("GitHubPullRequestApi.getPullRequestStack")(function* (input) { const { owner, name } = parseRepositorySelector(input.repository); // Polled by the pull request sync for every linked pull request, so revalidate it. - return revalidatedRestRead({ + const stack = yield* revalidatedRestRead({ cwd: input.cwd, host: input.host, operation: "getPullRequestStack", @@ -2017,6 +2029,31 @@ export const make = Effect.gen(function* () { GitHubApiNotFoundError: () => Effect.succeed(null), }), ); + if (!input.includeDetails || stack === null) return stack; + const membership = yield* readGitHubPullRequestQueue({ + host: input.host, + repository: input.repository, + numbers: stack.layers + .filter((layer) => layer.state === "open") + .map((layer) => layer.number), + }).pipe( + Effect.provideService(GitHubRequestPolicy.GitHubRequestPolicy, policy), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.catch((error) => + Effect.logWarning("Pull request stack queue membership enrichment failed", { + operation: "getPullRequestStack", + host: input.host, + error: error._tag, + }).pipe(Effect.as(new Map())), + ), + ); + return { + ...stack, + layers: stack.layers.map((layer) => { + const queued = membership.get(layer.number); + return queued === undefined ? layer : { ...layer, inMergeQueue: queued }; + }), + }; }), getPullRequestActivity: Effect.fn("GitHubPullRequestApi.getPullRequestActivity")( @@ -2563,6 +2600,7 @@ export const make = Effect.gen(function* () { owner, name, number: input.number, + mergeQueue: asksRequired(input.host), headRef: `refs/pull/${input.number}/head`, }) .pipe( @@ -2580,6 +2618,11 @@ export const make = Effect.gen(function* () { }) .pipe(graphql(target), Effect.asVoid); } + if (state.isInMergeQueue === true) + return yield* new GitHubPullRequestQueuedError({ + repository: input.repository, + number: input.number, + }); let body: string | undefined; let expectedHead: string | undefined; if (input.removeAgentCreditsOnMerge === true && input.mergeMethod !== "rebase") { diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index a5a08fcfc2cc4..45d1f3698166e 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -446,6 +446,48 @@ describe("GitHubSourceControlProvider change request lookups by head", () => { }).pipe(Effect.provide(layer)); }); + it.effect.each(["github.com", "enterprise.example"])( + "shares the branch lookup fragment and preserves host-specific queue fields on %s", + (host) => { + const cloud = host === "github.com"; + const { layer, graphqlCalls } = harness({ + remotes: remotesOutput(["origin", `git@${host}:acme/web.git`]), + graphql: (lookup) => ({ + pullRequests: { + nodes: [ + { ...node(42, String(lookup["head"])), ...(cloud ? { isInMergeQueue: true } : {}) }, + ], + }, + }), + }); + return Effect.gen(function* () { + const provider = yield* GitHubSourceControlProvider.make; + const pending = yield* Effect.forEach( + ["feature/a", "feature/b"], + (headSelector) => + provider.listChangeRequests({ + cwd: "/repo", + headSelector, + state: "open", + limit: 1, + context: githubContext(host), + }), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* TestClock.adjust("50 millis"); + const results = yield* Fiber.join(pending); + assert.deepStrictEqual( + results.map((rows) => rows[0]?.inMergeQueue), + cloud ? [true, true] : [undefined, undefined], + ); + assert.lengthOf(graphqlCalls, 1); + const query = graphqlCalls[0]!.query; + assert.lengthOf(query.match(/fragment\s+\w*PullRequestNode\b/g) ?? [], 1); + if (!cloud) assert.notMatch(query, /mergeQueue|isInMergeQueue/); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("maps API failures onto the errors callers handle", () => { let signedIn = false; const { layer } = harness({ @@ -485,30 +527,34 @@ describe("GitHubSourceControlProvider change request lookups by head", () => { }); describe("GitHubSourceControlProvider.getChangeRequest", () => { - it.effect("reads a pull request by number, and by URL on its own repository", () => { - const { layer, graphqlCalls } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - graphql: () => ({ pullRequest: node(42, "feature") }), - }); - return Effect.gen(function* () { - const gh = yield* GitHubSourceControlProvider.make; - assert.strictEqual( - (yield* gh.getChangeRequest({ cwd: "/repo", reference: "#42" })).number, - 42, - ); - yield* gh.getChangeRequest({ - cwd: "/repo", - reference: "https://github.com/other/thing/pull/42", + it.effect.each([true, false])( + "reads a pull request by number and URL with queue membership %s", + (queued) => { + const { layer, graphqlCalls } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + graphql: () => ({ pullRequest: { ...node(42, "feature"), isInMergeQueue: queued } }), }); - assert.deepStrictEqual( - graphqlCalls.flatMap((call) => call.lookups), - [ - { owner: "acme", name: "web", number: 42 }, - { owner: "other", name: "thing", number: 42 }, - ], - ); - }).pipe(Effect.provide(layer)); - }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + assert.strictEqual( + (yield* gh.getChangeRequest({ cwd: "/repo", reference: "#42" })).number, + 42, + ); + const byUrl = yield* gh.getChangeRequest({ + cwd: "/repo", + reference: "https://github.com/other/thing/pull/42", + }); + assert.strictEqual(byUrl.inMergeQueue, queued); + assert.deepStrictEqual( + graphqlCalls.flatMap((call) => call.lookups), + [ + { owner: "acme", name: "web", number: 42, mergeQueue: true }, + { owner: "other", name: "thing", number: 42, mergeQueue: true }, + ], + ); + }).pipe(Effect.provide(layer)); + }, + ); }); describe("GitHubSourceControlProvider writes", () => { diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index bc0438cf41b60..63134aadf9b12 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -67,7 +67,7 @@ import { readSourceControlHostSettings } from "@t3tools/source-control-core/shar import * as GitHubClient from "../shared/definition.ts"; import { PullRequestByNumber, - PullRequestNode, + PullRequest, PullRequestsByHead, } from "./sourceControlProvider.graphql.ts"; @@ -84,7 +84,7 @@ const LenientPullRequestsByHead = GraphQL.query(PullRequestsByHead.name, { ), }), }); -const decodePullRequestNode = Schema.decodeUnknownOption(PullRequestNode); +const decodePullRequestNode = Schema.decodeUnknownOption(PullRequest); const decodeLinkSubject = Schema.decodeUnknownEffect( Schema.fromJsonString( @@ -406,7 +406,7 @@ function trimmed(value: string): string | null { } /** Null isolates unusable PRs from the rest of a listing. */ -function toPullRequestRecord(node: PullRequestNode): NormalizedGitHubPullRequestRecord | null { +function toPullRequestRecord(node: PullRequest): NormalizedGitHubPullRequestRecord | null { const title = trimmed(node.title); const url = trimmed(node.url); const baseRefName = trimmed(node.baseRefName); @@ -561,6 +561,7 @@ export const make = Effect.gen(function* () { head: headRefName, states: GRAPHQL_STATES[input.state], first: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, + mergeQueue: locator.host.toLowerCase() === "github.com", }, input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver, ), @@ -612,6 +613,7 @@ export const make = Effect.gen(function* () { owner: locator.owner, name: locator.name, number: parsed.number, + mergeQueue: locator.host.toLowerCase() === "github.com", }), ); const node = result.repository?.pullRequest; diff --git a/packages/source-control-github/src/server/gitHubGraphQlClient.ts b/packages/source-control-github/src/server/gitHubGraphQlClient.ts index 7030abb1742e2..ba095beee408a 100644 --- a/packages/source-control-github/src/server/gitHubGraphQlClient.ts +++ b/packages/source-control-github/src/server/gitHubGraphQlClient.ts @@ -65,6 +65,13 @@ export class GitHubGraphQlAuth extends GraphQLMiddleware.Service< "github.kind": "graphql", "github.graphql.query_hash": queryHash(request.query), }); + // Enterprise validates excluded fields too; strip queue selections from generated queries. + const query = + host === "github.com" + ? request.query + : request.query + .replace(/\bisInMergeQueue@include\(if:\$\w+\)/g, "") + .replace(/\$\w*mergeQueue:Boolean!/g, ""); return yield* policy.request( { host, @@ -73,7 +80,7 @@ export class GitHubGraphQlAuth extends GraphQLMiddleware.Service< allowReserve: target.allowReserve ?? (yield* SourceControlRateLimit.Interactive), }, (headers, source) => - next({ ...request, headers: Headers.setAll(request.headers, headers) }).pipe( + next({ ...request, query, headers: Headers.setAll(request.headers, headers) }).pipe( Effect.map((answer) => { const status = answer.status; const responseHeaders = answer.headers ?? Headers.empty; diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts index 622b48db3b85d..e6cd98761c427 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -251,6 +251,13 @@ function expectSuccess(result: Result.Result): A { } describe("pull request list decoding", () => { + it.each([true, false, undefined])("preserves queue membership in listings: %s", (queued) => { + const batch = expectSuccess( + decodePullRequestListJson(listJson([queued === undefined ? {} : { isInMergeQueue: queued }])), + ); + expect(batch.items[0]).toMatchObject(queued === undefined ? {} : { inMergeQueue: queued }); + if (queued === undefined) expect(batch.items[0]).not.toHaveProperty("inMergeQueue"); + }); it("treats a merge timestamp as merged even when the state still says closed", () => { const [entry] = expectSuccess( decodePullRequestListJson(listJson([{ state: "CLOSED", mergedAt: "2026-07-03T00:00:00Z" }])), @@ -395,6 +402,12 @@ describe("pull request detail decoding", () => { pullRequest?: Record, ) => expectSuccess(decodePullRequestCoreJson(coreJson(rollup, pullRequest))); + it.each([true, false, undefined])("preserves queue membership in detail: %s", (queued) => { + const detail = detailOf([], queued === undefined ? {} : { isInMergeQueue: queued }); + expect(detail).toMatchObject(queued === undefined ? {} : { inMergeQueue: queued }); + if (queued === undefined) expect(detail).not.toHaveProperty("inMergeQueue"); + }); + it.each([ [{}, true], [{ viewerDidAuthor: false }, false], diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index e7c05a4aa8445..ae85149aa372b 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -90,6 +90,7 @@ interface ListItemFields { readonly baseRefName: string; readonly state?: string | null | undefined; readonly isDraft?: boolean | undefined; + readonly isInMergeQueue?: boolean | undefined; readonly mergeable?: string | null | undefined; readonly reviewDecision?: string | null | undefined; readonly additions?: number | undefined; @@ -225,8 +226,10 @@ export function toPullRequestPreview( ): Omit | null { const pullRequest = result.repository?.pullRequest; if (pullRequest == null) return null; + const { isInMergeQueue, ...preview } = pullRequest; return { - ...pullRequest, + ...preview, + ...(isInMergeQueue === undefined ? {} : { inMergeQueue: isInMergeQueue }), author: toActor(pullRequest.author), state: toState(pullRequest), }; @@ -332,6 +335,7 @@ export interface GitHubPullRequestListItem { readonly baseBranch: string; readonly state: PullRequestState; readonly isDraft: boolean; + readonly inMergeQueue?: boolean; readonly mergeability: PullRequestMergeability; readonly reviewDecision: PullRequestReviewDecision | null; readonly additions: number; @@ -709,6 +713,7 @@ function toListItem(raw: ListItemFields): GitHubPullRequestListItem { baseBranch: raw.baseRefName, state: toState(raw), isDraft: raw.isDraft ?? false, + ...(raw.isInMergeQueue === undefined ? {} : { inMergeQueue: raw.isInMergeQueue }), mergeability: toMergeability(raw.mergeable), reviewDecision: toReviewDecisionWithReviews(raw.reviewDecision, raw.latestReviews), additions: raw.additions ?? 0, @@ -896,6 +901,7 @@ export interface GitHubPullRequestSummary { readonly baseBranch: string; readonly state: PullRequestState; readonly isDraft: boolean; + readonly inMergeQueue?: boolean; readonly closedAt: string | null; readonly mergedAt: string | null; readonly updatedAt: string; @@ -919,6 +925,7 @@ export function toPullRequestSummary(pr: SummarizedPullRequest): GitHubPullReque baseBranch: pr.baseRefName, state: toState(pr), isDraft: pr.isDraft, + ...(pr.isInMergeQueue === undefined ? {} : { inMergeQueue: pr.isInMergeQueue }), closedAt: trimmed(pr.closedAt), mergedAt: trimmed(pr.mergedAt), updatedAt: pr.updatedAt, @@ -1734,6 +1741,7 @@ const RawStackSchema = Schema.Struct({ const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema)); export interface GitHubPullRequestStackLayer { + readonly inMergeQueue?: boolean; readonly title?: string; readonly isDraft?: boolean; readonly headSha?: string; diff --git a/packages/source-control-github/src/server/gitHubPullRequestQueue.ts b/packages/source-control-github/src/server/gitHubPullRequestQueue.ts new file mode 100644 index 0000000000000..96b565e5bfdaf --- /dev/null +++ b/packages/source-control-github/src/server/gitHubPullRequestQueue.ts @@ -0,0 +1,80 @@ +import * as Effect from "effect/Effect"; +import * as RequestResolver from "effect/RequestResolver"; +import * as Schema from "effect/Schema"; +import { GraphQLClient, GraphQLClientError, GraphQLResolver } from "effect/graphql"; +import * as GitHubGraphQlClient from "./gitHubGraphQlClient.ts"; +import { pullRequestLookup } from "./gitHubPullRequestJson.ts"; +import { PullRequestBatchesGraphQLGroup } from "./pullRequestBatches.graphql.ts"; + +export class GitHubPullRequestQueuedError extends Schema.TaggedError()( + "GitHubPullRequestQueuedError", + { repository: Schema.String, number: Schema.Int }, +) { + override get message(): string { + return `PR #${this.number} is already in the merge queue. Refresh it before trying to merge again.`; + } +} + +export class GitHubPullRequestQueueReadError extends Schema.TaggedError()( + "GitHubPullRequestQueueReadError", + { repository: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return "GitHub returned an unreadable merge queue state."; + } +} + +export const readGitHubPullRequestQueue = Effect.fn("GitHubPullRequestQueue.read")( + function* (input: { + readonly host: string; + readonly repository: string; + readonly numbers: ReadonlyArray; + }) { + if (input.host.toLowerCase() !== "github.com" || input.numbers.length === 0) + return new Map(); + const lookup = pullRequestLookup({ + repository: input.repository, + number: input.numbers[0] ?? 0, + }); + if (lookup === null) + return yield* new GitHubPullRequestQueueReadError({ + repository: input.repository, + cause: new Error("Invalid pull request selector."), + }); + const client = yield* GraphQLClient.make( + PullRequestBatchesGraphQLGroup.middleware(GitHubGraphQlClient.GitHubGraphQlAuth), + ).pipe(Effect.provide(GitHubGraphQlClient.layer)); + const resolver = GraphQLResolver.aliased(client.PullRequestQueueMembership, { + batchKey: GitHubGraphQlClient.batchKey, + }).pipe(RequestResolver.batchN(25)); + const entries = yield* Effect.forEach( + input.numbers, + (number) => + GraphQLResolver.request({ ...lookup, number }, resolver).pipe( + Effect.flatMap((result) => { + const queued = result.repository?.pullRequest?.isInMergeQueue; + return queued === undefined + ? Effect.fail( + new GitHubPullRequestQueueReadError({ + repository: input.repository, + cause: new Error("Missing queue membership."), + }), + ) + : Effect.succeed([number, queued] satisfies [number, boolean]); + }), + ), + { concurrency: "unbounded" }, + ).pipe( + GitHubGraphQlClient.call({ + host: input.host, + operation: "readPullRequestQueue", + allowReserve: true, + }), + Effect.catchIf( + GraphQLClientError.isGraphQLClientError, + (cause) => new GitHubPullRequestQueueReadError({ repository: input.repository, cause }), + ), + ); + return new Map(entries); + }, +); diff --git a/packages/source-control-github/src/server/gitHubPullRequests.ts b/packages/source-control-github/src/server/gitHubPullRequests.ts index 0637096feadd3..52d668633b122 100644 --- a/packages/source-control-github/src/server/gitHubPullRequests.ts +++ b/packages/source-control-github/src/server/gitHubPullRequests.ts @@ -17,6 +17,7 @@ export interface NormalizedGitHubPullRequestRecord { readonly headSha?: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly inMergeQueue?: boolean; readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt: Option.Option; @@ -34,6 +35,7 @@ const GitHubPullRequestSchema = Schema.Struct({ headRefOid: Schema.optional(Schema.NullOr(Schema.String)), state: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.Boolean), + isInMergeQueue: Schema.optional(Schema.Boolean), closedAt: Schema.optional(Schema.NullOr(Schema.String)), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), @@ -97,6 +99,7 @@ export function normalizeGitHubPullRequestRecord( ...(headSha ? { headSha } : {}), state: normalizeGitHubPullRequestState(raw), ...(raw.isDraft === true ? { isDraft: true } : {}), + ...(raw.isInMergeQueue === undefined ? {} : { inMergeQueue: raw.isInMergeQueue }), closedAt: raw.closedAt ?? null, mergedAt: raw.mergedAt ?? null, updatedAt: raw.updatedAt ?? Option.none(), diff --git a/packages/source-control-github/src/server/githubStackActions.test.ts b/packages/source-control-github/src/server/githubStackActions.test.ts index 3d95638bc48e9..4cc659732b6dd 100644 --- a/packages/source-control-github/src/server/githubStackActions.test.ts +++ b/packages/source-control-github/src/server/githubStackActions.test.ts @@ -181,12 +181,28 @@ const rebased = { }; const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; -function fake(responses: readonly unknown[]) { +function fake(responses: readonly unknown[], queued: ReadonlyArray = []) { const calls: Call[] = []; + let responseIndex = 0; const execute: Send = (call) => Effect.sync(() => { calls.push(call); - const value = responses[calls.length - 1]; + if (call[0]?.includes("PullRequestQueueMembership")) { + return Object.fromEntries( + call.flatMap((word) => { + const match = /^(_\d+_)number=(\d+)$/.exec(word); + return match + ? [ + [ + match[1] + "repository", + { pullRequest: { isInMergeQueue: queued.includes(Number(match[2])) } }, + ], + ] + : []; + }), + ); + } + const value = responses[responseIndex++]; if (value === undefined) throw new Error("Unexpected GitHub request"); return value; }); @@ -197,11 +213,11 @@ it.effect("submits one atomic merge with the reviewed head and respects the merg Effect.gen(function* () { const api = fake([stack, { status: "enqueued", details: {} }]); yield* runGitHubStackAction(api.execute, { ...input, mergeMethod: "squash" }); - expect(api.calls).toHaveLength(2); - expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); - expect(api.calls[1]).toContain("sha=ccc"); - expect(api.calls[1]).toContain("merge_action=default"); - expect(api.calls[1]).toContain("merge_method=squash"); + expect(api.calls).toHaveLength(3); + expect(api.calls[2]).toContain("repos/acme/web/pulls/3/merge-async"); + expect(api.calls[2]).toContain("sha=ccc"); + expect(api.calls[2]).toContain("merge_action=default"); + expect(api.calls[2]).toContain("merge_method=squash"); }), ); @@ -224,10 +240,22 @@ it.effect("merges through the selected layer without including later draft layer number: 3, expectedStackHeads: [1, 2, 3].map((number) => ({ number, headSha: `sha-${number}` })), }); - expect(api.calls).toHaveLength(2); - expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); - expect(api.calls[1]).toContain("sha=sha-3"); - expect(api.calls[1]).toContain("merge_action=default"); + expect(api.calls).toHaveLength(3); + expect(api.calls[2]).toContain("repos/acme/web/pulls/3/merge-async"); + expect(api.calls[2]).toContain("sha=sha-3"); + expect(api.calls[2]).toContain("merge_action=default"); + }), +); + +it.effect("rejects a stack merge when a freshly read lower layer is queued", () => + Effect.gen(function* () { + const api = fake([stack, { status: "enqueued", details: {} }], [2]); + const result = yield* runGitHubStackAction(api.execute, input).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubPullRequestQueuedError", number: 2 }, + }); + expect(api.calls.some((call) => call[0]?.includes("merge-async"))).toBe(false); }), ); diff --git a/packages/source-control-github/src/server/githubStackActions.ts b/packages/source-control-github/src/server/githubStackActions.ts index f3997a45642a1..cf99ae5cc09a5 100644 --- a/packages/source-control-github/src/server/githubStackActions.ts +++ b/packages/source-control-github/src/server/githubStackActions.ts @@ -1,3 +1,8 @@ +import { + GitHubPullRequestQueuedError, + GitHubPullRequestQueueReadError, + readGitHubPullRequestQueue, +} from "./gitHubPullRequestQueue.ts"; import type { PullRequestAction, PullRequestMergeMethod, @@ -90,6 +95,8 @@ export class GitHubStackRebaseFailedError extends Schema.TaggedError layer.isDraft)) return yield* new GitHubStackUnsupportedError({ ...identity }); + const membership = yield* readGitHubPullRequestQueue({ + host: input.host, + repository: input.repository, + numbers: open.map((layer) => layer.number), + }); + const queued = open.find((layer) => membership.get(layer.number) === true); + if (queued) + return yield* new GitHubPullRequestQueuedError({ + repository: input.repository, + number: queued.number, + }); const decode = (raw: string) => decodeMergeResponse(raw).pipe( Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), diff --git a/packages/source-control-github/src/server/pullRequestBatches.graphql b/packages/source-control-github/src/server/pullRequestBatches.graphql index 24b6925fcf45c..35eb324d2db26 100644 --- a/packages/source-control-github/src/server/pullRequestBatches.graphql +++ b/packages/source-control-github/src/server/pullRequestBatches.graphql @@ -10,6 +10,7 @@ query PullRequestSummary($owner: String!, $name: String!, $number: Int!, $stacks url state isDraft + isInMergeQueue @include(if: $stacks) mergeable reviewDecision additions @@ -126,3 +127,11 @@ query PullRequestWatchFingerprint($owner: String!, $name: String!, $number: Int! } } } + +query PullRequestQueueMembership($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + isInMergeQueue + } + } +} diff --git a/packages/source-control-github/src/server/pullRequestBatches.graphql.ts b/packages/source-control-github/src/server/pullRequestBatches.graphql.ts index 04874bae0beb9..ce7b062913350 100644 --- a/packages/source-control-github/src/server/pullRequestBatches.graphql.ts +++ b/packages/source-control-github/src/server/pullRequestBatches.graphql.ts @@ -107,6 +107,8 @@ export const PullRequestSummaryRepositoryPullRequestPullRequest = Schema.Struct( state: GraphQL.enumLiterals(Shared.PullRequestState.literals), /** Identifies if the pull request is a draft. */ isDraft: Schema.Boolean, + /** Indicates whether the pull request is in a merge queue */ + isInMergeQueue: Schema.optionalKey(Schema.Boolean), /** Whether or not the pull request can be merged based on the existence of merge conflicts. */ mergeable: GraphQL.enumLiterals(Shared.MergeableState.literals), /** The current status of this pull request with respect to code review. */ @@ -160,7 +162,7 @@ export const PullRequestSummary = GraphQL.query("PullRequestSummary", { { name: "stacks", body: "Boolean!" } ], selections: [ - { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){pullRequest(number:$", "number", "){number title url state isDraft mergeable reviewDecision additions deletions changedFiles updatedAt mergedAt closedAt headRefName baseRefName author{__typename login avatarUrl...on User{name}}latestReviews(first:20){nodes{state author{__typename login}}}commits(last:1){nodes{commit{statusCheckRollup{state}}}}stack@include(if:$", "stacks", "){number size baseRefName}stackEntry@include(if:$", "stacks", "){position}}}"] } + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){pullRequest(number:$", "number", "){number title url state isDraft isInMergeQueue@include(if:$", "stacks", ")mergeable reviewDecision additions deletions changedFiles updatedAt mergedAt closedAt headRefName baseRefName author{__typename login avatarUrl...on User{name}}latestReviews(first:20){nodes{state author{__typename login}}}commits(last:1){nodes{commit{statusCheckRollup{state}}}}stack@include(if:$", "stacks", "){number size baseRefName}stackEntry@include(if:$", "stacks", "){position}}}"] } ], fragments: [] }, @@ -416,5 +418,47 @@ export declare namespace PullRequestWatchFingerprint { export type Result = typeof PullRequestWatchFingerprint.result.Type } +/** A repository pull request. */ +export const PullRequestQueueMembershipRepositoryPullRequestPullRequest = Schema.Struct({ + /** Indicates whether the pull request is in a merge queue */ + isInMergeQueue: Schema.Boolean +}) +export interface PullRequestQueueMembershipRepositoryPullRequestPullRequest extends Schema.Struct.Type {} + +/** A repository contains the content for a project. */ +export const PullRequestQueueMembershipRepositoryRepository = Schema.Struct({ + /** Returns a single pull request from the current repository by number. */ + pullRequest: Schema.NullOr(PullRequestQueueMembershipRepositoryPullRequestPullRequest) +}) +export interface PullRequestQueueMembershipRepositoryRepository extends Schema.Struct.Type {} + +/** `query PullRequestQueueMembership` */ +export const PullRequestQueueMembership = GraphQL.query("PullRequestQueueMembership", { + document: { + variables: [ + { name: "owner", body: "String!" }, + { name: "name", body: "String!" }, + { name: "number", body: "Int!" } + ], + selections: [ + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){pullRequest(number:$", "number", "){isInMergeQueue}}"] } + ], + fragments: [] + }, + variables: { + owner: Schema.String, + name: Schema.String, + number: Shared.Int + }, + result: Schema.Struct({ + /** Lookup a given repository by the owner and repository name. */ + repository: Schema.NullOr(PullRequestQueueMembershipRepositoryRepository) + }) +}) +export declare namespace PullRequestQueueMembership { + export type Variables = typeof PullRequestQueueMembership.variables.Type + export type Result = typeof PullRequestQueueMembership.result.Type +} + /** Every operation in pullRequestBatches.graphql. */ -export const PullRequestBatchesGraphQLGroup = GraphQLGroup.make(PullRequestSummary, PullRequestStats, PullRequestStackMembership, PullRequestWatchFingerprint) +export const PullRequestBatchesGraphQLGroup = GraphQLGroup.make(PullRequestSummary, PullRequestStats, PullRequestStackMembership, PullRequestWatchFingerprint, PullRequestQueueMembership) diff --git a/packages/source-control-github/src/server/pullRequestListings.graphql b/packages/source-control-github/src/server/pullRequestListings.graphql index b90f4b28609da..0211617990f36 100644 --- a/packages/source-control-github/src/server/pullRequestListings.graphql +++ b/packages/source-control-github/src/server/pullRequestListings.graphql @@ -32,6 +32,7 @@ fragment PullRequestRow on PullRequest { baseRefName state isDraft + isInMergeQueue @include(if: $mergeQueue) mergeable reviewDecision latestReviews(first: 20) { @@ -89,6 +90,7 @@ query PullRequestSearch( $first: Int! $after: String $stacks: Boolean! + $mergeQueue: Boolean! $stats: Boolean! ) { search(query: $q, type: ISSUE, first: $first, after: $after) { @@ -111,6 +113,7 @@ query PullRequestList( $first: Int! $after: String $stacks: Boolean! + $mergeQueue: Boolean! $stats: Boolean! ) { repository(owner: $owner, name: $name) { diff --git a/packages/source-control-github/src/server/pullRequestListings.graphql.ts b/packages/source-control-github/src/server/pullRequestListings.graphql.ts index ad55696b377c8..6cbc58abc5efe 100644 --- a/packages/source-control-github/src/server/pullRequestListings.graphql.ts +++ b/packages/source-control-github/src/server/pullRequestListings.graphql.ts @@ -187,6 +187,8 @@ export const PullRequestRow = Schema.Struct({ state: GraphQL.enumLiterals(Shared.PullRequestState.literals), /** Identifies if the pull request is a draft. */ isDraft: Schema.Boolean, + /** Indicates whether the pull request is in a merge queue */ + isInMergeQueue: Schema.optionalKey(Schema.Boolean), /** Whether or not the pull request can be merged based on the existence of merge conflicts. */ mergeable: GraphQL.enumLiterals(Shared.MergeableState.literals), /** The current status of this pull request with respect to code review. */ @@ -260,13 +262,14 @@ export const PullRequestSearch = GraphQL.query("PullRequestSearch", { { name: "first", body: "Int!" }, { name: "after", body: "String" }, { name: "stacks", body: "Boolean!" }, + { name: "mergeQueue", body: "Boolean!" }, { name: "stats", body: "Boolean!" } ], selections: [ { name: "search", body: ["(query:$", "q", " type:ISSUE first:$", "first", " after:$", "after", "){pageInfo{hasNextPage endCursor}nodes{__typename...", "PullRequestRow", "}}"] } ], fragments: [ - { name: "PullRequestRow", body: ["on PullRequest{stack@include(if:$", "stacks", "){number size baseRefName}stackEntry@include(if:$", "stacks", "){position}number title url author{__typename login avatarUrl...on User{name}}headRefName baseRefName state isDraft mergeable reviewDecision latestReviews(first:20){nodes{state author{__typename login}}}additions@include(if:$", "stats", ")deletions@include(if:$", "stats", ")createdAt updatedAt mergedAt repository{nameWithOwner}reviewRequests(first:20){nodes{requestedReviewer{__typename...on User{login}...on Team{slug}}}}labels(first:20){nodes{name color}}commits(last:1){nodes{commit{statusCheckRollup{state}}}}}"] } + { name: "PullRequestRow", body: ["on PullRequest{stack@include(if:$", "stacks", "){number size baseRefName}stackEntry@include(if:$", "stacks", "){position}number title url author{__typename login avatarUrl...on User{name}}headRefName baseRefName state isDraft isInMergeQueue@include(if:$", "mergeQueue", ")mergeable reviewDecision latestReviews(first:20){nodes{state author{__typename login}}}additions@include(if:$", "stats", ")deletions@include(if:$", "stats", ")createdAt updatedAt mergedAt repository{nameWithOwner}reviewRequests(first:20){nodes{requestedReviewer{__typename...on User{login}...on Team{slug}}}}labels(first:20){nodes{name color}}commits(last:1){nodes{commit{statusCheckRollup{state}}}}}"] } ] }, variables: { @@ -274,6 +277,7 @@ export const PullRequestSearch = GraphQL.query("PullRequestSearch", { first: Shared.Int, after: Schema.optional(Schema.NullOr(Schema.String)), stacks: Schema.Boolean, + mergeQueue: Schema.Boolean, stats: Schema.Boolean }, result: Schema.Struct({ @@ -312,13 +316,14 @@ export const PullRequestList = GraphQL.query("PullRequestList", { { name: "first", body: "Int!" }, { name: "after", body: "String" }, { name: "stacks", body: "Boolean!" }, + { name: "mergeQueue", body: "Boolean!" }, { name: "stats", body: "Boolean!" } ], selections: [ { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){pullRequests(first:$", "first", " after:$", "after", " states:$", "states", " orderBy:{field:CREATED_AT direction:DESC}){pageInfo{hasNextPage endCursor}nodes{...", "PullRequestRow", "}}}"] } ], fragments: [ - { name: "PullRequestRow", body: ["on PullRequest{stack@include(if:$", "stacks", "){number size baseRefName}stackEntry@include(if:$", "stacks", "){position}number title url author{__typename login avatarUrl...on User{name}}headRefName baseRefName state isDraft mergeable reviewDecision latestReviews(first:20){nodes{state author{__typename login}}}additions@include(if:$", "stats", ")deletions@include(if:$", "stats", ")createdAt updatedAt mergedAt repository{nameWithOwner}reviewRequests(first:20){nodes{requestedReviewer{__typename...on User{login}...on Team{slug}}}}labels(first:20){nodes{name color}}commits(last:1){nodes{commit{statusCheckRollup{state}}}}}"] } + { name: "PullRequestRow", body: ["on PullRequest{stack@include(if:$", "stacks", "){number size baseRefName}stackEntry@include(if:$", "stacks", "){position}number title url author{__typename login avatarUrl...on User{name}}headRefName baseRefName state isDraft isInMergeQueue@include(if:$", "mergeQueue", ")mergeable reviewDecision latestReviews(first:20){nodes{state author{__typename login}}}additions@include(if:$", "stats", ")deletions@include(if:$", "stats", ")createdAt updatedAt mergedAt repository{nameWithOwner}reviewRequests(first:20){nodes{requestedReviewer{__typename...on User{login}...on Team{slug}}}}labels(first:20){nodes{name color}}commits(last:1){nodes{commit{statusCheckRollup{state}}}}}"] } ] }, variables: { @@ -328,6 +333,7 @@ export const PullRequestList = GraphQL.query("PullRequestList", { first: Shared.Int, after: Schema.optional(Schema.NullOr(Schema.String)), stacks: Schema.Boolean, + mergeQueue: Schema.Boolean, stats: Schema.Boolean }, result: Schema.Struct({ diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql b/packages/source-control-github/src/server/pullRequestReads.graphql index 78e5efe0e4558..318d893326c08 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql +++ b/packages/source-control-github/src/server/pullRequestReads.graphql @@ -73,6 +73,7 @@ query PullRequestCore( body state isDraft + isInMergeQueue @include(if: $required) mergeable mergeStateStatus reviewDecision @@ -198,13 +199,14 @@ query PullRequestCheckContexts( } } -query PullRequestPreview($owner: String!, $name: String!, $number: Int!) { +query PullRequestPreview($owner: String!, $name: String!, $number: Int!, $mergeQueue: Boolean!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { number title url state + isInMergeQueue @include(if: $mergeQueue) isDraft createdAt author { @@ -559,11 +561,18 @@ query PullRequestMergeMessage( } # What `gh pr merge` and `gh pr update-branch` read before they act. -query PullRequestActionState($owner: String!, $name: String!, $number: Int!, $headRef: String!) { +query PullRequestActionState( + $owner: String! + $name: String! + $number: Int! + $headRef: String! + $mergeQueue: Boolean! +) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { id headRefOid + isInMergeQueue @include(if: $mergeQueue) isMergeQueueEnabled mergeStateStatus baseRef { diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql.ts b/packages/source-control-github/src/server/pullRequestReads.graphql.ts index 5bc09fa4e2ce4..9896fec595d0f 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql.ts +++ b/packages/source-control-github/src/server/pullRequestReads.graphql.ts @@ -367,6 +367,8 @@ export const PullRequestCoreRepositoryPullRequestPullRequest = Schema.Struct({ state: GraphQL.enumLiterals(Shared.PullRequestState.literals), /** Identifies if the pull request is a draft. */ isDraft: Schema.Boolean, + /** Indicates whether the pull request is in a merge queue */ + isInMergeQueue: Schema.optionalKey(Schema.Boolean), /** Whether or not the pull request can be merged based on the existence of merge conflicts. */ mergeable: GraphQL.enumLiterals(Shared.MergeableState.literals), /** Detailed information about the current pull request merge state status. */ @@ -459,7 +461,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}}}}}}}}"] } + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed autoMergeAllowed viewerPermission pullRequest(number:$", "number", "){number title url body state isDraft isInMergeQueue@include(if:$", "required", ")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}}}}}}}}"] } ], 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}}}}}"] } @@ -601,6 +603,8 @@ export const PullRequestPreviewRepositoryPullRequestPullRequest = Schema.Struct( url: Shared.URI, /** Identifies the state of the pull request. */ state: GraphQL.enumLiterals(Shared.PullRequestState.literals), + /** Indicates whether the pull request is in a merge queue */ + isInMergeQueue: Schema.optionalKey(Schema.Boolean), /** Identifies if the pull request is a draft. */ isDraft: Schema.Boolean, /** Identifies the date and time when the object was created. */ @@ -626,17 +630,19 @@ export const PullRequestPreview = GraphQL.query("PullRequestPreview", { variables: [ { name: "owner", body: "String!" }, { name: "name", body: "String!" }, - { name: "number", body: "Int!" } + { name: "number", body: "Int!" }, + { name: "mergeQueue", body: "Boolean!" } ], selections: [ - { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){pullRequest(number:$", "number", "){number title url state isDraft createdAt author{__typename login avatarUrl...on User{name}}}}"] } + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){pullRequest(number:$", "number", "){number title url state isInMergeQueue@include(if:$", "mergeQueue", ")isDraft createdAt author{__typename login avatarUrl...on User{name}}}}"] } ], fragments: [] }, variables: { owner: Schema.String, name: Schema.String, - number: Shared.Int + number: Shared.Int, + mergeQueue: Schema.Boolean }, result: Schema.Struct({ /** Lookup a given repository by the owner and repository name. */ @@ -1553,6 +1559,8 @@ export const PullRequestActionStateRepositoryPullRequestPullRequest = Schema.Str id: Shared.ID, /** Identifies the oid of the head ref associated with the pull request, even if the ref has been deleted. */ headRefOid: Shared.GitObjectID, + /** Indicates whether the pull request is in a merge queue */ + isInMergeQueue: Schema.optionalKey(Schema.Boolean), /** Indicates whether the pull request's base ref has a merge queue enabled. */ isMergeQueueEnabled: Schema.Boolean, /** Detailed information about the current pull request merge state status. */ @@ -1576,10 +1584,11 @@ export const PullRequestActionState = GraphQL.query("PullRequestActionState", { { name: "owner", body: "String!" }, { name: "name", body: "String!" }, { name: "number", body: "Int!" }, - { name: "headRef", body: "String!" } + { name: "headRef", body: "String!" }, + { name: "mergeQueue", body: "Boolean!" } ], selections: [ - { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){pullRequest(number:$", "number", "){id headRefOid isMergeQueueEnabled mergeStateStatus baseRef{compare(headRef:$", "headRef", "){behindBy}}}}"] } + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){pullRequest(number:$", "number", "){id headRefOid isInMergeQueue@include(if:$", "mergeQueue", ")isMergeQueueEnabled mergeStateStatus baseRef{compare(headRef:$", "headRef", "){behindBy}}}}"] } ], fragments: [] }, @@ -1587,7 +1596,8 @@ export const PullRequestActionState = GraphQL.query("PullRequestActionState", { owner: Schema.String, name: Schema.String, number: Shared.Int, - headRef: Schema.String + headRef: Schema.String, + mergeQueue: Schema.Boolean }, result: Schema.Struct({ /** Lookup a given repository by the owner and repository name. */ diff --git a/packages/source-control-github/src/server/sourceControlProvider.graphql b/packages/source-control-github/src/server/sourceControlProvider.graphql index d1673a64d4ad3..7719de99ebac5 100644 --- a/packages/source-control-github/src/server/sourceControlProvider.graphql +++ b/packages/source-control-github/src/server/sourceControlProvider.graphql @@ -31,6 +31,7 @@ query PullRequestsByHead( $head: String! $states: [PullRequestState!] $first: Int! + $mergeQueue: Boolean! ) { repository(owner: $owner, name: $name) { # `gh pr list` orders the same way, so a head with more matches than the limit keeps the @@ -43,15 +44,17 @@ query PullRequestsByHead( ) { nodes { ...PullRequestNode + isInMergeQueue @include(if: $mergeQueue) } } } } -query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { +query PullRequestByNumber($owner: String!, $name: String!, $number: Int!, $mergeQueue: Boolean!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { ...PullRequestNode + isInMergeQueue @include(if: $mergeQueue) } } } diff --git a/packages/source-control-github/src/server/sourceControlProvider.graphql.ts b/packages/source-control-github/src/server/sourceControlProvider.graphql.ts index b67d52509214a..80fbdead6556b 100644 --- a/packages/source-control-github/src/server/sourceControlProvider.graphql.ts +++ b/packages/source-control-github/src/server/sourceControlProvider.graphql.ts @@ -57,10 +57,18 @@ export const PullRequestNode = Schema.Struct({ }) export interface PullRequestNode extends Schema.Struct.Type {} +/** A repository pull request. */ +export const PullRequest = Schema.Struct({ + ...PullRequestNode.fields, + /** Indicates whether the pull request is in a merge queue */ + isInMergeQueue: Schema.optionalKey(Schema.Boolean) +}) +export interface PullRequest extends Schema.Struct.Type {} + /** The connection type for PullRequest. */ export const PullRequestConnection = Schema.Struct({ /** A list of nodes. */ - nodes: Schema.NullOr(Schema.Array(Schema.NullOr(PullRequestNode))) + nodes: Schema.NullOr(Schema.Array(Schema.NullOr(PullRequest))) }) export interface PullRequestConnection extends Schema.Struct.Type {} @@ -79,10 +87,11 @@ export const PullRequestsByHead = GraphQL.query("PullRequestsByHead", { { name: "name", body: "String!" }, { name: "head", body: "String!" }, { name: "states", body: "[PullRequestState!]" }, - { name: "first", body: "Int!" } + { name: "first", body: "Int!" }, + { name: "mergeQueue", body: "Boolean!" } ], selections: [ - { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){pullRequests(headRefName:$", "head", " states:$", "states", " first:$", "first", " orderBy:{field:CREATED_AT direction:DESC}){nodes{...PullRequestNode}}}"] } + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){pullRequests(headRefName:$", "head", " states:$", "states", " first:$", "first", " orderBy:{field:CREATED_AT direction:DESC}){nodes{...PullRequestNode isInMergeQueue@include(if:$", "mergeQueue", ")}}}"] } ], fragments: [ { name: "PullRequestNode", body: ["on PullRequest{number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository{name nameWithOwner}headRepositoryOwner{__typename login}}"] } @@ -93,7 +102,8 @@ export const PullRequestsByHead = GraphQL.query("PullRequestsByHead", { name: Schema.String, head: Schema.String, states: Schema.optional(Schema.NullOr(Schema.Array(Shared.PullRequestState))), - first: Shared.Int + first: Shared.Int, + mergeQueue: Schema.Boolean }, result: Schema.Struct({ /** Lookup a given repository by the owner and repository name. */ @@ -108,7 +118,7 @@ export declare namespace PullRequestsByHead { /** A repository contains the content for a project. */ export const PullRequestByNumberRepositoryRepository = Schema.Struct({ /** Returns a single pull request from the current repository by number. */ - pullRequest: Schema.NullOr(PullRequestNode) + pullRequest: Schema.NullOr(PullRequest) }) export interface PullRequestByNumberRepositoryRepository extends Schema.Struct.Type {} @@ -118,10 +128,11 @@ export const PullRequestByNumber = GraphQL.query("PullRequestByNumber", { variables: [ { name: "owner", body: "String!" }, { name: "name", body: "String!" }, - { name: "number", body: "Int!" } + { name: "number", body: "Int!" }, + { name: "mergeQueue", body: "Boolean!" } ], selections: [ - { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){pullRequest(number:$", "number", "){...PullRequestNode}}"] } + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){pullRequest(number:$", "number", "){...PullRequestNode isInMergeQueue@include(if:$", "mergeQueue", ")}}"] } ], fragments: [ { name: "PullRequestNode", body: ["on PullRequest{number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository{name nameWithOwner}headRepositoryOwner{__typename login}}"] } @@ -130,7 +141,8 @@ export const PullRequestByNumber = GraphQL.query("PullRequestByNumber", { variables: { owner: Schema.String, name: Schema.String, - number: Shared.Int + number: Shared.Int, + mergeQueue: Schema.Boolean }, result: Schema.Struct({ /** Lookup a given repository by the owner and repository name. */