Files
paseo/packages/app/src/git/policy.test.ts
T
a8ebd390fa feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)
* refactor(forge): forge-neutral foundation (GitHub-only)

Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged.

- Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry.
- GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved.
- forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client).
- createPullRequest reports typed CLI/auth errors instead of a generic message.
- forge-resolver host/remote caches are LRU-bounded.
- Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com.
- Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology.
- Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs.

* feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals

GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals.

- threadIsResolved is part of the neutral timeline item.
- Pipeline load failures show an error instead of an empty section.
- Manual pipeline jobs render as pending.
- Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request).

* feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg)

One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts.

- CI status aggregates commit statuses and Actions runs together.
- Gitea's terminal "warning" state maps to failure on server and client.
- Gitea Actions check details are reachable from the PR pane by workflowRunId.

* refactor(forge): localize compatibility handling

* test(forge): expect normalized GitLab facts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-17 15:03:26 +08:00

1089 lines
32 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { buildGitActions, type BuildGitActionsInput } from "./policy";
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
forge: "github";
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;
};
function githubStatus(overrides: Partial<GithubMergeFactsFixture> = {}): GithubMergeFactsFixture {
return {
forge: "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,
...overrides,
};
}
function createInput(
overrides: Partial<Omit<BuildGitActionsInput, "mergeCapability">> & {
pullRequestGithub?: unknown;
} = {},
): BuildGitActionsInput {
const { pullRequestGithub = null, ...rest } = overrides;
return {
isGit: true,
githubFeaturesEnabled: true,
forgeBrandLabel: "GitHub",
forgeChangeRequestNoun: "PR",
githubAutoMergeActionsEnabled: true,
hasPullRequest: false,
pullRequestUrl: null,
pullRequestState: null,
pullRequestIsDraft: false,
pullRequestIsMerged: false,
pullRequestMergeable: "UNKNOWN",
mergeCapability: deriveMergeCapability(pullRequestGithub),
hasRemote: false,
isPaseoOwnedWorktree: false,
isOnBaseBranch: true,
hasUncommittedChanges: false,
baseRefAvailable: true,
baseRefLabel: "main",
aheadCount: 0,
behindBaseCount: 0,
aheadOfOrigin: 0,
behindOfOrigin: 0,
shouldPromoteArchive: false,
shipDefault: "pr",
runtime: {
commit: {
disabled: false,
status: "idle",
handler: () => undefined,
},
pull: {
disabled: false,
status: "idle",
handler: () => undefined,
},
push: {
disabled: false,
status: "idle",
handler: () => undefined,
},
"pull-and-push": {
disabled: false,
status: "idle",
handler: () => undefined,
},
pr: {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-pr-squash": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-pr-merge": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-pr-rebase": {
disabled: false,
status: "idle",
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": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-from-base": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"archive-workspace": {
disabled: false,
status: "idle",
handler: () => undefined,
},
},
...rest,
};
}
describe("git-actions-policy", () => {
afterEach(async () => {
await i18n.changeLanguage("en");
});
it("shows only remote sync actions on the base branch", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
expect(actions.primary).toBeNull();
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"archive-workspace",
]);
});
it("prioritizes pull when the branch is behind origin", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
behindOfOrigin: 2,
}),
);
expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" });
});
it("keeps push clickable with a clearer message when the branch diverged", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
aheadOfOrigin: 1,
behindOfOrigin: 1,
}),
);
const pushAction = actions.secondary.find((action) => action.id === "push");
expect(pushAction).toMatchObject({
disabled: false,
unavailableMessage:
"Push isn't available yet because there are newer changes to bring in first",
});
});
it("keeps push available for a no-upstream Paseo worktree with local commits", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isPaseoOwnedWorktree: true,
isOnBaseBranch: false,
aheadCount: 1,
aheadOfOrigin: null,
behindOfOrigin: null,
}),
);
const pushAction = actions.secondary.find((action) => action.id === "push");
expect(pushAction).toMatchObject({
disabled: false,
unavailableMessage: undefined,
});
});
it("prioritizes push over pull request merge when local commits are unpushed", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
aheadOfOrigin: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({ id: "push", label: "Push" });
});
it("shows update-from-base only on feature branches that are behind the base branch", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
behindBaseCount: 3,
}),
);
const updateAction = actions.secondary.find((action) => action.id === "merge-from-base");
expect(updateAction).toMatchObject({
label: "Update from main",
disabled: false,
unavailableMessage: undefined,
});
});
it("uses a clear sentence when pull is unavailable", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
const pullAction = actions.secondary.find((action) => action.id === "pull");
expect(pullAction).toMatchObject({
disabled: false,
unavailableMessage: "Pull isn't available because this branch is already up to date",
});
});
it("keeps update-from-base off the base branch entirely", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
behindOfOrigin: 2,
}),
);
expect(actions.secondary.some((action) => action.id === "merge-from-base")).toBe(false);
});
it("keeps feature branch actions available off the base branch", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
behindBaseCount: 1,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
}),
);
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",
"archive-workspace",
]);
expect(
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
).toBe(true);
});
it("enables pull-and-push when the branch has both incoming and outgoing commits", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
aheadOfOrigin: 2,
behindOfOrigin: 3,
}),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action).toMatchObject({
label: "Pull and push",
disabled: false,
unavailableMessage: undefined,
});
});
it("keeps pull-and-push unavailable when the branch only has outgoing commits", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
aheadOfOrigin: 2,
behindOfOrigin: 0,
}),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action).toMatchObject({
label: "Pull and push",
unavailableMessage: expect.any(String),
});
});
it("keeps pull-and-push unavailable when the branch only has incoming commits", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
aheadOfOrigin: 0,
behindOfOrigin: 2,
}),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action).toMatchObject({
label: "Pull and push",
unavailableMessage: expect.any(String),
});
});
it("explains why pull-and-push is unavailable when the branch is in sync", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action).toMatchObject({
disabled: false,
unavailableMessage: "Pull and push isn't available because this branch is already in sync",
});
});
it("explains why pull-and-push is unavailable when there is nothing to pull first", () => {
const actions = buildGitActions(
createInput({ hasRemote: true, aheadOfOrigin: 1, behindOfOrigin: 0 }),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action?.unavailableMessage).toBe(
"Pull and push isn't available because there are no incoming changes to pull first",
);
});
it("explains why pull-and-push is unavailable when there is nothing to push after pulling", () => {
const actions = buildGitActions(
createInput({ hasRemote: true, aheadOfOrigin: 0, behindOfOrigin: 1 }),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action?.unavailableMessage).toBe(
"Pull and push isn't available because there is nothing new to send after pulling",
);
});
it("explains why pull-and-push is unavailable when there are uncommitted changes", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
hasUncommittedChanges: true,
aheadOfOrigin: 1,
}),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action?.unavailableMessage).toBe(
"Pull and push isn't available while you have local changes so commit or stash them first",
);
});
it("hides Git actions for a non-Git workspace", () => {
const directory = buildGitActions(createInput({ isGit: false }));
expect(directory).toEqual({ primary: null, secondary: [], menu: [] });
});
it("offers archive workspace for Git checkouts and worktrees", () => {
const localCheckout = buildGitActions(createInput({ hasUncommittedChanges: true }));
const worktree = buildGitActions(
createInput({ hasUncommittedChanges: true, isPaseoOwnedWorktree: true }),
);
expect(localCheckout.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
expect(worktree.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
});
it("does not promote archive to primary for an idle regular Git checkout", () => {
const actions = buildGitActions(createInput());
expect(actions.primary).toBeNull();
expect(actions.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
});
it("still promotes archive as primary for an idle Paseo-owned worktree", () => {
const actions = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
expect(actions.primary).toMatchObject({ id: "archive-workspace" });
});
it("promotes squash-and-merge when an open PR is mergeable and the branch is in sync", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge PR (squash)",
});
});
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",
}),
);
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge PR (squash)",
});
});
it("offers direct PR merge when GitHub says the PR is mergeable even if the local branch is behind", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
behindBaseCount: 3,
aheadOfOrigin: 0,
behindOfOrigin: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({ mergeStateStatus: "CLEAN" }),
shipDefault: "pr",
}),
);
expect(actions.secondary).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "merge-pr-squash",
disabled: false,
unavailableMessage: undefined,
}),
expect.objectContaining({
id: "merge-pr-merge",
disabled: false,
unavailableMessage: undefined,
}),
expect.objectContaining({
id: "merge-pr-rebase",
disabled: false,
unavailableMessage: undefined,
}),
]),
);
});
it("promotes ready PR merge over update-from-base", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
behindBaseCount: 3,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({ mergeStateStatus: "CLEAN" }),
}),
);
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge PR (squash)",
});
});
it("promotes push over Create PR when local commits are unpushed", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
aheadOfOrigin: 2,
behindBaseCount: 3,
}),
);
expect(actions.primary).toMatchObject({
id: "push",
label: "Push",
});
});
it("uses the forge change-request noun in unavailable no-forge copy", () => {
const createActions = buildGitActions(
createInput({
githubFeaturesEnabled: false,
forgeBrandLabel: "GitLab",
forgeChangeRequestNoun: "MR",
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 1,
}),
);
const viewActions = buildGitActions(
createInput({
githubFeaturesEnabled: false,
forgeBrandLabel: "GitLab",
forgeChangeRequestNoun: "MR",
hasRemote: true,
isOnBaseBranch: false,
hasPullRequest: true,
pullRequestUrl: "https://gitlab.com/example/repo/-/merge_requests/1",
}),
);
const createPrAction = [...createActions.secondary, ...createActions.menu].find(
(action) => action.id === "pr",
);
const viewPrAction = [viewActions.primary, ...viewActions.secondary, ...viewActions.menu].find(
(action) => action?.id === "pr",
);
expect(createPrAction?.unavailableMessage).toBe(
"Create MR isn't available right now because GitLab isn't connected",
);
expect(viewPrAction?.unavailableMessage).toBe(
"View MR isn't available right now because GitLab isn't connected",
);
});
it("uses local merge when merge is the stored ship default", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
behindBaseCount: 3,
shipDefault: "merge",
}),
);
expect(actions.primary).toMatchObject({
id: "merge-branch",
label: "Merge locally",
});
});
it("promotes ready PR merge over local merge", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
}),
);
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge PR (squash)",
});
expect(actions.secondary.some((action) => action.id === "merge-branch")).toBe(true);
});
it("keeps the merge-pr actions in the feature branch menu", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
}),
);
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",
"archive-workspace",
]);
});
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: "Merge PR (squash)",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
unavailableMessage: undefined,
startsGroup: true,
},
{
id: "merge-pr-merge",
label: "Merge PR (merge)",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
unavailableMessage: undefined,
startsGroup: false,
},
{
id: "merge-pr-rebase",
label: "Merge PR (rebase)",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
unavailableMessage: undefined,
startsGroup: false,
},
]);
});
it("uses Merge locally for the local merge action", () => {
const actions = buildGitActions(
createInput({
isOnBaseBranch: false,
aheadCount: 2,
}),
);
const action = actions.secondary.find((entry) => entry.id === "merge-branch");
expect(action).toMatchObject({ label: "Merge locally" });
});
it("uses the active language for policy-owned action labels and unavailable messages", async () => {
await i18n.changeLanguage("zh-CN");
const actions = buildGitActions(
createInput({
hasRemote: true,
behindOfOrigin: 1,
isOnBaseBranch: false,
aheadCount: 0,
}),
);
expect(actions.primary).toMatchObject({
id: "pull",
label: "Pull",
pendingLabel: "正在 pull...",
successLabel: "已 pull",
});
expect(actions.secondary.find((entry) => entry.id === "pr")).toMatchObject({
label: "创建 PR",
unavailableMessage: "无法创建 PR,因为此分支还没有新的 commit",
});
});
it.each([
["draft", { pullRequestIsDraft: true }],
["merged", { pullRequestIsMerged: true }],
["closed", { pullRequestState: "closed" as const }],
["conflicting", { pullRequestMergeable: "CONFLICTING" as const }],
])("does not offer direct merge actions when the PR is %s", (_name, overrides) => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
...overrides,
}),
);
const mergePrActions = actions.secondary.filter((action) =>
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
);
expect(mergePrActions).toEqual([]);
expect(actions.secondary).toEqual(
expect.arrayContaining([expect.objectContaining({ id: "pr", label: "View PR" })]),
);
});
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.forgeSpecific,
shipDefault: "pr",
}),
);
expect(oldDaemonStatus.forgeSpecific).toBeUndefined();
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge PR (squash)",
});
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",
"archive-workspace",
]);
});
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: "Auto merge (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",
"archive-workspace",
]);
expect(
actions.secondary.some((action) =>
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
),
).toBe(false);
});
it.each([
["SQUASH", "enable-pr-auto-merge-squash", "Auto merge (squash)"],
["MERGE", "enable-pr-auto-merge-merge", "Auto merge (merge)"],
["REBASE", "enable-pr-auto-merge-rebase", "Auto merge (rebase)"],
] as const)(
"labels the %s auto-merge action with its method",
(viewerDefaultMergeMethod, id, label) => {
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: true,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod,
},
}),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({ id, label });
},
);
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.primary).toMatchObject({ id: "pr", label: "View PR" });
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: "Merge PR (merge)",
});
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"merge-from-base",
"merge-branch",
"pr",
"merge-pr-merge",
"archive-workspace",
]);
});
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", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
isPaseoOwnedWorktree: true,
}),
);
const allActions = [...actions.secondary];
const groupStarters = allActions
.filter((action) => action.startsGroup)
.map((action) => action.id);
const nonGroupStarters = allActions
.filter((action) => !action.startsGroup)
.map((action) => action.id);
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-workspace"]);
expect(nonGroupStarters).toEqual([
"pull",
"push",
"pull-and-push",
"merge-branch",
"pr",
"merge-pr-merge",
"merge-pr-rebase",
]);
});
});