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:
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user