import { resolvePullRequestDetailChecks } from "@t3tools/client-runtime/pull-request-checks"; import { capitalizeTerm, sourceControlClients, } from "@t3tools/client-runtime/source-control-clients"; import { PullRequestChangeBreakdownRows } from "../pullRequest/PullRequestChangeBreakdownStat"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; import { selectEnvironmentQueryData } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ProjectId, PullRequestRef } from "@t3tools/contracts"; import { ArrowUpRightIcon, EyeIcon, EyeOffIcon, FileDiffIcon, GitBranchIcon, TriangleAlertIcon, } from "lucide-react"; import { AsyncResult } from "effect/reactivity"; import { useState, type MouseEvent as ReactMouseEvent } from "react"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; import { usePullRequestChecksRefresh } from "~/hooks/usePullRequestChecksRefresh"; import { cn } from "~/lib/utils"; import { useServerConfigs } from "~/state/entities"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useUiStateStore } from "~/uiStateStore"; import { useEnvironmentQueryIsNewer, useEnvironmentQueryRefresh, useEnvironmentQuerySelector, } from "~/state/query"; import { buildFixFindingsHandoff, buildResolveConflictsPrompt, classifyPullRequestChecks, describePullRequestChecks, isPullRequestConflicting, resolvePullRequestMergeMethod, allowedPullRequestMergeMethods, resolveThreadPanelPullRequestAction, } from "../pullRequest/pullRequestDetail.logic"; import { PullRequestChecksPopover } from "../pullRequest/PullRequestChecksPopover"; import { pullRequestChecksState, PullRequestCheckStatusIcon, PullRequestDiffStat, resolvePullRequestState, } from "../pullRequest/pullRequestPresentation"; import { usePullRequestActionRunner, usePullRequestDefaultMergeMethodResolver, usePullRequestHandoffs, } from "../pullRequest/usePullRequestActions"; import { ChangeRequestStatusIcon, type PrStatusIndicator, type ThreadPr, } from "../ThreadStatusIndicators"; import { AlertDialog, AlertDialogClose, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogPopup, AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { THREAD_DETAILS_PANEL_ICON_CLASS, THREAD_DETAILS_PANEL_LABEL_CLASS, THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, } from "./threadDetailsPanelStyles"; // A null checks response is settled; polling cannot find checks. const isSettledWithoutChecks = (result: AsyncResult.AsyncResult) => result._tag === "Success" && result.value === null; export function ThreadDetailsPrRow({ environmentId, pr, number, reference: linkedReference, status, project, label, openAriaLabel, onOpen, onActed, onStopWatching, }: { environmentId: EnvironmentId; pr: ThreadPr; number: number; reference?: Pick | null; status: PrStatusIndicator | null; project: EnvironmentProject | null; label: string; openAriaLabel: string; onOpen: (event: ReactMouseEvent) => void; onActed?: () => void; onStopWatching?: (() => void) | undefined; }) { const serverConfigs = useServerConfigs(); const supportsPullRequests = serverConfigs.get(environmentId) !== undefined; const repository = sourceControlClients.repositorySelector(project?.repositoryIdentity); const reference: PullRequestRef | null = supportsPullRequests && project !== null ? linkedReference ? { ...linkedReference, projectId: project.id as ProjectId } : repository !== null ? { projectId: project.id as ProjectId, repository, number } : null : null; const detailAtom = reference === null ? null : pullRequestEnvironment.detail({ environmentId, input: { ...reference, allowStale: false }, }); // Select query parts because polling rebuilds unchanged results. const detailData = useEnvironmentQuerySelector(detailAtom, selectEnvironmentQueryData); const detailQuery = useEnvironmentQueryRefresh(detailAtom); const supportsChecks = serverConfigs.get(environmentId)?.environment.capabilities.pullRequestChecks === true; const checksAtom = supportsChecks && reference !== null && detailData !== null ? pullRequestEnvironment.checks({ environmentId, input: reference }) : null; const checksData = useEnvironmentQuerySelector(checksAtom, selectEnvironmentQueryData); const checksSettledEmpty = useEnvironmentQuerySelector(checksAtom, isSettledWithoutChecks); const checksQuery = useEnvironmentQueryRefresh(checksAtom); const checksAreNewer = useEnvironmentQueryIsNewer(checksAtom, detailAtom); const detail = resolvePullRequestDetailChecks(detailData, checksData, checksAreNewer); const terminology = sourceControlClients.get( detail?.provider ?? project?.repositoryIdentity?.provider, ).changeRequest; const open = reference !== null && (detail?.state ?? pr?.state) === "open"; const refreshKey = `${environmentId}:${project?.id}:${reference?.host}:${reference?.repository}:${number}`; useLiveRefresh( () => { if (!detailQuery.isPending()) detailQuery.refresh(); }, { enabled: open, key: `workspace-pr:${refreshKey}`, intervalMs: 10 * 60_000 }, ); usePullRequestChecksRefresh({ refresh: () => { if (!checksQuery.isPending() && !detailQuery.isPending()) checksQuery.refresh(); }, enabled: open && supportsChecks && !checksSettledEmpty, key: `workspace-pr-checks:${refreshKey}`, checks: detail?.checks ?? [], }); const { actionPending, perform } = usePullRequestActionRunner({ environmentId, reference, terminology, onSuccess: () => { checksQuery.refresh(); onActed?.(); }, }); const { handoff, startHandoff } = usePullRequestHandoffs({ environmentId, detail }); const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod); const resolveProjectDefaultMergeMethod = usePullRequestDefaultMergeMethodResolver( environmentId, (project?.id as ProjectId | undefined) ?? null, ); const [confirmingMerge, setConfirmingMerge] = useState(false); const rowAction = resolveThreadPanelPullRequestAction(detail); if (confirmingMerge && rowAction !== "merge") { setConfirmingMerge(false); } const conflicting = isPullRequestConflicting(detail); const checksState = detail === null ? "none" : classifyPullRequestChecks(detail.checks); const checksRollup = detail === null ? null : pullRequestChecksState(detail.checks); const selectedMergeMethod = resolvePullRequestMergeMethod( allowedPullRequestMergeMethods(detail), null, resolveProjectDefaultMergeMethod(), lastSelectedMergeMethod, ); const startResolveConflicts = () => { if (detail === null) return; void startHandoff("conflicts", { prompt: buildResolveConflictsPrompt({ number: detail.number, url: detail.url, headBranch: detail.headBranch, baseBranch: detail.baseBranch, }), }); }; const startFixChecks = () => { if (detail === null) return; void startHandoff( "findings", buildFixFindingsHandoff({ number: detail.number, title: detail.title, url: detail.url, headBranch: detail.headBranch, baseBranch: detail.baseBranch, state: detail.state, isDraft: detail.isDraft, reviewThreads: [], comments: [], checks: detail.checks, commentsTruncated: false, }), ); }; const statePresentation = detail === null ? null : resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft, }); const icon = statePresentation ? ( ) : pr && status ? ( ) : ( ); const rowTooltip = detail === null || statePresentation === null ? ( {status?.tooltip ?? `${capitalizeTerm(terminology.singular)} #${number}`} ) : (
{detail.title} #{detail.number}
{statePresentation.label}
{detail.baseBranch} ← {detail.headBranch}
{detail.state === "open" && checksState !== "none" ? (
{describePullRequestChecks(detail.checks)}
) : null} {detail.isDraft && conflicting ? (
Merge conflicts with {detail.baseBranch}
) : null}
{detail.changedFiles.toLocaleString()}{" "} {detail.changedFiles === 1 ? "file" : "files"}
{reference !== null && (detail.additions > 0 || detail.deletions > 0) ? ( ) : null}
); const trailingAction = rowAction === "resolve" ? { label: "Resolve", pendingLabel: "Preparing...", pending: handoff === "conflicts", destructive: true, suffix: , tooltip: "Check the branch out and resolve the conflicts in a new thread", onClick: startResolveConflicts, } : rowAction === "ready" ? { label: "Ready", pendingLabel: "Marking...", pending: actionPending, destructive: false, suffix: null, tooltip: `Mark this ${terminology.singular} as ready for review`, onClick: () => void perform("ready"), } : rowAction === "fix" ? { label: "Fix", pendingLabel: "Preparing...", pending: handoff === "findings", destructive: true, suffix: , tooltip: "Fix the failing checks in a new thread", onClick: startFixChecks, } : rowAction === "merge" ? { label: "Merge", pendingLabel: "Merging...", pending: actionPending, destructive: false, suffix: null, tooltip: `Merge this ${terminology.singular} (${selectedMergeMethod})`, onClick: () => setConfirmingMerge(true), } : null; const watching = onStopWatching !== undefined && (detail?.state ?? pr?.state ?? "open") === "open"; const watchControl = (part: "secondary" | "icon") => watching ? ( } > Watching: the agent wakes when checks finish, someone comments, or the branch conflicts. Click to stop. ) : null; // The title row wraps to two lines; a conventional-commit prefix eats most of one line. const openRow = (text: string, part: "row" | "primary", wrap = false) => ( svg]:mt-px" : undefined} aria-label={openAriaLabel} onClick={onOpen} onAuxClick={onOpen} /> } > {icon} {text} {rowTooltip} ); const showChecks = detail !== null && checksRollup !== null && !conflicting && !detail.isDraft; return ( <> {detail !== null && (showChecks || trailingAction !== null || watching) ? (
{openRow(detail.title, "row", true)}
#{number} {showChecks && checksRollup !== null ? ( } /> ) : null} {watchControl("icon")} {trailingAction ? ( } > {trailingAction.pending ? trailingAction.pendingLabel : trailingAction.label} {trailingAction.suffix} {trailingAction.tooltip} ) : null}
) : watching ? (
{openRow(label, "primary")}
) : ( openRow(label, "row") )} {rowAction === "merge" ? ( setConfirmingMerge(open)}> Merge {terminology.singular}? This merges #{number} using {selectedMergeMethod}. }> Cancel ) : null} ); }