diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts
index 06d85442..48a3f8eb 100644
--- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts
+++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts
@@ -39,11 +39,6 @@ describe("GitHubPullRequestProvider.runAction", () => {
input: { action: "update-branch", updateMethod: "rebase" },
args: ["pr", "update-branch", "12", "--repo", "octocat/example-app", "--rebase"],
},
- {
- name: "disarms auto-merge",
- input: { action: "disable-auto-merge" },
- args: ["pr", "merge", "12", "--repo", "octocat/example-app", "--disable-auto"],
- },
{
name: "deletes the head branch after a merge when asked",
input: { action: "merge", mergeMethod: "merge", deleteBranch: true },
@@ -64,11 +59,22 @@ describe("GitHubPullRequestProvider.runAction", () => {
);
}
- /** The host answers the readiness read with one status and every write with nothing. */
- const hostReports = (status: string) => {
+ /**
+ * The host answers the readiness read over GraphQL and every write with
+ * nothing. `isMergeQueueEnabled` is what the queue case turns on.
+ */
+ const hostReports = (status: string, isMergeQueueEnabled = false) => {
mockExecute.mockImplementation((input) =>
Effect.succeed(
- processOutput(input.args[1] === "view" ? JSON.stringify({ mergeStateStatus: status }) : ""),
+ processOutput(
+ input.args[0] === "api"
+ ? JSON.stringify({
+ data: {
+ repository: { pullRequest: { mergeStateStatus: status, isMergeQueueEnabled } },
+ },
+ })
+ : "",
+ ),
),
);
};
@@ -90,7 +96,6 @@ describe("GitHubPullRequestProvider.runAction", () => {
});
assert.deepStrictEqual(prCalls(), [
- ["pr", "view", "12", "--repo", "octocat/example-app", "--json", "mergeStateStatus"],
// The strategy travels with the standing instruction.
["pr", "merge", "12", "--repo", "octocat/example-app", "--auto", "--squash"],
]);
@@ -112,13 +117,77 @@ describe("GitHubPullRequestProvider.runAction", () => {
.pipe(Effect.flip);
assert.equal(error.detail, "This pull request can merge right now. Use Merge instead.");
- // Nothing ran but the read: the merge never reached the host.
+ // Nothing ran but the GraphQL read: the merge never reached the host.
assert.deepStrictEqual(
- prCalls().map((args) => args[1]),
- ["view"],
+ calls().map((call) => call.args[0]),
+ ["api"],
);
}).pipe(Effect.provide(layer)),
);
+
+ /** The host answers the standing read with whether the pull request is queued. */
+ const hostQueues = (isInMergeQueue: boolean) => {
+ mockExecute.mockImplementation((input) =>
+ Effect.succeed(
+ processOutput(
+ input.args[0] === "api" && String(input.stdin).includes("isInMergeQueue")
+ ? JSON.stringify({
+ data: { repository: { pullRequest: { id: "PR_kwDO123", isInMergeQueue } } },
+ })
+ : "",
+ ),
+ ),
+ );
+ };
+
+ it.effect("disarms auto-merge through gh while the pull request is not queued", () =>
+ Effect.gen(function* () {
+ hostQueues(false);
+ const provider = yield* GitHubPullRequestProvider.make();
+
+ yield* provider.runAction({ ...repository, number: 12, action: "disable-auto-merge" });
+
+ assert.deepStrictEqual(prCalls(), [
+ ["pr", "merge", "12", "--repo", "octocat/example-app", "--disable-auto"],
+ ]);
+ }).pipe(Effect.provide(layer)),
+ );
+
+ it.effect("takes a queued pull request out of the merge queue by id instead", () =>
+ Effect.gen(function* () {
+ hostQueues(true);
+ const provider = yield* GitHubPullRequestProvider.make();
+
+ yield* provider.runAction({ ...repository, number: 12, action: "disable-auto-merge" });
+
+ // gh's own disarm returns without touching a queued pull request, so the
+ // only write is the dequeue mutation and nothing reaches `gh pr`.
+ assert.deepStrictEqual(prCalls(), []);
+ const writes = calls().filter((call) => String(call.stdin).includes("dequeuePullRequest"));
+ assert.equal(writes.length, 1);
+ assert.match(String(writes[0]?.stdin), /"id":"PR_kwDO123"/);
+ }).pipe(Effect.provide(layer)),
+ );
+
+ it.effect("arms a green pull request whose base runs a merge queue", () =>
+ Effect.gen(function* () {
+ hostReports("CLEAN", true);
+ const provider = yield* GitHubPullRequestProvider.make();
+
+ yield* provider.runAction({
+ ...repository,
+ number: 12,
+ action: "enable-auto-merge",
+ mergeMethod: "squash",
+ });
+
+ // Under a queue this call adds the pull request to it, which is the
+ // whole point, so the refusal must not stand in the way.
+ assert.deepStrictEqual(prCalls(), [
+ ["pr", "merge", "12", "--repo", "octocat/example-app", "--auto", "--squash"],
+ ]);
+ }).pipe(Effect.provide(layer)),
+ );
});
describe("GitHubPullRequestProvider.submitReview", () => {
diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts
index bcd8f379..0725e7f3 100644
--- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts
+++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts
@@ -32,7 +32,6 @@ import {
import {
decodeGitHubPullRequestActivityJson,
decodeGitHubPullRequestDetailJson,
- decodeGitHubImmediatelyMergeableJson,
decodeGitHubRepositoryJson,
GITHUB_PULL_REQUEST_ACTIVITY_FIELDS,
GITHUB_PULL_REQUEST_DETAIL_FIELDS,
@@ -40,19 +39,25 @@ import {
import {
ADD_REACTION_GRAPHQL_MUTATION,
AUTHORED_PULL_REQUESTS_GRAPHQL_QUERY,
- BASE_COMPARISON_GRAPHQL_QUERY,
+ AUTO_MERGE_READINESS_GRAPHQL_QUERY,
buildGitHubReviewerRequestJson,
buildGitHubReviewSubmissionJson,
decodeGitHubAuthoredPullRequestsJson,
- decodeGitHubBaseComparisonJson,
+ decodeGitHubDetailBaseStateJson,
+ decodeGitHubImmediatelyMergeableJson,
+ decodeGitHubMergeQueueStandingJson,
decodeGitHubPullRequestConversationJson,
decodeGitHubPullRequestNodeIdJson,
decodeGitHubReviewerCandidatesJson,
decodeGitHubSubjectScopeJson,
+ DEQUEUE_PULL_REQUEST_GRAPHQL_MUTATION,
+ DETAIL_BASE_STATE_GRAPHQL_QUERY,
encodeGraphQlRequestJson,
gitHubAuthoredSearchQuery,
gitHubReactionContent,
+ type GitHubDetailBaseState,
type GitHubGraphQlVariable,
+ MERGE_QUEUE_STANDING_GRAPHQL_QUERY,
PULL_REQUEST_CONVERSATION_GRAPHQL_QUERY,
PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,
REACTION_SUBJECT_SCOPE_GRAPHQL_QUERY,
@@ -260,31 +265,42 @@ export const make = Effect.fn("makeGitHubPullRequestProvider")(function* () {
stdin: encodeGraphQlRequestJson({ query: input.query, variables: input.variables }),
});
+ const graphqlRead = (input: {
+ readonly operation: string;
+ readonly cwd: string;
+ readonly query: string;
+ readonly variables: Readonly
- {mergeBlock} + {primaryBlock}
) : null} {mutation.isError ? ( diff --git a/apps/web/src/components/pull-requests/pullRequests.logic.test.ts b/apps/web/src/components/pull-requests/pullRequests.logic.test.ts index 75503b6a..cf214b9d 100644 --- a/apps/web/src/components/pull-requests/pullRequests.logic.test.ts +++ b/apps/web/src/components/pull-requests/pullRequests.logic.test.ts @@ -39,7 +39,9 @@ import { pullRequestFiltersFromSearch, pullRequestFiltersToSearch, pullRequestLabelColor, + pullRequestMergeQueueLabel, resolveDefaultMergeMethod, + resolveMergeWhenReadyBlock, resolveNeedsYouReason, resolvePullRequestMergeBlock, resolvePullRequestReviewPosition, @@ -1019,6 +1021,53 @@ describe("resolvePullRequestMergeBlock", () => { }); }); +describe("resolveMergeWhenReadyBlock", () => { + it("stops only for what a merge queue cannot fix for itself", () => { + expect(resolveMergeWhenReadyBlock({ mergeability: "mergeable", isDraft: true })).toBe( + "Mark as ready first", + ); + expect(resolveMergeWhenReadyBlock({ mergeability: "conflicting", isDraft: false })).toBe( + "Resolve the conflicts first", + ); + // A stale base and a rule still waiting are the queue's own work: it + // builds its merge on the latest base and waits the checks out there. + expect(resolveMergeWhenReadyBlock({ mergeability: "mergeable", isDraft: false })).toBeNull(); + }); +}); + +describe("pullRequestMergeQueueLabel", () => { + const base = { baseBranch: "main", autoMergeEnabled: false }; + + it("says nothing where the base runs no queue", () => { + expect(pullRequestMergeQueueLabel({ ...base, autoMergeEnabled: true })).toBeNull(); + }); + + it("counts a place in the queue in English, and leaves first place bare", () => { + const at = (position: number) => + pullRequestMergeQueueLabel({ ...base, mergeQueue: { position } })?.label; + expect(at(1)).toBe("Queued"); + expect(at(2)).toBe("Queued, 2nd"); + expect(at(3)).toBe("Queued, 3rd"); + expect(at(4)).toBe("Queued, 4th"); + expect(at(11)).toBe("Queued, 11th"); + expect(at(21)).toBe("Queued, 21st"); + expect(pullRequestMergeQueueLabel({ ...base, mergeQueue: { position: 2 } })?.tooltip).toBe( + "In the merge queue for main", + ); + }); + + it("says what an armed pull request is waiting to do, and nothing when it is not armed", () => { + expect( + pullRequestMergeQueueLabel({ + ...base, + autoMergeEnabled: true, + mergeQueue: { position: null }, + })?.label, + ).toBe("Merge when ready"); + expect(pullRequestMergeQueueLabel({ ...base, mergeQueue: { position: null } })).toBeNull(); + }); +}); + describe("shouldPollPullRequestDetail", () => { const now = Date.parse("2026-09-04T12:00:00.000Z"); const check = (status: PullRequestCheck["status"]) => ({ @@ -1056,6 +1105,18 @@ describe("shouldPollPullRequestDetail", () => { shouldPollPullRequestDetail({ ...justPushed, state: "merged" as const, checks: [] }, now), ).toBe(false); }); + + it("keeps watching a pull request the host has taken into its merge queue", () => { + const inQueue = { ...settled, checks: [check("success")] }; + expect(shouldPollPullRequestDetail({ ...inQueue, mergeQueue: { position: 2 } }, now)).toBe( + true, + ); + // Merely armed under a queue is a settled state: nothing moves until the + // requirements pass, which the checks already say. + expect(shouldPollPullRequestDetail({ ...inQueue, mergeQueue: { position: null } }, now)).toBe( + false, + ); + }); }); describe("handing a review comment to a thread", () => { diff --git a/apps/web/src/components/pull-requests/pullRequests.logic.ts b/apps/web/src/components/pull-requests/pullRequests.logic.ts index 9ddc507f..d7ae7fa4 100644 --- a/apps/web/src/components/pull-requests/pullRequests.logic.ts +++ b/apps/web/src/components/pull-requests/pullRequests.logic.ts @@ -1451,6 +1451,67 @@ export function resolvePullRequestMergeBlock( return null; } +/** + * Why "Merge when ready" is off the table right now, or null when it is + * available. Only the two things a merge queue cannot fix for itself: the queue + * builds its own merge on top of the latest base, so a branch that is behind or + * waiting on a rule is exactly what it is for. + */ +export function resolveMergeWhenReadyBlock( + detail: Pick