mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Surface GitHub auto-merge actions (#1001)
* Surface GitHub auto-merge actions * Namespace GitHub auto-merge RPC * Update workspace git refresh test expectation * Avoid redundant GitHub refresh queue
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ There is no dedicated welcome message; the server emits a `status` session messa
|
|||||||
|
|
||||||
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
|
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
|
||||||
|
|
||||||
|
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
|
||||||
|
|
||||||
**Notable session message types:**
|
**Notable session message types:**
|
||||||
|
|
||||||
- `agent_update` — Agent state changed (status, title, labels)
|
- `agent_update` — Agent state changed (status, title, labels)
|
||||||
|
|||||||
84
docs/rpc-namespacing.md
Normal file
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.
|
||||||
@@ -97,6 +97,13 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
|||||||
[theme.colors.foreground, toast],
|
[theme.colors.foreground, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handlePrimaryPress = useCallback(() => {
|
||||||
|
if (!gitActions.primary) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleActionSelect(gitActions.primary);
|
||||||
|
}, [gitActions.primary, handleActionSelect]);
|
||||||
|
|
||||||
const overflowMenuButtonStyle = useMemo(() => [styles.iconButton, styles.overflowMenuButton], []);
|
const overflowMenuButtonStyle = useMemo(() => [styles.iconButton, styles.overflowMenuButton], []);
|
||||||
|
|
||||||
const primaryDisabled = gitActions.primary?.disabled;
|
const primaryDisabled = gitActions.primary?.disabled;
|
||||||
@@ -124,7 +131,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
|||||||
<Pressable
|
<Pressable
|
||||||
testID="changes-primary-cta"
|
testID="changes-primary-cta"
|
||||||
style={primaryPressableStyle}
|
style={primaryPressableStyle}
|
||||||
onPress={gitActions.primary.handler}
|
onPress={handlePrimaryPress}
|
||||||
disabled={gitActions.primary.disabled}
|
disabled={gitActions.primary.disabled}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={gitActions.primary.label}
|
accessibilityLabel={gitActions.primary.label}
|
||||||
|
|||||||
@@ -170,6 +170,91 @@ describe("checkout-git-actions-store", () => {
|
|||||||
).toBe("idle");
|
).toBe("idle");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => {
|
||||||
|
const client = {
|
||||||
|
checkoutGithubSetAutoMerge: vi.fn(async () => ({
|
||||||
|
enabled: true,
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||||
|
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||||
|
serverId,
|
||||||
|
hostname: null,
|
||||||
|
version: null,
|
||||||
|
features: { checkoutGithubSetAutoMerge: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await useCheckoutGitActionsStore
|
||||||
|
.getState()
|
||||||
|
.enablePrAutoMerge({ serverId, cwd, method: "squash" });
|
||||||
|
|
||||||
|
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, {
|
||||||
|
enabled: true,
|
||||||
|
method: "squash",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
useCheckoutGitActionsStore
|
||||||
|
.getState()
|
||||||
|
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
|
||||||
|
).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables PR auto-merge when the daemon advertises auto-merge actions", async () => {
|
||||||
|
const client = {
|
||||||
|
checkoutGithubSetAutoMerge: vi.fn(async () => ({
|
||||||
|
enabled: false,
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||||
|
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||||
|
serverId,
|
||||||
|
hostname: null,
|
||||||
|
version: null,
|
||||||
|
features: { checkoutGithubSetAutoMerge: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd });
|
||||||
|
|
||||||
|
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false });
|
||||||
|
expect(
|
||||||
|
useCheckoutGitActionsStore
|
||||||
|
.getState()
|
||||||
|
.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
|
||||||
|
).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call PR auto-merge RPCs when the daemon lacks the feature flag", async () => {
|
||||||
|
const client = {
|
||||||
|
checkoutGithubSetAutoMerge: vi.fn(async () => ({
|
||||||
|
enabled: true,
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||||
|
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||||
|
serverId,
|
||||||
|
hostname: null,
|
||||||
|
version: null,
|
||||||
|
features: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
useCheckoutGitActionsStore.getState().enablePrAutoMerge({ serverId, cwd, method: "merge" }),
|
||||||
|
).rejects.toThrow("Update the host to use GitHub auto-merge actions.");
|
||||||
|
|
||||||
|
expect(client.checkoutGithubSetAutoMerge).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
useCheckoutGitActionsStore
|
||||||
|
.getState()
|
||||||
|
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
|
||||||
|
).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
it("hides an archived worktree optimistically while the archive RPC is in flight", async () => {
|
it("hides an archived worktree optimistically while the archive RPC is in flight", async () => {
|
||||||
const deferred = createDeferred<Record<string, never>>();
|
const deferred = createDeferred<Record<string, never>>();
|
||||||
const client = {
|
const client = {
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export type CheckoutGitAsyncActionId =
|
|||||||
| "merge-pr-squash"
|
| "merge-pr-squash"
|
||||||
| "merge-pr-merge"
|
| "merge-pr-merge"
|
||||||
| "merge-pr-rebase"
|
| "merge-pr-rebase"
|
||||||
|
| "enable-pr-auto-merge-squash"
|
||||||
|
| "enable-pr-auto-merge-merge"
|
||||||
|
| "enable-pr-auto-merge-rebase"
|
||||||
|
| "disable-pr-auto-merge"
|
||||||
| "merge-branch"
|
| "merge-branch"
|
||||||
| "merge-from-base"
|
| "merge-from-base"
|
||||||
| "archive-worktree";
|
| "archive-worktree";
|
||||||
@@ -52,6 +56,13 @@ function resolveClient(serverId: string) {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertGitHubAutoMergeActionsSupported(serverId: string) {
|
||||||
|
const session = useSessionStore.getState().sessions[serverId];
|
||||||
|
if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge !== true) {
|
||||||
|
throw new Error("Update the host to use GitHub auto-merge actions.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setStatus(
|
function setStatus(
|
||||||
key: CheckoutKey,
|
key: CheckoutKey,
|
||||||
actionId: CheckoutGitAsyncActionId,
|
actionId: CheckoutGitAsyncActionId,
|
||||||
@@ -231,6 +242,12 @@ interface CheckoutGitActionsStoreState {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
method: CheckoutPrMergeMethod;
|
method: CheckoutPrMergeMethod;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
|
enablePrAutoMerge: (params: {
|
||||||
|
serverId: string;
|
||||||
|
cwd: string;
|
||||||
|
method: CheckoutPrMergeMethod;
|
||||||
|
}) => Promise<void>;
|
||||||
|
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||||
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||||
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||||
archiveWorktree: (params: {
|
archiveWorktree: (params: {
|
||||||
@@ -392,6 +409,38 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
enablePrAutoMerge: async ({ serverId, cwd, method }) => {
|
||||||
|
assertGitHubAutoMergeActionsSupported(serverId);
|
||||||
|
await runCheckoutAction({
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
actionId: `enable-pr-auto-merge-${method}`,
|
||||||
|
run: async () => {
|
||||||
|
const client = resolveClient(serverId);
|
||||||
|
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method });
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
disablePrAutoMerge: async ({ serverId, cwd }) => {
|
||||||
|
assertGitHubAutoMergeActionsSupported(serverId);
|
||||||
|
await runCheckoutAction({
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
actionId: "disable-pr-auto-merge",
|
||||||
|
run: async () => {
|
||||||
|
const client = resolveClient(serverId);
|
||||||
|
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: false });
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
mergeBranch: async ({ serverId, cwd, baseRef }) => {
|
mergeBranch: async ({ serverId, cwd, baseRef }) => {
|
||||||
await runCheckoutAction({
|
await runCheckoutAction({
|
||||||
serverId,
|
serverId,
|
||||||
|
|||||||
@@ -1,17 +1,43 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { CheckoutPrStatusSchema } from "@server/shared/messages";
|
||||||
|
|
||||||
import { buildGitActions, type BuildGitActionsInput } from "./policy";
|
import { buildGitActions, type BuildGitActionsInput } from "./policy";
|
||||||
|
|
||||||
|
function githubStatus(
|
||||||
|
overrides: Partial<NonNullable<BuildGitActionsInput["pullRequestGithub"]>> = {},
|
||||||
|
): NonNullable<BuildGitActionsInput["pullRequestGithub"]> {
|
||||||
|
return {
|
||||||
|
mergeStateStatus: "CLEAN",
|
||||||
|
autoMergeRequest: null,
|
||||||
|
viewerCanEnableAutoMerge: false,
|
||||||
|
viewerCanDisableAutoMerge: false,
|
||||||
|
viewerCanMergeAsAdmin: false,
|
||||||
|
viewerCanUpdateBranch: false,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: true,
|
||||||
|
squashMergeAllowed: true,
|
||||||
|
rebaseMergeAllowed: true,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
},
|
||||||
|
isMergeQueueEnabled: false,
|
||||||
|
isInMergeQueue: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
|
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
|
||||||
return {
|
return {
|
||||||
isGit: true,
|
isGit: true,
|
||||||
githubFeaturesEnabled: true,
|
githubFeaturesEnabled: true,
|
||||||
|
githubAutoMergeActionsEnabled: true,
|
||||||
hasPullRequest: false,
|
hasPullRequest: false,
|
||||||
pullRequestUrl: null,
|
pullRequestUrl: null,
|
||||||
pullRequestState: null,
|
pullRequestState: null,
|
||||||
pullRequestIsDraft: false,
|
pullRequestIsDraft: false,
|
||||||
pullRequestIsMerged: false,
|
pullRequestIsMerged: false,
|
||||||
pullRequestMergeable: "UNKNOWN",
|
pullRequestMergeable: "UNKNOWN",
|
||||||
|
pullRequestGithub: null,
|
||||||
hasRemote: false,
|
hasRemote: false,
|
||||||
isPaseoOwnedWorktree: false,
|
isPaseoOwnedWorktree: false,
|
||||||
isOnBaseBranch: true,
|
isOnBaseBranch: true,
|
||||||
@@ -65,6 +91,26 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
|||||||
status: "idle",
|
status: "idle",
|
||||||
handler: () => undefined,
|
handler: () => undefined,
|
||||||
},
|
},
|
||||||
|
"enable-pr-auto-merge-squash": {
|
||||||
|
disabled: false,
|
||||||
|
status: "idle",
|
||||||
|
handler: () => undefined,
|
||||||
|
},
|
||||||
|
"enable-pr-auto-merge-merge": {
|
||||||
|
disabled: false,
|
||||||
|
status: "idle",
|
||||||
|
handler: () => undefined,
|
||||||
|
},
|
||||||
|
"enable-pr-auto-merge-rebase": {
|
||||||
|
disabled: false,
|
||||||
|
status: "idle",
|
||||||
|
handler: () => undefined,
|
||||||
|
},
|
||||||
|
"disable-pr-auto-merge": {
|
||||||
|
disabled: false,
|
||||||
|
status: "idle",
|
||||||
|
handler: () => undefined,
|
||||||
|
},
|
||||||
"merge-branch": {
|
"merge-branch": {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
@@ -167,6 +213,9 @@ describe("git-actions-policy", () => {
|
|||||||
behindBaseCount: 1,
|
behindBaseCount: 1,
|
||||||
hasPullRequest: true,
|
hasPullRequest: true,
|
||||||
pullRequestUrl: "https://example.com/pr/456",
|
pullRequestUrl: "https://example.com/pr/456",
|
||||||
|
pullRequestState: "open",
|
||||||
|
pullRequestMergeable: "MERGEABLE",
|
||||||
|
pullRequestGithub: githubStatus(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -177,9 +226,6 @@ describe("git-actions-policy", () => {
|
|||||||
"merge-from-base",
|
"merge-from-base",
|
||||||
"merge-branch",
|
"merge-branch",
|
||||||
"pr",
|
"pr",
|
||||||
"merge-pr-squash",
|
|
||||||
"merge-pr-merge",
|
|
||||||
"merge-pr-rebase",
|
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
|
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
|
||||||
@@ -246,6 +292,28 @@ describe("git-actions-policy", () => {
|
|||||||
pullRequestUrl: "https://example.com/pr/456",
|
pullRequestUrl: "https://example.com/pr/456",
|
||||||
pullRequestState: "open",
|
pullRequestState: "open",
|
||||||
pullRequestMergeable: "MERGEABLE",
|
pullRequestMergeable: "MERGEABLE",
|
||||||
|
pullRequestGithub: githubStatus(),
|
||||||
|
shipDefault: "pr",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(actions.primary).toMatchObject({
|
||||||
|
id: "merge-pr-squash",
|
||||||
|
label: "Squash and merge",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses GitHub merge state, not mergeable, for direct merge readiness", () => {
|
||||||
|
const actions = buildGitActions(
|
||||||
|
createInput({
|
||||||
|
hasRemote: true,
|
||||||
|
isOnBaseBranch: false,
|
||||||
|
aheadCount: 2,
|
||||||
|
hasPullRequest: true,
|
||||||
|
pullRequestUrl: "https://example.com/pr/456",
|
||||||
|
pullRequestState: "open",
|
||||||
|
pullRequestMergeable: "UNKNOWN",
|
||||||
|
pullRequestGithub: githubStatus({ mergeStateStatus: "CLEAN" }),
|
||||||
shipDefault: "pr",
|
shipDefault: "pr",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -266,6 +334,7 @@ describe("git-actions-policy", () => {
|
|||||||
pullRequestUrl: "https://example.com/pr/456",
|
pullRequestUrl: "https://example.com/pr/456",
|
||||||
pullRequestState: "open",
|
pullRequestState: "open",
|
||||||
pullRequestMergeable: "MERGEABLE",
|
pullRequestMergeable: "MERGEABLE",
|
||||||
|
pullRequestGithub: githubStatus(),
|
||||||
shipDefault: "pr",
|
shipDefault: "pr",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -278,6 +347,7 @@ describe("git-actions-policy", () => {
|
|||||||
pullRequestUrl: "https://example.com/pr/456",
|
pullRequestUrl: "https://example.com/pr/456",
|
||||||
pullRequestState: "open",
|
pullRequestState: "open",
|
||||||
pullRequestMergeable: "MERGEABLE",
|
pullRequestMergeable: "MERGEABLE",
|
||||||
|
pullRequestGithub: githubStatus(),
|
||||||
shipDefault: "merge",
|
shipDefault: "merge",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -305,6 +375,7 @@ describe("git-actions-policy", () => {
|
|||||||
pullRequestUrl: "https://example.com/pr/456",
|
pullRequestUrl: "https://example.com/pr/456",
|
||||||
pullRequestState: "open",
|
pullRequestState: "open",
|
||||||
pullRequestMergeable: "MERGEABLE",
|
pullRequestMergeable: "MERGEABLE",
|
||||||
|
pullRequestGithub: githubStatus(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -321,6 +392,73 @@ describe("git-actions-policy", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the visible PR action model stable on feature branches", () => {
|
||||||
|
const actions = buildGitActions(
|
||||||
|
createInput({
|
||||||
|
hasRemote: true,
|
||||||
|
isOnBaseBranch: false,
|
||||||
|
aheadCount: 2,
|
||||||
|
hasPullRequest: true,
|
||||||
|
pullRequestUrl: "https://example.com/pr/456",
|
||||||
|
pullRequestState: "open",
|
||||||
|
pullRequestMergeable: "MERGEABLE",
|
||||||
|
pullRequestGithub: githubStatus(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const pullRequestActions = actions.secondary
|
||||||
|
.filter((action) =>
|
||||||
|
["pr", "merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
|
||||||
|
)
|
||||||
|
.map((action) => ({
|
||||||
|
id: action.id,
|
||||||
|
label: action.label,
|
||||||
|
pendingLabel: action.pendingLabel,
|
||||||
|
successLabel: action.successLabel,
|
||||||
|
disabled: action.disabled,
|
||||||
|
unavailableMessage: action.unavailableMessage,
|
||||||
|
startsGroup: action.startsGroup,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(pullRequestActions).toEqual([
|
||||||
|
{
|
||||||
|
id: "pr",
|
||||||
|
label: "View PR",
|
||||||
|
pendingLabel: "View PR",
|
||||||
|
successLabel: "View PR",
|
||||||
|
disabled: false,
|
||||||
|
unavailableMessage: undefined,
|
||||||
|
startsGroup: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "merge-pr-squash",
|
||||||
|
label: "Squash and merge",
|
||||||
|
pendingLabel: "Merging PR...",
|
||||||
|
successLabel: "PR merged",
|
||||||
|
disabled: false,
|
||||||
|
unavailableMessage: undefined,
|
||||||
|
startsGroup: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "merge-pr-merge",
|
||||||
|
label: "Create a merge commit",
|
||||||
|
pendingLabel: "Merging PR...",
|
||||||
|
successLabel: "PR merged",
|
||||||
|
disabled: false,
|
||||||
|
unavailableMessage: undefined,
|
||||||
|
startsGroup: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "merge-pr-rebase",
|
||||||
|
label: "Rebase and merge",
|
||||||
|
pendingLabel: "Merging PR...",
|
||||||
|
successLabel: "PR merged",
|
||||||
|
disabled: false,
|
||||||
|
unavailableMessage: undefined,
|
||||||
|
startsGroup: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses Merge locally for the local merge action", () => {
|
it("uses Merge locally for the local merge action", () => {
|
||||||
const actions = buildGitActions(
|
const actions = buildGitActions(
|
||||||
createInput({
|
createInput({
|
||||||
@@ -334,27 +472,11 @@ describe("git-actions-policy", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[
|
["draft", { pullRequestIsDraft: true }],
|
||||||
"draft",
|
["merged", { pullRequestIsMerged: true }],
|
||||||
{ pullRequestIsDraft: true },
|
["closed", { pullRequestState: "closed" as const }],
|
||||||
"Merge PR isn't available because the pull request is still a draft",
|
["conflicting", { pullRequestMergeable: "CONFLICTING" as const }],
|
||||||
],
|
])("does not offer direct merge actions when the PR is %s", (_name, overrides) => {
|
||||||
[
|
|
||||||
"merged",
|
|
||||||
{ pullRequestIsMerged: true },
|
|
||||||
"Merge PR isn't available because the pull request is already merged",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"closed",
|
|
||||||
{ pullRequestState: "closed" as const },
|
|
||||||
"Merge PR isn't available because the pull request is closed",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"conflicting",
|
|
||||||
{ pullRequestMergeable: "CONFLICTING" as const },
|
|
||||||
"Merge PR isn't available because the pull request has conflicts",
|
|
||||||
],
|
|
||||||
])("marks merge-pr actions unavailable when the PR is %s", (_name, overrides, message) => {
|
|
||||||
const actions = buildGitActions(
|
const actions = buildGitActions(
|
||||||
createInput({
|
createInput({
|
||||||
hasRemote: true,
|
hasRemote: true,
|
||||||
@@ -364,6 +486,7 @@ describe("git-actions-policy", () => {
|
|||||||
pullRequestUrl: "https://example.com/pr/456",
|
pullRequestUrl: "https://example.com/pr/456",
|
||||||
pullRequestState: "open",
|
pullRequestState: "open",
|
||||||
pullRequestMergeable: "MERGEABLE",
|
pullRequestMergeable: "MERGEABLE",
|
||||||
|
pullRequestGithub: githubStatus(),
|
||||||
...overrides,
|
...overrides,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -371,13 +494,224 @@ describe("git-actions-policy", () => {
|
|||||||
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
|
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mergePrActions).toHaveLength(3);
|
expect(mergePrActions).toEqual([]);
|
||||||
expect(mergePrActions.map((action) => action.unavailableMessage)).toEqual([
|
expect(actions.secondary).toEqual(
|
||||||
message,
|
expect.arrayContaining([expect.objectContaining({ id: "pr", label: "View PR" })]),
|
||||||
message,
|
);
|
||||||
message,
|
});
|
||||||
|
|
||||||
|
it("preserves legacy direct merge actions when old payloads have no GitHub facts", () => {
|
||||||
|
const oldDaemonStatus = CheckoutPrStatusSchema.parse({
|
||||||
|
number: 456,
|
||||||
|
url: "https://example.com/pr/456",
|
||||||
|
title: "Legacy payload",
|
||||||
|
state: "open",
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "feature",
|
||||||
|
isMerged: false,
|
||||||
|
mergeable: "MERGEABLE",
|
||||||
|
});
|
||||||
|
const actions = buildGitActions(
|
||||||
|
createInput({
|
||||||
|
hasRemote: true,
|
||||||
|
isOnBaseBranch: false,
|
||||||
|
aheadCount: 2,
|
||||||
|
hasPullRequest: true,
|
||||||
|
pullRequestUrl: oldDaemonStatus.url,
|
||||||
|
pullRequestState: "open",
|
||||||
|
pullRequestIsDraft: oldDaemonStatus.isDraft,
|
||||||
|
pullRequestIsMerged: oldDaemonStatus.isMerged,
|
||||||
|
pullRequestMergeable: oldDaemonStatus.mergeable,
|
||||||
|
pullRequestGithub: oldDaemonStatus.github,
|
||||||
|
shipDefault: "pr",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(oldDaemonStatus.github).toBeUndefined();
|
||||||
|
expect(actions.primary).toMatchObject({ id: "merge-pr-squash", label: "Squash and merge" });
|
||||||
|
expect(actions.secondary.map((action) => action.id)).toEqual([
|
||||||
|
"pull",
|
||||||
|
"push",
|
||||||
|
"pull-and-push",
|
||||||
|
"merge-from-base",
|
||||||
|
"merge-branch",
|
||||||
|
"pr",
|
||||||
|
"merge-pr-squash",
|
||||||
|
"merge-pr-merge",
|
||||||
|
"merge-pr-rebase",
|
||||||
]);
|
]);
|
||||||
expect(mergePrActions.map((action) => action.disabled)).toEqual([true, true, true]);
|
});
|
||||||
|
|
||||||
|
it("requires GitHub's direct-merge allowlist before promoting PR merge", () => {
|
||||||
|
const actions = buildGitActions(
|
||||||
|
createInput({
|
||||||
|
hasRemote: true,
|
||||||
|
isOnBaseBranch: false,
|
||||||
|
aheadCount: 2,
|
||||||
|
hasPullRequest: true,
|
||||||
|
pullRequestUrl: "https://example.com/pr/993",
|
||||||
|
pullRequestState: "open",
|
||||||
|
pullRequestMergeable: "MERGEABLE",
|
||||||
|
pullRequestGithub: githubStatus({
|
||||||
|
mergeStateStatus: "BLOCKED",
|
||||||
|
viewerCanEnableAutoMerge: true,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: false,
|
||||||
|
squashMergeAllowed: true,
|
||||||
|
rebaseMergeAllowed: false,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
shipDefault: "pr",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(actions.primary).toMatchObject({
|
||||||
|
id: "enable-pr-auto-merge-squash",
|
||||||
|
label: "Enable auto-merge with squash",
|
||||||
|
});
|
||||||
|
expect(actions.secondary.map((action) => action.id)).toEqual([
|
||||||
|
"pull",
|
||||||
|
"push",
|
||||||
|
"pull-and-push",
|
||||||
|
"merge-from-base",
|
||||||
|
"merge-branch",
|
||||||
|
"pr",
|
||||||
|
"enable-pr-auto-merge-squash",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
actions.secondary.some((action) =>
|
||||||
|
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not offer auto-merge when the daemon feature gate is missing", () => {
|
||||||
|
const actions = buildGitActions(
|
||||||
|
createInput({
|
||||||
|
hasRemote: true,
|
||||||
|
isOnBaseBranch: false,
|
||||||
|
aheadCount: 2,
|
||||||
|
hasPullRequest: true,
|
||||||
|
pullRequestUrl: "https://example.com/pr/993",
|
||||||
|
pullRequestState: "open",
|
||||||
|
pullRequestMergeable: "MERGEABLE",
|
||||||
|
pullRequestGithub: githubStatus({
|
||||||
|
mergeStateStatus: "BLOCKED",
|
||||||
|
viewerCanEnableAutoMerge: true,
|
||||||
|
}),
|
||||||
|
githubAutoMergeActionsEnabled: false,
|
||||||
|
shipDefault: "pr",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(actions.primary).toMatchObject({ id: "pr", label: "View PR" });
|
||||||
|
expect(actions.secondary.some((action) => action.id.startsWith("enable-pr-auto-merge"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows existing auto-merge as state and disables it when the viewer can", () => {
|
||||||
|
const actions = buildGitActions(
|
||||||
|
createInput({
|
||||||
|
hasRemote: true,
|
||||||
|
isOnBaseBranch: false,
|
||||||
|
aheadCount: 2,
|
||||||
|
hasPullRequest: true,
|
||||||
|
pullRequestUrl: "https://example.com/pr/456",
|
||||||
|
pullRequestState: "open",
|
||||||
|
pullRequestMergeable: "MERGEABLE",
|
||||||
|
pullRequestGithub: githubStatus({
|
||||||
|
autoMergeRequest: {
|
||||||
|
enabledAt: "2026-05-13T12:00:00Z",
|
||||||
|
mergeMethod: "SQUASH",
|
||||||
|
enabledBy: "octocat",
|
||||||
|
},
|
||||||
|
viewerCanDisableAutoMerge: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(actions.secondary).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "disable-pr-auto-merge",
|
||||||
|
label: "Auto-merge enabled",
|
||||||
|
disabled: false,
|
||||||
|
unavailableMessage: undefined,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
actions.secondary.some((action) =>
|
||||||
|
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects repository merge method policy for direct merge actions", () => {
|
||||||
|
const actions = buildGitActions(
|
||||||
|
createInput({
|
||||||
|
hasRemote: true,
|
||||||
|
isOnBaseBranch: false,
|
||||||
|
aheadCount: 2,
|
||||||
|
hasPullRequest: true,
|
||||||
|
pullRequestUrl: "https://example.com/pr/456",
|
||||||
|
pullRequestState: "open",
|
||||||
|
pullRequestMergeable: "MERGEABLE",
|
||||||
|
pullRequestGithub: githubStatus({
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: true,
|
||||||
|
squashMergeAllowed: false,
|
||||||
|
rebaseMergeAllowed: false,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
shipDefault: "pr",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(actions.primary).toMatchObject({
|
||||||
|
id: "merge-pr-merge",
|
||||||
|
label: "Create a merge commit",
|
||||||
|
});
|
||||||
|
expect(actions.secondary.map((action) => action.id)).toEqual([
|
||||||
|
"pull",
|
||||||
|
"push",
|
||||||
|
"pull-and-push",
|
||||||
|
"merge-from-base",
|
||||||
|
"merge-branch",
|
||||||
|
"pr",
|
||||||
|
"merge-pr-merge",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat merge queue repositories as direct mergeable", () => {
|
||||||
|
const actions = buildGitActions(
|
||||||
|
createInput({
|
||||||
|
hasRemote: true,
|
||||||
|
isOnBaseBranch: false,
|
||||||
|
aheadCount: 2,
|
||||||
|
hasPullRequest: true,
|
||||||
|
pullRequestUrl: "https://example.com/pr/456",
|
||||||
|
pullRequestState: "open",
|
||||||
|
pullRequestMergeable: "MERGEABLE",
|
||||||
|
pullRequestGithub: githubStatus({
|
||||||
|
mergeStateStatus: "CLEAN",
|
||||||
|
isMergeQueueEnabled: true,
|
||||||
|
}),
|
||||||
|
shipDefault: "pr",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(actions.primary).toMatchObject({ id: "pr", label: "View PR" });
|
||||||
|
expect(
|
||||||
|
actions.secondary.some((action) =>
|
||||||
|
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("groups merge-pr actions behind their own menu separator via startsGroup", () => {
|
it("groups merge-pr actions behind their own menu separator via startsGroup", () => {
|
||||||
@@ -390,6 +724,7 @@ describe("git-actions-policy", () => {
|
|||||||
pullRequestUrl: "https://example.com/pr/456",
|
pullRequestUrl: "https://example.com/pr/456",
|
||||||
pullRequestState: "open",
|
pullRequestState: "open",
|
||||||
pullRequestMergeable: "MERGEABLE",
|
pullRequestMergeable: "MERGEABLE",
|
||||||
|
pullRequestGithub: githubStatus(),
|
||||||
isPaseoOwnedWorktree: true,
|
isPaseoOwnedWorktree: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
import type { ActionStatus } from "@/components/ui/dropdown-menu";
|
import type { ActionStatus } from "@/components/ui/dropdown-menu";
|
||||||
import type { CheckoutPrMergeMethod, PullRequestMergeable } from "@server/shared/messages";
|
import type {
|
||||||
|
CheckoutPrMergeMethod,
|
||||||
|
CheckoutPrStatusResponse,
|
||||||
|
PullRequestMergeable,
|
||||||
|
} from "@server/shared/messages";
|
||||||
|
|
||||||
export type GitActionId =
|
export type GitActionId =
|
||||||
| "commit"
|
| "commit"
|
||||||
@@ -12,6 +16,10 @@ export type GitActionId =
|
|||||||
| "merge-pr-squash"
|
| "merge-pr-squash"
|
||||||
| "merge-pr-merge"
|
| "merge-pr-merge"
|
||||||
| "merge-pr-rebase"
|
| "merge-pr-rebase"
|
||||||
|
| "enable-pr-auto-merge-squash"
|
||||||
|
| "enable-pr-auto-merge-merge"
|
||||||
|
| "enable-pr-auto-merge-rebase"
|
||||||
|
| "disable-pr-auto-merge"
|
||||||
| "merge-branch"
|
| "merge-branch"
|
||||||
| "merge-from-base"
|
| "merge-from-base"
|
||||||
| "archive-worktree";
|
| "archive-worktree";
|
||||||
@@ -46,12 +54,14 @@ interface GitActionRuntimeState {
|
|||||||
export interface BuildGitActionsInput {
|
export interface BuildGitActionsInput {
|
||||||
isGit: boolean;
|
isGit: boolean;
|
||||||
githubFeaturesEnabled: boolean;
|
githubFeaturesEnabled: boolean;
|
||||||
|
githubAutoMergeActionsEnabled: boolean;
|
||||||
hasPullRequest: boolean;
|
hasPullRequest: boolean;
|
||||||
pullRequestUrl: string | null;
|
pullRequestUrl: string | null;
|
||||||
pullRequestState: "open" | "closed" | null;
|
pullRequestState: "open" | "closed" | null;
|
||||||
pullRequestIsDraft: boolean;
|
pullRequestIsDraft: boolean;
|
||||||
pullRequestIsMerged: boolean;
|
pullRequestIsMerged: boolean;
|
||||||
pullRequestMergeable: PullRequestMergeable;
|
pullRequestMergeable: PullRequestMergeable;
|
||||||
|
pullRequestGithub: PullRequestGithubStatus | null;
|
||||||
hasRemote: boolean;
|
hasRemote: boolean;
|
||||||
isPaseoOwnedWorktree: boolean;
|
isPaseoOwnedWorktree: boolean;
|
||||||
isOnBaseBranch: boolean;
|
isOnBaseBranch: boolean;
|
||||||
@@ -67,24 +77,117 @@ export interface BuildGitActionsInput {
|
|||||||
runtime: Record<GitActionId, GitActionRuntimeState>;
|
runtime: Record<GitActionId, GitActionRuntimeState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
|
type PullRequestActionId = Extract<
|
||||||
const FEATURE_ACTION_IDS: GitActionId[] = [
|
GitActionId,
|
||||||
"merge-from-base",
|
| "pr"
|
||||||
"merge-branch",
|
| "merge-pr-squash"
|
||||||
"pr",
|
| "merge-pr-merge"
|
||||||
"merge-pr-squash",
|
| "merge-pr-rebase"
|
||||||
"merge-pr-merge",
|
| "enable-pr-auto-merge-squash"
|
||||||
"merge-pr-rebase",
|
| "enable-pr-auto-merge-merge"
|
||||||
|
| "enable-pr-auto-merge-rebase"
|
||||||
|
| "disable-pr-auto-merge"
|
||||||
|
>;
|
||||||
|
type PullRequestDirectMergeActionId = Extract<
|
||||||
|
GitActionId,
|
||||||
|
"merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase"
|
||||||
|
>;
|
||||||
|
type PullRequestAutoMergeEnableActionId = Extract<
|
||||||
|
GitActionId,
|
||||||
|
"enable-pr-auto-merge-squash" | "enable-pr-auto-merge-merge" | "enable-pr-auto-merge-rebase"
|
||||||
|
>;
|
||||||
|
type PullRequestActionRole = "status" | "direct" | "auto";
|
||||||
|
type PullRequestGithubStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>["github"];
|
||||||
|
|
||||||
|
interface PullRequestActionModel {
|
||||||
|
readonly id: PullRequestActionId;
|
||||||
|
readonly role: PullRequestActionRole;
|
||||||
|
readonly build: (input: BuildGitActionsInput) => GitAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PullRequestDirectMergeActionModel {
|
||||||
|
readonly id: PullRequestDirectMergeActionId;
|
||||||
|
readonly role: "direct";
|
||||||
|
readonly label: string;
|
||||||
|
readonly method: CheckoutPrMergeMethod;
|
||||||
|
readonly startsGroup: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PullRequestAutoMergeEnableActionModel {
|
||||||
|
readonly id: PullRequestAutoMergeEnableActionId;
|
||||||
|
readonly role: "auto";
|
||||||
|
readonly label: string;
|
||||||
|
readonly method: CheckoutPrMergeMethod;
|
||||||
|
readonly startsGroup: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS = [
|
||||||
|
{
|
||||||
|
id: "merge-pr-squash",
|
||||||
|
role: "direct",
|
||||||
|
label: "Squash and merge",
|
||||||
|
method: "squash",
|
||||||
|
startsGroup: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "merge-pr-merge",
|
||||||
|
role: "direct",
|
||||||
|
label: "Create a merge commit",
|
||||||
|
method: "merge",
|
||||||
|
startsGroup: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "merge-pr-rebase",
|
||||||
|
role: "direct",
|
||||||
|
label: "Rebase and merge",
|
||||||
|
method: "rebase",
|
||||||
|
startsGroup: false,
|
||||||
|
},
|
||||||
|
] as const satisfies readonly PullRequestDirectMergeActionModel[];
|
||||||
|
|
||||||
|
const PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS = [
|
||||||
|
{
|
||||||
|
id: "enable-pr-auto-merge-squash",
|
||||||
|
role: "auto",
|
||||||
|
label: "Enable auto-merge with squash",
|
||||||
|
method: "squash",
|
||||||
|
startsGroup: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enable-pr-auto-merge-merge",
|
||||||
|
role: "auto",
|
||||||
|
label: "Enable auto-merge with merge commit",
|
||||||
|
method: "merge",
|
||||||
|
startsGroup: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enable-pr-auto-merge-rebase",
|
||||||
|
role: "auto",
|
||||||
|
label: "Enable auto-merge with rebase",
|
||||||
|
method: "rebase",
|
||||||
|
startsGroup: false,
|
||||||
|
},
|
||||||
|
] as const satisfies readonly PullRequestAutoMergeEnableActionModel[];
|
||||||
|
|
||||||
|
const PULL_REQUEST_ACTION_MODELS: readonly PullRequestActionModel[] = [
|
||||||
|
{ id: "pr", role: "status", build: buildPrAction },
|
||||||
|
...PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS.map((model) => ({
|
||||||
|
...model,
|
||||||
|
build: (input: BuildGitActionsInput) => buildDirectPullRequestMergeAction(input, model),
|
||||||
|
})),
|
||||||
|
...PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS.map((model) => ({
|
||||||
|
...model,
|
||||||
|
build: (input: BuildGitActionsInput) => buildEnablePullRequestAutoMergeAction(input, model),
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
id: "disable-pr-auto-merge",
|
||||||
|
role: "auto",
|
||||||
|
build: buildDisablePullRequestAutoMergeAction,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const MERGE_PR_METHODS: Record<
|
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
|
||||||
"merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase",
|
const GITHUB_DIRECT_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]);
|
||||||
{ label: string; method: CheckoutPrMergeMethod }
|
|
||||||
> = {
|
|
||||||
"merge-pr-squash": { label: "Squash and merge", method: "squash" },
|
|
||||||
"merge-pr-merge": { label: "Create a merge commit", method: "merge" },
|
|
||||||
"merge-pr-rebase": { label: "Rebase and merge", method: "rebase" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function narrowPullRequestState(state: string | null | undefined): "open" | "closed" | null {
|
export function narrowPullRequestState(state: string | null | undefined): "open" | "closed" | null {
|
||||||
if (state === "open") return "open";
|
if (state === "open") return "open";
|
||||||
@@ -152,11 +255,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
|||||||
handler: input.runtime["pull-and-push"].handler,
|
handler: input.runtime["pull-and-push"].handler,
|
||||||
});
|
});
|
||||||
|
|
||||||
allActions.set("pr", buildPrAction(input));
|
for (const model of PULL_REQUEST_ACTION_MODELS) {
|
||||||
|
allActions.set(model.id, model.build(input));
|
||||||
allActions.set("merge-pr-squash", buildMergePrAction(input, "merge-pr-squash"));
|
}
|
||||||
allActions.set("merge-pr-merge", buildMergePrAction(input, "merge-pr-merge"));
|
|
||||||
allActions.set("merge-pr-rebase", buildMergePrAction(input, "merge-pr-rebase"));
|
|
||||||
|
|
||||||
allActions.set("merge-branch", {
|
allActions.set("merge-branch", {
|
||||||
id: "merge-branch",
|
id: "merge-branch",
|
||||||
@@ -209,7 +310,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
|||||||
|
|
||||||
const secondaryIds = [...REMOTE_ACTION_IDS];
|
const secondaryIds = [...REMOTE_ACTION_IDS];
|
||||||
if (!input.isOnBaseBranch) {
|
if (!input.isOnBaseBranch) {
|
||||||
secondaryIds.push(...FEATURE_ACTION_IDS);
|
secondaryIds.push(...getFeatureActionIds(input));
|
||||||
}
|
}
|
||||||
if (input.isPaseoOwnedWorktree) {
|
if (input.isPaseoOwnedWorktree) {
|
||||||
secondaryIds.push("archive-worktree");
|
secondaryIds.push("archive-worktree");
|
||||||
@@ -239,7 +340,10 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
|||||||
return "merge-from-base";
|
return "merge-from-base";
|
||||||
}
|
}
|
||||||
if (canMergePr(input) && input.shipDefault === "pr") {
|
if (canMergePr(input) && input.shipDefault === "pr") {
|
||||||
return "merge-pr-squash";
|
return getDefaultDirectPullRequestMergeActionId(input);
|
||||||
|
}
|
||||||
|
if (canEnablePrAutoMerge(input) && input.shipDefault === "pr") {
|
||||||
|
return getDefaultEnablePullRequestAutoMergeActionId(input);
|
||||||
}
|
}
|
||||||
if (!input.isOnBaseBranch && input.aheadCount > 0 && input.shipDefault === "merge") {
|
if (!input.isOnBaseBranch && input.aheadCount > 0 && input.shipDefault === "merge") {
|
||||||
return "merge-branch";
|
return "merge-branch";
|
||||||
@@ -253,6 +357,41 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPullRequestActionIds(filter: {
|
||||||
|
roles: readonly PullRequestActionRole[];
|
||||||
|
input: BuildGitActionsInput;
|
||||||
|
}): PullRequestActionId[] {
|
||||||
|
return PULL_REQUEST_ACTION_MODELS.filter((model) => filter.roles.includes(model.role))
|
||||||
|
.filter((model) => shouldShowPullRequestAction(filter.input, model.id))
|
||||||
|
.map((model) => model.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFeatureActionIds(input: BuildGitActionsInput): GitActionId[] {
|
||||||
|
return [
|
||||||
|
"merge-from-base",
|
||||||
|
"merge-branch",
|
||||||
|
...getPullRequestActionIds({ roles: ["status", "direct", "auto"], input }),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultDirectPullRequestMergeActionId(
|
||||||
|
input: BuildGitActionsInput,
|
||||||
|
): PullRequestDirectMergeActionId {
|
||||||
|
return (
|
||||||
|
getPreferredDirectPullRequestMergeActionModel(input)?.id ??
|
||||||
|
PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS[0].id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultEnablePullRequestAutoMergeActionId(
|
||||||
|
input: BuildGitActionsInput,
|
||||||
|
): PullRequestAutoMergeEnableActionId {
|
||||||
|
return (
|
||||||
|
getPreferredEnablePullRequestAutoMergeActionModel(input)?.id ??
|
||||||
|
PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS[0].id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildPrAction(input: BuildGitActionsInput): GitAction {
|
function buildPrAction(input: BuildGitActionsInput): GitAction {
|
||||||
if (input.hasPullRequest && input.pullRequestUrl) {
|
if (input.hasPullRequest && input.pullRequestUrl) {
|
||||||
return {
|
return {
|
||||||
@@ -288,23 +427,60 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMergePrAction(
|
function buildDirectPullRequestMergeAction(
|
||||||
input: BuildGitActionsInput,
|
input: BuildGitActionsInput,
|
||||||
id: "merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase",
|
model: PullRequestDirectMergeActionModel,
|
||||||
): GitAction {
|
): GitAction {
|
||||||
const runtime = input.runtime[id];
|
const runtime = input.runtime[model.id];
|
||||||
const config = MERGE_PR_METHODS[id];
|
|
||||||
const unavailableMessage = getMergePrUnavailableMessage(input);
|
const unavailableMessage = getMergePrUnavailableMessage(input);
|
||||||
return {
|
return {
|
||||||
id,
|
id: model.id,
|
||||||
label: config.label,
|
label: model.label,
|
||||||
pendingLabel: "Merging PR...",
|
pendingLabel: "Merging PR...",
|
||||||
successLabel: "PR merged",
|
successLabel: "PR merged",
|
||||||
disabled: runtime.disabled || shouldDisableMergePrAction(input),
|
disabled: runtime.disabled || shouldDisableMergePrAction(input),
|
||||||
status: runtime.status,
|
status: runtime.status,
|
||||||
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
|
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
|
||||||
icon: runtime.icon,
|
icon: runtime.icon,
|
||||||
startsGroup: id === "merge-pr-squash",
|
startsGroup: model.startsGroup,
|
||||||
|
handler: runtime.handler,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEnablePullRequestAutoMergeAction(
|
||||||
|
input: BuildGitActionsInput,
|
||||||
|
model: PullRequestAutoMergeEnableActionModel,
|
||||||
|
): GitAction {
|
||||||
|
const runtime = input.runtime[model.id];
|
||||||
|
return {
|
||||||
|
id: model.id,
|
||||||
|
label: model.label,
|
||||||
|
pendingLabel: "Enabling auto-merge...",
|
||||||
|
successLabel: "Auto-merge enabled",
|
||||||
|
disabled: runtime.disabled,
|
||||||
|
status: runtime.status,
|
||||||
|
icon: runtime.icon,
|
||||||
|
startsGroup: model.startsGroup,
|
||||||
|
handler: runtime.handler,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): GitAction {
|
||||||
|
const runtime = input.runtime["disable-pr-auto-merge"];
|
||||||
|
const unavailableMessage =
|
||||||
|
input.pullRequestGithub?.viewerCanDisableAutoMerge === true
|
||||||
|
? undefined
|
||||||
|
: "Auto-merge is enabled, but this account can't disable it";
|
||||||
|
return {
|
||||||
|
id: "disable-pr-auto-merge",
|
||||||
|
label: "Auto-merge enabled",
|
||||||
|
pendingLabel: "Disabling auto-merge...",
|
||||||
|
successLabel: "Auto-merge disabled",
|
||||||
|
disabled: runtime.disabled || input.pullRequestGithub?.viewerCanDisableAutoMerge !== true,
|
||||||
|
status: runtime.status,
|
||||||
|
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
|
||||||
|
icon: runtime.icon,
|
||||||
|
startsGroup: true,
|
||||||
handler: runtime.handler,
|
handler: runtime.handler,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -327,18 +503,55 @@ function canMergeFromBase(input: BuildGitActionsInput): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canMergePr(input: BuildGitActionsInput): boolean {
|
function canMergePr(input: BuildGitActionsInput): boolean {
|
||||||
return (
|
const github = input.pullRequestGithub;
|
||||||
|
const canMergeFromTopLevelStatus =
|
||||||
input.githubFeaturesEnabled &&
|
input.githubFeaturesEnabled &&
|
||||||
input.hasPullRequest &&
|
input.hasPullRequest &&
|
||||||
input.pullRequestState === "open" &&
|
input.pullRequestState === "open" &&
|
||||||
!input.pullRequestIsDraft &&
|
!input.pullRequestIsDraft &&
|
||||||
!input.pullRequestIsMerged &&
|
!input.pullRequestIsMerged &&
|
||||||
input.pullRequestMergeable === "MERGEABLE" &&
|
input.pullRequestMergeable !== "CONFLICTING" &&
|
||||||
input.aheadCount > 0 &&
|
input.aheadCount > 0 &&
|
||||||
!input.hasUncommittedChanges &&
|
!input.hasUncommittedChanges &&
|
||||||
input.behindOfOrigin === 0 &&
|
input.behindOfOrigin === 0 &&
|
||||||
input.aheadOfOrigin === 0 &&
|
input.aheadOfOrigin === 0 &&
|
||||||
!canMergeFromBase(input)
|
!canMergeFromBase(input);
|
||||||
|
|
||||||
|
if (!canMergeFromTopLevelStatus) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPullRequestGithubFacts(github)) {
|
||||||
|
return input.pullRequestMergeable === "MERGEABLE";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? "") &&
|
||||||
|
github.autoMergeRequest === null &&
|
||||||
|
!github.isMergeQueueEnabled &&
|
||||||
|
!github.isInMergeQueue &&
|
||||||
|
getAllowedDirectPullRequestMergeActionModels(input).length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canEnablePrAutoMerge(input: BuildGitActionsInput): boolean {
|
||||||
|
const github = input.pullRequestGithub;
|
||||||
|
return (
|
||||||
|
input.githubFeaturesEnabled &&
|
||||||
|
input.githubAutoMergeActionsEnabled &&
|
||||||
|
input.hasPullRequest &&
|
||||||
|
input.pullRequestState === "open" &&
|
||||||
|
!input.pullRequestIsDraft &&
|
||||||
|
!input.pullRequestIsMerged &&
|
||||||
|
input.pullRequestMergeable !== "CONFLICTING" &&
|
||||||
|
hasPullRequestGithubFacts(github) &&
|
||||||
|
github.autoMergeRequest === null &&
|
||||||
|
github.mergeStateStatus === "BLOCKED" &&
|
||||||
|
github.repository.autoMergeAllowed &&
|
||||||
|
github.viewerCanEnableAutoMerge &&
|
||||||
|
!github.isMergeQueueEnabled &&
|
||||||
|
!github.isInMergeQueue &&
|
||||||
|
getAllowedAutoMergeEnableActionModels(input).length > 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,14 +649,131 @@ function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | und
|
|||||||
if (input.pullRequestMergeable === "CONFLICTING") {
|
if (input.pullRequestMergeable === "CONFLICTING") {
|
||||||
return "Merge PR isn't available because the pull request has conflicts";
|
return "Merge PR isn't available because the pull request has conflicts";
|
||||||
}
|
}
|
||||||
|
if (!hasPullRequestGithubFacts(input.pullRequestGithub)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (input.pullRequestGithub?.isMergeQueueEnabled || input.pullRequestGithub?.isInMergeQueue) {
|
||||||
|
return "Merge PR isn't available here because this repository uses a merge queue";
|
||||||
|
}
|
||||||
|
if (!GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(input.pullRequestGithub?.mergeStateStatus ?? "")) {
|
||||||
|
return "Merge PR isn't available until GitHub reports the pull request is ready to merge";
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldDisableMergePrAction(input: BuildGitActionsInput): boolean {
|
function shouldDisableMergePrAction(input: BuildGitActionsInput): boolean {
|
||||||
return (
|
return !canMergePr(input);
|
||||||
input.pullRequestIsDraft ||
|
}
|
||||||
input.pullRequestIsMerged ||
|
|
||||||
input.pullRequestState === "closed" ||
|
function shouldShowPullRequestAction(
|
||||||
input.pullRequestMergeable === "CONFLICTING"
|
input: BuildGitActionsInput,
|
||||||
|
id: PullRequestActionId,
|
||||||
|
): boolean {
|
||||||
|
if (id === "pr") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (id === "disable-pr-auto-merge") {
|
||||||
|
return (
|
||||||
|
input.githubAutoMergeActionsEnabled &&
|
||||||
|
hasPullRequestGithubFacts(input.pullRequestGithub) &&
|
||||||
|
input.pullRequestGithub.autoMergeRequest !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isDirectPullRequestMergeActionId(id)) {
|
||||||
|
return canMergePr(input) && getAllowedDirectPullRequestMergeActionIds(input).includes(id);
|
||||||
|
}
|
||||||
|
if (isEnablePullRequestAutoMergeActionId(id)) {
|
||||||
|
return canEnablePrAutoMerge(input) && getAllowedAutoMergeEnableActionIds(input).includes(id);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectPullRequestMergeActionId(
|
||||||
|
id: PullRequestActionId,
|
||||||
|
): id is PullRequestDirectMergeActionId {
|
||||||
|
return PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS.some((model) => model.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnablePullRequestAutoMergeActionId(
|
||||||
|
id: PullRequestActionId,
|
||||||
|
): id is PullRequestAutoMergeEnableActionId {
|
||||||
|
return PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS.some((model) => model.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllowedDirectPullRequestMergeActionIds(
|
||||||
|
input: BuildGitActionsInput,
|
||||||
|
): PullRequestDirectMergeActionId[] {
|
||||||
|
return getAllowedDirectPullRequestMergeActionModels(input).map((model) => model.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllowedAutoMergeEnableActionIds(
|
||||||
|
input: BuildGitActionsInput,
|
||||||
|
): PullRequestAutoMergeEnableActionId[] {
|
||||||
|
return getAllowedAutoMergeEnableActionModels(input).map((model) => model.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllowedDirectPullRequestMergeActionModels(
|
||||||
|
input: BuildGitActionsInput,
|
||||||
|
): readonly PullRequestDirectMergeActionModel[] {
|
||||||
|
return PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS.filter((model) =>
|
||||||
|
isPullRequestMergeMethodAllowed(input, model.method),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAllowedAutoMergeEnableActionModels(
|
||||||
|
input: BuildGitActionsInput,
|
||||||
|
): readonly PullRequestAutoMergeEnableActionModel[] {
|
||||||
|
return PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS.filter((model) =>
|
||||||
|
isPullRequestMergeMethodAllowed(input, model.method),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreferredDirectPullRequestMergeActionModel(
|
||||||
|
input: BuildGitActionsInput,
|
||||||
|
): PullRequestDirectMergeActionModel | null {
|
||||||
|
const allowed = getAllowedDirectPullRequestMergeActionModels(input);
|
||||||
|
const preferred = normalizeGithubMergeMethod(
|
||||||
|
input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null,
|
||||||
|
);
|
||||||
|
return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreferredEnablePullRequestAutoMergeActionModel(
|
||||||
|
input: BuildGitActionsInput,
|
||||||
|
): PullRequestAutoMergeEnableActionModel | null {
|
||||||
|
const allowed = getAllowedAutoMergeEnableActionModels(input);
|
||||||
|
const preferred = normalizeGithubMergeMethod(
|
||||||
|
input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null,
|
||||||
|
);
|
||||||
|
return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPullRequestMergeMethodAllowed(
|
||||||
|
input: BuildGitActionsInput,
|
||||||
|
method: CheckoutPrMergeMethod,
|
||||||
|
): boolean {
|
||||||
|
const repository = input.pullRequestGithub?.repository;
|
||||||
|
if (!repository) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "squash") {
|
||||||
|
return repository.squashMergeAllowed;
|
||||||
|
}
|
||||||
|
if (method === "merge") {
|
||||||
|
return repository.mergeCommitAllowed;
|
||||||
|
}
|
||||||
|
return repository.rebaseMergeAllowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPullRequestGithubFacts(
|
||||||
|
github: PullRequestGithubStatus | null,
|
||||||
|
): github is NonNullable<PullRequestGithubStatus> {
|
||||||
|
return github !== null && github !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGithubMergeMethod(value: string | null): CheckoutPrMergeMethod | null {
|
||||||
|
if (value === "SQUASH") return "squash";
|
||||||
|
if (value === "MERGE") return "merge";
|
||||||
|
if (value === "REBASE") return "rebase";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,24 @@ import {
|
|||||||
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
||||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||||
|
|
||||||
|
const githubStatus: CheckoutPrStatus["github"] = {
|
||||||
|
mergeStateStatus: null,
|
||||||
|
autoMergeRequest: null,
|
||||||
|
viewerCanEnableAutoMerge: false,
|
||||||
|
viewerCanDisableAutoMerge: false,
|
||||||
|
viewerCanMergeAsAdmin: false,
|
||||||
|
viewerCanUpdateBranch: false,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: false,
|
||||||
|
mergeCommitAllowed: false,
|
||||||
|
squashMergeAllowed: false,
|
||||||
|
rebaseMergeAllowed: false,
|
||||||
|
viewerDefaultMergeMethod: null,
|
||||||
|
},
|
||||||
|
isMergeQueueEnabled: false,
|
||||||
|
isInMergeQueue: false,
|
||||||
|
};
|
||||||
|
|
||||||
const baseStatus: CheckoutPrStatus = {
|
const baseStatus: CheckoutPrStatus = {
|
||||||
number: 42,
|
number: 42,
|
||||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||||
@@ -26,6 +44,7 @@ const baseStatus: CheckoutPrStatus = {
|
|||||||
mergeable: "UNKNOWN",
|
mergeable: "UNKNOWN",
|
||||||
checks: [],
|
checks: [],
|
||||||
reviewDecision: null,
|
reviewDecision: null,
|
||||||
|
github: githubStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseTimeline: PullRequestTimeline = {
|
const baseTimeline: PullRequestTimeline = {
|
||||||
|
|||||||
@@ -246,6 +246,20 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
s.getStatus({ serverId, cwd, actionId: "merge-pr-rebase" }),
|
s.getStatus({ serverId, cwd, actionId: "merge-pr-rebase" }),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
const enablePrAutoMergeStatuses: Record<CheckoutPrMergeMethod, CheckoutGitActionStatus> = {
|
||||||
|
squash: useCheckoutGitActionsStore((s) =>
|
||||||
|
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
|
||||||
|
),
|
||||||
|
merge: useCheckoutGitActionsStore((s) =>
|
||||||
|
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
|
||||||
|
),
|
||||||
|
rebase: useCheckoutGitActionsStore((s) =>
|
||||||
|
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-rebase" }),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const disablePrAutoMergeStatus = useCheckoutGitActionsStore((s) =>
|
||||||
|
s.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
|
||||||
|
);
|
||||||
const mergeStatus = useCheckoutGitActionsStore((s) =>
|
const mergeStatus = useCheckoutGitActionsStore((s) =>
|
||||||
s.getStatus({ serverId, cwd, actionId: "merge-branch" }),
|
s.getStatus({ serverId, cwd, actionId: "merge-branch" }),
|
||||||
);
|
);
|
||||||
@@ -262,9 +276,14 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
const runPullAndPush = useCheckoutGitActionsStore((s) => s.pullAndPush);
|
const runPullAndPush = useCheckoutGitActionsStore((s) => s.pullAndPush);
|
||||||
const runCreatePr = useCheckoutGitActionsStore((s) => s.createPr);
|
const runCreatePr = useCheckoutGitActionsStore((s) => s.createPr);
|
||||||
const runMergePr = useCheckoutGitActionsStore((s) => s.mergePr);
|
const runMergePr = useCheckoutGitActionsStore((s) => s.mergePr);
|
||||||
|
const runEnablePrAutoMerge = useCheckoutGitActionsStore((s) => s.enablePrAutoMerge);
|
||||||
|
const runDisablePrAutoMerge = useCheckoutGitActionsStore((s) => s.disablePrAutoMerge);
|
||||||
const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch);
|
const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch);
|
||||||
const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase);
|
const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase);
|
||||||
const runArchiveWorktree = useCheckoutGitActionsStore((s) => s.archiveWorktree);
|
const runArchiveWorktree = useCheckoutGitActionsStore((s) => s.archiveWorktree);
|
||||||
|
const githubAutoMergeActionsEnabled = useSessionStore(
|
||||||
|
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true,
|
||||||
|
);
|
||||||
|
|
||||||
const toastActionError = useCallback(
|
const toastActionError = useCallback(
|
||||||
(error: unknown, fallback: string) => {
|
(error: unknown, fallback: string) => {
|
||||||
@@ -354,6 +373,32 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
[cwd, persistShipDefault, runMergePr, serverId, toastActionError, toastActionSuccess],
|
[cwd, persistShipDefault, runMergePr, serverId, toastActionError, toastActionSuccess],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleEnablePrAutoMerge = useCallback(
|
||||||
|
(method: CheckoutPrMergeMethod) => {
|
||||||
|
void persistShipDefault("pr");
|
||||||
|
void runEnablePrAutoMerge({ serverId, cwd, method })
|
||||||
|
.then(() => {
|
||||||
|
toastActionSuccess("Auto-merge enabled");
|
||||||
|
return;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toastActionError(err, "Failed to enable auto-merge");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[cwd, persistShipDefault, runEnablePrAutoMerge, serverId, toastActionError, toastActionSuccess],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDisablePrAutoMerge = useCallback(() => {
|
||||||
|
void runDisablePrAutoMerge({ serverId, cwd })
|
||||||
|
.then(() => {
|
||||||
|
toastActionSuccess("Auto-merge disabled");
|
||||||
|
return;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toastActionError(err, "Failed to disable auto-merge");
|
||||||
|
});
|
||||||
|
}, [cwd, runDisablePrAutoMerge, serverId, toastActionError, toastActionSuccess]);
|
||||||
|
|
||||||
const handleMergeBranch = useCallback(() => {
|
const handleMergeBranch = useCallback(() => {
|
||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
toast.error("Base ref unavailable");
|
toast.error("Base ref unavailable");
|
||||||
@@ -484,12 +529,14 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
return buildGitActions({
|
return buildGitActions({
|
||||||
isGit,
|
isGit,
|
||||||
githubFeaturesEnabled,
|
githubFeaturesEnabled,
|
||||||
|
githubAutoMergeActionsEnabled,
|
||||||
hasPullRequest,
|
hasPullRequest,
|
||||||
pullRequestUrl: prStatus?.url ?? null,
|
pullRequestUrl: prStatus?.url ?? null,
|
||||||
pullRequestState: narrowPullRequestState(prStatus?.state),
|
pullRequestState: narrowPullRequestState(prStatus?.state),
|
||||||
pullRequestIsDraft: prStatus?.isDraft ?? false,
|
pullRequestIsDraft: prStatus?.isDraft ?? false,
|
||||||
pullRequestIsMerged: prStatus?.isMerged ?? false,
|
pullRequestIsMerged: prStatus?.isMerged ?? false,
|
||||||
pullRequestMergeable: prStatus?.mergeable ?? "UNKNOWN",
|
pullRequestMergeable: prStatus?.mergeable ?? "UNKNOWN",
|
||||||
|
pullRequestGithub: prStatus?.github ?? null,
|
||||||
hasRemote,
|
hasRemote,
|
||||||
isPaseoOwnedWorktree,
|
isPaseoOwnedWorktree,
|
||||||
isOnBaseBranch,
|
isOnBaseBranch,
|
||||||
@@ -551,6 +598,30 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
icon: icons.mergePrRebase,
|
icon: icons.mergePrRebase,
|
||||||
handler: () => handleMergePr("rebase"),
|
handler: () => handleMergePr("rebase"),
|
||||||
},
|
},
|
||||||
|
"enable-pr-auto-merge-squash": {
|
||||||
|
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.squash),
|
||||||
|
status: enablePrAutoMergeStatuses.squash,
|
||||||
|
icon: icons.mergePrSquash,
|
||||||
|
handler: () => handleEnablePrAutoMerge("squash"),
|
||||||
|
},
|
||||||
|
"enable-pr-auto-merge-merge": {
|
||||||
|
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.merge),
|
||||||
|
status: enablePrAutoMergeStatuses.merge,
|
||||||
|
icon: icons.mergePrMerge,
|
||||||
|
handler: () => handleEnablePrAutoMerge("merge"),
|
||||||
|
},
|
||||||
|
"enable-pr-auto-merge-rebase": {
|
||||||
|
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.rebase),
|
||||||
|
status: enablePrAutoMergeStatuses.rebase,
|
||||||
|
icon: icons.mergePrRebase,
|
||||||
|
handler: () => handleEnablePrAutoMerge("rebase"),
|
||||||
|
},
|
||||||
|
"disable-pr-auto-merge": {
|
||||||
|
disabled: isActionDisabled(actionsDisabled, disablePrAutoMergeStatus),
|
||||||
|
status: disablePrAutoMergeStatus,
|
||||||
|
icon: icons.viewPr,
|
||||||
|
handler: handleDisablePrAutoMerge,
|
||||||
|
},
|
||||||
"merge-branch": {
|
"merge-branch": {
|
||||||
disabled: isActionDisabled(actionsDisabled, mergeStatus),
|
disabled: isActionDisabled(actionsDisabled, mergeStatus),
|
||||||
status: mergeStatus,
|
status: mergeStatus,
|
||||||
@@ -580,11 +651,13 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
prStatus?.isDraft,
|
prStatus?.isDraft,
|
||||||
prStatus?.isMerged,
|
prStatus?.isMerged,
|
||||||
prStatus?.mergeable,
|
prStatus?.mergeable,
|
||||||
|
prStatus?.github,
|
||||||
aheadCount,
|
aheadCount,
|
||||||
behindBaseCount,
|
behindBaseCount,
|
||||||
isPaseoOwnedWorktree,
|
isPaseoOwnedWorktree,
|
||||||
isOnBaseBranch,
|
isOnBaseBranch,
|
||||||
githubFeaturesEnabled,
|
githubFeaturesEnabled,
|
||||||
|
githubAutoMergeActionsEnabled,
|
||||||
hasUncommittedChanges,
|
hasUncommittedChanges,
|
||||||
aheadOfOrigin,
|
aheadOfOrigin,
|
||||||
behindOfOrigin,
|
behindOfOrigin,
|
||||||
@@ -600,6 +673,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
mergePrStatuses.squash,
|
mergePrStatuses.squash,
|
||||||
mergePrStatuses.merge,
|
mergePrStatuses.merge,
|
||||||
mergePrStatuses.rebase,
|
mergePrStatuses.rebase,
|
||||||
|
enablePrAutoMergeStatuses.squash,
|
||||||
|
enablePrAutoMergeStatuses.merge,
|
||||||
|
enablePrAutoMergeStatuses.rebase,
|
||||||
|
disablePrAutoMergeStatus,
|
||||||
mergeStatus,
|
mergeStatus,
|
||||||
mergeFromBaseStatus,
|
mergeFromBaseStatus,
|
||||||
archiveStatus,
|
archiveStatus,
|
||||||
@@ -609,6 +686,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
handlePullAndPush,
|
handlePullAndPush,
|
||||||
handlePrAction,
|
handlePrAction,
|
||||||
handleMergePr,
|
handleMergePr,
|
||||||
|
handleEnablePrAutoMerge,
|
||||||
|
handleDisablePrAutoMerge,
|
||||||
handleMergeBranch,
|
handleMergeBranch,
|
||||||
handleMergeFromBase,
|
handleMergeFromBase,
|
||||||
handleArchiveWorktree,
|
handleArchiveWorktree,
|
||||||
|
|||||||
@@ -54,6 +54,24 @@ vi.mock("@/runtime/host-runtime", () => ({
|
|||||||
const cwd = "/repo";
|
const cwd = "/repo";
|
||||||
const serverId = "server-1";
|
const serverId = "server-1";
|
||||||
|
|
||||||
|
const githubStatus: CheckoutPrStatus["github"] = {
|
||||||
|
mergeStateStatus: null,
|
||||||
|
autoMergeRequest: null,
|
||||||
|
viewerCanEnableAutoMerge: false,
|
||||||
|
viewerCanDisableAutoMerge: false,
|
||||||
|
viewerCanMergeAsAdmin: false,
|
||||||
|
viewerCanUpdateBranch: false,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: false,
|
||||||
|
mergeCommitAllowed: false,
|
||||||
|
squashMergeAllowed: false,
|
||||||
|
rebaseMergeAllowed: false,
|
||||||
|
viewerDefaultMergeMethod: null,
|
||||||
|
},
|
||||||
|
isMergeQueueEnabled: false,
|
||||||
|
isInMergeQueue: false,
|
||||||
|
};
|
||||||
|
|
||||||
function status(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
|
function status(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
|
||||||
return {
|
return {
|
||||||
number: 42,
|
number: 42,
|
||||||
@@ -69,6 +87,7 @@ function status(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
|
|||||||
reviewDecision: null,
|
reviewDecision: null,
|
||||||
repoOwner: "getpaseo",
|
repoOwner: "getpaseo",
|
||||||
repoName: "paseo",
|
repoName: "paseo",
|
||||||
|
github: githubStatus,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1586,6 +1586,118 @@ test("requests checkout merge from base via RPC", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("requests GitHub auto-merge enable via namespaced RPC", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_unit_test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const promise = client.checkoutGithubSetAutoMerge(
|
||||||
|
"/tmp/project",
|
||||||
|
{ enabled: true, method: "squash" },
|
||||||
|
"req-enable-auto-merge",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mock.sent).toHaveLength(1);
|
||||||
|
const request = parseSentFrame(mock.sent[0]);
|
||||||
|
expect(request.type).toBe("checkout.github.set_auto_merge.request");
|
||||||
|
expect(request.cwd).toBe("/tmp/project");
|
||||||
|
expect(request.enabled).toBe(true);
|
||||||
|
expect(request.mergeMethod).toBe("squash");
|
||||||
|
expect(request.requestId).toBe("req-enable-auto-merge");
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "checkout.github.set_auto_merge.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
enabled: true,
|
||||||
|
requestId: "req-enable-auto-merge",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toEqual({
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
enabled: true,
|
||||||
|
requestId: "req-enable-auto-merge",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requests GitHub auto-merge disable via namespaced RPC", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_unit_test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const promise = client.checkoutGithubSetAutoMerge(
|
||||||
|
"/tmp/project",
|
||||||
|
{ enabled: false },
|
||||||
|
"req-disable-auto-merge",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mock.sent).toHaveLength(1);
|
||||||
|
const request = parseSentFrame(mock.sent[0]);
|
||||||
|
expect(request.type).toBe("checkout.github.set_auto_merge.request");
|
||||||
|
expect(request.cwd).toBe("/tmp/project");
|
||||||
|
expect(request.enabled).toBe(false);
|
||||||
|
expect(request.mergeMethod).toBeUndefined();
|
||||||
|
expect(request.requestId).toBe("req-disable-auto-merge");
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "checkout.github.set_auto_merge.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
enabled: false,
|
||||||
|
requestId: "req-disable-auto-merge",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toEqual({
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
enabled: false,
|
||||||
|
requestId: "req-disable-auto-merge",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("requests checkout pull via RPC", async () => {
|
test("requests checkout pull via RPC", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import type {
|
|||||||
CheckoutPrCreateResponse,
|
CheckoutPrCreateResponse,
|
||||||
CheckoutPrMergeResponse,
|
CheckoutPrMergeResponse,
|
||||||
CheckoutPrMergeMethod,
|
CheckoutPrMergeMethod,
|
||||||
|
CheckoutGithubSetAutoMergeResponse,
|
||||||
CheckoutPrStatusResponse,
|
CheckoutPrStatusResponse,
|
||||||
PullRequestTimelineResponse,
|
PullRequestTimelineResponse,
|
||||||
CheckoutSwitchBranchResponse,
|
CheckoutSwitchBranchResponse,
|
||||||
@@ -274,6 +275,7 @@ type CheckoutPullPayload = CheckoutPullResponse["payload"];
|
|||||||
type CheckoutPushPayload = CheckoutPushResponse["payload"];
|
type CheckoutPushPayload = CheckoutPushResponse["payload"];
|
||||||
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
||||||
type CheckoutPrMergePayload = CheckoutPrMergeResponse["payload"];
|
type CheckoutPrMergePayload = CheckoutPrMergeResponse["payload"];
|
||||||
|
type CheckoutGithubSetAutoMergePayload = CheckoutGithubSetAutoMergeResponse["payload"];
|
||||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||||
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
|
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
|
||||||
type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"];
|
type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"];
|
||||||
@@ -1425,6 +1427,25 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sendNamespacedCorrelatedSessionRequest<
|
||||||
|
TResponseType extends CorrelatedResponseType,
|
||||||
|
TResult = CorrelatedResponsePayload<TResponseType>,
|
||||||
|
>(params: {
|
||||||
|
requestId?: string;
|
||||||
|
message: { type: Extract<SessionInboundMessage["type"], `${string}.request`> } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
timeout: number;
|
||||||
|
selectPayload?: (payload: CorrelatedResponsePayload<TResponseType>) => TResult | null;
|
||||||
|
}): Promise<TResult> {
|
||||||
|
const responseType = params.message.type.replace(/\.request$/, ".response") as TResponseType;
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
...params,
|
||||||
|
responseType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private sendSessionMessageStrict(message: SessionInboundMessage): void {
|
private sendSessionMessageStrict(message: SessionInboundMessage): void {
|
||||||
if (!this.transport || this.connectionState.status !== "connected") {
|
if (!this.transport || this.connectionState.status !== "connected") {
|
||||||
throw new Error("Transport not connected");
|
throw new Error("Transport not connected");
|
||||||
@@ -2817,6 +2838,23 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkoutGithubSetAutoMerge(
|
||||||
|
cwd: string,
|
||||||
|
input: { enabled: true; method: CheckoutPrMergeMethod } | { enabled: false },
|
||||||
|
requestId?: string,
|
||||||
|
): Promise<CheckoutGithubSetAutoMergePayload> {
|
||||||
|
return this.sendNamespacedCorrelatedSessionRequest<"checkout.github.set_auto_merge.response">({
|
||||||
|
requestId,
|
||||||
|
message: {
|
||||||
|
type: "checkout.github.set_auto_merge.request",
|
||||||
|
cwd,
|
||||||
|
enabled: input.enabled,
|
||||||
|
...(input.enabled ? { mergeMethod: input.method } : {}),
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async checkoutPrStatus(cwd: string, requestId?: string): Promise<CheckoutPrStatusPayload> {
|
async checkoutPrStatus(cwd: string, requestId?: string): Promise<CheckoutPrStatusPayload> {
|
||||||
return this.sendCorrelatedSessionRequest({
|
return this.sendCorrelatedSessionRequest({
|
||||||
requestId,
|
requestId,
|
||||||
|
|||||||
@@ -35,4 +35,63 @@ describe("checkout status projection", () => {
|
|||||||
expect(payload).toHaveProperty("mergeable", "MERGEABLE");
|
expect(payload).toHaveProperty("mergeable", "MERGEABLE");
|
||||||
expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload);
|
expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("projects PR 993 GitHub merge facts without changing top-level status fields", () => {
|
||||||
|
const payload = normalizeCheckoutPrStatusPayload({
|
||||||
|
number: 993,
|
||||||
|
repoOwner: "getpaseo",
|
||||||
|
repoName: "paseo",
|
||||||
|
url: "https://github.com/getpaseo/paseo/pull/993",
|
||||||
|
title: "Auto-merge UX",
|
||||||
|
state: "open",
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "github-pr-auto-merge-ux",
|
||||||
|
isMerged: false,
|
||||||
|
isDraft: false,
|
||||||
|
mergeable: "MERGEABLE",
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
name: "server tests",
|
||||||
|
status: "pending",
|
||||||
|
url: "https://github.com/getpaseo/paseo/actions/runs/993",
|
||||||
|
workflow: "CI",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checksStatus: "pending",
|
||||||
|
reviewDecision: "approved",
|
||||||
|
github: {
|
||||||
|
mergeStateStatus: "BLOCKED",
|
||||||
|
autoMergeRequest: null,
|
||||||
|
viewerCanEnableAutoMerge: true,
|
||||||
|
viewerCanDisableAutoMerge: false,
|
||||||
|
viewerCanMergeAsAdmin: false,
|
||||||
|
viewerCanUpdateBranch: true,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: false,
|
||||||
|
squashMergeAllowed: true,
|
||||||
|
rebaseMergeAllowed: false,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
},
|
||||||
|
isMergeQueueEnabled: false,
|
||||||
|
isInMergeQueue: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
number: 993,
|
||||||
|
mergeable: "MERGEABLE",
|
||||||
|
checksStatus: "pending",
|
||||||
|
github: {
|
||||||
|
mergeStateStatus: "BLOCKED",
|
||||||
|
viewerCanEnableAutoMerge: true,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
squashMergeAllowed: true,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function normalizeCheckoutPrStatusPayload(
|
|||||||
if (!status) {
|
if (!status) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
const payload: CheckoutPrStatusPayloadStatus = {
|
||||||
number: status.number,
|
number: status.number,
|
||||||
url: status.url,
|
url: status.url,
|
||||||
title: status.title,
|
title: status.title,
|
||||||
@@ -131,4 +131,8 @@ export function normalizeCheckoutPrStatusPayload(
|
|||||||
checksStatus: status.checksStatus,
|
checksStatus: status.checksStatus,
|
||||||
reviewDecision: status.reviewDecision,
|
reviewDecision: status.reviewDecision,
|
||||||
};
|
};
|
||||||
|
if (status.github) {
|
||||||
|
payload.github = status.github;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
createProviderSnapshotManagerStub,
|
createProviderSnapshotManagerStub,
|
||||||
} from "./test-utils/session-stubs.js";
|
} from "./test-utils/session-stubs.js";
|
||||||
import { isPlatform } from "../test-utils/platform.js";
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
|
import type { GitHubPullRequestStatusFacts } from "../services/github-service.js";
|
||||||
|
|
||||||
interface SessionHandlerInternals {
|
interface SessionHandlerInternals {
|
||||||
startVoiceTurnController(): Promise<void>;
|
startVoiceTurnController(): Promise<void>;
|
||||||
@@ -64,6 +65,7 @@ interface SessionHandlerInternals {
|
|||||||
handleCheckoutCommitRequest(params: unknown): Promise<unknown>;
|
handleCheckoutCommitRequest(params: unknown): Promise<unknown>;
|
||||||
handleCheckoutPrCreateRequest(params: unknown): Promise<unknown>;
|
handleCheckoutPrCreateRequest(params: unknown): Promise<unknown>;
|
||||||
handleCheckoutPrMergeRequest(params: unknown): Promise<unknown>;
|
handleCheckoutPrMergeRequest(params: unknown): Promise<unknown>;
|
||||||
|
handleCheckoutGithubSetAutoMergeRequest(params: unknown): Promise<unknown>;
|
||||||
handleCheckoutPullRequest(params: unknown): Promise<unknown>;
|
handleCheckoutPullRequest(params: unknown): Promise<unknown>;
|
||||||
handleCheckoutPushRequest(params: unknown): Promise<unknown>;
|
handleCheckoutPushRequest(params: unknown): Promise<unknown>;
|
||||||
handleCheckoutStatusRequest(params: unknown): Promise<unknown>;
|
handleCheckoutStatusRequest(params: unknown): Promise<unknown>;
|
||||||
@@ -1971,6 +1973,23 @@ describe("session checkout pull request merge", () => {
|
|||||||
github: {
|
github: {
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
number: 42,
|
number: 42,
|
||||||
|
github: {
|
||||||
|
mergeStateStatus: "CLEAN",
|
||||||
|
autoMergeRequest: null,
|
||||||
|
viewerCanEnableAutoMerge: false,
|
||||||
|
viewerCanDisableAutoMerge: false,
|
||||||
|
viewerCanMergeAsAdmin: false,
|
||||||
|
viewerCanUpdateBranch: false,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: true,
|
||||||
|
squashMergeAllowed: true,
|
||||||
|
rebaseMergeAllowed: true,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
},
|
||||||
|
isMergeQueueEnabled: false,
|
||||||
|
isInMergeQueue: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -1988,8 +2007,33 @@ describe("session checkout pull request merge", () => {
|
|||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
prNumber: 42,
|
prNumber: 42,
|
||||||
mergeMethod: "squash",
|
mergeMethod: "squash",
|
||||||
|
status: {
|
||||||
|
number: 42,
|
||||||
|
github: {
|
||||||
|
mergeStateStatus: "CLEAN",
|
||||||
|
autoMergeRequest: null,
|
||||||
|
viewerCanEnableAutoMerge: false,
|
||||||
|
viewerCanDisableAutoMerge: false,
|
||||||
|
viewerCanMergeAsAdmin: false,
|
||||||
|
viewerCanUpdateBranch: false,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: true,
|
||||||
|
squashMergeAllowed: true,
|
||||||
|
rebaseMergeAllowed: true,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
},
|
||||||
|
isMergeQueueEnabled: false,
|
||||||
|
isInMergeQueue: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
|
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, "/tmp/request-worktree", {
|
||||||
|
force: true,
|
||||||
|
includeGitHub: true,
|
||||||
|
reason: "merge-pr-validation",
|
||||||
|
});
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, "/tmp/request-worktree", {
|
||||||
force: true,
|
force: true,
|
||||||
reason: "merge-pr",
|
reason: "merge-pr",
|
||||||
});
|
});
|
||||||
@@ -2005,6 +2049,131 @@ describe("session checkout pull request merge", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects direct merge when fresh GitHub facts block a warm clean snapshot", async () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const github = {
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
mergePullRequest: vi.fn(
|
||||||
|
async (input: { status?: { github?: { mergeStateStatus?: string | null } } }) => {
|
||||||
|
if (input.status?.github?.mergeStateStatus === "BLOCKED") {
|
||||||
|
throw new Error("GitHub does not report this pull request as ready for direct merge");
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const createSnapshot = (mergeStateStatus: "CLEAN" | "BLOCKED") => ({
|
||||||
|
github: {
|
||||||
|
pullRequest: {
|
||||||
|
number: 42,
|
||||||
|
github: {
|
||||||
|
mergeStateStatus,
|
||||||
|
autoMergeRequest: null,
|
||||||
|
viewerCanEnableAutoMerge: false,
|
||||||
|
viewerCanDisableAutoMerge: false,
|
||||||
|
viewerCanMergeAsAdmin: false,
|
||||||
|
viewerCanUpdateBranch: false,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: true,
|
||||||
|
squashMergeAllowed: true,
|
||||||
|
rebaseMergeAllowed: true,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
},
|
||||||
|
isMergeQueueEnabled: false,
|
||||||
|
isInMergeQueue: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const workspaceGitService = {
|
||||||
|
getSnapshot: vi.fn(async (_cwd: string, options?: { force?: boolean }) =>
|
||||||
|
createSnapshot(options?.force ? "BLOCKED" : "CLEAN"),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
|
await asSessionInternals(session).handleCheckoutPrMergeRequest({
|
||||||
|
type: "checkout_pr_merge_request",
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
mergeMethod: "squash",
|
||||||
|
requestId: "request-pr-merge-fresh-blocked",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
|
||||||
|
force: true,
|
||||||
|
includeGitHub: true,
|
||||||
|
reason: "merge-pr-validation",
|
||||||
|
});
|
||||||
|
expect(github.mergePullRequest).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: expect.objectContaining({
|
||||||
|
github: expect.objectContaining({ mergeStateStatus: "BLOCKED" }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(github.invalidate).not.toHaveBeenCalled();
|
||||||
|
expect(messages).toContainEqual({
|
||||||
|
type: "checkout_pr_merge_response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "UNKNOWN",
|
||||||
|
message: "GitHub does not report this pull request as ready for direct merge",
|
||||||
|
},
|
||||||
|
requestId: "request-pr-merge-fresh-blocked",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects direct merge when the current pull request is missing GitHub merge facts", async () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const github = {
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
mergePullRequest: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
};
|
||||||
|
const workspaceGitService = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
github: {
|
||||||
|
pullRequest: {
|
||||||
|
number: 42,
|
||||||
|
mergeable: "MERGEABLE",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
|
await asSessionInternals(session).handleCheckoutPrMergeRequest({
|
||||||
|
type: "checkout_pr_merge_request",
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
mergeMethod: "squash",
|
||||||
|
requestId: "request-pr-merge-missing-github-facts",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(github.mergePullRequest).not.toHaveBeenCalled();
|
||||||
|
expect(github.invalidate).not.toHaveBeenCalled();
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
|
||||||
|
force: true,
|
||||||
|
includeGitHub: true,
|
||||||
|
reason: "merge-pr-validation",
|
||||||
|
});
|
||||||
|
expect(messages).toContainEqual({
|
||||||
|
type: "checkout_pr_merge_response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "UNKNOWN",
|
||||||
|
message: "GitHub merge facts are unavailable for this pull request",
|
||||||
|
},
|
||||||
|
requestId: "request-pr-merge-missing-github-facts",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("surfaces merge errors verbatim", async () => {
|
test("surfaces merge errors verbatim", async () => {
|
||||||
const messages: unknown[] = [];
|
const messages: unknown[] = [];
|
||||||
const github = {
|
const github = {
|
||||||
@@ -2016,6 +2185,23 @@ describe("session checkout pull request merge", () => {
|
|||||||
github: {
|
github: {
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
number: 42,
|
number: 42,
|
||||||
|
github: {
|
||||||
|
mergeStateStatus: "CLEAN",
|
||||||
|
autoMergeRequest: null,
|
||||||
|
viewerCanEnableAutoMerge: false,
|
||||||
|
viewerCanDisableAutoMerge: false,
|
||||||
|
viewerCanMergeAsAdmin: false,
|
||||||
|
viewerCanUpdateBranch: false,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: true,
|
||||||
|
squashMergeAllowed: true,
|
||||||
|
rebaseMergeAllowed: true,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
},
|
||||||
|
isMergeQueueEnabled: false,
|
||||||
|
isInMergeQueue: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -2044,6 +2230,362 @@ describe("session checkout pull request merge", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("session checkout pull request auto-merge", () => {
|
||||||
|
const autoMergeGithubFacts = (
|
||||||
|
overrides: Partial<GitHubPullRequestStatusFacts> = {},
|
||||||
|
): GitHubPullRequestStatusFacts => ({
|
||||||
|
mergeStateStatus: "BLOCKED",
|
||||||
|
autoMergeRequest: null,
|
||||||
|
viewerCanEnableAutoMerge: true,
|
||||||
|
viewerCanDisableAutoMerge: false,
|
||||||
|
viewerCanMergeAsAdmin: false,
|
||||||
|
viewerCanUpdateBranch: false,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: true,
|
||||||
|
squashMergeAllowed: true,
|
||||||
|
rebaseMergeAllowed: true,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
},
|
||||||
|
isMergeQueueEnabled: false,
|
||||||
|
isInMergeQueue: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
test("enables auto-merge for the current pull request and refreshes GitHub state", async () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const github = {
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
enablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
};
|
||||||
|
const workspaceGitService = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
github: {
|
||||||
|
pullRequest: {
|
||||||
|
number: 42,
|
||||||
|
mergeable: "MERGEABLE",
|
||||||
|
github: autoMergeGithubFacts(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
|
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
||||||
|
type: "checkout.github.set_auto_merge.request",
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
enabled: true,
|
||||||
|
mergeMethod: "squash",
|
||||||
|
requestId: "request-pr-auto-merge-enable",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(github.enablePullRequestAutoMerge).toHaveBeenCalledWith({
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
prNumber: 42,
|
||||||
|
mergeMethod: "squash",
|
||||||
|
status: {
|
||||||
|
number: 42,
|
||||||
|
mergeable: "MERGEABLE",
|
||||||
|
github: autoMergeGithubFacts(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, "/tmp/request-worktree", {
|
||||||
|
force: true,
|
||||||
|
includeGitHub: true,
|
||||||
|
reason: "auto-merge-validation",
|
||||||
|
});
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, "/tmp/request-worktree", {
|
||||||
|
force: true,
|
||||||
|
reason: "enable-pr-auto-merge",
|
||||||
|
});
|
||||||
|
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
|
||||||
|
expect(messages).toContainEqual({
|
||||||
|
type: "checkout.github.set_auto_merge.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
enabled: true,
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
requestId: "request-pr-auto-merge-enable",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disables auto-merge for the current pull request and refreshes GitHub state", async () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const github = {
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
disablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
};
|
||||||
|
const workspaceGitService = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
github: {
|
||||||
|
pullRequest: {
|
||||||
|
number: 42,
|
||||||
|
github: autoMergeGithubFacts({
|
||||||
|
autoMergeRequest: {
|
||||||
|
enabledAt: "2026-05-13T17:00:00Z",
|
||||||
|
mergeMethod: "SQUASH",
|
||||||
|
enabledBy: "moboudra",
|
||||||
|
},
|
||||||
|
viewerCanEnableAutoMerge: false,
|
||||||
|
viewerCanDisableAutoMerge: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
|
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
||||||
|
type: "checkout.github.set_auto_merge.request",
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
enabled: false,
|
||||||
|
requestId: "request-pr-auto-merge-disable",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(github.disablePullRequestAutoMerge).toHaveBeenCalledWith({
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
prNumber: 42,
|
||||||
|
status: {
|
||||||
|
number: 42,
|
||||||
|
github: autoMergeGithubFacts({
|
||||||
|
autoMergeRequest: {
|
||||||
|
enabledAt: "2026-05-13T17:00:00Z",
|
||||||
|
mergeMethod: "SQUASH",
|
||||||
|
enabledBy: "moboudra",
|
||||||
|
},
|
||||||
|
viewerCanEnableAutoMerge: false,
|
||||||
|
viewerCanDisableAutoMerge: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, "/tmp/request-worktree", {
|
||||||
|
force: true,
|
||||||
|
includeGitHub: true,
|
||||||
|
reason: "auto-merge-validation",
|
||||||
|
});
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, "/tmp/request-worktree", {
|
||||||
|
force: true,
|
||||||
|
reason: "disable-pr-auto-merge",
|
||||||
|
});
|
||||||
|
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
|
||||||
|
expect(messages).toContainEqual({
|
||||||
|
type: "checkout.github.set_auto_merge.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
enabled: false,
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
requestId: "request-pr-auto-merge-disable",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("surfaces auto-merge errors verbatim", async () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const github = {
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
enablePullRequestAutoMerge: vi.fn().mockRejectedValue(new Error("auto-merge is disabled")),
|
||||||
|
};
|
||||||
|
const workspaceGitService = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
github: {
|
||||||
|
pullRequest: {
|
||||||
|
number: 42,
|
||||||
|
github: autoMergeGithubFacts(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
|
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
||||||
|
type: "checkout.github.set_auto_merge.request",
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
enabled: true,
|
||||||
|
mergeMethod: "merge",
|
||||||
|
requestId: "request-pr-auto-merge-failure",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toContainEqual({
|
||||||
|
type: "checkout.github.set_auto_merge.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
enabled: true,
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "UNKNOWN",
|
||||||
|
message: "auto-merge is disabled",
|
||||||
|
},
|
||||||
|
requestId: "request-pr-auto-merge-failure",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects auto-merge enable when the requested method is disabled", async () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const github = {
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
enablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
};
|
||||||
|
const workspaceGitService = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
github: {
|
||||||
|
pullRequest: {
|
||||||
|
number: 42,
|
||||||
|
github: autoMergeGithubFacts({
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: true,
|
||||||
|
squashMergeAllowed: false,
|
||||||
|
rebaseMergeAllowed: true,
|
||||||
|
viewerDefaultMergeMethod: "MERGE",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
|
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
||||||
|
type: "checkout.github.set_auto_merge.request",
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
enabled: true,
|
||||||
|
mergeMethod: "squash",
|
||||||
|
requestId: "request-pr-auto-merge-method-disabled",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(github.enablePullRequestAutoMerge).not.toHaveBeenCalled();
|
||||||
|
expect(github.invalidate).not.toHaveBeenCalled();
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
|
||||||
|
force: true,
|
||||||
|
includeGitHub: true,
|
||||||
|
reason: "auto-merge-validation",
|
||||||
|
});
|
||||||
|
expect(messages).toContainEqual({
|
||||||
|
type: "checkout.github.set_auto_merge.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
enabled: true,
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "UNKNOWN",
|
||||||
|
message: "Auto-merge is not available because squash is disabled",
|
||||||
|
},
|
||||||
|
requestId: "request-pr-auto-merge-method-disabled",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects auto-merge disable when the viewer cannot disable it", async () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const github = {
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
disablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
};
|
||||||
|
const workspaceGitService = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
github: {
|
||||||
|
pullRequest: {
|
||||||
|
number: 42,
|
||||||
|
github: autoMergeGithubFacts({
|
||||||
|
autoMergeRequest: {
|
||||||
|
enabledAt: "2026-05-13T17:00:00Z",
|
||||||
|
mergeMethod: "SQUASH",
|
||||||
|
enabledBy: "someone-else",
|
||||||
|
},
|
||||||
|
viewerCanEnableAutoMerge: false,
|
||||||
|
viewerCanDisableAutoMerge: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
|
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
||||||
|
type: "checkout.github.set_auto_merge.request",
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
enabled: false,
|
||||||
|
requestId: "request-pr-auto-merge-disable-forbidden",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(github.disablePullRequestAutoMerge).not.toHaveBeenCalled();
|
||||||
|
expect(github.invalidate).not.toHaveBeenCalled();
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
|
||||||
|
force: true,
|
||||||
|
includeGitHub: true,
|
||||||
|
reason: "auto-merge-validation",
|
||||||
|
});
|
||||||
|
expect(messages).toContainEqual({
|
||||||
|
type: "checkout.github.set_auto_merge.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
enabled: false,
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "UNKNOWN",
|
||||||
|
message: "GitHub does not allow this viewer to disable auto-merge",
|
||||||
|
},
|
||||||
|
requestId: "request-pr-auto-merge-disable-forbidden",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects auto-merge disable requests that include a merge method", async () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const github = {
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
disablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
};
|
||||||
|
const workspaceGitService = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
github: {
|
||||||
|
pullRequest: {
|
||||||
|
number: 42,
|
||||||
|
github: autoMergeGithubFacts({
|
||||||
|
autoMergeRequest: {
|
||||||
|
enabledAt: "2026-05-13T17:00:00Z",
|
||||||
|
mergeMethod: "SQUASH",
|
||||||
|
enabledBy: "moboudra",
|
||||||
|
},
|
||||||
|
viewerCanEnableAutoMerge: false,
|
||||||
|
viewerCanDisableAutoMerge: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
|
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
||||||
|
type: "checkout.github.set_auto_merge.request",
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
enabled: false,
|
||||||
|
mergeMethod: "squash",
|
||||||
|
requestId: "request-pr-auto-merge-disable-with-method",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(github.disablePullRequestAutoMerge).not.toHaveBeenCalled();
|
||||||
|
expect(github.invalidate).not.toHaveBeenCalled();
|
||||||
|
expect(messages).toContainEqual({
|
||||||
|
type: "checkout.github.set_auto_merge.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/request-worktree",
|
||||||
|
enabled: false,
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "UNKNOWN",
|
||||||
|
message: "mergeMethod is not allowed when disabling auto-merge",
|
||||||
|
},
|
||||||
|
requestId: "request-pr-auto-merge-disable-with-method",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("session checkout pull and push handling", () => {
|
describe("session checkout pull and push handling", () => {
|
||||||
test("forces workspace git and GitHub refresh after pulling", async () => {
|
test("forces workspace git and GitHub refresh after pulling", async () => {
|
||||||
const messages: unknown[] = [];
|
const messages: unknown[] = [];
|
||||||
|
|||||||
@@ -82,7 +82,11 @@ import type { DaemonConfigStore } from "./daemon-config-store.js";
|
|||||||
import { applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
|
import { applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
|
||||||
import { getErrorMessage, getErrorMessageOr } from "../shared/error-utils.js";
|
import { getErrorMessage, getErrorMessageOr } from "../shared/error-utils.js";
|
||||||
import { getAgentStatusPriority } from "../shared/agent-state-bucket.js";
|
import { getAgentStatusPriority } from "../shared/agent-state-bucket.js";
|
||||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
import type {
|
||||||
|
WorkspaceGitRuntimeSnapshot,
|
||||||
|
WorkspaceGitService,
|
||||||
|
WorkspaceGitSnapshotOptions,
|
||||||
|
} from "./workspace-git-service.js";
|
||||||
|
|
||||||
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -211,6 +215,8 @@ import { LoopService } from "./loop-service.js";
|
|||||||
import { ScheduleService } from "./schedule/service.js";
|
import { ScheduleService } from "./schedule/service.js";
|
||||||
import { execCommand } from "../utils/spawn.js";
|
import { execCommand } from "../utils/spawn.js";
|
||||||
import {
|
import {
|
||||||
|
assertPullRequestAutoMergeDisableReady,
|
||||||
|
assertPullRequestAutoMergeEnableReady,
|
||||||
createGitHubService,
|
createGitHubService,
|
||||||
type GitHubService,
|
type GitHubService,
|
||||||
type PullRequestTimelineItem,
|
type PullRequestTimelineItem,
|
||||||
@@ -242,6 +248,12 @@ import { toWorktreeWireError } from "./worktree-errors.js";
|
|||||||
|
|
||||||
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
|
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
|
||||||
|
|
||||||
|
type CurrentWorkspacePullRequest = NonNullable<
|
||||||
|
WorkspaceGitRuntimeSnapshot["github"]["pullRequest"]
|
||||||
|
> & {
|
||||||
|
number: number;
|
||||||
|
};
|
||||||
|
|
||||||
interface ResolveKnownProjectRootForConfigInput {
|
interface ResolveKnownProjectRootForConfigInput {
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
projectRegistry: Pick<ProjectRegistry, "list">;
|
projectRegistry: Pick<ProjectRegistry, "list">;
|
||||||
@@ -288,6 +300,8 @@ type GitMutationRefreshReason =
|
|||||||
| "merge-to-base"
|
| "merge-to-base"
|
||||||
| "merge-from-base"
|
| "merge-from-base"
|
||||||
| "merge-pr"
|
| "merge-pr"
|
||||||
|
| "enable-pr-auto-merge"
|
||||||
|
| "disable-pr-auto-merge"
|
||||||
| "create-pr"
|
| "create-pr"
|
||||||
| "switch-branch"
|
| "switch-branch"
|
||||||
| "create-branch"
|
| "create-branch"
|
||||||
@@ -2013,6 +2027,8 @@ export class Session {
|
|||||||
return this.handleCheckoutPrCreateRequest(msg);
|
return this.handleCheckoutPrCreateRequest(msg);
|
||||||
case "checkout_pr_merge_request":
|
case "checkout_pr_merge_request":
|
||||||
return this.handleCheckoutPrMergeRequest(msg);
|
return this.handleCheckoutPrMergeRequest(msg);
|
||||||
|
case "checkout.github.set_auto_merge.request":
|
||||||
|
return this.handleCheckoutGithubSetAutoMergeRequest(msg);
|
||||||
case "checkout_pr_status_request":
|
case "checkout_pr_status_request":
|
||||||
return this.handleCheckoutPrStatusRequest(msg);
|
return this.handleCheckoutPrStatusRequest(msg);
|
||||||
case "pull_request_timeline_request":
|
case "pull_request_timeline_request":
|
||||||
@@ -5180,16 +5196,17 @@ export class Session {
|
|||||||
const { cwd, requestId } = msg;
|
const { cwd, requestId } = msg;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
|
const pullRequest = await this.resolveCurrentPullRequest(cwd, "merge", {
|
||||||
const prNumber = snapshot.github.pullRequest?.number;
|
force: true,
|
||||||
if (typeof prNumber !== "number") {
|
includeGitHub: true,
|
||||||
throw new Error("Unable to determine GitHub pull request number for merge");
|
reason: "merge-pr-validation",
|
||||||
}
|
});
|
||||||
|
this.assertCurrentPullRequestHasGithubMergeFacts(pullRequest);
|
||||||
await this.github.mergePullRequest({
|
await this.github.mergePullRequest({
|
||||||
cwd,
|
cwd,
|
||||||
prNumber,
|
prNumber: pullRequest.number,
|
||||||
mergeMethod: msg.mergeMethod,
|
mergeMethod: msg.mergeMethod,
|
||||||
|
status: pullRequest,
|
||||||
});
|
});
|
||||||
await this.notifyGitMutation(cwd, "merge-pr", { invalidateGithub: true });
|
await this.notifyGitMutation(cwd, "merge-pr", { invalidateGithub: true });
|
||||||
|
|
||||||
@@ -5215,6 +5232,96 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assertCurrentPullRequestHasGithubMergeFacts(
|
||||||
|
pullRequest: CurrentWorkspacePullRequest,
|
||||||
|
): void {
|
||||||
|
if (!pullRequest.github) {
|
||||||
|
throw new Error("GitHub merge facts are unavailable for this pull request");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleCheckoutGithubSetAutoMergeRequest(
|
||||||
|
msg: Extract<SessionInboundMessage, { type: "checkout.github.set_auto_merge.request" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
const { cwd, requestId } = msg;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pullRequest = await this.resolveCurrentPullRequest(cwd, "auto-merge", {
|
||||||
|
force: true,
|
||||||
|
includeGitHub: true,
|
||||||
|
reason: "auto-merge-validation",
|
||||||
|
});
|
||||||
|
if (msg.enabled) {
|
||||||
|
const mergeMethod = msg.mergeMethod;
|
||||||
|
if (!mergeMethod) {
|
||||||
|
throw new Error("mergeMethod is required when enabling auto-merge");
|
||||||
|
}
|
||||||
|
assertPullRequestAutoMergeEnableReady({
|
||||||
|
mergeMethod,
|
||||||
|
status: pullRequest,
|
||||||
|
});
|
||||||
|
await this.github.enablePullRequestAutoMerge({
|
||||||
|
cwd,
|
||||||
|
prNumber: pullRequest.number,
|
||||||
|
mergeMethod,
|
||||||
|
status: pullRequest,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (msg.mergeMethod) {
|
||||||
|
throw new Error("mergeMethod is not allowed when disabling auto-merge");
|
||||||
|
}
|
||||||
|
assertPullRequestAutoMergeDisableReady({ status: pullRequest });
|
||||||
|
await this.github.disablePullRequestAutoMerge({
|
||||||
|
cwd,
|
||||||
|
prNumber: pullRequest.number,
|
||||||
|
status: pullRequest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.notifyGitMutation(
|
||||||
|
cwd,
|
||||||
|
msg.enabled ? "enable-pr-auto-merge" : "disable-pr-auto-merge",
|
||||||
|
{
|
||||||
|
invalidateGithub: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: "checkout.github.set_auto_merge.response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
enabled: msg.enabled,
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.emit({
|
||||||
|
type: "checkout.github.set_auto_merge.response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
enabled: msg.enabled,
|
||||||
|
success: false,
|
||||||
|
error: toCheckoutError(error),
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveCurrentPullRequest(
|
||||||
|
cwd: string,
|
||||||
|
operation: "merge" | "auto-merge",
|
||||||
|
options?: WorkspaceGitSnapshotOptions,
|
||||||
|
): Promise<CurrentWorkspacePullRequest> {
|
||||||
|
const snapshot = await this.workspaceGitService.getSnapshot(cwd, options);
|
||||||
|
const pullRequest = snapshot.github.pullRequest;
|
||||||
|
if (!pullRequest || typeof pullRequest.number !== "number") {
|
||||||
|
throw new Error(`Unable to determine GitHub pull request number for ${operation}`);
|
||||||
|
}
|
||||||
|
return { ...pullRequest, number: pullRequest.number };
|
||||||
|
}
|
||||||
|
|
||||||
private async handleCheckoutPrStatusRequest(
|
private async handleCheckoutPrStatusRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "checkout_pr_status_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "checkout_pr_status_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -451,6 +451,23 @@ describe("workspace git watch targets", () => {
|
|||||||
],
|
],
|
||||||
checksStatus: "success",
|
checksStatus: "success",
|
||||||
reviewDecision: "approved",
|
reviewDecision: "approved",
|
||||||
|
github: {
|
||||||
|
mergeStateStatus: "CLEAN",
|
||||||
|
autoMergeRequest: null,
|
||||||
|
viewerCanEnableAutoMerge: true,
|
||||||
|
viewerCanDisableAutoMerge: false,
|
||||||
|
viewerCanMergeAsAdmin: false,
|
||||||
|
viewerCanUpdateBranch: true,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: true,
|
||||||
|
squashMergeAllowed: true,
|
||||||
|
rebaseMergeAllowed: false,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
},
|
||||||
|
isMergeQueueEnabled: false,
|
||||||
|
isInMergeQueue: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
@@ -483,6 +500,23 @@ describe("workspace git watch targets", () => {
|
|||||||
],
|
],
|
||||||
checksStatus: "success",
|
checksStatus: "success",
|
||||||
reviewDecision: "approved",
|
reviewDecision: "approved",
|
||||||
|
github: {
|
||||||
|
mergeStateStatus: "CLEAN",
|
||||||
|
autoMergeRequest: null,
|
||||||
|
viewerCanEnableAutoMerge: true,
|
||||||
|
viewerCanDisableAutoMerge: false,
|
||||||
|
viewerCanMergeAsAdmin: false,
|
||||||
|
viewerCanUpdateBranch: true,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: true,
|
||||||
|
squashMergeAllowed: true,
|
||||||
|
rebaseMergeAllowed: false,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
},
|
||||||
|
isMergeQueueEnabled: false,
|
||||||
|
isInMergeQueue: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
githubFeaturesEnabled: true,
|
githubFeaturesEnabled: true,
|
||||||
error: null,
|
error: null,
|
||||||
|
|||||||
@@ -1048,6 +1048,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
features: {
|
features: {
|
||||||
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
|
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
|
||||||
providersSnapshot: true,
|
providersSnapshot: true,
|
||||||
|
// COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
|
||||||
|
checkoutGithubSetAutoMerge: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,11 +79,11 @@ function createCheckoutStatus(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPullRequestStatusResult(): PullRequestStatusResult {
|
function createPullRequestStatusResult(title = "Update feature"): PullRequestStatusResult {
|
||||||
return {
|
return {
|
||||||
status: {
|
status: {
|
||||||
url: "https://github.com/acme/repo/pull/123",
|
url: "https://github.com/acme/repo/pull/123",
|
||||||
title: "Update feature",
|
title,
|
||||||
state: "open",
|
state: "open",
|
||||||
baseRefName: "main",
|
baseRefName: "main",
|
||||||
headRefName: "feature",
|
headRefName: "feature",
|
||||||
@@ -537,6 +537,61 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a forced GitHub-inclusive call during an in-flight forced git refresh queues a GitHub refresh", async () => {
|
||||||
|
const forcedGitRefresh = createDeferred<CheckoutStatusGit>();
|
||||||
|
const getCheckoutStatus = vi
|
||||||
|
.fn<() => Promise<CheckoutStatusGit>>()
|
||||||
|
.mockImplementationOnce(async () => forcedGitRefresh.promise)
|
||||||
|
.mockImplementation(async () => createCheckoutStatus(REPO_CWD));
|
||||||
|
const getPullRequestStatus = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(createPullRequestStatusResult("Stale cached PR"))
|
||||||
|
.mockResolvedValueOnce(createPullRequestStatusResult("Fresh validation PR"));
|
||||||
|
const service = createService({ getCheckoutStatus, getPullRequestStatus });
|
||||||
|
|
||||||
|
const gitRefresh = service.getSnapshot(REPO_CWD, {
|
||||||
|
force: true,
|
||||||
|
includeGitHub: false,
|
||||||
|
reason: "watch",
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const validationRefresh = service.getSnapshot(REPO_CWD, {
|
||||||
|
force: true,
|
||||||
|
includeGitHub: true,
|
||||||
|
reason: "merge-pr-validation",
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
forcedGitRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
||||||
|
|
||||||
|
await expect(validationRefresh).resolves.toEqual(
|
||||||
|
createSnapshot(REPO_CWD, {
|
||||||
|
github: {
|
||||||
|
pullRequest: {
|
||||||
|
url: "https://github.com/acme/repo/pull/123",
|
||||||
|
title: "Fresh validation PR",
|
||||||
|
state: "open",
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "feature",
|
||||||
|
isMerged: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await gitRefresh;
|
||||||
|
|
||||||
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
|
expect(getPullRequestStatus).toHaveBeenNthCalledWith(2, REPO_CWD, expect.anything(), {
|
||||||
|
force: true,
|
||||||
|
reason: "merge-pr-validation",
|
||||||
|
});
|
||||||
|
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test("ref-watch firing during an in-flight forced refresh does not produce an extra shell burst", async () => {
|
test("ref-watch firing during an in-flight forced refresh does not produce an extra shell burst", async () => {
|
||||||
const forcedRefresh = createDeferred<CheckoutStatusGit>();
|
const forcedRefresh = createDeferred<CheckoutStatusGit>();
|
||||||
const getCheckoutStatus = vi
|
const getCheckoutStatus = vi
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from "../utils/checkout-git.js";
|
} from "../utils/checkout-git.js";
|
||||||
import {
|
import {
|
||||||
createGitHubService,
|
createGitHubService,
|
||||||
|
type GitHubPullRequestStatusFacts,
|
||||||
type GitHubService,
|
type GitHubService,
|
||||||
type PullRequestMergeable,
|
type PullRequestMergeable,
|
||||||
} from "../services/github-service.js";
|
} from "../services/github-service.js";
|
||||||
@@ -92,6 +93,7 @@ export interface WorkspaceGitRuntimeSnapshot {
|
|||||||
}>;
|
}>;
|
||||||
checksStatus?: "none" | "pending" | "success" | "failure";
|
checksStatus?: "none" | "pending" | "success" | "failure";
|
||||||
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
|
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
|
||||||
|
github?: GitHubPullRequestStatusFacts;
|
||||||
} | null;
|
} | null;
|
||||||
error: { message: string } | null;
|
error: { message: string } | null;
|
||||||
};
|
};
|
||||||
@@ -1324,7 +1326,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
request: WorkspaceGitRefreshRequest,
|
request: WorkspaceGitRefreshRequest,
|
||||||
): Promise<WorkspaceGitRuntimeSnapshot> {
|
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||||
if (target.refreshState.status === "in-flight") {
|
if (target.refreshState.status === "in-flight") {
|
||||||
if (request.force && !target.refreshState.force) {
|
const needsForcedRefresh = request.force && !target.refreshState.force;
|
||||||
|
const needsGitHubRefresh =
|
||||||
|
request.force && request.includeGitHub && !target.refreshState.includeGitHub;
|
||||||
|
if (needsForcedRefresh || needsGitHubRefresh) {
|
||||||
target.refreshState.queued = this.mergeQueuedRefresh(target.refreshState.queued, request);
|
target.refreshState.queued = this.mergeQueuedRefresh(target.refreshState.queued, request);
|
||||||
}
|
}
|
||||||
return target.refreshState.promise;
|
return target.refreshState.promise;
|
||||||
@@ -1424,10 +1429,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const force = queued.force || request.force;
|
const force = queued.force || request.force;
|
||||||
|
const upgradesForce = request.force && !queued.force;
|
||||||
|
const upgradesGitHub = request.includeGitHub && !queued.includeGitHub;
|
||||||
return {
|
return {
|
||||||
force,
|
force,
|
||||||
includeGitHub: queued.includeGitHub || request.includeGitHub,
|
includeGitHub: queued.includeGitHub || request.includeGitHub,
|
||||||
reason: request.force && !queued.force ? request.reason : queued.reason,
|
reason: upgradesForce || upgradesGitHub ? request.reason : queued.reason,
|
||||||
notify: queued.notify || request.notify,
|
notify: queued.notify || request.notify,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,31 @@ function currentPullRequestJson(overrides: Record<string, unknown> = {}): string
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentPullRequestGithubFactsJson(overrides: Record<string, unknown> = {}): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
data: {
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: false,
|
||||||
|
squashMergeAllowed: true,
|
||||||
|
rebaseMergeAllowed: false,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
pullRequest: {
|
||||||
|
mergeStateStatus: "BLOCKED",
|
||||||
|
autoMergeRequest: null,
|
||||||
|
viewerCanEnableAutoMerge: true,
|
||||||
|
viewerCanDisableAutoMerge: false,
|
||||||
|
viewerCanMergeAsAdmin: false,
|
||||||
|
viewerCanUpdateBranch: true,
|
||||||
|
isMergeQueueEnabled: false,
|
||||||
|
isInMergeQueue: false,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createCurrentPullRequestStatus(
|
function createCurrentPullRequestStatus(
|
||||||
overrides: Partial<GitHubCurrentPullRequestStatus> = {},
|
overrides: Partial<GitHubCurrentPullRequestStatus> = {},
|
||||||
): GitHubCurrentPullRequestStatus {
|
): GitHubCurrentPullRequestStatus {
|
||||||
@@ -137,6 +162,29 @@ function createCurrentPullRequestStatus(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function githubStatusFacts(
|
||||||
|
overrides: Partial<NonNullable<GitHubCurrentPullRequestStatus["github"]>> = {},
|
||||||
|
): NonNullable<GitHubCurrentPullRequestStatus["github"]> {
|
||||||
|
return {
|
||||||
|
mergeStateStatus: "CLEAN",
|
||||||
|
autoMergeRequest: null,
|
||||||
|
viewerCanEnableAutoMerge: false,
|
||||||
|
viewerCanDisableAutoMerge: false,
|
||||||
|
viewerCanMergeAsAdmin: false,
|
||||||
|
viewerCanUpdateBranch: false,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: true,
|
||||||
|
squashMergeAllowed: true,
|
||||||
|
rebaseMergeAllowed: true,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
},
|
||||||
|
isMergeQueueEnabled: false,
|
||||||
|
isInMergeQueue: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function recordCurrentPullRequestStatusReads(service: ReturnType<typeof createGitHubService>) {
|
function recordCurrentPullRequestStatusReads(service: ReturnType<typeof createGitHubService>) {
|
||||||
const reads: GitHubReadOptions[] = [];
|
const reads: GitHubReadOptions[] = [];
|
||||||
const getCurrentPullRequestStatus = service.getCurrentPullRequestStatus.bind(service);
|
const getCurrentPullRequestStatus = service.getCurrentPullRequestStatus.bind(service);
|
||||||
@@ -147,6 +195,12 @@ function recordCurrentPullRequestStatusReads(service: ReturnType<typeof createGi
|
|||||||
return reads;
|
return reads;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentPullRequestStatusCalls(calls: RunnerCall[]): RunnerCall[] {
|
||||||
|
return calls.filter(
|
||||||
|
(call) => call.args[0] === "pr" && (call.args[1] === "view" || call.args[1] === "list"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function noPullRequestError(args: string[] = ["pr", "view"]): GitHubCommandError {
|
function noPullRequestError(args: string[] = ["pr", "view"]): GitHubCommandError {
|
||||||
return new GitHubCommandError({
|
return new GitHubCommandError({
|
||||||
args,
|
args,
|
||||||
@@ -314,6 +368,9 @@ describe("GitHubService", () => {
|
|||||||
cwd: "/tmp/repo",
|
cwd: "/tmp/repo",
|
||||||
prNumber: 42,
|
prNumber: 42,
|
||||||
mergeMethod,
|
mergeMethod,
|
||||||
|
status: createCurrentPullRequestStatus({
|
||||||
|
github: githubStatusFacts(),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual({ success: true });
|
).resolves.toEqual({ success: true });
|
||||||
|
|
||||||
@@ -326,6 +383,196 @@ describe("GitHubService", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects direct merge when GitHub facts are unavailable", async () => {
|
||||||
|
const runner = createRunner([""]);
|
||||||
|
const service = createGitHubService({
|
||||||
|
runner: runner.runner,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.mergePullRequest({
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
prNumber: 42,
|
||||||
|
mergeMethod: "squash",
|
||||||
|
status: createCurrentPullRequestStatus(),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("GitHub merge facts are unavailable");
|
||||||
|
|
||||||
|
expect(runner.calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["BLOCKED", "DIRTY", null] as const)(
|
||||||
|
"rejects direct merge when GitHub mergeStateStatus is %s",
|
||||||
|
async (mergeStateStatus) => {
|
||||||
|
const runner = createRunner([""]);
|
||||||
|
const service = createGitHubService({
|
||||||
|
runner: runner.runner,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.mergePullRequest({
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
prNumber: 42,
|
||||||
|
mergeMethod: "squash",
|
||||||
|
status: createCurrentPullRequestStatus({
|
||||||
|
github: githubStatusFacts({ mergeStateStatus }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("ready for direct merge");
|
||||||
|
|
||||||
|
expect(runner.calls).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["merge queue enabled", { isMergeQueueEnabled: true }],
|
||||||
|
["PR already in merge queue", { isInMergeQueue: true }],
|
||||||
|
] as const)("rejects direct merge when %s", async (_name, overrides) => {
|
||||||
|
const runner = createRunner([""]);
|
||||||
|
const service = createGitHubService({
|
||||||
|
runner: runner.runner,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.mergePullRequest({
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
prNumber: 42,
|
||||||
|
mergeMethod: "squash",
|
||||||
|
status: createCurrentPullRequestStatus({
|
||||||
|
github: githubStatusFacts(overrides),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("merge queue");
|
||||||
|
|
||||||
|
expect(runner.calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects direct merge when auto-merge is already enabled", async () => {
|
||||||
|
const runner = createRunner([""]);
|
||||||
|
const service = createGitHubService({
|
||||||
|
runner: runner.runner,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.mergePullRequest({
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
prNumber: 42,
|
||||||
|
mergeMethod: "squash",
|
||||||
|
status: createCurrentPullRequestStatus({
|
||||||
|
github: githubStatusFacts({
|
||||||
|
autoMergeRequest: {
|
||||||
|
enabledAt: "2026-05-13T12:00:00Z",
|
||||||
|
mergeMethod: "SQUASH",
|
||||||
|
enabledBy: "octocat",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("auto-merge is already enabled");
|
||||||
|
|
||||||
|
expect(runner.calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects direct merge when the requested method is disabled by repository policy", async () => {
|
||||||
|
const runner = createRunner([""]);
|
||||||
|
const service = createGitHubService({
|
||||||
|
runner: runner.runner,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.mergePullRequest({
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
prNumber: 42,
|
||||||
|
mergeMethod: "squash",
|
||||||
|
status: createCurrentPullRequestStatus({
|
||||||
|
github: githubStatusFacts({
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: true,
|
||||||
|
squashMergeAllowed: false,
|
||||||
|
rebaseMergeAllowed: true,
|
||||||
|
viewerDefaultMergeMethod: "MERGE",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("squash is disabled");
|
||||||
|
|
||||||
|
expect(runner.calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["merge", ["pr", "merge", "42", "--auto", "--merge"]],
|
||||||
|
["squash", ["pr", "merge", "42", "--auto", "--squash"]],
|
||||||
|
["rebase", ["pr", "merge", "42", "--auto", "--rebase"]],
|
||||||
|
] as const)("enables auto-merge with gh using %s", async (mergeMethod, expectedArgs) => {
|
||||||
|
const runner = createRunner([""]);
|
||||||
|
const service = createGitHubService({
|
||||||
|
runner: runner.runner,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.enablePullRequestAutoMerge({
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
prNumber: 42,
|
||||||
|
mergeMethod,
|
||||||
|
status: createCurrentPullRequestStatus({
|
||||||
|
github: githubStatusFacts({
|
||||||
|
mergeStateStatus: "BLOCKED",
|
||||||
|
viewerCanEnableAutoMerge: true,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: true,
|
||||||
|
squashMergeAllowed: true,
|
||||||
|
rebaseMergeAllowed: true,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
|
||||||
|
expect(runner.calls).toEqual([
|
||||||
|
{
|
||||||
|
args: expectedArgs,
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
envOverlay: { GH_PROMPT_DISABLED: "1" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables auto-merge with gh", async () => {
|
||||||
|
const runner = createRunner([""]);
|
||||||
|
const service = createGitHubService({
|
||||||
|
runner: runner.runner,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.disablePullRequestAutoMerge({
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
prNumber: 42,
|
||||||
|
status: createCurrentPullRequestStatus({
|
||||||
|
github: githubStatusFacts({
|
||||||
|
autoMergeRequest: {
|
||||||
|
enabledAt: "2026-05-13T12:00:00Z",
|
||||||
|
mergeMethod: "SQUASH",
|
||||||
|
enabledBy: "octocat",
|
||||||
|
},
|
||||||
|
viewerCanDisableAutoMerge: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
|
||||||
|
expect(runner.calls).toEqual([
|
||||||
|
{
|
||||||
|
args: ["pr", "merge", "42", "--disable-auto"],
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
envOverlay: { GH_PROMPT_DISABLED: "1" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("computes fast cadence for pending and slow cadence for stable PR states", () => {
|
it("computes fast cadence for pending and slow cadence for stable PR states", () => {
|
||||||
const pendingStatus = createCurrentPullRequestStatus({ checksStatus: "pending" });
|
const pendingStatus = createCurrentPullRequestStatus({ checksStatus: "pending" });
|
||||||
const runningCheckStatus = createCurrentPullRequestStatus({
|
const runningCheckStatus = createCurrentPullRequestStatus({
|
||||||
@@ -408,7 +655,7 @@ describe("GitHubService", () => {
|
|||||||
now = EXPECTED_GITHUB_FAST_POLL_MS;
|
now = EXPECTED_GITHUB_FAST_POLL_MS;
|
||||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
||||||
|
|
||||||
expect(runner.calls).toHaveLength(2);
|
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(2);
|
||||||
expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]);
|
expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]);
|
||||||
|
|
||||||
subscription?.unsubscribe();
|
subscription?.unsubscribe();
|
||||||
@@ -441,11 +688,11 @@ describe("GitHubService", () => {
|
|||||||
|
|
||||||
now = EXPECTED_GITHUB_FAST_POLL_MS;
|
now = EXPECTED_GITHUB_FAST_POLL_MS;
|
||||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
||||||
expect(runner.calls).toHaveLength(1);
|
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
|
||||||
|
|
||||||
now = EXPECTED_GITHUB_SLOW_POLL_MS;
|
now = EXPECTED_GITHUB_SLOW_POLL_MS;
|
||||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS - EXPECTED_GITHUB_FAST_POLL_MS);
|
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS - EXPECTED_GITHUB_FAST_POLL_MS);
|
||||||
expect(runner.calls).toHaveLength(2);
|
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(2);
|
||||||
expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]);
|
expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]);
|
||||||
|
|
||||||
subscription?.unsubscribe();
|
subscription?.unsubscribe();
|
||||||
@@ -458,14 +705,17 @@ describe("GitHubService", () => {
|
|||||||
currentPullRequestJson({
|
currentPullRequestJson({
|
||||||
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }],
|
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }],
|
||||||
}),
|
}),
|
||||||
|
currentPullRequestGithubFactsJson(),
|
||||||
{ error: new Error("network down") },
|
{ error: new Error("network down") },
|
||||||
{ error: new Error("network still down") },
|
{ error: new Error("network still down") },
|
||||||
currentPullRequestJson({
|
currentPullRequestJson({
|
||||||
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }],
|
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }],
|
||||||
}),
|
}),
|
||||||
|
currentPullRequestGithubFactsJson(),
|
||||||
currentPullRequestJson({
|
currentPullRequestJson({
|
||||||
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }],
|
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }],
|
||||||
}),
|
}),
|
||||||
|
currentPullRequestGithubFactsJson(),
|
||||||
]);
|
]);
|
||||||
const service = createGitHubService({
|
const service = createGitHubService({
|
||||||
ttlMs: 0,
|
ttlMs: 0,
|
||||||
@@ -490,7 +740,7 @@ describe("GitHubService", () => {
|
|||||||
now += EXPECTED_GITHUB_SLOW_POLL_MS;
|
now += EXPECTED_GITHUB_SLOW_POLL_MS;
|
||||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS);
|
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS);
|
||||||
|
|
||||||
expect(runner.calls).toHaveLength(5);
|
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(5);
|
||||||
expect(reads.map((read) => read.reason)).toEqual([
|
expect(reads.map((read) => read.reason)).toEqual([
|
||||||
undefined,
|
undefined,
|
||||||
"self-heal-github",
|
"self-heal-github",
|
||||||
@@ -527,7 +777,7 @@ describe("GitHubService", () => {
|
|||||||
now = EXPECTED_GITHUB_FAST_POLL_MS;
|
now = EXPECTED_GITHUB_FAST_POLL_MS;
|
||||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
||||||
|
|
||||||
expect(runner.calls).toHaveLength(1);
|
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
|
||||||
|
|
||||||
service.dispose?.();
|
service.dispose?.();
|
||||||
});
|
});
|
||||||
@@ -553,7 +803,7 @@ describe("GitHubService", () => {
|
|||||||
now = EXPECTED_GITHUB_FAST_POLL_MS;
|
now = EXPECTED_GITHUB_FAST_POLL_MS;
|
||||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
||||||
|
|
||||||
expect(runner.calls).toHaveLength(1);
|
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches PR reviews and issue comments with one GraphQL call sorted chronologically", async () => {
|
it("fetches PR reviews and issue comments with one GraphQL call sorted chronologically", async () => {
|
||||||
@@ -1139,7 +1389,7 @@ describe("GitHubService", () => {
|
|||||||
headRef: "feature/pr-pane",
|
headRef: "feature/pr-pane",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(runner.calls.map((call) => call.args)).toEqual([
|
expect(runner.calls.slice(0, 2).map((call) => call.args)).toEqual([
|
||||||
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
|
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
|
||||||
["pr", "view", "--json", CURRENT_PR_STATUS_BASE_FIELDS],
|
["pr", "view", "--json", CURRENT_PR_STATUS_BASE_FIELDS],
|
||||||
]);
|
]);
|
||||||
@@ -1171,6 +1421,72 @@ describe("GitHubService", () => {
|
|||||||
expect(status?.mergeable).toBe("UNKNOWN");
|
expect(status?.mergeable).toBe("UNKNOWN");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads GitHub merge, auto-merge, permission, policy, and queue facts for PR 993 shape", async () => {
|
||||||
|
const runner = createScriptedRunner([
|
||||||
|
currentPullRequestJson({
|
||||||
|
number: 993,
|
||||||
|
url: "https://github.com/getpaseo/paseo/pull/993",
|
||||||
|
title: "Auto-merge UX",
|
||||||
|
headRefName: "github-pr-auto-merge-ux",
|
||||||
|
mergeable: "MERGEABLE",
|
||||||
|
reviewDecision: "APPROVED",
|
||||||
|
statusCheckRollup: [
|
||||||
|
{
|
||||||
|
__typename: "CheckRun",
|
||||||
|
name: "server tests",
|
||||||
|
workflowName: "CI",
|
||||||
|
status: "IN_PROGRESS",
|
||||||
|
conclusion: null,
|
||||||
|
detailsUrl: "https://github.com/getpaseo/paseo/actions/runs/993",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
currentPullRequestGithubFactsJson(),
|
||||||
|
]);
|
||||||
|
const service = createGitHubService({
|
||||||
|
runner: runner.runner,
|
||||||
|
resolveGhPath: async () => "/usr/bin/gh",
|
||||||
|
now: () => 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await service.getCurrentPullRequestStatus({
|
||||||
|
cwd: "/repo",
|
||||||
|
headRef: "github-pr-auto-merge-ux",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runner.calls.map((call) => call.args[0])).toEqual(["pr", "api"]);
|
||||||
|
expect(status).toMatchObject({
|
||||||
|
number: 993,
|
||||||
|
mergeable: "MERGEABLE",
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
name: "server tests",
|
||||||
|
status: "pending",
|
||||||
|
url: "https://github.com/getpaseo/paseo/actions/runs/993",
|
||||||
|
workflow: "CI",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checksStatus: "pending",
|
||||||
|
github: {
|
||||||
|
mergeStateStatus: "BLOCKED",
|
||||||
|
autoMergeRequest: null,
|
||||||
|
viewerCanEnableAutoMerge: true,
|
||||||
|
viewerCanDisableAutoMerge: false,
|
||||||
|
viewerCanMergeAsAdmin: false,
|
||||||
|
viewerCanUpdateBranch: true,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: false,
|
||||||
|
squashMergeAllowed: true,
|
||||||
|
rebaseMergeAllowed: false,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
},
|
||||||
|
isMergeQueueEnabled: false,
|
||||||
|
isInMergeQueue: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => {
|
it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => {
|
||||||
const runner = createScriptedRunner([
|
const runner = createScriptedRunner([
|
||||||
currentPullRequestJson({
|
currentPullRequestJson({
|
||||||
@@ -1231,7 +1547,7 @@ describe("GitHubService", () => {
|
|||||||
title: "Real fork PR",
|
title: "Real fork PR",
|
||||||
headRefName: "feature/fork",
|
headRefName: "feature/fork",
|
||||||
});
|
});
|
||||||
expect(runner.calls.map((call) => call.args)).toEqual([
|
expect(runner.calls.slice(0, 3).map((call) => call.args)).toEqual([
|
||||||
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
|
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
|
||||||
["repo", "view", "--json", "owner,name,parent"],
|
["repo", "view", "--json", "owner,name,parent"],
|
||||||
[
|
[
|
||||||
@@ -1314,7 +1630,7 @@ describe("GitHubService", () => {
|
|||||||
checks: [],
|
checks: [],
|
||||||
checksStatus: "none",
|
checksStatus: "none",
|
||||||
});
|
});
|
||||||
expect(runner.calls.map((call) => call.args)).toEqual([
|
expect(runner.calls.slice(0, 4).map((call) => call.args)).toEqual([
|
||||||
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
|
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
|
||||||
["repo", "view", "--json", "owner,name,parent"],
|
["repo", "view", "--json", "owner,name,parent"],
|
||||||
[
|
[
|
||||||
@@ -1452,7 +1768,7 @@ describe("GitHubService", () => {
|
|||||||
repoName: "repo",
|
repoName: "repo",
|
||||||
headRefName: "main",
|
headRefName: "main",
|
||||||
});
|
});
|
||||||
expect(runner.calls.map((call) => call.args)).toEqual([
|
expect(runner.calls.slice(0, 2).map((call) => call.args)).toEqual([
|
||||||
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
|
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
|
||||||
[
|
[
|
||||||
"pr",
|
"pr",
|
||||||
@@ -1978,13 +2294,15 @@ describe("GitHubService", () => {
|
|||||||
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }),
|
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }),
|
||||||
).resolves.toMatchObject({ number: 42 });
|
).resolves.toMatchObject({ number: 42 });
|
||||||
|
|
||||||
expect(runner.calls).toHaveLength(1);
|
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bypasses the warm PR status cache for forced reads", async () => {
|
it("bypasses the warm PR status cache for forced reads", async () => {
|
||||||
const runner = createRunner([
|
const runner = createRunner([
|
||||||
currentPullRequestJson({ number: 41, title: "First" }),
|
currentPullRequestJson({ number: 41, title: "First" }),
|
||||||
|
currentPullRequestGithubFactsJson(),
|
||||||
currentPullRequestJson({ number: 42, title: "Forced" }),
|
currentPullRequestJson({ number: 42, title: "Forced" }),
|
||||||
|
currentPullRequestGithubFactsJson(),
|
||||||
]);
|
]);
|
||||||
const service = createGitHubService({
|
const service = createGitHubService({
|
||||||
runner: runner.runner,
|
runner: runner.runner,
|
||||||
@@ -2004,7 +2322,7 @@ describe("GitHubService", () => {
|
|||||||
}),
|
}),
|
||||||
).resolves.toMatchObject({ number: 42 });
|
).resolves.toMatchObject({ number: 42 });
|
||||||
|
|
||||||
expect(runner.calls).toHaveLength(2);
|
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("coalesces concurrent PR status callers", async () => {
|
it("coalesces concurrent PR status callers", async () => {
|
||||||
@@ -2019,8 +2337,13 @@ describe("GitHubService", () => {
|
|||||||
const second = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
|
const second = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(runner.calls).toHaveLength(1);
|
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
|
||||||
runner.resolveNext(currentPullRequestJson());
|
runner.resolveNext(currentPullRequestJson());
|
||||||
|
for (let i = 0; i < 10 && runner.calls.length < 2; i += 1) {
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
expect(runner.calls[1]?.args[0]).toBe("api");
|
||||||
|
runner.resolveNext(currentPullRequestGithubFactsJson());
|
||||||
|
|
||||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||||
expect.objectContaining({ number: 42 }),
|
expect.objectContaining({ number: 42 }),
|
||||||
|
|||||||
@@ -93,6 +93,52 @@ const HeadRepositoryOwnerSchema = z
|
|||||||
|
|
||||||
const PullRequestMergeableSchema = z.enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"]).catch("UNKNOWN");
|
const PullRequestMergeableSchema = z.enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"]).catch("UNKNOWN");
|
||||||
|
|
||||||
|
const GitHubAutoMergeRequestSchema = z
|
||||||
|
.object({
|
||||||
|
enabledAt: z.string().nullable().optional().catch(null),
|
||||||
|
mergeMethod: z.string().nullable().optional().catch(null),
|
||||||
|
enabledBy: z
|
||||||
|
.object({
|
||||||
|
login: z.string().nullable().optional().catch(null),
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.catch(null),
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.catch(null);
|
||||||
|
|
||||||
|
const GitHubPullRequestFactsGraphqlSchema = z.object({
|
||||||
|
data: z.object({
|
||||||
|
repository: z
|
||||||
|
.object({
|
||||||
|
autoMergeAllowed: z.boolean().optional().catch(false),
|
||||||
|
mergeCommitAllowed: z.boolean().optional().catch(false),
|
||||||
|
squashMergeAllowed: z.boolean().optional().catch(false),
|
||||||
|
rebaseMergeAllowed: z.boolean().optional().catch(false),
|
||||||
|
viewerDefaultMergeMethod: z.string().nullable().optional().catch(null),
|
||||||
|
pullRequest: z
|
||||||
|
.object({
|
||||||
|
mergeStateStatus: z.string().nullable().optional().catch(null),
|
||||||
|
autoMergeRequest: GitHubAutoMergeRequestSchema,
|
||||||
|
viewerCanEnableAutoMerge: z.boolean().optional().catch(false),
|
||||||
|
viewerCanDisableAutoMerge: z.boolean().optional().catch(false),
|
||||||
|
viewerCanMergeAsAdmin: z.boolean().optional().catch(false),
|
||||||
|
viewerCanUpdateBranch: z.boolean().optional().catch(false),
|
||||||
|
isMergeQueueEnabled: z.boolean().optional().catch(false),
|
||||||
|
isInMergeQueue: z.boolean().optional().catch(false),
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.catch(null),
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.catch(null),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const CurrentPullRequestStatusSchema = z.object({
|
const CurrentPullRequestStatusSchema = z.object({
|
||||||
number: z.number().optional(),
|
number: z.number().optional(),
|
||||||
url: z.string().catch(""),
|
url: z.string().catch(""),
|
||||||
@@ -240,6 +286,33 @@ const CURRENT_PR_STATUS_BASE_FIELDS =
|
|||||||
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
|
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
|
||||||
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
|
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
|
||||||
|
|
||||||
|
const PULL_REQUEST_STATUS_FACTS_QUERY = `
|
||||||
|
query PullRequestStatusFacts($owner: String!, $name: String!, $number: Int!) {
|
||||||
|
repository(owner: $owner, name: $name) {
|
||||||
|
autoMergeAllowed
|
||||||
|
mergeCommitAllowed
|
||||||
|
squashMergeAllowed
|
||||||
|
rebaseMergeAllowed
|
||||||
|
viewerDefaultMergeMethod
|
||||||
|
pullRequest(number: $number) {
|
||||||
|
mergeStateStatus
|
||||||
|
autoMergeRequest {
|
||||||
|
enabledAt
|
||||||
|
mergeMethod
|
||||||
|
enabledBy {
|
||||||
|
login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewerCanEnableAutoMerge
|
||||||
|
viewerCanDisableAutoMerge
|
||||||
|
viewerCanMergeAsAdmin
|
||||||
|
viewerCanUpdateBranch
|
||||||
|
isMergeQueueEnabled
|
||||||
|
isInMergeQueue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
const PULL_REQUEST_TIMELINE_QUERY = `
|
const PULL_REQUEST_TIMELINE_QUERY = `
|
||||||
query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) {
|
query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) {
|
||||||
repository(owner: $owner, name: $name) {
|
repository(owner: $owner, name: $name) {
|
||||||
@@ -353,6 +426,28 @@ export type PullRequestChecksStatus = "none" | "pending" | "success" | "failure"
|
|||||||
export type PullRequestReviewDecision = "approved" | "changes_requested" | "pending" | null;
|
export type PullRequestReviewDecision = "approved" | "changes_requested" | "pending" | null;
|
||||||
export type PullRequestMergeable = "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
export type PullRequestMergeable = "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
||||||
|
|
||||||
|
export interface GitHubPullRequestStatusFacts {
|
||||||
|
mergeStateStatus: string | null;
|
||||||
|
autoMergeRequest: {
|
||||||
|
enabledAt: string | null;
|
||||||
|
mergeMethod: string | null;
|
||||||
|
enabledBy: string | null;
|
||||||
|
} | null;
|
||||||
|
viewerCanEnableAutoMerge: boolean;
|
||||||
|
viewerCanDisableAutoMerge: boolean;
|
||||||
|
viewerCanMergeAsAdmin: boolean;
|
||||||
|
viewerCanUpdateBranch: boolean;
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: boolean;
|
||||||
|
mergeCommitAllowed: boolean;
|
||||||
|
squashMergeAllowed: boolean;
|
||||||
|
rebaseMergeAllowed: boolean;
|
||||||
|
viewerDefaultMergeMethod: string | null;
|
||||||
|
};
|
||||||
|
isMergeQueueEnabled: boolean;
|
||||||
|
isInMergeQueue: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitHubCurrentPullRequestStatus {
|
export interface GitHubCurrentPullRequestStatus {
|
||||||
number?: number;
|
number?: number;
|
||||||
repoOwner?: string;
|
repoOwner?: string;
|
||||||
@@ -368,6 +463,7 @@ export interface GitHubCurrentPullRequestStatus {
|
|||||||
checks: PullRequestCheck[];
|
checks: PullRequestCheck[];
|
||||||
checksStatus: PullRequestChecksStatus;
|
checksStatus: PullRequestChecksStatus;
|
||||||
reviewDecision: PullRequestReviewDecision;
|
reviewDecision: PullRequestReviewDecision;
|
||||||
|
github?: GitHubPullRequestStatusFacts;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PullRequestTimelineReviewState = "approved" | "changes_requested" | "commented";
|
export type PullRequestTimelineReviewState = "approved" | "changes_requested" | "commented";
|
||||||
@@ -412,17 +508,41 @@ export interface GitHubPullRequestCreateResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type GitHubPullRequestMergeMethod = "merge" | "squash" | "rebase";
|
export type GitHubPullRequestMergeMethod = "merge" | "squash" | "rebase";
|
||||||
|
const DIRECT_PULL_REQUEST_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]);
|
||||||
|
|
||||||
|
export interface GitHubPullRequestCommandStatus {
|
||||||
|
mergeable?: PullRequestMergeable;
|
||||||
|
github?: GitHubPullRequestStatusFacts;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MergeGitHubPullRequestOptions {
|
export interface MergeGitHubPullRequestOptions {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
prNumber: number;
|
prNumber: number;
|
||||||
mergeMethod: GitHubPullRequestMergeMethod;
|
mergeMethod: GitHubPullRequestMergeMethod;
|
||||||
|
status?: GitHubPullRequestCommandStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnableGitHubPullRequestAutoMergeOptions {
|
||||||
|
cwd: string;
|
||||||
|
prNumber: number;
|
||||||
|
mergeMethod: GitHubPullRequestMergeMethod;
|
||||||
|
status?: GitHubPullRequestCommandStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisableGitHubPullRequestAutoMergeOptions {
|
||||||
|
cwd: string;
|
||||||
|
prNumber: number;
|
||||||
|
status?: GitHubPullRequestCommandStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitHubPullRequestMergeResult {
|
export interface GitHubPullRequestMergeResult {
|
||||||
success: true;
|
success: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitHubPullRequestAutoMergeResult {
|
||||||
|
success: true;
|
||||||
|
}
|
||||||
|
|
||||||
export type GitHubReadOptions =
|
export type GitHubReadOptions =
|
||||||
| {
|
| {
|
||||||
force?: false;
|
force?: false;
|
||||||
@@ -512,6 +632,12 @@ export interface GitHubService {
|
|||||||
options: CreateGitHubPullRequestOptions,
|
options: CreateGitHubPullRequestOptions,
|
||||||
): Promise<GitHubPullRequestCreateResult>;
|
): Promise<GitHubPullRequestCreateResult>;
|
||||||
mergePullRequest(options: MergeGitHubPullRequestOptions): Promise<GitHubPullRequestMergeResult>;
|
mergePullRequest(options: MergeGitHubPullRequestOptions): Promise<GitHubPullRequestMergeResult>;
|
||||||
|
enablePullRequestAutoMerge(
|
||||||
|
options: EnableGitHubPullRequestAutoMergeOptions,
|
||||||
|
): Promise<GitHubPullRequestAutoMergeResult>;
|
||||||
|
disablePullRequestAutoMerge(
|
||||||
|
options: DisableGitHubPullRequestAutoMergeOptions,
|
||||||
|
): Promise<GitHubPullRequestAutoMergeResult>;
|
||||||
isAuthenticated(options: { cwd: string } & GitHubReadOptions): Promise<boolean>;
|
isAuthenticated(options: { cwd: string } & GitHubReadOptions): Promise<boolean>;
|
||||||
retainCurrentPullRequestStatusPoll?(options: {
|
retainCurrentPullRequestStatusPoll?(options: {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -577,6 +703,13 @@ interface CommandFailureLike {
|
|||||||
type PullRequestCheckRunNode = z.infer<typeof PullRequestCheckRunNodeSchema>;
|
type PullRequestCheckRunNode = z.infer<typeof PullRequestCheckRunNodeSchema>;
|
||||||
type PullRequestStatusContextNode = z.infer<typeof PullRequestStatusContextNodeSchema>;
|
type PullRequestStatusContextNode = z.infer<typeof PullRequestStatusContextNodeSchema>;
|
||||||
type CurrentPullRequestStatusItem = z.infer<typeof CurrentPullRequestStatusSchema>;
|
type CurrentPullRequestStatusItem = z.infer<typeof CurrentPullRequestStatusSchema>;
|
||||||
|
type GitHubPullRequestFactsGraphql = z.infer<typeof GitHubPullRequestFactsGraphqlSchema>;
|
||||||
|
type GitHubPullRequestFactsRepository = NonNullable<
|
||||||
|
GitHubPullRequestFactsGraphql["data"]["repository"]
|
||||||
|
>;
|
||||||
|
type GitHubPullRequestFactsPullRequest = NonNullable<
|
||||||
|
GitHubPullRequestFactsRepository["pullRequest"]
|
||||||
|
>;
|
||||||
|
|
||||||
interface InFlightCacheEntry {
|
interface InFlightCacheEntry {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -882,12 +1015,13 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
},
|
},
|
||||||
readOptions: input,
|
readOptions: input,
|
||||||
load: async () => {
|
load: async () => {
|
||||||
return resolveCurrentPullRequestView({
|
const status = await resolveCurrentPullRequestView({
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
headRef: input.headRef,
|
headRef: input.headRef,
|
||||||
headRepositoryOwner: input.headRepositoryOwner,
|
headRepositoryOwner: input.headRepositoryOwner,
|
||||||
run,
|
run,
|
||||||
});
|
});
|
||||||
|
return addCurrentPullRequestGithubFacts({ cwd: input.cwd, status, run });
|
||||||
},
|
},
|
||||||
}).then((status) => {
|
}).then((status) => {
|
||||||
updatePollTargetAfterSuccess({
|
updatePollTargetAfterSuccess({
|
||||||
@@ -1051,6 +1185,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
},
|
},
|
||||||
|
|
||||||
async mergePullRequest(input) {
|
async mergePullRequest(input) {
|
||||||
|
assertDirectPullRequestMergeReady(input);
|
||||||
await run(["pr", "merge", String(input.prNumber), `--${input.mergeMethod}`], {
|
await run(["pr", "merge", String(input.prNumber), `--${input.mergeMethod}`], {
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
envOverlay: { GH_PROMPT_DISABLED: "1" },
|
envOverlay: { GH_PROMPT_DISABLED: "1" },
|
||||||
@@ -1058,6 +1193,24 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async enablePullRequestAutoMerge(input) {
|
||||||
|
assertPullRequestAutoMergeEnableReady(input);
|
||||||
|
await run(["pr", "merge", String(input.prNumber), "--auto", `--${input.mergeMethod}`], {
|
||||||
|
cwd: input.cwd,
|
||||||
|
envOverlay: { GH_PROMPT_DISABLED: "1" },
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
async disablePullRequestAutoMerge(input) {
|
||||||
|
assertPullRequestAutoMergeDisableReady(input);
|
||||||
|
await run(["pr", "merge", String(input.prNumber), "--disable-auto"], {
|
||||||
|
cwd: input.cwd,
|
||||||
|
envOverlay: { GH_PROMPT_DISABLED: "1" },
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
isAuthenticated(input) {
|
isAuthenticated(input) {
|
||||||
return cached({
|
return cached({
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
@@ -1161,6 +1314,89 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
return api;
|
return api;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertDirectPullRequestMergeReady(input: MergeGitHubPullRequestOptions): void {
|
||||||
|
const github = input.status?.github;
|
||||||
|
if (!github) {
|
||||||
|
throw new Error("GitHub merge facts are unavailable for this pull request");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DIRECT_PULL_REQUEST_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? "")) {
|
||||||
|
throw new Error("GitHub does not report this pull request as ready for direct merge");
|
||||||
|
}
|
||||||
|
if (github.isMergeQueueEnabled || github.isInMergeQueue) {
|
||||||
|
throw new Error("Direct merge is not available because this repository uses a merge queue");
|
||||||
|
}
|
||||||
|
if (github.autoMergeRequest !== null) {
|
||||||
|
throw new Error("Direct merge is not available because auto-merge is already enabled");
|
||||||
|
}
|
||||||
|
if (!isPullRequestMergeMethodAllowed(github.repository, input.mergeMethod)) {
|
||||||
|
throw new Error(`Direct merge is not available because ${input.mergeMethod} is disabled`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertPullRequestAutoMergeEnableReady(
|
||||||
|
input: Pick<EnableGitHubPullRequestAutoMergeOptions, "mergeMethod" | "status">,
|
||||||
|
): void {
|
||||||
|
const github = input.status?.github;
|
||||||
|
if (!github) {
|
||||||
|
throw new Error("GitHub auto-merge facts are unavailable for this pull request");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (github.mergeStateStatus !== "BLOCKED") {
|
||||||
|
throw new Error("GitHub does not report this pull request as blocked for auto-merge");
|
||||||
|
}
|
||||||
|
if (!github.viewerCanEnableAutoMerge) {
|
||||||
|
throw new Error("GitHub does not allow this viewer to enable auto-merge");
|
||||||
|
}
|
||||||
|
if (!github.repository.autoMergeAllowed) {
|
||||||
|
throw new Error("Auto-merge is disabled for this repository");
|
||||||
|
}
|
||||||
|
if (!isPullRequestMergeMethodAllowed(github.repository, input.mergeMethod)) {
|
||||||
|
throw new Error(`Auto-merge is not available because ${input.mergeMethod} is disabled`);
|
||||||
|
}
|
||||||
|
if (github.autoMergeRequest !== null) {
|
||||||
|
throw new Error("Auto-merge is already enabled for this pull request");
|
||||||
|
}
|
||||||
|
if (github.isMergeQueueEnabled || github.isInMergeQueue) {
|
||||||
|
throw new Error("Auto-merge is not available because this repository uses a merge queue");
|
||||||
|
}
|
||||||
|
if (input.status?.mergeable === "CONFLICTING") {
|
||||||
|
throw new Error("Auto-merge is not available because this pull request has conflicts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertPullRequestAutoMergeDisableReady(
|
||||||
|
input: Pick<DisableGitHubPullRequestAutoMergeOptions, "status">,
|
||||||
|
): void {
|
||||||
|
const github = input.status?.github;
|
||||||
|
if (!github) {
|
||||||
|
throw new Error("GitHub auto-merge facts are unavailable for this pull request");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (github.autoMergeRequest === null) {
|
||||||
|
throw new Error("Auto-merge is not enabled for this pull request");
|
||||||
|
}
|
||||||
|
if (!github.viewerCanDisableAutoMerge) {
|
||||||
|
throw new Error("GitHub does not allow this viewer to disable auto-merge");
|
||||||
|
}
|
||||||
|
if (github.isMergeQueueEnabled || github.isInMergeQueue) {
|
||||||
|
throw new Error("Auto-merge is not available because this repository uses a merge queue");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPullRequestMergeMethodAllowed(
|
||||||
|
repository: GitHubPullRequestStatusFacts["repository"],
|
||||||
|
method: GitHubPullRequestMergeMethod,
|
||||||
|
): boolean {
|
||||||
|
if (method === "squash") {
|
||||||
|
return repository.squashMergeAllowed;
|
||||||
|
}
|
||||||
|
if (method === "merge") {
|
||||||
|
return repository.mergeCommitAllowed;
|
||||||
|
}
|
||||||
|
return repository.rebaseMergeAllowed;
|
||||||
|
}
|
||||||
|
|
||||||
export function computeGithubNextInterval(
|
export function computeGithubNextInterval(
|
||||||
status: GitHubCurrentPullRequestStatus | null,
|
status: GitHubCurrentPullRequestStatus | null,
|
||||||
consecutiveErrors: number,
|
consecutiveErrors: number,
|
||||||
@@ -1372,6 +1608,68 @@ async function resolveCurrentPullRequestView(options: {
|
|||||||
return match?.status ?? null;
|
return match?.status ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function addCurrentPullRequestGithubFacts(options: {
|
||||||
|
cwd: string;
|
||||||
|
status: GitHubCurrentPullRequestStatus | null;
|
||||||
|
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||||
|
}): Promise<GitHubCurrentPullRequestStatus | null> {
|
||||||
|
const { status } = options;
|
||||||
|
if (!status?.repoOwner || !status.repoName || typeof status.number !== "number") {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const facts = await loadPullRequestGithubFacts({
|
||||||
|
cwd: options.cwd,
|
||||||
|
owner: status.repoOwner,
|
||||||
|
name: status.repoName,
|
||||||
|
number: status.number,
|
||||||
|
run: options.run,
|
||||||
|
});
|
||||||
|
if (!facts) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...status,
|
||||||
|
github: facts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPullRequestGithubFacts(options: {
|
||||||
|
cwd: string;
|
||||||
|
owner: string;
|
||||||
|
name: string;
|
||||||
|
number: number;
|
||||||
|
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||||
|
}): Promise<GitHubPullRequestStatusFacts | null> {
|
||||||
|
try {
|
||||||
|
const stdout = await options.run(
|
||||||
|
[
|
||||||
|
"api",
|
||||||
|
"graphql",
|
||||||
|
"-f",
|
||||||
|
`query=${PULL_REQUEST_STATUS_FACTS_QUERY}`,
|
||||||
|
"-F",
|
||||||
|
`owner=${options.owner}`,
|
||||||
|
"-F",
|
||||||
|
`name=${options.name}`,
|
||||||
|
"-F",
|
||||||
|
`number=${options.number}`,
|
||||||
|
],
|
||||||
|
{ cwd: options.cwd },
|
||||||
|
);
|
||||||
|
return parsePullRequestGithubFacts(stdout);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof GitHubCommandError ||
|
||||||
|
error instanceof z.ZodError ||
|
||||||
|
error instanceof SyntaxError
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function tryCurrentPullRequestView(options: {
|
async function tryCurrentPullRequestView(options: {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
headRef: string;
|
headRef: string;
|
||||||
@@ -1469,6 +1767,52 @@ function parseCurrentPullRequestCandidateList(
|
|||||||
.filter((candidate): candidate is ResolvedPullRequestCandidate => candidate !== null);
|
.filter((candidate): candidate is ResolvedPullRequestCandidate => candidate !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePullRequestGithubFacts(stdout: string): GitHubPullRequestStatusFacts | null {
|
||||||
|
const parsed = GitHubPullRequestFactsGraphqlSchema.parse(JSON.parse(stdout || "{}"));
|
||||||
|
const repository = parsed.data.repository;
|
||||||
|
const pullRequest = repository?.pullRequest;
|
||||||
|
if (!repository || !pullRequest) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mergeStateStatus: pullRequest.mergeStateStatus ?? null,
|
||||||
|
autoMergeRequest: toGitHubAutoMergeRequest(pullRequest.autoMergeRequest),
|
||||||
|
viewerCanEnableAutoMerge: pullRequest.viewerCanEnableAutoMerge ?? false,
|
||||||
|
viewerCanDisableAutoMerge: pullRequest.viewerCanDisableAutoMerge ?? false,
|
||||||
|
viewerCanMergeAsAdmin: pullRequest.viewerCanMergeAsAdmin ?? false,
|
||||||
|
viewerCanUpdateBranch: pullRequest.viewerCanUpdateBranch ?? false,
|
||||||
|
repository: toGitHubRepositoryMergePolicy(repository),
|
||||||
|
isMergeQueueEnabled: pullRequest.isMergeQueueEnabled ?? false,
|
||||||
|
isInMergeQueue: pullRequest.isInMergeQueue ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGitHubAutoMergeRequest(
|
||||||
|
request: GitHubPullRequestFactsPullRequest["autoMergeRequest"],
|
||||||
|
): GitHubPullRequestStatusFacts["autoMergeRequest"] {
|
||||||
|
if (!request) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabledAt: request.enabledAt ?? null,
|
||||||
|
mergeMethod: request.mergeMethod ?? null,
|
||||||
|
enabledBy: request.enabledBy?.login ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGitHubRepositoryMergePolicy(
|
||||||
|
repository: GitHubPullRequestFactsRepository,
|
||||||
|
): GitHubPullRequestStatusFacts["repository"] {
|
||||||
|
return {
|
||||||
|
autoMergeAllowed: repository.autoMergeAllowed ?? false,
|
||||||
|
mergeCommitAllowed: repository.mergeCommitAllowed ?? false,
|
||||||
|
squashMergeAllowed: repository.squashMergeAllowed ?? false,
|
||||||
|
rebaseMergeAllowed: repository.rebaseMergeAllowed ?? false,
|
||||||
|
viewerDefaultMergeMethod: repository.viewerDefaultMergeMethod ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function toCurrentPullRequestCandidate(
|
function toCurrentPullRequestCandidate(
|
||||||
item: CurrentPullRequestStatusItem,
|
item: CurrentPullRequestStatusItem,
|
||||||
fallbackHeadRefName: string,
|
fallbackHeadRefName: string,
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { CheckoutPrMergeRequestSchema, CheckoutPrStatusSchema } from "./messages.js";
|
import {
|
||||||
|
CheckoutGithubSetAutoMergeRequestSchema,
|
||||||
|
CheckoutGithubSetAutoMergeResponseSchema,
|
||||||
|
CheckoutPrMergeRequestSchema,
|
||||||
|
CheckoutPrStatusSchema,
|
||||||
|
ServerInfoStatusPayloadSchema,
|
||||||
|
} from "./messages.js";
|
||||||
|
|
||||||
describe("checkout PR schemas", () => {
|
describe("checkout PR schemas", () => {
|
||||||
test("parses PR status payloads without mergeability", () => {
|
test("parses PR status payloads without mergeability", () => {
|
||||||
@@ -20,6 +26,67 @@ describe("checkout PR schemas", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps missing provider-specific GitHub PR facts absent for old daemons", () => {
|
||||||
|
const parsed = CheckoutPrStatusSchema.parse({
|
||||||
|
number: 42,
|
||||||
|
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||||
|
title: "Ship it",
|
||||||
|
state: "open",
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "feature/ship-it",
|
||||||
|
isMerged: false,
|
||||||
|
mergeable: "MERGEABLE",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.github).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses provider-specific GitHub PR status facts", () => {
|
||||||
|
expect(
|
||||||
|
CheckoutPrStatusSchema.parse({
|
||||||
|
number: 993,
|
||||||
|
url: "https://github.com/getpaseo/paseo/pull/993",
|
||||||
|
title: "Block direct merge while checks run",
|
||||||
|
state: "open",
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "phase-2",
|
||||||
|
isMerged: false,
|
||||||
|
mergeable: "MERGEABLE",
|
||||||
|
checks: [{ name: "server tests", status: "pending", url: null }],
|
||||||
|
checksStatus: "pending",
|
||||||
|
github: {
|
||||||
|
mergeStateStatus: "BLOCKED",
|
||||||
|
autoMergeRequest: null,
|
||||||
|
viewerCanEnableAutoMerge: true,
|
||||||
|
viewerCanDisableAutoMerge: false,
|
||||||
|
viewerCanMergeAsAdmin: false,
|
||||||
|
viewerCanUpdateBranch: true,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
mergeCommitAllowed: false,
|
||||||
|
squashMergeAllowed: true,
|
||||||
|
rebaseMergeAllowed: false,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
},
|
||||||
|
isMergeQueueEnabled: false,
|
||||||
|
isInMergeQueue: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
mergeable: "MERGEABLE",
|
||||||
|
checksStatus: "pending",
|
||||||
|
github: {
|
||||||
|
mergeStateStatus: "BLOCKED",
|
||||||
|
viewerCanEnableAutoMerge: true,
|
||||||
|
repository: {
|
||||||
|
autoMergeAllowed: true,
|
||||||
|
squashMergeAllowed: true,
|
||||||
|
viewerDefaultMergeMethod: "SQUASH",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.each(["merge", "squash", "rebase"] as const)(
|
test.each(["merge", "squash", "rebase"] as const)(
|
||||||
"accepts %s as a PR merge method",
|
"accepts %s as a PR merge method",
|
||||||
(mergeMethod) => {
|
(mergeMethod) => {
|
||||||
@@ -44,4 +111,79 @@ describe("checkout PR schemas", () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each(["merge", "squash", "rebase"] as const)(
|
||||||
|
"accepts %s as a GitHub set-auto-merge enable method",
|
||||||
|
(mergeMethod) => {
|
||||||
|
expect(
|
||||||
|
CheckoutGithubSetAutoMergeRequestSchema.parse({
|
||||||
|
type: "checkout.github.set_auto_merge.request",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
enabled: true,
|
||||||
|
mergeMethod,
|
||||||
|
requestId: "request-enable-auto-merge",
|
||||||
|
}),
|
||||||
|
).toMatchObject({ enabled: true, mergeMethod });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("rejects unknown GitHub set-auto-merge enable methods", () => {
|
||||||
|
expect(() =>
|
||||||
|
CheckoutGithubSetAutoMergeRequestSchema.parse({
|
||||||
|
type: "checkout.github.set_auto_merge.request",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
enabled: true,
|
||||||
|
mergeMethod: "auto",
|
||||||
|
requestId: "request-enable-auto-merge",
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts GitHub set-auto-merge disable requests", () => {
|
||||||
|
expect(
|
||||||
|
CheckoutGithubSetAutoMergeRequestSchema.parse({
|
||||||
|
type: "checkout.github.set_auto_merge.request",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
enabled: false,
|
||||||
|
requestId: "request-disable-auto-merge",
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
enabled: false,
|
||||||
|
requestId: "request-disable-auto-merge",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts GitHub set-auto-merge responses", () => {
|
||||||
|
const payload = {
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
enabled: true,
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
requestId: "request-auto-merge",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
CheckoutGithubSetAutoMergeResponseSchema.parse({
|
||||||
|
type: "checkout.github.set_auto_merge.response",
|
||||||
|
payload,
|
||||||
|
}).payload,
|
||||||
|
).toEqual(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts the GitHub auto-merge server_info feature flag", () => {
|
||||||
|
expect(
|
||||||
|
ServerInfoStatusPayloadSchema.parse({
|
||||||
|
status: "server_info",
|
||||||
|
serverId: "srv_test",
|
||||||
|
features: {
|
||||||
|
providersSnapshot: true,
|
||||||
|
checkoutGithubSetAutoMerge: true,
|
||||||
|
},
|
||||||
|
}).features,
|
||||||
|
).toEqual({
|
||||||
|
providersSnapshot: true,
|
||||||
|
checkoutGithubSetAutoMerge: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1314,6 +1314,14 @@ export const CheckoutPrMergeRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CheckoutGithubSetAutoMergeRequestSchema = z.object({
|
||||||
|
type: z.literal("checkout.github.set_auto_merge.request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const CheckoutPrStatusRequestSchema = z.object({
|
export const CheckoutPrStatusRequestSchema = z.object({
|
||||||
type: z.literal("checkout_pr_status_request"),
|
type: z.literal("checkout_pr_status_request"),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -1763,6 +1771,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutPushRequestSchema,
|
CheckoutPushRequestSchema,
|
||||||
CheckoutPrCreateRequestSchema,
|
CheckoutPrCreateRequestSchema,
|
||||||
CheckoutPrMergeRequestSchema,
|
CheckoutPrMergeRequestSchema,
|
||||||
|
CheckoutGithubSetAutoMergeRequestSchema,
|
||||||
CheckoutPrStatusRequestSchema,
|
CheckoutPrStatusRequestSchema,
|
||||||
PullRequestTimelineRequestSchema,
|
PullRequestTimelineRequestSchema,
|
||||||
CheckoutSwitchBranchRequestSchema,
|
CheckoutSwitchBranchRequestSchema,
|
||||||
@@ -1985,6 +1994,7 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
features: z
|
features: z
|
||||||
.object({
|
.object({
|
||||||
providersSnapshot: z.boolean().optional(),
|
providersSnapshot: z.boolean().optional(),
|
||||||
|
checkoutGithubSetAutoMerge: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
@@ -2705,6 +2715,47 @@ export const CheckoutStatusResponseSchema = z.object({
|
|||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const CheckoutPrGithubAutoMergeRequestSchema = z
|
||||||
|
.object({
|
||||||
|
enabledAt: z.string().nullable().optional().default(null),
|
||||||
|
mergeMethod: z.string().nullable().optional().default(null),
|
||||||
|
enabledBy: z.string().nullable().optional().default(null),
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.default(null);
|
||||||
|
|
||||||
|
const CheckoutPrGithubRepositoryPolicySchema = z
|
||||||
|
.object({
|
||||||
|
autoMergeAllowed: z.boolean().optional().default(false),
|
||||||
|
mergeCommitAllowed: z.boolean().optional().default(false),
|
||||||
|
squashMergeAllowed: z.boolean().optional().default(false),
|
||||||
|
rebaseMergeAllowed: z.boolean().optional().default(false),
|
||||||
|
viewerDefaultMergeMethod: z.string().nullable().optional().default(null),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.default({
|
||||||
|
autoMergeAllowed: false,
|
||||||
|
mergeCommitAllowed: false,
|
||||||
|
squashMergeAllowed: false,
|
||||||
|
rebaseMergeAllowed: false,
|
||||||
|
viewerDefaultMergeMethod: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const CheckoutPrGithubStatusSchema = z
|
||||||
|
.object({
|
||||||
|
mergeStateStatus: z.string().nullable().optional().default(null),
|
||||||
|
autoMergeRequest: CheckoutPrGithubAutoMergeRequestSchema,
|
||||||
|
viewerCanEnableAutoMerge: z.boolean().optional().default(false),
|
||||||
|
viewerCanDisableAutoMerge: z.boolean().optional().default(false),
|
||||||
|
viewerCanMergeAsAdmin: z.boolean().optional().default(false),
|
||||||
|
viewerCanUpdateBranch: z.boolean().optional().default(false),
|
||||||
|
repository: CheckoutPrGithubRepositoryPolicySchema,
|
||||||
|
isMergeQueueEnabled: z.boolean().optional().default(false),
|
||||||
|
isInMergeQueue: z.boolean().optional().default(false),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
export const CheckoutPrStatusSchema = z.object({
|
export const CheckoutPrStatusSchema = z.object({
|
||||||
number: z.number().optional(),
|
number: z.number().optional(),
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
@@ -2735,6 +2786,7 @@ export const CheckoutPrStatusSchema = z.object({
|
|||||||
reviewDecision: z.string().nullable().optional(),
|
reviewDecision: z.string().nullable().optional(),
|
||||||
repoOwner: z.string().optional(),
|
repoOwner: z.string().optional(),
|
||||||
repoName: z.string().optional(),
|
repoName: z.string().optional(),
|
||||||
|
github: CheckoutPrGithubStatusSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
const CheckoutPrStatusPayloadSchema = z.object({
|
const CheckoutPrStatusPayloadSchema = z.object({
|
||||||
@@ -2850,6 +2902,17 @@ export const CheckoutPrMergeResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CheckoutGithubSetAutoMergeResponseSchema = z.object({
|
||||||
|
type: z.literal("checkout.github.set_auto_merge.response"),
|
||||||
|
payload: z.object({
|
||||||
|
cwd: z.string(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: CheckoutErrorSchema.nullable(),
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const CheckoutPrStatusResponseSchema = z.object({
|
export const CheckoutPrStatusResponseSchema = z.object({
|
||||||
type: z.literal("checkout_pr_status_response"),
|
type: z.literal("checkout_pr_status_response"),
|
||||||
payload: CheckoutPrStatusPayloadSchema,
|
payload: CheckoutPrStatusPayloadSchema,
|
||||||
@@ -3408,6 +3471,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutPushResponseSchema,
|
CheckoutPushResponseSchema,
|
||||||
CheckoutPrCreateResponseSchema,
|
CheckoutPrCreateResponseSchema,
|
||||||
CheckoutPrMergeResponseSchema,
|
CheckoutPrMergeResponseSchema,
|
||||||
|
CheckoutGithubSetAutoMergeResponseSchema,
|
||||||
CheckoutPrStatusResponseSchema,
|
CheckoutPrStatusResponseSchema,
|
||||||
PullRequestTimelineResponseSchema,
|
PullRequestTimelineResponseSchema,
|
||||||
CheckoutSwitchBranchResponseSchema,
|
CheckoutSwitchBranchResponseSchema,
|
||||||
@@ -3664,6 +3728,12 @@ export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSc
|
|||||||
export type CheckoutPrMergeRequest = z.infer<typeof CheckoutPrMergeRequestSchema>;
|
export type CheckoutPrMergeRequest = z.infer<typeof CheckoutPrMergeRequestSchema>;
|
||||||
export type CheckoutPrMergeResponse = z.infer<typeof CheckoutPrMergeResponseSchema>;
|
export type CheckoutPrMergeResponse = z.infer<typeof CheckoutPrMergeResponseSchema>;
|
||||||
export type CheckoutPrMergeMethod = z.infer<typeof CheckoutPrMergeRequestSchema>["mergeMethod"];
|
export type CheckoutPrMergeMethod = z.infer<typeof CheckoutPrMergeRequestSchema>["mergeMethod"];
|
||||||
|
export type CheckoutGithubSetAutoMergeRequest = z.infer<
|
||||||
|
typeof CheckoutGithubSetAutoMergeRequestSchema
|
||||||
|
>;
|
||||||
|
export type CheckoutGithubSetAutoMergeResponse = z.infer<
|
||||||
|
typeof CheckoutGithubSetAutoMergeResponseSchema
|
||||||
|
>;
|
||||||
export type PullRequestMergeable = z.infer<typeof CheckoutPrStatusSchema>["mergeable"];
|
export type PullRequestMergeable = z.infer<typeof CheckoutPrStatusSchema>["mergeable"];
|
||||||
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
|
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
|
||||||
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
|
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
|
||||||
|
|||||||
@@ -1708,6 +1708,30 @@ const x = 1;
|
|||||||
expect(callCount).toBe(1);
|
expect(callCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes forced PR status reads through to the GitHub service", async () => {
|
||||||
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||||
|
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requested: Array<{ force?: boolean; reason?: string }> = [];
|
||||||
|
const github = createGitHubServiceForStatus(null);
|
||||||
|
github.getCurrentPullRequestStatus = async (options) => {
|
||||||
|
requested.push({
|
||||||
|
...(options.force ? { force: options.force } : {}),
|
||||||
|
...(options.reason ? { reason: options.reason } : {}),
|
||||||
|
});
|
||||||
|
return createPullRequestStatus();
|
||||||
|
};
|
||||||
|
|
||||||
|
await getPullRequestStatus(repoDir, github, {
|
||||||
|
force: true,
|
||||||
|
reason: "merge-pr-validation",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requested).toEqual([{ force: true, reason: "merge-pr-validation" }]);
|
||||||
|
});
|
||||||
|
|
||||||
it("expires cached PR status after the TTL", async () => {
|
it("expires cached PR status after the TTL", async () => {
|
||||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||||
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
|
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
|
||||||
@@ -1777,6 +1801,39 @@ const x = 1;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not use stale PR status fallback for forced GitHub errors", async () => {
|
||||||
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||||
|
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const github = createGitHubServiceForStatus(null);
|
||||||
|
github.getCurrentPullRequestStatus = async () =>
|
||||||
|
createPullRequestStatus({
|
||||||
|
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fresh = await getPullRequestStatus(repoDir, github);
|
||||||
|
expect(fresh.status?.url).toContain("/pull/123");
|
||||||
|
|
||||||
|
const error = new GitHubCommandError({
|
||||||
|
args: ["pr", "view"],
|
||||||
|
cwd: repoDir,
|
||||||
|
exitCode: 1,
|
||||||
|
stderr: "could not resolve host: github.com",
|
||||||
|
});
|
||||||
|
github.getCurrentPullRequestStatus = async () => {
|
||||||
|
throw error;
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
getPullRequestStatus(repoDir, github, {
|
||||||
|
force: true,
|
||||||
|
reason: "merge-pr-validation",
|
||||||
|
}),
|
||||||
|
).rejects.toBe(error);
|
||||||
|
});
|
||||||
|
|
||||||
it("clears stale PR status after a successful no-PR refresh", async () => {
|
it("clears stale PR status after a successful no-PR refresh", async () => {
|
||||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||||
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
|
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
GitHubCommandError,
|
GitHubCommandError,
|
||||||
createGitHubService,
|
createGitHubService,
|
||||||
resolveGitHubRepo,
|
resolveGitHubRepo,
|
||||||
|
type GitHubCurrentPullRequestStatus,
|
||||||
|
type GitHubPullRequestStatusFacts,
|
||||||
type GitHubService,
|
type GitHubService,
|
||||||
type PullRequestMergeable,
|
type PullRequestMergeable,
|
||||||
} from "../services/github-service.js";
|
} from "../services/github-service.js";
|
||||||
@@ -2318,6 +2320,7 @@ export interface PullRequestStatus {
|
|||||||
checks?: PullRequestCheck[];
|
checks?: PullRequestCheck[];
|
||||||
checksStatus?: ChecksStatus;
|
checksStatus?: ChecksStatus;
|
||||||
reviewDecision?: ReviewDecision;
|
reviewDecision?: ReviewDecision;
|
||||||
|
github?: GitHubPullRequestStatusFacts;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PullRequestStatusResult {
|
export interface PullRequestStatusResult {
|
||||||
@@ -2402,7 +2405,7 @@ export async function getPullRequestStatus(
|
|||||||
return status;
|
return status;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error instanceof GitHubCommandError) {
|
if (!options?.force && error instanceof GitHubCommandError) {
|
||||||
const stale = lastSuccessfulPullRequestStatus.get(cacheKey);
|
const stale = lastSuccessfulPullRequestStatus.get(cacheKey);
|
||||||
if (stale) {
|
if (stale) {
|
||||||
return stale;
|
return stale;
|
||||||
@@ -2433,11 +2436,25 @@ async function getPullRequestStatusUncached(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head);
|
const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head);
|
||||||
const status = await github.getCurrentPullRequestStatus({
|
let status: GitHubCurrentPullRequestStatus | null;
|
||||||
cwd,
|
if (options?.force) {
|
||||||
...lookupTarget,
|
const reason = options.reason;
|
||||||
reason: options?.reason,
|
if (!reason) {
|
||||||
});
|
throw new Error("Forced PR status read requires a reason");
|
||||||
|
}
|
||||||
|
status = await github.getCurrentPullRequestStatus({
|
||||||
|
cwd,
|
||||||
|
...lookupTarget,
|
||||||
|
force: true,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
status = await github.getCurrentPullRequestStatus({
|
||||||
|
cwd,
|
||||||
|
...lookupTarget,
|
||||||
|
reason: options?.reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
githubFeaturesEnabled: true,
|
githubFeaturesEnabled: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user