| 1 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; |
| 2 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 3 | import type { |
| 4 | EnvironmentId, |
| 5 | PullRequestComment, |
| 6 | PullRequestDetailView, |
| 7 | PullRequestRef, |
| 8 | PullRequestReviewThread, |
| 9 | ScopedThreadRef, |
| 10 | } from "@t3tools/contracts"; |
| 11 | import { |
| 12 | ArrowDownUpIcon, |
| 13 | ChevronDownIcon, |
| 14 | ChevronRightIcon, |
| 15 | HammerIcon, |
| 16 | ListFilterIcon, |
| 17 | TagIcon, |
| 18 | UsersIcon, |
| 19 | } from "lucide-react"; |
| 20 | import * as Schema from "effect/Schema"; |
| 21 | import { useRef, useState, type ReactNode } from "react"; |
| 22 | |
| 23 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 24 | import { useLocalStorage } from "~/hooks/useLocalStorage"; |
| 25 | import { cn } from "~/lib/utils"; |
| 26 | import { useOpenLink } from "~/browser/useOpenLink"; |
| 27 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 28 | |
| 29 | import { Button } from "../ui/button"; |
| 30 | import { PullRequestEditButton } from "./PullRequestEditButton"; |
| 31 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| 32 | import { |
| 33 | Menu, |
| 34 | MenuCheckboxItem, |
| 35 | MenuGroup, |
| 36 | MenuGroupLabel, |
| 37 | MenuPopup, |
| 38 | MenuTrigger, |
| 39 | } from "../ui/menu"; |
| 40 | import { toastManager } from "../ui/toast"; |
| 41 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 42 | import { |
| 43 | PullRequestActorLabel, |
| 44 | PullRequestCheckStatusIcon, |
| 45 | pullRequestCheckStatusLabel, |
| 46 | PullRequestLabelChip, |
| 47 | PullRequestReviewOutcomeBadge, |
| 48 | pullRequestReviewOutcomeLabel, |
| 49 | pullRequestReviewOutcomeRingClassName, |
| 50 | pullRequestReviewOutcomeStaleLabel, |
| 51 | } from "./pullRequestPresentation"; |
| 52 | import { PullRequestLabelPicker } from "./PullRequestLabelPicker"; |
| 53 | import { PullRequestReviewerPicker } from "./PullRequestReviewerPicker"; |
| 54 | import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; |
| 55 | import { |
| 56 | latestPullRequestReviewOutcomes, |
| 57 | orderPullRequestComments, |
| 58 | pullRequestFindingKey, |
| 59 | pullRequestReviewOutcome, |
| 60 | visibleBody, |
| 61 | type PullRequestFinding, |
| 62 | } from "./pullRequestDetail.logic"; |
| 63 | import { |
| 64 | canEditPullRequestChangeRequest, |
| 65 | canEditPullRequestComment, |
| 66 | } from "./pullRequestEditing.logic"; |
| 67 | import { PullRequestMarkdown } from "./PullRequestMarkdown"; |
| 68 | import { PullRequestCommentBody } from "./PullRequestCommentBody"; |
| 69 | import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; |
| 70 | import { PullRequestReactionBar } from "./PullRequestReactions"; |
| 71 | import { PullRequestConversationGhost } from "./PullRequestGhosts"; |
| 72 | import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; |
| 73 | |
| 74 | /** One reviewer, however a host happens to have cased their login this time. */ |
| 75 | function reviewerKey(login: string): string { |
| 76 | return login.toLowerCase(); |
| 77 | } |
| 78 | |
| 79 | function CommentIdentity({ |
| 80 | comment, |
| 81 | detail, |
| 82 | }: { |
| 83 | comment: PullRequestComment; |
| 84 | detail: PullRequestDetailView; |
| 85 | }) { |
| 86 | const actor = comment.author; |
| 87 | const profileUrl = actor |
| 88 | ? sourceControlClients.get(detail.provider).authorProfileUrl(actor.login, detail.url) |
| 89 | : null; |
| 90 | return ( |
| 91 | <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1 text-xs"> |
| 92 | <PullRequestActorLabel actor={actor} profileUrl={profileUrl} className="max-w-full" /> |
| 93 | <Tooltip> |
| 94 | <TooltipTrigger |
| 95 | render={ |
| 96 | comment.url ? ( |
| 97 | <a |
| 98 | href={comment.url} |
| 99 | target="_blank" |
| 100 | rel="noopener noreferrer" |
| 101 | className="text-muted-foreground hover:text-foreground hover:underline" |
| 102 | /> |
| 103 | ) : ( |
| 104 | <span className="text-muted-foreground" /> |
| 105 | ) |
| 106 | } |
| 107 | > |
| 108 | <time dateTime={comment.createdAt}>{formatRelativeTimeLabel(comment.createdAt)}</time> |
| 109 | </TooltipTrigger> |
| 110 | <TooltipPopup> |
| 111 | {new Date(comment.createdAt).toLocaleString()} |
| 112 | {comment.url ? " · Open comment on host" : ""} |
| 113 | </TooltipPopup> |
| 114 | </Tooltip> |
| 115 | </div> |
| 116 | ); |
| 117 | } |
| 118 | |
| 119 | function CommentLocation({ |
| 120 | comment, |
| 121 | thread, |
| 122 | }: { |
| 123 | comment: PullRequestComment; |
| 124 | thread: PullRequestReviewThread | undefined; |
| 125 | }) { |
| 126 | const path = thread?.path ?? comment.path; |
| 127 | if (!path) return null; |
| 128 | const label = `${path}${thread?.line ? `:${thread.line}` : ""}`; |
| 129 | return ( |
| 130 | <div className="mt-2 flex min-w-0 items-center gap-2 text-xs text-muted-foreground"> |
| 131 | <Tooltip> |
| 132 | <TooltipTrigger render={<span className="truncate font-mono" />}>{label}</TooltipTrigger> |
| 133 | <TooltipPopup>{label}</TooltipPopup> |
| 134 | </Tooltip> |
| 135 | {thread?.isOutdated ? <span className="shrink-0">Outdated</span> : null} |
| 136 | </div> |
| 137 | ); |
| 138 | } |
| 139 | |
| 140 | /** "CHANGES_REQUESTED" reads as "Changes requested": one capital, the host's underscores gone. */ |
| 141 | function reviewStateLabel(state: string): string { |
| 142 | const words = state.toLowerCase().replace(/_/g, " "); |
| 143 | return words.charAt(0).toUpperCase() + words.slice(1); |
| 144 | } |
| 145 | |
| 146 | /** What every remark in the conversation needs to be rewritten where it sits. */ |
| 147 | interface CommentEditing { |
| 148 | readonly cwd: string; |
| 149 | readonly environmentId: EnvironmentId; |
| 150 | readonly threadRef: ScopedThreadRef | null; |
| 151 | readonly canEdit: (comment: PullRequestComment) => boolean; |
| 152 | readonly editingId: string | null; |
| 153 | readonly saving: boolean; |
| 154 | readonly onEdit: (comment: PullRequestComment | null) => void; |
| 155 | readonly onSave: (comment: PullRequestComment, body: string) => void; |
| 156 | } |
| 157 | |
| 158 | /** |
| 159 | * A remark's words, and the pencil that swaps them for the editor. The pencil is revealed by |
| 160 | * hovering the remark, like the reaction bar's own, so the parent must carry `group`. |
| 161 | */ |
| 162 | function CommentBody({ |
| 163 | comment, |
| 164 | editing, |
| 165 | className, |
| 166 | }: { |
| 167 | comment: PullRequestComment; |
| 168 | editing: CommentEditing; |
| 169 | className?: string | undefined; |
| 170 | }) { |
| 171 | if (editing.editingId === comment.id) { |
| 172 | return ( |
| 173 | <PullRequestMarkdownEditor |
| 174 | className={className} |
| 175 | value={comment.body} |
| 176 | cwd={editing.cwd} |
| 177 | environmentId={editing.environmentId} |
| 178 | threadRef={editing.threadRef} |
| 179 | label="Edit comment" |
| 180 | saving={editing.saving} |
| 181 | onSave={(body) => editing.onSave(comment, body)} |
| 182 | onCancel={() => editing.onEdit(null)} |
| 183 | /> |
| 184 | ); |
| 185 | } |
| 186 | return ( |
| 187 | <div className={cn("flex items-start gap-1", className)}> |
| 188 | <PullRequestCommentBody |
| 189 | key={comment.id} |
| 190 | className="min-w-0 flex-1" |
| 191 | text={comment.body} |
| 192 | cwd={editing.cwd} |
| 193 | environmentId={editing.environmentId} |
| 194 | threadRef={editing.threadRef} |
| 195 | /> |
| 196 | {editing.canEdit(comment) ? ( |
| 197 | <PullRequestEditButton aria-label="Edit comment" onClick={() => editing.onEdit(comment)} /> |
| 198 | ) : null} |
| 199 | </div> |
| 200 | ); |
| 201 | } |
| 202 | |
| 203 | /** Finished work — a resolved conversation or a dismissed review — opens collapsed. */ |
| 204 | function CollapsedComment({ |
| 205 | comment, |
| 206 | editing, |
| 207 | label, |
| 208 | body, |
| 209 | reactionBar, |
| 210 | detail, |
| 211 | thread, |
| 212 | }: { |
| 213 | comment: PullRequestComment; |
| 214 | editing: CommentEditing; |
| 215 | label: string; |
| 216 | /** Null where the remark is nothing but its verdict, which a dismissal usually is. */ |
| 217 | body: string | null; |
| 218 | reactionBar: ReactNode; |
| 219 | detail: PullRequestDetailView; |
| 220 | thread: PullRequestReviewThread | undefined; |
| 221 | }) { |
| 222 | const [open, setOpen] = useState(false); |
| 223 | const statusTriggerRef = useRef<HTMLButtonElement>(null); |
| 224 | return ( |
| 225 | <Collapsible open={open} onOpenChange={setOpen}> |
| 226 | <article className="group rounded-lg border border-border/60 [contain-intrinsic-block-size:44px] [content-visibility:auto]"> |
| 227 | <div className="p-3"> |
| 228 | <div className="flex flex-wrap items-start gap-2"> |
| 229 | <CommentIdentity comment={comment} detail={detail} /> |
| 230 | <CollapsibleTrigger |
| 231 | ref={statusTriggerRef} |
| 232 | className="flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground" |
| 233 | > |
| 234 | {label} |
| 235 | <ChevronDownIcon |
| 236 | aria-hidden |
| 237 | className={cn("size-3.5 transition-transform", open && "rotate-180")} |
| 238 | /> |
| 239 | </CollapsibleTrigger> |
| 240 | {reactionBar} |
| 241 | </div> |
| 242 | <CommentLocation comment={comment} thread={thread} /> |
| 243 | {!open && body ? ( |
| 244 | <CollapsibleTrigger |
| 245 | className="mt-2 block w-full truncate text-left text-xs text-muted-foreground hover:text-foreground" |
| 246 | onClick={() => statusTriggerRef.current?.focus({ preventScroll: true })} |
| 247 | > |
| 248 | {body |
| 249 | .replace(/<!--[\s\S]*?-->/gu, "") |
| 250 | .replace(/^\s*>?\s*\[!\w+\]\s*$/gmu, "") |
| 251 | .replace(/!?(\[([^\]]+)\])\([^)]*\)/gu, "$2") |
| 252 | .replace(/^[\s>#*-]+/gmu, "") |
| 253 | .replace(/[*`]/gu, "") |
| 254 | .replace(/\s+/g, " ") |
| 255 | .trim()} |
| 256 | </CollapsibleTrigger> |
| 257 | ) : null} |
| 258 | </div> |
| 259 | <CollapsiblePanel> |
| 260 | {open ? ( |
| 261 | <div className="px-3 pb-3"> |
| 262 | {/* A dismissal carries no more words than an approval does, and an empty markdown |
| 263 | block reads as a card somebody forgot to fill in. */} |
| 264 | {body === null && !editing.canEdit(comment) ? null : ( |
| 265 | <CommentBody className="mt-2" comment={comment} editing={editing} /> |
| 266 | )} |
| 267 | </div> |
| 268 | ) : null} |
| 269 | </CollapsiblePanel> |
| 270 | </article> |
| 271 | </Collapsible> |
| 272 | ); |
| 273 | } |
| 274 | |
| 275 | function MetaRow({ |
| 276 | icon, |
| 277 | label, |
| 278 | children, |
| 279 | }: { |
| 280 | icon: ReactNode; |
| 281 | label: string; |
| 282 | children: ReactNode; |
| 283 | }) { |
| 284 | return ( |
| 285 | <div className="grid min-h-7 min-w-0 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs sm:min-h-6"> |
| 286 | <span className="flex items-center gap-1.5 text-muted-foreground"> |
| 287 | {icon} |
| 288 | {label} |
| 289 | </span> |
| 290 | <span className="min-w-0 text-foreground">{children}</span> |
| 291 | </div> |
| 292 | ); |
| 293 | } |
| 294 | |
| 295 | function Section({ |
| 296 | title, |
| 297 | defaultOpen = true, |
| 298 | keepMounted = false, |
| 299 | actions, |
| 300 | children, |
| 301 | }: { |
| 302 | title: string; |
| 303 | defaultOpen?: boolean; |
| 304 | keepMounted?: boolean; |
| 305 | /** Heading controls stay separate from the collapse trigger so they remain independently usable. */ |
| 306 | actions?: ReactNode; |
| 307 | children: ReactNode; |
| 308 | }) { |
| 309 | const [open, setOpen] = useState(defaultOpen); |
| 310 | const headingRef = useRef<HTMLDivElement>(null); |
| 311 | const setOpenWithScrollAnchor = (nextOpen: boolean) => { |
| 312 | if (!nextOpen) { |
| 313 | const heading = headingRef.current; |
| 314 | const section = heading?.closest<HTMLElement>("[data-pull-request-summary-section]"); |
| 315 | const scroller = heading?.closest<HTMLElement>("[data-pull-request-summary-scroll]"); |
| 316 | if (heading && section && scroller) { |
| 317 | const target = sectionCollapseAnchorScrollTop({ |
| 318 | scrollTop: scroller.scrollTop, |
| 319 | viewportTop: scroller.getBoundingClientRect().top, |
| 320 | sectionTop: section.getBoundingClientRect().top, |
| 321 | headingTop: heading.getBoundingClientRect().top, |
| 322 | }); |
| 323 | // Synchronous with the press: React commits the collapsed height before the browser |
| 324 | // paints, so the reader sees the heading they pressed stay put rather than a jump first. |
| 325 | if (target !== null) scroller.scrollTop = target; |
| 326 | } |
| 327 | } |
| 328 | setOpen(nextOpen); |
| 329 | }; |
| 330 | return ( |
| 331 | <Collapsible |
| 332 | open={open} |
| 333 | onOpenChange={setOpenWithScrollAnchor} |
| 334 | render={<section aria-label={title} />} |
| 335 | data-pull-request-summary-section |
| 336 | > |
| 337 | {/* The heading rides the top of the scroll box the way a diff's file header does, so a |
| 338 | section can be collapsed from wherever its body has been read to rather than only from |
| 339 | where it started. Opaque, because the rows it covers scroll beneath it. */} |
| 340 | <div |
| 341 | ref={headingRef} |
| 342 | className="sticky top-0 z-10 flex w-full items-center bg-background pr-4" |
| 343 | > |
| 344 | <CollapsibleTrigger className="flex min-w-0 flex-1 items-center gap-1.5 px-4 py-3 text-left text-xs font-medium text-muted-foreground hover:text-foreground"> |
| 345 | <span>{title}</span> |
| 346 | <ChevronRightIcon |
| 347 | aria-hidden |
| 348 | className={cn( |
| 349 | "size-3.5 text-muted-foreground/60 transition-transform", |
| 350 | open && "rotate-90", |
| 351 | )} |
| 352 | /> |
| 353 | </CollapsibleTrigger> |
| 354 | {actions} |
| 355 | </div> |
| 356 | <CollapsiblePanel keepMounted={keepMounted}> |
| 357 | <div className="px-4 pb-4">{children}</div> |
| 358 | </CollapsiblePanel> |
| 359 | </Collapsible> |
| 360 | ); |
| 361 | } |
| 362 | |
| 363 | /** |
| 364 | * What a first render of the conversation carries. A pull request with two hundred comments is |
| 365 | * two hundred markdown documents, and the ones worth arriving for are the recent ones. |
| 366 | */ |
| 367 | const COMMENT_PAGE = 10; |
| 368 | |
| 369 | /** |
| 370 | * Which kinds of comment the conversation shows. Kept per browser rather than per pull request: |
| 371 | * whether a reader wants bot reports and finished threads in front of them is a habit, not a |
| 372 | * property of one change. |
| 373 | */ |
| 374 | const COMMENT_FILTER_STORAGE_KEY = "t3code:pull-request-comment-filter:v1"; |
| 375 | const CommentFilterSchema = Schema.Struct({ bots: Schema.Boolean, resolved: Schema.Boolean }); |
| 376 | const DEFAULT_COMMENT_FILTER: typeof CommentFilterSchema.Type = { bots: false, resolved: false }; |
| 377 | |
| 378 | export function PullRequestSummaryTab({ |
| 379 | scrollerRef, |
| 380 | environmentId, |
| 381 | threadRef, |
| 382 | reference, |
| 383 | detail, |
| 384 | activityPending, |
| 385 | checksStale = false, |
| 386 | activityError, |
| 387 | pendingFinding, |
| 388 | fixFindingLabel = "Fix in a thread", |
| 389 | fixCheckLabel = "Fix", |
| 390 | onFixFinding, |
| 391 | onRefresh, |
| 392 | onRefreshChecks = onRefresh, |
| 393 | }: { |
| 394 | scrollerRef: (node: HTMLDivElement | null) => void; |
| 395 | environmentId: EnvironmentId; |
| 396 | threadRef: ScopedThreadRef | null; |
| 397 | reference: PullRequestRef; |
| 398 | detail: PullRequestDetailView; |
| 399 | activityPending: boolean; |
| 400 | checksStale?: boolean; |
| 401 | activityError: string | null; |
| 402 | /** The hand-off currently preparing, if any, so only the finding it belongs to says so. */ |
| 403 | pendingFinding?: string | null; |
| 404 | fixFindingLabel?: string; |
| 405 | fixCheckLabel?: string; |
| 406 | onFixFinding?: (finding: PullRequestFinding) => void; |
| 407 | onRefresh: () => void; |
| 408 | onRefreshChecks?: () => void; |
| 409 | }) { |
| 410 | // Keyed by the pull request, so opening another one starts at the end of its conversation |
| 411 | // rather than wherever the last one had been read back to. |
| 412 | const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE }); |
| 413 | const shownComments = shown.url === detail.url ? shown.count : COMMENT_PAGE; |
| 414 | const [commentFilter, setCommentFilter] = useLocalStorage( |
| 415 | COMMENT_FILTER_STORAGE_KEY, |
| 416 | DEFAULT_COMMENT_FILTER, |
| 417 | CommentFilterSchema, |
| 418 | ); |
| 419 | // A comment that already lives on a review thread is that thread: the thread carries the line |
| 420 | // and side the bare comment has lost, and a resolved one is finished work nobody should be |
| 421 | // invited to fix again — the same call the whole-review hand-off makes. |
| 422 | const threadByCommentId = new Map( |
| 423 | detail.reviewThreads.flatMap((thread) => |
| 424 | thread.comments.map((comment) => [comment.id, thread] as const), |
| 425 | ), |
| 426 | ); |
| 427 | |
| 428 | const isFinished = (comment: PullRequestComment) => |
| 429 | threadByCommentId.get(comment.id)?.isResolved === true || |
| 430 | pullRequestReviewOutcome(comment.reviewState) === "dismissed"; |
| 431 | const isBot = (comment: PullRequestComment) => |
| 432 | comment.author?.isBot === true || comment.author?.login.endsWith("[bot]") === true; |
| 433 | const filteredComments = detail.comments.filter( |
| 434 | (comment) => |
| 435 | (commentFilter.bots || !isBot(comment)) && (commentFilter.resolved || !isFinished(comment)), |
| 436 | ); |
| 437 | // The one count the filter shows. Per-kind counts would overlap, since a resolved bot comment |
| 438 | // is both kinds and needs both shown. |
| 439 | const filteredOutCount = detail.comments.length - filteredComments.length; |
| 440 | // Windowed by recency regardless of display order: expanding always reaches further back in |
| 441 | // time, whether the newest comment currently reads first or last. |
| 442 | const recentComments = filteredComments.slice( |
| 443 | Math.max(0, filteredComments.length - shownComments), |
| 444 | ); |
| 445 | const hiddenCommentCount = filteredComments.length - recentComments.length; |
| 446 | const [commentOrder, setCommentOrder] = useState<"newest" | "oldest">("newest"); |
| 447 | const visibleComments = orderPullRequestComments(recentComments, commentOrder); |
| 448 | const showOldestCommentsButton = |
| 449 | hiddenCommentCount > 0 ? ( |
| 450 | <Button |
| 451 | size="sm" |
| 452 | variant="outline" |
| 453 | className="w-full" |
| 454 | onClick={() => setShown({ url: detail.url, count: shownComments + COMMENT_PAGE })} |
| 455 | > |
| 456 | Show {Math.min(hiddenCommentCount, COMMENT_PAGE)} older comment |
| 457 | {hiddenCommentCount === 1 ? "" : "s"} ({hiddenCommentCount} hidden) |
| 458 | </Button> |
| 459 | ) : null; |
| 460 | // Read from the whole conversation, not the window shown below it: a verdict older than the |
| 461 | // visible comments still stands. |
| 462 | const reviewOutcomes = latestPullRequestReviewOutcomes(detail.comments, detail.commits); |
| 463 | // Hosts do not promise one casing for a login across two fields of the same response, and |
| 464 | // none of them lets `Octocat` and `octocat` be two people — so matching on the literal string |
| 465 | // would show one reviewer twice and drop the verdict off both. |
| 466 | const outcomeByLogin = new Map( |
| 467 | reviewOutcomes.flatMap((entry) => |
| 468 | entry.actor ? [[reviewerKey(entry.actor.login), entry] as const] : [], |
| 469 | ), |
| 470 | ); |
| 471 | // Everyone whose face belongs on this row: the people a review was asked of, then anyone who |
| 472 | // ruled without being on that list. A host drops a reviewer from the requested set once they |
| 473 | // have reviewed, and their verdict is the thing this row now exists to show. |
| 474 | const reviewerEntries = [ |
| 475 | ...detail.reviewers.map((actor) => ({ |
| 476 | key: actor.login, |
| 477 | actor, |
| 478 | outcome: outcomeByLogin.get(reviewerKey(actor.login))?.outcome ?? null, |
| 479 | stale: outcomeByLogin.get(reviewerKey(actor.login))?.stale ?? false, |
| 480 | })), |
| 481 | ...reviewOutcomes |
| 482 | .filter( |
| 483 | (entry) => |
| 484 | !detail.reviewers.some( |
| 485 | (actor) => |
| 486 | entry.actor !== null && reviewerKey(actor.login) === reviewerKey(entry.actor.login), |
| 487 | ), |
| 488 | ) |
| 489 | .map((entry) => ({ |
| 490 | key: entry.key, |
| 491 | actor: entry.actor, |
| 492 | outcome: entry.outcome, |
| 493 | stale: entry.stale, |
| 494 | })), |
| 495 | ]; |
| 496 | |
| 497 | const openLink = useOpenLink(threadRef); |
| 498 | const openCheck = (url: string) => { |
| 499 | void openLink(url).catch((error: unknown) => { |
| 500 | console.error(error); |
| 501 | toastManager.add({ type: "error", title: "Unable to open check details" }); |
| 502 | }); |
| 503 | }; |
| 504 | |
| 505 | const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false }); |
| 506 | const updateComment = useAtomCommand(pullRequestEnvironment.updateComment, { |
| 507 | reportFailure: false, |
| 508 | }); |
| 509 | // Keyed by the pull request, like the comment window above it, so an editor left open never |
| 510 | // reappears over the next pull request's description. |
| 511 | const [bodyScope, setBodyScope] = useState<string | null>(null); |
| 512 | const [bodySaving, setBodySaving] = useState(false); |
| 513 | // The remark being rewritten, named with the pull request it belongs to: a comment id is the |
| 514 | // host's own, and two hosts — or two pull requests on Azure DevOps, which numbers a remark |
| 515 | // inside its thread — hand out the same one. Without the pull request beside it, opening a |
| 516 | // different one would leave its like-numbered remark sitting open in an editor. |
| 517 | const [commentScope, setCommentScope] = useState<{ |
| 518 | readonly pullRequest: string; |
| 519 | readonly commentId: string; |
| 520 | } | null>(null); |
| 521 | const [commentSaving, setCommentSaving] = useState(false); |
| 522 | const editingCommentId = commentScope?.pullRequest === detail.url ? commentScope.commentId : null; |
| 523 | |
| 524 | const saveBody = async (body: string) => { |
| 525 | if (bodySaving) return; |
| 526 | setBodySaving(true); |
| 527 | const result = await update({ environmentId, input: { ...reference, body } }); |
| 528 | setBodySaving(false); |
| 529 | if (result._tag === "Failure") { |
| 530 | toastManager.add({ type: "error", title: "Could not save the description" }); |
| 531 | return; |
| 532 | } |
| 533 | setBodyScope(null); |
| 534 | onRefresh(); |
| 535 | }; |
| 536 | |
| 537 | const commentEditing: CommentEditing = { |
| 538 | cwd: detail.workspaceRoot, |
| 539 | environmentId, |
| 540 | threadRef, |
| 541 | canEdit: (comment) => canEditPullRequestComment(detail, comment), |
| 542 | editingId: editingCommentId, |
| 543 | saving: commentSaving, |
| 544 | onEdit: (comment) => |
| 545 | setCommentScope(comment === null ? null : { pullRequest: detail.url, commentId: comment.id }), |
| 546 | onSave: async (comment, body) => { |
| 547 | // A review's own summary is not a kind any host rewrites, which is why no pencil is ever |
| 548 | // offered on one; the check is here because the comment's own type still allows it. |
| 549 | if (commentSaving || comment.kind === "review") return; |
| 550 | setCommentSaving(true); |
| 551 | const result = await updateComment({ |
| 552 | environmentId, |
| 553 | input: { ...reference, commentId: comment.id, kind: comment.kind, body }, |
| 554 | }); |
| 555 | setCommentSaving(false); |
| 556 | if (result._tag === "Failure") { |
| 557 | toastManager.add({ type: "error", title: "Could not save the comment" }); |
| 558 | return; |
| 559 | } |
| 560 | setCommentScope(null); |
| 561 | onRefresh(); |
| 562 | }, |
| 563 | }; |
| 564 | |
| 565 | const renderComment = (comment: PullRequestComment) => { |
| 566 | const thread = threadByCommentId.get(comment.id); |
| 567 | const body = visibleBody(comment.body); |
| 568 | if (isFinished(comment)) { |
| 569 | return ( |
| 570 | <CollapsedComment |
| 571 | key={`${detail.url}:${comment.id}`} |
| 572 | comment={comment} |
| 573 | editing={commentEditing} |
| 574 | detail={detail} |
| 575 | thread={thread} |
| 576 | label={thread?.isResolved ? "Resolved" : "Review dismissed"} |
| 577 | body={body} |
| 578 | reactionBar={ |
| 579 | <PullRequestReactionBar |
| 580 | className="ml-auto justify-end" |
| 581 | reactions={comment.reactions ?? []} |
| 582 | canReact={detail.capabilities.reactions === true} |
| 583 | subjectId={comment.id} |
| 584 | environmentId={environmentId} |
| 585 | reference={reference} |
| 586 | onRefresh={onRefresh} |
| 587 | /> |
| 588 | } |
| 589 | /> |
| 590 | ); |
| 591 | } |
| 592 | const outcome = pullRequestReviewOutcome(comment.reviewState); |
| 593 | // An approval is a verdict, not a finding: there is nothing in it to fix. |
| 594 | const finding: PullRequestFinding | null = |
| 595 | (comment.kind !== "review" && comment.kind !== "review-comment") || outcome === "approved" |
| 596 | ? null |
| 597 | : thread === undefined |
| 598 | ? // Nor is a remark with nothing in it: offering to hand an empty review |
| 599 | // to a thread promises work it does not describe. |
| 600 | body === null |
| 601 | ? null |
| 602 | : { kind: "comment", comment } |
| 603 | : { kind: "thread", thread }; |
| 604 | const reactionBar = ( |
| 605 | <PullRequestReactionBar |
| 606 | reactions={comment.reactions ?? []} |
| 607 | canReact={detail.capabilities.reactions === true} |
| 608 | subjectId={comment.id} |
| 609 | environmentId={environmentId} |
| 610 | reference={reference} |
| 611 | onRefresh={onRefresh} |
| 612 | className="ml-auto justify-end" |
| 613 | /> |
| 614 | ); |
| 615 | return ( |
| 616 | <article |
| 617 | key={`${detail.url}:${comment.id}`} |
| 618 | // Offscreen comments skip style, layout and paint. Bot comments carry pages of |
| 619 | // highlighted code, and the conversation is below the description either way. |
| 620 | className="group rounded-lg border border-border/60 bg-background [contain-intrinsic-block-size:160px] [content-visibility:auto]" |
| 621 | > |
| 622 | <div className="flex flex-wrap items-start gap-2 rounded-t-lg bg-muted/25 px-3 py-2.5"> |
| 623 | <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 text-xs text-muted-foreground"> |
| 624 | <CommentIdentity comment={comment} detail={detail} /> |
| 625 | {outcome ? ( |
| 626 | <PullRequestReviewOutcomeBadge outcome={outcome} /> |
| 627 | ) : comment.reviewState ? ( |
| 628 | <span>{reviewStateLabel(comment.reviewState)}</span> |
| 629 | ) : null} |
| 630 | </div> |
| 631 | {/* Review remarks only. A plain conversation comment is talk, not a finding, |
| 632 | and offering to fix one would promise more than it says. */} |
| 633 | {onFixFinding && finding ? ( |
| 634 | <Button |
| 635 | size="xs" |
| 636 | variant="ghost" |
| 637 | className="-mt-1 shrink-0" |
| 638 | disabled={pendingFinding !== null && pendingFinding !== undefined} |
| 639 | onClick={() => onFixFinding(finding)} |
| 640 | > |
| 641 | <HammerIcon className="size-3" /> |
| 642 | {pendingFinding === pullRequestFindingKey(finding) ? "Preparing..." : fixFindingLabel} |
| 643 | </Button> |
| 644 | ) : null} |
| 645 | {reactionBar} |
| 646 | </div> |
| 647 | <div className="px-3"> |
| 648 | <CommentLocation comment={comment} thread={thread} /> |
| 649 | </div> |
| 650 | {/* A verdict usually carries no words, and an empty markdown block reads as |
| 651 | a card somebody forgot to fill in — the badge above already said it. |
| 652 | Kept where this reader may rewrite the remark: the pencil lives in here, |
| 653 | and hiding the block would take away the only way back to it. */} |
| 654 | {body === null && !commentEditing.canEdit(comment) ? null : ( |
| 655 | <CommentBody className="px-3 py-3" comment={comment} editing={commentEditing} /> |
| 656 | )} |
| 657 | </article> |
| 658 | ); |
| 659 | }; |
| 660 | |
| 661 | return ( |
| 662 | <div ref={scrollerRef} className="h-full overflow-y-auto" data-pull-request-summary-scroll> |
| 663 | <section className="px-4 pt-2.5 pb-1"> |
| 664 | <div className="space-y-2"> |
| 665 | <MetaRow icon={<UsersIcon className="size-3.5" />} label="Reviewers"> |
| 666 | <span className="flex min-w-0 flex-wrap items-center gap-1.5"> |
| 667 | {reviewerEntries.length === 0 ? ( |
| 668 | <span className="text-muted-foreground">None</span> |
| 669 | ) : ( |
| 670 | <span className="flex items-center -space-x-1"> |
| 671 | {reviewerEntries.map((entry) => { |
| 672 | const login = entry.actor?.login ?? "ghost"; |
| 673 | const named = |
| 674 | entry.actor?.name && entry.actor.name !== login |
| 675 | ? `${entry.actor.name} (@${login})` |
| 676 | : login; |
| 677 | return ( |
| 678 | <Tooltip key={entry.key}> |
| 679 | {/* A verdict rides the face that earned it rather than a row of its own: |
| 680 | the ring sits outside the one that separates overlapping avatars, so |
| 681 | it reads at a glance without adding anything to scroll past. */} |
| 682 | <TooltipTrigger |
| 683 | render={ |
| 684 | <span |
| 685 | className={cn( |
| 686 | "relative rounded-full hover:z-10", |
| 687 | // The verdict replaces the separator rather than ringing it. Both |
| 688 | // occupy the same 2px immediately outside a 16px avatar, so the |
| 689 | // colour costs no size: anything drawn further out would be a |
| 690 | // halo wide enough to eclipse the neighbour this stack overlaps |
| 691 | // by 4px. Painted by this wrapper because a child's box-shadow |
| 692 | // covers its parent's, never the other way round. |
| 693 | entry.outcome |
| 694 | ? pullRequestReviewOutcomeRingClassName( |
| 695 | entry.outcome, |
| 696 | entry.stale, |
| 697 | ) |
| 698 | : undefined, |
| 699 | )} |
| 700 | /> |
| 701 | } |
| 702 | > |
| 703 | <PullRequestActorLabel |
| 704 | actor={entry.actor} |
| 705 | tooltip={false} |
| 706 | variant="avatar" |
| 707 | /> |
| 708 | {/* Colour alone says nothing to a reader who cannot see it, and the |
| 709 | login beside this is already in the accessible name. */} |
| 710 | {entry.outcome ? ( |
| 711 | <span className="sr-only"> |
| 712 | {entry.stale |
| 713 | ? pullRequestReviewOutcomeStaleLabel(entry.outcome) |
| 714 | : pullRequestReviewOutcomeLabel(entry.outcome)} |
| 715 | </span> |
| 716 | ) : null} |
| 717 | </TooltipTrigger> |
| 718 | <TooltipPopup side="bottom"> |
| 719 | {entry.outcome |
| 720 | ? `${named} — ${ |
| 721 | entry.stale |
| 722 | ? pullRequestReviewOutcomeStaleLabel(entry.outcome) |
| 723 | : pullRequestReviewOutcomeLabel(entry.outcome) |
| 724 | }` |
| 725 | : named} |
| 726 | </TooltipPopup> |
| 727 | </Tooltip> |
| 728 | ); |
| 729 | })} |
| 730 | </span> |
| 731 | )} |
| 732 | {/* Shown wherever the host can take a review request at all, and disabled with the |
| 733 | reason where this account may not make one: a control that vanishes teaches |
| 734 | nobody why, and "you need write access" is the answer to the question a reader |
| 735 | actually has. Azure DevOps is the exception — it takes a reviewer but will not |
| 736 | say who could be one, so there is nothing to open. */} |
| 737 | {detail.capabilities.reviewers.request && |
| 738 | detail.capabilities.reviewers.listCandidates ? ( |
| 739 | <PullRequestReviewerPicker |
| 740 | environmentId={environmentId} |
| 741 | reference={reference} |
| 742 | allowed={detail.viewerPermissions.requestReviewers} |
| 743 | /> |
| 744 | ) : null} |
| 745 | </span> |
| 746 | </MetaRow> |
| 747 | {/* The row is shown empty only where a label could be put on it from here; on a host |
| 748 | with none to offer, an empty row is a row about nothing. */} |
| 749 | {detail.labels.length > 0 || detail.capabilities.labels === true ? ( |
| 750 | <MetaRow icon={<TagIcon className="size-3.5" />} label="Labels"> |
| 751 | <span className="flex min-w-0 flex-wrap items-center gap-1"> |
| 752 | {detail.labels.length === 0 ? ( |
| 753 | <span className="text-muted-foreground">None</span> |
| 754 | ) : ( |
| 755 | detail.labels.map((label) => ( |
| 756 | <PullRequestLabelChip |
| 757 | key={label.name} |
| 758 | label={label} |
| 759 | size="default" |
| 760 | className="max-w-48" |
| 761 | /> |
| 762 | )) |
| 763 | )} |
| 764 | {detail.capabilities.labels === true ? ( |
| 765 | <PullRequestLabelPicker |
| 766 | environmentId={environmentId} |
| 767 | reference={reference} |
| 768 | allowed={detail.viewerPermissions.labels !== false} |
| 769 | /> |
| 770 | ) : null} |
| 771 | </span> |
| 772 | </MetaRow> |
| 773 | ) : null} |
| 774 | </div> |
| 775 | </section> |
| 776 | |
| 777 | <Section key={`description:${detail.url}`} title="Description" keepMounted> |
| 778 | <div className="group"> |
| 779 | {bodyScope === detail.url ? ( |
| 780 | <PullRequestMarkdownEditor |
| 781 | // Empty is a real answer here: saving nothing is how a description is cleared. |
| 782 | allowEmpty |
| 783 | value={detail.body} |
| 784 | cwd={detail.workspaceRoot} |
| 785 | environmentId={environmentId} |
| 786 | threadRef={threadRef} |
| 787 | label="Pull request description" |
| 788 | placeholder="Describe this pull request" |
| 789 | saving={bodySaving} |
| 790 | onSave={(body) => void saveBody(body)} |
| 791 | onCancel={() => setBodyScope(null)} |
| 792 | /> |
| 793 | ) : ( |
| 794 | <div className="flex items-start gap-1"> |
| 795 | <PullRequestMarkdown |
| 796 | className="min-w-0 flex-1" |
| 797 | text={detail.body.trim().length > 0 ? detail.body : "_No description provided._"} |
| 798 | cwd={detail.workspaceRoot} |
| 799 | environmentId={environmentId} |
| 800 | threadRef={threadRef} |
| 801 | /> |
| 802 | {canEditPullRequestChangeRequest(detail) ? ( |
| 803 | <PullRequestEditButton |
| 804 | aria-label="Edit description" |
| 805 | onClick={() => setBodyScope(detail.url)} |
| 806 | /> |
| 807 | ) : null} |
| 808 | </div> |
| 809 | )} |
| 810 | </div> |
| 811 | </Section> |
| 812 | |
| 813 | <Section key={`checks:${detail.url}`} title="Checks" defaultOpen={false}> |
| 814 | {checksStale ? ( |
| 815 | <div className="flex items-center gap-2 text-xs text-muted-foreground"> |
| 816 | <span>Check details are out of date.</span> |
| 817 | <Button size="xs" variant="ghost" onClick={onRefreshChecks}> |
| 818 | Refresh |
| 819 | </Button> |
| 820 | </div> |
| 821 | ) : detail.checks.length === 0 ? ( |
| 822 | <p className="text-xs text-muted-foreground">No checks reported.</p> |
| 823 | ) : ( |
| 824 | detail.checks.map((check, index) => { |
| 825 | const finding = { kind: "check", check } as const; |
| 826 | const failing = check.status === "failure" || check.status === "cancelled"; |
| 827 | return ( |
| 828 | <div |
| 829 | // Position too: the host decides how many runs share a name, and a repeated |
| 830 | // key would be a rendering fault on top of whatever the list already says. |
| 831 | key={`${index}:${check.name}:${check.url ?? ""}`} |
| 832 | className="group flex items-center gap-2 rounded-md pr-1 hover:bg-accent/60" |
| 833 | > |
| 834 | <button |
| 835 | type="button" |
| 836 | disabled={!check.url} |
| 837 | onClick={() => check.url && openCheck(check.url)} |
| 838 | className={cn( |
| 839 | "flex min-w-0 flex-1 items-start gap-2 rounded-md px-2 py-2 text-left text-xs leading-5 [&>svg]:mt-0.5", |
| 840 | check.url ? "cursor-pointer" : "cursor-default", |
| 841 | )} |
| 842 | > |
| 843 | <PullRequestCheckStatusIcon status={check.status} /> |
| 844 | <span className="min-w-0 flex-1 wrap-anywhere">{check.name}</span> |
| 845 | <span className="shrink-0 text-muted-foreground"> |
| 846 | {pullRequestCheckStatusLabel(check)} |
| 847 | </span> |
| 848 | </button> |
| 849 | {/* Only where there is something to fix. A passing check has no failure to |
| 850 | reproduce, and the button would be an invitation to waste a thread. */} |
| 851 | {onFixFinding && failing ? ( |
| 852 | <Button |
| 853 | size="xs" |
| 854 | variant="ghost" |
| 855 | className="shrink-0" |
| 856 | disabled={pendingFinding !== null && pendingFinding !== undefined} |
| 857 | onClick={() => onFixFinding(finding)} |
| 858 | > |
| 859 | <HammerIcon className="size-3" /> |
| 860 | {pendingFinding === pullRequestFindingKey(finding) |
| 861 | ? "Preparing..." |
| 862 | : fixCheckLabel} |
| 863 | </Button> |
| 864 | ) : null} |
| 865 | </div> |
| 866 | ); |
| 867 | }) |
| 868 | )} |
| 869 | </Section> |
| 870 | |
| 871 | <Section |
| 872 | title={`Comments (${detail.commentCount})`} |
| 873 | actions={ |
| 874 | <div className="flex shrink-0 items-center gap-1"> |
| 875 | <Menu> |
| 876 | <MenuTrigger |
| 877 | render={<Button size="xs" variant="ghost-muted" />} |
| 878 | aria-label="Filter comments" |
| 879 | > |
| 880 | <ListFilterIcon aria-hidden className="size-3" /> |
| 881 | {filteredOutCount > 0 ? `${filteredOutCount} hidden` : "Filter"} |
| 882 | </MenuTrigger> |
| 883 | <MenuPopup align="end"> |
| 884 | <MenuGroup> |
| 885 | <MenuGroupLabel>Show</MenuGroupLabel> |
| 886 | <MenuCheckboxItem |
| 887 | checked={commentFilter.bots} |
| 888 | closeOnClick={false} |
| 889 | onCheckedChange={(bots) => setCommentFilter((value) => ({ ...value, bots }))} |
| 890 | > |
| 891 | Bot comments |
| 892 | </MenuCheckboxItem> |
| 893 | <MenuCheckboxItem |
| 894 | checked={commentFilter.resolved} |
| 895 | closeOnClick={false} |
| 896 | onCheckedChange={(resolved) => |
| 897 | setCommentFilter((value) => ({ ...value, resolved })) |
| 898 | } |
| 899 | > |
| 900 | Resolved or dismissed |
| 901 | </MenuCheckboxItem> |
| 902 | </MenuGroup> |
| 903 | </MenuPopup> |
| 904 | </Menu> |
| 905 | <Button |
| 906 | size="xs" |
| 907 | variant="ghost-muted" |
| 908 | aria-label={ |
| 909 | commentOrder === "newest" |
| 910 | ? "Show oldest comments first" |
| 911 | : "Show newest comments first" |
| 912 | } |
| 913 | onClick={() => setCommentOrder((value) => (value === "newest" ? "oldest" : "newest"))} |
| 914 | > |
| 915 | <ArrowDownUpIcon aria-hidden className="size-3" /> |
| 916 | {commentOrder === "newest" ? "Newest first" : "Oldest first"} |
| 917 | </Button> |
| 918 | </div> |
| 919 | } |
| 920 | > |
| 921 | {activityPending ? ( |
| 922 | <PullRequestConversationGhost /> |
| 923 | ) : activityError ? ( |
| 924 | <PullRequestActivityUnavailableState compact error={activityError} onRetry={onRefresh} /> |
| 925 | ) : ( |
| 926 | <> |
| 927 | {detail.commentsTruncated ? ( |
| 928 | <p className="mb-2 rounded-md border border-warning/30 bg-warning-surface px-2 py-1.5 text-xs"> |
| 929 | This conversation is longer than this page reads in one go. The most recent{" "} |
| 930 | {detail.comments.length} are here; open it on the host to read the rest. |
| 931 | </p> |
| 932 | ) : null} |
| 933 | {detail.comments.length === 0 ? ( |
| 934 | <p className="py-2 text-xs text-muted-foreground">No comments yet.</p> |
| 935 | ) : filteredComments.length === 0 ? ( |
| 936 | <p className="py-2 text-xs text-muted-foreground"> |
| 937 | {filteredOutCount === 1 |
| 938 | ? "The only comment is hidden by the filter." |
| 939 | : `All ${filteredOutCount} comments are hidden by the filter.`} |
| 940 | </p> |
| 941 | ) : ( |
| 942 | <div className="space-y-3"> |
| 943 | {commentOrder === "oldest" ? showOldestCommentsButton : null} |
| 944 | {visibleComments.map(renderComment)} |
| 945 | {commentOrder === "newest" ? showOldestCommentsButton : null} |
| 946 | {shownComments > COMMENT_PAGE ? ( |
| 947 | <Button |
| 948 | size="sm" |
| 949 | variant="ghost" |
| 950 | className="w-full" |
| 951 | onClick={() => setShown({ url: detail.url, count: COMMENT_PAGE })} |
| 952 | > |
| 953 | Show only {COMMENT_PAGE} recent comments |
| 954 | </Button> |
| 955 | ) : null} |
| 956 | </div> |
| 957 | )} |
| 958 | </> |
| 959 | )} |
| 960 | </Section> |
| 961 | </div> |
| 962 | ); |
| 963 | } |