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. */