| author | |
| committer | |
| log | c7e6d711d3f2982e54854258987509e8f69b31cd |
| tree | 68c456e0a86bb4eac333265465f53dfbbb44677c |
| parent | cd096b9ad5a4156ffeab85de617cbb219057007f |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 761 insertions(+), 41 deletions(-)
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+34| ... | @@ -103,6 +103,7 @@ import { | ... | @@ -103,6 +103,7 @@ import { |
| 103 | buildResolveConflictsPrompt, | 103 | buildResolveConflictsPrompt, |
| 104 | handoffPrompt, | 104 | handoffPrompt, |
| 105 | handoffReviewComments, | 105 | handoffReviewComments, |
| 106 | latestPullRequestReviewOutcomes, | ||
| 106 | pullRequestActionMenuHasGroup, | 107 | pullRequestActionMenuHasGroup, |
| 107 | pullRequestActionNeedsHostRefresh, | 108 | pullRequestActionNeedsHostRefresh, |
| 108 | pullRequestComposerTarget, | 109 | pullRequestComposerTarget, |
| ... | @@ -123,7 +124,9 @@ import { | ... | @@ -123,7 +124,9 @@ import { |
| 123 | PullRequestActorLabel, | 124 | PullRequestActorLabel, |
| 124 | PullRequestDiffStat, | 125 | PullRequestDiffStat, |
| 125 | PullRequestMetaLine, | 126 | PullRequestMetaLine, |
| 127 | PullRequestReviewOutcomeIcon, | ||
| 126 | pullRequestChecksState, | 128 | pullRequestChecksState, |
| 129 | pullRequestReviewOutcomeToneClassName, | ||
| 127 | resolvePullRequestState, | 130 | resolvePullRequestState, |
| 128 | summarizePullRequestChecks, | 131 | summarizePullRequestChecks, |
| 129 | } from "./pullRequestPresentation"; | 132 | } from "./pullRequestPresentation"; |
| ... | @@ -1075,6 +1078,19 @@ export function PullRequestDetailPanel({ | ... | @@ -1075,6 +1078,19 @@ export function PullRequestDetailPanel({ |
| 1075 | : null; | 1078 | : null; |
| 1076 | const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null; | 1079 | const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null; |
| 1077 | const checksState = detail ? pullRequestChecksState(detail.checks) : null; | 1080 | const checksState = detail ? pullRequestChecksState(detail.checks) : null; |
| 1081 | // Approvals that still stand, and only those. A superseded one is dimmed beside the reviewer | ||
| 1082 | // who gave it, so counting it here would have the header assert in a number what the row next | ||
| 1083 | // to it has just qualified. | ||
| 1084 | // | ||
| 1085 | // Not counted at all from a conversation this page only holds the recent end of: an approval | ||
| 1086 | // older than the window would be missing, and "1" beside a tick is read as the whole answer. | ||
| 1087 | // The Summary tab's row can say it may be short; a bare number cannot, so it stays away. | ||
| 1088 | const approvalCount = | ||
| 1089 | detail && !detail.commentsTruncated | ||
| 1090 | ? latestPullRequestReviewOutcomes(detail.comments, detail.commits).filter( | ||
| 1091 | (entry) => entry.outcome === "approved" && !entry.stale, | ||
| 1092 | ).length | ||
| 1093 | : 0; | ||
| 1078 | 1094 | ||
| 1079 | return ( | 1095 | return ( |
| 1080 | <div className="flex h-full min-h-0 w-full flex-col bg-background"> | 1096 | <div className="flex h-full min-h-0 w-full flex-col bg-background"> |
| ... | @@ -1813,6 +1829,24 @@ export function PullRequestDetailPanel({ | ... | @@ -1813,6 +1829,24 @@ export function PullRequestDetailPanel({ |
| 1813 | ? "…" | 1829 | ? "…" |
| 1814 | : detail.commits.length.toLocaleString()} | 1830 | : detail.commits.length.toLocaleString()} |
| 1815 | </span> | 1831 | </span> |
| 1832 | {/* Only once somebody has approved: a nought beside a tick would read as a | ||
| 1833 | verdict of its own on a change nobody has looked at yet. */} | ||
| 1834 | {approvalCount > 0 ? ( | ||
| 1835 | <span | ||
| 1836 | className={cn( | ||
| 1837 | "inline-flex items-center gap-1", | ||
| 1838 | pullRequestReviewOutcomeToneClassName("approved"), | ||
| 1839 | )} | ||
| 1840 | > | ||
| 1841 | <PullRequestReviewOutcomeIcon outcome="approved" className="size-3" /> | ||
| 1842 | {approvalCount.toLocaleString()} | ||
| 1843 | {/* The icon is decorative and a name written onto a generic span is | ||
| 1844 | not announced, so the bare number says what it counts in words. */} | ||
| 1845 | <span className="sr-only"> | ||
| 1846 | {approvalCount === 1 ? "approval" : "approvals"} | ||
| 1847 | </span> | ||
| 1848 | </span> | ||
| 1849 | ) : null} | ||
| 1816 | </PullRequestMetaLine> | 1850 | </PullRequestMetaLine> |
| 1817 | <Button | 1851 | <Button |
| 1818 | size="xs" | 1852 | size="xs" |
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+159-39| ... | @@ -33,13 +33,20 @@ import { | ... | @@ -33,13 +33,20 @@ import { |
| 33 | PullRequestActorAvatar, | 33 | PullRequestActorAvatar, |
| 34 | PullRequestActorLabel, | 34 | PullRequestActorLabel, |
| 35 | PullRequestCheckStatusIcon, | 35 | PullRequestCheckStatusIcon, |
| 36 | PullRequestReviewOutcomeBadge, | ||
| 36 | pullRequestCheckStatusLabel, | 37 | pullRequestCheckStatusLabel, |
| 38 | pullRequestReviewOutcomeLabel, | ||
| 39 | pullRequestReviewOutcomeRingClassName, | ||
| 40 | pullRequestReviewOutcomeStaleLabel, | ||
| 37 | } from "./pullRequestPresentation"; | 41 | } from "./pullRequestPresentation"; |
| 38 | import { PullRequestReviewerPicker } from "./PullRequestReviewerPicker"; | 42 | import { PullRequestReviewerPicker } from "./PullRequestReviewerPicker"; |
| 39 | import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; | 43 | import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; |
| 40 | import { | 44 | import { |
| 45 | latestPullRequestReviewOutcomes, | ||
| 41 | orderPullRequestComments, | 46 | orderPullRequestComments, |
| 42 | pullRequestFindingKey, | 47 | pullRequestFindingKey, |
| 48 | pullRequestReviewOutcome, | ||
| 49 | visibleBody, | ||
| 43 | type PullRequestFinding, | 50 | type PullRequestFinding, |
| 44 | } from "./pullRequestDetail.logic"; | 51 | } from "./pullRequestDetail.logic"; |
| 45 | import { | 52 | import { |
| ... | @@ -52,6 +59,11 @@ import { PullRequestReactionBar } from "./PullRequestReactions"; | ... | @@ -52,6 +59,11 @@ import { PullRequestReactionBar } from "./PullRequestReactions"; |
| 52 | import { PullRequestConversationGhost } from "./PullRequestGhosts"; | 59 | import { PullRequestConversationGhost } from "./PullRequestGhosts"; |
| 53 | import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; | 60 | import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; |
| 54 | 61 | ||
| 62 | /** One reviewer, however a host happens to have cased their login this time. */ | ||
| 63 | function reviewerKey(login: string): string { | ||
| 64 | return login.toLowerCase(); | ||
| 65 | } | ||
| 66 | |||
| 55 | /** A host colour only when it is one, so a malformed value falls back to the neutral dot. */ | 67 | /** A host colour only when it is one, so a malformed value falls back to the neutral dot. */ |
| 56 | function labelDotColor(color: string | null): string | null { | 68 | function labelDotColor(color: string | null): string | null { |
| 57 | const hex = color?.trim().replace(/^#/, "") ?? ""; | 69 | const hex = color?.trim().replace(/^#/, "") ?? ""; |
| ... | @@ -138,11 +150,14 @@ function CollapsedComment({ | ... | @@ -138,11 +150,14 @@ function CollapsedComment({ |
| 138 | comment, | 150 | comment, |
| 139 | editing, | 151 | editing, |
| 140 | label, | 152 | label, |
| 153 | body, | ||
| 141 | reactionBar, | 154 | reactionBar, |
| 142 | }: { | 155 | }: { |
| 143 | comment: PullRequestComment; | 156 | comment: PullRequestComment; |
| 144 | editing: CommentEditing; | 157 | editing: CommentEditing; |
| 145 | label: string; | 158 | label: string; |
| 159 | /** Null where the remark is nothing but its verdict, which a dismissal usually is. */ | ||
| 160 | body: string | null; | ||
| 146 | reactionBar: ReactNode; | 161 | reactionBar: ReactNode; |
| 147 | }) { | 162 | }) { |
| 148 | const [open, setOpen] = useState(false); | 163 | const [open, setOpen] = useState(false); |
| ... | @@ -181,7 +196,11 @@ function CollapsedComment({ | ... | @@ -181,7 +196,11 @@ function CollapsedComment({ |
| 181 | <TooltipPopup side="top">{comment.path}</TooltipPopup> | 196 | <TooltipPopup side="top">{comment.path}</TooltipPopup> |
| 182 | </Tooltip> | 197 | </Tooltip> |
| 183 | ) : null} | 198 | ) : null} |
| 184 | <CommentBody className="mt-2" comment={comment} editing={editing} /> | 199 | {/* A dismissal carries no more words than an approval does, and an empty markdown |
| 200 | block reads as a card somebody forgot to fill in. */} | ||
| 201 | {body === null && !editing.canEdit(comment) ? null : ( | ||
| 202 | <CommentBody className="mt-2" comment={comment} editing={editing} /> | ||
| 203 | )} | ||
| 185 | {reactionBar} | 204 | {reactionBar} |
| 186 | </div> | 205 | </div> |
| 187 | ) : null} | 206 | ) : null} |
| ... | @@ -386,6 +405,42 @@ export function PullRequestSummaryTab({ | ... | @@ -386,6 +405,42 @@ export function PullRequestSummaryTab({ |
| 386 | const hiddenCommentCount = detail.comments.length - recentComments.length; | 405 | const hiddenCommentCount = detail.comments.length - recentComments.length; |
| 387 | const [commentOrder, setCommentOrder] = useState<"newest" | "oldest">("newest"); | 406 | const [commentOrder, setCommentOrder] = useState<"newest" | "oldest">("newest"); |
| 388 | const visibleComments = orderPullRequestComments(recentComments, commentOrder); | 407 | const visibleComments = orderPullRequestComments(recentComments, commentOrder); |
| 408 | // Read from the whole conversation, not the window shown below it: a verdict older than the | ||
| 409 | // last thirty comments still stands. | ||
| 410 | const reviewOutcomes = latestPullRequestReviewOutcomes(detail.comments, detail.commits); | ||
| 411 | // Hosts do not promise one casing for a login across two fields of the same response, and | ||
| 412 | // none of them lets `Octocat` and `octocat` be two people — so matching on the literal string | ||
| 413 | // would show one reviewer twice and drop the verdict off both. | ||
| 414 | const outcomeByLogin = new Map( | ||
| 415 | reviewOutcomes.flatMap((entry) => | ||
| 416 | entry.actor ? [[reviewerKey(entry.actor.login), entry] as const] : [], | ||
| 417 | ), | ||
| 418 | ); | ||
| 419 | // Everyone whose face belongs on this row: the people a review was asked of, then anyone who | ||
| 420 | // ruled without being on that list. A host drops a reviewer from the requested set once they | ||
| 421 | // have reviewed, and their verdict is the thing this row now exists to show. | ||
| 422 | const reviewerEntries = [ | ||
| 423 | ...detail.reviewers.map((actor) => ({ | ||
| 424 | key: actor.login, | ||
| 425 | actor, | ||
| 426 | outcome: outcomeByLogin.get(reviewerKey(actor.login))?.outcome ?? null, | ||
| 427 | stale: outcomeByLogin.get(reviewerKey(actor.login))?.stale ?? false, | ||
| 428 | })), | ||
| 429 | ...reviewOutcomes | ||
| 430 | .filter( | ||
| 431 | (entry) => | ||
| 432 | !detail.reviewers.some( | ||
| 433 | (actor) => | ||
| 434 | entry.actor !== null && reviewerKey(actor.login) === reviewerKey(entry.actor.login), | ||
| 435 | ), | ||
| 436 | ) | ||
| 437 | .map((entry) => ({ | ||
| 438 | key: entry.key, | ||
| 439 | actor: entry.actor, | ||
| 440 | outcome: entry.outcome, | ||
| 441 | stale: entry.stale, | ||
| 442 | })), | ||
| 443 | ]; | ||
| 389 | 444 | ||
| 390 | // A comment that already lives on a review thread is that thread: the thread carries the line | 445 | // A comment that already lives on a review thread is that thread: the thread carries the line |
| 391 | // and side the bare comment has lost, and a resolved one is finished work nobody should be | 446 | // and side the bare comment has lost, and a resolved one is finished work nobody should be |
| ... | @@ -464,33 +519,76 @@ export function PullRequestSummaryTab({ | ... | @@ -464,33 +519,76 @@ export function PullRequestSummaryTab({ |
| 464 | <div> | 519 | <div> |
| 465 | <MetaRow icon={<UsersIcon className="size-3.5" />} label="Reviewers"> | 520 | <MetaRow icon={<UsersIcon className="size-3.5" />} label="Reviewers"> |
| 466 | <span className="flex min-w-0 flex-wrap items-center gap-1.5"> | 521 | <span className="flex min-w-0 flex-wrap items-center gap-1.5"> |
| 467 | {detail.reviewers.length === 0 ? ( | 522 | {reviewerEntries.length === 0 ? ( |
| 468 | <span className="text-muted-foreground">None</span> | 523 | <span className="text-muted-foreground">None</span> |
| 469 | ) : ( | 524 | ) : ( |
| 470 | <span className="flex items-center -space-x-1"> | 525 | <span className="flex items-center -space-x-1"> |
| 471 | {detail.reviewers.map((actor) => ( | 526 | {reviewerEntries.map((entry) => { |
| 472 | <Tooltip key={actor.login}> | 527 | const login = entry.actor?.login ?? "ghost"; |
| 473 | <TooltipTrigger | 528 | const named = |
| 474 | render={ | 529 | entry.actor?.name && entry.actor.name !== login |
| 475 | <span | 530 | ? `${entry.actor.name} (@${login})` |
| 476 | className="relative rounded-full hover:z-10" | 531 | : login; |
| 477 | aria-label={actor.name ?? actor.login} | 532 | return ( |
| 533 | <Tooltip key={entry.key}> | ||
| 534 | {/* A verdict rides the face that earned it rather than a row of its own: | ||
| 535 | the ring sits outside the one that separates overlapping avatars, so | ||
| 536 | it reads at a glance without adding anything to scroll past. */} | ||
| 537 | <TooltipTrigger | ||
| 538 | render={ | ||
| 539 | <span | ||
| 540 | className={cn( | ||
| 541 | "relative rounded-full hover:z-10", | ||
| 542 | // The verdict replaces the separator rather than ringing it. Both | ||
| 543 | // occupy the same 2px immediately outside a 16px avatar, so the | ||
| 544 | // colour costs no size: anything drawn further out would be a | ||
| 545 | // halo wide enough to eclipse the neighbour this stack overlaps | ||
| 546 | // by 4px. Painted by this wrapper because a child's box-shadow | ||
| 547 | // covers its parent's, never the other way round. | ||
| 548 | entry.outcome | ||
| 549 | ? pullRequestReviewOutcomeRingClassName( | ||
| 550 | entry.outcome, | ||
| 551 | entry.stale, | ||
| 552 | ) | ||
| 553 | : undefined, | ||
| 554 | )} | ||
| 555 | /> | ||
| 556 | } | ||
| 557 | > | ||
| 558 | <PullRequestActorLabel | ||
| 559 | actor={entry.actor} | ||
| 560 | tooltip={false} | ||
| 561 | className={cn( | ||
| 562 | "gap-0 [&>span:last-child]:sr-only", | ||
| 563 | // Only where the wrapper is not already drawing one, or the opaque | ||
| 564 | // separator would cover the verdict in the band they share. | ||
| 565 | entry.outcome | ||
| 566 | ? undefined | ||
| 567 | : "[&>img]:ring-2 [&>img]:ring-background [&>span:first-child]:ring-2 [&>span:first-child]:ring-background", | ||
| 568 | )} | ||
| 478 | /> | 569 | /> |
| 479 | } | 570 | {/* Colour alone says nothing to a reader who cannot see it, and the |
| 480 | > | 571 | login beside this is already in the accessible name. */} |
| 481 | <PullRequestActorLabel | 572 | {entry.outcome ? ( |
| 482 | actor={actor} | 573 | <span className="sr-only"> |
| 483 | tooltip={false} | 574 | {entry.stale |
| 484 | className="gap-0 [&>img]:ring-2 [&>img]:ring-background [&>span:first-child]:ring-2 [&>span:first-child]:ring-background [&>span:last-child]:sr-only" | 575 | ? pullRequestReviewOutcomeStaleLabel(entry.outcome) |
| 485 | /> | 576 | : pullRequestReviewOutcomeLabel(entry.outcome)} |
| 486 | </TooltipTrigger> | 577 | </span> |
| 487 | <TooltipPopup side="bottom"> | 578 | ) : null} |
| 488 | {actor.name && actor.name !== actor.login | 579 | </TooltipTrigger> |
| 489 | ? `${actor.name} (@${actor.login})` | 580 | <TooltipPopup side="bottom"> |
| 490 | : actor.login} | 581 | {entry.outcome |
| 491 | </TooltipPopup> | 582 | ? `${named} — ${ |
| 492 | </Tooltip> | 583 | entry.stale |
| 493 | ))} | 584 | ? pullRequestReviewOutcomeStaleLabel(entry.outcome) |
| 585 | : pullRequestReviewOutcomeLabel(entry.outcome) | ||
| 586 | }` | ||
| 587 | : named} | ||
| 588 | </TooltipPopup> | ||
| 589 | </Tooltip> | ||
| 590 | ); | ||
| 591 | })} | ||
| 494 | </span> | 592 | </span> |
| 495 | )} | 593 | )} |
| 496 | {/* Shown wherever the host can take a review request at all, and disabled with the | 594 | {/* Shown wherever the host can take a review request at all, and disabled with the |
| ... | @@ -697,14 +795,16 @@ export function PullRequestSummaryTab({ | ... | @@ -697,14 +795,16 @@ export function PullRequestSummaryTab({ |
| 697 | ) : null} | 795 | ) : null} |
| 698 | {visibleComments.map((comment) => { | 796 | {visibleComments.map((comment) => { |
| 699 | const thread = threadByCommentId.get(comment.id); | 797 | const thread = threadByCommentId.get(comment.id); |
| 700 | const reviewState = comment.reviewState?.toLowerCase(); | 798 | const body = visibleBody(comment.body); |
| 701 | if (thread?.isResolved || reviewState === "dismissed") { | 799 | const outcome = pullRequestReviewOutcome(comment.reviewState); |
| 800 | if (thread?.isResolved || outcome === "dismissed") { | ||
| 702 | return ( | 801 | return ( |
| 703 | <CollapsedComment | 802 | <CollapsedComment |
| 704 | key={comment.id} | 803 | key={comment.id} |
| 705 | comment={comment} | 804 | comment={comment} |
| 706 | editing={commentEditing} | 805 | editing={commentEditing} |
| 707 | label={thread?.isResolved ? "Resolved" : "Approval dismissed"} | 806 | label={thread?.isResolved ? "Resolved" : "Approval dismissed"} |
| 807 | body={body} | ||
| 708 | reactionBar={ | 808 | reactionBar={ |
| 709 | <PullRequestReactionBar | 809 | <PullRequestReactionBar |
| 710 | className="mt-2" | 810 | className="mt-2" |
| ... | @@ -722,11 +822,30 @@ export function PullRequestSummaryTab({ | ... | @@ -722,11 +822,30 @@ export function PullRequestSummaryTab({ |
| 722 | // An approval is a verdict, not a finding: there is nothing in it to fix. | 822 | // An approval is a verdict, not a finding: there is nothing in it to fix. |
| 723 | const finding: PullRequestFinding | null = | 823 | const finding: PullRequestFinding | null = |
| 724 | (comment.kind !== "review" && comment.kind !== "review-comment") || | 824 | (comment.kind !== "review" && comment.kind !== "review-comment") || |
| 725 | reviewState === "approved" | 825 | outcome === "approved" |
| 726 | ? null | 826 | ? null |
| 727 | : thread === undefined | 827 | : thread === undefined |
| 728 | ? { kind: "comment", comment } | 828 | ? // Nor is a remark with nothing in it: offering to hand an empty review |
| 829 | // to a thread promises work it does not describe. | ||
| 830 | body === null | ||
| 831 | ? null | ||
| 832 | : { kind: "comment", comment } | ||
| 729 | : { kind: "thread", thread }; | 833 | : { kind: "thread", thread }; |
| 834 | // One bar, two homes. Under a card with words in it, it is the row beneath | ||
| 835 | // them. A bodiless verdict has nothing above it, so a row reserved for an add | ||
| 836 | // button nobody can see until they hover is a hole — there it rides the header | ||
| 837 | // line instead, which keeps the affordance every sibling card offers. | ||
| 838 | const reactionBar = ( | ||
| 839 | <PullRequestReactionBar | ||
| 840 | reactions={comment.reactions ?? []} | ||
| 841 | canReact={detail.capabilities.reactions === true} | ||
| 842 | subjectId={comment.id} | ||
| 843 | environmentId={environmentId} | ||
| 844 | reference={reference} | ||
| 845 | onRefresh={onRefresh} | ||
| 846 | {...(body === null ? {} : { className: "mt-2" })} | ||
| 847 | /> | ||
| 848 | ); | ||
| 730 | return ( | 849 | return ( |
| 731 | <article | 850 | <article |
| 732 | key={comment.id} | 851 | key={comment.id} |
| ... | @@ -738,9 +857,12 @@ export function PullRequestSummaryTab({ | ... | @@ -738,9 +857,12 @@ export function PullRequestSummaryTab({ |
| 738 | <span className="flex min-w-0 flex-1 items-center gap-2 text-xs text-muted-foreground"> | 857 | <span className="flex min-w-0 flex-1 items-center gap-2 text-xs text-muted-foreground"> |
| 739 | <CommentAuthor actor={comment.author} /> | 858 | <CommentAuthor actor={comment.author} /> |
| 740 | <span>{formatRelativeTimeLabel(comment.createdAt)}</span> | 859 | <span>{formatRelativeTimeLabel(comment.createdAt)}</span> |
| 741 | {comment.reviewState ? ( | 860 | {outcome ? ( |
| 861 | <PullRequestReviewOutcomeBadge outcome={outcome} /> | ||
| 862 | ) : comment.reviewState ? ( | ||
| 742 | <span>{reviewStateLabel(comment.reviewState)}</span> | 863 | <span>{reviewStateLabel(comment.reviewState)}</span> |
| 743 | ) : null} | 864 | ) : null} |
| 865 | {body === null ? reactionBar : null} | ||
| 744 | </span> | 866 | </span> |
| 745 | {/* Review remarks only. A plain conversation comment is talk, not a finding, | 867 | {/* Review remarks only. A plain conversation comment is talk, not a finding, |
| 746 | and offering to fix one would promise more than it says. */} | 868 | and offering to fix one would promise more than it says. */} |
| ... | @@ -771,16 +893,14 @@ export function PullRequestSummaryTab({ | ... | @@ -771,16 +893,14 @@ export function PullRequestSummaryTab({ |
| 771 | <TooltipPopup side="top">{comment.path}</TooltipPopup> | 893 | <TooltipPopup side="top">{comment.path}</TooltipPopup> |
| 772 | </Tooltip> | 894 | </Tooltip> |
| 773 | ) : null} | 895 | ) : null} |
| 774 | <CommentBody className="mt-2" comment={comment} editing={commentEditing} /> | 896 | {/* A verdict usually carries no words, and an empty markdown block reads as |
| 775 | <PullRequestReactionBar | 897 | a card somebody forgot to fill in — the badge above already said it. |
| 776 | className="mt-2" | 898 | Kept where this reader may rewrite the remark: the pencil lives in here, |
| 777 | reactions={comment.reactions ?? []} | 899 | and hiding the block would take away the only way back to it. */} |
| 778 | canReact={detail.capabilities.reactions === true} | 900 | {body === null && !commentEditing.canEdit(comment) ? null : ( |
| 779 | subjectId={comment.id} | 901 | <CommentBody className="mt-2" comment={comment} editing={commentEditing} /> |
| 780 | environmentId={environmentId} | 902 | )} |
| 781 | reference={reference} | 903 | {body === null ? null : reactionBar} |
| 782 | onRefresh={onRefresh} | ||
| 783 | /> | ||
| 784 | </article> | 904 | </article> |
| 785 | ); | 905 | ); |
| 786 | })} | 906 | })} |
apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx+118| ... | @@ -27,9 +27,14 @@ import { formatRelativeTimeLabel } from "~/timestampFormat"; | ... | @@ -27,9 +27,14 @@ import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 27 | import { Button } from "../ui/button"; | 27 | import { Button } from "../ui/button"; |
| 28 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; | 28 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| 29 | import { toastManager } from "../ui/toast"; | 29 | import { toastManager } from "../ui/toast"; |
| 30 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 30 | import { | 31 | import { |
| 31 | buildPullRequestTimeline, | 32 | buildPullRequestTimeline, |
| 32 | groupPullRequestTimelineConversations, | 33 | groupPullRequestTimelineConversations, |
| 34 | isPullRequestVerdictStale, | ||
| 35 | newestPullRequestCommitAt, | ||
| 36 | pullRequestReviewOutcome, | ||
| 37 | type PullRequestReviewOutcome, | ||
| 33 | type PullRequestTimelineEvent, | 38 | type PullRequestTimelineEvent, |
| 34 | } from "./pullRequestDetail.logic"; | 39 | } from "./pullRequestDetail.logic"; |
| 35 | import { canEditPullRequestComment } from "./pullRequestEditing.logic"; | 40 | import { canEditPullRequestComment } from "./pullRequestEditing.logic"; |
| ... | @@ -40,6 +45,10 @@ import { | ... | @@ -40,6 +45,10 @@ import { |
| 40 | PullRequestActorAvatar, | 45 | PullRequestActorAvatar, |
| 41 | PullRequestDiffStat, | 46 | PullRequestDiffStat, |
| 42 | PullRequestMetaLine, | 47 | PullRequestMetaLine, |
| 48 | PullRequestReviewOutcomeIcon, | ||
| 49 | pullRequestReviewOutcomeLabel, | ||
| 50 | pullRequestReviewOutcomeStaleLabel, | ||
| 51 | pullRequestReviewOutcomeToneClassName, | ||
| 43 | } from "./pullRequestPresentation"; | 52 | } from "./pullRequestPresentation"; |
| 44 | 53 | ||
| 45 | /** What every comment on the timeline needs to react; only the subject differs between them. */ | 54 | /** What every comment on the timeline needs to react; only the subject differs between them. */ |
| ... | @@ -411,6 +420,100 @@ function LifecycleEvent({ event }: { event: PullRequestTimelineEvent }) { | ... | @@ -411,6 +420,100 @@ function LifecycleEvent({ event }: { event: PullRequestTimelineEvent }) { |
| 411 | ); | 420 | ); |
| 412 | } | 421 | } |
| 413 | 422 | ||
| 423 | /** | ||
| 424 | * A verdict, as its own row rather than a line inside a collapsed conversation. It wears the | ||
| 425 | * reviewer's face on the rail and the verdict's own icon beside their name, so "approved" reads | ||
| 426 | * at a glance from the same place a merge or a commit does. | ||
| 427 | */ | ||
| 428 | function ReviewVerdictEvent({ | ||
| 429 | event, | ||
| 430 | outcome, | ||
| 431 | stale, | ||
| 432 | cwd, | ||
| 433 | onOpen, | ||
| 434 | reactions, | ||
| 435 | }: { | ||
| 436 | event: PullRequestTimelineEvent; | ||
| 437 | outcome: PullRequestReviewOutcome; | ||
| 438 | /** Commits landed after this verdict, so it speaks for code the branch no longer has. */ | ||
| 439 | stale: boolean; | ||
| 440 | cwd: string; | ||
| 441 | onOpen: (url: string) => void; | ||
| 442 | reactions: ReactionSurface; | ||
| 443 | }) { | ||
| 444 | return ( | ||
| 445 | <div className="group relative mb-5 pl-12 [contain-intrinsic-block-size:48px] [content-visibility:auto]"> | ||
| 446 | {/* Pinned rather than centred: this row grows with a body and a reaction bar, and a | ||
| 447 | centred avatar drifts down beside them instead of sitting by the name. */} | ||
| 448 | <ActorTimelineMarker | ||
| 449 | actors={event.actor ? [event.actor] : []} | ||
| 450 | className="top-6" | ||
| 451 | fallback={<PullRequestReviewOutcomeIcon outcome={outcome} />} | ||
| 452 | /> | ||
| 453 | <div className="flex min-w-0 items-start gap-2 py-1.5"> | ||
| 454 | <div className="min-w-0 flex-1"> | ||
| 455 | <div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs"> | ||
| 456 | <ActorName actor={event.actor} /> | ||
| 457 | {/* The word alone, in the verdict's own colour — green for an approval, red for a | ||
| 458 | request for changes. A verdict overtaken by later commits keeps its word and | ||
| 459 | loses that colour: it still happened, and it no longer speaks for what is on the | ||
| 460 | branch. Lowercased in the styling rather than the string, so what a screen reader | ||
| 461 | announces stays the label every other surface uses. */} | ||
| 462 | <Tooltip disabled={!stale}> | ||
| 463 | <TooltipTrigger | ||
| 464 | render={ | ||
| 465 | <span | ||
| 466 | className={cn( | ||
| 467 | "font-medium lowercase", | ||
| 468 | stale | ||
| 469 | ? "text-muted-foreground opacity-70" | ||
| 470 | : pullRequestReviewOutcomeToneClassName(outcome), | ||
| 471 | )} | ||
| 472 | /> | ||
| 473 | } | ||
| 474 | > | ||
| 475 | {pullRequestReviewOutcomeLabel(outcome)} | ||
| 476 | {stale ? <span className="sr-only">, before the latest commits</span> : null} | ||
| 477 | </TooltipTrigger> | ||
| 478 | <TooltipPopup>{pullRequestReviewOutcomeStaleLabel(outcome)}</TooltipPopup> | ||
| 479 | </Tooltip> | ||
| 480 | </div> | ||
| 481 | {/* The reaction bar rides this line rather than taking one of its own. Its add button | ||
| 482 | is invisible until hovered but still occupies `h-6`, and under a verdict — usually a | ||
| 483 | single line with no body — a row of that reserved on its own reads as a hole. */} | ||
| 484 | <div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"> | ||
| 485 | <PullRequestMetaLine className="flex-wrap text-[11px] text-muted-foreground"> | ||
| 486 | <span>{formatRelativeTimeLabel(event.at)}</span> | ||
| 487 | {event.path ? ( | ||
| 488 | <span className="inline-flex min-w-0 items-center gap-1"> | ||
| 489 | <FileCode2Icon aria-hidden className="size-3 shrink-0" /> | ||
| 490 | <span className="truncate">{event.path}</span> | ||
| 491 | </span> | ||
| 492 | ) : null} | ||
| 493 | </PullRequestMetaLine> | ||
| 494 | {reactions.canReact || event.reactions.length > 0 ? ( | ||
| 495 | <PullRequestReactionBar | ||
| 496 | reactions={event.reactions} | ||
| 497 | canReact={reactions.canReact} | ||
| 498 | subjectId={event.id} | ||
| 499 | environmentId={reactions.environmentId} | ||
| 500 | reference={reactions.reference} | ||
| 501 | onRefresh={reactions.onRefresh} | ||
| 502 | /> | ||
| 503 | ) : null} | ||
| 504 | </div> | ||
| 505 | {/* An approval usually carries no words. When it does they are the review, so they stay | ||
| 506 | visible rather than being folded away with the ordinary conversation. */} | ||
| 507 | {event.body ? ( | ||
| 508 | <TimelineBody body={event.body} markdown={event.markdown} cwd={cwd} /> | ||
| 509 | ) : null} | ||
| 510 | </div> | ||
| 511 | <OpenOnHostButton url={event.url} onOpen={onOpen} /> | ||
| 512 | </div> | ||
| 513 | </div> | ||
| 514 | ); | ||
| 515 | } | ||
| 516 | |||
| 414 | export function PullRequestTimelineTab({ | 517 | export function PullRequestTimelineTab({ |
| 415 | detail, | 518 | detail, |
| 416 | environmentId, | 519 | environmentId, |
| ... | @@ -427,6 +530,7 @@ export function PullRequestTimelineTab({ | ... | @@ -427,6 +530,7 @@ export function PullRequestTimelineTab({ |
| 427 | onRefresh: () => void; | 530 | onRefresh: () => void; |
| 428 | }) { | 531 | }) { |
| 429 | const events = buildPullRequestTimeline(detail); | 532 | const events = buildPullRequestTimeline(detail); |
| 533 | const newestCommitAt = newestPullRequestCommitAt(detail.commits); | ||
| 430 | const reactions: ReactionSurface = { | 534 | const reactions: ReactionSurface = { |
| 431 | canReact: detail.capabilities.reactions === true, | 535 | canReact: detail.capabilities.reactions === true, |
| 432 | environmentId, | 536 | environmentId, |
| ... | @@ -468,6 +572,20 @@ export function PullRequestTimelineTab({ | ... | @@ -468,6 +572,20 @@ export function PullRequestTimelineTab({ |
| 468 | if (event.kind === "commit") { | 572 | if (event.kind === "commit") { |
| 469 | return <CommitEvent key={event.id} event={event} onOpen={onOpenCommit} />; | 573 | return <CommitEvent key={event.id} event={event} onOpen={onOpenCommit} />; |
| 470 | } | 574 | } |
| 575 | const outcome = pullRequestReviewOutcome(event.reviewState); | ||
| 576 | if (outcome !== null) { | ||
| 577 | return ( | ||
| 578 | <ReviewVerdictEvent | ||
| 579 | key={event.id} | ||
| 580 | event={event} | ||
| 581 | outcome={outcome} | ||
| 582 | stale={isPullRequestVerdictStale(event.at, newestCommitAt)} | ||
| 583 | cwd={detail.workspaceRoot} | ||
| 584 | onOpen={openOnHost} | ||
| 585 | reactions={reactions} | ||
| 586 | /> | ||
| 587 | ); | ||
| 588 | } | ||
| 471 | return <LifecycleEvent key={event.id} event={event} />; | 589 | return <LifecycleEvent key={event.id} event={event} />; |
| 472 | })} | 590 | })} |
| 473 | </div> | 591 | </div> |
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+210| ... | @@ -16,7 +16,10 @@ import { | ... | @@ -16,7 +16,10 @@ import { |
| 16 | groupPullRequestTimelineConversations, | 16 | groupPullRequestTimelineConversations, |
| 17 | handoffPrompt, | 17 | handoffPrompt, |
| 18 | handoffReviewComments, | 18 | handoffReviewComments, |
| 19 | isPullRequestVerdictStale, | ||
| 19 | isThreadOwnPullRequest, | 20 | isThreadOwnPullRequest, |
| 21 | latestPullRequestReviewOutcomes, | ||
| 22 | newestPullRequestCommitAt, | ||
| 20 | mergePullRequestThreadComments, | 23 | mergePullRequestThreadComments, |
| 21 | orderPullRequestComments, | 24 | orderPullRequestComments, |
| 22 | pullRequestActionMenuHasGroup, | 25 | pullRequestActionMenuHasGroup, |
| ... | @@ -24,6 +27,7 @@ import { | ... | @@ -24,6 +27,7 @@ import { |
| 24 | pullRequestComposerTarget, | 27 | pullRequestComposerTarget, |
| 25 | pullRequestFindingKey, | 28 | pullRequestFindingKey, |
| 26 | pullRequestHandoffLabels, | 29 | pullRequestHandoffLabels, |
| 30 | pullRequestReviewOutcome, | ||
| 27 | readableFailure, | 31 | readableFailure, |
| 28 | shouldRefreshPullRequestActivity, | 32 | shouldRefreshPullRequestActivity, |
| 29 | resolveBaseFreshness, | 33 | resolveBaseFreshness, |
| ... | @@ -177,6 +181,171 @@ describe("ordering comments", () => { | ... | @@ -177,6 +181,171 @@ describe("ordering comments", () => { |
| 177 | }); | 181 | }); |
| 178 | }); | 182 | }); |
| 179 | 183 | ||
| 184 | describe("review verdicts", () => { | ||
| 185 | it("reads the same three verdicts however a host spells them", () => { | ||
| 186 | expect(pullRequestReviewOutcome("APPROVED")).toBe("approved"); | ||
| 187 | expect(pullRequestReviewOutcome("approved")).toBe("approved"); | ||
| 188 | expect(pullRequestReviewOutcome("CHANGES_REQUESTED")).toBe("changes-requested"); | ||
| 189 | expect(pullRequestReviewOutcome("changes_requested")).toBe("changes-requested"); | ||
| 190 | expect(pullRequestReviewOutcome("DISMISSED")).toBe("dismissed"); | ||
| 191 | }); | ||
| 192 | |||
| 193 | it("is not a verdict where the review only carried remarks", () => { | ||
| 194 | expect(pullRequestReviewOutcome("COMMENTED")).toBeNull(); | ||
| 195 | expect(pullRequestReviewOutcome("PENDING")).toBeNull(); | ||
| 196 | expect(pullRequestReviewOutcome(null)).toBeNull(); | ||
| 197 | }); | ||
| 198 | |||
| 199 | it("keeps each reviewer's last word, whatever order the host returned them in", () => { | ||
| 200 | const review = ( | ||
| 201 | id: string, | ||
| 202 | login: string, | ||
| 203 | reviewState: string, | ||
| 204 | createdAt: string, | ||
| 205 | ): PullRequestComment => ({ | ||
| 206 | id, | ||
| 207 | kind: "review", | ||
| 208 | author: { login, name: null, avatarUrl: null }, | ||
| 209 | body: "", | ||
| 210 | createdAt, | ||
| 211 | url: null, | ||
| 212 | path: null, | ||
| 213 | reviewState, | ||
| 214 | }); | ||
| 215 | |||
| 216 | expect( | ||
| 217 | latestPullRequestReviewOutcomes([ | ||
| 218 | review("r3", "bilal", "APPROVED", "2026-07-03T00:00:00Z"), | ||
| 219 | review("r1", "bilal", "CHANGES_REQUESTED", "2026-07-01T00:00:00Z"), | ||
| 220 | review("r2", "octocat", "CHANGES_REQUESTED", "2026-07-02T00:00:00Z"), | ||
| 221 | // Not a verdict, so it neither adds a reviewer nor overwrites one. | ||
| 222 | review("r4", "octocat", "COMMENTED", "2026-07-04T00:00:00Z"), | ||
| 223 | ]).map((entry) => [entry.actor?.login, entry.outcome]), | ||
| 224 | ).toEqual([ | ||
| 225 | ["bilal", "approved"], | ||
| 226 | ["octocat", "changes-requested"], | ||
| 227 | ]); | ||
| 228 | }); | ||
| 229 | |||
| 230 | it("keeps two deleted accounts apart rather than counting them as one reviewer", () => { | ||
| 231 | expect( | ||
| 232 | latestPullRequestReviewOutcomes([ | ||
| 233 | { | ||
| 234 | ...TIMELINE_SOURCE.comments[0]!, | ||
| 235 | id: "r1", | ||
| 236 | kind: "review", | ||
| 237 | author: null, | ||
| 238 | reviewState: "APPROVED", | ||
| 239 | createdAt: "2026-07-01T00:00:00Z", | ||
| 240 | }, | ||
| 241 | { | ||
| 242 | ...TIMELINE_SOURCE.comments[0]!, | ||
| 243 | id: "r2", | ||
| 244 | kind: "review", | ||
| 245 | author: null, | ||
| 246 | reviewState: "APPROVED", | ||
| 247 | createdAt: "2026-07-02T00:00:00Z", | ||
| 248 | }, | ||
| 249 | ]), | ||
| 250 | ).toHaveLength(2); | ||
| 251 | }); | ||
| 252 | |||
| 253 | it("gives every entry a key that separates the reviewers it kept apart", () => { | ||
| 254 | const entries = latestPullRequestReviewOutcomes([ | ||
| 255 | { | ||
| 256 | ...TIMELINE_SOURCE.comments[0]!, | ||
| 257 | id: "r1", | ||
| 258 | kind: "review", | ||
| 259 | author: null, | ||
| 260 | reviewState: "APPROVED", | ||
| 261 | createdAt: "2026-07-01T00:00:00Z", | ||
| 262 | }, | ||
| 263 | { | ||
| 264 | ...TIMELINE_SOURCE.comments[0]!, | ||
| 265 | id: "r2", | ||
| 266 | kind: "review", | ||
| 267 | author: null, | ||
| 268 | reviewState: "APPROVED", | ||
| 269 | createdAt: "2026-07-01T00:00:00Z", | ||
| 270 | }, | ||
| 271 | ]); | ||
| 272 | // Same author (none) and the same instant, so only the review's own id tells them apart. | ||
| 273 | expect(new Set(entries.map((entry) => entry.key)).size).toBe(2); | ||
| 274 | }); | ||
| 275 | |||
| 276 | it("calls a verdict stale once commits land after it, and current before that", () => { | ||
| 277 | const commits = [ | ||
| 278 | { oid: "c0ffee", messageHeadline: "later work", committedDate: "2026-07-05T00:00:00Z" }, | ||
| 279 | ]; | ||
| 280 | const review = (createdAt: string): PullRequestComment => ({ | ||
| 281 | ...TIMELINE_SOURCE.comments[0]!, | ||
| 282 | kind: "review", | ||
| 283 | reviewState: "APPROVED", | ||
| 284 | createdAt, | ||
| 285 | }); | ||
| 286 | |||
| 287 | expect( | ||
| 288 | latestPullRequestReviewOutcomes([review("2026-07-01T00:00:00Z")], commits)[0]?.stale, | ||
| 289 | ).toBe(true); | ||
| 290 | expect( | ||
| 291 | latestPullRequestReviewOutcomes([review("2026-07-06T00:00:00Z")], commits)[0]?.stale, | ||
| 292 | ).toBe(false); | ||
| 293 | // Nothing to be overtaken by, so nothing is stale. | ||
| 294 | expect(latestPullRequestReviewOutcomes([review("2026-07-01T00:00:00Z")], [])[0]?.stale).toBe( | ||
| 295 | false, | ||
| 296 | ); | ||
| 297 | }); | ||
| 298 | |||
| 299 | it("measures staleness against the newest commit, not the last one listed", () => { | ||
| 300 | expect( | ||
| 301 | newestPullRequestCommitAt([ | ||
| 302 | { oid: "a", messageHeadline: "", committedDate: "2026-07-09T00:00:00Z" }, | ||
| 303 | { oid: "b", messageHeadline: "", committedDate: "2026-07-02T00:00:00Z" }, | ||
| 304 | ]), | ||
| 305 | ).toBe("2026-07-09T00:00:00Z"); | ||
| 306 | expect(newestPullRequestCommitAt([])).toBeNull(); | ||
| 307 | }); | ||
| 308 | |||
| 309 | it("orders instants rather than their text, so a UTC offset cannot invert them", () => { | ||
| 310 | // 01:00+02:00 is 23:00 the previous day, so as text it sorts after the Z stamp and in time | ||
| 311 | // it falls well before it. | ||
| 312 | expect( | ||
| 313 | newestPullRequestCommitAt([ | ||
| 314 | { oid: "a", messageHeadline: "", committedDate: "2026-07-05T00:30:00Z" }, | ||
| 315 | { oid: "b", messageHeadline: "", committedDate: "2026-07-05T01:00:00+02:00" }, | ||
| 316 | ]), | ||
| 317 | ).toBe("2026-07-05T00:30:00Z"); | ||
| 318 | expect(isPullRequestVerdictStale("2026-07-05T00:30:00Z", "2026-07-05T01:00:00+02:00")).toBe( | ||
| 319 | false, | ||
| 320 | ); | ||
| 321 | // A timestamp nothing can parse is not a position, so it settles nothing either way. | ||
| 322 | expect(isPullRequestVerdictStale("2026-07-01T00:00:00Z", "not a date")).toBe(false); | ||
| 323 | expect( | ||
| 324 | newestPullRequestCommitAt([{ oid: "a", messageHeadline: "", committedDate: "not a date" }]), | ||
| 325 | ).toBeNull(); | ||
| 326 | }); | ||
| 327 | |||
| 328 | it("shows nothing for a reviewer whose verdict was dismissed", () => { | ||
| 329 | expect( | ||
| 330 | latestPullRequestReviewOutcomes([ | ||
| 331 | { | ||
| 332 | ...TIMELINE_SOURCE.comments[0]!, | ||
| 333 | kind: "review", | ||
| 334 | reviewState: "APPROVED", | ||
| 335 | createdAt: "2026-07-01T00:00:00Z", | ||
| 336 | }, | ||
| 337 | { | ||
| 338 | ...TIMELINE_SOURCE.comments[0]!, | ||
| 339 | id: "c2", | ||
| 340 | kind: "review", | ||
| 341 | reviewState: "DISMISSED", | ||
| 342 | createdAt: "2026-07-02T00:00:00Z", | ||
| 343 | }, | ||
| 344 | ]), | ||
| 345 | ).toEqual([]); | ||
| 346 | }); | ||
| 347 | }); | ||
| 348 | |||
| 180 | describe("pull request timeline", () => { | 349 | describe("pull request timeline", () => { |
| 181 | it("orders creation, commits and comments newest first", () => { | 350 | it("orders creation, commits and comments newest first", () => { |
| 182 | // What happened last is what the reader opening the tab is asking about. | 351 | // What happened last is what the reader opening the tab is asking about. |
| ... | @@ -321,6 +490,47 @@ describe("pull request timeline", () => { | ... | @@ -321,6 +490,47 @@ describe("pull request timeline", () => { |
| 321 | ["event", "created"], | 490 | ["event", "created"], |
| 322 | ]); | 491 | ]); |
| 323 | }); | 492 | }); |
| 493 | |||
| 494 | it("keeps a verdict out of the collapsed conversation it was submitted in", () => { | ||
| 495 | const events = buildPullRequestTimeline({ | ||
| 496 | ...TIMELINE_SOURCE, | ||
| 497 | comments: [ | ||
| 498 | { ...TIMELINE_SOURCE.comments[0]!, id: "chatter-1", createdAt: "2026-07-05T00:00:00Z" }, | ||
| 499 | { | ||
| 500 | ...TIMELINE_SOURCE.comments[0]!, | ||
| 501 | id: "approval", | ||
| 502 | kind: "review", | ||
| 503 | body: "", | ||
| 504 | reviewState: "APPROVED", | ||
| 505 | createdAt: "2026-07-04T00:00:00Z", | ||
| 506 | }, | ||
| 507 | { ...TIMELINE_SOURCE.comments[0]!, id: "chatter-2", createdAt: "2026-07-03T00:00:00Z" }, | ||
| 508 | // A review without a verdict is ordinary conversation and still groups. | ||
| 509 | { | ||
| 510 | ...TIMELINE_SOURCE.comments[0]!, | ||
| 511 | id: "remark", | ||
| 512 | kind: "review", | ||
| 513 | reviewState: "COMMENTED", | ||
| 514 | createdAt: "2026-07-02T12:00:00Z", | ||
| 515 | }, | ||
| 516 | ], | ||
| 517 | }); | ||
| 518 | |||
| 519 | const rows = groupPullRequestTimelineConversations(events); | ||
| 520 | expect( | ||
| 521 | rows.map((row) => | ||
| 522 | row.kind === "comments" | ||
| 523 | ? [row.kind, ...row.events.map((event) => event.id)] | ||
| 524 | : [row.kind, row.event.id], | ||
| 525 | ), | ||
| 526 | ).toEqual([ | ||
| 527 | ["comments", "chatter-1"], | ||
| 528 | ["event", "approval"], | ||
| 529 | ["comments", "chatter-2", "remark"], | ||
| 530 | ["event", "1baf7bdcafe"], | ||
| 531 | ["event", "created"], | ||
| 532 | ]); | ||
| 533 | }); | ||
| 324 | }); | 534 | }); |
| 325 | 535 | ||
| 326 | describe("fix findings handoff", () => { | 536 | describe("fix findings handoff", () => { |
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+127-2| ... | @@ -4,6 +4,7 @@ import type { | ... | @@ -4,6 +4,7 @@ import type { |
| 4 | PullRequestBaseComparison, | 4 | PullRequestBaseComparison, |
| 5 | PullRequestCheck, | 5 | PullRequestCheck, |
| 6 | PullRequestComment, | 6 | PullRequestComment, |
| 7 | PullRequestCommit, | ||
| 7 | PullRequestDetailView, | 8 | PullRequestDetailView, |
| 8 | PullRequestMergeability, | 9 | PullRequestMergeability, |
| 9 | PullRequestReaction, | 10 | PullRequestReaction, |
| ... | @@ -117,6 +118,123 @@ export function orderPullRequestComments<T extends { readonly createdAt: string | ... | @@ -117,6 +118,123 @@ export function orderPullRequestComments<T extends { readonly createdAt: string |
| 117 | return order === "newest" ? comments.toReversed() : comments; | 118 | return order === "newest" ? comments.toReversed() : comments; |
| 118 | } | 119 | } |
| 119 | 120 | ||
| 121 | /** A review that says something about the change itself, rather than only carrying remarks. */ | ||
| 122 | export type PullRequestReviewOutcome = "approved" | "changes-requested" | "dismissed"; | ||
| 123 | |||
| 124 | /** | ||
| 125 | * Which review states are a verdict. Hosts spell the same three differently — GitHub reports | ||
| 126 | * `CHANGES_REQUESTED`, Bitbucket `changes_requested` — so case and separator are ignored, and | ||
| 127 | * anything else (GitHub's `COMMENTED`, a state no host here reports yet) is not a verdict. | ||
| 128 | */ | ||
| 129 | export function pullRequestReviewOutcome( | ||
| 130 | reviewState: string | null, | ||
| 131 | ): PullRequestReviewOutcome | null { | ||
| 132 | switch (reviewState?.trim().toLowerCase().replaceAll("_", "-")) { | ||
| 133 | case "approved": | ||
| 134 | return "approved"; | ||
| 135 | case "changes-requested": | ||
| 136 | return "changes-requested"; | ||
| 137 | case "dismissed": | ||
| 138 | return "dismissed"; | ||
| 139 | default: | ||
| 140 | return null; | ||
| 141 | } | ||
| 142 | } | ||
| 143 | |||
| 144 | /** | ||
| 145 | * An instant as a number, because the text is not the order. Every host returns ISO-8601 but not | ||
| 146 | * all of them in UTC, and `2026-07-05T01:00:00+02:00` sorts after `2026-07-05T00:30:00Z` as text | ||
| 147 | * while falling an hour and a half before it in time. NaN for anything unparseable, which every | ||
| 148 | * caller treats as "cannot say" rather than as a position. | ||
| 149 | */ | ||
| 150 | function instant(iso: string): number { | ||
| 151 | return Date.parse(iso); | ||
| 152 | } | ||
| 153 | |||
| 154 | /** | ||
| 155 | * The newest commit on the branch, which is what a verdict is current against. Null where the | ||
| 156 | * host reported no commits — or none with a timestamp that parses — since nothing can then be | ||
| 157 | * said to predate them. | ||
| 158 | */ | ||
| 159 | export function newestPullRequestCommitAt( | ||
| 160 | commits: ReadonlyArray<PullRequestCommit>, | ||
| 161 | ): string | null { | ||
| 162 | let newest: string | null = null; | ||
| 163 | let newestAt = Number.NEGATIVE_INFINITY; | ||
| 164 | for (const commit of commits) { | ||
| 165 | const at = instant(commit.committedDate); | ||
| 166 | if (Number.isNaN(at) || at <= newestAt) continue; | ||
| 167 | newest = commit.committedDate; | ||
| 168 | newestAt = at; | ||
| 169 | } | ||
| 170 | return newest; | ||
| 171 | } | ||
| 172 | |||
| 173 | /** | ||
| 174 | * Whether a verdict was given before the code it was given on. | ||
| 175 | * | ||
| 176 | * Measured against commit dates, which is the only thing the detail carries. That is a proxy and | ||
| 177 | * not the question: a commit date says when the work was written, not when it reached this change | ||
| 178 | * request, so pushing a branch of older commits after an approval leaves the approval reading as | ||
| 179 | * current, and a rebase re-dates commits a verdict already covered. Answering it exactly needs | ||
| 180 | * the host's own review-to-commit link — GitHub hangs a commit off every review — which no | ||
| 181 | * adapter reads yet. Until one does, this errs towards leaving a verdict alone: it dims only | ||
| 182 | * where the branch plainly moved on. | ||
| 183 | */ | ||
| 184 | export function isPullRequestVerdictStale(at: string, newestCommitAt: string | null): boolean { | ||
| 185 | if (newestCommitAt === null) return false; | ||
| 186 | const verdictAt = instant(at); | ||
| 187 | const commitAt = instant(newestCommitAt); | ||
| 188 | return !Number.isNaN(verdictAt) && !Number.isNaN(commitAt) && verdictAt < commitAt; | ||
| 189 | } | ||
| 190 | |||
| 191 | export interface PullRequestReviewOutcomeEntry { | ||
| 192 | /** | ||
| 193 | * What made this entry its own reviewer. A login where the host reported one, and otherwise the | ||
| 194 | * review's own id — so a surface listing these has a key that separates the same two authorless | ||
| 195 | * verdicts this does, rather than collapsing them back into one row. | ||
| 196 | */ | ||
| 197 | readonly key: string; | ||
| 198 | readonly actor: PullRequestActor | null; | ||
| 199 | readonly outcome: PullRequestReviewOutcome; | ||
| 200 | readonly at: string; | ||
| 201 | /** Commits landed after this verdict, so it speaks for code that is no longer on the branch. */ | ||
| 202 | readonly stale: boolean; | ||
| 203 | } | ||
| 204 | |||
| 205 | /** | ||
| 206 | * Where each reviewer landed, which is what "is this approved?" actually asks. One entry per | ||
| 207 | * person and only their last word: a host keeps every review somebody ever submitted, and an | ||
| 208 | * approval later followed by a request for changes is not an approval any more. A dismissal is a | ||
| 209 | * verdict taken back, so it leaves nothing to show rather than showing itself. | ||
| 210 | */ | ||
| 211 | export function latestPullRequestReviewOutcomes( | ||
| 212 | comments: ReadonlyArray<PullRequestComment>, | ||
| 213 | /** Left empty by a caller with no commits to hand, which makes no verdict stale. */ | ||
| 214 | commits: ReadonlyArray<PullRequestCommit> = [], | ||
| 215 | ): ReadonlyArray<PullRequestReviewOutcomeEntry> { | ||
| 216 | const newestCommitAt = newestPullRequestCommitAt(commits); | ||
| 217 | const latest = new Map<string, PullRequestReviewOutcomeEntry>(); | ||
| 218 | for (const comment of comments) { | ||
| 219 | const outcome = pullRequestReviewOutcome(comment.reviewState); | ||
| 220 | if (outcome === null) continue; | ||
| 221 | // Two deleted accounts are two reviewers. Keying both as "ghost" would let one overwrite the | ||
| 222 | // other and undercount the verdicts, so a review with no author identity stands alone. | ||
| 223 | const login = comment.author?.login ?? `ghost:${comment.id}`; | ||
| 224 | const current = latest.get(login); | ||
| 225 | // Not every host returns its reviews in order, so the newest wins rather than the last read. | ||
| 226 | if (current !== undefined && instant(current.at) > instant(comment.createdAt)) continue; | ||
| 227 | latest.set(login, { | ||
| 228 | key: login, | ||
| 229 | actor: comment.author, | ||
| 230 | outcome, | ||
| 231 | at: comment.createdAt, | ||
| 232 | stale: isPullRequestVerdictStale(comment.createdAt, newestCommitAt), | ||
| 233 | }); | ||
| 234 | } | ||
| 235 | return [...latest.values()].filter((entry) => entry.outcome !== "dismissed"); | ||
| 236 | } | ||
| 237 | |||
| 120 | export interface PullRequestTimelineEvent { | 238 | export interface PullRequestTimelineEvent { |
| 121 | readonly id: string; | 239 | readonly id: string; |
| 122 | readonly at: string; | 240 | readonly at: string; |
| ... | @@ -146,13 +264,20 @@ export type PullRequestTimelineRow = | ... | @@ -146,13 +264,20 @@ export type PullRequestTimelineRow = |
| 146 | * Consecutive comments are one conversation section. Commits and pull-request lifecycle updates | 264 | * Consecutive comments are one conversation section. Commits and pull-request lifecycle updates |
| 147 | * stay first-class rows and split those sections, so expanding a conversation never hides the | 265 | * stay first-class rows and split those sections, so expanding a conversation never hides the |
| 148 | * work that happened between two review rounds. | 266 | * work that happened between two review rounds. |
| 267 | * | ||
| 268 | * A verdict is a first-class row too. Whether the change was approved is the question a reader | ||
| 269 | * opens the timeline with, and folding the answer into a collapsed "9 comments" section hides it | ||
| 270 | * behind a press — the one thing on the page that must be readable without one. | ||
| 149 | */ | 271 | */ |
| 150 | export function groupPullRequestTimelineConversations( | 272 | export function groupPullRequestTimelineConversations( |
| 151 | events: ReadonlyArray<PullRequestTimelineEvent>, | 273 | events: ReadonlyArray<PullRequestTimelineEvent>, |
| 152 | ): ReadonlyArray<PullRequestTimelineRow> { | 274 | ): ReadonlyArray<PullRequestTimelineRow> { |
| 153 | const rows: PullRequestTimelineRow[] = []; | 275 | const rows: PullRequestTimelineRow[] = []; |
| 154 | for (const event of events) { | 276 | for (const event of events) { |
| 155 | if (event.kind === "comment" || event.kind === "review") { | 277 | if ( |
| 278 | (event.kind === "comment" || event.kind === "review") && | ||
| 279 | pullRequestReviewOutcome(event.reviewState) === null | ||
| 280 | ) { | ||
| 156 | const last = rows.at(-1); | 281 | const last = rows.at(-1); |
| 157 | if (last?.kind === "comments") { | 282 | if (last?.kind === "comments") { |
| 158 | rows[rows.length - 1] = { kind: "comments", events: [...last.events, event] }; | 283 | rows[rows.length - 1] = { kind: "comments", events: [...last.events, event] }; |
| ... | @@ -172,7 +297,7 @@ export function groupPullRequestTimelineConversations( | ... | @@ -172,7 +297,7 @@ export function groupPullRequestTimelineConversations( |
| 172 | * at all. The stripped text decides that and nothing else: the body itself is passed on whole, | 297 | * at all. The stripped text decides that and nothing else: the body itself is passed on whole, |
| 173 | * because a comment demonstrating an HTML comment inside a code fence still has to show it. | 298 | * because a comment demonstrating an HTML comment inside a code fence still has to show it. |
| 174 | */ | 299 | */ |
| 175 | function visibleBody(body: string): string | null { | 300 | export function visibleBody(body: string): string | null { |
| 176 | return body.replace(/<!--[\s\S]*?-->/gu, "").trim().length === 0 ? null : body.trim(); | 301 | return body.replace(/<!--[\s\S]*?-->/gu, "").trim().length === 0 ? null : body.trim(); |
| 177 | } | 302 | } |
| 178 | 303 |
apps/web/src/components/pullRequest/pullRequestPresentation.tsx+113| ... | @@ -22,7 +22,9 @@ import { Children, isValidElement, type ReactNode } from "react"; | ... | @@ -22,7 +22,9 @@ import { Children, isValidElement, type ReactNode } from "react"; |
| 22 | 22 | ||
| 23 | import { cn } from "~/lib/utils"; | 23 | import { cn } from "~/lib/utils"; |
| 24 | 24 | ||
| 25 | import { Badge } from "../ui/badge"; | ||
| 25 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 26 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 27 | import type { PullRequestReviewOutcome } from "./pullRequestDetail.logic"; | ||
| 26 | 28 | ||
| 27 | interface StatePresentation { | 29 | interface StatePresentation { |
| 28 | readonly label: string; | 30 | readonly label: string; |
| ... | @@ -191,6 +193,117 @@ export function pullRequestChecksState( | ... | @@ -191,6 +193,117 @@ export function pullRequestChecksState( |
| 191 | return statuses.includes("success") ? "passing" : null; | 193 | return statuses.includes("success") ? "passing" : null; |
| 192 | } | 194 | } |
| 193 | 195 | ||
| 196 | /** | ||
| 197 | * How a verdict reads, in the one place every surface takes it from. The green is the green a | ||
| 198 | * passing check already wears in the same panel, so "approved" and "all checks passed" cannot | ||
| 199 | * look like two different kinds of good news. | ||
| 200 | * | ||
| 201 | * The ring runs a shade stronger than the text tones. At 16px across it is a thin arc, and the | ||
| 202 | * muted pairing that reads well as a word was barely there as an outline. | ||
| 203 | */ | ||
| 204 | const REVIEW_OUTCOME_PRESENTATION = { | ||
| 205 | approved: { | ||
| 206 | label: "Approved", | ||
| 207 | Icon: CircleCheckIcon, | ||
| 208 | toneClassName: "text-emerald-600 dark:text-emerald-300/90", | ||
| 209 | ringClassName: "ring-2 ring-emerald-500 dark:ring-emerald-400", | ||
| 210 | staleRingClassName: | ||
| 211 | "ring-2 ring-[color-mix(in_srgb,var(--color-emerald-500)_35%,var(--background))] dark:ring-[color-mix(in_srgb,var(--color-emerald-400)_35%,var(--background))]", | ||
| 212 | badgeVariant: "success", | ||
| 213 | }, | ||
| 214 | "changes-requested": { | ||
| 215 | label: "Changes requested", | ||
| 216 | Icon: CircleXIcon, | ||
| 217 | toneClassName: "text-destructive", | ||
| 218 | ringClassName: "ring-2 ring-destructive", | ||
| 219 | staleRingClassName: "ring-2 ring-[color-mix(in_srgb,var(--destructive)_35%,var(--background))]", | ||
| 220 | badgeVariant: "error", | ||
| 221 | }, | ||
| 222 | dismissed: { | ||
| 223 | label: "Review dismissed", | ||
| 224 | Icon: CircleDashedIcon, | ||
| 225 | toneClassName: "text-muted-foreground/70", | ||
| 226 | ringClassName: "ring-2 ring-muted-foreground/60", | ||
| 227 | staleRingClassName: | ||
| 228 | "ring-2 ring-[color-mix(in_srgb,var(--muted-foreground)_30%,var(--background))]", | ||
| 229 | badgeVariant: "outline", | ||
| 230 | }, | ||
| 231 | } as const satisfies Record< | ||
| 232 | PullRequestReviewOutcome, | ||
| 233 | { | ||
| 234 | label: string; | ||
| 235 | Icon: typeof CircleCheckIcon; | ||
| 236 | toneClassName: string; | ||
| 237 | ringClassName: string; | ||
| 238 | staleRingClassName: string; | ||
| 239 | badgeVariant: "success" | "error" | "outline"; | ||
| 240 | } | ||
| 241 | >; | ||
| 242 | |||
| 243 | export function pullRequestReviewOutcomeToneClassName(outcome: PullRequestReviewOutcome): string { | ||
| 244 | return REVIEW_OUTCOME_PRESENTATION[outcome].toneClassName; | ||
| 245 | } | ||
| 246 | |||
| 247 | /** Worn by whatever wraps a reviewer's avatar, so their verdict reads without a row of its own. */ | ||
| 248 | /** | ||
| 249 | * A faded verdict is mixed into the background rather than made translucent. The ring is the only | ||
| 250 | * separator an avatar carrying one has — the summary drops the opaque `ring-background` where a | ||
| 251 | * verdict is drawn — and the stack overlaps by 4px, so an alpha ring would let the neighbour show | ||
| 252 | * straight through it and the two faces would merge. | ||
| 253 | */ | ||
| 254 | export function pullRequestReviewOutcomeRingClassName( | ||
| 255 | outcome: PullRequestReviewOutcome, | ||
| 256 | stale = false, | ||
| 257 | ): string { | ||
| 258 | const presentation = REVIEW_OUTCOME_PRESENTATION[outcome]; | ||
| 259 | return stale ? presentation.staleRingClassName : presentation.ringClassName; | ||
| 260 | } | ||
| 261 | |||
| 262 | /** | ||
| 263 | * What a superseded verdict says, which is the same word with when it applied added. Commits | ||
| 264 | * landed after it, so it stands for code the branch no longer has. | ||
| 265 | */ | ||
| 266 | export function pullRequestReviewOutcomeStaleLabel(outcome: PullRequestReviewOutcome): string { | ||
| 267 | return `${REVIEW_OUTCOME_PRESENTATION[outcome].label} earlier changes`; | ||
| 268 | } | ||
| 269 | |||
| 270 | /** Decorative: every caller says which verdict this is in words beside it. */ | ||
| 271 | export function PullRequestReviewOutcomeIcon({ | ||
| 272 | outcome, | ||
| 273 | className, | ||
| 274 | }: { | ||
| 275 | outcome: PullRequestReviewOutcome; | ||
| 276 | className?: string; | ||
| 277 | }) { | ||
| 278 | const presentation = REVIEW_OUTCOME_PRESENTATION[outcome]; | ||
| 279 | return ( | ||
| 280 | <presentation.Icon | ||
| 281 | aria-hidden | ||
| 282 | className={cn("size-3.5 shrink-0", presentation.toneClassName, className)} | ||
| 283 | /> | ||
| 284 | ); | ||
| 285 | } | ||
| 286 | |||
| 287 | export function pullRequestReviewOutcomeLabel(outcome: PullRequestReviewOutcome): string { | ||
| 288 | return REVIEW_OUTCOME_PRESENTATION[outcome].label; | ||
| 289 | } | ||
| 290 | |||
| 291 | export function PullRequestReviewOutcomeBadge({ | ||
| 292 | outcome, | ||
| 293 | className, | ||
| 294 | }: { | ||
| 295 | outcome: PullRequestReviewOutcome; | ||
| 296 | className?: string; | ||
| 297 | }) { | ||
| 298 | const presentation = REVIEW_OUTCOME_PRESENTATION[outcome]; | ||
| 299 | return ( | ||
| 300 | <Badge size="sm" variant={presentation.badgeVariant} className={cn("gap-1", className)}> | ||
| 301 | <presentation.Icon aria-hidden className="size-3" /> | ||
| 302 | {presentation.label} | ||
| 303 | </Badge> | ||
| 304 | ); | ||
| 305 | } | ||
| 306 | |||
| 194 | export function PullRequestActorAvatar({ | 307 | export function PullRequestActorAvatar({ |
| 195 | actor, | 308 | actor, |
| 196 | className, | 309 | className, |