Skip to content
3 changes: 3 additions & 0 deletions apps/server/src/pullRequest/GitHubPullRequestCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2071,6 +2071,7 @@ export const make = Effect.gen(function* () {
});
const entries: GitHubReviewThreadEntry[] = [];
const avatarsByLogin = new Map<string, string>();
const botLogins = new Set<string>();
const commitStats = new Map<
string,
{ readonly additions: number; readonly deletions: number }
Expand All @@ -2087,6 +2088,7 @@ export const make = Effect.gen(function* () {
do {
const read: GitHubReviewThreadPage = yield* threadPage(cursor);
entries.push(...read.threads);
for (const login of read.botLogins) botLogins.add(login);
for (const [login, avatarUrl] of read.avatarsByLogin)
avatarsByLogin.set(login, avatarUrl);
// The roster, the commits and the viewer's standing travel with every page, and the
Expand Down Expand Up @@ -2152,6 +2154,7 @@ export const make = Effect.gen(function* () {
reactionsById,
reviewers,
avatarsByLogin,
botLogins,
commitStats,
commits,
viewer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,7 @@ describe("getChangeRequest commits", () => {
reactionsById: new Map<string, ReadonlyArray<PullRequestReaction>>(),
reviewers: [],
avatarsByLogin: new Map<string, string>(),
botLogins: new Set<string>(),
commitStats: new Map<string, { readonly additions: number; readonly deletions: number }>(),
viewer: { canUpdate: true, didAuthor: false },
};
Expand Down Expand Up @@ -807,7 +808,7 @@ describe("getChangeRequestActivity dismissed reviews", () => {
const dismissedReview = (body: string) => ({
id: "PRR_1",
kind: "review" as const,
author: null,
author: { login: "macroscopeapp", name: null, avatarUrl: null },
body,
createdAt: "2026-07-03T00:00:00Z",
url: null,
Expand All @@ -824,6 +825,7 @@ describe("getChangeRequestActivity dismissed reviews", () => {
reactionsById: new Map(),
reviewers: [],
avatarsByLogin: new Map(),
botLogins: new Set(["macroscopeapp"]),
commitStats: new Map(),
commits: [],
viewer: { canUpdate: true, didAuthor: false },
Expand All @@ -850,6 +852,7 @@ describe("getChangeRequestActivity dismissed reviews", () => {
readActivity.pipe(
Effect.map((activity) => {
expect(activity.comments[0]?.body).toBe("Dismissing prior approval to re-evaluate 9b66581");
expect(activity.comments[0]?.author?.isBot).toBe(true);
}),
Effect.provide(layerFor("<!-- Macroscope (Approvability) review body marker -->")),
),
Expand Down
35 changes: 30 additions & 5 deletions apps/server/src/pullRequest/GitHubPullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,11 @@ function withAvatar(
actor: PullRequestActor | null,
avatarsByLogin: ReadonlyMap<string, string>,
host: string,
botLogins?: ReadonlySet<string>,
): PullRequestActor | null {
if (actor === null || actor.avatarUrl !== null) return actor;
if (actor === null) return actor;
if (botLogins?.has(actor.login)) actor = { ...actor, isBot: true };
if (actor.avatarUrl !== null) return actor;
const avatarUrl = avatarsByLogin.get(actor.login) ?? loginAvatarUrl(actor.login, host);
return avatarUrl === null ? actor : { ...actor, avatarUrl };
}
Expand Down Expand Up @@ -450,6 +453,7 @@ export const make = Effect.gen(function* () {
truncated: true,
reviewers: [],
avatarsByLogin: new Map<string, string>(),
botLogins: new Set<string>(),
commitStats: new Map<
string,
{ readonly additions: number; readonly deletions: number }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand All @@ -463,7 +467,12 @@ export const make = Effect.gen(function* () {
).pipe(
Effect.mapError(fail("getChangeRequestActivity")),
Effect.map(([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({
author: withAvatar(pullRequest.author, reviewThreads.avatarsByLogin, input.host),
author: withAvatar(
pullRequest.author,
reviewThreads.avatarsByLogin,
input.host,
reviewThreads.botLogins,
),
reviewers: reviewThreads.reviewers,
reactions: reviewThreads.reactions,
commits: (reviewThreads.commits.length > 0
Expand All @@ -473,7 +482,13 @@ export const make = Effect.gen(function* () {
...commit,
...reviewThreads.commitStats.get(commit.oid),
authors: commit.authors?.map(
(author) => withAvatar(author, reviewThreads.avatarsByLogin, input.host) ?? author,
(author) =>
withAvatar(
author,
reviewThreads.avatarsByLogin,
input.host,
reviewThreads.botLogins,
) ?? author,
),
})),
comments: [...pullRequest.comments, ...reviewThreads.comments]
Expand All @@ -489,7 +504,12 @@ export const make = Effect.gen(function* () {
rendersEmpty(comment.body)
? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body)
: comment.body,
author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host),
author: withAvatar(
comment.author,
reviewThreads.avatarsByLogin,
input.host,
reviewThreads.botLogins,
),
// A comment out of `gh pr view --json` carries none of its own: that read
// reports no reaction at all, so they arrive from the GraphQL page by node id.
reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [],
Expand All @@ -503,7 +523,12 @@ export const make = Effect.gen(function* () {
...thread,
comments: thread.comments.map((comment) => ({
...comment,
author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host),
author: withAvatar(
comment.author,
reviewThreads.avatarsByLogin,
input.host,
reviewThreads.botLogins,
),
})),
})),
})),
Expand Down
16 changes: 14 additions & 2 deletions apps/server/src/pullRequest/gitHubPullRequestJson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,14 +436,26 @@ describe("review thread decoding", () => {
requested: [{ login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" }],
// An app that has reviewed is no longer an outstanding request, which is why asking
// only for requests reported nobody on a pull request a bot had reviewed.
reviewed: [{ login: "macroscopeapp", avatarUrl: "https://avatars/in/900172.png" }],
reviewed: [
{
__typename: "Bot",
login: "macroscopeapp",
avatarUrl: "https://avatars/in/900172.png",
},
],
}),
),
);

expect(result.botLogins).toEqual(new Set(["macroscopeapp"]));
expect(result.reviewers).toEqual([
{ login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" },
{ login: "macroscopeapp", name: null, avatarUrl: "https://avatars/in/900172.png" },
{
login: "macroscopeapp",
name: null,
avatarUrl: "https://avatars/in/900172.png",
isBot: true,
},
]);
});

Expand Down
34 changes: 24 additions & 10 deletions apps/server/src/pullRequest/gitHubPullRequestJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import { dedupeChecks } from "./pullRequestChecks.ts";
* release that adds a conclusion or a review state must not fail the whole payload.
*/
const RawActorSchema = Schema.Struct({
__typename: Schema.optional(Schema.String),
is_bot: Schema.optional(Schema.Boolean),
/**
* Optional because a review can be requested from a team or a mannequin, which the query has
* no fragment for and GraphQL answers with an empty object. A reviewer with no login names
Expand Down Expand Up @@ -480,7 +482,7 @@ const RawReviewThreadsSchema = Schema.Struct({
),
),
/**
* Reviews for their reactions alone: the words and the verdict arrive with
* Reviews for their reactions and actor identity: the words and the verdict arrive with
* `gh pr view --json reviews`, which reports no reaction of any kind.
*/
reviews: Schema.optional(
Expand All @@ -489,6 +491,7 @@ const RawReviewThreadsSchema = Schema.Struct({
nodes: Schema.Array(
Schema.Struct({
id: Schema.optional(Schema.NullOr(Schema.String)),
author: Schema.optional(Schema.NullOr(RawActorSchema)),
reactionGroups: RawReactionGroupsSchema,
}),
),
Expand Down Expand Up @@ -676,7 +679,7 @@ export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = fals
number
title
url
author { login avatarUrl ... on User { name } }
author { __typename login avatarUrl ... on User { name } }
headRefName
baseRefName
state
Expand Down Expand Up @@ -736,28 +739,28 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin
comments(first: 10) {
totalCount
pageInfo { hasNextPage endCursor }
nodes { id author { login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} }
nodes { id author { __typename login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} }
}
}
}
viewerCanUpdate
viewerDidAuthor
author { login avatarUrl }
author { __typename login avatarUrl }
${REACTION_GROUPS_FIELDS}
comments(first: ${GRAPHQL_PAGE_SIZE}) {
nodes { id author { login avatarUrl } ${REACTION_GROUPS_FIELDS} }
nodes { id author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} }
}
reviews(first: ${GRAPHQL_PAGE_SIZE}) { nodes { id ${REACTION_GROUPS_FIELDS} } }
reviews(first: ${GRAPHQL_PAGE_SIZE}) { nodes { id author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} } }
reviewRequests(first: 50) {
nodes {
requestedReviewer {
... on User { login name avatarUrl }
... on Bot { login avatarUrl }
... on Bot { __typename login avatarUrl }
}
}
}
latestReviews(first: 50) {
nodes { author { login avatarUrl } }
nodes { author { __typename login avatarUrl } }
}
reviewDismissals: timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}) {
pageInfo { hasNextPage endCursor }
Expand Down Expand Up @@ -793,7 +796,7 @@ export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($owner: String!, $nam
pullRequest { id }
comments(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes { id author { login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} }
nodes { id author { __typename login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} }
}
}
}
Expand Down Expand Up @@ -1134,7 +1137,12 @@ function toActor(raw: Schema.Schema.Type<typeof RawActorSchema> | null | undefin
const login = trimmed(raw?.login);
return login === null
? null
: { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatarUrl) };
: {
login,
name: trimmed(raw?.name),
avatarUrl: trimmed(raw?.avatarUrl),
...(raw?.__typename === "Bot" || raw?.is_bot === true ? { isBot: true } : {}),
};
}

function toCommitActor(
Expand Down Expand Up @@ -1753,6 +1761,7 @@ export interface GitHubReviewThreadComments {
* so an app's avatar arrives the same way a person's does.
*/
readonly avatarsByLogin: ReadonlyMap<string, string>;
readonly botLogins: ReadonlySet<string>;
/** Per-commit line counts carried by the same bounded pull-request query. */
readonly commitStats: ReadonlyMap<
string,
Expand Down Expand Up @@ -1791,6 +1800,7 @@ export interface GitHubReviewThreadPage {
readonly reactionsById: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
readonly reviewers: ReadonlyArray<PullRequestActor>;
readonly avatarsByLogin: ReadonlyMap<string, string>;
readonly botLogins: ReadonlySet<string>;
readonly commitStats: ReadonlyMap<
string,
{ readonly additions: number; readonly deletions: number }
Expand Down Expand Up @@ -1928,16 +1938,19 @@ export function decodeReviewThreadsJson(
});
const pullRequest = decoded.success.data.repository.pullRequest;
const avatarsByLogin = new Map<string, string>();
const botLogins = new Set<string>();
for (const raw of [
pullRequest.author,
...(pullRequest.comments?.nodes ?? []).map((node) => node.author),
...(pullRequest.reviews?.nodes ?? []).map((node) => node.author),
...(pullRequest.reviewRequests?.nodes ?? []).map((node) => node.requestedReviewer),
...(pullRequest.latestReviews?.nodes ?? []).map((node) => node.author),
...threads.nodes.flatMap((thread) => thread.comments.nodes.map((comment) => comment.author)),
]) {
const login = trimmed(raw?.login);
const avatarUrl = trimmed(raw?.avatarUrl);
if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl);
if (login !== null && toActor(raw)?.isBot) botLogins.add(login);
}
const reviewers = new Map<string, PullRequestActor>();
for (const raw of [
Expand Down Expand Up @@ -1996,6 +2009,7 @@ export function decodeReviewThreadsJson(
reactionsById,
reviewers: [...reviewers.values()],
avatarsByLogin,
botLogins,
commitStats,
commits,
viewer: toPullRequestViewerFields(pullRequest),
Expand Down
61 changes: 61 additions & 0 deletions apps/web/src/components/pullRequest/PullRequestCommentBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useEffect, useId, useRef, useState, type ComponentProps } from "react";

import { cn } from "~/lib/utils";
import { Button } from "../ui/button";
import { PullRequestMarkdown } from "./PullRequestMarkdown";

/** Keep the complete markdown intact while limiting long reports to a readable preview. */
export function PullRequestCommentBody({
className,
...props
}: ComponentProps<typeof PullRequestMarkdown>) {
const [expanded, setExpanded] = useState(false);
const [overflowing, setOverflowing] = useState(false);
const content = useRef<HTMLDivElement>(null);
const id = useId();

useEffect(() => {
const element = content.current;
if (!element) return;
const measure = () => setOverflowing(element.getBoundingClientRect().height > 240);
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, []);

return (
<div className={cn("min-w-0", className)}>
<div
id={id}
className={cn("relative", !expanded && "max-h-[240px] overflow-hidden")}
onFocusCapture={() => setExpanded(true)}
>
<div ref={content}>
<PullRequestMarkdown {...props} />
</div>
{overflowing && !expanded ? (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-background to-transparent"
/>
) : null}
</div>
{overflowing ? (
<Button
size="xs"
variant="ghost"
className="mt-2 text-muted-foreground"
aria-expanded={expanded}
aria-controls={id}
onClick={() => {
if (expanded) content.current?.parentElement?.scrollIntoView({ block: "nearest" });
setExpanded(!expanded);
}}
>
{expanded ? "Show less" : "Show full comment"}
</Button>
) : null}
</div>
);
}
15 changes: 2 additions & 13 deletions apps/web/src/components/pullRequest/PullRequestReactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,7 @@ function reactionsSignature(reactions: ReadonlyArray<PullRequestReaction>): stri
.join(" ");
}

/**
* The reaction pills under a remark, and the picker that adds one. The same bar serves the
* description, a conversation comment and a review thread's comments: what differs between them
* is only which subject the host is told about.
*
* The add button is revealed by hovering the remark it belongs to, the way GitHub's is, so the
* parent must carry `group`. It stays put once there is something to press it beside, while the
* picker is open, and whenever it is focused — a control only a mouse can find is no control.
*/
/** Reaction counts and an always-visible picker, routed to the supplied host subject. */
export function PullRequestReactionBar({
reactions,
canReact,
Expand Down Expand Up @@ -98,7 +90,7 @@ export function PullRequestReactionBar({
if (shown.length === 0 && !canReact) return null;

return (
<div className={cn("flex flex-wrap items-center gap-1", className)}>
<div className={cn("flex min-w-0 max-w-full flex-wrap items-center gap-1", className)}>
{shown.map((reaction) => (
<Tooltip key={reaction.content}>
<TooltipTrigger
Expand Down Expand Up @@ -136,9 +128,6 @@ export function PullRequestReactionBar({
className={cn(
PILL_CLASS,
"border-border/70 px-1.5 text-muted-foreground hover:border-primary/60 hover:text-foreground",
shown.length === 0 &&
!pickerOpen &&
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100",
)}
/>
}
Expand Down
Loading
Loading