diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cfbeb3aa7f72..07aa53750271 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7624,6 +7624,7 @@ export default function ChatView(props: ChatViewProps) { : "page" } composerDraftTarget={composerDraftTarget} + onOpenPullRequest={openThreadPullRequest} {...(linkedThreadPullRequest === null ? { onStateChange: handlePullRequestTabStatusChange } : {})} diff --git a/apps/web/src/components/pullRequest/PullRequestDependencyNavigator.tsx b/apps/web/src/components/pullRequest/PullRequestDependencyNavigator.tsx new file mode 100644 index 000000000000..54fa805f94da --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestDependencyNavigator.tsx @@ -0,0 +1,470 @@ +import { + ArrowLeftIcon, + ArrowRightIcon, + CircleDashedIcon, + GitForkIcon, + GitMergeIcon, + GitPullRequestClosedIcon, + GitPullRequestDraftIcon, + LayersIcon, + LoaderCircleIcon, + TriangleAlertIcon, +} from "lucide-react"; +import { useLayoutEffect, useRef } from "react"; + +import { Button } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import type { DependencyChip, DependencyNavigation } from "./pullRequestDependencyNavigation.logic"; + +function stateIcon(chip: DependencyChip) { + if (chip.isDraft) return ; + if (chip.state === "merged") return ; + if (chip.state === "closed") return ; + return null; +} +function chipLabel(chip: DependencyChip) { + const suffix = chip.isDraft + ? " (draft)" + : chip.state && chip.state !== "open" + ? ` (${chip.state})` + : ""; + return `Open #${chip.number}${chip.title ? `: ${chip.title}` : ""}${suffix}`; +} +const Arrow = () => ; +const Dot = () => ( + + · + +); +const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`; + +function DependencyChipButton({ + chip, + onOpen, +}: { + readonly chip: DependencyChip; + readonly onOpen: (number: number) => void; +}) { + return ( + + onOpen(chip.number)} + > + {stateIcon(chip)}#{chip.number} + + } + /> + {chip.title ?? "Details not loaded"} + + ); +} + +function DependencyChoiceChip({ + label, + items, + onOpen, +}: { + readonly label: string; + readonly items: ReadonlyArray; + readonly onOpen: (number: number) => void; +}) { + return ( + + + + {label} + + } + /> + + {items.map((chip) => ( + onOpen(chip.number)}> + {stateIcon(chip)} + #{chip.number} + {chip.title ?? "Not loaded"} + + ))} + + + ); +} + +function NavButton({ + direction, + target, + disabledReason, + disabled, + onOpen, +}: { + readonly direction: "parent" | "child"; + readonly target: number | null; + readonly disabledReason: string; + readonly disabled: boolean; + readonly onOpen: (number: number) => void; +}) { + const Icon = direction === "parent" ? ArrowLeftIcon : ArrowRightIcon; + const label = target === null ? disabledReason : `Open ${direction} #${target}`; + return ( + + + + + } + /> + {label} + + ); +} + +export function PullRequestDependencyNavButtons({ + navigation, + refreshing, + onOpenPullRequest, +}: { + readonly navigation: DependencyNavigation; + readonly refreshing: boolean; + readonly onOpenPullRequest: (number: number) => void; +}) { + if (navigation.status !== "ready") return null; + const show = + navigation.parent !== null || + navigation.child !== null || + navigation.children.length > 0 || + navigation.possibleParents.length > 0 || + navigation.parentAmbiguous; + if (!show) return null; + return ( + <> + 0 || navigation.parentAmbiguous + ? "Parent not confirmed" + : "No confirmed parent" + } + disabled={refreshing} + onOpen={onOpenPullRequest} + /> + 0 && navigation.focusIndex === navigation.path.length - 1 + ? "Choose a child" + : "No confirmed child" + } + disabled={refreshing} + onOpen={onOpenPullRequest} + /> + + ); +} + +export function PullRequestDependencyRow({ + navigation, + hostLabel, + refreshing, + onOpenPullRequest, + onRetry, +}: { + readonly navigation: DependencyNavigation; + readonly hostLabel: string; + readonly refreshing: boolean; + readonly onOpenPullRequest: (number: number) => void; + readonly onRetry?: () => void; +}) { + const trackRef = useRef(null); + const focusRef = useRef(null); + const focusNumber = + navigation.status === "ready" ? navigation.path[navigation.focusIndex]?.number : null; + useLayoutEffect(() => { + if (focusNumber === null) return; + const track = trackRef.current; + const chip = focusRef.current; + if (!track || !chip) return; + track.scrollLeft = chip.offsetLeft - (track.clientWidth - chip.offsetWidth) / 2; + }, [focusNumber]); + if (navigation.status === "hidden" || navigation.status === "pending") return null; + const rowClass = "mt-2 flex min-w-0 items-center gap-2 text-xs text-muted-foreground"; + const retry = onRetry ? ( + + ) : null; + if (navigation.status === "partial-empty") + return ( + + ); + if (navigation.status === "unavailable") + return ( + + ); + const first = navigation.path[0]!; + const last = navigation.path.at(-1)!; + return ( + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 3ed0c61139d3..65d7b4aefca1 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -74,7 +74,7 @@ import { useSharedPullRequestSummary, } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; -import { vcsEnvironment } from "~/state/vcs"; +import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { @@ -122,7 +122,6 @@ import { handoffPrompt, handoffReviewComments, latestPullRequestReviewOutcomes, - isStackedPullRequestBase, pullRequestActionMenuHasGroup, pullRequestActionNeedsHostRefresh, pullRequestComposerTarget, @@ -144,6 +143,11 @@ import { type PickableEnvironment, } from "./pullRequestProjectAssignment.logic"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; +import { + PullRequestDependencyNavButtons, + PullRequestDependencyRow, +} from "./PullRequestDependencyNavigator"; +import { pullRequestDependencyNavigation } from "./pullRequestDependencyNavigation.logic"; import { PullRequestActorLabel, PullRequestDiffStat, @@ -460,6 +464,7 @@ export function PullRequestDetailPanel({ onActed, onClose, onStateChange, + onOpenPullRequest, context = "page", composerDraftTarget, }: { @@ -489,6 +494,8 @@ export function PullRequestDetailPanel({ onClose?: () => void; /** Keeps surrounding inferred thread state in step with refreshed host state. */ onStateChange?: (status: { repository: string; number: number; state: PullRequestState }) => void; + /** Opens a same-repository dependency in the existing PR surface owner. */ + onOpenPullRequest?: (number: number) => void; /** * Beside a thread, the checkout affordance disappears: the panel is showing that thread's * own pull request, so the branch is already under the reader's feet — and checking it out @@ -674,28 +681,36 @@ export function PullRequestDetailPanel({ detail.headRepositoryNameWithOwner, ) : null; - const branchRefsQuery = useEnvironmentQuery( - detail === null + const dependencyContextQuery = useEnvironmentQuery( + detail === null || detail.capabilities.dependencies?.branchRelationships !== true ? null - : vcsEnvironment.listRefs({ - environmentId, - input: { - cwd: detail.workspaceRoot, - includeMatchingRemoteRefs: true, - // listRefs keeps the current ref first and a known default second. - limit: 2, - }, - }), + : pullRequestEnvironment.dependencyContext({ environmentId, input: reference }), ); - const isStackedPullRequest = - detail !== null && - isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []); + const dependencyNavigation = pullRequestDependencyNavigation({ + supported: detail?.capabilities.dependencies?.branchRelationships === true, + context: dependencyContextQuery.data ?? null, + pending: dependencyContextQuery.isPending, + failed: dependencyContextQuery.error !== null, + }); + const dependencyHostLabel = detail + ? getSourceControlPresentationForKind(detail.provider).providerName + : "host"; + const condensedDependencyIndicator = + dependencyNavigation.status === "ready" && + (dependencyNavigation.path.length > 1 || + dependencyNavigation.children.length > 0 || + dependencyNavigation.native.status === "present"); + const condensedDependencyTooltip = + dependencyNavigation.status !== "ready" || dependencyNavigation.path.length <= 1 + ? "Stacked pull request" + : `Stacked pull request · ${dependencyNavigation.focusIndex + 1} of ${dependencyNavigation.path.length}${dependencyNavigation.coverage === "partial" ? " · partial" : ""}`; const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; const refreshDetail = useCallback(() => { detailQuery.refresh(); activityQuery.refresh(); - }, [activityQuery.refresh, detailQuery.refresh]); + dependencyContextQuery.refresh(); + }, [activityQuery.refresh, dependencyContextQuery.refresh, detailQuery.refresh]); const [refreshToken, setRefreshToken] = useState(0); const codeRefreshToken = refreshToken + (turnRefresh ?? 0); const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>( @@ -724,6 +739,7 @@ export function PullRequestDetailPanel({ useLiveRefresh( () => { detailQuery.refresh(); + dependencyContextQuery.refresh(); setRefreshToken((token) => token + 1); }, { key: `pull-request:${reference.projectId}:${reference.repository}#${reference.number}` }, @@ -733,6 +749,10 @@ export function PullRequestDetailPanel({ // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run // and at worst answer from it. const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); + const retryDependencies = useCallback(async () => { + await invalidate({ environmentId, input: { reference } }); + dependencyContextQuery.refresh(); + }, [dependencyContextQuery.refresh, environmentId, invalidate, reference]); const refreshFromHost = useCallback(async () => { await invalidate({ environmentId, input: { reference } }); refreshDetail(); @@ -1903,19 +1923,16 @@ export function PullRequestDetailPanel({ - {isStackedPullRequest ? ( - + {condensedDependencyIndicator ? ( + ) : null} {detail.baseBranch} } /> - {isStackedPullRequest - ? `Stacked on ${detail.baseBranch}` + {condensedDependencyIndicator + ? condensedDependencyTooltip : detail.baseBranch} @@ -1942,6 +1959,13 @@ export function PullRequestDetailPanel({ + {onOpenPullRequest ? ( + + ) : null} - {isStackedPullRequest ? ( - - ) : null} {detail.baseBranch} } /> - - {isStackedPullRequest - ? `Stacked on ${detail.baseBranch}` - : detail.baseBranch} - + {detail.baseBranch} {freshness ? ( + {onOpenPullRequest ? ( + + ) : null} ) : null} diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index c60b1d228d2d..bf1c65fc1ec7 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -18,7 +18,6 @@ import { handoffPrompt, handoffReviewComments, isPullRequestVerdictStale, - isStackedPullRequestBase, isThreadOwnPullRequest, latestPullRequestReviewOutcomes, newestPullRequestCommitAt, @@ -236,47 +235,6 @@ describe("pull request composer target", () => { }); }); -describe("stacked pull request classification", () => { - it("requires a known default branch", () => { - expect(isStackedPullRequestBase("main", [{ name: "main", isDefault: false }])).toBe(false); - }); - - it("recognizes local and remote forms of the default branch", () => { - expect( - isStackedPullRequestBase("main", [{ name: "main", isDefault: true, isRemote: false }]), - ).toBe(false); - expect( - isStackedPullRequestBase("main", [ - { name: "origin/main", isDefault: true, isRemote: true, remoteName: "origin" }, - ]), - ).toBe(false); - }); - - it("classifies a non-default base as stacked once the default is known", () => { - expect( - isStackedPullRequestBase("feature-base", [ - { name: "origin/main", isDefault: true, isRemote: true, remoteName: "origin" }, - ]), - ).toBe(true); - }); - - it("does not mistake a nested branch suffix for the default branch", () => { - expect( - isStackedPullRequestBase("main", [ - { - name: "origin/feature/main", - isDefault: true, - isRemote: true, - remoteName: "origin", - }, - ]), - ).toBe(true); - expect( - isStackedPullRequestBase("1.0", [{ name: "release/1.0", isDefault: true, isRemote: false }]), - ).toBe(true); - }); -}); - describe("ordering comments", () => { it("reverses the chronological list for newest first, and leaves oldest first alone", () => { const comments = [{ createdAt: "a" }, { createdAt: "b" }, { createdAt: "c" }]; diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 494a1ce9285c..be21f06138b3 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -16,7 +16,6 @@ import { type PullRequestState, type PullRequestUpdateMethod, type SourceControlProviderKind, - type VcsRef, } from "@t3tools/contracts"; import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; @@ -176,20 +175,6 @@ export function pullRequestActionMenuHasGroup( return showsDraftToggle || showsAutoMerge || showsMergeMethods; } -export function isStackedPullRequestBase( - baseBranch: string, - refs: ReadonlyArray>, -): boolean { - const defaultRef = refs.find((refName) => refName.isDefault); - if (!defaultRef) return false; - if (defaultRef.isRemote !== true) return defaultRef.name !== baseBranch; - const remotePrefix = `${defaultRef.remoteName ?? defaultRef.name.split("/")[0]}/`; - const defaultBranch = defaultRef.name.startsWith(remotePrefix) - ? defaultRef.name.slice(remotePrefix.length) - : defaultRef.name; - return defaultBranch !== baseBranch; -} - /** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */ export function describePullRequestState(state: PullRequestState, isDraft: boolean): string { if (state === "merged") return "Merged"; diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index bcc22daa4a9d..89059fb10013 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1984,6 +1984,22 @@ function PullRequestsRouteView() { authoredQuery.refresh(); reviewingQuery.refresh(); }} + onOpenPullRequest={(number) => { + if (rightPanelRef === null) return; + const nextSelection = { + environmentId: panelEnvironmentId, + projectId: renderedPullRequestSurface.projectId as ProjectId, + repository: renderedPullRequestSurface.repository, + number, + }; + useRightPanelStore.getState().openPullRequest(rightPanelRef, nextSelection); + updateSearch({ + repository: nextSelection.repository, + number: nextSelection.number, + selectedProjectId: nextSelection.projectId, + selectedEnvironmentId: nextSelection.environmentId, + }); + }} /> ) : null} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 853ef9a63689..beb33374b59d 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -107,6 +107,11 @@ Open **Pull requests** to review changes and comments, request reviewers, check or merge. You can edit review titles and descriptions and your own comments where the host allows it. GitLab calls these merge requests. +When the host can confirm that one pull request targets another pull request's branch, its review +panel shows the dependency chain. Select a related pull request there to open its usual review +panel. T3 marks incomplete discovery instead of guessing whether a release branch or another +unrelated non-default branch is part of a stack. + GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding. GitHub also supports approving waiting fork workflows and opening a revert pull request for a merged change.