Surface GitHub auto-merge actions (#1001)

* Surface GitHub auto-merge actions

* Namespace GitHub auto-merge RPC

* Update workspace git refresh test expectation

* Avoid redundant GitHub refresh queue
This commit is contained in:
Mohamed Boudra
2026-05-14 17:48:05 +08:00
committed by GitHub
parent 44292b2b69
commit eb7fa02ac8
27 changed files with 3029 additions and 105 deletions

View File

@@ -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/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/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/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/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/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 | | [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. - **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. - **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. - 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. - **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.

View File

@@ -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). **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:** **Notable session message types:**
- `agent_update` — Agent state changed (status, title, labels) - `agent_update` — Agent state changed (status, title, labels)

84
docs/rpc-namespacing.md Normal file
View File

@@ -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.

View File

@@ -97,6 +97,13 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
[theme.colors.foreground, toast], [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 overflowMenuButtonStyle = useMemo(() => [styles.iconButton, styles.overflowMenuButton], []);
const primaryDisabled = gitActions.primary?.disabled; const primaryDisabled = gitActions.primary?.disabled;
@@ -124,7 +131,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
<Pressable <Pressable
testID="changes-primary-cta" testID="changes-primary-cta"
style={primaryPressableStyle} style={primaryPressableStyle}
onPress={gitActions.primary.handler} onPress={handlePrimaryPress}
disabled={gitActions.primary.disabled} disabled={gitActions.primary.disabled}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={gitActions.primary.label} accessibilityLabel={gitActions.primary.label}

View File

@@ -170,6 +170,91 @@ describe("checkout-git-actions-store", () => {
).toBe("idle"); ).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 () => { it("hides an archived worktree optimistically while the archive RPC is in flight", async () => {
const deferred = createDeferred<Record<string, never>>(); const deferred = createDeferred<Record<string, never>>();
const client = { const client = {

View File

@@ -32,6 +32,10 @@ export type CheckoutGitAsyncActionId =
| "merge-pr-squash" | "merge-pr-squash"
| "merge-pr-merge" | "merge-pr-merge"
| "merge-pr-rebase" | "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-branch"
| "merge-from-base" | "merge-from-base"
| "archive-worktree"; | "archive-worktree";
@@ -52,6 +56,13 @@ function resolveClient(serverId: string) {
return client; 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( function setStatus(
key: CheckoutKey, key: CheckoutKey,
actionId: CheckoutGitAsyncActionId, actionId: CheckoutGitAsyncActionId,
@@ -231,6 +242,12 @@ interface CheckoutGitActionsStoreState {
cwd: string; cwd: string;
method: CheckoutPrMergeMethod; method: CheckoutPrMergeMethod;
}) => Promise<void>; }) => Promise<void>;
enablePrAutoMerge: (params: {
serverId: string;
cwd: string;
method: CheckoutPrMergeMethod;
}) => Promise<void>;
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>; mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>; mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
archiveWorktree: (params: { archiveWorktree: (params: {
@@ -392,6 +409,38 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
}); });
}, },
enablePrAutoMerge: async ({ serverId, cwd, method }) => {
assertGitHubAutoMergeActionsSupported(serverId);
await runCheckoutAction({
serverId,
cwd,
actionId: `enable-pr-auto-merge-${method}`,
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method });
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
disablePrAutoMerge: async ({ serverId, cwd }) => {
assertGitHubAutoMergeActionsSupported(serverId);
await runCheckoutAction({
serverId,
cwd,
actionId: "disable-pr-auto-merge",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: false });
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
mergeBranch: async ({ serverId, cwd, baseRef }) => { mergeBranch: async ({ serverId, cwd, baseRef }) => {
await runCheckoutAction({ await runCheckoutAction({
serverId, serverId,

View File

@@ -1,17 +1,43 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { CheckoutPrStatusSchema } from "@server/shared/messages";
import { buildGitActions, type BuildGitActionsInput } from "./policy"; import { buildGitActions, type BuildGitActionsInput } from "./policy";
function githubStatus(
overrides: Partial<NonNullable<BuildGitActionsInput["pullRequestGithub"]>> = {},
): NonNullable<BuildGitActionsInput["pullRequestGithub"]> {
return {
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: false,
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod: "SQUASH",
},
isMergeQueueEnabled: false,
isInMergeQueue: false,
...overrides,
};
}
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput { function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
return { return {
isGit: true, isGit: true,
githubFeaturesEnabled: true, githubFeaturesEnabled: true,
githubAutoMergeActionsEnabled: true,
hasPullRequest: false, hasPullRequest: false,
pullRequestUrl: null, pullRequestUrl: null,
pullRequestState: null, pullRequestState: null,
pullRequestIsDraft: false, pullRequestIsDraft: false,
pullRequestIsMerged: false, pullRequestIsMerged: false,
pullRequestMergeable: "UNKNOWN", pullRequestMergeable: "UNKNOWN",
pullRequestGithub: null,
hasRemote: false, hasRemote: false,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
isOnBaseBranch: true, isOnBaseBranch: true,
@@ -65,6 +91,26 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
status: "idle", status: "idle",
handler: () => undefined, 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": { "merge-branch": {
disabled: false, disabled: false,
status: "idle", status: "idle",
@@ -167,6 +213,9 @@ describe("git-actions-policy", () => {
behindBaseCount: 1, behindBaseCount: 1,
hasPullRequest: true, hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456", pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
}), }),
); );
@@ -177,9 +226,6 @@ describe("git-actions-policy", () => {
"merge-from-base", "merge-from-base",
"merge-branch", "merge-branch",
"pr", "pr",
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
]); ]);
expect( expect(
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"), 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", pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open", pullRequestState: "open",
pullRequestMergeable: "MERGEABLE", 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", shipDefault: "pr",
}), }),
); );
@@ -266,6 +334,7 @@ describe("git-actions-policy", () => {
pullRequestUrl: "https://example.com/pr/456", pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open", pullRequestState: "open",
pullRequestMergeable: "MERGEABLE", pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
shipDefault: "pr", shipDefault: "pr",
}), }),
); );
@@ -278,6 +347,7 @@ describe("git-actions-policy", () => {
pullRequestUrl: "https://example.com/pr/456", pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open", pullRequestState: "open",
pullRequestMergeable: "MERGEABLE", pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
shipDefault: "merge", shipDefault: "merge",
}), }),
); );
@@ -305,6 +375,7 @@ describe("git-actions-policy", () => {
pullRequestUrl: "https://example.com/pr/456", pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open", pullRequestState: "open",
pullRequestMergeable: "MERGEABLE", 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", () => { it("uses Merge locally for the local merge action", () => {
const actions = buildGitActions( const actions = buildGitActions(
createInput({ createInput({
@@ -334,27 +472,11 @@ describe("git-actions-policy", () => {
}); });
it.each([ it.each([
[ ["draft", { pullRequestIsDraft: true }],
"draft", ["merged", { pullRequestIsMerged: true }],
{ pullRequestIsDraft: true }, ["closed", { pullRequestState: "closed" as const }],
"Merge PR isn't available because the pull request is still a draft", ["conflicting", { pullRequestMergeable: "CONFLICTING" as const }],
], ])("does not offer direct merge actions when the PR is %s", (_name, overrides) => {
[
"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) => {
const actions = buildGitActions( const actions = buildGitActions(
createInput({ createInput({
hasRemote: true, hasRemote: true,
@@ -364,6 +486,7 @@ describe("git-actions-policy", () => {
pullRequestUrl: "https://example.com/pr/456", pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open", pullRequestState: "open",
pullRequestMergeable: "MERGEABLE", pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
...overrides, ...overrides,
}), }),
); );
@@ -371,13 +494,224 @@ describe("git-actions-policy", () => {
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id), ["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
); );
expect(mergePrActions).toHaveLength(3); expect(mergePrActions).toEqual([]);
expect(mergePrActions.map((action) => action.unavailableMessage)).toEqual([ expect(actions.secondary).toEqual(
message, expect.arrayContaining([expect.objectContaining({ id: "pr", label: "View PR" })]),
message, );
message, });
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", () => { 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", pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open", pullRequestState: "open",
pullRequestMergeable: "MERGEABLE", pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
isPaseoOwnedWorktree: true, isPaseoOwnedWorktree: true,
}), }),
); );

View File

@@ -1,7 +1,11 @@
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import type { ActionStatus } from "@/components/ui/dropdown-menu"; 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 = export type GitActionId =
| "commit" | "commit"
@@ -12,6 +16,10 @@ export type GitActionId =
| "merge-pr-squash" | "merge-pr-squash"
| "merge-pr-merge" | "merge-pr-merge"
| "merge-pr-rebase" | "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-branch"
| "merge-from-base" | "merge-from-base"
| "archive-worktree"; | "archive-worktree";
@@ -46,12 +54,14 @@ interface GitActionRuntimeState {
export interface BuildGitActionsInput { export interface BuildGitActionsInput {
isGit: boolean; isGit: boolean;
githubFeaturesEnabled: boolean; githubFeaturesEnabled: boolean;
githubAutoMergeActionsEnabled: boolean;
hasPullRequest: boolean; hasPullRequest: boolean;
pullRequestUrl: string | null; pullRequestUrl: string | null;
pullRequestState: "open" | "closed" | null; pullRequestState: "open" | "closed" | null;
pullRequestIsDraft: boolean; pullRequestIsDraft: boolean;
pullRequestIsMerged: boolean; pullRequestIsMerged: boolean;
pullRequestMergeable: PullRequestMergeable; pullRequestMergeable: PullRequestMergeable;
pullRequestGithub: PullRequestGithubStatus | null;
hasRemote: boolean; hasRemote: boolean;
isPaseoOwnedWorktree: boolean; isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean; isOnBaseBranch: boolean;
@@ -67,24 +77,117 @@ export interface BuildGitActionsInput {
runtime: Record<GitActionId, GitActionRuntimeState>; runtime: Record<GitActionId, GitActionRuntimeState>;
} }
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"]; type PullRequestActionId = Extract<
const FEATURE_ACTION_IDS: GitActionId[] = [ GitActionId,
"merge-from-base", | "pr"
"merge-branch", | "merge-pr-squash"
"pr", | "merge-pr-merge"
"merge-pr-squash", | "merge-pr-rebase"
"merge-pr-merge", | "enable-pr-auto-merge-squash"
"merge-pr-rebase", | "enable-pr-auto-merge-merge"
| "enable-pr-auto-merge-rebase"
| "disable-pr-auto-merge"
>;
type PullRequestDirectMergeActionId = Extract<
GitActionId,
"merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase"
>;
type PullRequestAutoMergeEnableActionId = Extract<
GitActionId,
"enable-pr-auto-merge-squash" | "enable-pr-auto-merge-merge" | "enable-pr-auto-merge-rebase"
>;
type PullRequestActionRole = "status" | "direct" | "auto";
type PullRequestGithubStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>["github"];
interface PullRequestActionModel {
readonly id: PullRequestActionId;
readonly role: PullRequestActionRole;
readonly build: (input: BuildGitActionsInput) => GitAction;
}
interface PullRequestDirectMergeActionModel {
readonly id: PullRequestDirectMergeActionId;
readonly role: "direct";
readonly label: string;
readonly method: CheckoutPrMergeMethod;
readonly startsGroup: boolean;
}
interface PullRequestAutoMergeEnableActionModel {
readonly id: PullRequestAutoMergeEnableActionId;
readonly role: "auto";
readonly label: string;
readonly method: CheckoutPrMergeMethod;
readonly startsGroup: boolean;
}
const PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS = [
{
id: "merge-pr-squash",
role: "direct",
label: "Squash and merge",
method: "squash",
startsGroup: true,
},
{
id: "merge-pr-merge",
role: "direct",
label: "Create a merge commit",
method: "merge",
startsGroup: false,
},
{
id: "merge-pr-rebase",
role: "direct",
label: "Rebase and merge",
method: "rebase",
startsGroup: false,
},
] as const satisfies readonly PullRequestDirectMergeActionModel[];
const PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS = [
{
id: "enable-pr-auto-merge-squash",
role: "auto",
label: "Enable auto-merge with squash",
method: "squash",
startsGroup: true,
},
{
id: "enable-pr-auto-merge-merge",
role: "auto",
label: "Enable auto-merge with merge commit",
method: "merge",
startsGroup: false,
},
{
id: "enable-pr-auto-merge-rebase",
role: "auto",
label: "Enable auto-merge with rebase",
method: "rebase",
startsGroup: false,
},
] as const satisfies readonly PullRequestAutoMergeEnableActionModel[];
const PULL_REQUEST_ACTION_MODELS: readonly PullRequestActionModel[] = [
{ id: "pr", role: "status", build: buildPrAction },
...PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS.map((model) => ({
...model,
build: (input: BuildGitActionsInput) => buildDirectPullRequestMergeAction(input, model),
})),
...PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS.map((model) => ({
...model,
build: (input: BuildGitActionsInput) => buildEnablePullRequestAutoMergeAction(input, model),
})),
{
id: "disable-pr-auto-merge",
role: "auto",
build: buildDisablePullRequestAutoMergeAction,
},
]; ];
const MERGE_PR_METHODS: Record< const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
"merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase", const GITHUB_DIRECT_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]);
{ 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" },
};
export function narrowPullRequestState(state: string | null | undefined): "open" | "closed" | null { export function narrowPullRequestState(state: string | null | undefined): "open" | "closed" | null {
if (state === "open") return "open"; if (state === "open") return "open";
@@ -152,11 +255,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
handler: input.runtime["pull-and-push"].handler, handler: input.runtime["pull-and-push"].handler,
}); });
allActions.set("pr", buildPrAction(input)); for (const model of PULL_REQUEST_ACTION_MODELS) {
allActions.set(model.id, model.build(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"));
allActions.set("merge-branch", { allActions.set("merge-branch", {
id: "merge-branch", id: "merge-branch",
@@ -209,7 +310,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
const secondaryIds = [...REMOTE_ACTION_IDS]; const secondaryIds = [...REMOTE_ACTION_IDS];
if (!input.isOnBaseBranch) { if (!input.isOnBaseBranch) {
secondaryIds.push(...FEATURE_ACTION_IDS); secondaryIds.push(...getFeatureActionIds(input));
} }
if (input.isPaseoOwnedWorktree) { if (input.isPaseoOwnedWorktree) {
secondaryIds.push("archive-worktree"); secondaryIds.push("archive-worktree");
@@ -239,7 +340,10 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
return "merge-from-base"; return "merge-from-base";
} }
if (canMergePr(input) && input.shipDefault === "pr") { 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") { if (!input.isOnBaseBranch && input.aheadCount > 0 && input.shipDefault === "merge") {
return "merge-branch"; return "merge-branch";
@@ -253,6 +357,41 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
return 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 { function buildPrAction(input: BuildGitActionsInput): GitAction {
if (input.hasPullRequest && input.pullRequestUrl) { if (input.hasPullRequest && input.pullRequestUrl) {
return { return {
@@ -288,23 +427,60 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
}; };
} }
function buildMergePrAction( function buildDirectPullRequestMergeAction(
input: BuildGitActionsInput, input: BuildGitActionsInput,
id: "merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase", model: PullRequestDirectMergeActionModel,
): GitAction { ): GitAction {
const runtime = input.runtime[id]; const runtime = input.runtime[model.id];
const config = MERGE_PR_METHODS[id];
const unavailableMessage = getMergePrUnavailableMessage(input); const unavailableMessage = getMergePrUnavailableMessage(input);
return { return {
id, id: model.id,
label: config.label, label: model.label,
pendingLabel: "Merging PR...", pendingLabel: "Merging PR...",
successLabel: "PR merged", successLabel: "PR merged",
disabled: runtime.disabled || shouldDisableMergePrAction(input), disabled: runtime.disabled || shouldDisableMergePrAction(input),
status: runtime.status, status: runtime.status,
unavailableMessage: runtime.disabled ? undefined : unavailableMessage, unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
icon: runtime.icon, 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, handler: runtime.handler,
}; };
} }
@@ -327,18 +503,55 @@ function canMergeFromBase(input: BuildGitActionsInput): boolean {
} }
function canMergePr(input: BuildGitActionsInput): boolean { function canMergePr(input: BuildGitActionsInput): boolean {
return ( const github = input.pullRequestGithub;
const canMergeFromTopLevelStatus =
input.githubFeaturesEnabled && input.githubFeaturesEnabled &&
input.hasPullRequest && input.hasPullRequest &&
input.pullRequestState === "open" && input.pullRequestState === "open" &&
!input.pullRequestIsDraft && !input.pullRequestIsDraft &&
!input.pullRequestIsMerged && !input.pullRequestIsMerged &&
input.pullRequestMergeable === "MERGEABLE" && input.pullRequestMergeable !== "CONFLICTING" &&
input.aheadCount > 0 && input.aheadCount > 0 &&
!input.hasUncommittedChanges && !input.hasUncommittedChanges &&
input.behindOfOrigin === 0 && input.behindOfOrigin === 0 &&
input.aheadOfOrigin === 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") { if (input.pullRequestMergeable === "CONFLICTING") {
return "Merge PR isn't available because the pull request has conflicts"; 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; return undefined;
} }
function shouldDisableMergePrAction(input: BuildGitActionsInput): boolean { function shouldDisableMergePrAction(input: BuildGitActionsInput): boolean {
return ( return !canMergePr(input);
input.pullRequestIsDraft || }
input.pullRequestIsMerged ||
input.pullRequestState === "closed" || function shouldShowPullRequestAction(
input.pullRequestMergeable === "CONFLICTING" input: BuildGitActionsInput,
id: PullRequestActionId,
): boolean {
if (id === "pr") {
return true;
}
if (id === "disable-pr-auto-merge") {
return (
input.githubAutoMergeActionsEnabled &&
hasPullRequestGithubFacts(input.pullRequestGithub) &&
input.pullRequestGithub.autoMergeRequest !== null
);
}
if (isDirectPullRequestMergeActionId(id)) {
return canMergePr(input) && getAllowedDirectPullRequestMergeActionIds(input).includes(id);
}
if (isEnablePullRequestAutoMergeActionId(id)) {
return canEnablePrAutoMerge(input) && getAllowedAutoMergeEnableActionIds(input).includes(id);
}
return false;
}
function isDirectPullRequestMergeActionId(
id: PullRequestActionId,
): id is PullRequestDirectMergeActionId {
return PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS.some((model) => model.id === id);
}
function isEnablePullRequestAutoMergeActionId(
id: PullRequestActionId,
): id is PullRequestAutoMergeEnableActionId {
return PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS.some((model) => model.id === id);
}
function getAllowedDirectPullRequestMergeActionIds(
input: BuildGitActionsInput,
): PullRequestDirectMergeActionId[] {
return getAllowedDirectPullRequestMergeActionModels(input).map((model) => model.id);
}
function getAllowedAutoMergeEnableActionIds(
input: BuildGitActionsInput,
): PullRequestAutoMergeEnableActionId[] {
return getAllowedAutoMergeEnableActionModels(input).map((model) => model.id);
}
function getAllowedDirectPullRequestMergeActionModels(
input: BuildGitActionsInput,
): readonly PullRequestDirectMergeActionModel[] {
return PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS.filter((model) =>
isPullRequestMergeMethodAllowed(input, model.method),
); );
} }
function getAllowedAutoMergeEnableActionModels(
input: BuildGitActionsInput,
): readonly PullRequestAutoMergeEnableActionModel[] {
return PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS.filter((model) =>
isPullRequestMergeMethodAllowed(input, model.method),
);
}
function getPreferredDirectPullRequestMergeActionModel(
input: BuildGitActionsInput,
): PullRequestDirectMergeActionModel | null {
const allowed = getAllowedDirectPullRequestMergeActionModels(input);
const preferred = normalizeGithubMergeMethod(
input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null,
);
return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null;
}
function getPreferredEnablePullRequestAutoMergeActionModel(
input: BuildGitActionsInput,
): PullRequestAutoMergeEnableActionModel | null {
const allowed = getAllowedAutoMergeEnableActionModels(input);
const preferred = normalizeGithubMergeMethod(
input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null,
);
return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null;
}
function isPullRequestMergeMethodAllowed(
input: BuildGitActionsInput,
method: CheckoutPrMergeMethod,
): boolean {
const repository = input.pullRequestGithub?.repository;
if (!repository) {
return true;
}
if (method === "squash") {
return repository.squashMergeAllowed;
}
if (method === "merge") {
return repository.mergeCommitAllowed;
}
return repository.rebaseMergeAllowed;
}
function hasPullRequestGithubFacts(
github: PullRequestGithubStatus | null,
): github is NonNullable<PullRequestGithubStatus> {
return github !== null && github !== undefined;
}
function normalizeGithubMergeMethod(value: string | null): CheckoutPrMergeMethod | null {
if (value === "SQUASH") return "squash";
if (value === "MERGE") return "merge";
if (value === "REBASE") return "rebase";
return null;
}

View File

@@ -14,6 +14,24 @@ import {
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>; type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
type PullRequestTimeline = PullRequestTimelineResponse["payload"]; 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 = { const baseStatus: CheckoutPrStatus = {
number: 42, number: 42,
url: "https://github.com/getpaseo/paseo/pull/42", url: "https://github.com/getpaseo/paseo/pull/42",
@@ -26,6 +44,7 @@ const baseStatus: CheckoutPrStatus = {
mergeable: "UNKNOWN", mergeable: "UNKNOWN",
checks: [], checks: [],
reviewDecision: null, reviewDecision: null,
github: githubStatus,
}; };
const baseTimeline: PullRequestTimeline = { const baseTimeline: PullRequestTimeline = {

View File

@@ -246,6 +246,20 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
s.getStatus({ serverId, cwd, actionId: "merge-pr-rebase" }), s.getStatus({ serverId, cwd, actionId: "merge-pr-rebase" }),
), ),
}; };
const enablePrAutoMergeStatuses: Record<CheckoutPrMergeMethod, CheckoutGitActionStatus> = {
squash: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
),
merge: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
),
rebase: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-rebase" }),
),
};
const disablePrAutoMergeStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
);
const mergeStatus = useCheckoutGitActionsStore((s) => const mergeStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-branch" }), 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 runPullAndPush = useCheckoutGitActionsStore((s) => s.pullAndPush);
const runCreatePr = useCheckoutGitActionsStore((s) => s.createPr); const runCreatePr = useCheckoutGitActionsStore((s) => s.createPr);
const runMergePr = useCheckoutGitActionsStore((s) => s.mergePr); 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 runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase); const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((s) => s.archiveWorktree); const runArchiveWorktree = useCheckoutGitActionsStore((s) => s.archiveWorktree);
const githubAutoMergeActionsEnabled = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true,
);
const toastActionError = useCallback( const toastActionError = useCallback(
(error: unknown, fallback: string) => { (error: unknown, fallback: string) => {
@@ -354,6 +373,32 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
[cwd, persistShipDefault, runMergePr, serverId, toastActionError, toastActionSuccess], [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(() => { const handleMergeBranch = useCallback(() => {
if (!baseRef) { if (!baseRef) {
toast.error("Base ref unavailable"); toast.error("Base ref unavailable");
@@ -484,12 +529,14 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
return buildGitActions({ return buildGitActions({
isGit, isGit,
githubFeaturesEnabled, githubFeaturesEnabled,
githubAutoMergeActionsEnabled,
hasPullRequest, hasPullRequest,
pullRequestUrl: prStatus?.url ?? null, pullRequestUrl: prStatus?.url ?? null,
pullRequestState: narrowPullRequestState(prStatus?.state), pullRequestState: narrowPullRequestState(prStatus?.state),
pullRequestIsDraft: prStatus?.isDraft ?? false, pullRequestIsDraft: prStatus?.isDraft ?? false,
pullRequestIsMerged: prStatus?.isMerged ?? false, pullRequestIsMerged: prStatus?.isMerged ?? false,
pullRequestMergeable: prStatus?.mergeable ?? "UNKNOWN", pullRequestMergeable: prStatus?.mergeable ?? "UNKNOWN",
pullRequestGithub: prStatus?.github ?? null,
hasRemote, hasRemote,
isPaseoOwnedWorktree, isPaseoOwnedWorktree,
isOnBaseBranch, isOnBaseBranch,
@@ -551,6 +598,30 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
icon: icons.mergePrRebase, icon: icons.mergePrRebase,
handler: () => handleMergePr("rebase"), 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": { "merge-branch": {
disabled: isActionDisabled(actionsDisabled, mergeStatus), disabled: isActionDisabled(actionsDisabled, mergeStatus),
status: mergeStatus, status: mergeStatus,
@@ -580,11 +651,13 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
prStatus?.isDraft, prStatus?.isDraft,
prStatus?.isMerged, prStatus?.isMerged,
prStatus?.mergeable, prStatus?.mergeable,
prStatus?.github,
aheadCount, aheadCount,
behindBaseCount, behindBaseCount,
isPaseoOwnedWorktree, isPaseoOwnedWorktree,
isOnBaseBranch, isOnBaseBranch,
githubFeaturesEnabled, githubFeaturesEnabled,
githubAutoMergeActionsEnabled,
hasUncommittedChanges, hasUncommittedChanges,
aheadOfOrigin, aheadOfOrigin,
behindOfOrigin, behindOfOrigin,
@@ -600,6 +673,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
mergePrStatuses.squash, mergePrStatuses.squash,
mergePrStatuses.merge, mergePrStatuses.merge,
mergePrStatuses.rebase, mergePrStatuses.rebase,
enablePrAutoMergeStatuses.squash,
enablePrAutoMergeStatuses.merge,
enablePrAutoMergeStatuses.rebase,
disablePrAutoMergeStatus,
mergeStatus, mergeStatus,
mergeFromBaseStatus, mergeFromBaseStatus,
archiveStatus, archiveStatus,
@@ -609,6 +686,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
handlePullAndPush, handlePullAndPush,
handlePrAction, handlePrAction,
handleMergePr, handleMergePr,
handleEnablePrAutoMerge,
handleDisablePrAutoMerge,
handleMergeBranch, handleMergeBranch,
handleMergeFromBase, handleMergeFromBase,
handleArchiveWorktree, handleArchiveWorktree,

View File

@@ -54,6 +54,24 @@ vi.mock("@/runtime/host-runtime", () => ({
const cwd = "/repo"; const cwd = "/repo";
const serverId = "server-1"; 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> = {}): CheckoutPrStatus { function status(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
return { return {
number: 42, number: 42,
@@ -69,6 +87,7 @@ function status(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
reviewDecision: null, reviewDecision: null,
repoOwner: "getpaseo", repoOwner: "getpaseo",
repoName: "paseo", repoName: "paseo",
github: githubStatus,
...overrides, ...overrides,
}; };
} }

View File

@@ -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 () => { test("requests checkout pull via RPC", async () => {
const logger = createMockLogger(); const logger = createMockLogger();
const mock = createMockTransport(); const mock = createMockTransport();

View File

@@ -32,6 +32,7 @@ import type {
CheckoutPrCreateResponse, CheckoutPrCreateResponse,
CheckoutPrMergeResponse, CheckoutPrMergeResponse,
CheckoutPrMergeMethod, CheckoutPrMergeMethod,
CheckoutGithubSetAutoMergeResponse,
CheckoutPrStatusResponse, CheckoutPrStatusResponse,
PullRequestTimelineResponse, PullRequestTimelineResponse,
CheckoutSwitchBranchResponse, CheckoutSwitchBranchResponse,
@@ -274,6 +275,7 @@ type CheckoutPullPayload = CheckoutPullResponse["payload"];
type CheckoutPushPayload = CheckoutPushResponse["payload"]; type CheckoutPushPayload = CheckoutPushResponse["payload"];
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
type CheckoutPrMergePayload = CheckoutPrMergeResponse["payload"]; type CheckoutPrMergePayload = CheckoutPrMergeResponse["payload"];
type CheckoutGithubSetAutoMergePayload = CheckoutGithubSetAutoMergeResponse["payload"];
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"]; type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"]; type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"];
@@ -1425,6 +1427,25 @@ export class DaemonClient {
}); });
} }
private sendNamespacedCorrelatedSessionRequest<
TResponseType extends CorrelatedResponseType,
TResult = CorrelatedResponsePayload<TResponseType>,
>(params: {
requestId?: string;
message: { type: Extract<SessionInboundMessage["type"], `${string}.request`> } & Record<
string,
unknown
>;
timeout: number;
selectPayload?: (payload: CorrelatedResponsePayload<TResponseType>) => TResult | null;
}): Promise<TResult> {
const responseType = params.message.type.replace(/\.request$/, ".response") as TResponseType;
return this.sendCorrelatedSessionRequest({
...params,
responseType,
});
}
private sendSessionMessageStrict(message: SessionInboundMessage): void { private sendSessionMessageStrict(message: SessionInboundMessage): void {
if (!this.transport || this.connectionState.status !== "connected") { if (!this.transport || this.connectionState.status !== "connected") {
throw new Error("Transport not 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<CheckoutGithubSetAutoMergePayload> {
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<CheckoutPrStatusPayload> { async checkoutPrStatus(cwd: string, requestId?: string): Promise<CheckoutPrStatusPayload> {
return this.sendCorrelatedSessionRequest({ return this.sendCorrelatedSessionRequest({
requestId, requestId,

View File

@@ -35,4 +35,63 @@ describe("checkout status projection", () => {
expect(payload).toHaveProperty("mergeable", "MERGEABLE"); expect(payload).toHaveProperty("mergeable", "MERGEABLE");
expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload); 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);
});
}); });

View File

@@ -115,7 +115,7 @@ export function normalizeCheckoutPrStatusPayload(
if (!status) { if (!status) {
return null; return null;
} }
return { const payload: CheckoutPrStatusPayloadStatus = {
number: status.number, number: status.number,
url: status.url, url: status.url,
title: status.title, title: status.title,
@@ -131,4 +131,8 @@ export function normalizeCheckoutPrStatusPayload(
checksStatus: status.checksStatus, checksStatus: status.checksStatus,
reviewDecision: status.reviewDecision, reviewDecision: status.reviewDecision,
}; };
if (status.github) {
payload.github = status.github;
}
return payload;
} }

View File

@@ -46,6 +46,7 @@ import {
createProviderSnapshotManagerStub, createProviderSnapshotManagerStub,
} from "./test-utils/session-stubs.js"; } from "./test-utils/session-stubs.js";
import { isPlatform } from "../test-utils/platform.js"; import { isPlatform } from "../test-utils/platform.js";
import type { GitHubPullRequestStatusFacts } from "../services/github-service.js";
interface SessionHandlerInternals { interface SessionHandlerInternals {
startVoiceTurnController(): Promise<void>; startVoiceTurnController(): Promise<void>;
@@ -64,6 +65,7 @@ interface SessionHandlerInternals {
handleCheckoutCommitRequest(params: unknown): Promise<unknown>; handleCheckoutCommitRequest(params: unknown): Promise<unknown>;
handleCheckoutPrCreateRequest(params: unknown): Promise<unknown>; handleCheckoutPrCreateRequest(params: unknown): Promise<unknown>;
handleCheckoutPrMergeRequest(params: unknown): Promise<unknown>; handleCheckoutPrMergeRequest(params: unknown): Promise<unknown>;
handleCheckoutGithubSetAutoMergeRequest(params: unknown): Promise<unknown>;
handleCheckoutPullRequest(params: unknown): Promise<unknown>; handleCheckoutPullRequest(params: unknown): Promise<unknown>;
handleCheckoutPushRequest(params: unknown): Promise<unknown>; handleCheckoutPushRequest(params: unknown): Promise<unknown>;
handleCheckoutStatusRequest(params: unknown): Promise<unknown>; handleCheckoutStatusRequest(params: unknown): Promise<unknown>;
@@ -1971,6 +1973,23 @@ describe("session checkout pull request merge", () => {
github: { github: {
pullRequest: { pullRequest: {
number: 42, 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", cwd: "/tmp/request-worktree",
prNumber: 42, prNumber: 42,
mergeMethod: "squash", 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, force: true,
reason: "merge-pr", 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 () => { test("surfaces merge errors verbatim", async () => {
const messages: unknown[] = []; const messages: unknown[] = [];
const github = { const github = {
@@ -2016,6 +2185,23 @@ describe("session checkout pull request merge", () => {
github: { github: {
pullRequest: { pullRequest: {
number: 42, 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> = {},
): 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", () => { describe("session checkout pull and push handling", () => {
test("forces workspace git and GitHub refresh after pulling", async () => { test("forces workspace git and GitHub refresh after pulling", async () => {
const messages: unknown[] = []; const messages: unknown[] = [];

View File

@@ -82,7 +82,11 @@ import type { DaemonConfigStore } from "./daemon-config-store.js";
import { applyMutableProviderConfigToOverrides } from "./daemon-config-store.js"; import { applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
import { getErrorMessage, getErrorMessageOr } from "../shared/error-utils.js"; import { getErrorMessage, getErrorMessageOr } from "../shared/error-utils.js";
import { getAgentStatusPriority } from "../shared/agent-state-bucket.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 { buildProviderRegistry } from "./agent/provider-registry.js";
import type { import type {
@@ -211,6 +215,8 @@ import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js"; import { ScheduleService } from "./schedule/service.js";
import { execCommand } from "../utils/spawn.js"; import { execCommand } from "../utils/spawn.js";
import { import {
assertPullRequestAutoMergeDisableReady,
assertPullRequestAutoMergeEnableReady,
createGitHubService, createGitHubService,
type GitHubService, type GitHubService,
type PullRequestTimelineItem, type PullRequestTimelineItem,
@@ -242,6 +248,12 @@ import { toWorktreeWireError } from "./worktree-errors.js";
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__"; const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
type CurrentWorkspacePullRequest = NonNullable<
WorkspaceGitRuntimeSnapshot["github"]["pullRequest"]
> & {
number: number;
};
interface ResolveKnownProjectRootForConfigInput { interface ResolveKnownProjectRootForConfigInput {
repoRoot: string; repoRoot: string;
projectRegistry: Pick<ProjectRegistry, "list">; projectRegistry: Pick<ProjectRegistry, "list">;
@@ -288,6 +300,8 @@ type GitMutationRefreshReason =
| "merge-to-base" | "merge-to-base"
| "merge-from-base" | "merge-from-base"
| "merge-pr" | "merge-pr"
| "enable-pr-auto-merge"
| "disable-pr-auto-merge"
| "create-pr" | "create-pr"
| "switch-branch" | "switch-branch"
| "create-branch" | "create-branch"
@@ -2013,6 +2027,8 @@ export class Session {
return this.handleCheckoutPrCreateRequest(msg); return this.handleCheckoutPrCreateRequest(msg);
case "checkout_pr_merge_request": case "checkout_pr_merge_request":
return this.handleCheckoutPrMergeRequest(msg); return this.handleCheckoutPrMergeRequest(msg);
case "checkout.github.set_auto_merge.request":
return this.handleCheckoutGithubSetAutoMergeRequest(msg);
case "checkout_pr_status_request": case "checkout_pr_status_request":
return this.handleCheckoutPrStatusRequest(msg); return this.handleCheckoutPrStatusRequest(msg);
case "pull_request_timeline_request": case "pull_request_timeline_request":
@@ -5180,16 +5196,17 @@ export class Session {
const { cwd, requestId } = msg; const { cwd, requestId } = msg;
try { try {
const snapshot = await this.workspaceGitService.getSnapshot(cwd); const pullRequest = await this.resolveCurrentPullRequest(cwd, "merge", {
const prNumber = snapshot.github.pullRequest?.number; force: true,
if (typeof prNumber !== "number") { includeGitHub: true,
throw new Error("Unable to determine GitHub pull request number for merge"); reason: "merge-pr-validation",
} });
this.assertCurrentPullRequestHasGithubMergeFacts(pullRequest);
await this.github.mergePullRequest({ await this.github.mergePullRequest({
cwd, cwd,
prNumber, prNumber: pullRequest.number,
mergeMethod: msg.mergeMethod, mergeMethod: msg.mergeMethod,
status: pullRequest,
}); });
await this.notifyGitMutation(cwd, "merge-pr", { invalidateGithub: true }); 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<SessionInboundMessage, { type: "checkout.github.set_auto_merge.request" }>,
): Promise<void> {
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<CurrentWorkspacePullRequest> {
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( private async handleCheckoutPrStatusRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pr_status_request" }>, msg: Extract<SessionInboundMessage, { type: "checkout_pr_status_request" }>,
): Promise<void> { ): Promise<void> {

View File

@@ -451,6 +451,23 @@ describe("workspace git watch targets", () => {
], ],
checksStatus: "success", checksStatus: "success",
reviewDecision: "approved", 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, error: null,
}, },
@@ -483,6 +500,23 @@ describe("workspace git watch targets", () => {
], ],
checksStatus: "success", checksStatus: "success",
reviewDecision: "approved", 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, githubFeaturesEnabled: true,
error: null, error: null,

View File

@@ -1048,6 +1048,8 @@ export class VoiceAssistantWebSocketServer {
features: { features: {
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow. // COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
providersSnapshot: true, providersSnapshot: true,
// COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
checkoutGithubSetAutoMerge: true,
}, },
}; };
} }

View File

@@ -79,11 +79,11 @@ function createCheckoutStatus(
}; };
} }
function createPullRequestStatusResult(): PullRequestStatusResult { function createPullRequestStatusResult(title = "Update feature"): PullRequestStatusResult {
return { return {
status: { status: {
url: "https://github.com/acme/repo/pull/123", url: "https://github.com/acme/repo/pull/123",
title: "Update feature", title,
state: "open", state: "open",
baseRefName: "main", baseRefName: "main",
headRefName: "feature", headRefName: "feature",
@@ -537,6 +537,61 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
service.dispose(); service.dispose();
}); });
test("a forced GitHub-inclusive call during an in-flight forced git refresh queues a GitHub refresh", async () => {
const forcedGitRefresh = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi
.fn<() => Promise<CheckoutStatusGit>>()
.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 () => { test("ref-watch firing during an in-flight forced refresh does not produce an extra shell burst", async () => {
const forcedRefresh = createDeferred<CheckoutStatusGit>(); const forcedRefresh = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi const getCheckoutStatus = vi

View File

@@ -22,6 +22,7 @@ import {
} from "../utils/checkout-git.js"; } from "../utils/checkout-git.js";
import { import {
createGitHubService, createGitHubService,
type GitHubPullRequestStatusFacts,
type GitHubService, type GitHubService,
type PullRequestMergeable, type PullRequestMergeable,
} from "../services/github-service.js"; } from "../services/github-service.js";
@@ -92,6 +93,7 @@ export interface WorkspaceGitRuntimeSnapshot {
}>; }>;
checksStatus?: "none" | "pending" | "success" | "failure"; checksStatus?: "none" | "pending" | "success" | "failure";
reviewDecision?: "approved" | "changes_requested" | "pending" | null; reviewDecision?: "approved" | "changes_requested" | "pending" | null;
github?: GitHubPullRequestStatusFacts;
} | null; } | null;
error: { message: string } | null; error: { message: string } | null;
}; };
@@ -1324,7 +1326,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
request: WorkspaceGitRefreshRequest, request: WorkspaceGitRefreshRequest,
): Promise<WorkspaceGitRuntimeSnapshot> { ): Promise<WorkspaceGitRuntimeSnapshot> {
if (target.refreshState.status === "in-flight") { 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); target.refreshState.queued = this.mergeQueuedRefresh(target.refreshState.queued, request);
} }
return target.refreshState.promise; return target.refreshState.promise;
@@ -1424,10 +1429,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
} }
const force = queued.force || request.force; const force = queued.force || request.force;
const upgradesForce = request.force && !queued.force;
const upgradesGitHub = request.includeGitHub && !queued.includeGitHub;
return { return {
force, force,
includeGitHub: queued.includeGitHub || request.includeGitHub, 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, notify: queued.notify || request.notify,
}; };
} }

View File

@@ -115,6 +115,31 @@ function currentPullRequestJson(overrides: Record<string, unknown> = {}): string
}); });
} }
function currentPullRequestGithubFactsJson(overrides: Record<string, unknown> = {}): 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( function createCurrentPullRequestStatus(
overrides: Partial<GitHubCurrentPullRequestStatus> = {}, overrides: Partial<GitHubCurrentPullRequestStatus> = {},
): GitHubCurrentPullRequestStatus { ): GitHubCurrentPullRequestStatus {
@@ -137,6 +162,29 @@ function createCurrentPullRequestStatus(
}; };
} }
function githubStatusFacts(
overrides: Partial<NonNullable<GitHubCurrentPullRequestStatus["github"]>> = {},
): NonNullable<GitHubCurrentPullRequestStatus["github"]> {
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<typeof createGitHubService>) { function recordCurrentPullRequestStatusReads(service: ReturnType<typeof createGitHubService>) {
const reads: GitHubReadOptions[] = []; const reads: GitHubReadOptions[] = [];
const getCurrentPullRequestStatus = service.getCurrentPullRequestStatus.bind(service); const getCurrentPullRequestStatus = service.getCurrentPullRequestStatus.bind(service);
@@ -147,6 +195,12 @@ function recordCurrentPullRequestStatusReads(service: ReturnType<typeof createGi
return reads; return reads;
} }
function currentPullRequestStatusCalls(calls: RunnerCall[]): RunnerCall[] {
return calls.filter(
(call) => call.args[0] === "pr" && (call.args[1] === "view" || call.args[1] === "list"),
);
}
function noPullRequestError(args: string[] = ["pr", "view"]): GitHubCommandError { function noPullRequestError(args: string[] = ["pr", "view"]): GitHubCommandError {
return new GitHubCommandError({ return new GitHubCommandError({
args, args,
@@ -314,6 +368,9 @@ describe("GitHubService", () => {
cwd: "/tmp/repo", cwd: "/tmp/repo",
prNumber: 42, prNumber: 42,
mergeMethod, mergeMethod,
status: createCurrentPullRequestStatus({
github: githubStatusFacts(),
}),
}), }),
).resolves.toEqual({ success: true }); ).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", () => { it("computes fast cadence for pending and slow cadence for stable PR states", () => {
const pendingStatus = createCurrentPullRequestStatus({ checksStatus: "pending" }); const pendingStatus = createCurrentPullRequestStatus({ checksStatus: "pending" });
const runningCheckStatus = createCurrentPullRequestStatus({ const runningCheckStatus = createCurrentPullRequestStatus({
@@ -408,7 +655,7 @@ describe("GitHubService", () => {
now = EXPECTED_GITHUB_FAST_POLL_MS; now = EXPECTED_GITHUB_FAST_POLL_MS;
await vi.advanceTimersByTimeAsync(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"]); expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]);
subscription?.unsubscribe(); subscription?.unsubscribe();
@@ -441,11 +688,11 @@ describe("GitHubService", () => {
now = EXPECTED_GITHUB_FAST_POLL_MS; now = EXPECTED_GITHUB_FAST_POLL_MS;
await vi.advanceTimersByTimeAsync(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; now = EXPECTED_GITHUB_SLOW_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS - EXPECTED_GITHUB_FAST_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"]); expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]);
subscription?.unsubscribe(); subscription?.unsubscribe();
@@ -458,14 +705,17 @@ describe("GitHubService", () => {
currentPullRequestJson({ currentPullRequestJson({
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }], statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }],
}), }),
currentPullRequestGithubFactsJson(),
{ error: new Error("network down") }, { error: new Error("network down") },
{ error: new Error("network still down") }, { error: new Error("network still down") },
currentPullRequestJson({ currentPullRequestJson({
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }], statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }],
}), }),
currentPullRequestGithubFactsJson(),
currentPullRequestJson({ currentPullRequestJson({
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }], statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }],
}), }),
currentPullRequestGithubFactsJson(),
]); ]);
const service = createGitHubService({ const service = createGitHubService({
ttlMs: 0, ttlMs: 0,
@@ -490,7 +740,7 @@ describe("GitHubService", () => {
now += EXPECTED_GITHUB_SLOW_POLL_MS; now += EXPECTED_GITHUB_SLOW_POLL_MS;
await vi.advanceTimersByTimeAsync(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([ expect(reads.map((read) => read.reason)).toEqual([
undefined, undefined,
"self-heal-github", "self-heal-github",
@@ -527,7 +777,7 @@ describe("GitHubService", () => {
now = EXPECTED_GITHUB_FAST_POLL_MS; now = EXPECTED_GITHUB_FAST_POLL_MS;
await vi.advanceTimersByTimeAsync(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?.(); service.dispose?.();
}); });
@@ -553,7 +803,7 @@ describe("GitHubService", () => {
now = EXPECTED_GITHUB_FAST_POLL_MS; now = EXPECTED_GITHUB_FAST_POLL_MS;
await vi.advanceTimersByTimeAsync(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 () => { it("fetches PR reviews and issue comments with one GraphQL call sorted chronologically", async () => {
@@ -1139,7 +1389,7 @@ describe("GitHubService", () => {
headRef: "feature/pr-pane", 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_FIELDS],
["pr", "view", "--json", CURRENT_PR_STATUS_BASE_FIELDS], ["pr", "view", "--json", CURRENT_PR_STATUS_BASE_FIELDS],
]); ]);
@@ -1171,6 +1421,72 @@ describe("GitHubService", () => {
expect(status?.mergeable).toBe("UNKNOWN"); 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 () => { it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => {
const runner = createScriptedRunner([ const runner = createScriptedRunner([
currentPullRequestJson({ currentPullRequestJson({
@@ -1231,7 +1547,7 @@ describe("GitHubService", () => {
title: "Real fork PR", title: "Real fork PR",
headRefName: "feature/fork", 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], ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
["repo", "view", "--json", "owner,name,parent"], ["repo", "view", "--json", "owner,name,parent"],
[ [
@@ -1314,7 +1630,7 @@ describe("GitHubService", () => {
checks: [], checks: [],
checksStatus: "none", 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], ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
["repo", "view", "--json", "owner,name,parent"], ["repo", "view", "--json", "owner,name,parent"],
[ [
@@ -1452,7 +1768,7 @@ describe("GitHubService", () => {
repoName: "repo", repoName: "repo",
headRefName: "main", 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", "view", "--json", CURRENT_PR_STATUS_FIELDS],
[ [
"pr", "pr",
@@ -1978,13 +2294,15 @@ describe("GitHubService", () => {
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }), service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }),
).resolves.toMatchObject({ number: 42 }); ).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 () => { it("bypasses the warm PR status cache for forced reads", async () => {
const runner = createRunner([ const runner = createRunner([
currentPullRequestJson({ number: 41, title: "First" }), currentPullRequestJson({ number: 41, title: "First" }),
currentPullRequestGithubFactsJson(),
currentPullRequestJson({ number: 42, title: "Forced" }), currentPullRequestJson({ number: 42, title: "Forced" }),
currentPullRequestGithubFactsJson(),
]); ]);
const service = createGitHubService({ const service = createGitHubService({
runner: runner.runner, runner: runner.runner,
@@ -2004,7 +2322,7 @@ describe("GitHubService", () => {
}), }),
).resolves.toMatchObject({ number: 42 }); ).resolves.toMatchObject({ number: 42 });
expect(runner.calls).toHaveLength(2); expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(2);
}); });
it("coalesces concurrent PR status callers", async () => { it("coalesces concurrent PR status callers", async () => {
@@ -2019,8 +2337,13 @@ describe("GitHubService", () => {
const second = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }); const second = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
await Promise.resolve(); await Promise.resolve();
expect(runner.calls).toHaveLength(1); expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
runner.resolveNext(currentPullRequestJson()); 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([ await expect(Promise.all([first, second])).resolves.toEqual([
expect.objectContaining({ number: 42 }), expect.objectContaining({ number: 42 }),

View File

@@ -93,6 +93,52 @@ const HeadRepositoryOwnerSchema = z
const PullRequestMergeableSchema = z.enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"]).catch("UNKNOWN"); 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({ const CurrentPullRequestStatusSchema = z.object({
number: z.number().optional(), number: z.number().optional(),
url: z.string().catch(""), 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"; "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`; 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 = ` const PULL_REQUEST_TIMELINE_QUERY = `
query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) { query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) { 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 PullRequestReviewDecision = "approved" | "changes_requested" | "pending" | null;
export type PullRequestMergeable = "MERGEABLE" | "CONFLICTING" | "UNKNOWN"; 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 { export interface GitHubCurrentPullRequestStatus {
number?: number; number?: number;
repoOwner?: string; repoOwner?: string;
@@ -368,6 +463,7 @@ export interface GitHubCurrentPullRequestStatus {
checks: PullRequestCheck[]; checks: PullRequestCheck[];
checksStatus: PullRequestChecksStatus; checksStatus: PullRequestChecksStatus;
reviewDecision: PullRequestReviewDecision; reviewDecision: PullRequestReviewDecision;
github?: GitHubPullRequestStatusFacts;
} }
export type PullRequestTimelineReviewState = "approved" | "changes_requested" | "commented"; export type PullRequestTimelineReviewState = "approved" | "changes_requested" | "commented";
@@ -412,17 +508,41 @@ export interface GitHubPullRequestCreateResult {
} }
export type GitHubPullRequestMergeMethod = "merge" | "squash" | "rebase"; 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 { export interface MergeGitHubPullRequestOptions {
cwd: string; cwd: string;
prNumber: number; prNumber: number;
mergeMethod: GitHubPullRequestMergeMethod; 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 { export interface GitHubPullRequestMergeResult {
success: true; success: true;
} }
export interface GitHubPullRequestAutoMergeResult {
success: true;
}
export type GitHubReadOptions = export type GitHubReadOptions =
| { | {
force?: false; force?: false;
@@ -512,6 +632,12 @@ export interface GitHubService {
options: CreateGitHubPullRequestOptions, options: CreateGitHubPullRequestOptions,
): Promise<GitHubPullRequestCreateResult>; ): Promise<GitHubPullRequestCreateResult>;
mergePullRequest(options: MergeGitHubPullRequestOptions): Promise<GitHubPullRequestMergeResult>; mergePullRequest(options: MergeGitHubPullRequestOptions): Promise<GitHubPullRequestMergeResult>;
enablePullRequestAutoMerge(
options: EnableGitHubPullRequestAutoMergeOptions,
): Promise<GitHubPullRequestAutoMergeResult>;
disablePullRequestAutoMerge(
options: DisableGitHubPullRequestAutoMergeOptions,
): Promise<GitHubPullRequestAutoMergeResult>;
isAuthenticated(options: { cwd: string } & GitHubReadOptions): Promise<boolean>; isAuthenticated(options: { cwd: string } & GitHubReadOptions): Promise<boolean>;
retainCurrentPullRequestStatusPoll?(options: { retainCurrentPullRequestStatusPoll?(options: {
cwd: string; cwd: string;
@@ -577,6 +703,13 @@ interface CommandFailureLike {
type PullRequestCheckRunNode = z.infer<typeof PullRequestCheckRunNodeSchema>; type PullRequestCheckRunNode = z.infer<typeof PullRequestCheckRunNodeSchema>;
type PullRequestStatusContextNode = z.infer<typeof PullRequestStatusContextNodeSchema>; type PullRequestStatusContextNode = z.infer<typeof PullRequestStatusContextNodeSchema>;
type CurrentPullRequestStatusItem = z.infer<typeof CurrentPullRequestStatusSchema>; type CurrentPullRequestStatusItem = z.infer<typeof CurrentPullRequestStatusSchema>;
type GitHubPullRequestFactsGraphql = z.infer<typeof GitHubPullRequestFactsGraphqlSchema>;
type GitHubPullRequestFactsRepository = NonNullable<
GitHubPullRequestFactsGraphql["data"]["repository"]
>;
type GitHubPullRequestFactsPullRequest = NonNullable<
GitHubPullRequestFactsRepository["pullRequest"]
>;
interface InFlightCacheEntry { interface InFlightCacheEntry {
cwd: string; cwd: string;
@@ -882,12 +1015,13 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
}, },
readOptions: input, readOptions: input,
load: async () => { load: async () => {
return resolveCurrentPullRequestView({ const status = await resolveCurrentPullRequestView({
cwd: input.cwd, cwd: input.cwd,
headRef: input.headRef, headRef: input.headRef,
headRepositoryOwner: input.headRepositoryOwner, headRepositoryOwner: input.headRepositoryOwner,
run, run,
}); });
return addCurrentPullRequestGithubFacts({ cwd: input.cwd, status, run });
}, },
}).then((status) => { }).then((status) => {
updatePollTargetAfterSuccess({ updatePollTargetAfterSuccess({
@@ -1051,6 +1185,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
}, },
async mergePullRequest(input) { async mergePullRequest(input) {
assertDirectPullRequestMergeReady(input);
await run(["pr", "merge", String(input.prNumber), `--${input.mergeMethod}`], { await run(["pr", "merge", String(input.prNumber), `--${input.mergeMethod}`], {
cwd: input.cwd, cwd: input.cwd,
envOverlay: { GH_PROMPT_DISABLED: "1" }, envOverlay: { GH_PROMPT_DISABLED: "1" },
@@ -1058,6 +1193,24 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
return { success: true }; 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) { isAuthenticated(input) {
return cached({ return cached({
cwd: input.cwd, cwd: input.cwd,
@@ -1161,6 +1314,89 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
return api; 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<EnableGitHubPullRequestAutoMergeOptions, "mergeMethod" | "status">,
): 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<DisableGitHubPullRequestAutoMergeOptions, "status">,
): 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( export function computeGithubNextInterval(
status: GitHubCurrentPullRequestStatus | null, status: GitHubCurrentPullRequestStatus | null,
consecutiveErrors: number, consecutiveErrors: number,
@@ -1372,6 +1608,68 @@ async function resolveCurrentPullRequestView(options: {
return match?.status ?? null; return match?.status ?? null;
} }
async function addCurrentPullRequestGithubFacts(options: {
cwd: string;
status: GitHubCurrentPullRequestStatus | null;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<GitHubCurrentPullRequestStatus | null> {
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<string>;
}): Promise<GitHubPullRequestStatusFacts | null> {
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: { async function tryCurrentPullRequestView(options: {
cwd: string; cwd: string;
headRef: string; headRef: string;
@@ -1469,6 +1767,52 @@ function parseCurrentPullRequestCandidateList(
.filter((candidate): candidate is ResolvedPullRequestCandidate => candidate !== null); .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( function toCurrentPullRequestCandidate(
item: CurrentPullRequestStatusItem, item: CurrentPullRequestStatusItem,
fallbackHeadRefName: string, fallbackHeadRefName: string,

View File

@@ -1,6 +1,12 @@
import { describe, expect, test } from "vitest"; 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", () => { describe("checkout PR schemas", () => {
test("parses PR status payloads without mergeability", () => { 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)( test.each(["merge", "squash", "rebase"] as const)(
"accepts %s as a PR merge method", "accepts %s as a PR merge method",
(mergeMethod) => { (mergeMethod) => {
@@ -44,4 +111,79 @@ describe("checkout PR schemas", () => {
}), }),
).toThrow(); ).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,
});
});
}); });

View File

@@ -1314,6 +1314,14 @@ export const CheckoutPrMergeRequestSchema = z.object({
requestId: z.string(), 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({ export const CheckoutPrStatusRequestSchema = z.object({
type: z.literal("checkout_pr_status_request"), type: z.literal("checkout_pr_status_request"),
cwd: z.string(), cwd: z.string(),
@@ -1763,6 +1771,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushRequestSchema, CheckoutPushRequestSchema,
CheckoutPrCreateRequestSchema, CheckoutPrCreateRequestSchema,
CheckoutPrMergeRequestSchema, CheckoutPrMergeRequestSchema,
CheckoutGithubSetAutoMergeRequestSchema,
CheckoutPrStatusRequestSchema, CheckoutPrStatusRequestSchema,
PullRequestTimelineRequestSchema, PullRequestTimelineRequestSchema,
CheckoutSwitchBranchRequestSchema, CheckoutSwitchBranchRequestSchema,
@@ -1985,6 +1994,7 @@ export const ServerInfoStatusPayloadSchema = z
features: z features: z
.object({ .object({
providersSnapshot: z.boolean().optional(), providersSnapshot: z.boolean().optional(),
checkoutGithubSetAutoMerge: z.boolean().optional(),
}) })
.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({ export const CheckoutPrStatusSchema = z.object({
number: z.number().optional(), number: z.number().optional(),
url: z.string(), url: z.string(),
@@ -2735,6 +2786,7 @@ export const CheckoutPrStatusSchema = z.object({
reviewDecision: z.string().nullable().optional(), reviewDecision: z.string().nullable().optional(),
repoOwner: z.string().optional(), repoOwner: z.string().optional(),
repoName: z.string().optional(), repoName: z.string().optional(),
github: CheckoutPrGithubStatusSchema,
}); });
const CheckoutPrStatusPayloadSchema = z.object({ 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({ export const CheckoutPrStatusResponseSchema = z.object({
type: z.literal("checkout_pr_status_response"), type: z.literal("checkout_pr_status_response"),
payload: CheckoutPrStatusPayloadSchema, payload: CheckoutPrStatusPayloadSchema,
@@ -3408,6 +3471,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushResponseSchema, CheckoutPushResponseSchema,
CheckoutPrCreateResponseSchema, CheckoutPrCreateResponseSchema,
CheckoutPrMergeResponseSchema, CheckoutPrMergeResponseSchema,
CheckoutGithubSetAutoMergeResponseSchema,
CheckoutPrStatusResponseSchema, CheckoutPrStatusResponseSchema,
PullRequestTimelineResponseSchema, PullRequestTimelineResponseSchema,
CheckoutSwitchBranchResponseSchema, CheckoutSwitchBranchResponseSchema,
@@ -3664,6 +3728,12 @@ export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSc
export type CheckoutPrMergeRequest = z.infer<typeof CheckoutPrMergeRequestSchema>; export type CheckoutPrMergeRequest = z.infer<typeof CheckoutPrMergeRequestSchema>;
export type CheckoutPrMergeResponse = z.infer<typeof CheckoutPrMergeResponseSchema>; export type CheckoutPrMergeResponse = z.infer<typeof CheckoutPrMergeResponseSchema>;
export type CheckoutPrMergeMethod = z.infer<typeof CheckoutPrMergeRequestSchema>["mergeMethod"]; export type CheckoutPrMergeMethod = z.infer<typeof CheckoutPrMergeRequestSchema>["mergeMethod"];
export type CheckoutGithubSetAutoMergeRequest = z.infer<
typeof CheckoutGithubSetAutoMergeRequestSchema
>;
export type CheckoutGithubSetAutoMergeResponse = z.infer<
typeof CheckoutGithubSetAutoMergeResponseSchema
>;
export type PullRequestMergeable = z.infer<typeof CheckoutPrStatusSchema>["mergeable"]; export type PullRequestMergeable = z.infer<typeof CheckoutPrStatusSchema>["mergeable"];
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>; export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>; export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;

View File

@@ -1708,6 +1708,30 @@ const x = 1;
expect(callCount).toBe(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 () => { it("expires cached PR status after the TTL", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { 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 () => { it("clears stale PR status after a successful no-PR refresh", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {

View File

@@ -11,6 +11,8 @@ import {
GitHubCommandError, GitHubCommandError,
createGitHubService, createGitHubService,
resolveGitHubRepo, resolveGitHubRepo,
type GitHubCurrentPullRequestStatus,
type GitHubPullRequestStatusFacts,
type GitHubService, type GitHubService,
type PullRequestMergeable, type PullRequestMergeable,
} from "../services/github-service.js"; } from "../services/github-service.js";
@@ -2318,6 +2320,7 @@ export interface PullRequestStatus {
checks?: PullRequestCheck[]; checks?: PullRequestCheck[];
checksStatus?: ChecksStatus; checksStatus?: ChecksStatus;
reviewDecision?: ReviewDecision; reviewDecision?: ReviewDecision;
github?: GitHubPullRequestStatusFacts;
} }
export interface PullRequestStatusResult { export interface PullRequestStatusResult {
@@ -2402,7 +2405,7 @@ export async function getPullRequestStatus(
return status; return status;
}) })
.catch((error) => { .catch((error) => {
if (error instanceof GitHubCommandError) { if (!options?.force && error instanceof GitHubCommandError) {
const stale = lastSuccessfulPullRequestStatus.get(cacheKey); const stale = lastSuccessfulPullRequestStatus.get(cacheKey);
if (stale) { if (stale) {
return stale; return stale;
@@ -2433,11 +2436,25 @@ async function getPullRequestStatusUncached(
} }
try { try {
const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head); const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head);
const status = await github.getCurrentPullRequestStatus({ let status: GitHubCurrentPullRequestStatus | null;
cwd, if (options?.force) {
...lookupTarget, const reason = options.reason;
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 { return {
status, status,
githubFeaturesEnabled: true, githubFeaturesEnabled: true,