From f0515948e0b060779b0c519808b5040949f229fe Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Fri, 17 Apr 2026 22:25:56 -0400 Subject: [PATCH 1/5] feat(dashboard): activity timeline, repo shell, and GitHub helpers - Issue/PR detail activity timelines, grouped label events, close reasons - Comment reaction bar; sidebar section titleRight for contributors count - Repo activity card empty states; header star/fork actions - Extend github.functions/types and icons for new UI --- .../details/comment-reaction-bar.tsx | 198 +++++++ .../components/details/detail-activity.tsx | 125 ++++- .../src/components/details/detail-sidebar.tsx | 15 +- .../details/grouped-label-event.tsx | 250 +++++++-- .../issues/detail/issue-detail-activity.tsx | 155 +++++- .../issues/detail/issue-detail-page.tsx | 3 + .../pulls/detail/pull-detail-activity.tsx | 220 +++++++- .../components/repo/repo-activity-cards.tsx | 2 +- .../src/components/repo/repo-header.tsx | 141 +++-- .../components/repo/repo-overview-page.tsx | 2 +- .../src/components/repo/repo-sidebar.tsx | 59 ++- .../repo/repo-star-fork-actions.tsx | 148 ++++++ apps/dashboard/src/lib/github.functions.ts | 485 +++++++++++++++++- apps/dashboard/src/lib/github.types.ts | 38 ++ .../src/lib/timeline-close-reason.ts | 55 ++ packages/icons/src/index.ts | 3 + 16 files changed, 1743 insertions(+), 156 deletions(-) create mode 100644 apps/dashboard/src/components/details/comment-reaction-bar.tsx create mode 100644 apps/dashboard/src/components/repo/repo-star-fork-actions.tsx create mode 100644 apps/dashboard/src/lib/timeline-close-reason.ts diff --git a/apps/dashboard/src/components/details/comment-reaction-bar.tsx b/apps/dashboard/src/components/details/comment-reaction-bar.tsx new file mode 100644 index 0000000..50d4e9b --- /dev/null +++ b/apps/dashboard/src/components/details/comment-reaction-bar.tsx @@ -0,0 +1,198 @@ +import { toast } from "@diffkit/ui/components/sonner"; +import { cn } from "@diffkit/ui/lib/utils"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useRef } from "react"; +import { toggleIssueCommentReaction } from "#/lib/github.functions"; +import { type GitHubQueryScope, githubQueryKeys } from "#/lib/github.query"; +import type { + CommentReactionContent, + CommentReactionSummary, + IssuePageData, + PullPageData, +} from "#/lib/github.types"; +import { checkPermissionWarning } from "#/lib/warning-store"; + +const REACTION_EMOJI: Record = { + "+1": "πŸ‘", + "-1": "πŸ‘Ž", + laugh: "πŸ˜„", + confused: "πŸ™", + heart: "❀️", + hooray: "πŸŽ‰", + rocket: "πŸš€", + eyes: "πŸ‘€", +}; + +/** Matches GitHub reaction types; order is πŸ‘ πŸ‘Ž πŸ˜„ πŸŽ‰ πŸ™ ❀️ πŸš€ πŸ‘€ */ +const QUICK_REACTIONS: { content: CommentReactionContent; emoji: string }[] = [ + { content: "+1", emoji: REACTION_EMOJI["+1"] }, + { content: "-1", emoji: REACTION_EMOJI["-1"] }, + { content: "laugh", emoji: REACTION_EMOJI.laugh }, + { content: "hooray", emoji: REACTION_EMOJI.hooray }, + { content: "confused", emoji: REACTION_EMOJI.confused }, + { content: "heart", emoji: REACTION_EMOJI.heart }, + { content: "rocket", emoji: REACTION_EMOJI.rocket }, + { content: "eyes", emoji: REACTION_EMOJI.eyes }, +]; + +function patchCommentReactions( + prev: IssuePageData | PullPageData | undefined, + commentId: number, + content: CommentReactionContent, + remove: boolean, +): IssuePageData | PullPageData | undefined { + if (!prev?.comments?.length) { + return prev; + } + + let changed = false; + const comments = prev.comments.map((c) => { + if (c.id !== commentId) { + return c; + } + changed = true; + const base = c.reactions ?? { counts: {}, viewerReacted: [] }; + const counts = { ...base.counts }; + const viewerReacted = [...base.viewerReacted]; + if (remove) { + counts[content] = Math.max(0, (counts[content] ?? 0) - 1); + const i = viewerReacted.indexOf(content); + if (i >= 0) { + viewerReacted.splice(i, 1); + } + } else { + counts[content] = (counts[content] ?? 0) + 1; + if (!viewerReacted.includes(content)) { + viewerReacted.push(content); + } + } + return { ...c, reactions: { counts, viewerReacted } }; + }); + + if (!changed) { + return prev; + } + return { ...prev, comments }; +} + +export function IssueCommentReactionBar({ + owner, + repo, + issueNumber, + commentId, + commentGraphqlId, + scope, + reactions, +}: { + owner: string; + repo: string; + issueNumber: number; + commentId: number; + commentGraphqlId: string; + scope: GitHubQueryScope; + reactions?: CommentReactionSummary; +}) { + const queryClient = useQueryClient(); + const flight = useRef(false); + + const issuePageKey = githubQueryKeys.issues.page(scope, { + owner, + repo, + issueNumber, + }); + const pullPageKey = githubQueryKeys.pulls.page(scope, { + owner, + repo, + pullNumber: issueNumber, + }); + + const applyOptimistic = useCallback( + (content: CommentReactionContent, remove: boolean) => { + const prevIssue = queryClient.getQueryData(issuePageKey); + const prevPull = queryClient.getQueryData(pullPageKey); + queryClient.setQueryData( + issuePageKey, + patchCommentReactions(prevIssue, commentId, content, remove), + ); + queryClient.setQueryData( + pullPageKey, + patchCommentReactions(prevPull, commentId, content, remove), + ); + return { prevIssue, prevPull }; + }, + [commentId, issuePageKey, pullPageKey, queryClient], + ); + + const rollback = useCallback( + (snapshot: { + prevIssue: IssuePageData | undefined; + prevPull: PullPageData | undefined; + }) => { + queryClient.setQueryData(issuePageKey, snapshot.prevIssue); + queryClient.setQueryData(pullPageKey, snapshot.prevPull); + }, + [issuePageKey, pullPageKey, queryClient], + ); + + const handleToggle = async (content: CommentReactionContent) => { + if (flight.current) { + return; + } + const remove = reactions?.viewerReacted.includes(content) ?? false; + flight.current = true; + const snapshot = applyOptimistic(content, remove); + try { + const result = await toggleIssueCommentReaction({ + data: { + owner, + repo, + issueNumber, + commentId, + commentGraphqlId, + content, + remove, + }, + }); + if (!result.ok) { + rollback(snapshot); + toast.error(result.error); + checkPermissionWarning(result, `${owner}/${repo}`); + } + } catch { + rollback(snapshot); + toast.error("Failed to update reaction"); + } finally { + flight.current = false; + } + }; + + return ( +
+ {QUICK_REACTIONS.map(({ content, emoji }) => { + const count = reactions?.counts[content] ?? 0; + const active = reactions?.viewerReacted.includes(content) ?? false; + return ( + + ); + })} +
+ ); +} diff --git a/apps/dashboard/src/components/details/detail-activity.tsx b/apps/dashboard/src/components/details/detail-activity.tsx index 3162a08..1afb2f9 100644 --- a/apps/dashboard/src/components/details/detail-activity.tsx +++ b/apps/dashboard/src/components/details/detail-activity.tsx @@ -1,8 +1,18 @@ import { + ChevronDownIcon, CommentIcon, GitPullRequestClosedIcon, GitPullRequestIcon, + IssueClosedCompletedIcon, + IssueClosedNotPlannedIcon, + IssuesIcon, } from "@diffkit/icons"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@diffkit/ui/components/dropdown-menu"; import { MarkdownEditor, type MentionCandidate, @@ -11,7 +21,11 @@ import { toast } from "@diffkit/ui/components/sonner"; import { Spinner } from "@diffkit/ui/components/spinner"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo, useState } from "react"; -import { createComment, updatePullState } from "#/lib/github.functions"; +import { + createComment, + updateIssueState, + updatePullState, +} from "#/lib/github.functions"; import { type GitHubQueryScope, githubQueryKeys, @@ -47,6 +61,7 @@ export function DetailCommentBox({ scope, involvedUsers, pullState, + issueState, }: { owner: string; repo: string; @@ -54,10 +69,12 @@ export function DetailCommentBox({ scope: GitHubQueryScope; involvedUsers?: GitHubActor[]; pullState?: "open" | "closed"; + issueState?: "open" | "closed"; }) { const [value, setValue] = useState(""); const [isSending, setIsSending] = useState(false); const [isTogglingState, setIsTogglingState] = useState(false); + const [isTogglingIssueState, setIsTogglingIssueState] = useState(false); const [mentionActivated, setMentionActivated] = useState(false); const queryClient = useQueryClient(); @@ -164,6 +181,42 @@ export function DetailCommentBox({ } }; + const runIssueStateChange = async ( + next: "open" | "closed", + closeReason?: "completed" | "not_planned", + ) => { + setIsTogglingIssueState(true); + try { + if (value.trim()) { + await createComment({ + data: { owner, repo, issueNumber, body: value.trim() }, + }); + setValue(""); + } + const result = await updateIssueState({ + data: { + owner, + repo, + issueNumber, + state: next, + ...(next === "closed" && closeReason ? { closeReason } : {}), + }, + }); + if (result.ok) { + void queryClient.invalidateQueries({ + queryKey: githubQueryKeys.all, + }); + } else { + toast.error(result.error); + checkPermissionWarning(result, `${owner}/${repo}`); + } + } catch { + toast.error(`Failed to ${next === "closed" ? "close" : "reopen"} issue`); + } finally { + setIsTogglingIssueState(false); + } + }; + return (
+ {issueState && + (issueState === "open" ? ( + + + + + + + void runIssueStateChange("closed", "completed") + } + > + + Close as completed + + + void runIssueStateChange("closed", "not_planned") + } + > + + Close as not planned + + + + ) : ( + + ))} {pullState && (
); diff --git a/apps/dashboard/src/components/details/grouped-label-event.tsx b/apps/dashboard/src/components/details/grouped-label-event.tsx index 3c4f249..574c58c 100644 --- a/apps/dashboard/src/components/details/grouped-label-event.tsx +++ b/apps/dashboard/src/components/details/grouped-label-event.tsx @@ -1,10 +1,14 @@ +import { cn } from "@diffkit/ui/lib/utils"; +import { Fragment, type ReactNode } from "react"; import { LabelPill } from "#/components/details/label-pill"; import type { GitHubActor, + GroupedIssueStateToggleEvent, GroupedLabelEvent, GroupedReviewRequestEvent, TimelineEvent, } from "#/lib/github.types"; +import { parseCloseReason } from "#/lib/timeline-close-reason"; const GROUP_THRESHOLD_MS = 60_000; @@ -15,11 +19,16 @@ type GroupedItem = type: "review_request_group"; date: string; data: GroupedReviewRequestEvent; + } + | { + type: "issue_state_toggle_group"; + date: string; + data: GroupedIssueStateToggleEvent; }; /** - * Groups consecutive label and review-request events by the same actor - * that occur within a short time window into single grouped items. + * Groups consecutive timeline events by the same actor within a short window: + * label changes, review requests, and reopen/close toggles. */ export function groupTimelineEvents< T extends { type: string; date: string; data: unknown }, @@ -38,59 +47,36 @@ export function groupTimelineEvents< const event = item.data as TimelineEvent; - const isLabel = event.event === "labeled" || event.event === "unlabeled"; - const isReviewRequest = - event.event === "review_requested" || - event.event === "review_request_removed"; + if (event.event === "labeled" || event.event === "unlabeled") { + const actor = event.actor; + const events: TimelineEvent[] = [event]; + let j = i + 1; + while (j < items.length) { + const next = items[j]; + if (next.type !== "event") break; - if (!isLabel && !isReviewRequest) { - result.push(item); - i++; - continue; - } + const nextEvent = next.data as TimelineEvent; + const nextIsLabel = + nextEvent.event === "labeled" || nextEvent.event === "unlabeled"; + if (!nextIsLabel) break; + if (nextEvent.actor?.login !== actor?.login) break; - // Collect consecutive events of the same kind by the same actor - const actor = event.actor; - const eventKind = isLabel ? "label" : "review_request"; - const events: TimelineEvent[] = [event]; - - let j = i + 1; - while (j < items.length) { - const next = items[j]; - if (next.type !== "event") break; - - const nextEvent = next.data as TimelineEvent; - const nextIsLabel = - nextEvent.event === "labeled" || nextEvent.event === "unlabeled"; - const nextIsReviewRequest = - nextEvent.event === "review_requested" || - nextEvent.event === "review_request_removed"; - const nextKind = nextIsLabel - ? "label" - : nextIsReviewRequest - ? "review_request" - : null; - - if (nextKind !== eventKind) break; - if (nextEvent.actor?.login !== actor?.login) break; - - const timeDiff = Math.abs( - new Date(nextEvent.createdAt).getTime() - - new Date(event.createdAt).getTime(), - ); - if (timeDiff > GROUP_THRESHOLD_MS) break; + const timeDiff = Math.abs( + new Date(nextEvent.createdAt).getTime() - + new Date(event.createdAt).getTime(), + ); + if (timeDiff > GROUP_THRESHOLD_MS) break; - events.push(nextEvent); - j++; - } + events.push(nextEvent); + j++; + } - if (events.length === 1) { - result.push(item); - i++; - continue; - } + if (events.length === 1) { + result.push(item); + i++; + continue; + } - if (eventKind === "label") { const added: { name: string; color: string }[] = []; const removed: { name: string; color: string }[] = []; for (const e of events) { @@ -103,7 +89,44 @@ export function groupTimelineEvents< date: item.date, data: { actor, added, removed, createdAt: item.date }, }); - } else { + i = j; + continue; + } + + if ( + event.event === "review_requested" || + event.event === "review_request_removed" + ) { + const actor = event.actor; + const events: TimelineEvent[] = [event]; + let j = i + 1; + while (j < items.length) { + const next = items[j]; + if (next.type !== "event") break; + + const nextEvent = next.data as TimelineEvent; + const nextIsReviewRequest = + nextEvent.event === "review_requested" || + nextEvent.event === "review_request_removed"; + if (!nextIsReviewRequest) break; + if (nextEvent.actor?.login !== actor?.login) break; + + const timeDiff = Math.abs( + new Date(nextEvent.createdAt).getTime() - + new Date(event.createdAt).getTime(), + ); + if (timeDiff > GROUP_THRESHOLD_MS) break; + + events.push(nextEvent); + j++; + } + + if (events.length === 1) { + result.push(item); + i++; + continue; + } + const requested: (GitHubActor | { login: string })[] = []; const removed: (GitHubActor | { login: string })[] = []; for (const e of events) { @@ -119,9 +142,54 @@ export function groupTimelineEvents< date: item.date, data: { actor, requested, removed, createdAt: item.date }, }); + i = j; + continue; } - i = j; + if (event.event === "reopened" || event.event === "closed") { + const actor = event.actor; + const events: TimelineEvent[] = [event]; + const firstTs = new Date(event.createdAt).getTime(); + let j = i + 1; + while (j < items.length) { + const next = items[j]; + if (next.type !== "event") break; + + const nextEvent = next.data as TimelineEvent; + if (nextEvent.event !== "reopened" && nextEvent.event !== "closed") { + break; + } + if (nextEvent.actor?.login !== actor?.login) break; + + const timeDiff = Math.abs( + new Date(nextEvent.createdAt).getTime() - firstTs, + ); + if (timeDiff > GROUP_THRESHOLD_MS) break; + + events.push(nextEvent); + j++; + } + + if (events.length >= 2) { + result.push({ + type: "issue_state_toggle_group" as const, + date: item.date, + data: { + actor, + events, + createdAt: item.date, + }, + }); + i = j; + } else { + result.push(item); + i++; + } + continue; + } + + result.push(item); + i++; } return result; @@ -202,6 +270,84 @@ export function GroupedReviewRequestDescription({ ); } +export function GroupedIssueStateToggleDescription({ + group, + subject, + mergeCloseReason, +}: { + group: GroupedIssueStateToggleEvent; + subject: "issue" | "pull"; + mergeCloseReason: (e: TimelineEvent) => TimelineEvent; +}) { + const merged = group.events.map(mergeCloseReason); + const noun = subject === "issue" ? "this issue" : "this pull request"; + + const segments: ReactNode[] = []; + for (let idx = 0; idx < merged.length; idx++) { + const ev = merged[idx]; + const isFirst = idx === 0; + + if (ev.event === "reopened") { + segments.push( + + + reopened + + {isFirst ? ` ${noun}` : " it"} + , + ); + } else { + const kind = parseCloseReason(ev.stateReason); + segments.push( + + + closed + + {isFirst ? ` ${noun}` : " it"} + {kind === "completed" && ( + <> + {" as "} + completed + + )} + {kind === "not_planned" && ( + <> + {" as "} + + not planned + + + )} + , + ); + } + } + + return ( + + {" "} + {segments.map((seg, idx) => ( + + {idx > 0 && (idx === segments.length - 1 ? " and " : ", ")} + {seg} + + ))} + + ); +} + function ActorMention({ actor, }: { diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx index b6219b6..3249ab9 100644 --- a/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx +++ b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx @@ -4,6 +4,8 @@ import { CommentIcon, EditIcon, GitPullRequestIcon, + IssueClosedCompletedIcon, + IssueClosedNotPlannedIcon, IssuesIcon, ReviewsIcon, UserAddIcon, @@ -13,6 +15,7 @@ import { cn } from "@diffkit/ui/lib/utils"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useRef, useState } from "react"; import { CommentMoreMenu } from "#/components/details/comment-more-menu"; +import { IssueCommentReactionBar } from "#/components/details/comment-reaction-bar"; import { CommentReplyForm } from "#/components/details/comment-reply-form"; import { buildCommentThreads } from "#/components/details/comment-threads"; import { @@ -20,6 +23,7 @@ import { DetailCommentBox, } from "#/components/details/detail-activity"; import { + GroupedIssueStateToggleDescription, GroupedLabelDescription, GroupedReviewRequestDescription, groupTimelineEvents, @@ -32,12 +36,17 @@ import type { CommentPagination, EventPagination, GitHubActor, + GroupedIssueStateToggleEvent, GroupedLabelEvent, GroupedReviewRequestEvent, IssueComment, IssuePageData, TimelineEvent, } from "#/lib/github.types"; +import { + mergeIssueStateIntoCloseEvent, + parseCloseReason, +} from "#/lib/timeline-close-reason"; const WINDOW_THRESHOLD = 25; const EDGE_SIZE = 10; @@ -51,6 +60,11 @@ type IssueTimelineItem = type: "review_request_group"; date: string; data: GroupedReviewRequestEvent; + } + | { + type: "issue_state_toggle_group"; + date: string; + data: GroupedIssueStateToggleEvent; }; function useWindowedTimeline( @@ -255,6 +269,9 @@ export function IssueDetailActivitySection({ issueNumber, scope, issueAuthor, + issueState, + issueClosedAt, + issueStateReason, viewerLogin, }: { comments?: IssueComment[]; @@ -268,6 +285,10 @@ export function IssueDetailActivitySection({ issueNumber: number; scope: GitHubQueryScope; issueAuthor: GitHubActor | null; + issueState?: "open" | "closed"; + /** Fallback when timeline omits `state_reason` on the close event */ + issueClosedAt?: string | null; + issueStateReason?: string | null; viewerLogin?: string; }) { const { repliesByCommentId, replyIds } = useMemo( @@ -360,6 +381,7 @@ export function IssueDetailActivitySection({ "event", "label_group", "review_request_group", + "issue_state_toggle_group", ]); const isEventLike = eventLikeTypes.has(item.type); const prevIsEventLike = @@ -382,6 +404,7 @@ export function IssueDetailActivitySection({ owner={owner} repo={repo} issueNumber={issueNumber} + scope={scope} viewerLogin={viewerLogin} /> ); @@ -452,7 +475,72 @@ export function IssueDetailActivitySection({ ); } - const event = item.data; + if (item.type === "issue_state_toggle_group") { + const group = item.data as GroupedIssueStateToggleEvent; + const hasActorAvatar = group.actor?.avatarUrl; + const mergedForIcon = group.events.map((e) => + e.event === "closed" + ? mergeIssueStateIntoCloseEvent(e, { + issueState, + issueClosedAt, + issueStateReason, + }) + : e, + ); + const lastEvent = mergedForIcon[mergedForIcon.length - 1]; + const icon = lastEvent ? getIssueEventIcon(lastEvent) : null; + const toggleKey = group.events.map((e) => e.id).join("-"); + return ( +
+ {hasActorAvatar ? ( + {group.actor?.login} + ) : ( +
+ {icon} +
+ )} + + + e.event === "closed" + ? mergeIssueStateIntoCloseEvent(e, { + issueState, + issueClosedAt, + issueStateReason, + }) + : e + } + /> + + + {formatRelativeTime(group.createdAt)} + +
+ ); + } + + const event = mergeIssueStateIntoCloseEvent(item.data, { + issueState, + issueClosedAt, + issueStateReason, + }); const icon = getIssueEventIcon(event); const description = getIssueEventDescription(event); const isCrossRef = @@ -519,6 +607,7 @@ export function IssueDetailActivitySection({ issueNumber={issueNumber} scope={scope} involvedUsers={getInvolvedUsers(issueAuthor, comments)} + issueState={issueState} />
@@ -530,6 +619,7 @@ function IssueCommentBubble({ owner, repo, issueNumber, + scope, viewerLogin, onReply, isReply, @@ -538,6 +628,7 @@ function IssueCommentBubble({ owner: string; repo: string; issueNumber: number; + scope: GitHubQueryScope; viewerLogin?: string; onReply?: () => void; isReply?: boolean; @@ -604,6 +695,17 @@ function IssueCommentBubble({ {comment.body} + {comment.graphqlId ? ( + + ) : null} ); } @@ -615,6 +717,7 @@ function IssueCommentWithThread({ owner, repo, issueNumber, + scope, viewerLogin, }: { comment: IssueComment; @@ -623,6 +726,7 @@ function IssueCommentWithThread({ owner: string; repo: string; issueNumber: number; + scope: GitHubQueryScope; viewerLogin?: string; }) { const [showReplyForm, setShowReplyForm] = useState(false); @@ -635,6 +739,7 @@ function IssueCommentWithThread({ owner={owner} repo={repo} issueNumber={issueNumber} + scope={scope} viewerLogin={viewerLogin} onReply={() => setShowReplyForm(true)} /> @@ -648,6 +753,7 @@ function IssueCommentWithThread({ owner={owner} repo={repo} issueNumber={issueNumber} + scope={scope} viewerLogin={viewerLogin} onReply={() => setShowReplyForm(true)} isReply @@ -792,10 +898,30 @@ function getIssueEventIcon(event: TimelineEvent) { return ( ); - case "closed": + case "closed": { + const kind = parseCloseReason(event.stateReason); + if (kind === "completed") { + return ( + + ); + } + if (kind === "not_planned") { + return ( + + ); + } return ( - + ); + } case "reopened": return ( ); - case "closed": + case "closed": { + const kind = parseCloseReason(event.stateReason); return ( {" closed this"} + {kind === "completed" && ( + <> + {" as "} + completed + + )} + {kind === "not_planned" && ( + <> + {" as "} + + not planned + + + )} ); + } case "reopened": return ( - {" reopened this"} + + reopened + + {" this issue"} ); case "cross-referenced": diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx index 7e714b4..5770ab3 100644 --- a/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx +++ b/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx @@ -121,6 +121,9 @@ export function IssueDetailContent({ issueNumber={issueNumber} scope={scope} issueAuthor={issue.author} + issueState={issue.state === "closed" ? "closed" : "open"} + issueClosedAt={issue.closedAt} + issueStateReason={issue.stateReason} viewerLogin={viewerQuery.data?.login} /> diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx index 20151b2..34c871e 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx @@ -9,7 +9,10 @@ import { GitBranchIcon, GitCommitIcon, GitMergeIcon, + GitPullRequestClosedIcon, GitPullRequestIcon, + IssueClosedCompletedIcon, + IssueClosedNotPlannedIcon, MoreHorizontalIcon, RefreshCwIcon, ReviewsIcon, @@ -50,6 +53,7 @@ import { useState, } from "react"; import { CommentMoreMenu } from "#/components/details/comment-more-menu"; +import { IssueCommentReactionBar } from "#/components/details/comment-reaction-bar"; import { CommentReplyForm } from "#/components/details/comment-reply-form"; import { buildCommentThreads } from "#/components/details/comment-threads"; import { @@ -57,6 +61,7 @@ import { DetailCommentBox, } from "#/components/details/detail-activity"; import { + GroupedIssueStateToggleDescription, GroupedLabelDescription, GroupedReviewRequestDescription, groupTimelineEvents, @@ -85,6 +90,7 @@ import type { CommentPagination, EventPagination, GitHubActor, + GroupedIssueStateToggleEvent, GroupedLabelEvent, GroupedReviewRequestEvent, PullCheckRun, @@ -96,6 +102,10 @@ import type { PullStatus, TimelineEvent, } from "#/lib/github.types"; +import { + mergeIssueStateIntoCloseEvent, + parseCloseReason, +} from "#/lib/timeline-close-reason"; import { checkPermissionWarning } from "#/lib/warning-store"; // Lazy-load PatchDiff for review comment diff hunks @@ -216,6 +226,7 @@ export function PullDetailActivitySection({ owner={owner} repo={repo} pullNumber={pullNumber} + scope={scope} viewerLogin={viewerLogin} threadInfoByCommentId={threadInfoByCommentId} /> @@ -1376,6 +1387,11 @@ type TimelineItem = date: string; data: GroupedReviewRequestEvent; } + | { + type: "issue_state_toggle_group"; + date: string; + data: GroupedIssueStateToggleEvent; + } | { type: "merged"; date: string; data: PullDetail }; const WINDOW_THRESHOLD = 25; @@ -1585,6 +1601,7 @@ function ActivityTimeline({ owner, repo, pullNumber, + scope, viewerLogin, threadInfoByCommentId, }: { @@ -1599,6 +1616,7 @@ function ActivityTimeline({ owner: string; repo: string; pullNumber: number; + scope: GitHubQueryScope; viewerLogin?: string; threadInfoByCommentId?: ReadonlyMap< number, @@ -1689,6 +1707,7 @@ function ActivityTimeline({ "event", "label_group", "review_request_group", + "issue_state_toggle_group", ]); const isEventLike = eventLikeTypes.has(item.type); const prevIsEventLike = @@ -1711,6 +1730,7 @@ function ActivityTimeline({ owner={owner} repo={repo} pullNumber={pullNumber} + scope={scope} viewerLogin={viewerLogin} /> ); @@ -1862,6 +1882,65 @@ function ActivityTimeline({ ); } + if (item.type === "issue_state_toggle_group") { + const group = item.data as GroupedIssueStateToggleEvent; + const hasActorAvatar = group.actor?.avatarUrl; + const prClosed = + pr.state === "closed" && !pr.isMerged ? "closed" : undefined; + const mergedForIcon = group.events.map((e) => + e.event === "closed" + ? mergeIssueStateIntoCloseEvent(e, { + issueState: prClosed, + issueClosedAt: pr.closedAt, + issueStateReason: null, + }) + : e, + ); + const lastEvent = mergedForIcon[mergedForIcon.length - 1]; + const icon = lastEvent ? getEventIcon(lastEvent) : null; + const toggleKey = group.events.map((e) => e.id).join("-"); + return ( +
+ {hasActorAvatar ? ( + {group.actor?.login} + ) : ( +
+ {icon} +
+ )} + + + e.event === "closed" + ? mergeIssueStateIntoCloseEvent(e, { + issueState: prClosed, + issueClosedAt: pr.closedAt, + issueStateReason: null, + }) + : e + } + /> + + + {formatRelativeTime(group.createdAt)} + +
+ ); + } + const event = item.data; return ( void; isReply?: boolean; @@ -2238,6 +2319,17 @@ function PullCommentBubble({ {comment.body} + {comment.graphqlId ? ( + + ) : null} ); } @@ -2249,6 +2341,7 @@ function PullCommentWithThread({ owner, repo, pullNumber, + scope, viewerLogin, }: { comment: PullComment; @@ -2257,6 +2350,7 @@ function PullCommentWithThread({ owner: string; repo: string; pullNumber: number; + scope: GitHubQueryScope; viewerLogin?: string; }) { const [showReplyForm, setShowReplyForm] = useState(false); @@ -2269,6 +2363,7 @@ function PullCommentWithThread({ owner={owner} repo={repo} pullNumber={pullNumber} + scope={scope} viewerLogin={viewerLogin} onReply={() => setShowReplyForm(true)} /> @@ -2282,6 +2377,7 @@ function PullCommentWithThread({ owner={owner} repo={repo} pullNumber={pullNumber} + scope={scope} viewerLogin={viewerLogin} onReply={() => setShowReplyForm(true)} isReply @@ -2679,28 +2775,54 @@ function getEventIcon(event: TimelineEvent) { className="text-muted-foreground" /> ); - case "reviewed": + case "reviewed": { + const s = event.reviewState?.toLowerCase(); + let reviewIconClass = "text-muted-foreground"; + if (s === "approved") { + reviewIconClass = "text-green-600 dark:text-green-400"; + } else if (s === "changes_requested") { + reviewIconClass = "text-red-600 dark:text-red-400"; + } else if (s === "commented") { + reviewIconClass = "text-sky-600 dark:text-sky-400"; + } else if (s === "dismissed") { + reviewIconClass = "text-muted-foreground"; + } return ( - + ); + } case "renamed": return ( ); - case "closed": + case "closed": { + const kind = parseCloseReason(event.stateReason); + if (kind === "completed") { + return ( + + ); + } + if (kind === "not_planned") { + return ( + + ); + } return ( - + ); + } case "reopened": return ( + + approved + + {" this pull request"} + + ); + } else if (state === "changes_requested") { + action = ( + + requested changes + + ); + } else if (state === "commented") { + action = ( + + left a review + + ); + } else if (state === "dismissed") { + action = ( + + dismissed their review + + ); + } else { + action = "reviewed"; + } return ( - + - {` ${stateLabel}`} + {action} ); } @@ -2884,18 +3031,37 @@ function getEventDescription( ); - case "closed": + case "closed": { + const kind = parseCloseReason(event.stateReason); return ( - + - {" closed this"} + {" closed this pull request"} + {kind === "completed" && ( + <> + {" as "} + completed + + )} + {kind === "not_planned" && ( + <> + {" as "} + + not planned + + + )} ); + } case "reopened": return ( - + - {" reopened this"} + + reopened + + {" this pull request"} ); case "cross-referenced": diff --git a/apps/dashboard/src/components/repo/repo-activity-cards.tsx b/apps/dashboard/src/components/repo/repo-activity-cards.tsx index 5dabe90..d058fed 100644 --- a/apps/dashboard/src/components/repo/repo-activity-cards.tsx +++ b/apps/dashboard/src/components/repo/repo-activity-cards.tsx @@ -161,7 +161,7 @@ function ActivityCard({ ) : ( <> {items.length === 0 ? ( -

+

No open {title.toLowerCase()}

) : ( diff --git a/apps/dashboard/src/components/repo/repo-header.tsx b/apps/dashboard/src/components/repo/repo-header.tsx index e22b825..4eb0d03 100644 --- a/apps/dashboard/src/components/repo/repo-header.tsx +++ b/apps/dashboard/src/components/repo/repo-header.tsx @@ -1,45 +1,120 @@ -import { ArchiveIcon } from "@diffkit/icons"; +import { ArchiveIcon, ArrowMoveDownRightIcon } from "@diffkit/icons"; import { Badge } from "@diffkit/ui/components/badge"; import { cn } from "@diffkit/ui/lib/utils"; import { Link } from "@tanstack/react-router"; -import { useState } from "react"; +import { useMemo, useState } from "react"; +import { RepoStarForkActions } from "#/components/repo/repo-star-fork-actions"; +import type { GitHubQueryScope } from "#/lib/github.query"; import type { RepoOverview } from "#/lib/github.types"; -export function RepoHeader({ repo }: { repo: RepoOverview }) { +function parseOwnerRepo( + fullName: string, +): { owner: string; repo: string } | null { + const i = fullName.indexOf("/"); + if (i <= 0 || i === fullName.length - 1) { + return null; + } + return { owner: fullName.slice(0, i), repo: fullName.slice(i + 1) }; +} + +export function RepoHeader({ + repo, + scope, +}: { + repo: RepoOverview; + scope: GitHubQueryScope; +}) { const [imgError, setImgError] = useState(false); + const [forkImgError, setForkImgError] = useState(false); + + const forkLink = useMemo(() => { + if (!repo.isFork || !repo.forkParentFullName) { + return null; + } + return parseOwnerRepo(repo.forkParentFullName); + }, [repo.isFork, repo.forkParentFullName]); return ( -
- {repo.ownerAvatarUrl && !imgError ? ( - {repo.owner} setImgError(true)} - /> - ) : ( - - )} -
- - {repo.owner} - - / - {repo.name} -
- +
+
+ {repo.ownerAvatarUrl && !imgError ? ( + {repo.owner} setImgError(true)} + /> + ) : ( + + )} +
+ + {repo.owner} + + / + {repo.name} +
+ + {repo.isPrivate ? "Private" : "Public"} + +
+ {forkLink && ( +

+ + forked from + {repo.forkParentOwnerAvatarUrl && !forkImgError ? ( + setForkImgError(true)} + /> + ) : ( + + )} + + + {forkLink.owner} + + / + + {forkLink.repo} + + +

)} - > - {repo.isPrivate ? "Private" : "Public"} - +
+
); } diff --git a/apps/dashboard/src/components/repo/repo-overview-page.tsx b/apps/dashboard/src/components/repo/repo-overview-page.tsx index 6c93ad1..fbc6018 100644 --- a/apps/dashboard/src/components/repo/repo-overview-page.tsx +++ b/apps/dashboard/src/components/repo/repo-overview-page.tsx @@ -65,7 +65,7 @@ export function RepoOverviewPage() {
- + -
-
- {data.contributors.map((c) => ( - - - - {c.login} - - - - {c.login} Β· {formatCount(c.contributions)} commits - - - ))} + +
+
+
+ {data.contributors.map((c) => ( + + + + {c.login} + + + + {c.login} Β· {formatCount(c.contributions)} commits + + + ))} +
+ {data.totalCount > data.contributors.length && ( + + )}
- {data.totalCount > data.contributors.length && ( - - )}
); } diff --git a/apps/dashboard/src/components/repo/repo-star-fork-actions.tsx b/apps/dashboard/src/components/repo/repo-star-fork-actions.tsx new file mode 100644 index 0000000..5cbceca --- /dev/null +++ b/apps/dashboard/src/components/repo/repo-star-fork-actions.tsx @@ -0,0 +1,148 @@ +import { GitForkIcon, StarIcon } from "@diffkit/icons"; +import { toast } from "@diffkit/ui/components/sonner"; +import { Spinner } from "@diffkit/ui/components/spinner"; +import { cn } from "@diffkit/ui/lib/utils"; +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { useRef, useState } from "react"; +import { forkRepository, setRepoStarred } from "#/lib/github.functions"; +import { type GitHubQueryScope, githubQueryKeys } from "#/lib/github.query"; +import type { RepoOverview } from "#/lib/github.types"; +import { checkPermissionWarning } from "#/lib/warning-store"; + +function formatCount(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`; + return String(n); +} + +export function RepoStarForkActions({ + repo, + scope, +}: { + repo: RepoOverview; + scope: GitHubQueryScope; +}) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const starFlight = useRef(false); + const [forkPending, setForkPending] = useState(false); + + const overviewKey = githubQueryKeys.repo.overview(scope, { + owner: repo.owner, + repo: repo.name, + }); + + const handleStar = async () => { + if (starFlight.current) { + return; + } + starFlight.current = true; + const nextStarred = !repo.viewerHasStarred; + const delta = nextStarred ? 1 : -1; + const previous = queryClient.getQueryData(overviewKey); + + // Keep optimistic cache on success β€” invalidating refetches and flickers. + queryClient.setQueryData(overviewKey, (old: RepoOverview | undefined) => { + if (!old) { + return old; + } + return { + ...old, + viewerHasStarred: nextStarred, + stars: Math.max(0, old.stars + delta), + }; + }); + + try { + const result = await setRepoStarred({ + data: { + owner: repo.owner, + repo: repo.name, + starred: nextStarred, + }, + }); + if (!result.ok) { + queryClient.setQueryData(overviewKey, previous); + toast.error(result.error); + checkPermissionWarning(result, `${repo.owner}/${repo.name}`); + } + } catch { + queryClient.setQueryData(overviewKey, previous); + toast.error("Failed to update star"); + } finally { + starFlight.current = false; + } + }; + + const handleFork = async () => { + setForkPending(true); + try { + const result = await forkRepository({ + data: { owner: repo.owner, repo: repo.name }, + }); + if (result.ok) { + void navigate({ + to: "/$owner/$repo", + params: { owner: result.forkOwner, repo: result.forkName }, + }); + } else { + toast.error(result.error); + checkPermissionWarning(result, `${repo.owner}/${repo.name}`); + } + } catch { + toast.error("Failed to fork repository"); + } finally { + setForkPending(false); + } + }; + + return ( +
+ + +
+ ); +} diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index a14750d..23f05d1 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -3,6 +3,8 @@ import { type Octokit as OctokitType, RequestError } from "octokit"; import { debug } from "./debug"; import type { CommandPaletteSearchResult, + CommentReactionContent, + CommentReactionSummary, ContributionDay, ContributionWeek, CreateLabelInput, @@ -139,6 +141,12 @@ type GitHubGraphQLCommentNode = { body: string; createdAt: string; author: GitHubGraphQLActor; + reactions?: { + nodes: Array<{ + content: string; + user: { login: string } | null; + } | null> | null; + } | null; }; type GitHubGraphQLCommentConnection = { totalCount: number; @@ -248,6 +256,12 @@ type GitHubGraphQLRepoOverviewResponse = { totalCount: number; }; hasDiscussionsEnabled: boolean; + parent: { + nameWithOwner: string; + owner: { + avatarUrl: string; + }; + } | null; } | null; rateLimit: GitHubGraphQLRateLimit; }; @@ -1049,7 +1063,62 @@ function numericIdFromGraphQLId(id: string) { return Math.abs(hash); } +function gqlReactionContentToRest( + content: string, +): CommentReactionContent | null { + switch (content) { + case "THUMBS_UP": + return "+1"; + case "THUMBS_DOWN": + return "-1"; + case "LAUGH": + return "laugh"; + case "CONFUSED": + return "confused"; + case "HEART": + return "heart"; + case "HOORAY": + return "hooray"; + case "ROCKET": + return "rocket"; + case "EYES": + return "eyes"; + default: + return null; + } +} + +function buildCommentReactionSummary( + nodes: + | Array<{ content: string; user: { login: string } | null } | null> + | null + | undefined, + viewerLogin: string | undefined, +): CommentReactionSummary | undefined { + const list = nodes?.filter((n): n is NonNullable => n != null); + if (!list?.length) { + return undefined; + } + + const counts: Partial> = {}; + const viewerReacted: CommentReactionContent[] = []; + + for (const node of list) { + const rest = gqlReactionContentToRest(node.content); + if (!rest) { + continue; + } + counts[rest] = (counts[rest] ?? 0) + 1; + if (viewerLogin && node.user?.login === viewerLogin) { + viewerReacted.push(rest); + } + } + + return { counts, viewerReacted }; +} + function mapGraphQLComments( + viewerLogin: string | undefined, ...connections: GitHubGraphQLCommentConnection[] ): IssueComment[] { const byId = new Map(); @@ -1063,9 +1132,14 @@ function mapGraphQLComments( const id = node.databaseId ?? numericIdFromGraphQLId(node.id); byId.set(id, { id, + graphqlId: node.id, body: node.body, createdAt: node.createdAt, author: mapGraphQLActor(node.author), + reactions: buildCommentReactionSummary( + node.reactions?.nodes ?? undefined, + viewerLogin, + ), }); } } @@ -1232,6 +1306,7 @@ function mapGraphQLRepoOverview( description: repository.description, isPrivate: repository.isPrivate, isFork: repository.isFork, + viewerHasStarred: false, defaultBranch: repository.defaultBranchRef?.name ?? "main", stars: repository.stargazerCount, forks: repository.forkCount, @@ -1249,6 +1324,8 @@ function mapGraphQLRepoOverview( openPullCount: repository.pullRequests.totalCount, openIssueCount: repository.issues.totalCount, hasDiscussions: repository.hasDiscussionsEnabled, + forkParentFullName: repository.parent?.nameWithOwner ?? null, + forkParentOwnerAvatarUrl: repository.parent?.owner.avatarUrl ?? null, latestCommit, }; } @@ -1944,6 +2021,50 @@ async function getGitHubUserContextForRepository(input: { return getGitHubUserContextForOwner(input.owner); } +/** + * `viewerHasStarred` on the main repo overview query is wrong when that query runs + * with an **installation** token. We resolve it separately using the **user** client + * (OAuth or user-to-server) so it reflects the signed-in account. + * + * Note: `GET /user/starred/{owner}/{repo}` can incorrectly 404 for some GitHub App + * user tokens even when the repo is starred; GraphQL `repository.viewerHasStarred` + * with the same user client matches what the star/unstar REST calls use. + */ +async function resolveViewerHasStarredForRepo(data: { + owner: string; + repo: string; +}): Promise { + const userContext = await getGitHubUserContextForRepository(data); + if (!userContext) { + debug("github-star", "resolveViewerHasStarred: no user context", { + repo: `${data.owner}/${data.repo}`, + }); + return false; + } + + try { + const response = await executeGitHubGraphQL<{ + repository: { viewerHasStarred: boolean } | null; + }>( + userContext, + `github repo viewerHasStarred ${data.owner}/${data.repo}`, + `query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + viewerHasStarred + } + }`, + { owner: data.owner, name: data.repo }, + ); + return response.repository?.viewerHasStarred ?? false; + } catch (error) { + debug("github-star", "resolveViewerHasStarred: GraphQL failed", { + repo: `${data.owner}/${data.repo}`, + status: error instanceof RequestError ? error.status : undefined, + }); + return false; + } +} + function isBypassableRulesetMode( mode: GitHubRepositoryRuleset["current_user_can_bypass"] | undefined, ) { @@ -3027,6 +3148,8 @@ function mapTimelineEvents(rawEvents: unknown[]): TimelineEvent[] { | null | undefined; const source = raw.source as Record | null | undefined; + const issueSnapshot = raw.issue as Record | undefined; + const eventType = raw.event as string; let mappedSource: TimelineEvent["source"] = null; if (source) { @@ -3095,7 +3218,25 @@ function mapTimelineEvents(rawEvents: unknown[]): TimelineEvent[] { ? { title: (milestone.title as string) ?? "" } : undefined, source: mappedSource, - reviewState: (raw.state as string) ?? undefined, + reviewState: + eventType === "reviewed" + ? ((raw.state as string) ?? undefined) + : undefined, + stateReason: (() => { + const top = + (raw.state_reason as string) ?? (raw.stateReason as string); + if (top) { + return top; + } + if (eventType === "closed" && issueSnapshot) { + return ( + (issueSnapshot.state_reason as string) ?? + (issueSnapshot.stateReason as string) ?? + undefined + ); + } + return undefined; + })(), body: (raw.body as string) ?? undefined, }; }); @@ -3528,7 +3669,7 @@ async function getPullPageDataViaGraphQL( namespaceKeys: [pullNamespaceKey], cacheMode: "split", fetcher: async () => { - const [response, timelineResult] = await Promise.all([ + const [response, timelineResult, viewer] = await Promise.all([ executeGitHubGraphQL( context, `github pull page ${data.owner}/${data.repo}#${data.pullNumber}`, @@ -3613,6 +3754,9 @@ async function getPullPageDataViaGraphQL( body createdAt author { __typename login avatarUrl url } + reactions(first: 100) { + nodes { content user { login } } + } } } lastComments: comments(last: 30) { @@ -3623,6 +3767,9 @@ async function getPullPageDataViaGraphQL( body createdAt author { __typename login avatarUrl url } + reactions(first: 100) { + nodes { content user { login } } + } } } } @@ -3644,6 +3791,7 @@ async function getPullPageDataViaGraphQL( repo: data.repo, issueNumber: data.pullNumber, }), + getViewer(context), ]); const pull = response.repository.pullRequest; @@ -3662,7 +3810,11 @@ async function getPullPageDataViaGraphQL( kind: "success", data: { detail, - comments: mapGraphQLComments(pull.firstComments, pull.lastComments), + comments: mapGraphQLComments( + viewer.login, + pull.firstComments, + pull.lastComments, + ), commits: mapGraphQLPullCommits(pull), events, commentPagination: { @@ -3845,7 +3997,7 @@ async function getIssuePageDataViaGraphQL( namespaceKeys: [issueNamespaceKey], cacheMode: "split", fetcher: async () => { - const [response, timelineResult] = await Promise.all([ + const [response, timelineResult, viewer] = await Promise.all([ executeGitHubGraphQL( context, `github issue page ${data.owner}/${data.repo}#${data.issueNumber}`, @@ -3895,6 +4047,9 @@ async function getIssuePageDataViaGraphQL( body createdAt author { __typename login avatarUrl url } + reactions(first: 100) { + nodes { content user { login } } + } } } lastComments: comments(last: 30) { @@ -3905,6 +4060,9 @@ async function getIssuePageDataViaGraphQL( body createdAt author { __typename login avatarUrl url } + reactions(first: 100) { + nodes { content user { login } } + } } } } @@ -3926,6 +4084,7 @@ async function getIssuePageDataViaGraphQL( repo: data.repo, issueNumber: data.issueNumber, }), + getViewer(context), ]); const issue = response.repository.issue; @@ -3941,7 +4100,11 @@ async function getIssuePageDataViaGraphQL( kind: "success", data: { detail: mapGraphQLIssueDetail(issue), - comments: mapGraphQLComments(issue.firstComments, issue.lastComments), + comments: mapGraphQLComments( + viewer.login, + issue.firstComments, + issue.lastComments, + ), events: timelineResult.events, commentPagination: { totalCount: totalComments, @@ -5343,6 +5506,59 @@ export const updatePullState = createServerFn({ method: "POST" }) } }); +type UpdateIssueStateInput = { + owner: string; + repo: string; + issueNumber: number; + state: "open" | "closed"; + /** Required when `state` is `closed` (GitHub β€œclose as”) */ + closeReason?: "completed" | "not_planned"; +}; + +export const updateIssueState = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubUserContextForRepository(data); + if (!context) { + return { ok: false, error: "Not authenticated" }; + } + + if (data.state === "closed" && !data.closeReason) { + return { ok: false, error: "Close reason is required" }; + } + + try { + await context.octokit.rest.issues.update({ + owner: data.owner, + repo: data.repo, + issue_number: data.issueNumber, + state: data.state, + state_reason: + data.state === "closed" ? data.closeReason : ("reopened" as const), + }); + + const userId = context.session.user.id; + await Promise.all([ + bustIssueCaches(userId, { + owner: data.owner, + repo: data.repo, + issueNumber: data.issueNumber, + }), + bumpGitHubCacheNamespaces([ + githubRevalidationSignalKeys.issueEntity({ + owner: data.owner, + repo: data.repo, + issueNumber: data.issueNumber, + }), + ]), + ]); + + return { ok: true }; + } catch (error) { + return toMutationError("update issue", error); + } + }); + export const updatePullBranch = createServerFn({ method: "POST" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { @@ -5938,6 +6154,254 @@ export const createComment = createServerFn({ method: "POST" }) } }); +// ── Star / fork repository ─────────────────────────────────────── + +export type SetRepoStarredInput = { + owner: string; + repo: string; + starred: boolean; +}; + +export const setRepoStarred = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const target = `${data.owner}/${data.repo}`; + debug("github-star", "setRepoStarred called", { + target, + starred: data.starred, + }); + console.info("[github-star] request", { + target, + starred: data.starred, + }); + + const context = await getGitHubUserContextForRepository(data); + if (!context) { + debug("github-star", "no GitHub user context (OAuth token?)", { + target, + }); + console.warn("[github-star] aborted: no user context", { target }); + return { ok: false, error: "Not authenticated" }; + } + + try { + debug("github-star", "calling GitHub activity API", { + target, + action: data.starred ? "star" : "unstar", + }); + console.info("[github-star] calling octokit.rest.activity", { + target, + action: data.starred + ? "starRepoForAuthenticatedUser" + : "unstarRepoForAuthenticatedUser", + }); + + if (data.starred) { + await context.octokit.rest.activity.starRepoForAuthenticatedUser({ + owner: data.owner, + repo: data.repo, + }); + } else { + await context.octokit.rest.activity.unstarRepoForAuthenticatedUser({ + owner: data.owner, + repo: data.repo, + }); + } + + await bumpGitHubCacheNamespaces([ + githubRevalidationSignalKeys.repoMeta({ + owner: data.owner, + repo: data.repo, + }), + ]); + + debug("github-star", "success + cache namespace bumped", { target }); + console.info("[github-star] ok", { target, starred: data.starred }); + + return { ok: true }; + } catch (error) { + const errMeta = + error instanceof RequestError + ? { + status: error.status, + message: error.message, + request: error.request?.url, + } + : { message: error instanceof Error ? error.message : String(error) }; + debug("github-star", "GitHub API error", { + target, + ...errMeta, + }); + console.error("[github-star] GitHub API error", { + target, + starred: data.starred, + ...errMeta, + }); + return toMutationError( + data.starred ? "star repository" : "unstar repository", + error, + ); + } + }); + +export type ForkRepoInput = { + owner: string; + repo: string; +}; + +export type ForkRepoResult = + | { ok: true; forkOwner: string; forkName: string; htmlUrl: string } + | { ok: false; error: string; installUrl?: string }; + +function toForkRepoError(error: unknown): ForkRepoResult { + const result = toMutationError("fork repository", error); + if (result.ok) { + return { ok: false, error: "Failed to fork repository" }; + } + return { ok: false, error: result.error, installUrl: result.installUrl }; +} + +export const forkRepository = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubUserContextForRepository(data); + if (!context) { + return { ok: false, error: "Not authenticated" }; + } + + try { + const response = await context.octokit.rest.repos.createFork({ + owner: data.owner, + repo: data.repo, + }); + + await bumpGitHubCacheNamespaces([ + githubRevalidationSignalKeys.repoMeta({ + owner: response.data.owner.login, + repo: response.data.name, + }), + ]); + + return { + ok: true, + forkOwner: response.data.owner.login, + forkName: response.data.name, + htmlUrl: response.data.html_url, + }; + } catch (error) { + return toForkRepoError(error); + } + }); + +// ── Issue/PR comment reactions ─────────────────────────────────── + +export type ToggleIssueCommentReactionInput = { + owner: string; + repo: string; + /** Issue or pull request number (conversation thread) */ + issueNumber: number; + commentId: number; + /** GitHub GraphQL node id β€” numeric REST id can be wrong when GraphQL `databaseId` was absent */ + commentGraphqlId: string; + content: CommentReactionContent; + /** True = remove; false = add (from UI before toggle) */ + remove: boolean; +}; + +function restReactionContentToGraphQL(content: CommentReactionContent): string { + switch (content) { + case "+1": + return "THUMBS_UP"; + case "-1": + return "THUMBS_DOWN"; + case "laugh": + return "LAUGH"; + case "confused": + return "CONFUSED"; + case "heart": + return "HEART"; + case "hooray": + return "HOORAY"; + case "rocket": + return "ROCKET"; + case "eyes": + return "EYES"; + default: + return "THUMBS_UP"; + } +} + +export const toggleIssueCommentReaction = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubUserContextForRepository(data); + if (!context) { + return { ok: false, error: "Not authenticated" }; + } + + if (!data.commentGraphqlId) { + return { ok: false, error: "Missing comment node id" }; + } + + try { + const gqlContent = restReactionContentToGraphQL(data.content); + const mutation = data.remove + ? `mutation($subjectId: ID!, $content: ReactionContent!) { + removeReaction(input: { subjectId: $subjectId, content: $content }) { + subject { id } + } + }` + : `mutation($subjectId: ID!, $content: ReactionContent!) { + addReaction(input: { subjectId: $subjectId, content: $content }) { + subject { id } + } + }`; + + await executeGitHubGraphQL<{ + addReaction?: { subject: { id: string } | null } | null; + removeReaction?: { subject: { id: string } | null } | null; + }>( + context, + `github reaction ${data.remove ? "remove" : "add"}`, + mutation, + { + subjectId: data.commentGraphqlId, + content: gqlContent, + }, + ); + + const userId = context.session.user.id; + await Promise.all([ + bustGitHubCache(userId, "issues.comments", { + owner: data.owner, + repo: data.repo, + issueNumber: data.issueNumber, + }), + bustGitHubCache(userId, "pulls.comments", { + owner: data.owner, + repo: data.repo, + pullNumber: data.issueNumber, + }), + bumpGitHubCacheNamespaces([ + githubRevalidationSignalKeys.pullEntity({ + owner: data.owner, + repo: data.repo, + pullNumber: data.issueNumber, + }), + githubRevalidationSignalKeys.issueEntity({ + owner: data.owner, + repo: data.repo, + issueNumber: data.issueNumber, + }), + ]), + ]); + + return { ok: true }; + } catch (error) { + return toMutationError("toggle comment reaction", error); + } + }); + // ── Edit issue/PR comment ───────────────────────────────────────── export type EditCommentInput = { @@ -6986,6 +7450,12 @@ export const getRepoOverview = createServerFn({ method: "GET" }) totalCount } hasDiscussionsEnabled + parent { + nameWithOwner + owner { + avatarUrl + } + } } rateLimit { cost @@ -7005,9 +7475,12 @@ export const getRepoOverview = createServerFn({ method: "GET" }) ); } + const overview = mapGraphQLRepoOverview(response.repository); + overview.viewerHasStarred = await resolveViewerHasStarredForRepo(data); + return { kind: "success", - data: mapGraphQLRepoOverview(response.repository), + data: overview, metadata: createGraphQLResponseMetadata(response.rateLimit), }; }, diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index 7873330..5a6cb2a 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -133,18 +133,41 @@ export type CommandPaletteSearchResult = { issues: IssueSummary[]; }; +/** GitHub REST reaction `content` values for issue/PR comments */ +export type CommentReactionContent = + | "+1" + | "-1" + | "laugh" + | "confused" + | "heart" + | "hooray" + | "rocket" + | "eyes"; + +export type CommentReactionSummary = { + counts: Partial>; + /** Reaction types the authenticated user has left on this comment */ + viewerReacted: CommentReactionContent[]; +}; + export type PullComment = { id: number; + /** GraphQL node id (`IC_kwD...`); required for reactions API */ + graphqlId?: string; body: string; createdAt: string; author: GitHubActor | null; + reactions?: CommentReactionSummary; }; export type IssueComment = { id: number; + /** GraphQL node id (`IC_kwD...`); required for reactions API */ + graphqlId?: string; body: string; createdAt: string; author: GitHubActor | null; + reactions?: CommentReactionSummary; }; export type TimelineEvent = { @@ -167,6 +190,8 @@ export type TimelineEvent = { } | null; milestone?: { title: string } | null; reviewState?: string; + /** Issue/PR close: REST `state_reason` (e.g. completed, not_planned) */ + stateReason?: string; body?: string; }; @@ -184,6 +209,13 @@ export type GroupedReviewRequestEvent = { createdAt: string; }; +/** Consecutive reopen/close by the same actor within the grouping window */ +export type GroupedIssueStateToggleEvent = { + actor: GitHubActor | null; + events: TimelineEvent[]; + createdAt: string; +}; + export type IssuePageData = { detail: IssueDetail | null; comments: IssueComment[]; @@ -437,6 +469,8 @@ export type RepoOverview = { description: string | null; isPrivate: boolean; isFork: boolean; + /** Whether the authenticated user has starred this repository */ + viewerHasStarred: boolean; defaultBranch: string; stars: number; forks: number; @@ -452,6 +486,10 @@ export type RepoOverview = { openPullCount: number; openIssueCount: number; hasDiscussions: boolean; + /** Present when `isFork`; upstream `owner/name` */ + forkParentFullName: string | null; + /** Upstream owner avatar from the same overview query (no extra request) */ + forkParentOwnerAvatarUrl: string | null; latestCommit: { sha: string; message: string; diff --git a/apps/dashboard/src/lib/timeline-close-reason.ts b/apps/dashboard/src/lib/timeline-close-reason.ts new file mode 100644 index 0000000..820743f --- /dev/null +++ b/apps/dashboard/src/lib/timeline-close-reason.ts @@ -0,0 +1,55 @@ +import type { TimelineEvent } from "#/lib/github.types"; + +/** Normalized from GitHub REST `state_reason` on close events */ +export type CloseReasonKind = "completed" | "not_planned" | "other"; + +export function parseCloseReason( + raw: string | undefined, +): CloseReasonKind | null { + if (!raw) { + return null; + } + const r = raw.toLowerCase(); + if (r === "completed") { + return "completed"; + } + if (r === "not_planned") { + return "not_planned"; + } + return "other"; +} + +/** When the timeline payload omits `state_reason`, match the close row to the issue’s current `closedAt` / `stateReason`. */ +export function mergeIssueStateIntoCloseEvent( + event: TimelineEvent, + opts: { + issueState?: "open" | "closed"; + issueClosedAt?: string | null; + issueStateReason?: string | null; + }, +): TimelineEvent { + if (event.event !== "closed" || event.stateReason) { + return event; + } + if (opts.issueState !== "closed") { + return event; + } + const sr = opts.issueStateReason; + const closedAt = opts.issueClosedAt; + if (!sr || !closedAt) { + return event; + } + if (!closeTimestampsMatch(event.createdAt, closedAt)) { + return event; + } + return { ...event, stateReason: sr }; +} + +function closeTimestampsMatch(created: string, closed: string): boolean { + const a = Date.parse(created); + const b = Date.parse(closed); + if (Number.isNaN(a) || Number.isNaN(b)) { + return created === closed; + } + return Math.abs(a - b) <= 120_000; +} diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 97e4d8b..404ff81 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -8,6 +8,7 @@ export { ArrangeIcon as SortIcon, ArrowDown01Icon as ChevronDownIcon, ArrowLeft01Icon as ChevronLeftIcon, + ArrowMoveDownRightIcon, ArrowReloadHorizontalIcon as RefreshCwIcon, ArrowRight01Icon as ChevronRightIcon, ArrowUp01Icon as ChevronUpIcon, @@ -17,7 +18,9 @@ export { Calendar01Icon as CalendarIcon, Cancel01Icon as CloseIcon, Cancel01Icon as XIcon, + CancelCircleIcon as IssueClosedNotPlannedIcon, CheckListIcon as ReviewsIcon, + CheckmarkCircle01Icon as IssueClosedCompletedIcon, CircleIcon, Clock01Icon as ClockIcon, CommandIcon, From aafc293c239d2bd04c783da9ff1a222053f2e127 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Fri, 17 Apr 2026 22:41:12 -0400 Subject: [PATCH 2/5] fix(dashboard): comment reaction tooltips, NumberFlow, motion polish - Store userLoginsByContent from GraphQL; optimistic patch with viewerLogin - Tooltip lists reactors (first 10 +N); pass viewerLogin from issue/PR bubbles - Motion layout, AnimatePresence, NumberFlow for reaction chips --- .../details/comment-reaction-bar.tsx | 187 +++++++++++++++--- .../issues/detail/issue-detail-activity.tsx | 39 ++-- .../pulls/detail/pull-detail-activity.tsx | 39 ++-- apps/dashboard/src/lib/github.functions.ts | 11 +- apps/dashboard/src/lib/github.types.ts | 2 + 5 files changed, 221 insertions(+), 57 deletions(-) diff --git a/apps/dashboard/src/components/details/comment-reaction-bar.tsx b/apps/dashboard/src/components/details/comment-reaction-bar.tsx index 50d4e9b..4697ff3 100644 --- a/apps/dashboard/src/components/details/comment-reaction-bar.tsx +++ b/apps/dashboard/src/components/details/comment-reaction-bar.tsx @@ -1,7 +1,14 @@ import { toast } from "@diffkit/ui/components/sonner"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@diffkit/ui/components/tooltip"; import { cn } from "@diffkit/ui/lib/utils"; +import NumberFlow from "@number-flow/react"; import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useRef } from "react"; +import { AnimatePresence, LayoutGroup, motion } from "motion/react"; +import { Fragment, useCallback, useRef } from "react"; import { toggleIssueCommentReaction } from "#/lib/github.functions"; import { type GitHubQueryScope, githubQueryKeys } from "#/lib/github.query"; import type { @@ -35,11 +42,33 @@ const QUICK_REACTIONS: { content: CommentReactionContent; emoji: string }[] = [ { content: "eyes", emoji: REACTION_EMOJI.eyes }, ]; +const reactionSpring = { + type: "spring" as const, + duration: 0.22, + bounce: 0.12, +}; + +function reactionActorTooltipText( + logins: string[] | undefined, + total: number, +): string { + if (total <= 0) { + return ""; + } + if (!logins?.length) { + return total === 1 ? "1 reaction" : `${total} reactions`; + } + const shown = logins.slice(0, 10); + const rest = total - shown.length; + return rest > 0 ? `${shown.join(", ")} +${rest}` : shown.join(", "); +} + function patchCommentReactions( prev: IssuePageData | PullPageData | undefined, commentId: number, content: CommentReactionContent, remove: boolean, + viewerLogin: string | undefined, ): IssuePageData | PullPageData | undefined { if (!prev?.comments?.length) { return prev; @@ -54,19 +83,49 @@ function patchCommentReactions( const base = c.reactions ?? { counts: {}, viewerReacted: [] }; const counts = { ...base.counts }; const viewerReacted = [...base.viewerReacted]; + const userLoginsByContent: Partial< + Record + > = { ...(base.userLoginsByContent ?? {}) }; + const loginsFor = [...(userLoginsByContent[content] ?? [])]; + if (remove) { counts[content] = Math.max(0, (counts[content] ?? 0) - 1); const i = viewerReacted.indexOf(content); if (i >= 0) { viewerReacted.splice(i, 1); } + if (viewerLogin) { + const li = loginsFor.lastIndexOf(viewerLogin); + if (li >= 0) { + loginsFor.splice(li, 1); + } + } } else { counts[content] = (counts[content] ?? 0) + 1; if (!viewerReacted.includes(content)) { viewerReacted.push(content); } + if (viewerLogin && !loginsFor.includes(viewerLogin)) { + loginsFor.push(viewerLogin); + } } - return { ...c, reactions: { counts, viewerReacted } }; + + if (loginsFor.length === 0) { + delete userLoginsByContent[content]; + } else { + userLoginsByContent[content] = loginsFor; + } + + return { + ...c, + reactions: { + counts, + viewerReacted, + ...(Object.keys(userLoginsByContent).length > 0 + ? { userLoginsByContent } + : {}), + }, + }; }); if (!changed) { @@ -83,6 +142,10 @@ export function IssueCommentReactionBar({ commentGraphqlId, scope, reactions, + className, + /** When true, show reactions that have zero total count (hover / focus-within). */ + revealZeroCount, + viewerLogin, }: { owner: string; repo: string; @@ -91,6 +154,9 @@ export function IssueCommentReactionBar({ commentGraphqlId: string; scope: GitHubQueryScope; reactions?: CommentReactionSummary; + className?: string; + revealZeroCount: boolean; + viewerLogin?: string | null; }) { const queryClient = useQueryClient(); const flight = useRef(false); @@ -110,17 +176,18 @@ export function IssueCommentReactionBar({ (content: CommentReactionContent, remove: boolean) => { const prevIssue = queryClient.getQueryData(issuePageKey); const prevPull = queryClient.getQueryData(pullPageKey); + const viewer = viewerLogin ?? undefined; queryClient.setQueryData( issuePageKey, - patchCommentReactions(prevIssue, commentId, content, remove), + patchCommentReactions(prevIssue, commentId, content, remove, viewer), ); queryClient.setQueryData( pullPageKey, - patchCommentReactions(prevPull, commentId, content, remove), + patchCommentReactions(prevPull, commentId, content, remove, viewer), ); return { prevIssue, prevPull }; }, - [commentId, issuePageKey, pullPageKey, queryClient], + [commentId, issuePageKey, pullPageKey, queryClient, viewerLogin], ); const rollback = useCallback( @@ -166,33 +233,89 @@ export function IssueCommentReactionBar({ } }; + const counts = reactions?.counts ?? {}; + const orderedReactions = [ + ...QUICK_REACTIONS.filter((item) => (counts[item.content] ?? 0) > 0), + ...(revealZeroCount + ? QUICK_REACTIONS.filter((item) => (counts[item.content] ?? 0) === 0) + : []), + ]; + return ( -
- {QUICK_REACTIONS.map(({ content, emoji }) => { - const count = reactions?.counts[content] ?? 0; - const active = reactions?.viewerReacted.includes(content) ?? false; - return ( - - ); - })} -
+ + + + {orderedReactions.flatMap(({ content, emoji }) => { + const count = counts[content] ?? 0; + const active = reactions?.viewerReacted.includes(content) ?? false; + const tooltipText = reactionActorTooltipText( + reactions?.userLoginsByContent?.[content], + count, + ); + const chip = ( + void handleToggle(content)} + className={cn( + "relative inline-flex h-6 items-center gap-2 rounded-full border px-2.5 text-xs transition-colors", + "border-transparent text-muted-foreground hover:bg-surface-2 hover:text-foreground", + active + ? "bg-surface-2 hover:bg-surface-1 hover:text-foreground" + : "bg-surface-1 hover:bg-surface-2 hover:text-foreground", + )} + aria-label={`React with ${content}`} + > + + {emoji} + + + {count > 0 ? ( + + + + ) : null} + + + ); + return [ + count > 0 ? ( + + {chip} + + {tooltipText} + + + ) : ( + {chip} + ), + ]; + })} + + + ); } diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx index 3249ab9..0d7bd99 100644 --- a/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx +++ b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx @@ -633,12 +633,22 @@ function IssueCommentBubble({ onReply?: () => void; isReply?: boolean; }) { + const [commentActive, setCommentActive] = useState(false); + return (
setCommentActive(true)} + onPointerLeave={() => setCommentActive(false)} + onFocusCapture={() => setCommentActive(true)} + onBlurCapture={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { + setCommentActive(false); + } + }} >
{comment.author ? ( @@ -694,18 +704,23 @@ function IssueCommentBubble({ />
- {comment.body} - {comment.graphqlId ? ( - - ) : null} +
+ {comment.body} + {comment.graphqlId ? ( + + ) : null} +
); } diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx index 34c871e..ea7e9a4 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx @@ -2257,12 +2257,22 @@ function PullCommentBubble({ onReply?: () => void; isReply?: boolean; }) { + const [commentActive, setCommentActive] = useState(false); + return (
setCommentActive(true)} + onPointerLeave={() => setCommentActive(false)} + onFocusCapture={() => setCommentActive(true)} + onBlurCapture={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { + setCommentActive(false); + } + }} >
{comment.author ? ( @@ -2318,18 +2328,23 @@ function PullCommentBubble({ />
- {comment.body} - {comment.graphqlId ? ( - - ) : null} +
+ {comment.body} + {comment.graphqlId ? ( + + ) : null} +
); } diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 23f05d1..684d704 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -1102,6 +1102,8 @@ function buildCommentReactionSummary( const counts: Partial> = {}; const viewerReacted: CommentReactionContent[] = []; + const userLoginsByContent: Partial> = + {}; for (const node of list) { const rest = gqlReactionContentToRest(node.content); @@ -1109,12 +1111,19 @@ function buildCommentReactionSummary( continue; } counts[rest] = (counts[rest] ?? 0) + 1; + const login = node.user?.login; + if (login) { + if (!userLoginsByContent[rest]) { + userLoginsByContent[rest] = []; + } + userLoginsByContent[rest].push(login); + } if (viewerLogin && node.user?.login === viewerLogin) { viewerReacted.push(rest); } } - return { counts, viewerReacted }; + return { counts, viewerReacted, userLoginsByContent }; } function mapGraphQLComments( diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index 5a6cb2a..c83f388 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -148,6 +148,8 @@ export type CommentReactionSummary = { counts: Partial>; /** Reaction types the authenticated user has left on this comment */ viewerReacted: CommentReactionContent[]; + /** User logins per reaction type (API order; used for tooltips) */ + userLoginsByContent?: Partial>; }; export type PullComment = { From 8808f3cb59360c999d5b33b76cded6059d541c14 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Fri, 17 Apr 2026 22:44:12 -0400 Subject: [PATCH 3/5] fix: address CodeRabbit review (cache v2, viewer context, UI nits) - Bump GraphQL page and repo overview cache resource versions to v2 - Resolve viewer via getGitHubUserContextForRepository for issue/PR page loads - Map REST comment node_id to graphqlId for paginated comments and pull comments - Invalidate repoMeta on issue state change; bump parent repoMeta after fork - Gate close/reopen on successful createComment; repo header/sidebar a11y - Show reaction chips on coarse pointers (hover: none) without hover --- .../components/details/detail-activity.tsx | 14 ++- .../issues/detail/issue-detail-activity.tsx | 12 +- .../pulls/detail/pull-detail-activity.tsx | 11 +- .../src/components/repo/repo-header.tsx | 10 +- .../src/components/repo/repo-sidebar.tsx | 7 +- apps/dashboard/src/lib/github.functions.ts | 117 ++++++++++++------ 6 files changed, 116 insertions(+), 55 deletions(-) diff --git a/apps/dashboard/src/components/details/detail-activity.tsx b/apps/dashboard/src/components/details/detail-activity.tsx index 1afb2f9..add19f2 100644 --- a/apps/dashboard/src/components/details/detail-activity.tsx +++ b/apps/dashboard/src/components/details/detail-activity.tsx @@ -151,9 +151,14 @@ export function DetailCommentBox({ setIsTogglingState(true); try { if (value.trim()) { - await createComment({ + const commentResult = await createComment({ data: { owner, repo, issueNumber, body: value.trim() }, }); + if (!commentResult.ok) { + toast.error(commentResult.error); + checkPermissionWarning(commentResult, `${owner}/${repo}`); + return; + } setValue(""); } const result = await updatePullState({ @@ -188,9 +193,14 @@ export function DetailCommentBox({ setIsTogglingIssueState(true); try { if (value.trim()) { - await createComment({ + const commentResult = await createComment({ data: { owner, repo, issueNumber, body: value.trim() }, }); + if (!commentResult.ok) { + toast.error(commentResult.error); + checkPermissionWarning(commentResult, `${owner}/${repo}`); + return; + } setValue(""); } const result = await updateIssueState({ diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx index 0d7bd99..0d7eecb 100644 --- a/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx +++ b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx @@ -13,7 +13,7 @@ import { import { Markdown } from "@diffkit/ui/components/markdown"; import { cn } from "@diffkit/ui/lib/utils"; import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CommentMoreMenu } from "#/components/details/comment-more-menu"; import { IssueCommentReactionBar } from "#/components/details/comment-reaction-bar"; import { CommentReplyForm } from "#/components/details/comment-reply-form"; @@ -634,6 +634,14 @@ function IssueCommentBubble({ isReply?: boolean; }) { const [commentActive, setCommentActive] = useState(false); + const [coarsePointerNoHover, setCoarsePointerNoHover] = useState(false); + useEffect(() => { + const mq = window.matchMedia("(hover: none)"); + const sync = () => setCoarsePointerNoHover(mq.matches); + sync(); + mq.addEventListener("change", sync); + return () => mq.removeEventListener("change", sync); + }, []); return (
{ + const mq = window.matchMedia("(hover: none)"); + const sync = () => setCoarsePointerNoHover(mq.matches); + sync(); + mq.addEventListener("change", sync); + return () => mq.removeEventListener("change", sync); + }, []); return (
{repo.owner} / - {repo.name} + + {repo.name} +
{forkLink.owner} @@ -106,7 +108,7 @@ export function RepoHeader({ owner: forkLink.owner, repo: forkLink.repo, }} - className="min-w-0 truncate font-semibold text-foreground transition-colors hover:underline" + className="min-w-0 max-w-[min(100%,18rem)] truncate font-semibold text-foreground transition-colors hover:underline" > {forkLink.repo} diff --git a/apps/dashboard/src/components/repo/repo-sidebar.tsx b/apps/dashboard/src/components/repo/repo-sidebar.tsx index 0a53120..51b1d55 100644 --- a/apps/dashboard/src/components/repo/repo-sidebar.tsx +++ b/apps/dashboard/src/components/repo/repo-sidebar.tsx @@ -135,12 +135,9 @@ function ContributorsSection({
{data.totalCount > data.contributors.length && ( - + )}
diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 684d704..3399fad 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -2988,19 +2988,27 @@ async function getPullCommentsResult( headers, }), mapData: (comments) => - comments.map((comment) => ({ - id: comment.id, - body: comment.body ?? "", - createdAt: comment.created_at, - author: comment.user - ? { - login: comment.user.login, - avatarUrl: comment.user.avatar_url, - url: comment.user.html_url, - type: comment.user.type ?? "User", - } - : null, - })), + comments.map((comment) => { + const nodeId = + "node_id" in comment && + typeof (comment as { node_id?: unknown }).node_id === "string" + ? (comment as { node_id: string }).node_id + : undefined; + return { + id: comment.id, + ...(nodeId ? { graphqlId: nodeId } : {}), + body: comment.body ?? "", + createdAt: comment.created_at, + author: comment.user + ? { + login: comment.user.login, + avatarUrl: comment.user.avatar_url, + url: comment.user.html_url, + type: comment.user.type ?? "User", + } + : null, + }; + }), }); } @@ -3063,12 +3071,7 @@ async function getCommentsPageResult( context: GitHubContext, data: CommentPageInput, ): Promise<{ - comments: Array<{ - id: number; - body: string; - createdAt: string; - author: GitHubActor | null; - }>; + comments: IssueComment[]; total: number; }> { const response = await context.octokit.rest.issues.listComments({ @@ -3087,19 +3090,27 @@ async function getCommentsPageResult( } return { - comments: response.data.map((c) => ({ - id: c.id, - body: c.body ?? "", - createdAt: c.created_at, - author: c.user - ? { - login: c.user.login, - avatarUrl: c.user.avatar_url, - url: c.user.html_url, - type: c.user.type ?? "User", - } - : null, - })), + comments: response.data.map((c) => { + const nodeId = + "node_id" in c && + typeof (c as { node_id?: unknown }).node_id === "string" + ? (c as { node_id: string }).node_id + : undefined; + return { + id: c.id, + ...(nodeId ? { graphqlId: nodeId } : {}), + body: c.body ?? "", + createdAt: c.created_at, + author: c.user + ? { + login: c.user.login, + avatarUrl: c.user.avatar_url, + url: c.user.html_url, + type: c.user.type ?? "User", + } + : null, + }; + }), total, }; } @@ -3671,13 +3682,18 @@ async function getPullPageDataViaGraphQL( return getOrRevalidateGitHubResource({ userId: context.session.user.id, - resource: "pulls.pageData.graphql.v1", + resource: "pulls.pageData.graphql.v2", params: data, freshForMs: githubCachePolicy.detail.staleTimeMs, signalKeys: [pullNamespaceKey], namespaceKeys: [pullNamespaceKey], cacheMode: "split", fetcher: async () => { + const viewerPromise = getGitHubUserContextForRepository({ + owner: data.owner, + repo: data.repo, + }).then((userCtx) => (userCtx ? getViewer(userCtx) : null)); + const [response, timelineResult, viewer] = await Promise.all([ executeGitHubGraphQL( context, @@ -3800,7 +3816,7 @@ async function getPullPageDataViaGraphQL( repo: data.repo, issueNumber: data.pullNumber, }), - getViewer(context), + viewerPromise, ]); const pull = response.repository.pullRequest; @@ -3820,7 +3836,7 @@ async function getPullPageDataViaGraphQL( data: { detail, comments: mapGraphQLComments( - viewer.login, + viewer?.login, pull.firstComments, pull.lastComments, ), @@ -3999,13 +4015,18 @@ async function getIssuePageDataViaGraphQL( return getOrRevalidateGitHubResource({ userId: context.session.user.id, - resource: "issues.pageData.graphql.v1", + resource: "issues.pageData.graphql.v2", params: data, freshForMs: githubCachePolicy.detail.staleTimeMs, signalKeys: [issueNamespaceKey], namespaceKeys: [issueNamespaceKey], cacheMode: "split", fetcher: async () => { + const viewerPromise = getGitHubUserContextForRepository({ + owner: data.owner, + repo: data.repo, + }).then((userCtx) => (userCtx ? getViewer(userCtx) : null)); + const [response, timelineResult, viewer] = await Promise.all([ executeGitHubGraphQL( context, @@ -4093,7 +4114,7 @@ async function getIssuePageDataViaGraphQL( repo: data.repo, issueNumber: data.issueNumber, }), - getViewer(context), + viewerPromise, ]); const issue = response.repository.issue; @@ -4110,7 +4131,7 @@ async function getIssuePageDataViaGraphQL( data: { detail: mapGraphQLIssueDetail(issue), comments: mapGraphQLComments( - viewer.login, + viewer?.login, issue.firstComments, issue.lastComments, ), @@ -5559,6 +5580,10 @@ export const updateIssueState = createServerFn({ method: "POST" }) repo: data.repo, issueNumber: data.issueNumber, }), + githubRevalidationSignalKeys.repoMeta({ + owner: data.owner, + repo: data.repo, + }), ]), ]); @@ -6284,12 +6309,22 @@ export const forkRepository = createServerFn({ method: "POST" }) repo: data.repo, }); - await bumpGitHubCacheNamespaces([ + const namespaces = [ githubRevalidationSignalKeys.repoMeta({ owner: response.data.owner.login, repo: response.data.name, }), - ]); + ]; + const parent = response.data.parent; + if (parent?.owner?.login && parent?.name) { + namespaces.push( + githubRevalidationSignalKeys.repoMeta({ + owner: parent.owner.login, + repo: parent.name, + }), + ); + } + await bumpGitHubCacheNamespaces(namespaces); return { ok: true, @@ -7386,7 +7421,7 @@ export const getRepoOverview = createServerFn({ method: "GET" }) return getOrRevalidateGitHubResource({ userId: context.session.user.id, - resource: "repo.overview.v1", + resource: "repo.overview.v2", params: data, freshForMs: githubCachePolicy.repoMeta.staleTimeMs, signalKeys: [repoMetaKey, repoCodeKey], From 3cb57855b6b4feb791d96edfc0160abed59294b7 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Fri, 17 Apr 2026 22:46:15 -0400 Subject: [PATCH 4/5] refactor(dashboard): extract usePrefersNoHover for coarse pointer UI --- .../issues/detail/issue-detail-activity.tsx | 14 ++++---------- .../pulls/detail/pull-detail-activity.tsx | 13 +++---------- apps/dashboard/src/lib/use-prefers-no-hover.ts | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 20 deletions(-) create mode 100644 apps/dashboard/src/lib/use-prefers-no-hover.ts diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx index 0d7eecb..61442f7 100644 --- a/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx +++ b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx @@ -13,7 +13,7 @@ import { import { Markdown } from "@diffkit/ui/components/markdown"; import { cn } from "@diffkit/ui/lib/utils"; import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { CommentMoreMenu } from "#/components/details/comment-more-menu"; import { IssueCommentReactionBar } from "#/components/details/comment-reaction-bar"; import { CommentReplyForm } from "#/components/details/comment-reply-form"; @@ -47,6 +47,7 @@ import { mergeIssueStateIntoCloseEvent, parseCloseReason, } from "#/lib/timeline-close-reason"; +import { usePrefersNoHover } from "#/lib/use-prefers-no-hover"; const WINDOW_THRESHOLD = 25; const EDGE_SIZE = 10; @@ -634,14 +635,7 @@ function IssueCommentBubble({ isReply?: boolean; }) { const [commentActive, setCommentActive] = useState(false); - const [coarsePointerNoHover, setCoarsePointerNoHover] = useState(false); - useEffect(() => { - const mq = window.matchMedia("(hover: none)"); - const sync = () => setCoarsePointerNoHover(mq.matches); - sync(); - mq.addEventListener("change", sync); - return () => mq.removeEventListener("change", sync); - }, []); + const prefersNoHover = usePrefersNoHover(); return (
{ - const mq = window.matchMedia("(hover: none)"); - const sync = () => setCoarsePointerNoHover(mq.matches); - sync(); - mq.addEventListener("change", sync); - return () => mq.removeEventListener("change", sync); - }, []); + const prefersNoHover = usePrefersNoHover(); return (
{ + const mq = window.matchMedia("(hover: none)"); + const sync = () => setPrefersNoHover(mq.matches); + sync(); + mq.addEventListener("change", sync); + return () => mq.removeEventListener("change", sync); + }, []); + + return prefersNoHover; +} From d597259551f7cae826cc9c80c630ed61962f60eb Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Fri, 17 Apr 2026 22:56:53 -0400 Subject: [PATCH 5/5] feat(dashboard): reactions on issue/PR descriptions Fetch GraphQL id and reactions on issue/PR detail, optimistic detail patching in the reaction bar, and inline layout below markdown. --- .../details/comment-reaction-bar.tsx | 237 +++++++++++------- .../issues/detail/issue-detail-header.tsx | 55 +++- .../issues/detail/issue-detail-page.tsx | 9 +- .../pulls/detail/pull-body-section.tsx | 49 +++- .../pulls/detail/pull-detail-page.tsx | 1 + apps/dashboard/src/lib/github.functions.ts | 46 +++- apps/dashboard/src/lib/github.types.ts | 6 + 7 files changed, 298 insertions(+), 105 deletions(-) diff --git a/apps/dashboard/src/components/details/comment-reaction-bar.tsx b/apps/dashboard/src/components/details/comment-reaction-bar.tsx index 4697ff3..1595624 100644 --- a/apps/dashboard/src/components/details/comment-reaction-bar.tsx +++ b/apps/dashboard/src/components/details/comment-reaction-bar.tsx @@ -63,6 +63,56 @@ function reactionActorTooltipText( return rest > 0 ? `${shown.join(", ")} +${rest}` : shown.join(", "); } +function applyReactionToggleToSummary( + base: CommentReactionSummary | undefined, + content: CommentReactionContent, + remove: boolean, + viewerLogin: string | undefined, +): CommentReactionSummary { + const b = base ?? { counts: {}, viewerReacted: [] }; + const counts = { ...b.counts }; + const viewerReacted = [...b.viewerReacted]; + const userLoginsByContent: Partial> = + { ...(b.userLoginsByContent ?? {}) }; + const loginsFor = [...(userLoginsByContent[content] ?? [])]; + + if (remove) { + counts[content] = Math.max(0, (counts[content] ?? 0) - 1); + const i = viewerReacted.indexOf(content); + if (i >= 0) { + viewerReacted.splice(i, 1); + } + if (viewerLogin) { + const li = loginsFor.lastIndexOf(viewerLogin); + if (li >= 0) { + loginsFor.splice(li, 1); + } + } + } else { + counts[content] = (counts[content] ?? 0) + 1; + if (!viewerReacted.includes(content)) { + viewerReacted.push(content); + } + if (viewerLogin && !loginsFor.includes(viewerLogin)) { + loginsFor.push(viewerLogin); + } + } + + if (loginsFor.length === 0) { + delete userLoginsByContent[content]; + } else { + userLoginsByContent[content] = loginsFor; + } + + return { + counts, + viewerReacted, + ...(Object.keys(userLoginsByContent).length > 0 + ? { userLoginsByContent } + : {}), + }; +} + function patchCommentReactions( prev: IssuePageData | PullPageData | undefined, commentId: number, @@ -80,51 +130,14 @@ function patchCommentReactions( return c; } changed = true; - const base = c.reactions ?? { counts: {}, viewerReacted: [] }; - const counts = { ...base.counts }; - const viewerReacted = [...base.viewerReacted]; - const userLoginsByContent: Partial< - Record - > = { ...(base.userLoginsByContent ?? {}) }; - const loginsFor = [...(userLoginsByContent[content] ?? [])]; - - if (remove) { - counts[content] = Math.max(0, (counts[content] ?? 0) - 1); - const i = viewerReacted.indexOf(content); - if (i >= 0) { - viewerReacted.splice(i, 1); - } - if (viewerLogin) { - const li = loginsFor.lastIndexOf(viewerLogin); - if (li >= 0) { - loginsFor.splice(li, 1); - } - } - } else { - counts[content] = (counts[content] ?? 0) + 1; - if (!viewerReacted.includes(content)) { - viewerReacted.push(content); - } - if (viewerLogin && !loginsFor.includes(viewerLogin)) { - loginsFor.push(viewerLogin); - } - } - - if (loginsFor.length === 0) { - delete userLoginsByContent[content]; - } else { - userLoginsByContent[content] = loginsFor; - } - return { ...c, - reactions: { - counts, - viewerReacted, - ...(Object.keys(userLoginsByContent).length > 0 - ? { userLoginsByContent } - : {}), - }, + reactions: applyReactionToggleToSummary( + c.reactions, + content, + remove, + viewerLogin, + ), }; }); @@ -134,30 +147,68 @@ function patchCommentReactions( return { ...prev, comments }; } -export function IssueCommentReactionBar({ - owner, - repo, - issueNumber, - commentId, - commentGraphqlId, - scope, - reactions, - className, - /** When true, show reactions that have zero total count (hover / focus-within). */ - revealZeroCount, - viewerLogin, -}: { +function patchDetailReactions( + prev: T | undefined, + content: CommentReactionContent, + remove: boolean, + viewerLogin: string | undefined, +): T | undefined { + if (!prev?.detail) { + return prev; + } + return { + ...prev, + detail: { + ...prev.detail, + reactions: applyReactionToggleToSummary( + prev.detail.reactions, + content, + remove, + viewerLogin, + ), + }, + }; +} + +type IssueCommentReactionBarSharedProps = { owner: string; repo: string; issueNumber: number; - commentId: number; commentGraphqlId: string; scope: GitHubQueryScope; reactions?: CommentReactionSummary; className?: string; + /** When true, show reactions that have zero total count (hover / focus-within). */ revealZeroCount: boolean; viewerLogin?: string | null; -}) { +}; + +export type IssueCommentReactionBarProps = + | (IssueCommentReactionBarSharedProps & { + variant?: "comment"; + commentId: number; + }) + | (IssueCommentReactionBarSharedProps & { + variant: "detail"; + detailPage: "issue" | "pull"; + }); + +export function IssueCommentReactionBar(props: IssueCommentReactionBarProps) { + const { + owner, + repo, + issueNumber, + commentGraphqlId, + scope, + reactions, + className, + revealZeroCount, + viewerLogin, + } = props; + const isDetail = props.variant === "detail"; + const detailPage = isDetail ? props.detailPage : undefined; + const commentId = !isDetail ? props.commentId : undefined; + const queryClient = useQueryClient(); const flight = useRef(false); @@ -177,17 +228,37 @@ export function IssueCommentReactionBar({ const prevIssue = queryClient.getQueryData(issuePageKey); const prevPull = queryClient.getQueryData(pullPageKey); const viewer = viewerLogin ?? undefined; - queryClient.setQueryData( - issuePageKey, - patchCommentReactions(prevIssue, commentId, content, remove, viewer), - ); - queryClient.setQueryData( - pullPageKey, - patchCommentReactions(prevPull, commentId, content, remove, viewer), - ); + if (isDetail && detailPage === "issue") { + queryClient.setQueryData( + issuePageKey, + patchDetailReactions(prevIssue, content, remove, viewer), + ); + } else if (isDetail && detailPage === "pull") { + queryClient.setQueryData( + pullPageKey, + patchDetailReactions(prevPull, content, remove, viewer), + ); + } else if (!isDetail && commentId != null) { + queryClient.setQueryData( + issuePageKey, + patchCommentReactions(prevIssue, commentId, content, remove, viewer), + ); + queryClient.setQueryData( + pullPageKey, + patchCommentReactions(prevPull, commentId, content, remove, viewer), + ); + } return { prevIssue, prevPull }; }, - [commentId, issuePageKey, pullPageKey, queryClient, viewerLogin], + [ + commentId, + detailPage, + isDetail, + issuePageKey, + pullPageKey, + queryClient, + viewerLogin, + ], ); const rollback = useCallback( @@ -214,7 +285,7 @@ export function IssueCommentReactionBar({ owner, repo, issueNumber, - commentId, + commentId: props.variant === "detail" ? 0 : props.commentId, commentGraphqlId, content, remove, @@ -249,7 +320,7 @@ export function IssueCommentReactionBar({ transition={reactionSpring} > - {orderedReactions.flatMap(({ content, emoji }) => { + {orderedReactions.map(({ content, emoji }) => { const count = counts[content] ?? 0; const active = reactions?.viewerReacted.includes(content) ?? false; const tooltipText = reactionActorTooltipText( @@ -298,21 +369,19 @@ export function IssueCommentReactionBar({ ); - return [ - count > 0 ? ( - - {chip} - - {tooltipText} - - - ) : ( - {chip} - ), - ]; + return count > 0 ? ( + + {chip} + + {tooltipText} + + + ) : ( + {chip} + ); })} diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-header.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-header.tsx index ff56f3d..8581a17 100644 --- a/apps/dashboard/src/components/issues/detail/issue-detail-header.tsx +++ b/apps/dashboard/src/components/issues/detail/issue-detail-header.tsx @@ -1,9 +1,13 @@ import { IssuesIcon } from "@diffkit/icons"; import { Markdown } from "@diffkit/ui/components/markdown"; import { cn } from "@diffkit/ui/lib/utils"; +import { useState } from "react"; +import { IssueCommentReactionBar } from "#/components/details/comment-reaction-bar"; import { DetailPageTitle } from "#/components/details/detail-page"; import { formatRelativeTime } from "#/lib/format-relative-time"; +import type { GitHubQueryScope } from "#/lib/github.query"; import type { IssueDetail } from "#/lib/github.types"; +import { usePrefersNoHover } from "#/lib/use-prefers-no-hover"; type IssueStateConfig = { color: string; @@ -37,11 +41,17 @@ export function IssueDetailHeader({ owner, repo, issue, + scope, + viewerLogin, }: { owner: string; repo: string; issue: IssueDetail; + scope: GitHubQueryScope; + viewerLogin?: string | null; }) { + const [descActive, setDescActive] = useState(false); + const prefersNoHover = usePrefersNoHover(); const stateConfig = getIssueStateConfig(issue); return ( @@ -82,17 +92,42 @@ export function IssueDetailHeader({ } /> - {issue.body ? ( -
- {issue.body} +
+
setDescActive(true)} + onPointerLeave={() => setDescActive(false)} + onFocusCapture={() => setDescActive(true)} + onBlurCapture={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { + setDescActive(false); + } + }} + > + {issue.body ? ( + {issue.body} + ) : ( +

+ No description provided. +

+ )} + {issue.graphqlId ? ( + + ) : null}
- ) : ( -
-

- No description provided. -

-
- )} +
); } diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx index 5770ab3..906e6c5 100644 --- a/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx +++ b/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx @@ -83,6 +83,7 @@ export function IssueDetailContent({ useGitHubSignalStream(webhookRefreshTargets); const issue = pageQuery.data?.detail; + const viewerLogin = viewerQuery.data?.login; const comments = pageQuery.data?.comments; const events = pageQuery.data?.events; const commentPagination = pageQuery.data?.commentPagination; @@ -108,7 +109,13 @@ export function IssueDetailContent({ - + @@ -145,13 +151,40 @@ export function PullBodySection({ )} - {pr.body ? ( - {pr.body} - ) : ( -

- No description provided. -

- )} +
setBodyActive(true)} + onPointerLeave={() => setBodyActive(false)} + onFocusCapture={() => setBodyActive(true)} + onBlurCapture={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { + setBodyActive(false); + } + }} + > + {pr.body ? ( + {pr.body} + ) : ( +

+ No description provided. +

+ )} + {pr.graphqlId ? ( + + ) : null} +
); } diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx index 17b7c88..9090c4a 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx @@ -153,6 +153,7 @@ export function PullDetailContent({ pullNumber={pullNumber} isAuthor={viewer?.login === pr.author?.login} scope={scope} + viewerLogin={viewer?.login} /> | null; + } | null; additions: number; deletions: number; changedFiles: number; @@ -343,6 +350,7 @@ type GitHubGraphQLPullPageResponse = { type GitHubGraphQLIssuePageResponse = { repository: { issue: { + id: string; databaseId: number | null; number: number; title: string; @@ -353,6 +361,12 @@ type GitHubGraphQLIssuePageResponse = { closedAt: string | null; url: string; body: string; + reactions: { + nodes: Array<{ + content: string; + user: { login: string } | null; + } | null> | null; + } | null; comments: { totalCount: number }; author: GitHubGraphQLActor; labels: { @@ -890,6 +904,10 @@ function mapPullDetail( ): PullDetail { return { ...mapPullSummary(pull, repository), + graphqlId: + "node_id" in pull && typeof pull.node_id === "string" + ? pull.node_id + : undefined, body: pull.body ?? "", additions: pull.additions, deletions: pull.deletions, @@ -960,6 +978,10 @@ function mapIssueDetail( ): IssueDetail { return { ...mapIssueSummary(issue, repository), + graphqlId: + "node_id" in issue && typeof issue.node_id === "string" + ? issue.node_id + : undefined, body: issue.body ?? "", assignees: (issue.assignees ?? []) .map((assignee) => mapActor(assignee)) @@ -1165,6 +1187,7 @@ function getLoadedCommentPages(totalComments: number) { function mapGraphQLPullDetail( pull: NonNullable, + viewerLogin: string | undefined, ): PullDetail { const requestedReviewers: GitHubActor[] = []; const requestedTeams: RequestedTeam[] = []; @@ -1192,6 +1215,7 @@ function mapGraphQLPullDetail( return { id: pull.databaseId ?? 0, + graphqlId: pull.id, number: pull.number, title: pull.title, state: pull.state.toLowerCase(), @@ -1205,6 +1229,10 @@ function mapGraphQLPullDetail( author: mapGraphQLActor(pull.author), labels: mapGraphQLLabels(pull.labels), repository: mapGraphQLRepositoryRef(pull.repository), + reactions: buildCommentReactionSummary( + pull.reactions?.nodes ?? undefined, + viewerLogin, + ), body: pull.body, additions: pull.additions, deletions: pull.deletions, @@ -1258,9 +1286,11 @@ function mapGraphQLPullCommits( function mapGraphQLIssueDetail( issue: NonNullable, + viewerLogin: string | undefined, ): IssueDetail { return { id: issue.databaseId ?? 0, + graphqlId: issue.id, number: issue.number, title: issue.title, state: issue.state.toLowerCase(), @@ -1273,6 +1303,10 @@ function mapGraphQLIssueDetail( author: mapGraphQLActor(issue.author), labels: mapGraphQLLabels(issue.labels), repository: mapGraphQLRepositoryRef(issue.repository), + reactions: buildCommentReactionSummary( + issue.reactions?.nodes ?? undefined, + viewerLogin, + ), body: issue.body, assignees: (issue.assignees?.nodes ?? []) .map((assignee) => mapGraphQLActor(assignee)) @@ -3701,6 +3735,7 @@ async function getPullPageDataViaGraphQL( `query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { + id databaseId number title @@ -3712,6 +3747,9 @@ async function getPullPageDataViaGraphQL( mergedAt url body + reactions(first: 100) { + nodes { content user { login } } + } additions deletions changedFiles @@ -3828,7 +3866,7 @@ async function getPullPageDataViaGraphQL( const totalComments = pull.comments.totalCount; const loadedPages = getLoadedCommentPages(totalComments); - const detail = mapGraphQLPullDetail(pull); + const detail = mapGraphQLPullDetail(pull, viewer?.login); const events = timelineResult.events; return { @@ -4034,6 +4072,7 @@ async function getIssuePageDataViaGraphQL( `query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { issue(number: $number) { + id databaseId number title @@ -4044,6 +4083,9 @@ async function getIssuePageDataViaGraphQL( closedAt url body + reactions(first: 100) { + nodes { content user { login } } + } comments { totalCount } author { __typename login avatarUrl url } labels(first: 20) { @@ -4129,7 +4171,7 @@ async function getIssuePageDataViaGraphQL( return { kind: "success", data: { - detail: mapGraphQLIssueDetail(issue), + detail: mapGraphQLIssueDetail(issue, viewer?.login), comments: mapGraphQLComments( viewer?.login, issue.firstComments, diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index c83f388..0fc3d17 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -65,6 +65,9 @@ export type RequestedTeam = { }; export type PullDetail = PullSummary & { + /** GraphQL node id (`I_kwD...` / `PR_kwD...`); required for reactions API */ + graphqlId?: string; + reactions?: CommentReactionSummary; body: string; additions: number; deletions: number; @@ -101,6 +104,9 @@ export type IssueSummary = { }; export type IssueDetail = IssueSummary & { + /** GraphQL node id (`I_kwD...`); required for reactions API */ + graphqlId?: string; + reactions?: CommentReactionSummary; body: string; assignees: GitHubActor[]; milestone: {