Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 48 additions & 16 deletions apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ import {

import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
import { useClientSettings } from "~/hooks/useSettings";
import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import {
deriveLogicalProjectKeyFromSettings,
selectProjectGroupingSettings,
} from "~/logicalProject";
import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink";
import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
import { cn } from "~/lib/utils";
Expand All @@ -64,6 +69,7 @@ import { pullRequestEnvironment } from "~/state/pullRequests";
import { useAtomCommand } from "~/state/use-atom-command";
import { vcsEnvironment } from "~/state/vcs";
import { formatRelativeTimeLabel } from "~/timestampFormat";
import { useUiStateStore } from "~/uiStateStore";

import {
AlertDialog,
Expand Down Expand Up @@ -114,8 +120,10 @@ import {
pullRequestComposerTarget,
pullRequestFindingKey,
pullRequestHandoffLabels,
PULL_REQUEST_MERGE_METHOD_LABELS,
readableFailure,
resolveBaseFreshness,
resolvePullRequestMergeMethod,
type PullRequestFinding,
shouldRefreshPullRequestActivity,
} from "./pullRequestDetail.logic";
Expand Down Expand Up @@ -153,12 +161,6 @@ const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = {
"disable-auto-merge": "Auto-merge turned off",
};

const MERGE_METHOD_LABELS: Record<PullRequestMergeMethod, string> = {
merge: "Merge",
squash: "Squash",
rebase: "Rebase",
};

/** Said as the thing that did not happen, rather than as the operation that returned an error. */
const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = {
merge: "Could not merge this pull request",
Expand Down Expand Up @@ -453,7 +455,16 @@ export function PullRequestDetailPanel({
compensationRef.current = null;
if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta);
}, [condensed]);
const [mergeMethod, setMergeMethod] = useState<PullRequestMergeMethod>("merge");
const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod);
const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod);
const mergeMethodOverrides = useClientSettings(
(settings) => settings.pullRequestMergeMethodOverrides,
);
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const [mergeMethodScope, setMergeMethodScope] = useState<{
pullRequestKey: string;
method: PullRequestMergeMethod;
} | null>(null);
const [confirmation, setConfirmation] = useState<{
readonly open: boolean;
readonly action: "merge" | "close" | "enable-auto-merge";
Expand Down Expand Up @@ -590,6 +601,15 @@ export function PullRequestDetailPanel({
const newThread = useNewThreadHandler();
const { environments } = useEnvironments();
const projects = useProjects();
const mergeMethodProject = projects.find(
(project) => project.environmentId === environmentId && project.id === reference.projectId,
);
const mergeMethodProjectKey = mergeMethodProject
? deriveLogicalProjectKeyFromSettings(mergeMethodProject, projectGroupingSettings)
: null;
const projectDefaultMergeMethod = mergeMethodProjectKey
? mergeMethodOverrides[mergeMethodProjectKey]
: undefined;
// Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and
// the thread is already on one server's copy of the branch.
const pickableEnvironments = useMemo(
Expand Down Expand Up @@ -1022,10 +1042,15 @@ export function PullRequestDetailPanel({
const allowedMergeMethods = detail
? detail.capabilities.mergeMethods.filter((method) => detail.mergeCapabilities[method])
: [];
const selectedMergeMethod = allowedMergeMethods.includes(mergeMethod)
? mergeMethod
: (allowedMergeMethods[0] ?? "merge");
const selectedMergeMethodLabel = MERGE_METHOD_LABELS[selectedMergeMethod];
const currentMergeMethod =
mergeMethodScope?.pullRequestKey === pullRequestKey ? mergeMethodScope.method : null;
const selectedMergeMethod = resolvePullRequestMergeMethod(
allowedMergeMethods,
currentMergeMethod,
projectDefaultMergeMethod,
lastSelectedMergeMethod,
);
const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod];
const conflicting = detail?.state === "open" && detail.mergeability === "conflicting";
// Only an outright yes arms it. A host that reports nothing has not said the merge is already
// spoken for, and an off switch for something that may not be on says the wrong thing twice.
Expand Down Expand Up @@ -1418,17 +1443,24 @@ export function PullRequestDetailPanel({
{showsDraftToggle ? <MenuSeparator /> : null}
<MenuRadioGroup
value={selectedMergeMethod}
onValueChange={(method) =>
setMergeMethod(method as PullRequestMergeMethod)
}
onValueChange={(method) => {
const selectedMethod = method as PullRequestMergeMethod;
setMergeMethodScope({ pullRequestKey, method: selectedMethod });
setLastSelectedMergeMethod(selectedMethod);
}}
>
{allowedMergeMethods.map((method) => (
<MenuRadioItem key={method} value={method} disabled={actionPending}>
<MenuRadioItem
key={method}
value={method}
disabled={actionPending}
closeOnClick
>
{/* The radio item lays its children out as one block, so the
icon and the label need their own row to share a line. */}
<span className="flex min-w-0 items-center gap-2">
<GitMergeIcon className="size-3.5" />
<span>{MERGE_METHOD_LABELS[method]}</span>
<span>{PULL_REQUEST_MERGE_METHOD_LABELS[method]}</span>
</span>
</MenuRadioItem>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
readableFailure,
shouldRefreshPullRequestActivity,
resolveBaseFreshness,
resolvePullRequestMergeMethod,
buildPullRequestTimeline,
describePullRequestState,
editPullRequestThreadComment,
Expand Down Expand Up @@ -63,6 +64,21 @@ const TIMELINE_SOURCE: Pick<
closedAt: null,
};

describe("pull request merge method", () => {
it("uses the current choice, then the project default, then the last choice", () => {
expect(
resolvePullRequestMergeMethod(["merge", "squash", "rebase"], null, "squash", "rebase"),
).toBe("squash");
expect(
resolvePullRequestMergeMethod(["merge", "squash", "rebase"], "rebase", "squash", "merge"),
).toBe("rebase");
expect(resolvePullRequestMergeMethod(["merge", "rebase"], null, "squash", "rebase")).toBe(
"rebase",
);
expect(resolvePullRequestMergeMethod(["squash"], null, "merge", "rebase")).toBe("squash");
});
});

describe("pull request activity refresh", () => {
const first = {
key: "project:acme/web#7",
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/components/pullRequest/pullRequestDetail.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
PullRequestCommit,
PullRequestDetailView,
PullRequestMergeability,
PullRequestMergeMethod,
PullRequestReaction,
PullRequestReviewThread,
PullRequestState,
Expand All @@ -16,6 +17,24 @@ import type {

import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext";

export const PULL_REQUEST_MERGE_METHOD_LABELS: Record<PullRequestMergeMethod, string> = {
merge: "Merge",
squash: "Squash and merge",
rebase: "Rebase and merge",
};
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

export function resolvePullRequestMergeMethod(
allowed: ReadonlyArray<PullRequestMergeMethod>,
current: PullRequestMergeMethod | null,
projectDefault: PullRequestMergeMethod | undefined,
lastSelected: PullRequestMergeMethod,
): PullRequestMergeMethod {
for (const method of [current, projectDefault, lastSelected]) {
if (method && allowed.includes(method)) return method;
}
return allowed[0] ?? "merge";
}

/** Activity changes only when the same host resource reports a newer revision. */
export function shouldRefreshPullRequestActivity(
previous: { readonly key: string; readonly updatedAt: string } | null,
Expand Down
57 changes: 57 additions & 0 deletions apps/web/src/components/settings/ProjectSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
ContextMenuItem,
ModelSelection,
ProviderDriverKind,
PullRequestMergeMethod,
SidebarProjectGroupingMode,
T3ProjectFileScript,
ThreadEnvMode,
Expand Down Expand Up @@ -74,6 +75,7 @@ import { useAtomCommand } from "../../state/use-atom-command";
import { ProviderModelPicker } from "../chat/ProviderModelPicker";
import { TraitsPicker } from "../chat/TraitsPicker";
import { ProjectFavicon } from "../ProjectFavicon";
import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
import {
EMPTY_PROJECT_SCRIPT_INPUT,
editorRequestForScript,
Expand Down Expand Up @@ -294,6 +296,9 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
const settings = usePrimarySettings();
const updateClientSettings = useUpdateClientSettings();
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const mergeMethodOverrides = useClientSettings(
(settings) => settings.pullRequestMergeMethodOverrides,
);
const serverProviders = useAtomValue(primaryServerProvidersAtom);
const threads = useThreadShells();
const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });
Expand Down Expand Up @@ -323,6 +328,20 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
group.memberProjects.find(
(member) => member.environmentId === group.environmentId && member.id === group.id,
) ?? group.memberProjects[0]!;
const projectMergeMethod = mergeMethodOverrides[group.projectKey];
const setProjectMergeMethod = useCallback(
(method: PullRequestMergeMethod | null) => {
const nextOverrides = { ...mergeMethodOverrides };
if (method === null) {
delete nextOverrides[group.projectKey];
} else {
nextOverrides[group.projectKey] = method;
}
updateClientSettings({ pullRequestMergeMethodOverrides: nextOverrides });
},
[group.projectKey, mergeMethodOverrides, updateClientSettings],
);

const faviconPath = representative.faviconPath ?? null;
const pickProjectFavicon =
typeof window !== "undefined" &&
Expand Down Expand Up @@ -809,6 +828,44 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
</div>
}
/>
<SettingsRow
title="Default merge method"
description="Pull requests in this project start with this method. It overrides the last method selected."
resetAction={
projectMergeMethod !== undefined ? (
<SettingResetButton
label="project merge method"
onClick={() => setProjectMergeMethod(null)}
/>
) : null
}
control={
<Select
value={projectMergeMethod ?? "inherit"}
onValueChange={(value) => {
if (value === "merge" || value === "squash" || value === "rebase") {
setProjectMergeMethod(value);
} else if (value === "inherit") {
setProjectMergeMethod(null);
}
}}
>
<SelectTrigger aria-label="Default pull request merge method">
<SelectValue>
{projectMergeMethod === undefined
? "Last selected"
: PULL_REQUEST_MERGE_METHOD_LABELS[projectMergeMethod]}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem value="inherit">Last selected</SelectItem>
<SelectItem value="merge">{PULL_REQUEST_MERGE_METHOD_LABELS.merge}</SelectItem>
<SelectItem value="squash">{PULL_REQUEST_MERGE_METHOD_LABELS.squash}</SelectItem>
<SelectItem value="rebase">{PULL_REQUEST_MERGE_METHOD_LABELS.rebase}</SelectItem>
</SelectPopup>
</Select>
}
/>
</SettingsSection>

<SettingsSection title="New threads">
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/uiStateStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState {
threadLastVisitedAtById: {},
threadChangedFilesExpandedById: {},
defaultAdvertisedEndpointKey: null,
pullRequestMergeMethod: "merge",
...overrides,
};
}
Expand Down Expand Up @@ -147,6 +148,18 @@ describe("uiStateStore pure functions", () => {
});

describe("parsePersistedState", () => {
it("hydrates the last selected pull request merge method", () => {
const parsed = parsePersistedState({
pullRequestMergeMethod: "squash",
});
const invalid = parsePersistedState({
pullRequestMergeMethod: "fast-forward",
});

expect(parsed.pullRequestMergeMethod).toBe("squash");
expect(invalid.pullRequestMergeMethod).toBe("merge");
});

it("hydrates raw UI-owned state without server entities", () => {
const parsed = parsePersistedState({
projectExpandedById: {
Expand Down Expand Up @@ -177,6 +190,7 @@ describe("parsePersistedState", () => {
"environment:thread-1": "2026-02-25T12:35:00.000Z",
},
defaultAdvertisedEndpointKey: "desktop-core:lan:http",
pullRequestMergeMethod: "merge",
threadChangedFilesExpandedById: {
"environment:thread-1": {
"turn-1": false,
Expand Down Expand Up @@ -303,6 +317,7 @@ describe("uiStateStore persistence", () => {
"turn-2": true,
},
},
pullRequestMergeMethod: "merge",
});
expect(parsePersistedState(persisted)).toEqual({
...state,
Expand Down
Loading
Loading