mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Namespace GitHub auto-merge RPC
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
84
docs/rpc-namespacing.md
Normal 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.
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user