| author | |
| committer | |
| log | c12ba7581ac00ba2a623ad23283da5d03d34c2d8 |
| tree | aad3421300c8acdd2fe3859d0877c1aa19d87298 |
| parent | c1738f131d6f0d5c6dfabb3aad46755a25319663 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>13 files changed, 659 insertions(+), 254 deletions(-)
apps/server/src/pullRequest/GitHubPullRequestCli.ts+3| ... | ... | @@ -2071,6 +2071,7 @@ export const make = Effect.gen(function* () { |
| 2071 | 2071 | }); |
| 2072 | 2072 | const entries: GitHubReviewThreadEntry[] = []; |
| 2073 | 2073 | const avatarsByLogin = new Map<string, string>(); |
| 2074 | const botLogins = new Set<string>(); | |
| 2074 | 2075 | const commitStats = new Map< |
| 2075 | 2076 | string, |
| 2076 | 2077 | { readonly additions: number; readonly deletions: number } |
| ... | ... | @@ -2087,6 +2088,7 @@ export const make = Effect.gen(function* () { |
| 2087 | 2088 | do { |
| 2088 | 2089 | const read: GitHubReviewThreadPage = yield* threadPage(cursor); |
| 2089 | 2090 | entries.push(...read.threads); |
| 2091 | for (const login of read.botLogins) botLogins.add(login); | |
| 2090 | 2092 | for (const [login, avatarUrl] of read.avatarsByLogin) |
| 2091 | 2093 | avatarsByLogin.set(login, avatarUrl); |
| 2092 | 2094 | // The roster, the commits and the viewer's standing travel with every page, and the |
| ... | ... | @@ -2152,6 +2154,7 @@ export const make = Effect.gen(function* () { |
| 2152 | 2154 | reactionsById, |
| 2153 | 2155 | reviewers, |
| 2154 | 2156 | avatarsByLogin, |
| 2157 | botLogins, | |
| 2155 | 2158 | commitStats, |
| 2156 | 2159 | commits, |
| 2157 | 2160 | viewer, |
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+4-1| ... | ... | @@ -741,6 +741,7 @@ describe("getChangeRequest commits", () => { |
| 741 | 741 | reactionsById: new Map<string, ReadonlyArray<PullRequestReaction>>(), |
| 742 | 742 | reviewers: [], |
| 743 | 743 | avatarsByLogin: new Map<string, string>(), |
| 744 | botLogins: new Set<string>(), | |
| 744 | 745 | commitStats: new Map<string, { readonly additions: number; readonly deletions: number }>(), |
| 745 | 746 | viewer: { canUpdate: true, didAuthor: false }, |
| 746 | 747 | }; |
| ... | ... | @@ -807,7 +808,7 @@ describe("getChangeRequestActivity dismissed reviews", () => { |
| 807 | 808 | const dismissedReview = (body: string) => ({ |
| 808 | 809 | id: "PRR_1", |
| 809 | 810 | kind: "review" as const, |
| 810 | author: null, | |
| 811 | author: { login: "macroscopeapp", name: null, avatarUrl: null }, | |
| 811 | 812 | body, |
| 812 | 813 | createdAt: "2026-07-03T00:00:00Z", |
| 813 | 814 | url: null, |
| ... | ... | @@ -824,6 +825,7 @@ describe("getChangeRequestActivity dismissed reviews", () => { |
| 824 | 825 | reactionsById: new Map(), |
| 825 | 826 | reviewers: [], |
| 826 | 827 | avatarsByLogin: new Map(), |
| 828 | botLogins: new Set(["macroscopeapp"]), | |
| 827 | 829 | commitStats: new Map(), |
| 828 | 830 | commits: [], |
| 829 | 831 | viewer: { canUpdate: true, didAuthor: false }, |
| ... | ... | @@ -850,6 +852,7 @@ describe("getChangeRequestActivity dismissed reviews", () => { |
| 850 | 852 | readActivity.pipe( |
| 851 | 853 | Effect.map((activity) => { |
| 852 | 854 | expect(activity.comments[0]?.body).toBe("Dismissing prior approval to re-evaluate 9b66581"); |
| 855 | expect(activity.comments[0]?.author?.isBot).toBe(true); | |
| 853 | 856 | }), |
| 854 | 857 | Effect.provide(layerFor("<!-- Macroscope (Approvability) review body marker -->")), |
| 855 | 858 | ), |
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+30-5| ... | ... | @@ -133,8 +133,11 @@ function withAvatar( |
| 133 | 133 | actor: PullRequestActor | null, |
| 134 | 134 | avatarsByLogin: ReadonlyMap<string, string>, |
| 135 | 135 | host: string, |
| 136 | botLogins?: ReadonlySet<string>, | |
| 136 | 137 | ): PullRequestActor | null { |
| 137 | if (actor === null || actor.avatarUrl !== null) return actor; | |
| 138 | if (actor === null) return actor; | |
| 139 | if (botLogins?.has(actor.login)) actor = { ...actor, isBot: true }; | |
| 140 | if (actor.avatarUrl !== null) return actor; | |
| 138 | 141 | const avatarUrl = avatarsByLogin.get(actor.login) ?? loginAvatarUrl(actor.login, host); |
| 139 | 142 | return avatarUrl === null ? actor : { ...actor, avatarUrl }; |
| 140 | 143 | } |
| ... | ... | @@ -450,6 +453,7 @@ export const make = Effect.gen(function* () { |
| 450 | 453 | truncated: true, |
| 451 | 454 | reviewers: [], |
| 452 | 455 | avatarsByLogin: new Map<string, string>(), |
| 456 | botLogins: new Set<string>(), | |
| 453 | 457 | commitStats: new Map< |
| 454 | 458 | string, |
| 455 | 459 | { readonly additions: number; readonly deletions: number } |
| ... | ... | @@ -463,7 +467,12 @@ export const make = Effect.gen(function* () { |
| 463 | 467 | ).pipe( |
| 464 | 468 | Effect.mapError(fail("getChangeRequestActivity")), |
| 465 | 469 | Effect.map(([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({ |
| 466 | author: withAvatar(pullRequest.author, reviewThreads.avatarsByLogin, input.host), | |
| 470 | author: withAvatar( | |
| 471 | pullRequest.author, | |
| 472 | reviewThreads.avatarsByLogin, | |
| 473 | input.host, | |
| 474 | reviewThreads.botLogins, | |
| 475 | ), | |
| 467 | 476 | reviewers: reviewThreads.reviewers, |
| 468 | 477 | reactions: reviewThreads.reactions, |
| 469 | 478 | commits: (reviewThreads.commits.length > 0 |
| ... | ... | @@ -473,7 +482,13 @@ export const make = Effect.gen(function* () { |
| 473 | 482 | ...commit, |
| 474 | 483 | ...reviewThreads.commitStats.get(commit.oid), |
| 475 | 484 | authors: commit.authors?.map( |
| 476 | (author) => withAvatar(author, reviewThreads.avatarsByLogin, input.host) ?? author, | |
| 485 | (author) => | |
| 486 | withAvatar( | |
| 487 | author, | |
| 488 | reviewThreads.avatarsByLogin, | |
| 489 | input.host, | |
| 490 | reviewThreads.botLogins, | |
| 491 | ) ?? author, | |
| 477 | 492 | ), |
| 478 | 493 | })), |
| 479 | 494 | comments: [...pullRequest.comments, ...reviewThreads.comments] |
| ... | ... | @@ -489,7 +504,12 @@ export const make = Effect.gen(function* () { |
| 489 | 504 | rendersEmpty(comment.body) |
| 490 | 505 | ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body) |
| 491 | 506 | : comment.body, |
| 492 | author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host), | |
| 507 | author: withAvatar( | |
| 508 | comment.author, | |
| 509 | reviewThreads.avatarsByLogin, | |
| 510 | input.host, | |
| 511 | reviewThreads.botLogins, | |
| 512 | ), | |
| 493 | 513 | // A comment out of `gh pr view --json` carries none of its own: that read |
| 494 | 514 | // reports no reaction at all, so they arrive from the GraphQL page by node id. |
| 495 | 515 | reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [], |
| ... | ... | @@ -503,7 +523,12 @@ export const make = Effect.gen(function* () { |
| 503 | 523 | ...thread, |
| 504 | 524 | comments: thread.comments.map((comment) => ({ |
| 505 | 525 | ...comment, |
| 506 | author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host), | |
| 526 | author: withAvatar( | |
| 527 | comment.author, | |
| 528 | reviewThreads.avatarsByLogin, | |
| 529 | input.host, | |
| 530 | reviewThreads.botLogins, | |
| 531 | ), | |
| 507 | 532 | })), |
| 508 | 533 | })), |
| 509 | 534 | })), |
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+14-2| ... | ... | @@ -436,14 +436,26 @@ describe("review thread decoding", () => { |
| 436 | 436 | requested: [{ login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" }], |
| 437 | 437 | // An app that has reviewed is no longer an outstanding request, which is why asking |
| 438 | 438 | // only for requests reported nobody on a pull request a bot had reviewed. |
| 439 | reviewed: [{ login: "macroscopeapp", avatarUrl: "https://avatars/in/900172.png" }], | |
| 439 | reviewed: [ | |
| 440 | { | |
| 441 | __typename: "Bot", | |
| 442 | login: "macroscopeapp", | |
| 443 | avatarUrl: "https://avatars/in/900172.png", | |
| 444 | }, | |
| 445 | ], | |
| 440 | 446 | }), |
| 441 | 447 | ), |
| 442 | 448 | ); |
| 443 | 449 | |
| 450 | expect(result.botLogins).toEqual(new Set(["macroscopeapp"])); | |
| 444 | 451 | expect(result.reviewers).toEqual([ |
| 445 | 452 | { login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" }, |
| 446 | { login: "macroscopeapp", name: null, avatarUrl: "https://avatars/in/900172.png" }, | |
| 453 | { | |
| 454 | login: "macroscopeapp", | |
| 455 | name: null, | |
| 456 | avatarUrl: "https://avatars/in/900172.png", | |
| 457 | isBot: true, | |
| 458 | }, | |
| 447 | 459 | ]); |
| 448 | 460 | }); |
| 449 | 461 |
apps/server/src/pullRequest/gitHubPullRequestJson.ts+24-10| ... | ... | @@ -39,6 +39,8 @@ import { dedupeChecks } from "./pullRequestChecks.ts"; |
| 39 | 39 | * release that adds a conclusion or a review state must not fail the whole payload. |
| 40 | 40 | */ |
| 41 | 41 | const RawActorSchema = Schema.Struct({ |
| 42 | __typename: Schema.optional(Schema.String), | |
| 43 | is_bot: Schema.optional(Schema.Boolean), | |
| 42 | 44 | /** |
| 43 | 45 | * Optional because a review can be requested from a team or a mannequin, which the query has |
| 44 | 46 | * no fragment for and GraphQL answers with an empty object. A reviewer with no login names |
| ... | ... | @@ -480,7 +482,7 @@ const RawReviewThreadsSchema = Schema.Struct({ |
| 480 | 482 | ), |
| 481 | 483 | ), |
| 482 | 484 | /** |
| 483 | * Reviews for their reactions alone: the words and the verdict arrive with | |
| 485 | * Reviews for their reactions and actor identity: the words and the verdict arrive with | |
| 484 | 486 | * `gh pr view --json reviews`, which reports no reaction of any kind. |
| 485 | 487 | */ |
| 486 | 488 | reviews: Schema.optional( |
| ... | ... | @@ -489,6 +491,7 @@ const RawReviewThreadsSchema = Schema.Struct({ |
| 489 | 491 | nodes: Schema.Array( |
| 490 | 492 | Schema.Struct({ |
| 491 | 493 | id: Schema.optional(Schema.NullOr(Schema.String)), |
| 494 | author: Schema.optional(Schema.NullOr(RawActorSchema)), | |
| 492 | 495 | reactionGroups: RawReactionGroupsSchema, |
| 493 | 496 | }), |
| 494 | 497 | ), |
| ... | ... | @@ -676,7 +679,7 @@ export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = fals |
| 676 | 679 | number |
| 677 | 680 | title |
| 678 | 681 | url |
| 679 | author { login avatarUrl ... on User { name } } | |
| 682 | author { __typename login avatarUrl ... on User { name } } | |
| 680 | 683 | headRefName |
| 681 | 684 | baseRefName |
| 682 | 685 | state |
| ... | ... | @@ -736,28 +739,28 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin |
| 736 | 739 | comments(first: 10) { |
| 737 | 740 | totalCount |
| 738 | 741 | pageInfo { hasNextPage endCursor } |
| 739 | nodes { id author { login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} } | |
| 742 | nodes { id author { __typename login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} } | |
| 740 | 743 | } |
| 741 | 744 | } |
| 742 | 745 | } |
| 743 | 746 | viewerCanUpdate |
| 744 | 747 | viewerDidAuthor |
| 745 | author { login avatarUrl } | |
| 748 | author { __typename login avatarUrl } | |
| 746 | 749 | ${REACTION_GROUPS_FIELDS} |
| 747 | 750 | comments(first: ${GRAPHQL_PAGE_SIZE}) { |
| 748 | nodes { id author { login avatarUrl } ${REACTION_GROUPS_FIELDS} } | |
| 751 | nodes { id author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} } | |
| 749 | 752 | } |
| 750 | reviews(first: ${GRAPHQL_PAGE_SIZE}) { nodes { id ${REACTION_GROUPS_FIELDS} } } | |
| 753 | reviews(first: ${GRAPHQL_PAGE_SIZE}) { nodes { id author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} } } | |
| 751 | 754 | reviewRequests(first: 50) { |
| 752 | 755 | nodes { |
| 753 | 756 | requestedReviewer { |
| 754 | 757 | ... on User { login name avatarUrl } |
| 755 | ... on Bot { login avatarUrl } | |
| 758 | ... on Bot { __typename login avatarUrl } | |
| 756 | 759 | } |
| 757 | 760 | } |
| 758 | 761 | } |
| 759 | 762 | latestReviews(first: 50) { |
| 760 | nodes { author { login avatarUrl } } | |
| 763 | nodes { author { __typename login avatarUrl } } | |
| 761 | 764 | } |
| 762 | 765 | reviewDismissals: timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}) { |
| 763 | 766 | pageInfo { hasNextPage endCursor } |
| ... | ... | @@ -793,7 +796,7 @@ export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($owner: String!, $nam |
| 793 | 796 | pullRequest { id } |
| 794 | 797 | comments(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { |
| 795 | 798 | pageInfo { hasNextPage endCursor } |
| 796 | nodes { id author { login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} } | |
| 799 | nodes { id author { __typename login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} } | |
| 797 | 800 | } |
| 798 | 801 | } |
| 799 | 802 | } |
| ... | ... | @@ -1134,7 +1137,12 @@ function toActor(raw: Schema.Schema.Type<typeof RawActorSchema> | null | undefin |
| 1134 | 1137 | const login = trimmed(raw?.login); |
| 1135 | 1138 | return login === null |
| 1136 | 1139 | ? null |
| 1137 | : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatarUrl) }; | |
| 1140 | : { | |
| 1141 | login, | |
| 1142 | name: trimmed(raw?.name), | |
| 1143 | avatarUrl: trimmed(raw?.avatarUrl), | |
| 1144 | ...(raw?.__typename === "Bot" || raw?.is_bot === true ? { isBot: true } : {}), | |
| 1145 | }; | |
| 1138 | 1146 | } |
| 1139 | 1147 | |
| 1140 | 1148 | function toCommitActor( |
| ... | ... | @@ -1753,6 +1761,7 @@ export interface GitHubReviewThreadComments { |
| 1753 | 1761 | * so an app's avatar arrives the same way a person's does. |
| 1754 | 1762 | */ |
| 1755 | 1763 | readonly avatarsByLogin: ReadonlyMap<string, string>; |
| 1764 | readonly botLogins: ReadonlySet<string>; | |
| 1756 | 1765 | /** Per-commit line counts carried by the same bounded pull-request query. */ |
| 1757 | 1766 | readonly commitStats: ReadonlyMap< |
| 1758 | 1767 | string, |
| ... | ... | @@ -1791,6 +1800,7 @@ export interface GitHubReviewThreadPage { |
| 1791 | 1800 | readonly reactionsById: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>; |
| 1792 | 1801 | readonly reviewers: ReadonlyArray<PullRequestActor>; |
| 1793 | 1802 | readonly avatarsByLogin: ReadonlyMap<string, string>; |
| 1803 | readonly botLogins: ReadonlySet<string>; | |
| 1794 | 1804 | readonly commitStats: ReadonlyMap< |
| 1795 | 1805 | string, |
| 1796 | 1806 | { readonly additions: number; readonly deletions: number } |
| ... | ... | @@ -1928,9 +1938,11 @@ export function decodeReviewThreadsJson( |
| 1928 | 1938 | }); |
| 1929 | 1939 | const pullRequest = decoded.success.data.repository.pullRequest; |
| 1930 | 1940 | const avatarsByLogin = new Map<string, string>(); |
| 1941 | const botLogins = new Set<string>(); | |
| 1931 | 1942 | for (const raw of [ |
| 1932 | 1943 | pullRequest.author, |
| 1933 | 1944 | ...(pullRequest.comments?.nodes ?? []).map((node) => node.author), |
| 1945 | ...(pullRequest.reviews?.nodes ?? []).map((node) => node.author), | |
| 1934 | 1946 | ...(pullRequest.reviewRequests?.nodes ?? []).map((node) => node.requestedReviewer), |
| 1935 | 1947 | ...(pullRequest.latestReviews?.nodes ?? []).map((node) => node.author), |
| 1936 | 1948 | ...threads.nodes.flatMap((thread) => thread.comments.nodes.map((comment) => comment.author)), |
| ... | ... | @@ -1938,6 +1950,7 @@ export function decodeReviewThreadsJson( |
| 1938 | 1950 | const login = trimmed(raw?.login); |
| 1939 | 1951 | const avatarUrl = trimmed(raw?.avatarUrl); |
| 1940 | 1952 | if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl); |
| 1953 | if (login !== null && toActor(raw)?.isBot) botLogins.add(login); | |
| 1941 | 1954 | } |
| 1942 | 1955 | const reviewers = new Map<string, PullRequestActor>(); |
| 1943 | 1956 | for (const raw of [ |
| ... | ... | @@ -1996,6 +2009,7 @@ export function decodeReviewThreadsJson( |
| 1996 | 2009 | reactionsById, |
| 1997 | 2010 | reviewers: [...reviewers.values()], |
| 1998 | 2011 | avatarsByLogin, |
| 2012 | botLogins, | |
| 1999 | 2013 | commitStats, |
| 2000 | 2014 | commits, |
| 2001 | 2015 | viewer: toPullRequestViewerFields(pullRequest), |
apps/web/src/components/pullRequest/PullRequestCommentBody.tsx created+61| ... | ... | @@ -0,0 +1,61 @@ |
| 1 | import { useEffect, useId, useRef, useState, type ComponentProps } from "react"; | |
| 2 | ||
| 3 | import { cn } from "~/lib/utils"; | |
| 4 | import { Button } from "../ui/button"; | |
| 5 | import { PullRequestMarkdown } from "./PullRequestMarkdown"; | |
| 6 | ||
| 7 | /** Keep the complete markdown intact while limiting long reports to a readable preview. */ | |
| 8 | export function PullRequestCommentBody({ | |
| 9 | className, | |
| 10 | ...props | |
| 11 | }: ComponentProps<typeof PullRequestMarkdown>) { | |
| 12 | const [expanded, setExpanded] = useState(false); | |
| 13 | const [overflowing, setOverflowing] = useState(false); | |
| 14 | const content = useRef<HTMLDivElement>(null); | |
| 15 | const id = useId(); | |
| 16 | ||
| 17 | useEffect(() => { | |
| 18 | const element = content.current; | |
| 19 | if (!element) return; | |
| 20 | const measure = () => setOverflowing(element.getBoundingClientRect().height > 240); | |
| 21 | measure(); | |
| 22 | const observer = new ResizeObserver(measure); | |
| 23 | observer.observe(element); | |
| 24 | return () => observer.disconnect(); | |
| 25 | }, []); | |
| 26 | ||
| 27 | return ( | |
| 28 | <div className={cn("min-w-0", className)}> | |
| 29 | <div | |
| 30 | id={id} | |
| 31 | className={cn("relative", !expanded && "max-h-[240px] overflow-hidden")} | |
| 32 | onFocusCapture={() => setExpanded(true)} | |
| 33 | > | |
| 34 | <div ref={content}> | |
| 35 | <PullRequestMarkdown {...props} /> | |
| 36 | </div> | |
| 37 | {overflowing && !expanded ? ( | |
| 38 | <div | |
| 39 | aria-hidden | |
| 40 | className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-background to-transparent" | |
| 41 | /> | |
| 42 | ) : null} | |
| 43 | </div> | |
| 44 | {overflowing ? ( | |
| 45 | <Button | |
| 46 | size="xs" | |
| 47 | variant="ghost" | |
| 48 | className="mt-2 text-muted-foreground" | |
| 49 | aria-expanded={expanded} | |
| 50 | aria-controls={id} | |
| 51 | onClick={() => { | |
| 52 | if (expanded) content.current?.parentElement?.scrollIntoView({ block: "nearest" }); | |
| 53 | setExpanded(!expanded); | |
| 54 | }} | |
| 55 | > | |
| 56 | {expanded ? "Show less" : "Show full comment"} | |
| 57 | </Button> | |
| 58 | ) : null} | |
| 59 | </div> | |
| 60 | ); | |
| 61 | } |
apps/web/src/components/pullRequest/PullRequestReactions.tsx+2-13| ... | ... | @@ -34,15 +34,7 @@ function reactionsSignature(reactions: ReadonlyArray<PullRequestReaction>): stri |
| 34 | 34 | .join(" "); |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | /** | |
| 38 | * The reaction pills under a remark, and the picker that adds one. The same bar serves the | |
| 39 | * description, a conversation comment and a review thread's comments: what differs between them | |
| 40 | * is only which subject the host is told about. | |
| 41 | * | |
| 42 | * The add button is revealed by hovering the remark it belongs to, the way GitHub's is, so the | |
| 43 | * parent must carry `group`. It stays put once there is something to press it beside, while the | |
| 44 | * picker is open, and whenever it is focused — a control only a mouse can find is no control. | |
| 45 | */ | |
| 37 | /** Reaction counts and an always-visible picker, routed to the supplied host subject. */ | |
| 46 | 38 | export function PullRequestReactionBar({ |
| 47 | 39 | reactions, |
| 48 | 40 | canReact, |
| ... | ... | @@ -98,7 +90,7 @@ export function PullRequestReactionBar({ |
| 98 | 90 | if (shown.length === 0 && !canReact) return null; |
| 99 | 91 | |
| 100 | 92 | return ( |
| 101 | <div className={cn("flex flex-wrap items-center gap-1", className)}> | |
| 93 | <div className={cn("flex min-w-0 max-w-full flex-wrap items-center gap-1", className)}> | |
| 102 | 94 | {shown.map((reaction) => ( |
| 103 | 95 | <Tooltip key={reaction.content}> |
| 104 | 96 | <TooltipTrigger |
| ... | ... | @@ -136,9 +128,6 @@ export function PullRequestReactionBar({ |
| 136 | 128 | className={cn( |
| 137 | 129 | PILL_CLASS, |
| 138 | 130 | "border-border/70 px-1.5 text-muted-foreground hover:border-primary/60 hover:text-foreground", |
| 139 | shown.length === 0 && | |
| 140 | !pickerOpen && | |
| 141 | "opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100", | |
| 142 | 131 | )} |
| 143 | 132 | /> |
| 144 | 133 | } |
apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx+10-10| ... | ... | @@ -264,9 +264,18 @@ export function ReviewThreadCard({ |
| 264 | 264 | <div className="mt-2 space-y-3"> |
| 265 | 265 | {comments.map((comment) => ( |
| 266 | 266 | <article key={comment.id} className="group min-w-0"> |
| 267 | <div className="flex items-center gap-2 text-xs text-muted-foreground"> | |
| 267 | <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> | |
| 268 | 268 | <PullRequestActorLabel actor={comment.author} className="text-foreground" /> |
| 269 | 269 | <span>{formatRelativeTimeLabel(comment.createdAt)}</span> |
| 270 | <PullRequestReactionBar | |
| 271 | className="ml-auto justify-end" | |
| 272 | reactions={comment.reactions ?? []} | |
| 273 | canReact={canReact} | |
| 274 | subjectId={comment.id} | |
| 275 | environmentId={environmentId} | |
| 276 | reference={reference} | |
| 277 | onRefresh={onReacted} | |
| 278 | /> | |
| 270 | 279 | </div> |
| 271 | 280 | {editingId === comment.id ? ( |
| 272 | 281 | <PullRequestMarkdownEditor |
| ... | ... | @@ -300,15 +309,6 @@ export function ReviewThreadCard({ |
| 300 | 309 | ) : null} |
| 301 | 310 | </div> |
| 302 | 311 | )} |
| 303 | <PullRequestReactionBar | |
| 304 | className="mt-1.5" | |
| 305 | reactions={comment.reactions ?? []} | |
| 306 | canReact={canReact} | |
| 307 | subjectId={comment.id} | |
| 308 | environmentId={environmentId} | |
| 309 | reference={reference} | |
| 310 | onRefresh={onReacted} | |
| 311 | /> | |
| 312 | 312 | </article> |
| 313 | 313 | ))} |
| 314 | 314 | </div> |
apps/web/src/components/pullRequest/PullRequestSummaryTab.test.tsx+74-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { EnvironmentId, ProjectId, type PullRequestDetailView } from "@t3tools/contracts"; |
| 2 | import { act } from "react"; | |
| 2 | import { act, type ReactNode } from "react"; | |
| 3 | 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 4 | 4 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; |
| 5 | 5 | |
| ... | ... | @@ -9,6 +9,11 @@ vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => vi.fn() })); |
| 9 | 9 | vi.mock("./PullRequestMarkdown", () => ({ |
| 10 | 10 | PullRequestMarkdown: ({ text }: { text: string }) => <p>{text}</p>, |
| 11 | 11 | })); |
| 12 | vi.mock("../ui/tooltip", () => ({ | |
| 13 | Tooltip: ({ children }: { children: ReactNode }) => children, | |
| 14 | TooltipTrigger: ({ children }: { children: ReactNode }) => children, | |
| 15 | TooltipPopup: () => null, | |
| 16 | })); | |
| 12 | 17 | |
| 13 | 18 | import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; |
| 14 | 19 | |
| ... | ... | @@ -139,3 +144,71 @@ it("keeps an unsaved description when collapsed and reopened", () => { |
| 139 | 144 | click("Description"); |
| 140 | 145 | expect(renderer.root.findByType("textarea").props.value).toBe("Unsaved description"); |
| 141 | 146 | }); |
| 147 | ||
| 148 | it("opens bot reports in pages without hiding human comments", () => { | |
| 149 | const value: PullRequestDetailView = { | |
| 150 | ...detail, | |
| 151 | commentCount: 13, | |
| 152 | comments: Array.from({ length: 13 }, (_, index) => ({ | |
| 153 | id: `comment-${index}`, | |
| 154 | kind: "issue-comment", | |
| 155 | author: { | |
| 156 | login: index === 12 ? "human" : "review-app", | |
| 157 | name: null, | |
| 158 | avatarUrl: null, | |
| 159 | isBot: index !== 12, | |
| 160 | }, | |
| 161 | body: index === 12 ? "Human comment" : `Bot report ${index}`, | |
| 162 | createdAt: `2026-09-01T00:00:${String(index).padStart(2, "0")}Z`, | |
| 163 | url: null, | |
| 164 | path: null, | |
| 165 | reviewState: null, | |
| 166 | })), | |
| 167 | }; | |
| 168 | act(() => { | |
| 169 | renderer = create(render(value)); | |
| 170 | }); | |
| 171 | expect(renderer.root.findAllByType("p").map((p) => p.children.join(""))).toContain( | |
| 172 | "Human comment", | |
| 173 | ); | |
| 174 | expect( | |
| 175 | renderer.root.findAllByType("p").some((p) => p.children.join("").startsWith("Bot report")), | |
| 176 | ).toBe(false); | |
| 177 | const group = renderer.root | |
| 178 | .findAllByType("button") | |
| 179 | .find((button) => button.props["aria-label"] === "12 bot comments")!; | |
| 180 | act(() => group.props.onClick({ nativeEvent: {}, preventDefault() {}, stopPropagation() {} })); | |
| 181 | expect( | |
| 182 | renderer.root.findAllByType("p").filter((p) => p.children.join("").startsWith("Bot report")), | |
| 183 | ).toHaveLength(10); | |
| 184 | expect(renderer.root.findAllByType("p").map((p) => p.children.join(""))).not.toContain( | |
| 185 | "Bot report 0", | |
| 186 | ); | |
| 187 | act(() => | |
| 188 | renderer.root | |
| 189 | .findAllByType("button") | |
| 190 | .find((button) => button.children.includes(" older bot comment"))! | |
| 191 | .props.onClick(), | |
| 192 | ); | |
| 193 | expect( | |
| 194 | renderer.root.findAllByType("p").filter((p) => p.children.join("").startsWith("Bot report")), | |
| 195 | ).toHaveLength(12); | |
| 196 | act(() => group.props.onClick({ nativeEvent: {}, preventDefault() {}, stopPropagation() {} })); | |
| 197 | act(() => group.props.onClick({ nativeEvent: {}, preventDefault() {}, stopPropagation() {} })); | |
| 198 | expect(renderer.root.findAllByType("p").map((p) => p.children.join(""))).toContain( | |
| 199 | "Bot report 0", | |
| 200 | ); | |
| 201 | act(() => | |
| 202 | renderer.root | |
| 203 | .findAllByType("button") | |
| 204 | .find((button) => button.children.includes(" recent bot comments"))! | |
| 205 | .props.onClick(), | |
| 206 | ); | |
| 207 | expect( | |
| 208 | renderer.root.findAllByType("p").filter((p) => p.children.join("").startsWith("Bot report")), | |
| 209 | ).toHaveLength(10); | |
| 210 | act(() => renderer.update(render({ ...value, url: `${value.url}0`, number: 10 }))); | |
| 211 | expect( | |
| 212 | renderer.root.findAllByType("p").some((p) => p.children.join("").startsWith("Bot report")), | |
| 213 | ).toBe(false); | |
| 214 | }); |
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+394-171| ... | ... | @@ -1,9 +1,9 @@ |
| 1 | 1 | import type { |
| 2 | 2 | EnvironmentId, |
| 3 | PullRequestActor, | |
| 4 | 3 | PullRequestComment, |
| 5 | 4 | PullRequestDetailView, |
| 6 | 5 | PullRequestRef, |
| 6 | PullRequestReviewThread, | |
| 7 | 7 | ScopedThreadRef, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import { |
| ... | ... | @@ -28,7 +28,6 @@ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collaps |
| 28 | 28 | import { toastManager } from "../ui/toast"; |
| 29 | 29 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 30 | 30 | import { |
| 31 | PullRequestActorAvatar, | |
| 32 | 31 | PullRequestActorLabel, |
| 33 | 32 | PullRequestCheckStatusIcon, |
| 34 | 33 | PullRequestReviewOutcomeBadge, |
| ... | ... | @@ -53,6 +52,7 @@ import { |
| 53 | 52 | canEditPullRequestComment, |
| 54 | 53 | } from "./pullRequestEditing.logic"; |
| 55 | 54 | import { PullRequestMarkdown } from "./PullRequestMarkdown"; |
| 55 | import { PullRequestCommentBody } from "./PullRequestCommentBody"; | |
| 56 | 56 | import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; |
| 57 | 57 | import { PullRequestReactionBar } from "./PullRequestReactions"; |
| 58 | 58 | import { PullRequestConversationGhost } from "./PullRequestGhosts"; |
| ... | ... | @@ -64,18 +64,69 @@ function reviewerKey(login: string): string { |
| 64 | 64 | return login.toLowerCase(); |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | /** The avatar carries the attribution alone; who it is arrives on hover, like the reviewer row. */ | |
| 68 | function CommentAuthor({ actor }: { actor: PullRequestActor | null }) { | |
| 69 | const login = actor?.login ?? "ghost"; | |
| 67 | function CommentIdentity({ | |
| 68 | comment, | |
| 69 | detail, | |
| 70 | }: { | |
| 71 | comment: PullRequestComment; | |
| 72 | detail: PullRequestDetailView; | |
| 73 | }) { | |
| 74 | const actor = comment.author; | |
| 75 | const profileUrl = | |
| 76 | detail.provider === "github" && actor && !actor.login.endsWith("[bot]") | |
| 77 | ? new URL(`/${encodeURIComponent(actor.login)}`, detail.url).toString() | |
| 78 | : null; | |
| 79 | return ( | |
| 80 | <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1 text-xs"> | |
| 81 | <PullRequestActorLabel | |
| 82 | actor={actor} | |
| 83 | profileUrl={profileUrl} | |
| 84 | className="max-w-full font-medium text-foreground [&>img]:size-6 [&>span:first-child]:size-6" | |
| 85 | /> | |
| 86 | <Tooltip> | |
| 87 | <TooltipTrigger | |
| 88 | render={ | |
| 89 | comment.url ? ( | |
| 90 | <a | |
| 91 | href={comment.url} | |
| 92 | target="_blank" | |
| 93 | rel="noopener noreferrer" | |
| 94 | className="text-muted-foreground hover:text-foreground hover:underline" | |
| 95 | /> | |
| 96 | ) : ( | |
| 97 | <span className="text-muted-foreground" /> | |
| 98 | ) | |
| 99 | } | |
| 100 | > | |
| 101 | <time dateTime={comment.createdAt}>{formatRelativeTimeLabel(comment.createdAt)}</time> | |
| 102 | </TooltipTrigger> | |
| 103 | <TooltipPopup> | |
| 104 | {new Date(comment.createdAt).toLocaleString()} | |
| 105 | {comment.url ? " · Open comment on host" : ""} | |
| 106 | </TooltipPopup> | |
| 107 | </Tooltip> | |
| 108 | </div> | |
| 109 | ); | |
| 110 | } | |
| 111 | ||
| 112 | function CommentLocation({ | |
| 113 | comment, | |
| 114 | thread, | |
| 115 | }: { | |
| 116 | comment: PullRequestComment; | |
| 117 | thread: PullRequestReviewThread | undefined; | |
| 118 | }) { | |
| 119 | const path = thread?.path ?? comment.path; | |
| 120 | if (!path) return null; | |
| 121 | const label = `${path}${thread?.line ? `:${thread.line}` : ""}`; | |
| 70 | 122 | return ( |
| 71 | <Tooltip> | |
| 72 | <TooltipTrigger render={<span className="shrink-0 rounded-full" aria-label={login} />}> | |
| 73 | <PullRequestActorAvatar actor={actor} /> | |
| 74 | </TooltipTrigger> | |
| 75 | <TooltipPopup side="bottom"> | |
| 76 | {actor?.name && actor.name !== login ? `${actor.name} (@${login})` : login} | |
| 77 | </TooltipPopup> | |
| 78 | </Tooltip> | |
| 123 | <div className="mt-2 flex min-w-0 items-center gap-2 text-xs text-muted-foreground"> | |
| 124 | <Tooltip> | |
| 125 | <TooltipTrigger render={<span className="truncate font-mono" />}>{label}</TooltipTrigger> | |
| 126 | <TooltipPopup>{label}</TooltipPopup> | |
| 127 | </Tooltip> | |
| 128 | {thread?.isOutdated ? <span className="shrink-0">Outdated</span> : null} | |
| 129 | </div> | |
| 79 | 130 | ); |
| 80 | 131 | } |
| 81 | 132 | |
| ... | ... | @@ -127,7 +178,8 @@ function CommentBody({ |
| 127 | 178 | } |
| 128 | 179 | return ( |
| 129 | 180 | <div className={cn("flex items-start gap-1", className)}> |
| 130 | <PullRequestMarkdown | |
| 181 | <PullRequestCommentBody | |
| 182 | key={comment.id} | |
| 131 | 183 | className="min-w-0 flex-1" |
| 132 | 184 | text={comment.body} |
| 133 | 185 | cwd={editing.cwd} |
| ... | ... | @@ -149,13 +201,15 @@ function CommentBody({ |
| 149 | 201 | ); |
| 150 | 202 | } |
| 151 | 203 | |
| 152 | /** Finished work — a resolved conversation or a dismissed approval — opens collapsed. */ | |
| 204 | /** Finished work — a resolved conversation or a dismissed review — opens collapsed. */ | |
| 153 | 205 | function CollapsedComment({ |
| 154 | 206 | comment, |
| 155 | 207 | editing, |
| 156 | 208 | label, |
| 157 | 209 | body, |
| 158 | 210 | reactionBar, |
| 211 | detail, | |
| 212 | thread, | |
| 159 | 213 | }: { |
| 160 | 214 | comment: PullRequestComment; |
| 161 | 215 | editing: CommentEditing; |
| ... | ... | @@ -163,49 +217,54 @@ function CollapsedComment({ |
| 163 | 217 | /** Null where the remark is nothing but its verdict, which a dismissal usually is. */ |
| 164 | 218 | body: string | null; |
| 165 | 219 | reactionBar: ReactNode; |
| 220 | detail: PullRequestDetailView; | |
| 221 | thread: PullRequestReviewThread | undefined; | |
| 166 | 222 | }) { |
| 167 | 223 | const [open, setOpen] = useState(false); |
| 224 | const statusTriggerRef = useRef<HTMLButtonElement>(null); | |
| 168 | 225 | return ( |
| 169 | 226 | <Collapsible open={open} onOpenChange={setOpen}> |
| 170 | 227 | <article className="group rounded-lg border border-border/60 [contain-intrinsic-block-size:44px] [content-visibility:auto]"> |
| 171 | <CollapsibleTrigger | |
| 172 | className={cn( | |
| 173 | "flex w-full items-center gap-2 p-3 text-left transition-opacity hover:opacity-100", | |
| 174 | open ? "opacity-100" : "opacity-65", | |
| 175 | )} | |
| 176 | > | |
| 177 | <span className="flex min-w-0 flex-1 items-center gap-2 text-xs text-muted-foreground"> | |
| 178 | <CommentAuthor actor={comment.author} /> | |
| 179 | <span>{formatRelativeTimeLabel(comment.createdAt)}</span> | |
| 180 | <span>{label}</span> | |
| 181 | </span> | |
| 182 | <ChevronDownIcon | |
| 183 | aria-hidden | |
| 184 | className={cn( | |
| 185 | "size-3.5 shrink-0 text-muted-foreground/60 transition-transform", | |
| 186 | open && "rotate-180", | |
| 187 | )} | |
| 188 | /> | |
| 189 | </CollapsibleTrigger> | |
| 228 | <div className="p-3"> | |
| 229 | <div className="flex flex-wrap items-start gap-2"> | |
| 230 | <CommentIdentity comment={comment} detail={detail} /> | |
| 231 | <CollapsibleTrigger | |
| 232 | ref={statusTriggerRef} | |
| 233 | 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" | |
| 234 | > | |
| 235 | {label} | |
| 236 | <ChevronDownIcon | |
| 237 | aria-hidden | |
| 238 | className={cn("size-3.5 transition-transform", open && "rotate-180")} | |
| 239 | /> | |
| 240 | </CollapsibleTrigger> | |
| 241 | {reactionBar} | |
| 242 | </div> | |
| 243 | <CommentLocation comment={comment} thread={thread} /> | |
| 244 | {!open && body ? ( | |
| 245 | <CollapsibleTrigger | |
| 246 | className="mt-2 block w-full truncate text-left text-xs text-muted-foreground hover:text-foreground" | |
| 247 | onClick={() => statusTriggerRef.current?.focus({ preventScroll: true })} | |
| 248 | > | |
| 249 | {body | |
| 250 | .replace(/<!--[\s\S]*?-->/gu, "") | |
| 251 | .replace(/^\s*>?\s*\[!\w+\]\s*$/gmu, "") | |
| 252 | .replace(/!?(\[([^\]]+)\])\([^)]*\)/gu, "$2") | |
| 253 | .replace(/^[\s>#*-]+/gmu, "") | |
| 254 | .replace(/[*`]/gu, "") | |
| 255 | .replace(/\s+/g, " ") | |
| 256 | .trim()} | |
| 257 | </CollapsibleTrigger> | |
| 258 | ) : null} | |
| 259 | </div> | |
| 190 | 260 | <CollapsiblePanel> |
| 191 | 261 | {open ? ( |
| 192 | 262 | <div className="px-3 pb-3"> |
| 193 | {comment.path ? ( | |
| 194 | <Tooltip> | |
| 195 | <TooltipTrigger | |
| 196 | render={ | |
| 197 | <p className="truncate text-xs text-muted-foreground">{comment.path}</p> | |
| 198 | } | |
| 199 | /> | |
| 200 | <TooltipPopup side="top">{comment.path}</TooltipPopup> | |
| 201 | </Tooltip> | |
| 202 | ) : null} | |
| 203 | 263 | {/* A dismissal carries no more words than an approval does, and an empty markdown |
| 204 | 264 | block reads as a card somebody forgot to fill in. */} |
| 205 | 265 | {body === null && !editing.canEdit(comment) ? null : ( |
| 206 | 266 | <CommentBody className="mt-2" comment={comment} editing={editing} /> |
| 207 | 267 | )} |
| 208 | {reactionBar} | |
| 209 | 268 | </div> |
| 210 | 269 | ) : null} |
| 211 | 270 | </CollapsiblePanel> |
| ... | ... | @@ -302,11 +361,107 @@ function Section({ |
| 302 | 361 | ); |
| 303 | 362 | } |
| 304 | 363 | |
| 364 | function CommentGroup({ | |
| 365 | label, | |
| 366 | comments, | |
| 367 | detail, | |
| 368 | children, | |
| 369 | onOpenChange, | |
| 370 | }: { | |
| 371 | label: string; | |
| 372 | comments: readonly PullRequestComment[]; | |
| 373 | detail: PullRequestDetailView; | |
| 374 | children: ReactNode; | |
| 375 | onOpenChange?: (open: boolean) => void; | |
| 376 | }) { | |
| 377 | const authors = [ | |
| 378 | ...new Map( | |
| 379 | comments.map((comment) => [reviewerKey(comment.author?.login ?? "ghost"), comment.author]), | |
| 380 | ).values(), | |
| 381 | ]; | |
| 382 | const fileCount = new Set(comments.flatMap((comment) => (comment.path ? [comment.path] : []))) | |
| 383 | .size; | |
| 384 | const latest = comments.reduce<string | null>( | |
| 385 | (date, comment) => (date === null || comment.createdAt > date ? comment.createdAt : date), | |
| 386 | null, | |
| 387 | ); | |
| 388 | return ( | |
| 389 | <Collapsible | |
| 390 | className="overflow-hidden rounded-lg border border-border/70 bg-muted/20" | |
| 391 | onOpenChange={onOpenChange} | |
| 392 | > | |
| 393 | <div className="flex items-center gap-3 pl-3"> | |
| 394 | <div className="flex shrink-0 -space-x-1.5"> | |
| 395 | {authors.slice(0, 3).map((actor) => ( | |
| 396 | <PullRequestActorLabel | |
| 397 | key={actor?.login ?? "ghost"} | |
| 398 | actor={actor} | |
| 399 | profileUrl={ | |
| 400 | detail.provider === "github" && actor | |
| 401 | ? new URL( | |
| 402 | actor.isBot || actor.login.endsWith("[bot]") | |
| 403 | ? `/apps/${encodeURIComponent(actor.login.replace(/\[bot\]$/, ""))}` | |
| 404 | : `/${encodeURIComponent(actor.login)}`, | |
| 405 | detail.url, | |
| 406 | ).toString() | |
| 407 | : null | |
| 408 | } | |
| 409 | labelClassName="sr-only" | |
| 410 | className="relative rounded-full bg-background ring-2 ring-background hover:z-10 focus-visible:z-10 [&>img]:size-6 [&>span:first-child]:size-6" | |
| 411 | /> | |
| 412 | ))} | |
| 413 | {authors.length > 3 ? ( | |
| 414 | <span className="relative flex size-6 items-center justify-center rounded-full bg-muted text-[10px] text-muted-foreground ring-2 ring-background"> | |
| 415 | +{authors.length - 3} | |
| 416 | </span> | |
| 417 | ) : null} | |
| 418 | </div> | |
| 419 | <CollapsibleTrigger | |
| 420 | aria-label={label} | |
| 421 | className="group flex min-w-0 flex-1 items-center gap-3 rounded-md py-3 pr-3 text-left hover:bg-muted/30" | |
| 422 | > | |
| 423 | <span className="min-w-0 flex-1 space-y-1"> | |
| 424 | <span className="block text-xs font-medium text-foreground/90">{label}</span> | |
| 425 | <span className="flex flex-wrap gap-x-1.5 text-[11px] text-muted-foreground"> | |
| 426 | <span> | |
| 427 | {authors.length} {authors.length === 1 ? "author" : "authors"} | |
| 428 | </span> | |
| 429 | {fileCount > 0 ? ( | |
| 430 | <span> | |
| 431 | · {fileCount} {fileCount === 1 ? "file" : "files"} | |
| 432 | </span> | |
| 433 | ) : null} | |
| 434 | {latest ? ( | |
| 435 | <span> | |
| 436 | · Latest{" "} | |
| 437 | <Tooltip> | |
| 438 | <TooltipTrigger render={<time dateTime={latest} />}> | |
| 439 | {formatRelativeTimeLabel(latest)} | |
| 440 | </TooltipTrigger> | |
| 441 | <TooltipPopup>{new Date(latest).toLocaleString()}</TooltipPopup> | |
| 442 | </Tooltip> | |
| 443 | </span> | |
| 444 | ) : null} | |
| 445 | </span> | |
| 446 | </span> | |
| 447 | <ChevronRightIcon | |
| 448 | aria-hidden | |
| 449 | className="size-3.5 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" | |
| 450 | /> | |
| 451 | </CollapsibleTrigger> | |
| 452 | </div> | |
| 453 | <CollapsiblePanel keepMounted> | |
| 454 | <div className="border-t border-border/60 px-3 pb-3">{children}</div> | |
| 455 | </CollapsiblePanel> | |
| 456 | </Collapsible> | |
| 457 | ); | |
| 458 | } | |
| 459 | ||
| 305 | 460 | /** |
| 306 | 461 | * What a first render of the conversation carries. A pull request with two hundred comments is |
| 307 | 462 | * two hundred markdown documents, and the ones worth arriving for are the recent ones. |
| 308 | 463 | */ |
| 309 | const COMMENT_PAGE = 30; | |
| 464 | const COMMENT_PAGE = 10; | |
| 310 | 465 | |
| 311 | 466 | export function PullRequestSummaryTab({ |
| 312 | 467 | environmentId, |
| ... | ... | @@ -337,11 +492,35 @@ export function PullRequestSummaryTab({ |
| 337 | 492 | // Keyed by the pull request, so opening another one starts at the end of its conversation |
| 338 | 493 | // rather than wherever the last one had been read back to. |
| 339 | 494 | const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE }); |
| 495 | const [openedBotGroup, setOpenedBotGroup] = useState<string | null>(null); | |
| 496 | const [shownBots, setShownBots] = useState({ url: detail.url, count: COMMENT_PAGE }); | |
| 497 | const shownBotComments = shownBots.url === detail.url ? shownBots.count : COMMENT_PAGE; | |
| 340 | 498 | const shownComments = shown.url === detail.url ? shown.count : COMMENT_PAGE; |
| 499 | // A comment that already lives on a review thread is that thread: the thread carries the line | |
| 500 | // and side the bare comment has lost, and a resolved one is finished work nobody should be | |
| 501 | // invited to fix again — the same call the whole-review hand-off makes. | |
| 502 | const threadByCommentId = new Map( | |
| 503 | detail.reviewThreads.flatMap((thread) => | |
| 504 | thread.comments.map((comment) => [comment.id, thread] as const), | |
| 505 | ), | |
| 506 | ); | |
| 507 | ||
| 508 | const activeComments: PullRequestComment[] = []; | |
| 509 | const finishedComments: PullRequestComment[] = []; | |
| 510 | const botComments: PullRequestComment[] = []; | |
| 511 | for (const comment of detail.comments) { | |
| 512 | const finished = | |
| 513 | threadByCommentId.get(comment.id)?.isResolved || | |
| 514 | pullRequestReviewOutcome(comment.reviewState) === "dismissed"; | |
| 515 | const bot = comment.author?.isBot === true || comment.author?.login.endsWith("[bot]"); | |
| 516 | (finished ? finishedComments : bot ? botComments : activeComments).push(comment); | |
| 517 | } | |
| 341 | 518 | // Windowed by recency regardless of display order: expanding always reaches further back in |
| 342 | 519 | // time, whether the newest comment currently reads first or last. |
| 343 | const recentComments = detail.comments.slice(Math.max(0, detail.comments.length - shownComments)); | |
| 344 | const hiddenCommentCount = detail.comments.length - recentComments.length; | |
| 520 | const recentComments = activeComments.slice(Math.max(0, activeComments.length - shownComments)); | |
| 521 | const hiddenCommentCount = activeComments.length - recentComments.length; | |
| 522 | const recentBotComments = botComments.slice(Math.max(0, botComments.length - shownBotComments)); | |
| 523 | const hiddenBotCommentCount = botComments.length - recentBotComments.length; | |
| 345 | 524 | const [commentOrder, setCommentOrder] = useState<"newest" | "oldest">("newest"); |
| 346 | 525 | const visibleComments = orderPullRequestComments(recentComments, commentOrder); |
| 347 | 526 | const showOldestCommentsButton = |
| ... | ... | @@ -352,12 +531,12 @@ export function PullRequestSummaryTab({ |
| 352 | 531 | className="w-full" |
| 353 | 532 | onClick={() => setShown({ url: detail.url, count: shownComments + COMMENT_PAGE })} |
| 354 | 533 | > |
| 355 | Show {Math.min(hiddenCommentCount, COMMENT_PAGE)} oldest{" "} | |
| 356 | {hiddenCommentCount === 1 ? "comment" : "comments"} | |
| 534 | Show {Math.min(hiddenCommentCount, COMMENT_PAGE)} older comment | |
| 535 | {hiddenCommentCount === 1 ? "" : "s"} ({hiddenCommentCount} hidden) | |
| 357 | 536 | </Button> |
| 358 | 537 | ) : null; |
| 359 | 538 | // Read from the whole conversation, not the window shown below it: a verdict older than the |
| 360 | // last thirty comments still stands. | |
| 539 | // visible comments still stands. | |
| 361 | 540 | const reviewOutcomes = latestPullRequestReviewOutcomes(detail.comments, detail.commits); |
| 362 | 541 | // Hosts do not promise one casing for a login across two fields of the same response, and |
| 363 | 542 | // none of them lets `Octocat` and `octocat` be two people — so matching on the literal string |
| ... | ... | @@ -393,15 +572,6 @@ export function PullRequestSummaryTab({ |
| 393 | 572 | })), |
| 394 | 573 | ]; |
| 395 | 574 | |
| 396 | // A comment that already lives on a review thread is that thread: the thread carries the line | |
| 397 | // and side the bare comment has lost, and a resolved one is finished work nobody should be | |
| 398 | // invited to fix again — the same call the whole-review hand-off makes. | |
| 399 | const threadByCommentId = new Map( | |
| 400 | detail.reviewThreads.flatMap((thread) => | |
| 401 | thread.comments.map((comment) => [comment.id, thread] as const), | |
| 402 | ), | |
| 403 | ); | |
| 404 | ||
| 405 | 575 | const openLink = useOpenLink(threadRef); |
| 406 | 576 | const openCheck = (url: string) => { |
| 407 | 577 | void openLink(url).catch((error: unknown) => { |
| ... | ... | @@ -470,6 +640,78 @@ export function PullRequestSummaryTab({ |
| 470 | 640 | }, |
| 471 | 641 | }; |
| 472 | 642 | |
| 643 | const renderComment = (comment: PullRequestComment) => { | |
| 644 | const thread = threadByCommentId.get(comment.id); | |
| 645 | const body = visibleBody(comment.body); | |
| 646 | const outcome = pullRequestReviewOutcome(comment.reviewState); | |
| 647 | // An approval is a verdict, not a finding: there is nothing in it to fix. | |
| 648 | const finding: PullRequestFinding | null = | |
| 649 | (comment.kind !== "review" && comment.kind !== "review-comment") || outcome === "approved" | |
| 650 | ? null | |
| 651 | : thread === undefined | |
| 652 | ? // Nor is a remark with nothing in it: offering to hand an empty review | |
| 653 | // to a thread promises work it does not describe. | |
| 654 | body === null | |
| 655 | ? null | |
| 656 | : { kind: "comment", comment } | |
| 657 | : { kind: "thread", thread }; | |
| 658 | const reactionBar = ( | |
| 659 | <PullRequestReactionBar | |
| 660 | reactions={comment.reactions ?? []} | |
| 661 | canReact={detail.capabilities.reactions === true} | |
| 662 | subjectId={comment.id} | |
| 663 | environmentId={environmentId} | |
| 664 | reference={reference} | |
| 665 | onRefresh={onRefresh} | |
| 666 | className="ml-auto justify-end" | |
| 667 | /> | |
| 668 | ); | |
| 669 | return ( | |
| 670 | <article | |
| 671 | key={`${detail.url}:${comment.id}`} | |
| 672 | // Offscreen comments skip style, layout and paint. Bot comments carry pages of | |
| 673 | // highlighted code, and the conversation is below the description either way. | |
| 674 | className="group rounded-lg border border-border/60 bg-background [contain-intrinsic-block-size:160px] [content-visibility:auto]" | |
| 675 | > | |
| 676 | <div className="flex flex-wrap items-start gap-2 rounded-t-lg bg-muted/25 px-3 py-2.5"> | |
| 677 | <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 text-xs text-muted-foreground"> | |
| 678 | <CommentIdentity comment={comment} detail={detail} /> | |
| 679 | {outcome ? ( | |
| 680 | <PullRequestReviewOutcomeBadge outcome={outcome} /> | |
| 681 | ) : comment.reviewState ? ( | |
| 682 | <span>{reviewStateLabel(comment.reviewState)}</span> | |
| 683 | ) : null} | |
| 684 | </div> | |
| 685 | {/* Review remarks only. A plain conversation comment is talk, not a finding, | |
| 686 | and offering to fix one would promise more than it says. */} | |
| 687 | {onFixFinding && finding ? ( | |
| 688 | <Button | |
| 689 | size="xs" | |
| 690 | variant="ghost" | |
| 691 | className="-mt-1 shrink-0" | |
| 692 | disabled={pendingFinding !== null && pendingFinding !== undefined} | |
| 693 | onClick={() => onFixFinding(finding)} | |
| 694 | > | |
| 695 | <HammerIcon className="size-3" /> | |
| 696 | {pendingFinding === pullRequestFindingKey(finding) ? "Preparing..." : fixFindingLabel} | |
| 697 | </Button> | |
| 698 | ) : null} | |
| 699 | {reactionBar} | |
| 700 | </div> | |
| 701 | <div className="px-3"> | |
| 702 | <CommentLocation comment={comment} thread={thread} /> | |
| 703 | </div> | |
| 704 | {/* A verdict usually carries no words, and an empty markdown block reads as | |
| 705 | a card somebody forgot to fill in — the badge above already said it. | |
| 706 | Kept where this reader may rewrite the remark: the pencil lives in here, | |
| 707 | and hiding the block would take away the only way back to it. */} | |
| 708 | {body === null && !commentEditing.canEdit(comment) ? null : ( | |
| 709 | <CommentBody className="px-3 py-3" comment={comment} editing={commentEditing} /> | |
| 710 | )} | |
| 711 | </article> | |
| 712 | ); | |
| 713 | }; | |
| 714 | ||
| 473 | 715 | return ( |
| 474 | 716 | <div className="h-full overflow-y-auto" data-pull-request-summary-scroll> |
| 475 | 717 | <section className="px-4 pt-2.5 pb-1"> |
| ... | ... | @@ -694,7 +936,7 @@ export function PullRequestSummaryTab({ |
| 694 | 936 | </Section> |
| 695 | 937 | |
| 696 | 938 | <Section |
| 697 | title="Comments" | |
| 939 | title={`Comments (${detail.commentCount})`} | |
| 698 | 940 | actions={ |
| 699 | 941 | <Button |
| 700 | 942 | size="xs" |
| ... | ... | @@ -729,118 +971,99 @@ export function PullRequestSummaryTab({ |
| 729 | 971 | ) : ( |
| 730 | 972 | <div className="space-y-3"> |
| 731 | 973 | {commentOrder === "oldest" ? showOldestCommentsButton : null} |
| 732 | {visibleComments.map((comment) => { | |
| 733 | const thread = threadByCommentId.get(comment.id); | |
| 734 | const body = visibleBody(comment.body); | |
| 735 | const outcome = pullRequestReviewOutcome(comment.reviewState); | |
| 736 | if (thread?.isResolved || outcome === "dismissed") { | |
| 737 | return ( | |
| 738 | <CollapsedComment | |
| 739 | key={comment.id} | |
| 740 | comment={comment} | |
| 741 | editing={commentEditing} | |
| 742 | label={thread?.isResolved ? "Resolved" : "Approval dismissed"} | |
| 743 | body={body} | |
| 744 | reactionBar={ | |
| 745 | <PullRequestReactionBar | |
| 746 | className="mt-2" | |
| 747 | reactions={comment.reactions ?? []} | |
| 748 | canReact={detail.capabilities.reactions === true} | |
| 749 | subjectId={comment.id} | |
| 750 | environmentId={environmentId} | |
| 751 | reference={reference} | |
| 752 | onRefresh={onRefresh} | |
| 753 | /> | |
| 754 | } | |
| 755 | /> | |
| 756 | ); | |
| 757 | } | |
| 758 | // An approval is a verdict, not a finding: there is nothing in it to fix. | |
| 759 | const finding: PullRequestFinding | null = | |
| 760 | (comment.kind !== "review" && comment.kind !== "review-comment") || | |
| 761 | outcome === "approved" | |
| 762 | ? null | |
| 763 | : thread === undefined | |
| 764 | ? // Nor is a remark with nothing in it: offering to hand an empty review | |
| 765 | // to a thread promises work it does not describe. | |
| 766 | body === null | |
| 767 | ? null | |
| 768 | : { kind: "comment", comment } | |
| 769 | : { kind: "thread", thread }; | |
| 770 | // One bar, two homes. Under a card with words in it, it is the row beneath | |
| 771 | // them. A bodiless verdict has nothing above it, so a row reserved for an add | |
| 772 | // button nobody can see until they hover is a hole — there it rides the header | |
| 773 | // line instead, which keeps the affordance every sibling card offers. | |
| 774 | const reactionBar = ( | |
| 775 | <PullRequestReactionBar | |
| 776 | reactions={comment.reactions ?? []} | |
| 777 | canReact={detail.capabilities.reactions === true} | |
| 778 | subjectId={comment.id} | |
| 779 | environmentId={environmentId} | |
| 780 | reference={reference} | |
| 781 | onRefresh={onRefresh} | |
| 782 | {...(body === null ? {} : { className: "mt-2" })} | |
| 783 | /> | |
| 784 | ); | |
| 785 | return ( | |
| 786 | <article | |
| 787 | key={comment.id} | |
| 788 | // Offscreen comments skip style, layout and paint. Bot comments carry pages of | |
| 789 | // highlighted code, and the conversation is below the description either way. | |
| 790 | className="group rounded-lg border border-border/60 p-3 [contain-intrinsic-block-size:120px] [content-visibility:auto]" | |
| 791 | > | |
| 792 | <div className="flex items-start gap-2"> | |
| 793 | <span className="flex min-w-0 flex-1 items-center gap-2 text-xs text-muted-foreground"> | |
| 794 | <CommentAuthor actor={comment.author} /> | |
| 795 | <span>{formatRelativeTimeLabel(comment.createdAt)}</span> | |
| 796 | {outcome ? ( | |
| 797 | <PullRequestReviewOutcomeBadge outcome={outcome} /> | |
| 798 | ) : comment.reviewState ? ( | |
| 799 | <span>{reviewStateLabel(comment.reviewState)}</span> | |
| 800 | ) : null} | |
| 801 | {body === null ? reactionBar : null} | |
| 802 | </span> | |
| 803 | {/* Review remarks only. A plain conversation comment is talk, not a finding, | |
| 804 | and offering to fix one would promise more than it says. */} | |
| 805 | {onFixFinding && finding ? ( | |
| 806 | <Button | |
| 807 | size="xs" | |
| 808 | variant="ghost" | |
| 809 | className="-mt-1 shrink-0" | |
| 810 | disabled={pendingFinding !== null && pendingFinding !== undefined} | |
| 811 | onClick={() => onFixFinding(finding)} | |
| 812 | > | |
| 813 | <HammerIcon className="size-3" /> | |
| 814 | {pendingFinding === pullRequestFindingKey(finding) | |
| 815 | ? "Preparing..." | |
| 816 | : fixFindingLabel} | |
| 817 | </Button> | |
| 818 | ) : null} | |
| 819 | </div> | |
| 820 | {comment.path ? ( | |
| 821 | <Tooltip> | |
| 822 | <TooltipTrigger | |
| 823 | render={ | |
| 824 | <p className="mt-1 truncate text-xs text-muted-foreground"> | |
| 825 | {comment.path} | |
| 826 | </p> | |
| 974 | {visibleComments.map(renderComment)} | |
| 975 | {commentOrder === "newest" ? showOldestCommentsButton : null} | |
| 976 | {shownComments > COMMENT_PAGE ? ( | |
| 977 | <Button | |
| 978 | size="sm" | |
| 979 | variant="ghost" | |
| 980 | className="w-full" | |
| 981 | onClick={() => setShown({ url: detail.url, count: COMMENT_PAGE })} | |
| 982 | > | |
| 983 | Show only {COMMENT_PAGE} recent comments | |
| 984 | </Button> | |
| 985 | ) : null} | |
| 986 | {botComments.length > 0 ? ( | |
| 987 | <CommentGroup | |
| 988 | key={`bots:${detail.url}`} | |
| 989 | label={`${botComments.length} bot comment${botComments.length === 1 ? "" : "s"}`} | |
| 990 | comments={botComments} | |
| 991 | detail={detail} | |
| 992 | onOpenChange={(open) => { | |
| 993 | if (open) setOpenedBotGroup(detail.url); | |
| 994 | }} | |
| 995 | > | |
| 996 | <div className="space-y-3 pt-2"> | |
| 997 | {openedBotGroup === detail.url | |
| 998 | ? orderPullRequestComments(recentBotComments, commentOrder).map( | |
| 999 | renderComment, | |
| 1000 | ) | |
| 1001 | : null} | |
| 1002 | {hiddenBotCommentCount > 0 ? ( | |
| 1003 | <Button | |
| 1004 | size="sm" | |
| 1005 | variant="outline" | |
| 1006 | className="w-full" | |
| 1007 | onClick={() => | |
| 1008 | setShownBots({ | |
| 1009 | url: detail.url, | |
| 1010 | count: shownBotComments + COMMENT_PAGE, | |
| 1011 | }) | |
| 1012 | } | |
| 1013 | > | |
| 1014 | Show {Math.min(hiddenBotCommentCount, COMMENT_PAGE)} older bot comment | |
| 1015 | {hiddenBotCommentCount === 1 ? "" : "s"} ({hiddenBotCommentCount} hidden) | |
| 1016 | </Button> | |
| 1017 | ) : null} | |
| 1018 | {shownBotComments > COMMENT_PAGE ? ( | |
| 1019 | <Button | |
| 1020 | size="sm" | |
| 1021 | variant="ghost" | |
| 1022 | className="w-full" | |
| 1023 | onClick={() => setShownBots({ url: detail.url, count: COMMENT_PAGE })} | |
| 1024 | > | |
| 1025 | Show only {COMMENT_PAGE} recent bot comments | |
| 1026 | </Button> | |
| 1027 | ) : null} | |
| 1028 | </div> | |
| 1029 | </CommentGroup> | |
| 1030 | ) : null} | |
| 1031 | {finishedComments.length > 0 ? ( | |
| 1032 | <CommentGroup | |
| 1033 | key={detail.url} | |
| 1034 | label={`${finishedComments.length} resolved or dismissed comment${finishedComments.length === 1 ? "" : "s"}`} | |
| 1035 | comments={finishedComments} | |
| 1036 | detail={detail} | |
| 1037 | > | |
| 1038 | <div className="space-y-2 pt-2"> | |
| 1039 | {orderPullRequestComments(finishedComments, commentOrder).map((comment) => { | |
| 1040 | const thread = threadByCommentId.get(comment.id); | |
| 1041 | return ( | |
| 1042 | <CollapsedComment | |
| 1043 | key={comment.id} | |
| 1044 | comment={comment} | |
| 1045 | editing={commentEditing} | |
| 1046 | detail={detail} | |
| 1047 | thread={thread} | |
| 1048 | label={thread?.isResolved ? "Resolved" : "Review dismissed"} | |
| 1049 | body={visibleBody(comment.body)} | |
| 1050 | reactionBar={ | |
| 1051 | <PullRequestReactionBar | |
| 1052 | className="ml-auto justify-end" | |
| 1053 | reactions={comment.reactions ?? []} | |
| 1054 | canReact={detail.capabilities.reactions === true} | |
| 1055 | subjectId={comment.id} | |
| 1056 | environmentId={environmentId} | |
| 1057 | reference={reference} | |
| 1058 | onRefresh={onRefresh} | |
| 1059 | /> | |
| 827 | 1060 | } |
| 828 | 1061 | /> |
| 829 | <TooltipPopup side="top">{comment.path}</TooltipPopup> | |
| 830 | </Tooltip> | |
| 831 | ) : null} | |
| 832 | {/* A verdict usually carries no words, and an empty markdown block reads as | |
| 833 | a card somebody forgot to fill in — the badge above already said it. | |
| 834 | Kept where this reader may rewrite the remark: the pencil lives in here, | |
| 835 | and hiding the block would take away the only way back to it. */} | |
| 836 | {body === null && !commentEditing.canEdit(comment) ? null : ( | |
| 837 | <CommentBody className="mt-2" comment={comment} editing={commentEditing} /> | |
| 838 | )} | |
| 839 | {body === null ? null : reactionBar} | |
| 840 | </article> | |
| 841 | ); | |
| 842 | })} | |
| 843 | {commentOrder === "newest" ? showOldestCommentsButton : null} | |
| 1062 | ); | |
| 1063 | })} | |
| 1064 | </div> | |
| 1065 | </CommentGroup> | |
| 1066 | ) : null} | |
| 844 | 1067 | </div> |
| 845 | 1068 | )} |
| 846 | 1069 | </> |
apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx+37-40| ... | ... | @@ -217,7 +217,7 @@ function ConversationCard({ |
| 217 | 217 | return ( |
| 218 | 218 | <article className="group py-2"> |
| 219 | 219 | <div className="px-2"> |
| 220 | <div className="flex min-w-0 items-start gap-2"> | |
| 220 | <div className="flex min-w-0 flex-wrap items-start gap-2"> | |
| 221 | 221 | <div className="min-w-0 flex-1"> |
| 222 | 222 | <div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs"> |
| 223 | 223 | <ActorName actor={event.actor} /> |
| ... | ... | @@ -245,6 +245,17 @@ function ConversationCard({ |
| 245 | 245 | <PencilIcon className="size-3" /> |
| 246 | 246 | </Button> |
| 247 | 247 | ) : null} |
| 248 | {reactions.canReact || event.reactions.length > 0 ? ( | |
| 249 | <PullRequestReactionBar | |
| 250 | className="ml-auto justify-end" | |
| 251 | reactions={event.reactions} | |
| 252 | canReact={reactions.canReact} | |
| 253 | subjectId={event.id} | |
| 254 | environmentId={reactions.environmentId} | |
| 255 | reference={reactions.reference} | |
| 256 | onRefresh={reactions.onRefresh} | |
| 257 | /> | |
| 258 | ) : null} | |
| 248 | 259 | <OpenOnHostButton url={event.url} onOpen={onOpen} /> |
| 249 | 260 | </div> |
| 250 | 261 | </div> |
| ... | ... | @@ -272,18 +283,6 @@ function ConversationCard({ |
| 272 | 283 | /> |
| 273 | 284 | </div> |
| 274 | 285 | ) : null} |
| 275 | {reactions.canReact || event.reactions.length > 0 ? ( | |
| 276 | <div className="px-2 pb-2"> | |
| 277 | <PullRequestReactionBar | |
| 278 | reactions={event.reactions} | |
| 279 | canReact={reactions.canReact} | |
| 280 | subjectId={event.id} | |
| 281 | environmentId={reactions.environmentId} | |
| 282 | reference={reactions.reference} | |
| 283 | onRefresh={reactions.onRefresh} | |
| 284 | /> | |
| 285 | </div> | |
| 286 | ) : null} | |
| 287 | 286 | </article> |
| 288 | 287 | ); |
| 289 | 288 | } |
| ... | ... | @@ -469,14 +468,14 @@ function ReviewVerdictEvent({ |
| 469 | 468 | }) { |
| 470 | 469 | return ( |
| 471 | 470 | <div className="group relative mb-5 pl-12 [contain-intrinsic-block-size:48px] [content-visibility:auto]"> |
| 472 | {/* Pinned rather than centred: this row grows with a body and a reaction bar, and a | |
| 473 | centred avatar drifts down beside them instead of sitting by the name. */} | |
| 471 | {/* Pinned rather than centred: this row grows with a body, and a | |
| 472 | centred avatar drifts down beside it instead of sitting by the name. */} | |
| 474 | 473 | <ActorTimelineMarker |
| 475 | 474 | actors={event.actor ? [event.actor] : []} |
| 476 | 475 | className="top-6" |
| 477 | 476 | fallback={<PullRequestReviewOutcomeIcon outcome={outcome} />} |
| 478 | 477 | /> |
| 479 | <div className="flex min-w-0 items-start gap-2 py-1.5"> | |
| 478 | <div className="flex min-w-0 flex-wrap items-start gap-2 py-1.5"> | |
| 480 | 479 | <div className="min-w-0 flex-1"> |
| 481 | 480 | <div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs"> |
| 482 | 481 | <ActorName actor={event.actor} /> |
| ... | ... | @@ -504,9 +503,6 @@ function ReviewVerdictEvent({ |
| 504 | 503 | <TooltipPopup>{pullRequestReviewOutcomeStaleLabel(outcome)}</TooltipPopup> |
| 505 | 504 | </Tooltip> |
| 506 | 505 | </div> |
| 507 | {/* The reaction bar rides this line rather than taking one of its own. Its add button | |
| 508 | is invisible until hovered but still occupies `h-6`, and under a verdict — usually a | |
| 509 | single line with no body — a row of that reserved on its own reads as a hole. */} | |
| 510 | 506 | <div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"> |
| 511 | 507 | <PullRequestMetaLine className="flex-wrap text-[11px] text-muted-foreground"> |
| 512 | 508 | <span>{formatRelativeTimeLabel(event.at)}</span> |
| ... | ... | @@ -517,31 +513,32 @@ function ReviewVerdictEvent({ |
| 517 | 513 | </span> |
| 518 | 514 | ) : null} |
| 519 | 515 | </PullRequestMetaLine> |
| 520 | {reactions.canReact || event.reactions.length > 0 ? ( | |
| 521 | <PullRequestReactionBar | |
| 522 | reactions={event.reactions} | |
| 523 | canReact={reactions.canReact} | |
| 524 | subjectId={event.id} | |
| 525 | environmentId={reactions.environmentId} | |
| 526 | reference={reactions.reference} | |
| 527 | onRefresh={reactions.onRefresh} | |
| 528 | /> | |
| 529 | ) : null} | |
| 530 | 516 | </div> |
| 531 | {/* An approval usually carries no words. When it does they are the review, so they stay | |
| 532 | visible rather than being folded away with the ordinary conversation. */} | |
| 533 | {event.body ? ( | |
| 534 | <TimelineBody | |
| 535 | body={event.body} | |
| 536 | markdown={event.markdown} | |
| 537 | cwd={cwd} | |
| 538 | environmentId={reactions.environmentId} | |
| 539 | threadRef={reactions.threadRef} | |
| 540 | /> | |
| 541 | ) : null} | |
| 542 | 517 | </div> |
| 518 | {reactions.canReact || event.reactions.length > 0 ? ( | |
| 519 | <PullRequestReactionBar | |
| 520 | className="ml-auto justify-end" | |
| 521 | reactions={event.reactions} | |
| 522 | canReact={reactions.canReact} | |
| 523 | subjectId={event.id} | |
| 524 | environmentId={reactions.environmentId} | |
| 525 | reference={reactions.reference} | |
| 526 | onRefresh={reactions.onRefresh} | |
| 527 | /> | |
| 528 | ) : null} | |
| 543 | 529 | <OpenOnHostButton url={event.url} onOpen={onOpen} /> |
| 544 | 530 | </div> |
| 531 | {/* An approval usually carries no words. When it does they are the review, so they stay | |
| 532 | visible rather than being folded away with the ordinary conversation. */} | |
| 533 | {event.body ? ( | |
| 534 | <TimelineBody | |
| 535 | body={event.body} | |
| 536 | markdown={event.markdown} | |
| 537 | cwd={cwd} | |
| 538 | environmentId={reactions.environmentId} | |
| 539 | threadRef={reactions.threadRef} | |
| 540 | /> | |
| 541 | ) : null} | |
| 545 | 542 | </div> |
| 546 | 543 | ); |
| 547 | 544 | } |
apps/web/src/components/pullRequest/pullRequestPresentation.tsx+4-1| ... | ... | @@ -422,7 +422,10 @@ export function PullRequestActorLabel({ |
| 422 | 422 | > |
| 423 | 423 | {label} |
| 424 | 424 | </TooltipTrigger> |
| 425 | <TooltipPopup side="top">{profileUrl ? `Open ${login}'s profile` : login}</TooltipPopup> | |
| 425 | <TooltipPopup side="top"> | |
| 426 | {actor?.name && actor.name !== login ? `${actor.name} (@${login})` : login} | |
| 427 | {profileUrl ? " · Open profile" : ""} | |
| 428 | </TooltipPopup> | |
| 426 | 429 | </Tooltip> |
| 427 | 430 | ); |
| 428 | 431 | } |
packages/contracts/src/pullRequest.ts+2| ... | ... | @@ -121,6 +121,8 @@ export const PullRequestBaseComparison = Schema.Literals(["up-to-date", "behind" |
| 121 | 121 | export type PullRequestBaseComparison = typeof PullRequestBaseComparison.Type; |
| 122 | 122 | |
| 123 | 123 | export const PullRequestActor = Schema.Struct({ |
| 124 | /** Present when the host identifies an automated account. */ | |
| 125 | isBot: Schema.optional(Schema.Boolean), | |
| 124 | 126 | login: TrimmedNonEmptyString, |
| 125 | 127 | name: Schema.NullOr(Schema.String), |
| 126 | 128 | /** Null where a host does not report one, which is what the initials fall back to. */ |