diff --git a/CLAUDE.md b/CLAUDE.md index cfbd4a042..e5c0b8a85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end | | [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries | | [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | +| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | | [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization | | [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows | | [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness | @@ -86,6 +87,7 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir - **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape. - **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site. - Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal. + - **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones. - **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable. diff --git a/docs/architecture.md b/docs/architecture.md index 74911998b..100bd7967 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -146,6 +146,8 @@ There is no dedicated welcome message; the server emits a `status` session messa **Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages). +New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names. + **Notable session message types:** - `agent_update` — Agent state changed (status, title, labels) diff --git a/docs/rpc-namespacing.md b/docs/rpc-namespacing.md new file mode 100644 index 000000000..cf1b9e1d9 --- /dev/null +++ b/docs/rpc-namespacing.md @@ -0,0 +1,84 @@ +# RPC Namespacing + +New WebSocket session RPCs use dotted names with the direction as the final segment: + +```ts +checkout.github.set_auto_merge.request; +checkout.github.set_auto_merge.response; +``` + +The namespace reads left to right: + +- Domain: `checkout` +- Provider or subsystem: `github` +- Operation: `set_auto_merge` +- Direction: `request` or `response` + +Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing. + +## Request/Response Pairs + +For ordinary correlated RPCs, a `.request` has a matching `.response` with the same prefix. The daemon client may derive the response type mechanically: + +```ts +checkout.github.set_auto_merge.request; +// -> checkout.github.set_auto_merge.response +``` + +Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema. + +## Message Shape + +Requests keep their parameters at the top level: + +```ts +{ + type: "checkout.github.set_auto_merge.request", + cwd: "/repo", + enabled: true, + mergeMethod: "squash", + requestId: "req_123" +} +``` + +Responses put correlated result data under `payload`: + +```ts +{ + type: "checkout.github.set_auto_merge.response", + payload: { + cwd: "/repo", + enabled: true, + success: true, + error: null, + requestId: "req_123" + } +} +``` + +Keep `requestId` in both request and response payloads. It is the correlation key. + +## Provider Namespacing + +Provider-specific behavior belongs under the provider segment: + +- `checkout.github.*` for GitHub-specific checkout operations +- `checkout.gitlab.*` for future GitLab-specific checkout operations + +Do not put GitHub-specific enums or semantics into generic checkout RPC names. A generic RPC should only exist when the behavior is genuinely provider-neutral. + +## Compatibility + +The existing flat RPC names remain part of the protocol until they are intentionally migrated: + +```ts +checkout_pr_merge_request; +checkout_pr_merge_response; +``` + +Do not add new flat names. When migrating old RPCs, keep protocol compatibility rules in mind: + +- Add the new names first. +- Gate new feature behavior through `server_info.features.*` when an old host cannot support it. +- Keep old names accepted until the compatibility window expires. +- Mark shims with `COMPAT(...)` and a removal date. diff --git a/packages/app/src/git/actions-split-button.tsx b/packages/app/src/git/actions-split-button.tsx index 34f988edf..7fb73a05c 100644 --- a/packages/app/src/git/actions-split-button.tsx +++ b/packages/app/src/git/actions-split-button.tsx @@ -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 { ).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>(); const client = { diff --git a/packages/app/src/git/actions-store.ts b/packages/app/src/git/actions-store.ts index c4f5c6fb3..72483901c 100644 --- a/packages/app/src/git/actions-store.ts +++ b/packages/app/src/git/actions-store.ts @@ -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; + enablePrAutoMerge: (params: { + serverId: string; + cwd: string; + method: CheckoutPrMergeMethod; + }) => Promise; + disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise; mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise; mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise; archiveWorktree: (params: { @@ -392,6 +409,38 @@ export const useCheckoutGitActionsStore = create() }); }, + 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, diff --git a/packages/app/src/git/policy.test.ts b/packages/app/src/git/policy.test.ts index 13e4e1744..9552133b0 100644 --- a/packages/app/src/git/policy.test.ts +++ b/packages/app/src/git/policy.test.ts @@ -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 { + 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 { 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 = {}): 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, }), ); diff --git a/packages/app/src/git/policy.ts b/packages/app/src/git/policy.ts index 40eee5379..9133cfee1 100644 --- a/packages/app/src/git/policy.ts +++ b/packages/app/src/git/policy.ts @@ -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; } -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["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 { + 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; +} diff --git a/packages/app/src/git/pr-pane-data.test.ts b/packages/app/src/git/pr-pane-data.test.ts index 6d762886b..9160b3adf 100644 --- a/packages/app/src/git/pr-pane-data.test.ts +++ b/packages/app/src/git/pr-pane-data.test.ts @@ -14,6 +14,24 @@ import { type CheckoutPrStatus = NonNullable; 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 = { diff --git a/packages/app/src/git/use-actions.ts b/packages/app/src/git/use-actions.ts index 8fe31b3d3..6a4ba6a2a 100644 --- a/packages/app/src/git/use-actions.ts +++ b/packages/app/src/git/use-actions.ts @@ -246,6 +246,20 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use s.getStatus({ serverId, cwd, actionId: "merge-pr-rebase" }), ), }; + const enablePrAutoMergeStatuses: Record = { + 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, diff --git a/packages/app/src/hooks/use-pr-pane-data.test.ts b/packages/app/src/hooks/use-pr-pane-data.test.ts index 5d45e1012..dda023b06 100644 --- a/packages/app/src/hooks/use-pr-pane-data.test.ts +++ b/packages/app/src/hooks/use-pr-pane-data.test.ts @@ -54,6 +54,24 @@ vi.mock("@/runtime/host-runtime", () => ({ const cwd = "/repo"; const serverId = "server-1"; +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, +}; + function status(overrides: Partial = {}): CheckoutPrStatus { return { number: 42, @@ -69,6 +87,7 @@ function status(overrides: Partial = {}): CheckoutPrStatus { reviewDecision: null, repoOwner: "getpaseo", repoName: "paseo", + github: githubStatus, ...overrides, }; } diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index c94d1a3b7..9ee330904 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -1586,6 +1586,118 @@ test("requests checkout merge from base via RPC", async () => { }); }); +test("requests GitHub auto-merge enable via namespaced RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.checkoutGithubSetAutoMerge( + "/tmp/project", + { enabled: true, method: "squash" }, + "req-enable-auto-merge", + ); + + expect(mock.sent).toHaveLength(1); + const request = parseSentFrame(mock.sent[0]); + expect(request.type).toBe("checkout.github.set_auto_merge.request"); + expect(request.cwd).toBe("/tmp/project"); + expect(request.enabled).toBe(true); + expect(request.mergeMethod).toBe("squash"); + expect(request.requestId).toBe("req-enable-auto-merge"); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "checkout.github.set_auto_merge.response", + payload: { + cwd: "/tmp/project", + enabled: true, + requestId: "req-enable-auto-merge", + success: true, + error: null, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + cwd: "/tmp/project", + enabled: true, + requestId: "req-enable-auto-merge", + success: true, + error: null, + }); +}); + +test("requests GitHub auto-merge disable via namespaced RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.checkoutGithubSetAutoMerge( + "/tmp/project", + { enabled: false }, + "req-disable-auto-merge", + ); + + expect(mock.sent).toHaveLength(1); + const request = parseSentFrame(mock.sent[0]); + expect(request.type).toBe("checkout.github.set_auto_merge.request"); + expect(request.cwd).toBe("/tmp/project"); + expect(request.enabled).toBe(false); + expect(request.mergeMethod).toBeUndefined(); + expect(request.requestId).toBe("req-disable-auto-merge"); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "checkout.github.set_auto_merge.response", + payload: { + cwd: "/tmp/project", + enabled: false, + requestId: "req-disable-auto-merge", + success: true, + error: null, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + cwd: "/tmp/project", + enabled: false, + requestId: "req-disable-auto-merge", + success: true, + error: null, + }); +}); + test("requests checkout pull via RPC", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 9d766653b..c7f62a6c9 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -32,6 +32,7 @@ import type { CheckoutPrCreateResponse, CheckoutPrMergeResponse, CheckoutPrMergeMethod, + CheckoutGithubSetAutoMergeResponse, CheckoutPrStatusResponse, PullRequestTimelineResponse, CheckoutSwitchBranchResponse, @@ -274,6 +275,7 @@ type CheckoutPullPayload = CheckoutPullResponse["payload"]; type CheckoutPushPayload = CheckoutPushResponse["payload"]; type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrMergePayload = CheckoutPrMergeResponse["payload"]; +type CheckoutGithubSetAutoMergePayload = CheckoutGithubSetAutoMergeResponse["payload"]; type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"]; type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"]; @@ -1425,6 +1427,25 @@ export class DaemonClient { }); } + private sendNamespacedCorrelatedSessionRequest< + TResponseType extends CorrelatedResponseType, + TResult = CorrelatedResponsePayload, + >(params: { + requestId?: string; + message: { type: Extract } & Record< + string, + unknown + >; + timeout: number; + selectPayload?: (payload: CorrelatedResponsePayload) => TResult | null; + }): Promise { + const responseType = params.message.type.replace(/\.request$/, ".response") as TResponseType; + return this.sendCorrelatedSessionRequest({ + ...params, + responseType, + }); + } + private sendSessionMessageStrict(message: SessionInboundMessage): void { if (!this.transport || this.connectionState.status !== "connected") { throw new Error("Transport not connected"); @@ -2817,6 +2838,23 @@ export class DaemonClient { }); } + async checkoutGithubSetAutoMerge( + cwd: string, + input: { enabled: true; method: CheckoutPrMergeMethod } | { enabled: false }, + requestId?: string, + ): Promise { + return this.sendNamespacedCorrelatedSessionRequest<"checkout.github.set_auto_merge.response">({ + requestId, + message: { + type: "checkout.github.set_auto_merge.request", + cwd, + enabled: input.enabled, + ...(input.enabled ? { mergeMethod: input.method } : {}), + }, + timeout: 60000, + }); + } + async checkoutPrStatus(cwd: string, requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, diff --git a/packages/server/src/server/checkout/status-projection.test.ts b/packages/server/src/server/checkout/status-projection.test.ts index df6c424a2..45abefca8 100644 --- a/packages/server/src/server/checkout/status-projection.test.ts +++ b/packages/server/src/server/checkout/status-projection.test.ts @@ -35,4 +35,63 @@ describe("checkout status projection", () => { expect(payload).toHaveProperty("mergeable", "MERGEABLE"); expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload); }); + + test("projects PR 993 GitHub merge facts without changing top-level status fields", () => { + const payload = normalizeCheckoutPrStatusPayload({ + number: 993, + repoOwner: "getpaseo", + repoName: "paseo", + url: "https://github.com/getpaseo/paseo/pull/993", + title: "Auto-merge UX", + state: "open", + baseRefName: "main", + headRefName: "github-pr-auto-merge-ux", + isMerged: false, + isDraft: false, + mergeable: "MERGEABLE", + checks: [ + { + name: "server tests", + status: "pending", + url: "https://github.com/getpaseo/paseo/actions/runs/993", + workflow: "CI", + }, + ], + checksStatus: "pending", + reviewDecision: "approved", + github: { + mergeStateStatus: "BLOCKED", + autoMergeRequest: null, + viewerCanEnableAutoMerge: true, + viewerCanDisableAutoMerge: false, + viewerCanMergeAsAdmin: false, + viewerCanUpdateBranch: true, + repository: { + autoMergeAllowed: true, + mergeCommitAllowed: false, + squashMergeAllowed: true, + rebaseMergeAllowed: false, + viewerDefaultMergeMethod: "SQUASH", + }, + isMergeQueueEnabled: false, + isInMergeQueue: false, + }, + }); + + expect(payload).toMatchObject({ + number: 993, + mergeable: "MERGEABLE", + checksStatus: "pending", + github: { + mergeStateStatus: "BLOCKED", + viewerCanEnableAutoMerge: true, + repository: { + autoMergeAllowed: true, + squashMergeAllowed: true, + viewerDefaultMergeMethod: "SQUASH", + }, + }, + }); + expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload); + }); }); diff --git a/packages/server/src/server/checkout/status-projection.ts b/packages/server/src/server/checkout/status-projection.ts index 685134869..ac7cff270 100644 --- a/packages/server/src/server/checkout/status-projection.ts +++ b/packages/server/src/server/checkout/status-projection.ts @@ -115,7 +115,7 @@ export function normalizeCheckoutPrStatusPayload( if (!status) { return null; } - return { + const payload: CheckoutPrStatusPayloadStatus = { number: status.number, url: status.url, title: status.title, @@ -131,4 +131,8 @@ export function normalizeCheckoutPrStatusPayload( checksStatus: status.checksStatus, reviewDecision: status.reviewDecision, }; + if (status.github) { + payload.github = status.github; + } + return payload; } diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 68af31541..4f32ab8e1 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -46,6 +46,7 @@ import { createProviderSnapshotManagerStub, } from "./test-utils/session-stubs.js"; import { isPlatform } from "../test-utils/platform.js"; +import type { GitHubPullRequestStatusFacts } from "../services/github-service.js"; interface SessionHandlerInternals { startVoiceTurnController(): Promise; @@ -64,6 +65,7 @@ interface SessionHandlerInternals { handleCheckoutCommitRequest(params: unknown): Promise; handleCheckoutPrCreateRequest(params: unknown): Promise; handleCheckoutPrMergeRequest(params: unknown): Promise; + handleCheckoutGithubSetAutoMergeRequest(params: unknown): Promise; handleCheckoutPullRequest(params: unknown): Promise; handleCheckoutPushRequest(params: unknown): Promise; handleCheckoutStatusRequest(params: unknown): Promise; @@ -1971,6 +1973,23 @@ describe("session checkout pull request merge", () => { github: { pullRequest: { number: 42, + github: { + 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, + }, }, }, }), @@ -1988,8 +2007,33 @@ describe("session checkout pull request merge", () => { cwd: "/tmp/request-worktree", prNumber: 42, mergeMethod: "squash", + status: { + number: 42, + github: { + 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, + }, + }, }); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, "/tmp/request-worktree", { + force: true, + includeGitHub: true, + reason: "merge-pr-validation", + }); + expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, "/tmp/request-worktree", { force: true, reason: "merge-pr", }); @@ -2005,6 +2049,131 @@ describe("session checkout pull request merge", () => { }); }); + test("rejects direct merge when fresh GitHub facts block a warm clean snapshot", async () => { + const messages: unknown[] = []; + const github = { + invalidate: vi.fn(), + mergePullRequest: vi.fn( + async (input: { status?: { github?: { mergeStateStatus?: string | null } } }) => { + if (input.status?.github?.mergeStateStatus === "BLOCKED") { + throw new Error("GitHub does not report this pull request as ready for direct merge"); + } + return { success: true }; + }, + ), + }; + const createSnapshot = (mergeStateStatus: "CLEAN" | "BLOCKED") => ({ + github: { + pullRequest: { + number: 42, + github: { + mergeStateStatus, + 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, + }, + }, + }, + }); + const workspaceGitService = { + getSnapshot: vi.fn(async (_cwd: string, options?: { force?: boolean }) => + createSnapshot(options?.force ? "BLOCKED" : "CLEAN"), + ), + }; + const session = createSessionForTest({ github, workspaceGitService, messages }); + + await asSessionInternals(session).handleCheckoutPrMergeRequest({ + type: "checkout_pr_merge_request", + cwd: "/tmp/request-worktree", + mergeMethod: "squash", + requestId: "request-pr-merge-fresh-blocked", + }); + + expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(1); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + force: true, + includeGitHub: true, + reason: "merge-pr-validation", + }); + expect(github.mergePullRequest).toHaveBeenCalledWith( + expect.objectContaining({ + status: expect.objectContaining({ + github: expect.objectContaining({ mergeStateStatus: "BLOCKED" }), + }), + }), + ); + expect(github.invalidate).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "checkout_pr_merge_response", + payload: { + cwd: "/tmp/request-worktree", + success: false, + error: { + code: "UNKNOWN", + message: "GitHub does not report this pull request as ready for direct merge", + }, + requestId: "request-pr-merge-fresh-blocked", + }, + }); + }); + + test("rejects direct merge when the current pull request is missing GitHub merge facts", async () => { + const messages: unknown[] = []; + const github = { + invalidate: vi.fn(), + mergePullRequest: vi.fn().mockResolvedValue({ success: true }), + }; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue({ + github: { + pullRequest: { + number: 42, + mergeable: "MERGEABLE", + }, + }, + }), + }; + const session = createSessionForTest({ github, workspaceGitService, messages }); + + await asSessionInternals(session).handleCheckoutPrMergeRequest({ + type: "checkout_pr_merge_request", + cwd: "/tmp/request-worktree", + mergeMethod: "squash", + requestId: "request-pr-merge-missing-github-facts", + }); + + expect(github.mergePullRequest).not.toHaveBeenCalled(); + expect(github.invalidate).not.toHaveBeenCalled(); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + force: true, + includeGitHub: true, + reason: "merge-pr-validation", + }); + expect(messages).toContainEqual({ + type: "checkout_pr_merge_response", + payload: { + cwd: "/tmp/request-worktree", + success: false, + error: { + code: "UNKNOWN", + message: "GitHub merge facts are unavailable for this pull request", + }, + requestId: "request-pr-merge-missing-github-facts", + }, + }); + }); + test("surfaces merge errors verbatim", async () => { const messages: unknown[] = []; const github = { @@ -2016,6 +2185,23 @@ describe("session checkout pull request merge", () => { github: { pullRequest: { number: 42, + github: { + 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, + }, }, }, }), @@ -2044,6 +2230,362 @@ describe("session checkout pull request merge", () => { }); }); +describe("session checkout pull request auto-merge", () => { + const autoMergeGithubFacts = ( + overrides: Partial = {}, + ): GitHubPullRequestStatusFacts => ({ + mergeStateStatus: "BLOCKED", + autoMergeRequest: null, + viewerCanEnableAutoMerge: true, + viewerCanDisableAutoMerge: false, + viewerCanMergeAsAdmin: false, + viewerCanUpdateBranch: false, + repository: { + autoMergeAllowed: true, + mergeCommitAllowed: true, + squashMergeAllowed: true, + rebaseMergeAllowed: true, + viewerDefaultMergeMethod: "SQUASH", + }, + isMergeQueueEnabled: false, + isInMergeQueue: false, + ...overrides, + }); + + test("enables auto-merge for the current pull request and refreshes GitHub state", async () => { + const messages: unknown[] = []; + const github = { + invalidate: vi.fn(), + enablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }), + }; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue({ + github: { + pullRequest: { + number: 42, + mergeable: "MERGEABLE", + github: autoMergeGithubFacts(), + }, + }, + }), + }; + const session = createSessionForTest({ github, workspaceGitService, messages }); + + await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({ + type: "checkout.github.set_auto_merge.request", + cwd: "/tmp/request-worktree", + enabled: true, + mergeMethod: "squash", + requestId: "request-pr-auto-merge-enable", + }); + + expect(github.enablePullRequestAutoMerge).toHaveBeenCalledWith({ + cwd: "/tmp/request-worktree", + prNumber: 42, + mergeMethod: "squash", + status: { + number: 42, + mergeable: "MERGEABLE", + github: autoMergeGithubFacts(), + }, + }); + expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, "/tmp/request-worktree", { + force: true, + includeGitHub: true, + reason: "auto-merge-validation", + }); + expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, "/tmp/request-worktree", { + force: true, + reason: "enable-pr-auto-merge", + }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); + expect(messages).toContainEqual({ + type: "checkout.github.set_auto_merge.response", + payload: { + cwd: "/tmp/request-worktree", + enabled: true, + success: true, + error: null, + requestId: "request-pr-auto-merge-enable", + }, + }); + }); + + test("disables auto-merge for the current pull request and refreshes GitHub state", async () => { + const messages: unknown[] = []; + const github = { + invalidate: vi.fn(), + disablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }), + }; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue({ + github: { + pullRequest: { + number: 42, + github: autoMergeGithubFacts({ + autoMergeRequest: { + enabledAt: "2026-05-13T17:00:00Z", + mergeMethod: "SQUASH", + enabledBy: "moboudra", + }, + viewerCanEnableAutoMerge: false, + viewerCanDisableAutoMerge: true, + }), + }, + }, + }), + }; + const session = createSessionForTest({ github, workspaceGitService, messages }); + + await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({ + type: "checkout.github.set_auto_merge.request", + cwd: "/tmp/request-worktree", + enabled: false, + requestId: "request-pr-auto-merge-disable", + }); + + expect(github.disablePullRequestAutoMerge).toHaveBeenCalledWith({ + cwd: "/tmp/request-worktree", + prNumber: 42, + status: { + number: 42, + github: autoMergeGithubFacts({ + autoMergeRequest: { + enabledAt: "2026-05-13T17:00:00Z", + mergeMethod: "SQUASH", + enabledBy: "moboudra", + }, + viewerCanEnableAutoMerge: false, + viewerCanDisableAutoMerge: true, + }), + }, + }); + expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, "/tmp/request-worktree", { + force: true, + includeGitHub: true, + reason: "auto-merge-validation", + }); + expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, "/tmp/request-worktree", { + force: true, + reason: "disable-pr-auto-merge", + }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); + expect(messages).toContainEqual({ + type: "checkout.github.set_auto_merge.response", + payload: { + cwd: "/tmp/request-worktree", + enabled: false, + success: true, + error: null, + requestId: "request-pr-auto-merge-disable", + }, + }); + }); + + test("surfaces auto-merge errors verbatim", async () => { + const messages: unknown[] = []; + const github = { + invalidate: vi.fn(), + enablePullRequestAutoMerge: vi.fn().mockRejectedValue(new Error("auto-merge is disabled")), + }; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue({ + github: { + pullRequest: { + number: 42, + github: autoMergeGithubFacts(), + }, + }, + }), + }; + const session = createSessionForTest({ github, workspaceGitService, messages }); + + await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({ + type: "checkout.github.set_auto_merge.request", + cwd: "/tmp/request-worktree", + enabled: true, + mergeMethod: "merge", + requestId: "request-pr-auto-merge-failure", + }); + + expect(messages).toContainEqual({ + type: "checkout.github.set_auto_merge.response", + payload: { + cwd: "/tmp/request-worktree", + enabled: true, + success: false, + error: { + code: "UNKNOWN", + message: "auto-merge is disabled", + }, + requestId: "request-pr-auto-merge-failure", + }, + }); + }); + + test("rejects auto-merge enable when the requested method is disabled", async () => { + const messages: unknown[] = []; + const github = { + invalidate: vi.fn(), + enablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }), + }; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue({ + github: { + pullRequest: { + number: 42, + github: autoMergeGithubFacts({ + repository: { + autoMergeAllowed: true, + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + viewerDefaultMergeMethod: "MERGE", + }, + }), + }, + }, + }), + }; + const session = createSessionForTest({ github, workspaceGitService, messages }); + + await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({ + type: "checkout.github.set_auto_merge.request", + cwd: "/tmp/request-worktree", + enabled: true, + mergeMethod: "squash", + requestId: "request-pr-auto-merge-method-disabled", + }); + + expect(github.enablePullRequestAutoMerge).not.toHaveBeenCalled(); + expect(github.invalidate).not.toHaveBeenCalled(); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + force: true, + includeGitHub: true, + reason: "auto-merge-validation", + }); + expect(messages).toContainEqual({ + type: "checkout.github.set_auto_merge.response", + payload: { + cwd: "/tmp/request-worktree", + enabled: true, + success: false, + error: { + code: "UNKNOWN", + message: "Auto-merge is not available because squash is disabled", + }, + requestId: "request-pr-auto-merge-method-disabled", + }, + }); + }); + + test("rejects auto-merge disable when the viewer cannot disable it", async () => { + const messages: unknown[] = []; + const github = { + invalidate: vi.fn(), + disablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }), + }; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue({ + github: { + pullRequest: { + number: 42, + github: autoMergeGithubFacts({ + autoMergeRequest: { + enabledAt: "2026-05-13T17:00:00Z", + mergeMethod: "SQUASH", + enabledBy: "someone-else", + }, + viewerCanEnableAutoMerge: false, + viewerCanDisableAutoMerge: false, + }), + }, + }, + }), + }; + const session = createSessionForTest({ github, workspaceGitService, messages }); + + await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({ + type: "checkout.github.set_auto_merge.request", + cwd: "/tmp/request-worktree", + enabled: false, + requestId: "request-pr-auto-merge-disable-forbidden", + }); + + expect(github.disablePullRequestAutoMerge).not.toHaveBeenCalled(); + expect(github.invalidate).not.toHaveBeenCalled(); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + force: true, + includeGitHub: true, + reason: "auto-merge-validation", + }); + expect(messages).toContainEqual({ + type: "checkout.github.set_auto_merge.response", + payload: { + cwd: "/tmp/request-worktree", + enabled: false, + success: false, + error: { + code: "UNKNOWN", + message: "GitHub does not allow this viewer to disable auto-merge", + }, + requestId: "request-pr-auto-merge-disable-forbidden", + }, + }); + }); + + test("rejects auto-merge disable requests that include a merge method", async () => { + const messages: unknown[] = []; + const github = { + invalidate: vi.fn(), + disablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }), + }; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue({ + github: { + pullRequest: { + number: 42, + github: autoMergeGithubFacts({ + autoMergeRequest: { + enabledAt: "2026-05-13T17:00:00Z", + mergeMethod: "SQUASH", + enabledBy: "moboudra", + }, + viewerCanEnableAutoMerge: false, + viewerCanDisableAutoMerge: true, + }), + }, + }, + }), + }; + const session = createSessionForTest({ github, workspaceGitService, messages }); + + await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({ + type: "checkout.github.set_auto_merge.request", + cwd: "/tmp/request-worktree", + enabled: false, + mergeMethod: "squash", + requestId: "request-pr-auto-merge-disable-with-method", + }); + + expect(github.disablePullRequestAutoMerge).not.toHaveBeenCalled(); + expect(github.invalidate).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "checkout.github.set_auto_merge.response", + payload: { + cwd: "/tmp/request-worktree", + enabled: false, + success: false, + error: { + code: "UNKNOWN", + message: "mergeMethod is not allowed when disabling auto-merge", + }, + requestId: "request-pr-auto-merge-disable-with-method", + }, + }); + }); +}); + describe("session checkout pull and push handling", () => { test("forces workspace git and GitHub refresh after pulling", async () => { const messages: unknown[] = []; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index ae81e9e01..4a04ef5b3 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -82,7 +82,11 @@ import type { DaemonConfigStore } from "./daemon-config-store.js"; import { applyMutableProviderConfigToOverrides } from "./daemon-config-store.js"; import { getErrorMessage, getErrorMessageOr } from "../shared/error-utils.js"; import { getAgentStatusPriority } from "../shared/agent-state-bucket.js"; -import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; +import type { + WorkspaceGitRuntimeSnapshot, + WorkspaceGitService, + WorkspaceGitSnapshotOptions, +} from "./workspace-git-service.js"; import { buildProviderRegistry } from "./agent/provider-registry.js"; import type { @@ -211,6 +215,8 @@ import { LoopService } from "./loop-service.js"; import { ScheduleService } from "./schedule/service.js"; import { execCommand } from "../utils/spawn.js"; import { + assertPullRequestAutoMergeDisableReady, + assertPullRequestAutoMergeEnableReady, createGitHubService, type GitHubService, type PullRequestTimelineItem, @@ -242,6 +248,12 @@ import { toWorktreeWireError } from "./worktree-errors.js"; const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__"; +type CurrentWorkspacePullRequest = NonNullable< + WorkspaceGitRuntimeSnapshot["github"]["pullRequest"] +> & { + number: number; +}; + interface ResolveKnownProjectRootForConfigInput { repoRoot: string; projectRegistry: Pick; @@ -288,6 +300,8 @@ type GitMutationRefreshReason = | "merge-to-base" | "merge-from-base" | "merge-pr" + | "enable-pr-auto-merge" + | "disable-pr-auto-merge" | "create-pr" | "switch-branch" | "create-branch" @@ -2013,6 +2027,8 @@ export class Session { return this.handleCheckoutPrCreateRequest(msg); case "checkout_pr_merge_request": return this.handleCheckoutPrMergeRequest(msg); + case "checkout.github.set_auto_merge.request": + return this.handleCheckoutGithubSetAutoMergeRequest(msg); case "checkout_pr_status_request": return this.handleCheckoutPrStatusRequest(msg); case "pull_request_timeline_request": @@ -5180,16 +5196,17 @@ export class Session { const { cwd, requestId } = msg; try { - const snapshot = await this.workspaceGitService.getSnapshot(cwd); - const prNumber = snapshot.github.pullRequest?.number; - if (typeof prNumber !== "number") { - throw new Error("Unable to determine GitHub pull request number for merge"); - } - + const pullRequest = await this.resolveCurrentPullRequest(cwd, "merge", { + force: true, + includeGitHub: true, + reason: "merge-pr-validation", + }); + this.assertCurrentPullRequestHasGithubMergeFacts(pullRequest); await this.github.mergePullRequest({ cwd, - prNumber, + prNumber: pullRequest.number, mergeMethod: msg.mergeMethod, + status: pullRequest, }); await this.notifyGitMutation(cwd, "merge-pr", { invalidateGithub: true }); @@ -5215,6 +5232,96 @@ export class Session { } } + private assertCurrentPullRequestHasGithubMergeFacts( + pullRequest: CurrentWorkspacePullRequest, + ): void { + if (!pullRequest.github) { + throw new Error("GitHub merge facts are unavailable for this pull request"); + } + } + + private async handleCheckoutGithubSetAutoMergeRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + + try { + const pullRequest = await this.resolveCurrentPullRequest(cwd, "auto-merge", { + force: true, + includeGitHub: true, + reason: "auto-merge-validation", + }); + if (msg.enabled) { + const mergeMethod = msg.mergeMethod; + if (!mergeMethod) { + throw new Error("mergeMethod is required when enabling auto-merge"); + } + assertPullRequestAutoMergeEnableReady({ + mergeMethod, + status: pullRequest, + }); + await this.github.enablePullRequestAutoMerge({ + cwd, + prNumber: pullRequest.number, + mergeMethod, + status: pullRequest, + }); + } else { + if (msg.mergeMethod) { + throw new Error("mergeMethod is not allowed when disabling auto-merge"); + } + assertPullRequestAutoMergeDisableReady({ status: pullRequest }); + await this.github.disablePullRequestAutoMerge({ + cwd, + prNumber: pullRequest.number, + status: pullRequest, + }); + } + await this.notifyGitMutation( + cwd, + msg.enabled ? "enable-pr-auto-merge" : "disable-pr-auto-merge", + { + invalidateGithub: true, + }, + ); + + this.emit({ + type: "checkout.github.set_auto_merge.response", + payload: { + cwd, + enabled: msg.enabled, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout.github.set_auto_merge.response", + payload: { + cwd, + enabled: msg.enabled, + success: false, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + + private async resolveCurrentPullRequest( + cwd: string, + operation: "merge" | "auto-merge", + options?: WorkspaceGitSnapshotOptions, + ): Promise { + const snapshot = await this.workspaceGitService.getSnapshot(cwd, options); + const pullRequest = snapshot.github.pullRequest; + if (!pullRequest || typeof pullRequest.number !== "number") { + throw new Error(`Unable to determine GitHub pull request number for ${operation}`); + } + return { ...pullRequest, number: pullRequest.number }; + } + private async handleCheckoutPrStatusRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 03666d007..38e47afd0 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -451,6 +451,23 @@ describe("workspace git watch targets", () => { ], checksStatus: "success", reviewDecision: "approved", + github: { + mergeStateStatus: "CLEAN", + autoMergeRequest: null, + viewerCanEnableAutoMerge: true, + viewerCanDisableAutoMerge: false, + viewerCanMergeAsAdmin: false, + viewerCanUpdateBranch: true, + repository: { + autoMergeAllowed: true, + mergeCommitAllowed: true, + squashMergeAllowed: true, + rebaseMergeAllowed: false, + viewerDefaultMergeMethod: "SQUASH", + }, + isMergeQueueEnabled: false, + isInMergeQueue: false, + }, }, error: null, }, @@ -483,6 +500,23 @@ describe("workspace git watch targets", () => { ], checksStatus: "success", reviewDecision: "approved", + github: { + mergeStateStatus: "CLEAN", + autoMergeRequest: null, + viewerCanEnableAutoMerge: true, + viewerCanDisableAutoMerge: false, + viewerCanMergeAsAdmin: false, + viewerCanUpdateBranch: true, + repository: { + autoMergeAllowed: true, + mergeCommitAllowed: true, + squashMergeAllowed: true, + rebaseMergeAllowed: false, + viewerDefaultMergeMethod: "SQUASH", + }, + isMergeQueueEnabled: false, + isInMergeQueue: false, + }, }, githubFeaturesEnabled: true, error: null, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ac240a2bd..cdd65e37c 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1048,6 +1048,8 @@ export class VoiceAssistantWebSocketServer { features: { // COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow. providersSnapshot: true, + // COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13. + checkoutGithubSetAutoMerge: true, }, }; } diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index e31c28831..12f7204e9 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -79,11 +79,11 @@ function createCheckoutStatus( }; } -function createPullRequestStatusResult(): PullRequestStatusResult { +function createPullRequestStatusResult(title = "Update feature"): PullRequestStatusResult { return { status: { url: "https://github.com/acme/repo/pull/123", - title: "Update feature", + title, state: "open", baseRefName: "main", headRefName: "feature", @@ -537,6 +537,61 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { service.dispose(); }); + test("a forced GitHub-inclusive call during an in-flight forced git refresh queues a GitHub refresh", async () => { + const forcedGitRefresh = createDeferred(); + const getCheckoutStatus = vi + .fn<() => Promise>() + .mockImplementationOnce(async () => forcedGitRefresh.promise) + .mockImplementation(async () => createCheckoutStatus(REPO_CWD)); + const getPullRequestStatus = vi + .fn() + .mockResolvedValueOnce(createPullRequestStatusResult("Stale cached PR")) + .mockResolvedValueOnce(createPullRequestStatusResult("Fresh validation PR")); + const service = createService({ getCheckoutStatus, getPullRequestStatus }); + + const gitRefresh = service.getSnapshot(REPO_CWD, { + force: true, + includeGitHub: false, + reason: "watch", + }); + await flushPromises(); + + const validationRefresh = service.getSnapshot(REPO_CWD, { + force: true, + includeGitHub: true, + reason: "merge-pr-validation", + }); + await flushPromises(); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + + forcedGitRefresh.resolve(createCheckoutStatus(REPO_CWD)); + + await expect(validationRefresh).resolves.toEqual( + createSnapshot(REPO_CWD, { + github: { + pullRequest: { + url: "https://github.com/acme/repo/pull/123", + title: "Fresh validation PR", + state: "open", + baseRefName: "main", + headRefName: "feature", + isMerged: false, + }, + }, + }), + ); + await gitRefresh; + + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + expect(getPullRequestStatus).toHaveBeenNthCalledWith(2, REPO_CWD, expect.anything(), { + force: true, + reason: "merge-pr-validation", + }); + + service.dispose(); + }); + test("ref-watch firing during an in-flight forced refresh does not produce an extra shell burst", async () => { const forcedRefresh = createDeferred(); const getCheckoutStatus = vi diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 7e6e5ee25..1caae0dab 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -22,6 +22,7 @@ import { } from "../utils/checkout-git.js"; import { createGitHubService, + type GitHubPullRequestStatusFacts, type GitHubService, type PullRequestMergeable, } from "../services/github-service.js"; @@ -92,6 +93,7 @@ export interface WorkspaceGitRuntimeSnapshot { }>; checksStatus?: "none" | "pending" | "success" | "failure"; reviewDecision?: "approved" | "changes_requested" | "pending" | null; + github?: GitHubPullRequestStatusFacts; } | null; error: { message: string } | null; }; @@ -1324,7 +1326,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { request: WorkspaceGitRefreshRequest, ): Promise { if (target.refreshState.status === "in-flight") { - if (request.force && !target.refreshState.force) { + const needsForcedRefresh = request.force && !target.refreshState.force; + const needsGitHubRefresh = + request.force && request.includeGitHub && !target.refreshState.includeGitHub; + if (needsForcedRefresh || needsGitHubRefresh) { target.refreshState.queued = this.mergeQueuedRefresh(target.refreshState.queued, request); } return target.refreshState.promise; @@ -1424,10 +1429,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } const force = queued.force || request.force; + const upgradesForce = request.force && !queued.force; + const upgradesGitHub = request.includeGitHub && !queued.includeGitHub; return { force, includeGitHub: queued.includeGitHub || request.includeGitHub, - reason: request.force && !queued.force ? request.reason : queued.reason, + reason: upgradesForce || upgradesGitHub ? request.reason : queued.reason, notify: queued.notify || request.notify, }; } diff --git a/packages/server/src/services/github-service.test.ts b/packages/server/src/services/github-service.test.ts index 1df3fb231..c4011169f 100644 --- a/packages/server/src/services/github-service.test.ts +++ b/packages/server/src/services/github-service.test.ts @@ -115,6 +115,31 @@ function currentPullRequestJson(overrides: Record = {}): string }); } +function currentPullRequestGithubFactsJson(overrides: Record = {}): string { + return JSON.stringify({ + data: { + repository: { + autoMergeAllowed: true, + mergeCommitAllowed: false, + squashMergeAllowed: true, + rebaseMergeAllowed: false, + viewerDefaultMergeMethod: "SQUASH", + pullRequest: { + mergeStateStatus: "BLOCKED", + autoMergeRequest: null, + viewerCanEnableAutoMerge: true, + viewerCanDisableAutoMerge: false, + viewerCanMergeAsAdmin: false, + viewerCanUpdateBranch: true, + isMergeQueueEnabled: false, + isInMergeQueue: false, + }, + ...overrides, + }, + }, + }); +} + function createCurrentPullRequestStatus( overrides: Partial = {}, ): GitHubCurrentPullRequestStatus { @@ -137,6 +162,29 @@ function createCurrentPullRequestStatus( }; } +function githubStatusFacts( + overrides: Partial> = {}, +): NonNullable { + 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 recordCurrentPullRequestStatusReads(service: ReturnType) { const reads: GitHubReadOptions[] = []; const getCurrentPullRequestStatus = service.getCurrentPullRequestStatus.bind(service); @@ -147,6 +195,12 @@ function recordCurrentPullRequestStatusReads(service: ReturnType call.args[0] === "pr" && (call.args[1] === "view" || call.args[1] === "list"), + ); +} + function noPullRequestError(args: string[] = ["pr", "view"]): GitHubCommandError { return new GitHubCommandError({ args, @@ -314,6 +368,9 @@ describe("GitHubService", () => { cwd: "/tmp/repo", prNumber: 42, mergeMethod, + status: createCurrentPullRequestStatus({ + github: githubStatusFacts(), + }), }), ).resolves.toEqual({ success: true }); @@ -326,6 +383,196 @@ describe("GitHubService", () => { ]); }); + it("rejects direct merge when GitHub facts are unavailable", async () => { + const runner = createRunner([""]); + const service = createGitHubService({ + runner: runner.runner, + }); + + await expect( + service.mergePullRequest({ + cwd: "/tmp/repo", + prNumber: 42, + mergeMethod: "squash", + status: createCurrentPullRequestStatus(), + }), + ).rejects.toThrow("GitHub merge facts are unavailable"); + + expect(runner.calls).toEqual([]); + }); + + it.each(["BLOCKED", "DIRTY", null] as const)( + "rejects direct merge when GitHub mergeStateStatus is %s", + async (mergeStateStatus) => { + const runner = createRunner([""]); + const service = createGitHubService({ + runner: runner.runner, + }); + + await expect( + service.mergePullRequest({ + cwd: "/tmp/repo", + prNumber: 42, + mergeMethod: "squash", + status: createCurrentPullRequestStatus({ + github: githubStatusFacts({ mergeStateStatus }), + }), + }), + ).rejects.toThrow("ready for direct merge"); + + expect(runner.calls).toEqual([]); + }, + ); + + it.each([ + ["merge queue enabled", { isMergeQueueEnabled: true }], + ["PR already in merge queue", { isInMergeQueue: true }], + ] as const)("rejects direct merge when %s", async (_name, overrides) => { + const runner = createRunner([""]); + const service = createGitHubService({ + runner: runner.runner, + }); + + await expect( + service.mergePullRequest({ + cwd: "/tmp/repo", + prNumber: 42, + mergeMethod: "squash", + status: createCurrentPullRequestStatus({ + github: githubStatusFacts(overrides), + }), + }), + ).rejects.toThrow("merge queue"); + + expect(runner.calls).toEqual([]); + }); + + it("rejects direct merge when auto-merge is already enabled", async () => { + const runner = createRunner([""]); + const service = createGitHubService({ + runner: runner.runner, + }); + + await expect( + service.mergePullRequest({ + cwd: "/tmp/repo", + prNumber: 42, + mergeMethod: "squash", + status: createCurrentPullRequestStatus({ + github: githubStatusFacts({ + autoMergeRequest: { + enabledAt: "2026-05-13T12:00:00Z", + mergeMethod: "SQUASH", + enabledBy: "octocat", + }, + }), + }), + }), + ).rejects.toThrow("auto-merge is already enabled"); + + expect(runner.calls).toEqual([]); + }); + + it("rejects direct merge when the requested method is disabled by repository policy", async () => { + const runner = createRunner([""]); + const service = createGitHubService({ + runner: runner.runner, + }); + + await expect( + service.mergePullRequest({ + cwd: "/tmp/repo", + prNumber: 42, + mergeMethod: "squash", + status: createCurrentPullRequestStatus({ + github: githubStatusFacts({ + repository: { + autoMergeAllowed: true, + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + viewerDefaultMergeMethod: "MERGE", + }, + }), + }), + }), + ).rejects.toThrow("squash is disabled"); + + expect(runner.calls).toEqual([]); + }); + + it.each([ + ["merge", ["pr", "merge", "42", "--auto", "--merge"]], + ["squash", ["pr", "merge", "42", "--auto", "--squash"]], + ["rebase", ["pr", "merge", "42", "--auto", "--rebase"]], + ] as const)("enables auto-merge with gh using %s", async (mergeMethod, expectedArgs) => { + const runner = createRunner([""]); + const service = createGitHubService({ + runner: runner.runner, + }); + + await expect( + service.enablePullRequestAutoMerge({ + cwd: "/tmp/repo", + prNumber: 42, + mergeMethod, + status: createCurrentPullRequestStatus({ + github: githubStatusFacts({ + mergeStateStatus: "BLOCKED", + viewerCanEnableAutoMerge: true, + repository: { + autoMergeAllowed: true, + mergeCommitAllowed: true, + squashMergeAllowed: true, + rebaseMergeAllowed: true, + viewerDefaultMergeMethod: "SQUASH", + }, + }), + }), + }), + ).resolves.toEqual({ success: true }); + + expect(runner.calls).toEqual([ + { + args: expectedArgs, + cwd: "/tmp/repo", + envOverlay: { GH_PROMPT_DISABLED: "1" }, + }, + ]); + }); + + it("disables auto-merge with gh", async () => { + const runner = createRunner([""]); + const service = createGitHubService({ + runner: runner.runner, + }); + + await expect( + service.disablePullRequestAutoMerge({ + cwd: "/tmp/repo", + prNumber: 42, + status: createCurrentPullRequestStatus({ + github: githubStatusFacts({ + autoMergeRequest: { + enabledAt: "2026-05-13T12:00:00Z", + mergeMethod: "SQUASH", + enabledBy: "octocat", + }, + viewerCanDisableAutoMerge: true, + }), + }), + }), + ).resolves.toEqual({ success: true }); + + expect(runner.calls).toEqual([ + { + args: ["pr", "merge", "42", "--disable-auto"], + cwd: "/tmp/repo", + envOverlay: { GH_PROMPT_DISABLED: "1" }, + }, + ]); + }); + it("computes fast cadence for pending and slow cadence for stable PR states", () => { const pendingStatus = createCurrentPullRequestStatus({ checksStatus: "pending" }); const runningCheckStatus = createCurrentPullRequestStatus({ @@ -408,7 +655,7 @@ describe("GitHubService", () => { now = EXPECTED_GITHUB_FAST_POLL_MS; await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS); - expect(runner.calls).toHaveLength(2); + expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(2); expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]); subscription?.unsubscribe(); @@ -441,11 +688,11 @@ describe("GitHubService", () => { now = EXPECTED_GITHUB_FAST_POLL_MS; await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS); - expect(runner.calls).toHaveLength(1); + expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1); now = EXPECTED_GITHUB_SLOW_POLL_MS; await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS - EXPECTED_GITHUB_FAST_POLL_MS); - expect(runner.calls).toHaveLength(2); + expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(2); expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]); subscription?.unsubscribe(); @@ -458,14 +705,17 @@ describe("GitHubService", () => { currentPullRequestJson({ statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }], }), + currentPullRequestGithubFactsJson(), { error: new Error("network down") }, { error: new Error("network still down") }, currentPullRequestJson({ statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }], }), + currentPullRequestGithubFactsJson(), currentPullRequestJson({ statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }], }), + currentPullRequestGithubFactsJson(), ]); const service = createGitHubService({ ttlMs: 0, @@ -490,7 +740,7 @@ describe("GitHubService", () => { now += EXPECTED_GITHUB_SLOW_POLL_MS; await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS); - expect(runner.calls).toHaveLength(5); + expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(5); expect(reads.map((read) => read.reason)).toEqual([ undefined, "self-heal-github", @@ -527,7 +777,7 @@ describe("GitHubService", () => { now = EXPECTED_GITHUB_FAST_POLL_MS; await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS); - expect(runner.calls).toHaveLength(1); + expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1); service.dispose?.(); }); @@ -553,7 +803,7 @@ describe("GitHubService", () => { now = EXPECTED_GITHUB_FAST_POLL_MS; await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS); - expect(runner.calls).toHaveLength(1); + expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1); }); it("fetches PR reviews and issue comments with one GraphQL call sorted chronologically", async () => { @@ -1139,7 +1389,7 @@ describe("GitHubService", () => { headRef: "feature/pr-pane", }); - expect(runner.calls.map((call) => call.args)).toEqual([ + expect(runner.calls.slice(0, 2).map((call) => call.args)).toEqual([ ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS], ["pr", "view", "--json", CURRENT_PR_STATUS_BASE_FIELDS], ]); @@ -1171,6 +1421,72 @@ describe("GitHubService", () => { expect(status?.mergeable).toBe("UNKNOWN"); }); + it("loads GitHub merge, auto-merge, permission, policy, and queue facts for PR 993 shape", async () => { + const runner = createScriptedRunner([ + currentPullRequestJson({ + number: 993, + url: "https://github.com/getpaseo/paseo/pull/993", + title: "Auto-merge UX", + headRefName: "github-pr-auto-merge-ux", + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + statusCheckRollup: [ + { + __typename: "CheckRun", + name: "server tests", + workflowName: "CI", + status: "IN_PROGRESS", + conclusion: null, + detailsUrl: "https://github.com/getpaseo/paseo/actions/runs/993", + }, + ], + }), + currentPullRequestGithubFactsJson(), + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "github-pr-auto-merge-ux", + }); + + expect(runner.calls.map((call) => call.args[0])).toEqual(["pr", "api"]); + expect(status).toMatchObject({ + number: 993, + mergeable: "MERGEABLE", + checks: [ + { + name: "server tests", + status: "pending", + url: "https://github.com/getpaseo/paseo/actions/runs/993", + workflow: "CI", + }, + ], + checksStatus: "pending", + github: { + mergeStateStatus: "BLOCKED", + autoMergeRequest: null, + viewerCanEnableAutoMerge: true, + viewerCanDisableAutoMerge: false, + viewerCanMergeAsAdmin: false, + viewerCanUpdateBranch: true, + repository: { + autoMergeAllowed: true, + mergeCommitAllowed: false, + squashMergeAllowed: true, + rebaseMergeAllowed: false, + viewerDefaultMergeMethod: "SQUASH", + }, + isMergeQueueEnabled: false, + isInMergeQueue: false, + }, + }); + }); + it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => { const runner = createScriptedRunner([ currentPullRequestJson({ @@ -1231,7 +1547,7 @@ describe("GitHubService", () => { title: "Real fork PR", headRefName: "feature/fork", }); - expect(runner.calls.map((call) => call.args)).toEqual([ + expect(runner.calls.slice(0, 3).map((call) => call.args)).toEqual([ ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS], ["repo", "view", "--json", "owner,name,parent"], [ @@ -1314,7 +1630,7 @@ describe("GitHubService", () => { checks: [], checksStatus: "none", }); - expect(runner.calls.map((call) => call.args)).toEqual([ + expect(runner.calls.slice(0, 4).map((call) => call.args)).toEqual([ ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS], ["repo", "view", "--json", "owner,name,parent"], [ @@ -1452,7 +1768,7 @@ describe("GitHubService", () => { repoName: "repo", headRefName: "main", }); - expect(runner.calls.map((call) => call.args)).toEqual([ + expect(runner.calls.slice(0, 2).map((call) => call.args)).toEqual([ ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS], [ "pr", @@ -1978,13 +2294,15 @@ describe("GitHubService", () => { service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }), ).resolves.toMatchObject({ number: 42 }); - expect(runner.calls).toHaveLength(1); + expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1); }); it("bypasses the warm PR status cache for forced reads", async () => { const runner = createRunner([ currentPullRequestJson({ number: 41, title: "First" }), + currentPullRequestGithubFactsJson(), currentPullRequestJson({ number: 42, title: "Forced" }), + currentPullRequestGithubFactsJson(), ]); const service = createGitHubService({ runner: runner.runner, @@ -2004,7 +2322,7 @@ describe("GitHubService", () => { }), ).resolves.toMatchObject({ number: 42 }); - expect(runner.calls).toHaveLength(2); + expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(2); }); it("coalesces concurrent PR status callers", async () => { @@ -2019,8 +2337,13 @@ describe("GitHubService", () => { const second = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }); await Promise.resolve(); - expect(runner.calls).toHaveLength(1); + expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1); runner.resolveNext(currentPullRequestJson()); + for (let i = 0; i < 10 && runner.calls.length < 2; i += 1) { + await Promise.resolve(); + } + expect(runner.calls[1]?.args[0]).toBe("api"); + runner.resolveNext(currentPullRequestGithubFactsJson()); await expect(Promise.all([first, second])).resolves.toEqual([ expect.objectContaining({ number: 42 }), diff --git a/packages/server/src/services/github-service.ts b/packages/server/src/services/github-service.ts index f8f8e86bb..35a250538 100644 --- a/packages/server/src/services/github-service.ts +++ b/packages/server/src/services/github-service.ts @@ -93,6 +93,52 @@ const HeadRepositoryOwnerSchema = z const PullRequestMergeableSchema = z.enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"]).catch("UNKNOWN"); +const GitHubAutoMergeRequestSchema = z + .object({ + enabledAt: z.string().nullable().optional().catch(null), + mergeMethod: z.string().nullable().optional().catch(null), + enabledBy: z + .object({ + login: z.string().nullable().optional().catch(null), + }) + .nullable() + .optional() + .catch(null), + }) + .nullable() + .optional() + .catch(null); + +const GitHubPullRequestFactsGraphqlSchema = z.object({ + data: z.object({ + repository: z + .object({ + autoMergeAllowed: z.boolean().optional().catch(false), + mergeCommitAllowed: z.boolean().optional().catch(false), + squashMergeAllowed: z.boolean().optional().catch(false), + rebaseMergeAllowed: z.boolean().optional().catch(false), + viewerDefaultMergeMethod: z.string().nullable().optional().catch(null), + pullRequest: z + .object({ + mergeStateStatus: z.string().nullable().optional().catch(null), + autoMergeRequest: GitHubAutoMergeRequestSchema, + viewerCanEnableAutoMerge: z.boolean().optional().catch(false), + viewerCanDisableAutoMerge: z.boolean().optional().catch(false), + viewerCanMergeAsAdmin: z.boolean().optional().catch(false), + viewerCanUpdateBranch: z.boolean().optional().catch(false), + isMergeQueueEnabled: z.boolean().optional().catch(false), + isInMergeQueue: z.boolean().optional().catch(false), + }) + .nullable() + .optional() + .catch(null), + }) + .nullable() + .optional() + .catch(null), + }), +}); + const CurrentPullRequestStatusSchema = z.object({ number: z.number().optional(), url: z.string().catch(""), @@ -240,6 +286,33 @@ const CURRENT_PR_STATUS_BASE_FIELDS = "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,reviewDecision,mergeable,headRepositoryOwner"; const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`; +const PULL_REQUEST_STATUS_FACTS_QUERY = ` +query PullRequestStatusFacts($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + autoMergeAllowed + mergeCommitAllowed + squashMergeAllowed + rebaseMergeAllowed + viewerDefaultMergeMethod + pullRequest(number: $number) { + mergeStateStatus + autoMergeRequest { + enabledAt + mergeMethod + enabledBy { + login + } + } + viewerCanEnableAutoMerge + viewerCanDisableAutoMerge + viewerCanMergeAsAdmin + viewerCanUpdateBranch + isMergeQueueEnabled + isInMergeQueue + } + } +}`; + const PULL_REQUEST_TIMELINE_QUERY = ` query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { @@ -353,6 +426,28 @@ export type PullRequestChecksStatus = "none" | "pending" | "success" | "failure" export type PullRequestReviewDecision = "approved" | "changes_requested" | "pending" | null; export type PullRequestMergeable = "MERGEABLE" | "CONFLICTING" | "UNKNOWN"; +export interface GitHubPullRequestStatusFacts { + mergeStateStatus: string | null; + autoMergeRequest: { + enabledAt: string | null; + mergeMethod: string | null; + enabledBy: string | null; + } | null; + viewerCanEnableAutoMerge: boolean; + viewerCanDisableAutoMerge: boolean; + viewerCanMergeAsAdmin: boolean; + viewerCanUpdateBranch: boolean; + repository: { + autoMergeAllowed: boolean; + mergeCommitAllowed: boolean; + squashMergeAllowed: boolean; + rebaseMergeAllowed: boolean; + viewerDefaultMergeMethod: string | null; + }; + isMergeQueueEnabled: boolean; + isInMergeQueue: boolean; +} + export interface GitHubCurrentPullRequestStatus { number?: number; repoOwner?: string; @@ -368,6 +463,7 @@ export interface GitHubCurrentPullRequestStatus { checks: PullRequestCheck[]; checksStatus: PullRequestChecksStatus; reviewDecision: PullRequestReviewDecision; + github?: GitHubPullRequestStatusFacts; } export type PullRequestTimelineReviewState = "approved" | "changes_requested" | "commented"; @@ -412,17 +508,41 @@ export interface GitHubPullRequestCreateResult { } export type GitHubPullRequestMergeMethod = "merge" | "squash" | "rebase"; +const DIRECT_PULL_REQUEST_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]); + +export interface GitHubPullRequestCommandStatus { + mergeable?: PullRequestMergeable; + github?: GitHubPullRequestStatusFacts; +} export interface MergeGitHubPullRequestOptions { cwd: string; prNumber: number; mergeMethod: GitHubPullRequestMergeMethod; + status?: GitHubPullRequestCommandStatus | null; +} + +export interface EnableGitHubPullRequestAutoMergeOptions { + cwd: string; + prNumber: number; + mergeMethod: GitHubPullRequestMergeMethod; + status?: GitHubPullRequestCommandStatus | null; +} + +export interface DisableGitHubPullRequestAutoMergeOptions { + cwd: string; + prNumber: number; + status?: GitHubPullRequestCommandStatus | null; } export interface GitHubPullRequestMergeResult { success: true; } +export interface GitHubPullRequestAutoMergeResult { + success: true; +} + export type GitHubReadOptions = | { force?: false; @@ -512,6 +632,12 @@ export interface GitHubService { options: CreateGitHubPullRequestOptions, ): Promise; mergePullRequest(options: MergeGitHubPullRequestOptions): Promise; + enablePullRequestAutoMerge( + options: EnableGitHubPullRequestAutoMergeOptions, + ): Promise; + disablePullRequestAutoMerge( + options: DisableGitHubPullRequestAutoMergeOptions, + ): Promise; isAuthenticated(options: { cwd: string } & GitHubReadOptions): Promise; retainCurrentPullRequestStatusPoll?(options: { cwd: string; @@ -577,6 +703,13 @@ interface CommandFailureLike { type PullRequestCheckRunNode = z.infer; type PullRequestStatusContextNode = z.infer; type CurrentPullRequestStatusItem = z.infer; +type GitHubPullRequestFactsGraphql = z.infer; +type GitHubPullRequestFactsRepository = NonNullable< + GitHubPullRequestFactsGraphql["data"]["repository"] +>; +type GitHubPullRequestFactsPullRequest = NonNullable< + GitHubPullRequestFactsRepository["pullRequest"] +>; interface InFlightCacheEntry { cwd: string; @@ -882,12 +1015,13 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G }, readOptions: input, load: async () => { - return resolveCurrentPullRequestView({ + const status = await resolveCurrentPullRequestView({ cwd: input.cwd, headRef: input.headRef, headRepositoryOwner: input.headRepositoryOwner, run, }); + return addCurrentPullRequestGithubFacts({ cwd: input.cwd, status, run }); }, }).then((status) => { updatePollTargetAfterSuccess({ @@ -1051,6 +1185,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G }, async mergePullRequest(input) { + assertDirectPullRequestMergeReady(input); await run(["pr", "merge", String(input.prNumber), `--${input.mergeMethod}`], { cwd: input.cwd, envOverlay: { GH_PROMPT_DISABLED: "1" }, @@ -1058,6 +1193,24 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G return { success: true }; }, + async enablePullRequestAutoMerge(input) { + assertPullRequestAutoMergeEnableReady(input); + await run(["pr", "merge", String(input.prNumber), "--auto", `--${input.mergeMethod}`], { + cwd: input.cwd, + envOverlay: { GH_PROMPT_DISABLED: "1" }, + }); + return { success: true }; + }, + + async disablePullRequestAutoMerge(input) { + assertPullRequestAutoMergeDisableReady(input); + await run(["pr", "merge", String(input.prNumber), "--disable-auto"], { + cwd: input.cwd, + envOverlay: { GH_PROMPT_DISABLED: "1" }, + }); + return { success: true }; + }, + isAuthenticated(input) { return cached({ cwd: input.cwd, @@ -1161,6 +1314,89 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G return api; } +function assertDirectPullRequestMergeReady(input: MergeGitHubPullRequestOptions): void { + const github = input.status?.github; + if (!github) { + throw new Error("GitHub merge facts are unavailable for this pull request"); + } + + if (!DIRECT_PULL_REQUEST_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? "")) { + throw new Error("GitHub does not report this pull request as ready for direct merge"); + } + if (github.isMergeQueueEnabled || github.isInMergeQueue) { + throw new Error("Direct merge is not available because this repository uses a merge queue"); + } + if (github.autoMergeRequest !== null) { + throw new Error("Direct merge is not available because auto-merge is already enabled"); + } + if (!isPullRequestMergeMethodAllowed(github.repository, input.mergeMethod)) { + throw new Error(`Direct merge is not available because ${input.mergeMethod} is disabled`); + } +} + +export function assertPullRequestAutoMergeEnableReady( + input: Pick, +): void { + const github = input.status?.github; + if (!github) { + throw new Error("GitHub auto-merge facts are unavailable for this pull request"); + } + + if (github.mergeStateStatus !== "BLOCKED") { + throw new Error("GitHub does not report this pull request as blocked for auto-merge"); + } + if (!github.viewerCanEnableAutoMerge) { + throw new Error("GitHub does not allow this viewer to enable auto-merge"); + } + if (!github.repository.autoMergeAllowed) { + throw new Error("Auto-merge is disabled for this repository"); + } + if (!isPullRequestMergeMethodAllowed(github.repository, input.mergeMethod)) { + throw new Error(`Auto-merge is not available because ${input.mergeMethod} is disabled`); + } + if (github.autoMergeRequest !== null) { + throw new Error("Auto-merge is already enabled for this pull request"); + } + if (github.isMergeQueueEnabled || github.isInMergeQueue) { + throw new Error("Auto-merge is not available because this repository uses a merge queue"); + } + if (input.status?.mergeable === "CONFLICTING") { + throw new Error("Auto-merge is not available because this pull request has conflicts"); + } +} + +export function assertPullRequestAutoMergeDisableReady( + input: Pick, +): void { + const github = input.status?.github; + if (!github) { + throw new Error("GitHub auto-merge facts are unavailable for this pull request"); + } + + if (github.autoMergeRequest === null) { + throw new Error("Auto-merge is not enabled for this pull request"); + } + if (!github.viewerCanDisableAutoMerge) { + throw new Error("GitHub does not allow this viewer to disable auto-merge"); + } + if (github.isMergeQueueEnabled || github.isInMergeQueue) { + throw new Error("Auto-merge is not available because this repository uses a merge queue"); + } +} + +export function isPullRequestMergeMethodAllowed( + repository: GitHubPullRequestStatusFacts["repository"], + method: GitHubPullRequestMergeMethod, +): boolean { + if (method === "squash") { + return repository.squashMergeAllowed; + } + if (method === "merge") { + return repository.mergeCommitAllowed; + } + return repository.rebaseMergeAllowed; +} + export function computeGithubNextInterval( status: GitHubCurrentPullRequestStatus | null, consecutiveErrors: number, @@ -1372,6 +1608,68 @@ async function resolveCurrentPullRequestView(options: { return match?.status ?? null; } +async function addCurrentPullRequestGithubFacts(options: { + cwd: string; + status: GitHubCurrentPullRequestStatus | null; + run: (args: string[], options: GitHubCommandRunnerOptions) => Promise; +}): Promise { + const { status } = options; + if (!status?.repoOwner || !status.repoName || typeof status.number !== "number") { + return status; + } + + const facts = await loadPullRequestGithubFacts({ + cwd: options.cwd, + owner: status.repoOwner, + name: status.repoName, + number: status.number, + run: options.run, + }); + if (!facts) { + return status; + } + return { + ...status, + github: facts, + }; +} + +async function loadPullRequestGithubFacts(options: { + cwd: string; + owner: string; + name: string; + number: number; + run: (args: string[], options: GitHubCommandRunnerOptions) => Promise; +}): Promise { + try { + const stdout = await options.run( + [ + "api", + "graphql", + "-f", + `query=${PULL_REQUEST_STATUS_FACTS_QUERY}`, + "-F", + `owner=${options.owner}`, + "-F", + `name=${options.name}`, + "-F", + `number=${options.number}`, + ], + { cwd: options.cwd }, + ); + return parsePullRequestGithubFacts(stdout); + } catch (error) { + if ( + error instanceof GitHubCommandError || + error instanceof z.ZodError || + error instanceof SyntaxError + ) { + return null; + } + throw error; + } +} + async function tryCurrentPullRequestView(options: { cwd: string; headRef: string; @@ -1469,6 +1767,52 @@ function parseCurrentPullRequestCandidateList( .filter((candidate): candidate is ResolvedPullRequestCandidate => candidate !== null); } +function parsePullRequestGithubFacts(stdout: string): GitHubPullRequestStatusFacts | null { + const parsed = GitHubPullRequestFactsGraphqlSchema.parse(JSON.parse(stdout || "{}")); + const repository = parsed.data.repository; + const pullRequest = repository?.pullRequest; + if (!repository || !pullRequest) { + return null; + } + + return { + mergeStateStatus: pullRequest.mergeStateStatus ?? null, + autoMergeRequest: toGitHubAutoMergeRequest(pullRequest.autoMergeRequest), + viewerCanEnableAutoMerge: pullRequest.viewerCanEnableAutoMerge ?? false, + viewerCanDisableAutoMerge: pullRequest.viewerCanDisableAutoMerge ?? false, + viewerCanMergeAsAdmin: pullRequest.viewerCanMergeAsAdmin ?? false, + viewerCanUpdateBranch: pullRequest.viewerCanUpdateBranch ?? false, + repository: toGitHubRepositoryMergePolicy(repository), + isMergeQueueEnabled: pullRequest.isMergeQueueEnabled ?? false, + isInMergeQueue: pullRequest.isInMergeQueue ?? false, + }; +} + +function toGitHubAutoMergeRequest( + request: GitHubPullRequestFactsPullRequest["autoMergeRequest"], +): GitHubPullRequestStatusFacts["autoMergeRequest"] { + if (!request) { + return null; + } + return { + enabledAt: request.enabledAt ?? null, + mergeMethod: request.mergeMethod ?? null, + enabledBy: request.enabledBy?.login ?? null, + }; +} + +function toGitHubRepositoryMergePolicy( + repository: GitHubPullRequestFactsRepository, +): GitHubPullRequestStatusFacts["repository"] { + return { + autoMergeAllowed: repository.autoMergeAllowed ?? false, + mergeCommitAllowed: repository.mergeCommitAllowed ?? false, + squashMergeAllowed: repository.squashMergeAllowed ?? false, + rebaseMergeAllowed: repository.rebaseMergeAllowed ?? false, + viewerDefaultMergeMethod: repository.viewerDefaultMergeMethod ?? null, + }; +} + function toCurrentPullRequestCandidate( item: CurrentPullRequestStatusItem, fallbackHeadRefName: string, diff --git a/packages/server/src/shared/messages.checkout-pr-schema.test.ts b/packages/server/src/shared/messages.checkout-pr-schema.test.ts index c440b95a1..85ae910f0 100644 --- a/packages/server/src/shared/messages.checkout-pr-schema.test.ts +++ b/packages/server/src/shared/messages.checkout-pr-schema.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "vitest"; -import { CheckoutPrMergeRequestSchema, CheckoutPrStatusSchema } from "./messages.js"; +import { + CheckoutGithubSetAutoMergeRequestSchema, + CheckoutGithubSetAutoMergeResponseSchema, + CheckoutPrMergeRequestSchema, + CheckoutPrStatusSchema, + ServerInfoStatusPayloadSchema, +} from "./messages.js"; describe("checkout PR schemas", () => { test("parses PR status payloads without mergeability", () => { @@ -20,6 +26,67 @@ describe("checkout PR schemas", () => { }); }); + test("keeps missing provider-specific GitHub PR facts absent for old daemons", () => { + const parsed = CheckoutPrStatusSchema.parse({ + number: 42, + url: "https://github.com/getpaseo/paseo/pull/42", + title: "Ship it", + state: "open", + baseRefName: "main", + headRefName: "feature/ship-it", + isMerged: false, + mergeable: "MERGEABLE", + }); + + expect(parsed.github).toBeUndefined(); + }); + + test("parses provider-specific GitHub PR status facts", () => { + expect( + CheckoutPrStatusSchema.parse({ + number: 993, + url: "https://github.com/getpaseo/paseo/pull/993", + title: "Block direct merge while checks run", + state: "open", + baseRefName: "main", + headRefName: "phase-2", + isMerged: false, + mergeable: "MERGEABLE", + checks: [{ name: "server tests", status: "pending", url: null }], + checksStatus: "pending", + github: { + mergeStateStatus: "BLOCKED", + autoMergeRequest: null, + viewerCanEnableAutoMerge: true, + viewerCanDisableAutoMerge: false, + viewerCanMergeAsAdmin: false, + viewerCanUpdateBranch: true, + repository: { + autoMergeAllowed: true, + mergeCommitAllowed: false, + squashMergeAllowed: true, + rebaseMergeAllowed: false, + viewerDefaultMergeMethod: "SQUASH", + }, + isMergeQueueEnabled: false, + isInMergeQueue: false, + }, + }), + ).toMatchObject({ + mergeable: "MERGEABLE", + checksStatus: "pending", + github: { + mergeStateStatus: "BLOCKED", + viewerCanEnableAutoMerge: true, + repository: { + autoMergeAllowed: true, + squashMergeAllowed: true, + viewerDefaultMergeMethod: "SQUASH", + }, + }, + }); + }); + test.each(["merge", "squash", "rebase"] as const)( "accepts %s as a PR merge method", (mergeMethod) => { @@ -44,4 +111,79 @@ describe("checkout PR schemas", () => { }), ).toThrow(); }); + + test.each(["merge", "squash", "rebase"] as const)( + "accepts %s as a GitHub set-auto-merge enable method", + (mergeMethod) => { + expect( + CheckoutGithubSetAutoMergeRequestSchema.parse({ + type: "checkout.github.set_auto_merge.request", + cwd: "/tmp/repo", + enabled: true, + mergeMethod, + requestId: "request-enable-auto-merge", + }), + ).toMatchObject({ enabled: true, mergeMethod }); + }, + ); + + test("rejects unknown GitHub set-auto-merge enable methods", () => { + expect(() => + CheckoutGithubSetAutoMergeRequestSchema.parse({ + type: "checkout.github.set_auto_merge.request", + cwd: "/tmp/repo", + enabled: true, + mergeMethod: "auto", + requestId: "request-enable-auto-merge", + }), + ).toThrow(); + }); + + test("accepts GitHub set-auto-merge disable requests", () => { + expect( + CheckoutGithubSetAutoMergeRequestSchema.parse({ + type: "checkout.github.set_auto_merge.request", + cwd: "/tmp/repo", + enabled: false, + requestId: "request-disable-auto-merge", + }), + ).toMatchObject({ + cwd: "/tmp/repo", + enabled: false, + requestId: "request-disable-auto-merge", + }); + }); + + test("accepts GitHub set-auto-merge responses", () => { + const payload = { + cwd: "/tmp/repo", + enabled: true, + success: true, + error: null, + requestId: "request-auto-merge", + }; + + expect( + CheckoutGithubSetAutoMergeResponseSchema.parse({ + type: "checkout.github.set_auto_merge.response", + payload, + }).payload, + ).toEqual(payload); + }); + + test("accepts the GitHub auto-merge server_info feature flag", () => { + expect( + ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "srv_test", + features: { + providersSnapshot: true, + checkoutGithubSetAutoMerge: true, + }, + }).features, + ).toEqual({ + providersSnapshot: true, + checkoutGithubSetAutoMerge: true, + }); + }); }); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 309c2b6c7..b63a5ec81 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1314,6 +1314,14 @@ export const CheckoutPrMergeRequestSchema = z.object({ requestId: z.string(), }); +export const CheckoutGithubSetAutoMergeRequestSchema = z.object({ + type: z.literal("checkout.github.set_auto_merge.request"), + cwd: z.string(), + enabled: z.boolean(), + mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(), + requestId: z.string(), +}); + export const CheckoutPrStatusRequestSchema = z.object({ type: z.literal("checkout_pr_status_request"), cwd: z.string(), @@ -1763,6 +1771,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPushRequestSchema, CheckoutPrCreateRequestSchema, CheckoutPrMergeRequestSchema, + CheckoutGithubSetAutoMergeRequestSchema, CheckoutPrStatusRequestSchema, PullRequestTimelineRequestSchema, CheckoutSwitchBranchRequestSchema, @@ -1985,6 +1994,7 @@ export const ServerInfoStatusPayloadSchema = z features: z .object({ providersSnapshot: z.boolean().optional(), + checkoutGithubSetAutoMerge: z.boolean().optional(), }) .optional(), }) @@ -2705,6 +2715,47 @@ export const CheckoutStatusResponseSchema = z.object({ ]), }); +const CheckoutPrGithubAutoMergeRequestSchema = z + .object({ + enabledAt: z.string().nullable().optional().default(null), + mergeMethod: z.string().nullable().optional().default(null), + enabledBy: z.string().nullable().optional().default(null), + }) + .nullable() + .optional() + .default(null); + +const CheckoutPrGithubRepositoryPolicySchema = z + .object({ + autoMergeAllowed: z.boolean().optional().default(false), + mergeCommitAllowed: z.boolean().optional().default(false), + squashMergeAllowed: z.boolean().optional().default(false), + rebaseMergeAllowed: z.boolean().optional().default(false), + viewerDefaultMergeMethod: z.string().nullable().optional().default(null), + }) + .optional() + .default({ + autoMergeAllowed: false, + mergeCommitAllowed: false, + squashMergeAllowed: false, + rebaseMergeAllowed: false, + viewerDefaultMergeMethod: null, + }); + +const CheckoutPrGithubStatusSchema = z + .object({ + mergeStateStatus: z.string().nullable().optional().default(null), + autoMergeRequest: CheckoutPrGithubAutoMergeRequestSchema, + viewerCanEnableAutoMerge: z.boolean().optional().default(false), + viewerCanDisableAutoMerge: z.boolean().optional().default(false), + viewerCanMergeAsAdmin: z.boolean().optional().default(false), + viewerCanUpdateBranch: z.boolean().optional().default(false), + repository: CheckoutPrGithubRepositoryPolicySchema, + isMergeQueueEnabled: z.boolean().optional().default(false), + isInMergeQueue: z.boolean().optional().default(false), + }) + .optional(); + export const CheckoutPrStatusSchema = z.object({ number: z.number().optional(), url: z.string(), @@ -2735,6 +2786,7 @@ export const CheckoutPrStatusSchema = z.object({ reviewDecision: z.string().nullable().optional(), repoOwner: z.string().optional(), repoName: z.string().optional(), + github: CheckoutPrGithubStatusSchema, }); const CheckoutPrStatusPayloadSchema = z.object({ @@ -2850,6 +2902,17 @@ export const CheckoutPrMergeResponseSchema = z.object({ }), }); +export const CheckoutGithubSetAutoMergeResponseSchema = z.object({ + type: z.literal("checkout.github.set_auto_merge.response"), + payload: z.object({ + cwd: z.string(), + enabled: z.boolean(), + success: z.boolean(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + export const CheckoutPrStatusResponseSchema = z.object({ type: z.literal("checkout_pr_status_response"), payload: CheckoutPrStatusPayloadSchema, @@ -3408,6 +3471,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPushResponseSchema, CheckoutPrCreateResponseSchema, CheckoutPrMergeResponseSchema, + CheckoutGithubSetAutoMergeResponseSchema, CheckoutPrStatusResponseSchema, PullRequestTimelineResponseSchema, CheckoutSwitchBranchResponseSchema, @@ -3664,6 +3728,12 @@ export type CheckoutPrCreateResponse = z.infer; export type CheckoutPrMergeResponse = z.infer; export type CheckoutPrMergeMethod = z.infer["mergeMethod"]; +export type CheckoutGithubSetAutoMergeRequest = z.infer< + typeof CheckoutGithubSetAutoMergeRequestSchema +>; +export type CheckoutGithubSetAutoMergeResponse = z.infer< + typeof CheckoutGithubSetAutoMergeResponseSchema +>; export type PullRequestMergeable = z.infer["mergeable"]; export type CheckoutPrStatusRequest = z.infer; export type CheckoutPrStatusResponse = z.infer; diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 02ff020c3..82dc3d199 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -1708,6 +1708,30 @@ const x = 1; expect(callCount).toBe(1); }); + it("passes forced PR status reads through to the GitHub service", async () => { + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { + cwd: repoDir, + }); + + const requested: Array<{ force?: boolean; reason?: string }> = []; + const github = createGitHubServiceForStatus(null); + github.getCurrentPullRequestStatus = async (options) => { + requested.push({ + ...(options.force ? { force: options.force } : {}), + ...(options.reason ? { reason: options.reason } : {}), + }); + return createPullRequestStatus(); + }; + + await getPullRequestStatus(repoDir, github, { + force: true, + reason: "merge-pr-validation", + }); + + expect(requested).toEqual([{ force: true, reason: "merge-pr-validation" }]); + }); + it("expires cached PR status after the TTL", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { @@ -1777,6 +1801,39 @@ const x = 1; } }); + it("does not use stale PR status fallback for forced GitHub errors", async () => { + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { + cwd: repoDir, + }); + + const github = createGitHubServiceForStatus(null); + github.getCurrentPullRequestStatus = async () => + createPullRequestStatus({ + url: "https://github.com/getpaseo/paseo/pull/123", + }); + + const fresh = await getPullRequestStatus(repoDir, github); + expect(fresh.status?.url).toContain("/pull/123"); + + const error = new GitHubCommandError({ + args: ["pr", "view"], + cwd: repoDir, + exitCode: 1, + stderr: "could not resolve host: github.com", + }); + github.getCurrentPullRequestStatus = async () => { + throw error; + }; + + await expect( + getPullRequestStatus(repoDir, github, { + force: true, + reason: "merge-pr-validation", + }), + ).rejects.toBe(error); + }); + it("clears stale PR status after a successful no-PR refresh", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 51ea4cbce..684e4ea4a 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -11,6 +11,8 @@ import { GitHubCommandError, createGitHubService, resolveGitHubRepo, + type GitHubCurrentPullRequestStatus, + type GitHubPullRequestStatusFacts, type GitHubService, type PullRequestMergeable, } from "../services/github-service.js"; @@ -2318,6 +2320,7 @@ export interface PullRequestStatus { checks?: PullRequestCheck[]; checksStatus?: ChecksStatus; reviewDecision?: ReviewDecision; + github?: GitHubPullRequestStatusFacts; } export interface PullRequestStatusResult { @@ -2402,7 +2405,7 @@ export async function getPullRequestStatus( return status; }) .catch((error) => { - if (error instanceof GitHubCommandError) { + if (!options?.force && error instanceof GitHubCommandError) { const stale = lastSuccessfulPullRequestStatus.get(cacheKey); if (stale) { return stale; @@ -2433,11 +2436,25 @@ async function getPullRequestStatusUncached( } try { const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head); - const status = await github.getCurrentPullRequestStatus({ - cwd, - ...lookupTarget, - reason: options?.reason, - }); + let status: GitHubCurrentPullRequestStatus | null; + if (options?.force) { + const reason = options.reason; + if (!reason) { + throw new Error("Forced PR status read requires a reason"); + } + status = await github.getCurrentPullRequestStatus({ + cwd, + ...lookupTarget, + force: true, + reason, + }); + } else { + status = await github.getCurrentPullRequestStatus({ + cwd, + ...lookupTarget, + reason: options?.reason, + }); + } return { status, githubFeaturesEnabled: true,