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

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/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
@@ -86,6 +87,7 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
- **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site.
- Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable.

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).
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
**Notable session message types:**
- `agent_update` — Agent state changed (status, title, labels)

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

@@ -172,21 +172,28 @@ describe("checkout-git-actions-store", () => {
it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => {
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().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { githubAutoMergeActions: true },
features: { checkoutGithubSetAutoMerge: true },
});
await useCheckoutGitActionsStore
.getState()
.enablePrAutoMerge({ serverId, cwd, method: "squash" });
expect(client.checkoutPrAutoMergeEnable).toHaveBeenCalledWith(cwd, { method: "squash" });
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, {
enabled: true,
method: "squash",
});
expect(
useCheckoutGitActionsStore
.getState()
@@ -196,19 +203,23 @@ describe("checkout-git-actions-store", () => {
it("disables PR auto-merge when the daemon advertises auto-merge actions", async () => {
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().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { githubAutoMergeActions: true },
features: { checkoutGithubSetAutoMerge: true },
});
await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd });
expect(client.checkoutPrAutoMergeDisable).toHaveBeenCalledWith(cwd);
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false });
expect(
useCheckoutGitActionsStore
.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 () => {
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().updateSessionServerInfo(serverId, {
@@ -232,7 +247,7 @@ describe("checkout-git-actions-store", () => {
useCheckoutGitActionsStore.getState().enablePrAutoMerge({ serverId, cwd, method: "merge" }),
).rejects.toThrow("Update the host to use GitHub auto-merge actions.");
expect(client.checkoutPrAutoMergeEnable).not.toHaveBeenCalled();
expect(client.checkoutGithubSetAutoMerge).not.toHaveBeenCalled();
expect(
useCheckoutGitActionsStore
.getState()

View File

@@ -58,7 +58,7 @@ function resolveClient(serverId: string) {
function assertGitHubAutoMergeActionsSupported(serverId: string) {
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.");
}
}
@@ -417,7 +417,7 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
actionId: `enable-pr-auto-merge-${method}`,
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutPrAutoMergeEnable(cwd, { method });
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method });
if (payload.error) {
throw new Error(payload.error.message);
}
@@ -433,7 +433,7 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
actionId: "disable-pr-auto-merge",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutPrAutoMergeDisable(cwd);
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: false });
if (payload.error) {
throw new Error(payload.error.message);
}

View File

@@ -276,7 +276,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((s) => s.archiveWorktree);
const githubAutoMergeActionsEnabled = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.githubAutoMergeActions === true,
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true,
);
const toastActionError = useCallback(

View File

@@ -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 mock = createMockTransport();
@@ -1603,16 +1603,17 @@ test("requests PR auto-merge enable via RPC", async () => {
mock.triggerOpen();
await connectPromise;
const promise = client.checkoutPrAutoMergeEnable(
const promise = client.checkoutGithubSetAutoMerge(
"/tmp/project",
{ method: "squash" },
{ enabled: true, method: "squash" },
"req-enable-auto-merge",
);
expect(mock.sent).toHaveLength(1);
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.enabled).toBe(true);
expect(request.mergeMethod).toBe("squash");
expect(request.requestId).toBe("req-enable-auto-merge");
@@ -1620,9 +1621,10 @@ test("requests PR auto-merge enable via RPC", async () => {
JSON.stringify({
type: "session",
message: {
type: "checkout_pr_auto_merge_enable_response",
type: "checkout.github.set_auto_merge.response",
payload: {
cwd: "/tmp/project",
enabled: true,
requestId: "req-enable-auto-merge",
success: true,
error: null,
@@ -1633,13 +1635,14 @@ test("requests PR auto-merge enable via RPC", async () => {
await expect(promise).resolves.toEqual({
cwd: "/tmp/project",
enabled: true,
requestId: "req-enable-auto-merge",
success: true,
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 mock = createMockTransport();
@@ -1656,21 +1659,28 @@ test("requests PR auto-merge disable via RPC", async () => {
mock.triggerOpen();
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);
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.enabled).toBe(false);
expect(request.mergeMethod).toBeUndefined();
expect(request.requestId).toBe("req-disable-auto-merge");
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "checkout_pr_auto_merge_disable_response",
type: "checkout.github.set_auto_merge.response",
payload: {
cwd: "/tmp/project",
enabled: false,
requestId: "req-disable-auto-merge",
success: true,
error: null,
@@ -1681,6 +1691,7 @@ test("requests PR auto-merge disable via RPC", async () => {
await expect(promise).resolves.toEqual({
cwd: "/tmp/project",
enabled: false,
requestId: "req-disable-auto-merge",
success: true,
error: null,

View File

@@ -32,8 +32,7 @@ import type {
CheckoutPrCreateResponse,
CheckoutPrMergeResponse,
CheckoutPrMergeMethod,
CheckoutPrAutoMergeEnableResponse,
CheckoutPrAutoMergeDisableResponse,
CheckoutGithubSetAutoMergeResponse,
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
CheckoutSwitchBranchResponse,
@@ -276,8 +275,7 @@ type CheckoutPullPayload = CheckoutPullResponse["payload"];
type CheckoutPushPayload = CheckoutPushResponse["payload"];
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
type CheckoutPrMergePayload = CheckoutPrMergeResponse["payload"];
type CheckoutPrAutoMergeEnablePayload = CheckoutPrAutoMergeEnableResponse["payload"];
type CheckoutPrAutoMergeDisablePayload = CheckoutPrAutoMergeDisableResponse["payload"];
type CheckoutGithubSetAutoMergePayload = CheckoutGithubSetAutoMergeResponse["payload"];
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
type PullRequestTimelinePayload = PullRequestTimelineResponse["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 {
if (!this.transport || this.connectionState.status !== "connected") {
throw new Error("Transport not connected");
@@ -2821,34 +2838,19 @@ export class DaemonClient {
});
}
async checkoutPrAutoMergeEnable(
async checkoutGithubSetAutoMerge(
cwd: string,
input: { method: CheckoutPrMergeMethod },
input: { enabled: true; method: CheckoutPrMergeMethod } | { enabled: false },
requestId?: string,
): Promise<CheckoutPrAutoMergeEnablePayload> {
return this.sendCorrelatedSessionRequest({
): Promise<CheckoutGithubSetAutoMergePayload> {
return this.sendNamespacedCorrelatedSessionRequest<"checkout.github.set_auto_merge.response">({
requestId,
message: {
type: "checkout_pr_auto_merge_enable_request",
type: "checkout.github.set_auto_merge.request",
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,
});
}

View File

@@ -65,8 +65,7 @@ interface SessionHandlerInternals {
handleCheckoutCommitRequest(params: unknown): Promise<unknown>;
handleCheckoutPrCreateRequest(params: unknown): Promise<unknown>;
handleCheckoutPrMergeRequest(params: unknown): Promise<unknown>;
handleCheckoutPrAutoMergeEnableRequest(params: unknown): Promise<unknown>;
handleCheckoutPrAutoMergeDisableRequest(params: unknown): Promise<unknown>;
handleCheckoutGithubSetAutoMergeRequest(params: unknown): Promise<unknown>;
handleCheckoutPullRequest(params: unknown): Promise<unknown>;
handleCheckoutPushRequest(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 });
await asSessionInternals(session).handleCheckoutPrAutoMergeEnableRequest({
type: "checkout_pr_auto_merge_enable_request",
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",
});
@@ -2300,9 +2300,10 @@ describe("session checkout pull request auto-merge", () => {
});
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
expect(messages).toContainEqual({
type: "checkout_pr_auto_merge_enable_response",
type: "checkout.github.set_auto_merge.response",
payload: {
cwd: "/tmp/request-worktree",
enabled: true,
success: true,
error: null,
requestId: "request-pr-auto-merge-enable",
@@ -2336,9 +2337,10 @@ describe("session checkout pull request auto-merge", () => {
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrAutoMergeDisableRequest({
type: "checkout_pr_auto_merge_disable_request",
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/request-worktree",
enabled: false,
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(messages).toContainEqual({
type: "checkout_pr_auto_merge_disable_response",
type: "checkout.github.set_auto_merge.response",
payload: {
cwd: "/tmp/request-worktree",
enabled: false,
success: true,
error: null,
requestId: "request-pr-auto-merge-disable",
@@ -2397,17 +2400,19 @@ describe("session checkout pull request auto-merge", () => {
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrAutoMergeEnableRequest({
type: "checkout_pr_auto_merge_enable_request",
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_pr_auto_merge_enable_response",
type: "checkout.github.set_auto_merge.response",
payload: {
cwd: "/tmp/request-worktree",
enabled: true,
success: false,
error: {
code: "UNKNOWN",
@@ -2444,9 +2449,10 @@ describe("session checkout pull request auto-merge", () => {
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrAutoMergeEnableRequest({
type: "checkout_pr_auto_merge_enable_request",
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",
});
@@ -2459,9 +2465,10 @@ describe("session checkout pull request auto-merge", () => {
reason: "auto-merge-validation",
});
expect(messages).toContainEqual({
type: "checkout_pr_auto_merge_enable_response",
type: "checkout.github.set_auto_merge.response",
payload: {
cwd: "/tmp/request-worktree",
enabled: true,
success: false,
error: {
code: "UNKNOWN",
@@ -2498,9 +2505,10 @@ describe("session checkout pull request auto-merge", () => {
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrAutoMergeDisableRequest({
type: "checkout_pr_auto_merge_disable_request",
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/request-worktree",
enabled: false,
requestId: "request-pr-auto-merge-disable-forbidden",
});
@@ -2512,9 +2520,10 @@ describe("session checkout pull request auto-merge", () => {
reason: "auto-merge-validation",
});
expect(messages).toContainEqual({
type: "checkout_pr_auto_merge_disable_response",
type: "checkout.github.set_auto_merge.response",
payload: {
cwd: "/tmp/request-worktree",
enabled: false,
success: false,
error: {
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", () => {

View File

@@ -2027,10 +2027,8 @@ export class Session {
return this.handleCheckoutPrCreateRequest(msg);
case "checkout_pr_merge_request":
return this.handleCheckoutPrMergeRequest(msg);
case "checkout_pr_auto_merge_enable_request":
return this.handleCheckoutPrAutoMergeEnableRequest(msg);
case "checkout_pr_auto_merge_disable_request":
return this.handleCheckoutPrAutoMergeDisableRequest(msg);
case "checkout.github.set_auto_merge.request":
return this.handleCheckoutGithubSetAutoMergeRequest(msg);
case "checkout_pr_status_request":
return this.handleCheckoutPrStatusRequest(msg);
case "pull_request_timeline_request":
@@ -5242,8 +5240,8 @@ export class Session {
}
}
private async handleCheckoutPrAutoMergeEnableRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pr_auto_merge_enable_request" }>,
private async handleCheckoutGithubSetAutoMergeRequest(
msg: Extract<SessionInboundMessage, { type: "checkout.github.set_auto_merge.request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
@@ -5253,22 +5251,45 @@ export class Session {
includeGitHub: true,
reason: "auto-merge-validation",
});
assertPullRequestAutoMergeEnableReady({
mergeMethod: msg.mergeMethod,
status: pullRequest,
});
await this.github.enablePullRequestAutoMerge({
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,
prNumber: pullRequest.number,
mergeMethod: msg.mergeMethod,
status: pullRequest,
});
await this.notifyGitMutation(cwd, "enable-pr-auto-merge", { invalidateGithub: true });
msg.enabled ? "enable-pr-auto-merge" : "disable-pr-auto-merge",
{
invalidateGithub: true,
},
);
this.emit({
type: "checkout_pr_auto_merge_enable_response",
type: "checkout.github.set_auto_merge.response",
payload: {
cwd,
enabled: msg.enabled,
success: true,
error: null,
requestId,
@@ -5276,50 +5297,10 @@ export class Session {
});
} catch (error) {
this.emit({
type: "checkout_pr_auto_merge_enable_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",
type: "checkout.github.set_auto_merge.response",
payload: {
cwd,
enabled: msg.enabled,
success: false,
error: toCheckoutError(error),
requestId,

View File

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

View File

@@ -1,10 +1,8 @@
import { describe, expect, test } from "vitest";
import {
CheckoutPrAutoMergeDisableRequestSchema,
CheckoutPrAutoMergeDisableResponseSchema,
CheckoutPrAutoMergeEnableRequestSchema,
CheckoutPrAutoMergeEnableResponseSchema,
CheckoutGithubSetAutoMergeRequestSchema,
CheckoutGithubSetAutoMergeResponseSchema,
CheckoutPrMergeRequestSchema,
CheckoutPrStatusSchema,
ServerInfoStatusPayloadSchema,
@@ -115,60 +113,59 @@ describe("checkout PR schemas", () => {
});
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) => {
expect(
CheckoutPrAutoMergeEnableRequestSchema.parse({
type: "checkout_pr_auto_merge_enable_request",
CheckoutGithubSetAutoMergeRequestSchema.parse({
type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/repo",
enabled: true,
mergeMethod,
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(() =>
CheckoutPrAutoMergeEnableRequestSchema.parse({
type: "checkout_pr_auto_merge_enable_request",
CheckoutGithubSetAutoMergeRequestSchema.parse({
type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/repo",
enabled: true,
mergeMethod: "auto",
requestId: "request-enable-auto-merge",
}),
).toThrow();
});
test("accepts PR auto-merge disable requests", () => {
test("accepts GitHub set-auto-merge disable requests", () => {
expect(
CheckoutPrAutoMergeDisableRequestSchema.parse({
type: "checkout_pr_auto_merge_disable_request",
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 PR auto-merge mutation responses", () => {
test("accepts GitHub set-auto-merge responses", () => {
const payload = {
cwd: "/tmp/repo",
enabled: true,
success: true,
error: null,
requestId: "request-auto-merge",
};
expect(
CheckoutPrAutoMergeEnableResponseSchema.parse({
type: "checkout_pr_auto_merge_enable_response",
payload,
}).payload,
).toEqual(payload);
expect(
CheckoutPrAutoMergeDisableResponseSchema.parse({
type: "checkout_pr_auto_merge_disable_response",
CheckoutGithubSetAutoMergeResponseSchema.parse({
type: "checkout.github.set_auto_merge.response",
payload,
}).payload,
).toEqual(payload);
@@ -181,12 +178,12 @@ describe("checkout PR schemas", () => {
serverId: "srv_test",
features: {
providersSnapshot: true,
githubAutoMergeActions: true,
checkoutGithubSetAutoMerge: true,
},
}).features,
).toEqual({
providersSnapshot: true,
githubAutoMergeActions: true,
checkoutGithubSetAutoMerge: true,
});
});
});

View File

@@ -1314,16 +1314,11 @@ export const CheckoutPrMergeRequestSchema = z.object({
requestId: z.string(),
});
export const CheckoutPrAutoMergeEnableRequestSchema = z.object({
type: z.literal("checkout_pr_auto_merge_enable_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"),
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(),
});
@@ -1776,8 +1771,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushRequestSchema,
CheckoutPrCreateRequestSchema,
CheckoutPrMergeRequestSchema,
CheckoutPrAutoMergeEnableRequestSchema,
CheckoutPrAutoMergeDisableRequestSchema,
CheckoutGithubSetAutoMergeRequestSchema,
CheckoutPrStatusRequestSchema,
PullRequestTimelineRequestSchema,
CheckoutSwitchBranchRequestSchema,
@@ -2000,7 +1994,7 @@ export const ServerInfoStatusPayloadSchema = z
features: z
.object({
providersSnapshot: z.boolean().optional(),
githubAutoMergeActions: z.boolean().optional(),
checkoutGithubSetAutoMerge: z.boolean().optional(),
})
.optional(),
})
@@ -2908,20 +2902,11 @@ export const CheckoutPrMergeResponseSchema = z.object({
}),
});
export const CheckoutPrAutoMergeEnableResponseSchema = z.object({
type: z.literal("checkout_pr_auto_merge_enable_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"),
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(),
@@ -3486,8 +3471,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushResponseSchema,
CheckoutPrCreateResponseSchema,
CheckoutPrMergeResponseSchema,
CheckoutPrAutoMergeEnableResponseSchema,
CheckoutPrAutoMergeDisableResponseSchema,
CheckoutGithubSetAutoMergeResponseSchema,
CheckoutPrStatusResponseSchema,
PullRequestTimelineResponseSchema,
CheckoutSwitchBranchResponseSchema,
@@ -3744,17 +3728,11 @@ export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSc
export type CheckoutPrMergeRequest = z.infer<typeof CheckoutPrMergeRequestSchema>;
export type CheckoutPrMergeResponse = z.infer<typeof CheckoutPrMergeResponseSchema>;
export type CheckoutPrMergeMethod = z.infer<typeof CheckoutPrMergeRequestSchema>["mergeMethod"];
export type CheckoutPrAutoMergeEnableRequest = z.infer<
typeof CheckoutPrAutoMergeEnableRequestSchema
export type CheckoutGithubSetAutoMergeRequest = z.infer<
typeof CheckoutGithubSetAutoMergeRequestSchema
>;
export type CheckoutPrAutoMergeEnableResponse = z.infer<
typeof CheckoutPrAutoMergeEnableResponseSchema
>;
export type CheckoutPrAutoMergeDisableRequest = z.infer<
typeof CheckoutPrAutoMergeDisableRequestSchema
>;
export type CheckoutPrAutoMergeDisableResponse = z.infer<
typeof CheckoutPrAutoMergeDisableResponseSchema
export type CheckoutGithubSetAutoMergeResponse = z.infer<
typeof CheckoutGithubSetAutoMergeResponseSchema
>;
export type PullRequestMergeable = z.infer<typeof CheckoutPrStatusSchema>["mergeable"];
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;