Surface GitHub auto-merge actions (#1001)

* Surface GitHub auto-merge actions

* Namespace GitHub auto-merge RPC

* Update workspace git refresh test expectation

* Avoid redundant GitHub refresh queue
This commit is contained in:
Mohamed Boudra
2026-05-14 17:48:05 +08:00
committed by GitHub
parent 44292b2b69
commit eb7fa02ac8
27 changed files with 3029 additions and 105 deletions
+365 -30
View File
@@ -1,17 +1,43 @@
import { describe, expect, it } from "vitest";
import { CheckoutPrStatusSchema } from "@server/shared/messages";
import { buildGitActions, type BuildGitActionsInput } from "./policy";
function githubStatus(
overrides: Partial<NonNullable<BuildGitActionsInput["pullRequestGithub"]>> = {},
): NonNullable<BuildGitActionsInput["pullRequestGithub"]> {
return {
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: false,
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod: "SQUASH",
},
isMergeQueueEnabled: false,
isInMergeQueue: false,
...overrides,
};
}
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
return {
isGit: true,
githubFeaturesEnabled: true,
githubAutoMergeActionsEnabled: true,
hasPullRequest: false,
pullRequestUrl: null,
pullRequestState: null,
pullRequestIsDraft: false,
pullRequestIsMerged: false,
pullRequestMergeable: "UNKNOWN",
pullRequestGithub: null,
hasRemote: false,
isPaseoOwnedWorktree: false,
isOnBaseBranch: true,
@@ -65,6 +91,26 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
status: "idle",
handler: () => undefined,
},
"enable-pr-auto-merge-squash": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"enable-pr-auto-merge-merge": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"enable-pr-auto-merge-rebase": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"disable-pr-auto-merge": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-branch": {
disabled: false,
status: "idle",
@@ -167,6 +213,9 @@ describe("git-actions-policy", () => {
behindBaseCount: 1,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
}),
);
@@ -177,9 +226,6 @@ describe("git-actions-policy", () => {
"merge-from-base",
"merge-branch",
"pr",
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
]);
expect(
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
@@ -246,6 +292,28 @@ describe("git-actions-policy", () => {
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Squash and merge",
});
});
it("uses GitHub merge state, not mergeable, for direct merge readiness", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "UNKNOWN",
pullRequestGithub: githubStatus({ mergeStateStatus: "CLEAN" }),
shipDefault: "pr",
}),
);
@@ -266,6 +334,7 @@ describe("git-actions-policy", () => {
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
shipDefault: "pr",
}),
);
@@ -278,6 +347,7 @@ describe("git-actions-policy", () => {
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
shipDefault: "merge",
}),
);
@@ -305,6 +375,7 @@ describe("git-actions-policy", () => {
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
}),
);
@@ -321,6 +392,73 @@ describe("git-actions-policy", () => {
]);
});
it("keeps the visible PR action model stable on feature branches", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
}),
);
const pullRequestActions = actions.secondary
.filter((action) =>
["pr", "merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
)
.map((action) => ({
id: action.id,
label: action.label,
pendingLabel: action.pendingLabel,
successLabel: action.successLabel,
disabled: action.disabled,
unavailableMessage: action.unavailableMessage,
startsGroup: action.startsGroup,
}));
expect(pullRequestActions).toEqual([
{
id: "pr",
label: "View PR",
pendingLabel: "View PR",
successLabel: "View PR",
disabled: false,
unavailableMessage: undefined,
startsGroup: false,
},
{
id: "merge-pr-squash",
label: "Squash and merge",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
unavailableMessage: undefined,
startsGroup: true,
},
{
id: "merge-pr-merge",
label: "Create a merge commit",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
unavailableMessage: undefined,
startsGroup: false,
},
{
id: "merge-pr-rebase",
label: "Rebase and merge",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
unavailableMessage: undefined,
startsGroup: false,
},
]);
});
it("uses Merge locally for the local merge action", () => {
const actions = buildGitActions(
createInput({
@@ -334,27 +472,11 @@ describe("git-actions-policy", () => {
});
it.each([
[
"draft",
{ pullRequestIsDraft: true },
"Merge PR isn't available because the pull request is still a draft",
],
[
"merged",
{ pullRequestIsMerged: true },
"Merge PR isn't available because the pull request is already merged",
],
[
"closed",
{ pullRequestState: "closed" as const },
"Merge PR isn't available because the pull request is closed",
],
[
"conflicting",
{ pullRequestMergeable: "CONFLICTING" as const },
"Merge PR isn't available because the pull request has conflicts",
],
])("marks merge-pr actions unavailable when the PR is %s", (_name, overrides, message) => {
["draft", { pullRequestIsDraft: true }],
["merged", { pullRequestIsMerged: true }],
["closed", { pullRequestState: "closed" as const }],
["conflicting", { pullRequestMergeable: "CONFLICTING" as const }],
])("does not offer direct merge actions when the PR is %s", (_name, overrides) => {
const actions = buildGitActions(
createInput({
hasRemote: true,
@@ -364,6 +486,7 @@ describe("git-actions-policy", () => {
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
...overrides,
}),
);
@@ -371,13 +494,224 @@ describe("git-actions-policy", () => {
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
);
expect(mergePrActions).toHaveLength(3);
expect(mergePrActions.map((action) => action.unavailableMessage)).toEqual([
message,
message,
message,
expect(mergePrActions).toEqual([]);
expect(actions.secondary).toEqual(
expect.arrayContaining([expect.objectContaining({ id: "pr", label: "View PR" })]),
);
});
it("preserves legacy direct merge actions when old payloads have no GitHub facts", () => {
const oldDaemonStatus = CheckoutPrStatusSchema.parse({
number: 456,
url: "https://example.com/pr/456",
title: "Legacy payload",
state: "open",
baseRefName: "main",
headRefName: "feature",
isMerged: false,
mergeable: "MERGEABLE",
});
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: oldDaemonStatus.url,
pullRequestState: "open",
pullRequestIsDraft: oldDaemonStatus.isDraft,
pullRequestIsMerged: oldDaemonStatus.isMerged,
pullRequestMergeable: oldDaemonStatus.mergeable,
pullRequestGithub: oldDaemonStatus.github,
shipDefault: "pr",
}),
);
expect(oldDaemonStatus.github).toBeUndefined();
expect(actions.primary).toMatchObject({ id: "merge-pr-squash", label: "Squash and merge" });
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"merge-from-base",
"merge-branch",
"pr",
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
]);
expect(mergePrActions.map((action) => action.disabled)).toEqual([true, true, true]);
});
it("requires GitHub's direct-merge allowlist before promoting PR merge", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/993",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({
mergeStateStatus: "BLOCKED",
viewerCanEnableAutoMerge: true,
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: false,
squashMergeAllowed: true,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
},
}),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({
id: "enable-pr-auto-merge-squash",
label: "Enable auto-merge with squash",
});
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"merge-from-base",
"merge-branch",
"pr",
"enable-pr-auto-merge-squash",
]);
expect(
actions.secondary.some((action) =>
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
),
).toBe(false);
});
it("does not offer auto-merge when the daemon feature gate is missing", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/993",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({
mergeStateStatus: "BLOCKED",
viewerCanEnableAutoMerge: true,
}),
githubAutoMergeActionsEnabled: false,
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({ id: "pr", label: "View PR" });
expect(actions.secondary.some((action) => action.id.startsWith("enable-pr-auto-merge"))).toBe(
false,
);
});
it("shows existing auto-merge as state and disables it when the viewer can", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({
autoMergeRequest: {
enabledAt: "2026-05-13T12:00:00Z",
mergeMethod: "SQUASH",
enabledBy: "octocat",
},
viewerCanDisableAutoMerge: true,
}),
}),
);
expect(actions.secondary).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "disable-pr-auto-merge",
label: "Auto-merge enabled",
disabled: false,
unavailableMessage: undefined,
}),
]),
);
expect(
actions.secondary.some((action) =>
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
),
).toBe(false);
});
it("respects repository merge method policy for direct merge actions", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: false,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
},
}),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({
id: "merge-pr-merge",
label: "Create a merge commit",
});
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"merge-from-base",
"merge-branch",
"pr",
"merge-pr-merge",
]);
});
it("does not treat merge queue repositories as direct mergeable", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({
mergeStateStatus: "CLEAN",
isMergeQueueEnabled: true,
}),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({ id: "pr", label: "View PR" });
expect(
actions.secondary.some((action) =>
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
),
).toBe(false);
});
it("groups merge-pr actions behind their own menu separator via startsGroup", () => {
@@ -390,6 +724,7 @@ describe("git-actions-policy", () => {
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
isPaseoOwnedWorktree: true,
}),
);