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
78 changes: 65 additions & 13 deletions apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,36 @@
import {
EnvironmentId,
ProjectId,
GitCommandError,
GitManagerError,
ThreadId,
type ScopedThreadRef,
type PullRequestDetailView,
type ThreadPullRequestLink,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
import { act, type ReactNode, type ReactElement, type ComponentProps } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { DraftId, useComposerDraftStore } from "~/composerDraftStore";

const { newThread, prepareThread, refresh, Wrapper, Trigger } = vi.hoisted(() => ({
newThread: vi.fn(),
prepareThread: vi.fn(),
refresh: vi.fn(),
Wrapper: ({ children }: { children?: ReactNode }) => children,
Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => (
<>
{render}
{children}
</>
),
}));
const { newThread, prepareThread, refresh, addToast, updateToast, Wrapper, Trigger } = vi.hoisted(
() => ({
newThread: vi.fn(),
prepareThread: vi.fn(),
refresh: vi.fn(),
addToast: vi.fn(),
updateToast: vi.fn(),
Wrapper: ({ children }: { children?: ReactNode }) => children,
Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => (
<>
{render}
{children}
</>
),
}),
);
vi.mock("~/state/session", async (original) => ({
...(await original<typeof import("~/state/session")>()),
useEnvironmentScope: () => true,
Expand Down Expand Up @@ -72,7 +79,7 @@ vi.mock("~/state/usePullRequestStack", () => ({
refresh,
}),
}));
vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn(), update: vi.fn() } }));
vi.mock("../ui/toast", () => ({ toastManager: { add: addToast, update: updateToast } }));
vi.mock("../ui/tooltip", () => ({
TooltipProvider: Wrapper,
Tooltip: Wrapper,
Expand Down Expand Up @@ -217,6 +224,8 @@ beforeEach(() => {
newThread
.mockReset()
.mockResolvedValue({ draftId: newDraftId, threadId: ThreadId.make("new-thread") });
addToast.mockReset().mockReturnValue("checkout-toast");
updateToast.mockReset();
prepareThread.mockReset().mockResolvedValue({
_tag: "Success",
value: { branch: "feature", worktreePath: "/workspace/pr" },
Expand Down Expand Up @@ -342,3 +351,46 @@ describe.each([
}
});
});

const checkoutDiagnostic = "This PR branch is already checked out in the main repo. Use Local.";

it.each([
new GitManagerError({
cwd: "/server/private-project",
operation: "preparePullRequestThread",
detail: checkoutDiagnostic,
}),
new GitCommandError({
cwd: "/server/private-project",
operation: "GitVcsDriver.createWorktree",
command: "git worktree add",
detail: checkoutDiagnostic,
}),
])("shows the server detail from a failed checkout ($_tag) without its path", async (error) => {
prepareThread.mockResolvedValueOnce({ _tag: "Failure", cause: Cause.fail(error) });
await act(async () => {
renderer = create(
<PullRequestDetailPanel
environmentId={threadRef.environmentId}
reference={detail}
context="page"
shortcutsEnabled={false}
getShortcutContext={() => ({
terminalFocus: false,
terminalOpen: false,
previewFocus: false,
previewOpen: false,
isWeb: true,
isDesktop: false,
})}
/>,
);
});
await click("In a separate worktree");
expect(updateToast).toHaveBeenCalledWith("checkout-toast", {
type: "error",
title: "Could not prepare the pull request checkout",
description: expect.stringContaining(checkoutDiagnostic),
});
expect(updateToast.mock.lastCall?.[1].description).not.toContain("/server/private-project");
});
15 changes: 11 additions & 4 deletions apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
AuthOrchestrationOperateScope,
AuthSourceControlWriteScope,
type EnvironmentId,
GitCommandError,
type PullRequestAction,
type PullRequestMergeMethod,
type PullRequestListEntry,
Expand All @@ -16,6 +17,7 @@ import {
resolveEnvironmentMachineKind,
type ScopedThreadRef,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import {
ArrowDownUpIcon,
ArrowLeftIcon,
Expand Down Expand Up @@ -244,6 +246,8 @@ const ACTION_FAILURE_HINTS: Record<PullRequestAction, string> = {
const UPDATE_BRANCH_REBASE_FAILURE_HINT =
"The host refused it. A rebase stops at the first commit that does not apply cleanly; updating with a merge commit may still work.";

const isGitCommandError = Schema.is(GitCommandError);

const TABS: ReadonlyArray<{ value: DetailTab; label: string }> = [
{ value: "summary", label: "Summary" },
{ value: "timeline", label: "Timeline" },
Expand Down Expand Up @@ -1241,13 +1245,16 @@ export function PullRequestDetailPanel({
if (prepared._tag === "Failure") {
setHandoff(null);
// The server says what to do about it — that the branch is already checked out in the main
// repository, say — and that sentence is the only way out of the failure.
const detailMessage =
prepareThread.error instanceof Error ? prepareThread.error.message : null;
// repository, say — and that sentence is the only way out of the failure. A failed git
// command's message also names the server's project directory, so only its detail is shown.
const failure = squashAtomCommandFailure(prepared);
toastManager.update(toastId, {
type: "error",
title: "Could not prepare the pull request checkout",
...(detailMessage ? { description: detailMessage } : {}),
description: readableFailure(
isGitCommandError(failure) ? failure.detail : failure,
"Check that the branch still exists and is not checked out elsewhere, then try again.",
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
return;
}
Expand Down
Loading