* 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>
1089 lines
32 KiB
TypeScript
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",
|
|
]);
|
|
});
|
|
});
|