Namespace GitHub auto-merge RPC

This commit is contained in:
Mohamed Boudra
2026-05-13 23:29:31 +07:00
parent 6d4a0d0ea9
commit 75a6f82779
13 changed files with 318 additions and 186 deletions
+2
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.
+2
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
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.
+23 -8
View File
@@ -172,21 +172,28 @@ describe("checkout-git-actions-store", () => {
it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => { it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => {
const client = { const client = {
checkoutPrAutoMergeEnable: vi.fn(async () => ({ success: true, error: null })), checkoutGithubSetAutoMerge: vi.fn(async () => ({
enabled: true,
success: true,
error: null,
})),
}; };
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, { useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId, serverId,
hostname: null, hostname: null,
version: null, version: null,
features: { githubAutoMergeActions: true }, features: { checkoutGithubSetAutoMerge: true },
}); });
await useCheckoutGitActionsStore await useCheckoutGitActionsStore
.getState() .getState()
.enablePrAutoMerge({ serverId, cwd, method: "squash" }); .enablePrAutoMerge({ serverId, cwd, method: "squash" });
expect(client.checkoutPrAutoMergeEnable).toHaveBeenCalledWith(cwd, { method: "squash" }); expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, {
enabled: true,
method: "squash",
});
expect( expect(
useCheckoutGitActionsStore useCheckoutGitActionsStore
.getState() .getState()
@@ -196,19 +203,23 @@ describe("checkout-git-actions-store", () => {
it("disables PR auto-merge when the daemon advertises auto-merge actions", async () => { it("disables PR auto-merge when the daemon advertises auto-merge actions", async () => {
const client = { const client = {
checkoutPrAutoMergeDisable: vi.fn(async () => ({ success: true, error: null })), checkoutGithubSetAutoMerge: vi.fn(async () => ({
enabled: false,
success: true,
error: null,
})),
}; };
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, { useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId, serverId,
hostname: null, hostname: null,
version: null, version: null,
features: { githubAutoMergeActions: true }, features: { checkoutGithubSetAutoMerge: true },
}); });
await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd }); await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd });
expect(client.checkoutPrAutoMergeDisable).toHaveBeenCalledWith(cwd); expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false });
expect( expect(
useCheckoutGitActionsStore useCheckoutGitActionsStore
.getState() .getState()
@@ -218,7 +229,11 @@ describe("checkout-git-actions-store", () => {
it("does not call PR auto-merge RPCs when the daemon lacks the feature flag", async () => { it("does not call PR auto-merge RPCs when the daemon lacks the feature flag", async () => {
const client = { const client = {
checkoutPrAutoMergeEnable: vi.fn(async () => ({ success: true, error: null })), checkoutGithubSetAutoMerge: vi.fn(async () => ({
enabled: true,
success: true,
error: null,
})),
}; };
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, { useSessionStore.getState().updateSessionServerInfo(serverId, {
@@ -232,7 +247,7 @@ describe("checkout-git-actions-store", () => {
useCheckoutGitActionsStore.getState().enablePrAutoMerge({ serverId, cwd, method: "merge" }), useCheckoutGitActionsStore.getState().enablePrAutoMerge({ serverId, cwd, method: "merge" }),
).rejects.toThrow("Update the host to use GitHub auto-merge actions."); ).rejects.toThrow("Update the host to use GitHub auto-merge actions.");
expect(client.checkoutPrAutoMergeEnable).not.toHaveBeenCalled(); expect(client.checkoutGithubSetAutoMerge).not.toHaveBeenCalled();
expect( expect(
useCheckoutGitActionsStore useCheckoutGitActionsStore
.getState() .getState()
+3 -3
View File
@@ -58,7 +58,7 @@ function resolveClient(serverId: string) {
function assertGitHubAutoMergeActionsSupported(serverId: string) { function assertGitHubAutoMergeActionsSupported(serverId: string) {
const session = useSessionStore.getState().sessions[serverId]; const session = useSessionStore.getState().sessions[serverId];
if (session?.serverInfo?.features?.githubAutoMergeActions !== true) { if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge !== true) {
throw new Error("Update the host to use GitHub auto-merge actions."); throw new Error("Update the host to use GitHub auto-merge actions.");
} }
} }
@@ -417,7 +417,7 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
actionId: `enable-pr-auto-merge-${method}`, actionId: `enable-pr-auto-merge-${method}`,
run: async () => { run: async () => {
const client = resolveClient(serverId); const client = resolveClient(serverId);
const payload = await client.checkoutPrAutoMergeEnable(cwd, { method }); const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method });
if (payload.error) { if (payload.error) {
throw new Error(payload.error.message); throw new Error(payload.error.message);
} }
@@ -433,7 +433,7 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
actionId: "disable-pr-auto-merge", actionId: "disable-pr-auto-merge",
run: async () => { run: async () => {
const client = resolveClient(serverId); const client = resolveClient(serverId);
const payload = await client.checkoutPrAutoMergeDisable(cwd); const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: false });
if (payload.error) { if (payload.error) {
throw new Error(payload.error.message); throw new Error(payload.error.message);
} }
+1 -1
View File
@@ -276,7 +276,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
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( const githubAutoMergeActionsEnabled = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.githubAutoMergeActions === true, (s) => s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true,
); );
const toastActionError = useCallback( const toastActionError = useCallback(
@@ -1586,7 +1586,7 @@ test("requests checkout merge from base via RPC", async () => {
}); });
}); });
test("requests PR auto-merge enable via RPC", async () => { test("requests GitHub auto-merge enable via namespaced RPC", async () => {
const logger = createMockLogger(); const logger = createMockLogger();
const mock = createMockTransport(); const mock = createMockTransport();
@@ -1603,16 +1603,17 @@ test("requests PR auto-merge enable via RPC", async () => {
mock.triggerOpen(); mock.triggerOpen();
await connectPromise; await connectPromise;
const promise = client.checkoutPrAutoMergeEnable( const promise = client.checkoutGithubSetAutoMerge(
"/tmp/project", "/tmp/project",
{ method: "squash" }, { enabled: true, method: "squash" },
"req-enable-auto-merge", "req-enable-auto-merge",
); );
expect(mock.sent).toHaveLength(1); expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]); const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("checkout_pr_auto_merge_enable_request"); expect(request.type).toBe("checkout.github.set_auto_merge.request");
expect(request.cwd).toBe("/tmp/project"); expect(request.cwd).toBe("/tmp/project");
expect(request.enabled).toBe(true);
expect(request.mergeMethod).toBe("squash"); expect(request.mergeMethod).toBe("squash");
expect(request.requestId).toBe("req-enable-auto-merge"); expect(request.requestId).toBe("req-enable-auto-merge");
@@ -1620,9 +1621,10 @@ test("requests PR auto-merge enable via RPC", async () => {
JSON.stringify({ JSON.stringify({
type: "session", type: "session",
message: { message: {
type: "checkout_pr_auto_merge_enable_response", type: "checkout.github.set_auto_merge.response",
payload: { payload: {
cwd: "/tmp/project", cwd: "/tmp/project",
enabled: true,
requestId: "req-enable-auto-merge", requestId: "req-enable-auto-merge",
success: true, success: true,
error: null, error: null,
@@ -1633,13 +1635,14 @@ test("requests PR auto-merge enable via RPC", async () => {
await expect(promise).resolves.toEqual({ await expect(promise).resolves.toEqual({
cwd: "/tmp/project", cwd: "/tmp/project",
enabled: true,
requestId: "req-enable-auto-merge", requestId: "req-enable-auto-merge",
success: true, success: true,
error: null, error: null,
}); });
}); });
test("requests PR auto-merge disable via RPC", async () => { test("requests GitHub auto-merge disable via namespaced RPC", async () => {
const logger = createMockLogger(); const logger = createMockLogger();
const mock = createMockTransport(); const mock = createMockTransport();
@@ -1656,21 +1659,28 @@ test("requests PR auto-merge disable via RPC", async () => {
mock.triggerOpen(); mock.triggerOpen();
await connectPromise; await connectPromise;
const promise = client.checkoutPrAutoMergeDisable("/tmp/project", "req-disable-auto-merge"); const promise = client.checkoutGithubSetAutoMerge(
"/tmp/project",
{ enabled: false },
"req-disable-auto-merge",
);
expect(mock.sent).toHaveLength(1); expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]); const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("checkout_pr_auto_merge_disable_request"); expect(request.type).toBe("checkout.github.set_auto_merge.request");
expect(request.cwd).toBe("/tmp/project"); expect(request.cwd).toBe("/tmp/project");
expect(request.enabled).toBe(false);
expect(request.mergeMethod).toBeUndefined();
expect(request.requestId).toBe("req-disable-auto-merge"); expect(request.requestId).toBe("req-disable-auto-merge");
mock.triggerMessage( mock.triggerMessage(
JSON.stringify({ JSON.stringify({
type: "session", type: "session",
message: { message: {
type: "checkout_pr_auto_merge_disable_response", type: "checkout.github.set_auto_merge.response",
payload: { payload: {
cwd: "/tmp/project", cwd: "/tmp/project",
enabled: false,
requestId: "req-disable-auto-merge", requestId: "req-disable-auto-merge",
success: true, success: true,
error: null, error: null,
@@ -1681,6 +1691,7 @@ test("requests PR auto-merge disable via RPC", async () => {
await expect(promise).resolves.toEqual({ await expect(promise).resolves.toEqual({
cwd: "/tmp/project", cwd: "/tmp/project",
enabled: false,
requestId: "req-disable-auto-merge", requestId: "req-disable-auto-merge",
success: true, success: true,
error: null, error: null,
+28 -26
View File
@@ -32,8 +32,7 @@ import type {
CheckoutPrCreateResponse, CheckoutPrCreateResponse,
CheckoutPrMergeResponse, CheckoutPrMergeResponse,
CheckoutPrMergeMethod, CheckoutPrMergeMethod,
CheckoutPrAutoMergeEnableResponse, CheckoutGithubSetAutoMergeResponse,
CheckoutPrAutoMergeDisableResponse,
CheckoutPrStatusResponse, CheckoutPrStatusResponse,
PullRequestTimelineResponse, PullRequestTimelineResponse,
CheckoutSwitchBranchResponse, CheckoutSwitchBranchResponse,
@@ -276,8 +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 CheckoutPrAutoMergeEnablePayload = CheckoutPrAutoMergeEnableResponse["payload"]; type CheckoutGithubSetAutoMergePayload = CheckoutGithubSetAutoMergeResponse["payload"];
type CheckoutPrAutoMergeDisablePayload = CheckoutPrAutoMergeDisableResponse["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"];
@@ -1429,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");
@@ -2821,34 +2838,19 @@ export class DaemonClient {
}); });
} }
async checkoutPrAutoMergeEnable( async checkoutGithubSetAutoMerge(
cwd: string, cwd: string,
input: { method: CheckoutPrMergeMethod }, input: { enabled: true; method: CheckoutPrMergeMethod } | { enabled: false },
requestId?: string, requestId?: string,
): Promise<CheckoutPrAutoMergeEnablePayload> { ): Promise<CheckoutGithubSetAutoMergePayload> {
return this.sendCorrelatedSessionRequest({ return this.sendNamespacedCorrelatedSessionRequest<"checkout.github.set_auto_merge.response">({
requestId, requestId,
message: { message: {
type: "checkout_pr_auto_merge_enable_request", type: "checkout.github.set_auto_merge.request",
cwd, cwd,
mergeMethod: input.method, enabled: input.enabled,
...(input.enabled ? { mergeMethod: input.method } : {}),
}, },
responseType: "checkout_pr_auto_merge_enable_response",
timeout: 60000,
});
}
async checkoutPrAutoMergeDisable(
cwd: string,
requestId?: string,
): Promise<CheckoutPrAutoMergeDisablePayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "checkout_pr_auto_merge_disable_request",
cwd,
},
responseType: "checkout_pr_auto_merge_disable_response",
timeout: 60000, timeout: 60000,
}); });
} }
+77 -17
View File
@@ -65,8 +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>;
handleCheckoutPrAutoMergeEnableRequest(params: unknown): Promise<unknown>; handleCheckoutGithubSetAutoMergeRequest(params: unknown): Promise<unknown>;
handleCheckoutPrAutoMergeDisableRequest(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>;
@@ -2272,9 +2271,10 @@ describe("session checkout pull request auto-merge", () => {
}; };
const session = createSessionForTest({ github, workspaceGitService, messages }); const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrAutoMergeEnableRequest({ await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
type: "checkout_pr_auto_merge_enable_request", type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/request-worktree", cwd: "/tmp/request-worktree",
enabled: true,
mergeMethod: "squash", mergeMethod: "squash",
requestId: "request-pr-auto-merge-enable", requestId: "request-pr-auto-merge-enable",
}); });
@@ -2300,9 +2300,10 @@ describe("session checkout pull request auto-merge", () => {
}); });
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
expect(messages).toContainEqual({ expect(messages).toContainEqual({
type: "checkout_pr_auto_merge_enable_response", type: "checkout.github.set_auto_merge.response",
payload: { payload: {
cwd: "/tmp/request-worktree", cwd: "/tmp/request-worktree",
enabled: true,
success: true, success: true,
error: null, error: null,
requestId: "request-pr-auto-merge-enable", requestId: "request-pr-auto-merge-enable",
@@ -2336,9 +2337,10 @@ describe("session checkout pull request auto-merge", () => {
}; };
const session = createSessionForTest({ github, workspaceGitService, messages }); const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrAutoMergeDisableRequest({ await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
type: "checkout_pr_auto_merge_disable_request", type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/request-worktree", cwd: "/tmp/request-worktree",
enabled: false,
requestId: "request-pr-auto-merge-disable", requestId: "request-pr-auto-merge-disable",
}); });
@@ -2369,9 +2371,10 @@ describe("session checkout pull request auto-merge", () => {
}); });
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
expect(messages).toContainEqual({ expect(messages).toContainEqual({
type: "checkout_pr_auto_merge_disable_response", type: "checkout.github.set_auto_merge.response",
payload: { payload: {
cwd: "/tmp/request-worktree", cwd: "/tmp/request-worktree",
enabled: false,
success: true, success: true,
error: null, error: null,
requestId: "request-pr-auto-merge-disable", requestId: "request-pr-auto-merge-disable",
@@ -2397,17 +2400,19 @@ describe("session checkout pull request auto-merge", () => {
}; };
const session = createSessionForTest({ github, workspaceGitService, messages }); const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrAutoMergeEnableRequest({ await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
type: "checkout_pr_auto_merge_enable_request", type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/request-worktree", cwd: "/tmp/request-worktree",
enabled: true,
mergeMethod: "merge", mergeMethod: "merge",
requestId: "request-pr-auto-merge-failure", requestId: "request-pr-auto-merge-failure",
}); });
expect(messages).toContainEqual({ expect(messages).toContainEqual({
type: "checkout_pr_auto_merge_enable_response", type: "checkout.github.set_auto_merge.response",
payload: { payload: {
cwd: "/tmp/request-worktree", cwd: "/tmp/request-worktree",
enabled: true,
success: false, success: false,
error: { error: {
code: "UNKNOWN", code: "UNKNOWN",
@@ -2444,9 +2449,10 @@ describe("session checkout pull request auto-merge", () => {
}; };
const session = createSessionForTest({ github, workspaceGitService, messages }); const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrAutoMergeEnableRequest({ await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
type: "checkout_pr_auto_merge_enable_request", type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/request-worktree", cwd: "/tmp/request-worktree",
enabled: true,
mergeMethod: "squash", mergeMethod: "squash",
requestId: "request-pr-auto-merge-method-disabled", requestId: "request-pr-auto-merge-method-disabled",
}); });
@@ -2459,9 +2465,10 @@ describe("session checkout pull request auto-merge", () => {
reason: "auto-merge-validation", reason: "auto-merge-validation",
}); });
expect(messages).toContainEqual({ expect(messages).toContainEqual({
type: "checkout_pr_auto_merge_enable_response", type: "checkout.github.set_auto_merge.response",
payload: { payload: {
cwd: "/tmp/request-worktree", cwd: "/tmp/request-worktree",
enabled: true,
success: false, success: false,
error: { error: {
code: "UNKNOWN", code: "UNKNOWN",
@@ -2498,9 +2505,10 @@ describe("session checkout pull request auto-merge", () => {
}; };
const session = createSessionForTest({ github, workspaceGitService, messages }); const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrAutoMergeDisableRequest({ await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
type: "checkout_pr_auto_merge_disable_request", type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/request-worktree", cwd: "/tmp/request-worktree",
enabled: false,
requestId: "request-pr-auto-merge-disable-forbidden", requestId: "request-pr-auto-merge-disable-forbidden",
}); });
@@ -2512,9 +2520,10 @@ describe("session checkout pull request auto-merge", () => {
reason: "auto-merge-validation", reason: "auto-merge-validation",
}); });
expect(messages).toContainEqual({ expect(messages).toContainEqual({
type: "checkout_pr_auto_merge_disable_response", type: "checkout.github.set_auto_merge.response",
payload: { payload: {
cwd: "/tmp/request-worktree", cwd: "/tmp/request-worktree",
enabled: false,
success: false, success: false,
error: { error: {
code: "UNKNOWN", code: "UNKNOWN",
@@ -2524,6 +2533,57 @@ describe("session checkout pull request auto-merge", () => {
}, },
}); });
}); });
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", () => {
+40 -59
View File
@@ -2027,10 +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_pr_auto_merge_enable_request": case "checkout.github.set_auto_merge.request":
return this.handleCheckoutPrAutoMergeEnableRequest(msg); return this.handleCheckoutGithubSetAutoMergeRequest(msg);
case "checkout_pr_auto_merge_disable_request":
return this.handleCheckoutPrAutoMergeDisableRequest(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":
@@ -5242,8 +5240,8 @@ export class Session {
} }
} }
private async handleCheckoutPrAutoMergeEnableRequest( private async handleCheckoutGithubSetAutoMergeRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pr_auto_merge_enable_request" }>, msg: Extract<SessionInboundMessage, { type: "checkout.github.set_auto_merge.request" }>,
): Promise<void> { ): Promise<void> {
const { cwd, requestId } = msg; const { cwd, requestId } = msg;
@@ -5253,22 +5251,45 @@ export class Session {
includeGitHub: true, includeGitHub: true,
reason: "auto-merge-validation", reason: "auto-merge-validation",
}); });
assertPullRequestAutoMergeEnableReady({ if (msg.enabled) {
mergeMethod: msg.mergeMethod, const mergeMethod = msg.mergeMethod;
status: pullRequest, if (!mergeMethod) {
}); throw new Error("mergeMethod is required when enabling auto-merge");
await this.github.enablePullRequestAutoMerge({ }
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, cwd,
prNumber: pullRequest.number, msg.enabled ? "enable-pr-auto-merge" : "disable-pr-auto-merge",
mergeMethod: msg.mergeMethod, {
status: pullRequest, invalidateGithub: true,
}); },
await this.notifyGitMutation(cwd, "enable-pr-auto-merge", { invalidateGithub: true }); );
this.emit({ this.emit({
type: "checkout_pr_auto_merge_enable_response", type: "checkout.github.set_auto_merge.response",
payload: { payload: {
cwd, cwd,
enabled: msg.enabled,
success: true, success: true,
error: null, error: null,
requestId, requestId,
@@ -5276,50 +5297,10 @@ export class Session {
}); });
} catch (error) { } catch (error) {
this.emit({ this.emit({
type: "checkout_pr_auto_merge_enable_response", type: "checkout.github.set_auto_merge.response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async handleCheckoutPrAutoMergeDisableRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pr_auto_merge_disable_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
const pullRequest = await this.resolveCurrentPullRequest(cwd, "auto-merge", {
force: true,
includeGitHub: true,
reason: "auto-merge-validation",
});
assertPullRequestAutoMergeDisableReady({ status: pullRequest });
await this.github.disablePullRequestAutoMerge({
cwd,
prNumber: pullRequest.number,
status: pullRequest,
});
await this.notifyGitMutation(cwd, "disable-pr-auto-merge", { invalidateGithub: true });
this.emit({
type: "checkout_pr_auto_merge_disable_response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout_pr_auto_merge_disable_response",
payload: { payload: {
cwd, cwd,
enabled: msg.enabled,
success: false, success: false,
error: toCheckoutError(error), error: toCheckoutError(error),
requestId, requestId,
@@ -1048,8 +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(githubAutoMergeActions): added in v0.1.75, remove gate after 2026-11-13. // COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
githubAutoMergeActions: true, checkoutGithubSetAutoMerge: true,
}, },
}; };
} }
@@ -1,10 +1,8 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { import {
CheckoutPrAutoMergeDisableRequestSchema, CheckoutGithubSetAutoMergeRequestSchema,
CheckoutPrAutoMergeDisableResponseSchema, CheckoutGithubSetAutoMergeResponseSchema,
CheckoutPrAutoMergeEnableRequestSchema,
CheckoutPrAutoMergeEnableResponseSchema,
CheckoutPrMergeRequestSchema, CheckoutPrMergeRequestSchema,
CheckoutPrStatusSchema, CheckoutPrStatusSchema,
ServerInfoStatusPayloadSchema, ServerInfoStatusPayloadSchema,
@@ -115,60 +113,59 @@ describe("checkout PR schemas", () => {
}); });
test.each(["merge", "squash", "rebase"] as const)( test.each(["merge", "squash", "rebase"] as const)(
"accepts %s as a PR auto-merge enable method", "accepts %s as a GitHub set-auto-merge enable method",
(mergeMethod) => { (mergeMethod) => {
expect( expect(
CheckoutPrAutoMergeEnableRequestSchema.parse({ CheckoutGithubSetAutoMergeRequestSchema.parse({
type: "checkout_pr_auto_merge_enable_request", type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/repo", cwd: "/tmp/repo",
enabled: true,
mergeMethod, mergeMethod,
requestId: "request-enable-auto-merge", requestId: "request-enable-auto-merge",
}), }),
).toMatchObject({ mergeMethod }); ).toMatchObject({ enabled: true, mergeMethod });
}, },
); );
test("rejects unknown PR auto-merge enable methods", () => { test("rejects unknown GitHub set-auto-merge enable methods", () => {
expect(() => expect(() =>
CheckoutPrAutoMergeEnableRequestSchema.parse({ CheckoutGithubSetAutoMergeRequestSchema.parse({
type: "checkout_pr_auto_merge_enable_request", type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/repo", cwd: "/tmp/repo",
enabled: true,
mergeMethod: "auto", mergeMethod: "auto",
requestId: "request-enable-auto-merge", requestId: "request-enable-auto-merge",
}), }),
).toThrow(); ).toThrow();
}); });
test("accepts PR auto-merge disable requests", () => { test("accepts GitHub set-auto-merge disable requests", () => {
expect( expect(
CheckoutPrAutoMergeDisableRequestSchema.parse({ CheckoutGithubSetAutoMergeRequestSchema.parse({
type: "checkout_pr_auto_merge_disable_request", type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/repo", cwd: "/tmp/repo",
enabled: false,
requestId: "request-disable-auto-merge", requestId: "request-disable-auto-merge",
}), }),
).toMatchObject({ ).toMatchObject({
cwd: "/tmp/repo", cwd: "/tmp/repo",
enabled: false,
requestId: "request-disable-auto-merge", requestId: "request-disable-auto-merge",
}); });
}); });
test("accepts PR auto-merge mutation responses", () => { test("accepts GitHub set-auto-merge responses", () => {
const payload = { const payload = {
cwd: "/tmp/repo", cwd: "/tmp/repo",
enabled: true,
success: true, success: true,
error: null, error: null,
requestId: "request-auto-merge", requestId: "request-auto-merge",
}; };
expect( expect(
CheckoutPrAutoMergeEnableResponseSchema.parse({ CheckoutGithubSetAutoMergeResponseSchema.parse({
type: "checkout_pr_auto_merge_enable_response", type: "checkout.github.set_auto_merge.response",
payload,
}).payload,
).toEqual(payload);
expect(
CheckoutPrAutoMergeDisableResponseSchema.parse({
type: "checkout_pr_auto_merge_disable_response",
payload, payload,
}).payload, }).payload,
).toEqual(payload); ).toEqual(payload);
@@ -181,12 +178,12 @@ describe("checkout PR schemas", () => {
serverId: "srv_test", serverId: "srv_test",
features: { features: {
providersSnapshot: true, providersSnapshot: true,
githubAutoMergeActions: true, checkoutGithubSetAutoMerge: true,
}, },
}).features, }).features,
).toEqual({ ).toEqual({
providersSnapshot: true, providersSnapshot: true,
githubAutoMergeActions: true, checkoutGithubSetAutoMerge: true,
}); });
}); });
}); });
+14 -36
View File
@@ -1314,16 +1314,11 @@ export const CheckoutPrMergeRequestSchema = z.object({
requestId: z.string(), requestId: z.string(),
}); });
export const CheckoutPrAutoMergeEnableRequestSchema = z.object({ export const CheckoutGithubSetAutoMergeRequestSchema = z.object({
type: z.literal("checkout_pr_auto_merge_enable_request"), type: z.literal("checkout.github.set_auto_merge.request"),
cwd: z.string(),
mergeMethod: z.enum(["merge", "squash", "rebase"]),
requestId: z.string(),
});
export const CheckoutPrAutoMergeDisableRequestSchema = z.object({
type: z.literal("checkout_pr_auto_merge_disable_request"),
cwd: z.string(), cwd: z.string(),
enabled: z.boolean(),
mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(),
requestId: z.string(), requestId: z.string(),
}); });
@@ -1776,8 +1771,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushRequestSchema, CheckoutPushRequestSchema,
CheckoutPrCreateRequestSchema, CheckoutPrCreateRequestSchema,
CheckoutPrMergeRequestSchema, CheckoutPrMergeRequestSchema,
CheckoutPrAutoMergeEnableRequestSchema, CheckoutGithubSetAutoMergeRequestSchema,
CheckoutPrAutoMergeDisableRequestSchema,
CheckoutPrStatusRequestSchema, CheckoutPrStatusRequestSchema,
PullRequestTimelineRequestSchema, PullRequestTimelineRequestSchema,
CheckoutSwitchBranchRequestSchema, CheckoutSwitchBranchRequestSchema,
@@ -2000,7 +1994,7 @@ export const ServerInfoStatusPayloadSchema = z
features: z features: z
.object({ .object({
providersSnapshot: z.boolean().optional(), providersSnapshot: z.boolean().optional(),
githubAutoMergeActions: z.boolean().optional(), checkoutGithubSetAutoMerge: z.boolean().optional(),
}) })
.optional(), .optional(),
}) })
@@ -2908,20 +2902,11 @@ export const CheckoutPrMergeResponseSchema = z.object({
}), }),
}); });
export const CheckoutPrAutoMergeEnableResponseSchema = z.object({ export const CheckoutGithubSetAutoMergeResponseSchema = z.object({
type: z.literal("checkout_pr_auto_merge_enable_response"), type: z.literal("checkout.github.set_auto_merge.response"),
payload: z.object({
cwd: z.string(),
success: z.boolean(),
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
}),
});
export const CheckoutPrAutoMergeDisableResponseSchema = z.object({
type: z.literal("checkout_pr_auto_merge_disable_response"),
payload: z.object({ payload: z.object({
cwd: z.string(), cwd: z.string(),
enabled: z.boolean(),
success: z.boolean(), success: z.boolean(),
error: CheckoutErrorSchema.nullable(), error: CheckoutErrorSchema.nullable(),
requestId: z.string(), requestId: z.string(),
@@ -3486,8 +3471,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushResponseSchema, CheckoutPushResponseSchema,
CheckoutPrCreateResponseSchema, CheckoutPrCreateResponseSchema,
CheckoutPrMergeResponseSchema, CheckoutPrMergeResponseSchema,
CheckoutPrAutoMergeEnableResponseSchema, CheckoutGithubSetAutoMergeResponseSchema,
CheckoutPrAutoMergeDisableResponseSchema,
CheckoutPrStatusResponseSchema, CheckoutPrStatusResponseSchema,
PullRequestTimelineResponseSchema, PullRequestTimelineResponseSchema,
CheckoutSwitchBranchResponseSchema, CheckoutSwitchBranchResponseSchema,
@@ -3744,17 +3728,11 @@ 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 CheckoutPrAutoMergeEnableRequest = z.infer< export type CheckoutGithubSetAutoMergeRequest = z.infer<
typeof CheckoutPrAutoMergeEnableRequestSchema typeof CheckoutGithubSetAutoMergeRequestSchema
>; >;
export type CheckoutPrAutoMergeEnableResponse = z.infer< export type CheckoutGithubSetAutoMergeResponse = z.infer<
typeof CheckoutPrAutoMergeEnableResponseSchema typeof CheckoutGithubSetAutoMergeResponseSchema
>;
export type CheckoutPrAutoMergeDisableRequest = z.infer<
typeof CheckoutPrAutoMergeDisableRequestSchema
>;
export type CheckoutPrAutoMergeDisableResponse = z.infer<
typeof CheckoutPrAutoMergeDisableResponseSchema
>; >;
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>;