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:
@@ -97,6 +97,13 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
||||
[theme.colors.foreground, toast],
|
||||
);
|
||||
|
||||
const handlePrimaryPress = useCallback(() => {
|
||||
if (!gitActions.primary) {
|
||||
return;
|
||||
}
|
||||
handleActionSelect(gitActions.primary);
|
||||
}, [gitActions.primary, handleActionSelect]);
|
||||
|
||||
const overflowMenuButtonStyle = useMemo(() => [styles.iconButton, styles.overflowMenuButton], []);
|
||||
|
||||
const primaryDisabled = gitActions.primary?.disabled;
|
||||
@@ -124,7 +131,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
||||
<Pressable
|
||||
testID="changes-primary-cta"
|
||||
style={primaryPressableStyle}
|
||||
onPress={gitActions.primary.handler}
|
||||
onPress={handlePrimaryPress}
|
||||
disabled={gitActions.primary.disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={gitActions.primary.label}
|
||||
|
||||
@@ -170,6 +170,91 @@ describe("checkout-git-actions-store", () => {
|
||||
).toBe("idle");
|
||||
});
|
||||
|
||||
it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => {
|
||||
const client = {
|
||||
checkoutGithubSetAutoMerge: vi.fn(async () => ({
|
||||
enabled: true,
|
||||
success: true,
|
||||
error: null,
|
||||
})),
|
||||
};
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { checkoutGithubSetAutoMerge: true },
|
||||
});
|
||||
|
||||
await useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.enablePrAutoMerge({ serverId, cwd, method: "squash" });
|
||||
|
||||
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, {
|
||||
enabled: true,
|
||||
method: "squash",
|
||||
});
|
||||
expect(
|
||||
useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
|
||||
).toBe("success");
|
||||
});
|
||||
|
||||
it("disables PR auto-merge when the daemon advertises auto-merge actions", async () => {
|
||||
const client = {
|
||||
checkoutGithubSetAutoMerge: vi.fn(async () => ({
|
||||
enabled: false,
|
||||
success: true,
|
||||
error: null,
|
||||
})),
|
||||
};
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { checkoutGithubSetAutoMerge: true },
|
||||
});
|
||||
|
||||
await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd });
|
||||
|
||||
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false });
|
||||
expect(
|
||||
useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
|
||||
).toBe("success");
|
||||
});
|
||||
|
||||
it("does not call PR auto-merge RPCs when the daemon lacks the feature flag", async () => {
|
||||
const client = {
|
||||
checkoutGithubSetAutoMerge: vi.fn(async () => ({
|
||||
enabled: true,
|
||||
success: true,
|
||||
error: null,
|
||||
})),
|
||||
};
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
useCheckoutGitActionsStore.getState().enablePrAutoMerge({ serverId, cwd, method: "merge" }),
|
||||
).rejects.toThrow("Update the host to use GitHub auto-merge actions.");
|
||||
|
||||
expect(client.checkoutGithubSetAutoMerge).not.toHaveBeenCalled();
|
||||
expect(
|
||||
useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
|
||||
).toBe("idle");
|
||||
});
|
||||
|
||||
it("hides an archived worktree optimistically while the archive RPC is in flight", async () => {
|
||||
const deferred = createDeferred<Record<string, never>>();
|
||||
const client = {
|
||||
|
||||
@@ -32,6 +32,10 @@ export type CheckoutGitAsyncActionId =
|
||||
| "merge-pr-squash"
|
||||
| "merge-pr-merge"
|
||||
| "merge-pr-rebase"
|
||||
| "enable-pr-auto-merge-squash"
|
||||
| "enable-pr-auto-merge-merge"
|
||||
| "enable-pr-auto-merge-rebase"
|
||||
| "disable-pr-auto-merge"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
@@ -52,6 +56,13 @@ function resolveClient(serverId: string) {
|
||||
return client;
|
||||
}
|
||||
|
||||
function assertGitHubAutoMergeActionsSupported(serverId: string) {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge !== true) {
|
||||
throw new Error("Update the host to use GitHub auto-merge actions.");
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(
|
||||
key: CheckoutKey,
|
||||
actionId: CheckoutGitAsyncActionId,
|
||||
@@ -231,6 +242,12 @@ interface CheckoutGitActionsStoreState {
|
||||
cwd: string;
|
||||
method: CheckoutPrMergeMethod;
|
||||
}) => Promise<void>;
|
||||
enablePrAutoMerge: (params: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
method: CheckoutPrMergeMethod;
|
||||
}) => Promise<void>;
|
||||
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
archiveWorktree: (params: {
|
||||
@@ -392,6 +409,38 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
});
|
||||
},
|
||||
|
||||
enablePrAutoMerge: async ({ serverId, cwd, method }) => {
|
||||
assertGitHubAutoMergeActionsSupported(serverId);
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: `enable-pr-auto-merge-${method}`,
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
disablePrAutoMerge: async ({ serverId, cwd }) => {
|
||||
assertGitHubAutoMergeActionsSupported(serverId);
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: "disable-pr-auto-merge",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: false });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
mergeBranch: async ({ serverId, cwd, baseRef }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
+369
-39
@@ -1,7 +1,11 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import type { ActionStatus } from "@/components/ui/dropdown-menu";
|
||||
import type { CheckoutPrMergeMethod, PullRequestMergeable } from "@server/shared/messages";
|
||||
import type {
|
||||
CheckoutPrMergeMethod,
|
||||
CheckoutPrStatusResponse,
|
||||
PullRequestMergeable,
|
||||
} from "@server/shared/messages";
|
||||
|
||||
export type GitActionId =
|
||||
| "commit"
|
||||
@@ -12,6 +16,10 @@ export type GitActionId =
|
||||
| "merge-pr-squash"
|
||||
| "merge-pr-merge"
|
||||
| "merge-pr-rebase"
|
||||
| "enable-pr-auto-merge-squash"
|
||||
| "enable-pr-auto-merge-merge"
|
||||
| "enable-pr-auto-merge-rebase"
|
||||
| "disable-pr-auto-merge"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
@@ -46,12 +54,14 @@ interface GitActionRuntimeState {
|
||||
export interface BuildGitActionsInput {
|
||||
isGit: boolean;
|
||||
githubFeaturesEnabled: boolean;
|
||||
githubAutoMergeActionsEnabled: boolean;
|
||||
hasPullRequest: boolean;
|
||||
pullRequestUrl: string | null;
|
||||
pullRequestState: "open" | "closed" | null;
|
||||
pullRequestIsDraft: boolean;
|
||||
pullRequestIsMerged: boolean;
|
||||
pullRequestMergeable: PullRequestMergeable;
|
||||
pullRequestGithub: PullRequestGithubStatus | null;
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isOnBaseBranch: boolean;
|
||||
@@ -67,24 +77,117 @@ export interface BuildGitActionsInput {
|
||||
runtime: Record<GitActionId, GitActionRuntimeState>;
|
||||
}
|
||||
|
||||
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
|
||||
const FEATURE_ACTION_IDS: GitActionId[] = [
|
||||
"merge-from-base",
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-pr-squash",
|
||||
"merge-pr-merge",
|
||||
"merge-pr-rebase",
|
||||
type PullRequestActionId = Extract<
|
||||
GitActionId,
|
||||
| "pr"
|
||||
| "merge-pr-squash"
|
||||
| "merge-pr-merge"
|
||||
| "merge-pr-rebase"
|
||||
| "enable-pr-auto-merge-squash"
|
||||
| "enable-pr-auto-merge-merge"
|
||||
| "enable-pr-auto-merge-rebase"
|
||||
| "disable-pr-auto-merge"
|
||||
>;
|
||||
type PullRequestDirectMergeActionId = Extract<
|
||||
GitActionId,
|
||||
"merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase"
|
||||
>;
|
||||
type PullRequestAutoMergeEnableActionId = Extract<
|
||||
GitActionId,
|
||||
"enable-pr-auto-merge-squash" | "enable-pr-auto-merge-merge" | "enable-pr-auto-merge-rebase"
|
||||
>;
|
||||
type PullRequestActionRole = "status" | "direct" | "auto";
|
||||
type PullRequestGithubStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>["github"];
|
||||
|
||||
interface PullRequestActionModel {
|
||||
readonly id: PullRequestActionId;
|
||||
readonly role: PullRequestActionRole;
|
||||
readonly build: (input: BuildGitActionsInput) => GitAction;
|
||||
}
|
||||
|
||||
interface PullRequestDirectMergeActionModel {
|
||||
readonly id: PullRequestDirectMergeActionId;
|
||||
readonly role: "direct";
|
||||
readonly label: string;
|
||||
readonly method: CheckoutPrMergeMethod;
|
||||
readonly startsGroup: boolean;
|
||||
}
|
||||
|
||||
interface PullRequestAutoMergeEnableActionModel {
|
||||
readonly id: PullRequestAutoMergeEnableActionId;
|
||||
readonly role: "auto";
|
||||
readonly label: string;
|
||||
readonly method: CheckoutPrMergeMethod;
|
||||
readonly startsGroup: boolean;
|
||||
}
|
||||
|
||||
const PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS = [
|
||||
{
|
||||
id: "merge-pr-squash",
|
||||
role: "direct",
|
||||
label: "Squash and merge",
|
||||
method: "squash",
|
||||
startsGroup: true,
|
||||
},
|
||||
{
|
||||
id: "merge-pr-merge",
|
||||
role: "direct",
|
||||
label: "Create a merge commit",
|
||||
method: "merge",
|
||||
startsGroup: false,
|
||||
},
|
||||
{
|
||||
id: "merge-pr-rebase",
|
||||
role: "direct",
|
||||
label: "Rebase and merge",
|
||||
method: "rebase",
|
||||
startsGroup: false,
|
||||
},
|
||||
] as const satisfies readonly PullRequestDirectMergeActionModel[];
|
||||
|
||||
const PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS = [
|
||||
{
|
||||
id: "enable-pr-auto-merge-squash",
|
||||
role: "auto",
|
||||
label: "Enable auto-merge with squash",
|
||||
method: "squash",
|
||||
startsGroup: true,
|
||||
},
|
||||
{
|
||||
id: "enable-pr-auto-merge-merge",
|
||||
role: "auto",
|
||||
label: "Enable auto-merge with merge commit",
|
||||
method: "merge",
|
||||
startsGroup: false,
|
||||
},
|
||||
{
|
||||
id: "enable-pr-auto-merge-rebase",
|
||||
role: "auto",
|
||||
label: "Enable auto-merge with rebase",
|
||||
method: "rebase",
|
||||
startsGroup: false,
|
||||
},
|
||||
] as const satisfies readonly PullRequestAutoMergeEnableActionModel[];
|
||||
|
||||
const PULL_REQUEST_ACTION_MODELS: readonly PullRequestActionModel[] = [
|
||||
{ id: "pr", role: "status", build: buildPrAction },
|
||||
...PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS.map((model) => ({
|
||||
...model,
|
||||
build: (input: BuildGitActionsInput) => buildDirectPullRequestMergeAction(input, model),
|
||||
})),
|
||||
...PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS.map((model) => ({
|
||||
...model,
|
||||
build: (input: BuildGitActionsInput) => buildEnablePullRequestAutoMergeAction(input, model),
|
||||
})),
|
||||
{
|
||||
id: "disable-pr-auto-merge",
|
||||
role: "auto",
|
||||
build: buildDisablePullRequestAutoMergeAction,
|
||||
},
|
||||
];
|
||||
|
||||
const MERGE_PR_METHODS: Record<
|
||||
"merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase",
|
||||
{ label: string; method: CheckoutPrMergeMethod }
|
||||
> = {
|
||||
"merge-pr-squash": { label: "Squash and merge", method: "squash" },
|
||||
"merge-pr-merge": { label: "Create a merge commit", method: "merge" },
|
||||
"merge-pr-rebase": { label: "Rebase and merge", method: "rebase" },
|
||||
};
|
||||
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
|
||||
const GITHUB_DIRECT_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]);
|
||||
|
||||
export function narrowPullRequestState(state: string | null | undefined): "open" | "closed" | null {
|
||||
if (state === "open") return "open";
|
||||
@@ -152,11 +255,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
handler: input.runtime["pull-and-push"].handler,
|
||||
});
|
||||
|
||||
allActions.set("pr", buildPrAction(input));
|
||||
|
||||
allActions.set("merge-pr-squash", buildMergePrAction(input, "merge-pr-squash"));
|
||||
allActions.set("merge-pr-merge", buildMergePrAction(input, "merge-pr-merge"));
|
||||
allActions.set("merge-pr-rebase", buildMergePrAction(input, "merge-pr-rebase"));
|
||||
for (const model of PULL_REQUEST_ACTION_MODELS) {
|
||||
allActions.set(model.id, model.build(input));
|
||||
}
|
||||
|
||||
allActions.set("merge-branch", {
|
||||
id: "merge-branch",
|
||||
@@ -209,7 +310,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
|
||||
const secondaryIds = [...REMOTE_ACTION_IDS];
|
||||
if (!input.isOnBaseBranch) {
|
||||
secondaryIds.push(...FEATURE_ACTION_IDS);
|
||||
secondaryIds.push(...getFeatureActionIds(input));
|
||||
}
|
||||
if (input.isPaseoOwnedWorktree) {
|
||||
secondaryIds.push("archive-worktree");
|
||||
@@ -239,7 +340,10 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
return "merge-from-base";
|
||||
}
|
||||
if (canMergePr(input) && input.shipDefault === "pr") {
|
||||
return "merge-pr-squash";
|
||||
return getDefaultDirectPullRequestMergeActionId(input);
|
||||
}
|
||||
if (canEnablePrAutoMerge(input) && input.shipDefault === "pr") {
|
||||
return getDefaultEnablePullRequestAutoMergeActionId(input);
|
||||
}
|
||||
if (!input.isOnBaseBranch && input.aheadCount > 0 && input.shipDefault === "merge") {
|
||||
return "merge-branch";
|
||||
@@ -253,6 +357,41 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPullRequestActionIds(filter: {
|
||||
roles: readonly PullRequestActionRole[];
|
||||
input: BuildGitActionsInput;
|
||||
}): PullRequestActionId[] {
|
||||
return PULL_REQUEST_ACTION_MODELS.filter((model) => filter.roles.includes(model.role))
|
||||
.filter((model) => shouldShowPullRequestAction(filter.input, model.id))
|
||||
.map((model) => model.id);
|
||||
}
|
||||
|
||||
function getFeatureActionIds(input: BuildGitActionsInput): GitActionId[] {
|
||||
return [
|
||||
"merge-from-base",
|
||||
"merge-branch",
|
||||
...getPullRequestActionIds({ roles: ["status", "direct", "auto"], input }),
|
||||
];
|
||||
}
|
||||
|
||||
function getDefaultDirectPullRequestMergeActionId(
|
||||
input: BuildGitActionsInput,
|
||||
): PullRequestDirectMergeActionId {
|
||||
return (
|
||||
getPreferredDirectPullRequestMergeActionModel(input)?.id ??
|
||||
PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS[0].id
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultEnablePullRequestAutoMergeActionId(
|
||||
input: BuildGitActionsInput,
|
||||
): PullRequestAutoMergeEnableActionId {
|
||||
return (
|
||||
getPreferredEnablePullRequestAutoMergeActionModel(input)?.id ??
|
||||
PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS[0].id
|
||||
);
|
||||
}
|
||||
|
||||
function buildPrAction(input: BuildGitActionsInput): GitAction {
|
||||
if (input.hasPullRequest && input.pullRequestUrl) {
|
||||
return {
|
||||
@@ -288,23 +427,60 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
|
||||
};
|
||||
}
|
||||
|
||||
function buildMergePrAction(
|
||||
function buildDirectPullRequestMergeAction(
|
||||
input: BuildGitActionsInput,
|
||||
id: "merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase",
|
||||
model: PullRequestDirectMergeActionModel,
|
||||
): GitAction {
|
||||
const runtime = input.runtime[id];
|
||||
const config = MERGE_PR_METHODS[id];
|
||||
const runtime = input.runtime[model.id];
|
||||
const unavailableMessage = getMergePrUnavailableMessage(input);
|
||||
return {
|
||||
id,
|
||||
label: config.label,
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
pendingLabel: "Merging PR...",
|
||||
successLabel: "PR merged",
|
||||
disabled: runtime.disabled || shouldDisableMergePrAction(input),
|
||||
status: runtime.status,
|
||||
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
|
||||
icon: runtime.icon,
|
||||
startsGroup: id === "merge-pr-squash",
|
||||
startsGroup: model.startsGroup,
|
||||
handler: runtime.handler,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEnablePullRequestAutoMergeAction(
|
||||
input: BuildGitActionsInput,
|
||||
model: PullRequestAutoMergeEnableActionModel,
|
||||
): GitAction {
|
||||
const runtime = input.runtime[model.id];
|
||||
return {
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
pendingLabel: "Enabling auto-merge...",
|
||||
successLabel: "Auto-merge enabled",
|
||||
disabled: runtime.disabled,
|
||||
status: runtime.status,
|
||||
icon: runtime.icon,
|
||||
startsGroup: model.startsGroup,
|
||||
handler: runtime.handler,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): GitAction {
|
||||
const runtime = input.runtime["disable-pr-auto-merge"];
|
||||
const unavailableMessage =
|
||||
input.pullRequestGithub?.viewerCanDisableAutoMerge === true
|
||||
? undefined
|
||||
: "Auto-merge is enabled, but this account can't disable it";
|
||||
return {
|
||||
id: "disable-pr-auto-merge",
|
||||
label: "Auto-merge enabled",
|
||||
pendingLabel: "Disabling auto-merge...",
|
||||
successLabel: "Auto-merge disabled",
|
||||
disabled: runtime.disabled || input.pullRequestGithub?.viewerCanDisableAutoMerge !== true,
|
||||
status: runtime.status,
|
||||
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
|
||||
icon: runtime.icon,
|
||||
startsGroup: true,
|
||||
handler: runtime.handler,
|
||||
};
|
||||
}
|
||||
@@ -327,18 +503,55 @@ function canMergeFromBase(input: BuildGitActionsInput): boolean {
|
||||
}
|
||||
|
||||
function canMergePr(input: BuildGitActionsInput): boolean {
|
||||
return (
|
||||
const github = input.pullRequestGithub;
|
||||
const canMergeFromTopLevelStatus =
|
||||
input.githubFeaturesEnabled &&
|
||||
input.hasPullRequest &&
|
||||
input.pullRequestState === "open" &&
|
||||
!input.pullRequestIsDraft &&
|
||||
!input.pullRequestIsMerged &&
|
||||
input.pullRequestMergeable === "MERGEABLE" &&
|
||||
input.pullRequestMergeable !== "CONFLICTING" &&
|
||||
input.aheadCount > 0 &&
|
||||
!input.hasUncommittedChanges &&
|
||||
input.behindOfOrigin === 0 &&
|
||||
input.aheadOfOrigin === 0 &&
|
||||
!canMergeFromBase(input)
|
||||
!canMergeFromBase(input);
|
||||
|
||||
if (!canMergeFromTopLevelStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasPullRequestGithubFacts(github)) {
|
||||
return input.pullRequestMergeable === "MERGEABLE";
|
||||
}
|
||||
|
||||
return (
|
||||
GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? "") &&
|
||||
github.autoMergeRequest === null &&
|
||||
!github.isMergeQueueEnabled &&
|
||||
!github.isInMergeQueue &&
|
||||
getAllowedDirectPullRequestMergeActionModels(input).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function canEnablePrAutoMerge(input: BuildGitActionsInput): boolean {
|
||||
const github = input.pullRequestGithub;
|
||||
return (
|
||||
input.githubFeaturesEnabled &&
|
||||
input.githubAutoMergeActionsEnabled &&
|
||||
input.hasPullRequest &&
|
||||
input.pullRequestState === "open" &&
|
||||
!input.pullRequestIsDraft &&
|
||||
!input.pullRequestIsMerged &&
|
||||
input.pullRequestMergeable !== "CONFLICTING" &&
|
||||
hasPullRequestGithubFacts(github) &&
|
||||
github.autoMergeRequest === null &&
|
||||
github.mergeStateStatus === "BLOCKED" &&
|
||||
github.repository.autoMergeAllowed &&
|
||||
github.viewerCanEnableAutoMerge &&
|
||||
!github.isMergeQueueEnabled &&
|
||||
!github.isInMergeQueue &&
|
||||
getAllowedAutoMergeEnableActionModels(input).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -436,14 +649,131 @@ function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | und
|
||||
if (input.pullRequestMergeable === "CONFLICTING") {
|
||||
return "Merge PR isn't available because the pull request has conflicts";
|
||||
}
|
||||
if (!hasPullRequestGithubFacts(input.pullRequestGithub)) {
|
||||
return undefined;
|
||||
}
|
||||
if (input.pullRequestGithub?.isMergeQueueEnabled || input.pullRequestGithub?.isInMergeQueue) {
|
||||
return "Merge PR isn't available here because this repository uses a merge queue";
|
||||
}
|
||||
if (!GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(input.pullRequestGithub?.mergeStateStatus ?? "")) {
|
||||
return "Merge PR isn't available until GitHub reports the pull request is ready to merge";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldDisableMergePrAction(input: BuildGitActionsInput): boolean {
|
||||
return (
|
||||
input.pullRequestIsDraft ||
|
||||
input.pullRequestIsMerged ||
|
||||
input.pullRequestState === "closed" ||
|
||||
input.pullRequestMergeable === "CONFLICTING"
|
||||
return !canMergePr(input);
|
||||
}
|
||||
|
||||
function shouldShowPullRequestAction(
|
||||
input: BuildGitActionsInput,
|
||||
id: PullRequestActionId,
|
||||
): boolean {
|
||||
if (id === "pr") {
|
||||
return true;
|
||||
}
|
||||
if (id === "disable-pr-auto-merge") {
|
||||
return (
|
||||
input.githubAutoMergeActionsEnabled &&
|
||||
hasPullRequestGithubFacts(input.pullRequestGithub) &&
|
||||
input.pullRequestGithub.autoMergeRequest !== null
|
||||
);
|
||||
}
|
||||
if (isDirectPullRequestMergeActionId(id)) {
|
||||
return canMergePr(input) && getAllowedDirectPullRequestMergeActionIds(input).includes(id);
|
||||
}
|
||||
if (isEnablePullRequestAutoMergeActionId(id)) {
|
||||
return canEnablePrAutoMerge(input) && getAllowedAutoMergeEnableActionIds(input).includes(id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isDirectPullRequestMergeActionId(
|
||||
id: PullRequestActionId,
|
||||
): id is PullRequestDirectMergeActionId {
|
||||
return PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS.some((model) => model.id === id);
|
||||
}
|
||||
|
||||
function isEnablePullRequestAutoMergeActionId(
|
||||
id: PullRequestActionId,
|
||||
): id is PullRequestAutoMergeEnableActionId {
|
||||
return PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS.some((model) => model.id === id);
|
||||
}
|
||||
|
||||
function getAllowedDirectPullRequestMergeActionIds(
|
||||
input: BuildGitActionsInput,
|
||||
): PullRequestDirectMergeActionId[] {
|
||||
return getAllowedDirectPullRequestMergeActionModels(input).map((model) => model.id);
|
||||
}
|
||||
|
||||
function getAllowedAutoMergeEnableActionIds(
|
||||
input: BuildGitActionsInput,
|
||||
): PullRequestAutoMergeEnableActionId[] {
|
||||
return getAllowedAutoMergeEnableActionModels(input).map((model) => model.id);
|
||||
}
|
||||
|
||||
function getAllowedDirectPullRequestMergeActionModels(
|
||||
input: BuildGitActionsInput,
|
||||
): readonly PullRequestDirectMergeActionModel[] {
|
||||
return PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS.filter((model) =>
|
||||
isPullRequestMergeMethodAllowed(input, model.method),
|
||||
);
|
||||
}
|
||||
|
||||
function getAllowedAutoMergeEnableActionModels(
|
||||
input: BuildGitActionsInput,
|
||||
): readonly PullRequestAutoMergeEnableActionModel[] {
|
||||
return PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS.filter((model) =>
|
||||
isPullRequestMergeMethodAllowed(input, model.method),
|
||||
);
|
||||
}
|
||||
|
||||
function getPreferredDirectPullRequestMergeActionModel(
|
||||
input: BuildGitActionsInput,
|
||||
): PullRequestDirectMergeActionModel | null {
|
||||
const allowed = getAllowedDirectPullRequestMergeActionModels(input);
|
||||
const preferred = normalizeGithubMergeMethod(
|
||||
input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null,
|
||||
);
|
||||
return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null;
|
||||
}
|
||||
|
||||
function getPreferredEnablePullRequestAutoMergeActionModel(
|
||||
input: BuildGitActionsInput,
|
||||
): PullRequestAutoMergeEnableActionModel | null {
|
||||
const allowed = getAllowedAutoMergeEnableActionModels(input);
|
||||
const preferred = normalizeGithubMergeMethod(
|
||||
input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null,
|
||||
);
|
||||
return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null;
|
||||
}
|
||||
|
||||
function isPullRequestMergeMethodAllowed(
|
||||
input: BuildGitActionsInput,
|
||||
method: CheckoutPrMergeMethod,
|
||||
): boolean {
|
||||
const repository = input.pullRequestGithub?.repository;
|
||||
if (!repository) {
|
||||
return true;
|
||||
}
|
||||
if (method === "squash") {
|
||||
return repository.squashMergeAllowed;
|
||||
}
|
||||
if (method === "merge") {
|
||||
return repository.mergeCommitAllowed;
|
||||
}
|
||||
return repository.rebaseMergeAllowed;
|
||||
}
|
||||
|
||||
function hasPullRequestGithubFacts(
|
||||
github: PullRequestGithubStatus | null,
|
||||
): github is NonNullable<PullRequestGithubStatus> {
|
||||
return github !== null && github !== undefined;
|
||||
}
|
||||
|
||||
function normalizeGithubMergeMethod(value: string | null): CheckoutPrMergeMethod | null {
|
||||
if (value === "SQUASH") return "squash";
|
||||
if (value === "MERGE") return "merge";
|
||||
if (value === "REBASE") return "rebase";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,24 @@ import {
|
||||
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
|
||||
const githubStatus: CheckoutPrStatus["github"] = {
|
||||
mergeStateStatus: null,
|
||||
autoMergeRequest: null,
|
||||
viewerCanEnableAutoMerge: false,
|
||||
viewerCanDisableAutoMerge: false,
|
||||
viewerCanMergeAsAdmin: false,
|
||||
viewerCanUpdateBranch: false,
|
||||
repository: {
|
||||
autoMergeAllowed: false,
|
||||
mergeCommitAllowed: false,
|
||||
squashMergeAllowed: false,
|
||||
rebaseMergeAllowed: false,
|
||||
viewerDefaultMergeMethod: null,
|
||||
},
|
||||
isMergeQueueEnabled: false,
|
||||
isInMergeQueue: false,
|
||||
};
|
||||
|
||||
const baseStatus: CheckoutPrStatus = {
|
||||
number: 42,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
@@ -26,6 +44,7 @@ const baseStatus: CheckoutPrStatus = {
|
||||
mergeable: "UNKNOWN",
|
||||
checks: [],
|
||||
reviewDecision: null,
|
||||
github: githubStatus,
|
||||
};
|
||||
|
||||
const baseTimeline: PullRequestTimeline = {
|
||||
|
||||
@@ -246,6 +246,20 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
s.getStatus({ serverId, cwd, actionId: "merge-pr-rebase" }),
|
||||
),
|
||||
};
|
||||
const enablePrAutoMergeStatuses: Record<CheckoutPrMergeMethod, CheckoutGitActionStatus> = {
|
||||
squash: useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
|
||||
),
|
||||
merge: useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
|
||||
),
|
||||
rebase: useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-rebase" }),
|
||||
),
|
||||
};
|
||||
const disablePrAutoMergeStatus = useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
|
||||
);
|
||||
const mergeStatus = useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "merge-branch" }),
|
||||
);
|
||||
@@ -262,9 +276,14 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
const runPullAndPush = useCheckoutGitActionsStore((s) => s.pullAndPush);
|
||||
const runCreatePr = useCheckoutGitActionsStore((s) => s.createPr);
|
||||
const runMergePr = useCheckoutGitActionsStore((s) => s.mergePr);
|
||||
const runEnablePrAutoMerge = useCheckoutGitActionsStore((s) => s.enablePrAutoMerge);
|
||||
const runDisablePrAutoMerge = useCheckoutGitActionsStore((s) => s.disablePrAutoMerge);
|
||||
const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch);
|
||||
const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase);
|
||||
const runArchiveWorktree = useCheckoutGitActionsStore((s) => s.archiveWorktree);
|
||||
const githubAutoMergeActionsEnabled = useSessionStore(
|
||||
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true,
|
||||
);
|
||||
|
||||
const toastActionError = useCallback(
|
||||
(error: unknown, fallback: string) => {
|
||||
@@ -354,6 +373,32 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
[cwd, persistShipDefault, runMergePr, serverId, toastActionError, toastActionSuccess],
|
||||
);
|
||||
|
||||
const handleEnablePrAutoMerge = useCallback(
|
||||
(method: CheckoutPrMergeMethod) => {
|
||||
void persistShipDefault("pr");
|
||||
void runEnablePrAutoMerge({ serverId, cwd, method })
|
||||
.then(() => {
|
||||
toastActionSuccess("Auto-merge enabled");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to enable auto-merge");
|
||||
});
|
||||
},
|
||||
[cwd, persistShipDefault, runEnablePrAutoMerge, serverId, toastActionError, toastActionSuccess],
|
||||
);
|
||||
|
||||
const handleDisablePrAutoMerge = useCallback(() => {
|
||||
void runDisablePrAutoMerge({ serverId, cwd })
|
||||
.then(() => {
|
||||
toastActionSuccess("Auto-merge disabled");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to disable auto-merge");
|
||||
});
|
||||
}, [cwd, runDisablePrAutoMerge, serverId, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handleMergeBranch = useCallback(() => {
|
||||
if (!baseRef) {
|
||||
toast.error("Base ref unavailable");
|
||||
@@ -484,12 +529,14 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
return buildGitActions({
|
||||
isGit,
|
||||
githubFeaturesEnabled,
|
||||
githubAutoMergeActionsEnabled,
|
||||
hasPullRequest,
|
||||
pullRequestUrl: prStatus?.url ?? null,
|
||||
pullRequestState: narrowPullRequestState(prStatus?.state),
|
||||
pullRequestIsDraft: prStatus?.isDraft ?? false,
|
||||
pullRequestIsMerged: prStatus?.isMerged ?? false,
|
||||
pullRequestMergeable: prStatus?.mergeable ?? "UNKNOWN",
|
||||
pullRequestGithub: prStatus?.github ?? null,
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
@@ -551,6 +598,30 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
icon: icons.mergePrRebase,
|
||||
handler: () => handleMergePr("rebase"),
|
||||
},
|
||||
"enable-pr-auto-merge-squash": {
|
||||
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.squash),
|
||||
status: enablePrAutoMergeStatuses.squash,
|
||||
icon: icons.mergePrSquash,
|
||||
handler: () => handleEnablePrAutoMerge("squash"),
|
||||
},
|
||||
"enable-pr-auto-merge-merge": {
|
||||
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.merge),
|
||||
status: enablePrAutoMergeStatuses.merge,
|
||||
icon: icons.mergePrMerge,
|
||||
handler: () => handleEnablePrAutoMerge("merge"),
|
||||
},
|
||||
"enable-pr-auto-merge-rebase": {
|
||||
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.rebase),
|
||||
status: enablePrAutoMergeStatuses.rebase,
|
||||
icon: icons.mergePrRebase,
|
||||
handler: () => handleEnablePrAutoMerge("rebase"),
|
||||
},
|
||||
"disable-pr-auto-merge": {
|
||||
disabled: isActionDisabled(actionsDisabled, disablePrAutoMergeStatus),
|
||||
status: disablePrAutoMergeStatus,
|
||||
icon: icons.viewPr,
|
||||
handler: handleDisablePrAutoMerge,
|
||||
},
|
||||
"merge-branch": {
|
||||
disabled: isActionDisabled(actionsDisabled, mergeStatus),
|
||||
status: mergeStatus,
|
||||
@@ -580,11 +651,13 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
prStatus?.isDraft,
|
||||
prStatus?.isMerged,
|
||||
prStatus?.mergeable,
|
||||
prStatus?.github,
|
||||
aheadCount,
|
||||
behindBaseCount,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
githubFeaturesEnabled,
|
||||
githubAutoMergeActionsEnabled,
|
||||
hasUncommittedChanges,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
@@ -600,6 +673,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
mergePrStatuses.squash,
|
||||
mergePrStatuses.merge,
|
||||
mergePrStatuses.rebase,
|
||||
enablePrAutoMergeStatuses.squash,
|
||||
enablePrAutoMergeStatuses.merge,
|
||||
enablePrAutoMergeStatuses.rebase,
|
||||
disablePrAutoMergeStatus,
|
||||
mergeStatus,
|
||||
mergeFromBaseStatus,
|
||||
archiveStatus,
|
||||
@@ -609,6 +686,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
handlePullAndPush,
|
||||
handlePrAction,
|
||||
handleMergePr,
|
||||
handleEnablePrAutoMerge,
|
||||
handleDisablePrAutoMerge,
|
||||
handleMergeBranch,
|
||||
handleMergeFromBase,
|
||||
handleArchiveWorktree,
|
||||
|
||||
Reference in New Issue
Block a user