1010 lines · 41.3 KB · TypeScript
RawDownload
import type {  EnvironmentId,  PullRequestActor,  PullRequestComment,  PullRequestDetailView,  PullRequestRef,  ScopedThreadRef,} from "@t3tools/contracts";import {  ArrowDownUpIcon,  ChevronDownIcon,  ChevronRightIcon,  GitPullRequestClosedIcon,  HammerIcon,  MessageSquareIcon,  PencilIcon,  RotateCcwIcon,  SendIcon,  TagIcon,  UsersIcon,} from "lucide-react";import { useRef, useState, type ReactNode } from "react"; import { useAtomCommand } from "~/state/use-atom-command";import { pullRequestEnvironment } from "~/state/pullRequests";import { cn } from "~/lib/utils";import { useOpenLink } from "~/browser/useOpenLink";import { formatRelativeTimeLabel } from "~/timestampFormat"; import { Button } from "../ui/button";import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";import { Textarea } from "../ui/textarea";import { toastManager } from "../ui/toast";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import {  PullRequestActorAvatar,  PullRequestActorLabel,  PullRequestCheckStatusIcon,  PullRequestReviewOutcomeBadge,  pullRequestCheckStatusLabel,  pullRequestReviewOutcomeLabel,  pullRequestReviewOutcomeRingClassName,  pullRequestReviewOutcomeStaleLabel,} from "./pullRequestPresentation";import { PullRequestLabelPicker } from "./PullRequestLabelPicker";import { PullRequestReviewerPicker } from "./PullRequestReviewerPicker";import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";import {  latestPullRequestReviewOutcomes,  orderPullRequestComments,  pullRequestFindingKey,  pullRequestReviewOutcome,  visibleBody,  type PullRequestFinding,} from "./pullRequestDetail.logic";import {  canEditPullRequestChangeRequest,  canEditPullRequestComment,} from "./pullRequestEditing.logic";import { PullRequestMarkdown } from "./PullRequestMarkdown";import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor";import { PullRequestReactionBar } from "./PullRequestReactions";import { PullRequestConversationGhost } from "./PullRequestGhosts";import { pullRequestLabelColor } from "./pullRequestList.logic";import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; /** One reviewer, however a host happens to have cased their login this time. */function reviewerKey(login: string): string {  return login.toLowerCase();} /** The avatar carries the attribution alone; who it is arrives on hover, like the reviewer row. */function CommentAuthor({ actor }: { actor: PullRequestActor | null }) {  const login = actor?.login ?? "ghost";  return (    <Tooltip>      <TooltipTrigger render={<span className="shrink-0 rounded-full" aria-label={login} />}>        <PullRequestActorAvatar actor={actor} />      </TooltipTrigger>      <TooltipPopup side="bottom">        {actor?.name && actor.name !== login ? `${actor.name} (@${login})` : login}      </TooltipPopup>    </Tooltip>  );} /** "CHANGES_REQUESTED" reads as "Changes requested": one capital, the host's underscores gone. */function reviewStateLabel(state: string): string {  const words = state.toLowerCase().replace(/_/g, " ");  return words.charAt(0).toUpperCase() + words.slice(1);} /** What every remark in the conversation needs to be rewritten where it sits. */interface CommentEditing {  readonly cwd: string;  readonly environmentId: EnvironmentId;  readonly threadRef: ScopedThreadRef | null;  readonly canEdit: (comment: PullRequestComment) => boolean;  readonly editingId: string | null;  readonly saving: boolean;  readonly onEdit: (comment: PullRequestComment | null) => void;  readonly onSave: (comment: PullRequestComment, body: string) => void;} /** * A remark's words, and the pencil that swaps them for the editor. The pencil is revealed by * hovering the remark, like the reaction bar's own, so the parent must carry `group`. */function CommentBody({  comment,  editing,  className,}: {  comment: PullRequestComment;  editing: CommentEditing;  className?: string | undefined;}) {  if (editing.editingId === comment.id) {    return (      <PullRequestMarkdownEditor        className={className}        value={comment.body}        cwd={editing.cwd}        environmentId={editing.environmentId}        threadRef={editing.threadRef}        label="Edit comment"        saving={editing.saving}        onSave={(body) => editing.onSave(comment, body)}        onCancel={() => editing.onEdit(null)}      />    );  }  return (    <div className={cn("flex items-start gap-1", className)}>      <PullRequestMarkdown        className="min-w-0 flex-1"        text={comment.body}        cwd={editing.cwd}        environmentId={editing.environmentId}        threadRef={editing.threadRef}      />      {editing.canEdit(comment) ? (        <Button          size="icon-xs"          variant="ghost"          className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"          aria-label="Edit comment"          onClick={() => editing.onEdit(comment)}        >          <PencilIcon className="size-3" />        </Button>      ) : null}    </div>  );} /** Finished work — a resolved conversation or a dismissed approval — opens collapsed. */function CollapsedComment({  comment,  editing,  label,  body,  reactionBar,}: {  comment: PullRequestComment;  editing: CommentEditing;  label: string;  /** Null where the remark is nothing but its verdict, which a dismissal usually is. */  body: string | null;  reactionBar: ReactNode;}) {  const [open, setOpen] = useState(false);  return (    <Collapsible open={open} onOpenChange={setOpen}>      <article className="group rounded-lg border border-border/60 [contain-intrinsic-block-size:44px] [content-visibility:auto]">        <CollapsibleTrigger          className={cn(            "flex w-full items-center gap-2 p-3 text-left transition-opacity hover:opacity-100",            open ? "opacity-100" : "opacity-65",          )}        >          <span className="flex min-w-0 flex-1 items-center gap-2 text-xs text-muted-foreground">            <CommentAuthor actor={comment.author} />            <span>{formatRelativeTimeLabel(comment.createdAt)}</span>            <span>{label}</span>          </span>          <ChevronDownIcon            aria-hidden            className={cn(              "size-3.5 shrink-0 text-muted-foreground transition-transform",              open && "rotate-180",            )}          />        </CollapsibleTrigger>        <CollapsiblePanel>          {open ? (            <div className="px-3 pb-3">              {comment.path ? (                <Tooltip>                  <TooltipTrigger                    render={                      <p className="truncate text-xs text-muted-foreground">{comment.path}</p>                    }                  />                  <TooltipPopup side="top">{comment.path}</TooltipPopup>                </Tooltip>              ) : null}              {/* A dismissal carries no more words than an approval does, and an empty markdown                  block reads as a card somebody forgot to fill in. */}              {body === null && !editing.canEdit(comment) ? null : (                <CommentBody className="mt-2" comment={comment} editing={editing} />              )}              {reactionBar}            </div>          ) : null}        </CollapsiblePanel>      </article>    </Collapsible>  );} function MetaRow({  icon,  label,  children,}: {  icon: ReactNode;  label: string;  children: ReactNode;}) {  return (    <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 py-1.5 text-xs">      <span className="flex min-w-0 items-center gap-1.5 text-muted-foreground">        {icon}        {label}      </span>      <span className="min-w-0 text-foreground">{children}</span>    </div>  );} function Section({  title,  count,  defaultOpen = true,  actions,  children,}: {  title: string;  count?: number;  defaultOpen?: boolean;  /** Controls riding on the heading row itself. A sibling of the trigger, not a child of it —      a button cannot hold a button — and only while open, since they act on what is shown. */  actions?: ReactNode;  children: ReactNode;}) {  const [open, setOpen] = useState(defaultOpen);  const headingRef = useRef<HTMLDivElement>(null);  const setOpenWithScrollAnchor = (nextOpen: boolean) => {    if (!nextOpen) {      const heading = headingRef.current;      const section = heading?.closest<HTMLElement>("[data-pull-request-summary-section]");      const scroller = heading?.closest<HTMLElement>("[data-pull-request-summary-scroll]");      if (heading && section && scroller) {        const target = sectionCollapseAnchorScrollTop({          scrollTop: scroller.scrollTop,          viewportTop: scroller.getBoundingClientRect().top,          sectionTop: section.getBoundingClientRect().top,          headingTop: heading.getBoundingClientRect().top,        });        // Synchronous with the press: React commits the collapsed height before the browser        // paints, so the reader sees the heading they pressed stay put rather than a jump first.        if (target !== null) scroller.scrollTop = target;      }    }    setOpen(nextOpen);  };  return (    <Collapsible      open={open}      onOpenChange={setOpenWithScrollAnchor}      data-pull-request-summary-section    >      {/* The heading rides the top of the scroll box the way a diff's file header does, so a          section can be collapsed from wherever its body has been read to rather than only from          where it started. Opaque, because the rows it covers scroll beneath it. */}      <div        ref={headingRef}        className="sticky top-0 z-10 flex w-full items-center border-t border-border/60 bg-background pr-4"      >        {/* Title first, chevron riding to its right, count last: the row reads as a heading            with an affordance rather than a tree node. */}        <CollapsibleTrigger className="flex min-w-0 flex-1 items-center gap-1.5 px-4 py-3 text-left text-sm font-medium">          <span>{title}</span>          <ChevronRightIcon            aria-hidden            className={cn(              "size-3.5 text-muted-foreground transition-transform",              open && "rotate-90",            )}          />          {count === undefined ? null : (            <span className="text-xs tabular-nums text-muted-foreground">{count}</span>          )}        </CollapsibleTrigger>        {open ? actions : null}      </div>      <CollapsiblePanel>        <div className="px-4 pb-4">{children}</div>      </CollapsiblePanel>    </Collapsible>  );} function CommentComposer({  environmentId,  detail,  actionPending,  onCommentAction,  onCommented,}: {  environmentId: EnvironmentId;  detail: PullRequestDetailView;  actionPending: boolean;  onCommentAction: (    body: string,    action: "close" | "reopen",  ) => Promise<{ readonly commentPosted: boolean }>;  onCommented: () => void;}) {  const [body, setBody] = useState("");  const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null);  const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false });  const followUpAction =    detail.state === "open" &&    detail.capabilities.actions.includes("close") &&    detail.viewerPermissions.actions.includes("close")      ? ("close" as const)      : detail.state === "closed" &&          detail.capabilities.actions.includes("reopen") &&          detail.viewerPermissions.actions.includes("reopen")        ? ("reopen" as const)        : null;   const submit = async (action: "comment" | "close" | "reopen") => {    const trimmed = body.trim();    if (trimmed.length === 0 || submitting !== null || actionPending) return;    setSubmitting(action);    if (action !== "comment") {      const result = await onCommentAction(trimmed, action);      if (result.commentPosted) setBody("");      setSubmitting(null);      return;    }    const result = await postComment({      environmentId,      input: {        projectId: detail.projectId,        repository: detail.repository,        number: detail.number,        body: trimmed,      },    });    if (result._tag === "Failure") {      setSubmitting(null);      toastManager.add({ type: "error", title: "Could not post the comment" });      return;    }    setBody("");    setSubmitting(null);    onCommented();  };   return (    <div className="mt-3 space-y-2">      <Textarea        // Locked while posting: the body is cleared on success, which would otherwise throw        // away a new draft typed while the request was still in flight.        disabled={submitting !== null || actionPending}        value={body}        rows={3}        placeholder="Leave a comment"        aria-label="Comment on this pull request"        onChange={(event) => setBody(event.target.value)}      />      <div className="flex justify-end gap-2">        {followUpAction === null ? null : (          <Button            size="xs"            variant={followUpAction === "close" ? "destructive-outline" : "outline"}            disabled={body.trim().length === 0 || submitting !== null || actionPending}            onClick={() => void submit(followUpAction)}          >            {followUpAction === "close" ? (              <GitPullRequestClosedIcon className="size-3.5" />            ) : (              <RotateCcwIcon className="size-3.5" />            )}            {submitting === followUpAction              ? followUpAction === "close"                ? "Closing..."                : "Reopening..."              : followUpAction === "close"                ? "Close with comment"                : "Reopen with comment"}          </Button>        )}        <Button          size="xs"          variant="outline"          disabled={body.trim().length === 0 || submitting !== null || actionPending}          onClick={() => void submit("comment")}        >          <SendIcon className="size-3.5" />          {submitting === "comment" ? "Posting..." : "Comment"}        </Button>      </div>    </div>  );} /** * What a first render of the conversation carries. A pull request with two hundred comments is * two hundred markdown documents, and the ones worth arriving for are the recent ones. */const COMMENT_PAGE = 30; export function PullRequestSummaryTab({  environmentId,  threadRef,  reference,  detail,  activityPending,  activityError,  pendingFinding,  fixFindingLabel = "Fix in a thread",  fixCheckLabel = "Fix",  onFixFinding,  actionPending,  onCommentAction,  onRefresh,}: {  environmentId: EnvironmentId;  threadRef: ScopedThreadRef | null;  reference: PullRequestRef;  detail: PullRequestDetailView;  activityPending: boolean;  activityError: string | null;  /** The hand-off currently preparing, if any, so only the finding it belongs to says so. */  pendingFinding?: string | null;  fixFindingLabel?: string;  fixCheckLabel?: string;  onFixFinding?: (finding: PullRequestFinding) => void;  actionPending: boolean;  onCommentAction: (    body: string,    action: "close" | "reopen",  ) => Promise<{ readonly commentPosted: boolean }>;  onRefresh: () => void;}) {  // Keyed by the pull request, so opening another one starts at the end of its conversation  // rather than wherever the last one had been read back to.  const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE });  const shownComments = shown.url === detail.url ? shown.count : COMMENT_PAGE;  // Windowed by recency regardless of display order: expanding always reaches further back in  // time, whether the newest comment currently reads first or last.  const recentComments = detail.comments.slice(Math.max(0, detail.comments.length - shownComments));  const hiddenCommentCount = detail.comments.length - recentComments.length;  const [commentOrder, setCommentOrder] = useState<"newest" | "oldest">("newest");  const visibleComments = orderPullRequestComments(recentComments, commentOrder);  const showOldestCommentsButton =    hiddenCommentCount > 0 ? (      <Button        size="sm"        variant="outline"        className="w-full"        onClick={() => setShown({ url: detail.url, count: shownComments + COMMENT_PAGE })}      >        Show {Math.min(hiddenCommentCount, COMMENT_PAGE)} oldest{" "}        {hiddenCommentCount === 1 ? "comment" : "comments"}      </Button>    ) : null;  // Read from the whole conversation, not the window shown below it: a verdict older than the  // last thirty comments still stands.  const reviewOutcomes = latestPullRequestReviewOutcomes(detail.comments, detail.commits);  // Hosts do not promise one casing for a login across two fields of the same response, and  // none of them lets `Octocat` and `octocat` be two people — so matching on the literal string  // would show one reviewer twice and drop the verdict off both.  const outcomeByLogin = new Map(    reviewOutcomes.flatMap((entry) =>      entry.actor ? [[reviewerKey(entry.actor.login), entry] as const] : [],    ),  );  // Everyone whose face belongs on this row: the people a review was asked of, then anyone who  // ruled without being on that list. A host drops a reviewer from the requested set once they  // have reviewed, and their verdict is the thing this row now exists to show.  const reviewerEntries = [    ...detail.reviewers.map((actor) => ({      key: actor.login,      actor,

Lines 1–500 of 1010.