diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ddbc708..f09a1cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ DiffKit is a **pnpm monorepo** managed with **Turborepo**: - **TanStack Router** — File-based routing in `apps/dashboard/src/routes/` - **TanStack Query** — Server state management and caching - **Drizzle ORM** — Database schema and migrations in `apps/dashboard/src/db/` and `apps/dashboard/drizzle/` -- **Better Auth** — Authentication with a GitHub App +- **Better Auth** — Authentication with a GitHub OAuth App (+ GitHub App for webhooks) - **Cloudflare D1** — SQLite database at the edge ### Adding a New Route diff --git a/README.md b/README.md index 0a72503..c7694aa 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A fast, design-first GitHub dashboard for developers who want to stay on top of | Routing | TanStack Router (file-based) | | Data | TanStack Query + Octokit | | Database | Cloudflare D1 (SQLite) via Drizzle ORM | -| Auth | Better Auth with GitHub App | +| Auth | Better Auth with GitHub OAuth App + GitHub App | | Styling | Tailwind CSS 4 + Radix UI | | Icons | Lucide React | | Build | Vite 7 + Turborepo | @@ -32,7 +32,8 @@ A fast, design-first GitHub dashboard for developers who want to stay on top of - [Node.js](https://nodejs.org/) (v20+) - [pnpm](https://pnpm.io/) (v10+) -- A [GitHub App](https://github.com/settings/apps) +- A [GitHub OAuth App](https://github.com/settings/developers) (for user authentication) +- A [GitHub App](https://github.com/settings/apps) (for webhooks and installation management) ### Setup @@ -54,6 +55,8 @@ A fast, design-first GitHub dashboard for developers who want to stay on top of Create a `.dev.vars` file in `apps/dashboard/`: ``` + GITHUB_OAUTH_CLIENT_ID=your_oauth_app_client_id + GITHUB_OAUTH_CLIENT_SECRET=your_oauth_app_client_secret GITHUB_APP_CLIENT_ID=your_github_app_client_id GITHUB_APP_CLIENT_SECRET=your_github_app_client_secret GITHUB_WEBHOOK_SECRET=your_github_webhook_secret @@ -61,9 +64,19 @@ A fast, design-first GitHub dashboard for developers who want to stay on top of BETTER_AUTH_URL=http://localhost:3000 ``` - > DiffKit also accepts the legacy `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` names during migration, but new setups should use the `GITHUB_APP_*` names above. + > DiffKit also accepts the legacy `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` names as a fallback for the OAuth App credentials during migration. -4. **Create and install the GitHub App** +4. **Create the GitHub OAuth App** (for user authentication) + + In [OAuth App settings](https://github.com/settings/developers): + + - Click **New OAuth App** + - Set the callback URL to `http://localhost:3000/api/auth/callback/github` + - Note the **Client ID** and generate a **Client Secret** + + The OAuth App handles user login and provides a token with `repo` scope, which gives broad read access to public repositories (needed for cross-references and timeline events). + +5. **Create and install the GitHub App** (for webhooks and installations) In [GitHub App settings](https://github.com/settings/apps): @@ -116,13 +129,13 @@ A fast, design-first GitHub dashboard for developers who want to stay on top of For local Vite development, set `DEV_TUNNEL_URL` in `apps/dashboard/.dev.vars` to the full public tunnel URL, for example `https://your-subdomain.ngrok-free.app`. The dev server will use it to allow the tunnel host and configure HMR correctly. -5. **Run database migrations** +6. **Run database migrations** ```bash pnpm --filter dashboard migrate ``` -6. **Start the dev server** +7. **Start the dev server** ```bash pnpm dev diff --git a/apps/dashboard/.dev.vars.example b/apps/dashboard/.dev.vars.example index 6fe37be..11948ac 100644 --- a/apps/dashboard/.dev.vars.example +++ b/apps/dashboard/.dev.vars.example @@ -1,8 +1,16 @@ -# GitHub App credentials +# GitHub OAuth App credentials (used for user authentication) +# 1. Go to https://github.com/settings/developers > OAuth Apps > New OAuth App +# 2. Set the callback URL to http://localhost:3000/api/auth/callback/github +# 3. Note the Client ID and generate a Client Secret +# OAuth App tokens support scopes (repo, user:email) and don't expire. +GITHUB_OAUTH_CLIENT_ID= +GITHUB_OAUTH_CLIENT_SECRET= + +# GitHub App credentials (used for webhooks and installation management) # 1. Go to https://github.com/settings/apps -# 2. Create a new GitHub App +# 2. Create a new GitHub App (or use an existing one) # 3. Set the callback URL to http://localhost:3000/api/auth/callback/github -# 4. Under Permissions & events, grant Email addresses account permission: Read-only +# 4. Under Permissions & events, grant the permissions listed in the README # 5. Install the app on the repositories or organizations you want DiffKit to access GITHUB_APP_CLIENT_ID= GITHUB_APP_CLIENT_SECRET= @@ -17,7 +25,7 @@ GITHUB_WEBHOOK_SECRET= # Example: https://your-subdomain.ngrok-free.app DEV_TUNNEL_URL= -# Legacy OAuth-style names are still supported as a fallback during migration. +# Legacy OAuth-style names are still supported as a fallback for GITHUB_OAUTH_* during migration. GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= diff --git a/apps/dashboard/src/components/details/label-pill.tsx b/apps/dashboard/src/components/details/label-pill.tsx new file mode 100644 index 0000000..270d413 --- /dev/null +++ b/apps/dashboard/src/components/details/label-pill.tsx @@ -0,0 +1,29 @@ +import { cn } from "@diffkit/ui/lib/utils"; + +const sizes = { + sm: "px-1.5 py-px text-[10px]", + md: "px-2.5 py-0.5 text-xs", +}; + +export function LabelPill({ + name, + color, + size = "md", +}: { + name: string; + color: string; + size?: "sm" | "md"; +}) { + const hex = color.startsWith("#") ? color : `#${color}`; + return ( + + {name} + + ); +} 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 a9c283d..7ad9427 100644 --- a/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx +++ b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx @@ -1,22 +1,287 @@ +import { + ChevronDownIcon, + CircleIcon, + EditIcon, + GitPullRequestIcon, + IssuesIcon, + ReviewsIcon, + UserAddIcon, +} from "@diffkit/icons"; import { Markdown } from "@diffkit/ui/components/markdown"; import { cn } from "@diffkit/ui/lib/utils"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useRef, useState } from "react"; import { DetailActivityHeader, DetailCommentBox, } from "#/components/details/detail-activity"; +import { LabelPill } from "#/components/details/label-pill"; import { formatRelativeTime } from "#/lib/format-relative-time"; -import type { IssueComment } from "#/lib/github.types"; +import { getCommentPage, getTimelineEventPage } from "#/lib/github.functions"; +import type { + CommentPagination, + EventPagination, + IssueComment, + IssuePageData, + TimelineEvent, +} from "#/lib/github.types"; + +const WINDOW_THRESHOLD = 25; +const EDGE_SIZE = 10; +const LOAD_MORE_CHUNK = 20; + +type IssueTimelineItem = + | { type: "comment"; date: string; data: IssueComment } + | { type: "event"; date: string; data: TimelineEvent }; + +function useWindowedTimeline( + items: T[], + pagination?: { + commentPagination?: CommentPagination; + eventPagination?: EventPagination; + pageQueryKey: readonly unknown[]; + owner: string; + repo: string; + issueNumber: number; + }, +) { + const queryClient = useQueryClient(); + const [revealedCount, setRevealedCount] = useState(0); + const [isFetchingPage, setIsFetchingPage] = useState(false); + const loadedCommentPagesRef = useRef>(new Set()); + const loadedEventPagesRef = useRef>(new Set()); + const hasMoreEventsRef = useRef( + pagination?.eventPagination?.hasMore ?? false, + ); + + const totalCommentPages = pagination?.commentPagination + ? Math.max( + 1, + Math.ceil( + pagination.commentPagination.totalCount / + pagination.commentPagination.perPage, + ), + ) + : 1; + + // Sync loaded pages from pagination data + if (pagination?.commentPagination) { + for (const p of pagination.commentPagination.loadedPages) { + loadedCommentPagesRef.current.add(p); + } + } + if (pagination?.eventPagination) { + for (const p of pagination.eventPagination.loadedPages) { + loadedEventPagesRef.current.add(p); + } + hasMoreEventsRef.current = pagination.eventPagination.hasMore; + } + + const hasUnfetchedCommentPages = + totalCommentPages > 1 && + loadedCommentPagesRef.current.size < totalCommentPages; + const hasUnfetchedEventPages = hasMoreEventsRef.current; + const hasUnfetchedPages = hasUnfetchedCommentPages || hasUnfetchedEventPages; + + const needsWindowing = items.length > WINDOW_THRESHOLD || hasUnfetchedPages; + + const clientHiddenCount = needsWindowing + ? Math.max(0, items.length - EDGE_SIZE * 2 - revealedCount) + : 0; + + const hiddenCount = clientHiddenCount; + + const visibleItems = + needsWindowing && clientHiddenCount > 0 + ? [ + ...items.slice(0, EDGE_SIZE + revealedCount), + ...items.slice(items.length - EDGE_SIZE), + ] + : items; + + const showLoadMore = needsWindowing && (hiddenCount > 0 || hasUnfetchedPages); + const loadMoreIndex = showLoadMore ? EDGE_SIZE + revealedCount : -1; + + const loadMore = useCallback(() => { + // If we have client-side hidden items, reveal a chunk first + if (clientHiddenCount > 0) { + setRevealedCount((prev) => prev + LOAD_MORE_CHUNK); + return; + } + + if (!pagination) return; + + const hasMoreComments = hasUnfetchedCommentPages; + const hasMoreEvents = hasUnfetchedEventPages; + + if (!hasMoreComments && !hasMoreEvents) return; + + setIsFetchingPage(true); + + const fetches: Promise[] = []; + + // Fetch next comment page + if (hasMoreComments) { + let nextPage = -1; + for (let p = 2; p < totalCommentPages; p++) { + if (!loadedCommentPagesRef.current.has(p)) { + nextPage = p; + break; + } + } + if (nextPage !== -1) { + fetches.push( + getCommentPage({ + data: { + owner: pagination.owner, + repo: pagination.repo, + issueNumber: pagination.issueNumber, + page: nextPage, + }, + }).then((result) => { + const newComments = result.comments; + loadedCommentPagesRef.current.add(nextPage); + queryClient.setQueryData( + pagination.pageQueryKey, + (prev: IssuePageData | undefined) => { + if (!prev) return prev; + const existingIds = new Set(prev.comments.map((c) => c.id)); + const uniqueNew = newComments.filter( + (c) => !existingIds.has(c.id), + ); + return { + ...prev, + comments: [...prev.comments, ...uniqueNew], + commentPagination: { + ...prev.commentPagination, + loadedPages: [...loadedCommentPagesRef.current], + }, + }; + }, + ); + }), + ); + } + } + + // Fetch next event page + if (hasMoreEvents) { + const nextEventPage = Math.max(...loadedEventPagesRef.current, 0) + 1; + fetches.push( + getTimelineEventPage({ + data: { + owner: pagination.owner, + repo: pagination.repo, + issueNumber: pagination.issueNumber, + page: nextEventPage, + }, + }).then((result) => { + loadedEventPagesRef.current.add(nextEventPage); + hasMoreEventsRef.current = result.hasMore; + queryClient.setQueryData( + pagination.pageQueryKey, + (prev: IssuePageData | undefined) => { + if (!prev) return prev; + const existingKeys = new Set( + prev.events.map((e) => `${e.event}-${e.id}-${e.createdAt}`), + ); + const uniqueNew = result.events.filter( + (e) => !existingKeys.has(`${e.event}-${e.id}-${e.createdAt}`), + ); + return { + ...prev, + events: [...prev.events, ...uniqueNew], + eventPagination: { + loadedPages: [...loadedEventPagesRef.current], + hasMore: result.hasMore, + }, + }; + }, + ); + }), + ); + } + + Promise.all(fetches).finally(() => { + setIsFetchingPage(false); + }); + }, [ + clientHiddenCount, + pagination, + hasUnfetchedCommentPages, + hasUnfetchedEventPages, + totalCommentPages, + queryClient, + ]); + + return { + visibleItems, + hiddenCount, + loadMoreIndex, + loadMore, + isFetchingPage, + hasMorePages: hasUnfetchedPages, + }; +} export function IssueDetailActivitySection({ comments, + events, + commentPagination, + eventPagination, + pageQueryKey, isFetching, + owner, + repo, + issueNumber, }: { comments?: IssueComment[]; + events?: TimelineEvent[]; + commentPagination?: CommentPagination; + eventPagination?: EventPagination; + pageQueryKey: readonly unknown[]; isFetching: boolean; + owner: string; + repo: string; + issueNumber: number; }) { + const allItems: IssueTimelineItem[] = [ + ...(comments ?? []).map((comment) => ({ + type: "comment" as const, + date: comment.createdAt, + data: comment, + })), + ...(events ?? []).map((event) => ({ + type: "event" as const, + date: event.createdAt, + data: event, + })), + ].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + + const totalCount = (comments?.length ?? 0) + (events?.length ?? 0); + + const { + visibleItems, + hiddenCount, + hasMorePages, + loadMoreIndex, + loadMore, + isFetchingPage, + } = useWindowedTimeline(allItems, { + commentPagination, + eventPagination, + pageQueryKey, + owner, + repo, + issueNumber, + }); + return (
- + {isFetching && !comments && (
@@ -44,39 +309,116 @@ export function IssueDetailActivitySection({
)} - {comments && comments.length === 0 && ( -

No comments yet.

+ {visibleItems.length === 0 && comments && ( +

No activity yet.

)} - {comments && comments.length > 0 && ( + {visibleItems.length > 0 && (
- {comments.map((comment, index) => ( -
-
- {comment.author ? ( - {comment.author.login} { + const previousType = + index > 0 ? visibleItems[index - 1].type : null; + const nextType = + index < visibleItems.length - 1 + ? visibleItems[index + 1].type + : null; + const isConsecutiveEvent = + item.type === "event" && previousType === "event"; + const isLastInEventRun = + item.type === "event" && nextType !== "event"; + + const row = (() => { + if (item.type === "comment") { + const comment = item.data; + return ( +
+
+ {comment.author ? ( + {comment.author.login} + ) : ( +
+ )} + + {comment.author?.login ?? "Unknown"} + + + {formatRelativeTime(comment.createdAt)} + +
+ + {comment.body} + +
+ ); + } + + const event = item.data; + const icon = getIssueEventIcon(event); + const description = getIssueEventDescription(event); + const isCrossRef = + event.event === "cross-referenced" || + event.event === "referenced"; + const hasActorAvatar = !isCrossRef && event.actor?.avatarUrl; + + if (!description) return null; + + return ( +
+ {hasActorAvatar ? ( + {event.actor?.login} + ) : ( +
+ {icon} +
+ )} + + {description} + + {event.createdAt && ( + + {formatRelativeTime(event.createdAt)} + + )} +
+ ); + })(); + + return ( + <> + {index === loadMoreIndex && ( + - ) : ( -
)} - - {comment.author?.login ?? "Unknown"} - - - {formatRelativeTime(comment.createdAt)} - -
- - {comment.body} - -
- ))} + {row} + + ); + })}
)} @@ -86,3 +428,307 @@ export function IssueDetailActivitySection({
); } + +// ── Load more divider ─────────────────────────────────────────────── + +function LoadMoreDivider({ + hiddenCount, + hasMorePages, + isPending, + onLoadMore, +}: { + hiddenCount: number; + hasMorePages?: boolean; + isPending?: boolean; + onLoadMore: () => void; +}) { + const label = + hiddenCount > 0 + ? `${hiddenCount}${hasMorePages ? "+" : ""} more ${hiddenCount === 1 && !hasMorePages ? "item" : "items"}` + : "Load more"; + + return ( +
+
+ +
+
+ ); +} + +// ── Event rendering helpers ───────────────────────────────────────── + +function getIssueEventIcon(event: TimelineEvent) { + switch (event.event) { + case "labeled": + case "unlabeled": + return ( + + ); + case "assigned": + case "unassigned": + return ( + + ); + case "renamed": + return ( + + ); + case "closed": + return ( + + ); + case "reopened": + return ( + + ); + case "cross-referenced": + case "referenced": + return ( + + ); + case "milestoned": + case "demilestoned": + return ( + + ); + case "review_requested": + case "review_request_removed": + return ( + + ); + default: + return ( + + ); + } +} + +function ActorMention({ + actor, + hideAvatar, +}: { + actor: { login: string; avatarUrl?: string } | null | undefined; + hideAvatar?: boolean; +}) { + const login = actor?.login ?? "someone"; + return ( + + {!hideAvatar && actor?.avatarUrl && ( + {login} + )} + {login} + + ); +} + +function getIssueEventDescription(event: TimelineEvent): React.ReactNode { + const reviewer = + event.requestedReviewer ?? + (event.requestedTeam ? { login: event.requestedTeam.name } : null); + + switch (event.event) { + case "labeled": + return ( + + + {" added "} + {event.label && ( + + )} + + ); + case "unlabeled": + return ( + + + {" removed "} + {event.label && ( + + )} + + ); + case "assigned": + return ( + + + {" assigned "} + + + ); + case "unassigned": + return ( + + + {" unassigned "} + + + ); + case "renamed": + return ( + + + {" changed the title from "} + {event.rename?.from} + {" to "} + + {event.rename?.to} + + + ); + case "closed": + return ( + + + {" closed this"} + + ); + case "reopened": + return ( + + + {" reopened this"} + + ); + case "cross-referenced": + case "referenced": { + if (!event.source) return null; + const prefix = event.source.type === "pull_request" ? "PR" : "Issue"; + return ( + + + {" mentioned this in "} + + {event.source.repository + ? `${event.source.repository}#${event.source.number}` + : `${prefix} #${event.source.number}`} + + {event.source.title} + + ); + } + case "milestoned": + return ( + + + {" added this to the "} + + {event.milestone?.title} + + {" milestone"} + + ); + case "demilestoned": + return ( + + + {" removed this from the "} + + {event.milestone?.title} + + {" milestone"} + + ); + case "review_requested": + return ( + + + {" requested review from "} + + + ); + case "review_request_removed": + return ( + + + {" removed review request for "} + + + ); + default: + return null; + } +} 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 43684ed..1979bb2 100644 --- a/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx +++ b/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx @@ -5,7 +5,10 @@ import { DetailPageLayout, DetailPageSkeletonLayout, } from "#/components/details/detail-page"; -import { githubIssuePageQueryOptions } from "#/lib/github.query"; +import { + githubIssuePageQueryOptions, + githubQueryKeys, +} from "#/lib/github.query"; import { useHasMounted } from "#/lib/use-has-mounted"; import { useRegisterTab } from "#/lib/use-register-tab"; import { IssueDetailActivitySection } from "./issue-detail-activity"; @@ -28,6 +31,9 @@ export function IssueDetailPage() { const issue = pageQuery.data?.detail; const comments = pageQuery.data?.comments; + const events = pageQuery.data?.events; + const commentPagination = pageQuery.data?.commentPagination; + const eventPagination = pageQuery.data?.eventPagination; useRegisterTab( issue @@ -52,7 +58,18 @@ export function IssueDetailPage() { } diff --git a/apps/dashboard/src/components/layouts/github-access-dialog.tsx b/apps/dashboard/src/components/layouts/github-access-dialog.tsx index 03c68d8..6e50bd0 100644 --- a/apps/dashboard/src/components/layouts/github-access-dialog.tsx +++ b/apps/dashboard/src/components/layouts/github-access-dialog.tsx @@ -149,7 +149,7 @@ export function GitHubAccessDialog({ userId }: { userId: string }) { type AccessTarget = { login: string; type: "personal" | "org"; - installed: boolean; + status: "installed" | "not-installed" | "unknown"; scope: "all" | "selected" | null; href: string | null; isHighlighted: boolean; @@ -160,11 +160,16 @@ function buildTargets( highlightedOwner: string | null, ): AccessTarget[] { const targets: AccessTarget[] = []; + const canDetect = state.installationsAvailable; targets.push({ login: state.viewerLogin, type: "personal", - installed: !!state.personalInstallation, + status: canDetect + ? state.personalInstallation + ? "installed" + : "not-installed" + : "unknown", scope: state.personalInstallation ? state.personalInstallation.repositorySelection === "selected" ? "selected" @@ -180,7 +185,11 @@ function buildTargets( targets.push({ login: org.login, type: "org", - installed: !!installation, + status: canDetect + ? installation + ? "installed" + : "not-installed" + : "unknown", scope: installation ? installation.repositorySelection === "selected" ? "selected" @@ -215,7 +224,7 @@ function AccessList({ target.isHighlighted && "bg-accent/55", )} > - +
@@ -230,11 +239,13 @@ function AccessList({ ) : null}

- {target.installed + {target.status === "installed" ? target.scope === "selected" ? "Installed · selected repositories" : "Installed" - : "Not installed"} + : target.status === "not-installed" + ? "Not installed" + : "Check installation status on GitHub"} {target.type === "personal" ? " · personal" : " · org"}

@@ -242,12 +253,12 @@ function AccessList({ {target.href ? ( ) : null} @@ -265,12 +276,20 @@ function AccessList({ ); } -function StatusDot({ installed }: { installed: boolean }) { +function StatusDot({ + status, +}: { + status: "installed" | "not-installed" | "unknown"; +}) { return (
); 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 d4de677..127a6d7 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx @@ -2,10 +2,15 @@ import { CheckIcon, ChevronDownIcon, ChevronUpIcon, + CircleIcon, + EditIcon, GitCommitIcon, GitMergeIcon, + GitPullRequestIcon, MoreHorizontalIcon, RefreshCwIcon, + ReviewsIcon, + UserAddIcon, XIcon, } from "@diffkit/icons"; import { Button } from "@diffkit/ui/components/button"; @@ -27,14 +32,17 @@ import { toast } from "@diffkit/ui/components/sonner"; import { Spinner } from "@diffkit/ui/components/spinner"; import { cn } from "@diffkit/ui/lib/utils"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { DetailActivityHeader, DetailCommentBox, } from "#/components/details/detail-activity"; +import { LabelPill } from "#/components/details/label-pill"; import { formatRelativeTime } from "#/lib/format-relative-time"; import { dismissPullReview, + getCommentPage, + getTimelineEventPage, mergePullRequest, requestPullReviewers, updatePullBranch, @@ -44,17 +52,25 @@ import { githubPullStatusQueryOptions, } from "#/lib/github.query"; import type { + CommentPagination, + EventPagination, PullCheckRun, PullComment, PullCommit, PullDetail, + PullPageData, PullStatus, + TimelineEvent, } from "#/lib/github.types"; import { checkPermissionWarning } from "#/lib/warning-store"; export function PullDetailActivitySection({ comments, commits, + events, + commentPagination, + eventPagination, + pageQueryKey, isFetching, pr, owner, @@ -64,6 +80,10 @@ export function PullDetailActivitySection({ }: { comments?: PullComment[]; commits?: PullCommit[]; + events?: TimelineEvent[]; + commentPagination?: CommentPagination; + eventPagination?: EventPagination; + pageQueryKey: readonly unknown[]; isFetching: boolean; pr: PullDetail; owner: string; @@ -76,7 +96,9 @@ export function PullDetailActivitySection({ @@ -106,14 +128,25 @@ export function PullDetailActivitySection({
)} - {comments && commits && comments.length === 0 && commits.length === 0 && ( -

No activity yet.

- )} + {comments && + commits && + comments.length === 0 && + commits.length === 0 && + (!events || events.length === 0) && ( +

No activity yet.

+ )} {!pr.isMerged && pr.state !== "closed" && ( @@ -949,18 +982,228 @@ function MergeStatusSkeleton() { type TimelineItem = | { type: "comment"; date: string; data: PullComment } - | { type: "commit"; date: string; data: PullCommit }; + | { type: "commit"; date: string; data: PullCommit } + | { type: "event"; date: string; data: TimelineEvent }; + +const WINDOW_THRESHOLD = 25; +const EDGE_SIZE = 10; +const LOAD_MORE_CHUNK = 20; + +function useWindowedTimeline( + items: T[], + pagination?: { + commentPagination?: CommentPagination; + eventPagination?: EventPagination; + pageQueryKey: readonly unknown[]; + owner: string; + repo: string; + issueNumber: number; + }, +) { + const queryClient = useQueryClient(); + const [revealedCount, setRevealedCount] = useState(0); + const [isFetchingPage, setIsFetchingPage] = useState(false); + const loadedCommentPagesRef = useRef>(new Set()); + const loadedEventPagesRef = useRef>(new Set()); + const hasMoreEventsRef = useRef( + pagination?.eventPagination?.hasMore ?? false, + ); + + const totalCommentPages = pagination?.commentPagination + ? Math.max( + 1, + Math.ceil( + pagination.commentPagination.totalCount / + pagination.commentPagination.perPage, + ), + ) + : 1; + + // Sync loaded pages from pagination data + if (pagination?.commentPagination) { + for (const p of pagination.commentPagination.loadedPages) { + loadedCommentPagesRef.current.add(p); + } + } + if (pagination?.eventPagination) { + for (const p of pagination.eventPagination.loadedPages) { + loadedEventPagesRef.current.add(p); + } + hasMoreEventsRef.current = pagination.eventPagination.hasMore; + } + + const hasUnfetchedCommentPages = + totalCommentPages > 1 && + loadedCommentPagesRef.current.size < totalCommentPages; + const hasUnfetchedEventPages = hasMoreEventsRef.current; + const hasUnfetchedPages = hasUnfetchedCommentPages || hasUnfetchedEventPages; + + const needsWindowing = items.length > WINDOW_THRESHOLD || hasUnfetchedPages; + + const clientHiddenCount = needsWindowing + ? Math.max(0, items.length - EDGE_SIZE * 2 - revealedCount) + : 0; + + const hiddenCount = clientHiddenCount; + + const visibleItems = + needsWindowing && clientHiddenCount > 0 + ? [ + ...items.slice(0, EDGE_SIZE + revealedCount), + ...items.slice(items.length - EDGE_SIZE), + ] + : items; + + const showLoadMore = needsWindowing && (hiddenCount > 0 || hasUnfetchedPages); + const loadMoreIndex = showLoadMore ? EDGE_SIZE + revealedCount : -1; + + const loadMore = useCallback(() => { + // If we have client-side hidden items, reveal a chunk first + if (clientHiddenCount > 0) { + setRevealedCount((prev) => prev + LOAD_MORE_CHUNK); + return; + } + + if (!pagination) return; + + // Fetch next comment page or event page + const hasMoreComments = hasUnfetchedCommentPages; + const hasMoreEvents = hasUnfetchedEventPages; + + if (!hasMoreComments && !hasMoreEvents) return; + + setIsFetchingPage(true); + + const fetches: Promise[] = []; + + // Fetch next comment page + if (hasMoreComments) { + let nextPage = -1; + for (let p = 2; p < totalCommentPages; p++) { + if (!loadedCommentPagesRef.current.has(p)) { + nextPage = p; + break; + } + } + if (nextPage !== -1) { + fetches.push( + getCommentPage({ + data: { + owner: pagination.owner, + repo: pagination.repo, + issueNumber: pagination.issueNumber, + page: nextPage, + }, + }).then((result) => { + const newComments = result.comments; + loadedCommentPagesRef.current.add(nextPage); + queryClient.setQueryData( + pagination.pageQueryKey, + (prev: PullPageData | undefined) => { + if (!prev) return prev; + const existingIds = new Set(prev.comments.map((c) => c.id)); + const uniqueNew = newComments.filter( + (c) => !existingIds.has(c.id), + ); + return { + ...prev, + comments: [...prev.comments, ...uniqueNew], + commentPagination: { + ...prev.commentPagination, + loadedPages: [...loadedCommentPagesRef.current], + }, + }; + }, + ); + }), + ); + } + } + + // Fetch next event page + if (hasMoreEvents) { + const nextEventPage = Math.max(...loadedEventPagesRef.current, 0) + 1; + fetches.push( + getTimelineEventPage({ + data: { + owner: pagination.owner, + repo: pagination.repo, + issueNumber: pagination.issueNumber, + page: nextEventPage, + }, + }).then((result) => { + loadedEventPagesRef.current.add(nextEventPage); + hasMoreEventsRef.current = result.hasMore; + queryClient.setQueryData( + pagination.pageQueryKey, + (prev: PullPageData | undefined) => { + if (!prev) return prev; + const existingKeys = new Set( + prev.events.map((e) => `${e.event}-${e.id}-${e.createdAt}`), + ); + const uniqueNew = result.events.filter( + (e) => !existingKeys.has(`${e.event}-${e.id}-${e.createdAt}`), + ); + return { + ...prev, + events: [...prev.events, ...uniqueNew], + eventPagination: { + loadedPages: [...loadedEventPagesRef.current], + hasMore: result.hasMore, + }, + }; + }, + ); + }), + ); + } + + Promise.all(fetches).finally(() => { + setIsFetchingPage(false); + }); + }, [ + clientHiddenCount, + pagination, + hasUnfetchedCommentPages, + hasUnfetchedEventPages, + totalCommentPages, + queryClient, + ]); + + return { + visibleItems, + hiddenCount, + hasMorePages: hasUnfetchedPages, + loadMoreIndex, + loadMore, + isFetchingPage, + }; +} function ActivityTimeline({ comments, commits, + events, pr, + commentPagination, + eventPagination, + pageQueryKey, + owner, + repo, + pullNumber, }: { comments: PullComment[]; commits: PullCommit[]; + events: TimelineEvent[]; pr: PullDetail; + commentPagination?: CommentPagination; + eventPagination?: EventPagination; + pageQueryKey: readonly unknown[]; + owner: string; + repo: string; + pullNumber: number; }) { - const items: TimelineItem[] = [ + const allItems: TimelineItem[] = [ ...comments.map((comment) => ({ type: "comment" as const, date: comment.createdAt, @@ -971,87 +1214,148 @@ function ActivityTimeline({ date: commit.createdAt, data: commit, })), + ...events + .filter((event) => !(event.event === "closed" && pr.isMerged)) + .map((event) => ({ + type: "event" as const, + date: event.createdAt, + data: event, + })), ].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); - if (items.length === 0 && !pr.isMerged) return null; + const { + visibleItems, + hiddenCount, + hasMorePages, + loadMoreIndex, + loadMore, + isFetchingPage, + } = useWindowedTimeline(allItems, { + commentPagination, + eventPagination, + pageQueryKey, + owner, + repo, + issueNumber: pullNumber, + }); + + if (visibleItems.length === 0 && !pr.isMerged) return null; return (
- {items.map((item, index) => { - const previousType = index > 0 ? items[index - 1].type : null; + {visibleItems.map((item, index) => { + const previousType = index > 0 ? visibleItems[index - 1].type : null; const nextType = - index < items.length - 1 ? items[index + 1].type : null; + index < visibleItems.length - 1 ? visibleItems[index + 1].type : null; const isConsecutiveCommit = item.type === "commit" && previousType === "commit"; const isLastInCommitRun = item.type === "commit" && nextType !== "commit"; + const isConsecutiveEvent = + item.type === "event" && previousType === "event"; + const isLastInEventRun = item.type === "event" && nextType !== "event"; - if (item.type === "comment") { - const comment = item.data; - return ( -
-
- {comment.author ? ( + const row = (() => { + if (item.type === "comment") { + const comment = item.data; + return ( +
+
+ {comment.author ? ( + {comment.author.login} + ) : ( +
+ )} + + {comment.author?.login ?? "Unknown"} + + + {formatRelativeTime(comment.createdAt)} + +
+ + {comment.body} + +
+ ); + } + + if (item.type === "commit") { + const commit = item.data; + const firstLine = commit.message.split("\n")[0]; + return ( +
+
+ +
+ {commit.author ? ( {comment.author.login} ) : ( -
+
)} - - {comment.author?.login ?? "Unknown"} + + {firstLine} - - {formatRelativeTime(comment.createdAt)} + + {commit.sha.slice(0, 7)} + + + {formatRelativeTime(commit.createdAt)}
- - {comment.body} - -
+ ); + } + + const event = item.data; + return ( + ); - } + })(); - const commit = item.data; - const firstLine = commit.message.split("\n")[0]; return ( -
-
- -
- {commit.author ? ( - {commit.author.login} + {index === loadMoreIndex && ( + - ) : ( -
)} - {firstLine} - - {commit.sha.slice(0, 7)} - - - {formatRelativeTime(commit.createdAt)} - -
+ {row} + ); })} {pr.isMerged && pr.mergedAt && ( @@ -1068,7 +1372,7 @@ function ActivityTimeline({ ) : (
)} - + {pr.mergedBy?.login ?? "Unknown"} @@ -1083,7 +1387,7 @@ function ActivityTimeline({ {pr.baseRefName} - + {formatRelativeTime(pr.mergedAt)}
@@ -1091,3 +1395,414 @@ function ActivityTimeline({
); } + +function LoadMoreDivider({ + hiddenCount, + hasMorePages, + isPending, + onLoadMore, +}: { + hiddenCount: number; + hasMorePages?: boolean; + isPending?: boolean; + onLoadMore: () => void; +}) { + const label = + hiddenCount > 0 + ? `${hiddenCount}${hasMorePages ? "+" : ""} more ${hiddenCount === 1 && !hasMorePages ? "item" : "items"}` + : "Load more"; + + return ( +
+
+ +
+
+ ); +} + +// ── Timeline event row ────────────────────────────────────────────── + +function TimelineEventRow({ + event, + isFirst, + isConsecutive, + isLastInRun, +}: { + event: TimelineEvent; + isFirst: boolean; + isConsecutive: boolean; + isLastInRun: boolean; +}) { + const icon = getEventIcon(event); + const description = getEventDescription(event); + const isCrossRef = + event.event === "cross-referenced" || event.event === "referenced"; + const hasActorAvatar = !isCrossRef && event.actor?.avatarUrl; + + if (!description) return null; + + return ( +
+ {hasActorAvatar ? ( + {event.actor?.login} + ) : ( +
+ {icon} +
+ )} + + {description} + + {event.createdAt && ( + + {formatRelativeTime(event.createdAt)} + + )} +
+ ); +} + +function getEventIcon(event: TimelineEvent) { + switch (event.event) { + case "labeled": + case "unlabeled": + return ( + + ); + case "assigned": + case "unassigned": + return ( + + ); + case "review_requested": + case "review_request_removed": + return ( + + ); + case "reviewed": + return ( + + ); + case "renamed": + return ( + + ); + case "closed": + return ( + + ); + case "reopened": + return ( + + ); + case "cross-referenced": + case "referenced": + return ( + + ); + case "milestoned": + case "demilestoned": + return ( + + ); + case "convert_to_draft": + return ( + + ); + case "ready_for_review": + return ( + + ); + default: + return ( + + ); + } +} + +function ActorMention({ + actor, + hideAvatar, +}: { + actor: { login: string; avatarUrl?: string } | null | undefined; + hideAvatar?: boolean; +}) { + const login = actor?.login ?? "someone"; + return ( + + {!hideAvatar && actor?.avatarUrl && ( + {login} + )} + {login} + + ); +} + +function getEventDescription(event: TimelineEvent): React.ReactNode { + const reviewer = + event.requestedReviewer ?? + (event.requestedTeam ? { login: event.requestedTeam.name } : null); + + switch (event.event) { + case "labeled": + return ( + + + {" added "} + {event.label && ( + + )} + + ); + case "unlabeled": + return ( + + + {" removed "} + {event.label && ( + + )} + + ); + case "assigned": + return ( + + + {" assigned "} + + + ); + case "unassigned": + return ( + + + {" unassigned "} + + + ); + case "review_requested": + return ( + + + {" requested review from "} + + + ); + case "review_request_removed": + return ( + + + {" removed review request for "} + + + ); + case "reviewed": { + const state = event.reviewState?.toLowerCase(); + const stateLabel = + state === "approved" + ? "approved" + : state === "changes_requested" + ? "requested changes" + : "reviewed"; + return ( + + + {` ${stateLabel}`} + + ); + } + case "renamed": + return ( + + + {" changed the title from "} + {event.rename?.from} + {" to "} + + {event.rename?.to} + + + ); + case "closed": + return ( + + + {" closed this"} + + ); + case "reopened": + return ( + + + {" reopened this"} + + ); + case "cross-referenced": + case "referenced": { + if (!event.source) return null; + const prefix = event.source.type === "pull_request" ? "PR" : "Issue"; + return ( + + + {" mentioned this in "} + + {event.source.repository + ? `${event.source.repository}#${event.source.number}` + : `${prefix} #${event.source.number}`} + + {event.source.title} + + ); + } + case "milestoned": + return ( + + + {" added this to the "} + + {event.milestone?.title} + + {" milestone"} + + ); + case "demilestoned": + return ( + + + {" removed this from the "} + + {event.milestone?.title} + + {" milestone"} + + ); + case "convert_to_draft": + return ( + + + {" marked this as a draft"} + + ); + case "ready_for_review": + return ( + + + {" marked this as ready for review"} + + ); + default: + return 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 c6f7251..d779a75 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx @@ -7,6 +7,7 @@ import { } from "#/components/details/detail-page"; import { githubPullPageQueryOptions, + githubQueryKeys, githubViewerQueryOptions, } from "#/lib/github.query"; import { useHasMounted } from "#/lib/use-has-mounted"; @@ -37,6 +38,9 @@ export function PullDetailPage() { const pr = pageQuery.data?.detail; const comments = pageQuery.data?.comments; const commits = pageQuery.data?.commits; + const events = pageQuery.data?.events; + const commentPagination = pageQuery.data?.commentPagination; + const eventPagination = pageQuery.data?.eventPagination; const viewer = viewerQuery.data ?? null; useRegisterTab( @@ -79,6 +83,14 @@ export function PullDetailPage() { >(); - type WorkerEnvRecord = typeof env & Record; -type GitHubAccountRecord = typeof account.$inferSelect; - -type GitHubTokenRefreshSuccess = { - access_token: string; - expires_in?: number; - refresh_token?: string; - refresh_token_expires_in?: number; - scope?: string; -}; - -type GitHubTokenRefreshFailure = { - error: string; - error_description?: string; -}; function getWorkerEnv() { return env as WorkerEnvRecord; @@ -33,114 +14,53 @@ function pickFirstNonEmpty(...values: Array) { return values.find((value) => typeof value === "string" && value.length > 0); } -function toFutureDate(seconds?: number) { - if ( - typeof seconds !== "number" || - !Number.isFinite(seconds) || - seconds <= 0 - ) { - return null; - } - - return new Date(Date.now() + seconds * 1_000); -} - -function needsGitHubAccessTokenRefresh(githubAccount: GitHubAccountRecord) { - if (!githubAccount.accessTokenExpiresAt) { - return false; - } - - return ( - githubAccount.accessTokenExpiresAt.getTime() <= - Date.now() + GITHUB_TOKEN_REFRESH_BUFFER_MS - ); -} - -export function getGitHubAppAuthConfig() { +/** + * Returns the classic OAuth App credentials used for user authentication. + * OAuth App tokens support scopes (e.g. `repo`) and don't expire. + */ +export function getGitHubOAuthConfig() { const workerEnv = getWorkerEnv(); const clientId = pickFirstNonEmpty( - workerEnv.GITHUB_APP_CLIENT_ID, + workerEnv.GITHUB_OAUTH_CLIENT_ID, workerEnv.GITHUB_CLIENT_ID, ); const clientSecret = pickFirstNonEmpty( - workerEnv.GITHUB_APP_CLIENT_SECRET, + workerEnv.GITHUB_OAUTH_CLIENT_SECRET, workerEnv.GITHUB_CLIENT_SECRET, ); if (!clientId || !clientSecret) { throw new Error( - "Missing GitHub app credentials. Set GITHUB_APP_CLIENT_ID and GITHUB_APP_CLIENT_SECRET.", + "Missing GitHub OAuth credentials. Set GITHUB_OAUTH_CLIENT_ID and GITHUB_OAUTH_CLIENT_SECRET.", ); } - return { - clientId, - clientSecret, - }; + return { clientId, clientSecret }; } -export function getGitHubAppSlug(): string | null { - return pickFirstNonEmpty(getWorkerEnv().GITHUB_APP_SLUG) ?? null; -} - -export function getGitHubWebhookSecret() { - return pickFirstNonEmpty(getWorkerEnv().GITHUB_WEBHOOK_SECRET) ?? null; -} - -async function refreshGitHubAccessToken(githubAccount: GitHubAccountRecord) { - const { clientId, clientSecret } = getGitHubAppAuthConfig(); +/** + * Returns the GitHub App credentials used for webhooks and installation management. + */ +export function getGitHubAppAuthConfig() { + const workerEnv = getWorkerEnv(); + const clientId = pickFirstNonEmpty(workerEnv.GITHUB_APP_CLIENT_ID); + const clientSecret = pickFirstNonEmpty(workerEnv.GITHUB_APP_CLIENT_SECRET); - if (!githubAccount.refreshToken) { + if (!clientId || !clientSecret) { throw new Error( - "GitHub access token expired and no refresh token is available.", + "Missing GitHub App credentials. Set GITHUB_APP_CLIENT_ID and GITHUB_APP_CLIENT_SECRET.", ); } - const body = new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - grant_type: "refresh_token", - refresh_token: githubAccount.refreshToken, - }); - - const response = await fetch(GITHUB_ACCESS_TOKEN_ENDPOINT, { - method: "POST", - headers: { - accept: "application/json", - "content-type": "application/x-www-form-urlencoded", - }, - body: body.toString(), - }); - - const payload = (await response.json()) as - | GitHubTokenRefreshSuccess - | GitHubTokenRefreshFailure; - - if (!response.ok || "error" in payload || !payload.access_token) { - throw new Error( - "error" in payload - ? `GitHub token refresh failed: ${payload.error}` - : "GitHub token refresh failed.", - ); - } + return { clientId, clientSecret }; +} - const db = getDb(); - await db - .update(account) - .set({ - accessToken: payload.access_token, - refreshToken: payload.refresh_token ?? githubAccount.refreshToken, - accessTokenExpiresAt: - toFutureDate(payload.expires_in) ?? githubAccount.accessTokenExpiresAt, - refreshTokenExpiresAt: - toFutureDate(payload.refresh_token_expires_in) ?? - githubAccount.refreshTokenExpiresAt, - scope: payload.scope ?? githubAccount.scope, - updatedAt: new Date(), - }) - .where(eq(account.id, githubAccount.id)); - - return payload.access_token; +export function getGitHubAppSlug(): string | null { + return pickFirstNonEmpty(getWorkerEnv().GITHUB_APP_SLUG) ?? null; +} + +export function getGitHubWebhookSecret() { + return pickFirstNonEmpty(getWorkerEnv().GITHUB_WEBHOOK_SECRET) ?? null; } export async function getGitHubAccessTokenByUserId(userId: string) { @@ -155,22 +75,7 @@ export async function getGitHubAccessTokenByUserId(userId: string) { throw new Error("No GitHub account linked"); } - if (!needsGitHubAccessTokenRefresh(githubAccount)) { - return githubAccount.accessToken; - } - - const existingRefresh = githubTokenRefreshes.get(githubAccount.id); - if (existingRefresh) { - return existingRefresh; - } - - const refreshTask = refreshGitHubAccessToken(githubAccount).finally(() => { - githubTokenRefreshes.delete(githubAccount.id); - }); - - githubTokenRefreshes.set(githubAccount.id, refreshTask); - - return refreshTask; + return githubAccount.accessToken; } function fromHex(hex: string) { diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 2918708..91ad1ce 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -28,6 +28,7 @@ import type { RequestReviewersInput, SetLabelsInput, SubmitReviewInput, + TimelineEvent, UserRepoSummary, } from "./github.types"; import { @@ -882,6 +883,370 @@ async function getPullCommitsResult( }); } +const COMMENTS_PER_PAGE = 30; + +type CommentPageInput = { + owner: string; + repo: string; + issueNumber: number; + page: number; +}; + +async function getCommentsPageResult( + context: GitHubContext, + data: CommentPageInput, +): Promise<{ + comments: Array<{ + id: number; + body: string; + createdAt: string; + author: GitHubActor | null; + }>; + total: number; +}> { + const response = await context.octokit.rest.issues.listComments({ + owner: data.owner, + repo: data.repo, + issue_number: data.issueNumber, + per_page: COMMENTS_PER_PAGE, + page: data.page, + }); + + const linkHeader = response.headers.link ?? ""; + let total = response.data.length + (data.page - 1) * COMMENTS_PER_PAGE; + const lastMatch = linkHeader.match(/[&?]page=(\d+)[^>]*>;\s*rel="last"/); + if (lastMatch) { + total = Number(lastMatch[1]) * COMMENTS_PER_PAGE; + } + + 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, + })), + total, + }; +} + +const TIMELINE_EVENT_TYPES = new Set([ + "labeled", + "unlabeled", + "assigned", + "unassigned", + "review_requested", + "review_request_removed", + "renamed", + "closed", + "reopened", + "milestoned", + "demilestoned", + "cross-referenced", + "referenced", + "reviewed", + "convert_to_draft", + "ready_for_review", +]); + +function mapTimelineEvents(rawEvents: unknown[]): TimelineEvent[] { + return rawEvents + .filter((e) => { + const event = (e as Record).event as string | undefined; + return event && TIMELINE_EVENT_TYPES.has(event); + }) + .map((e) => { + const raw = e as Record; + const actor = raw.actor as Record | null | undefined; + const label = raw.label as Record | null | undefined; + const assignee = raw.assignee as + | Record + | null + | undefined; + const reviewer = raw.requested_reviewer as + | Record + | null + | undefined; + const team = raw.requested_team as + | Record + | null + | undefined; + const rename = raw.rename as Record | null | undefined; + const milestone = raw.milestone as + | Record + | null + | undefined; + const source = raw.source as Record | null | undefined; + + let mappedSource: TimelineEvent["source"] = null; + if (source) { + const issue = source.issue as Record | undefined; + if (issue) { + const repo = issue.repository as Record | undefined; + mappedSource = { + type: issue.pull_request ? "pull_request" : "issue", + number: issue.number as number, + title: (issue.title as string) ?? "", + state: (issue.state as string) ?? "", + url: (issue.html_url as string) ?? "", + repository: (repo?.full_name as string) ?? null, + }; + } + } + + return { + id: (raw.id as number) ?? 0, + event: raw.event as string, + createdAt: (raw.created_at as string) ?? "", + actor: actor + ? { + login: (actor.login as string) ?? "", + avatarUrl: (actor.avatar_url as string) ?? "", + url: (actor.html_url as string) ?? "", + type: (actor.type as string) ?? "User", + } + : null, + label: label + ? { + name: (label.name as string) ?? "", + color: (label.color as string) ?? "", + } + : undefined, + assignee: assignee + ? { + login: (assignee.login as string) ?? "", + avatarUrl: (assignee.avatar_url as string) ?? "", + url: (assignee.html_url as string) ?? "", + type: (assignee.type as string) ?? "User", + } + : undefined, + requestedReviewer: reviewer + ? { + login: (reviewer.login as string) ?? "", + avatarUrl: (reviewer.avatar_url as string) ?? "", + url: (reviewer.html_url as string) ?? "", + type: (reviewer.type as string) ?? "User", + } + : undefined, + requestedTeam: team + ? { + name: (team.name as string) ?? "", + slug: (team.slug as string) ?? "", + } + : undefined, + rename: rename + ? { + from: (rename.from as string) ?? "", + to: (rename.to as string) ?? "", + } + : undefined, + milestone: milestone + ? { title: (milestone.title as string) ?? "" } + : undefined, + source: mappedSource, + reviewState: (raw.state as string) ?? undefined, + body: (raw.body as string) ?? undefined, + }; + }); +} + +type GraphQLCrossRefResponse = { + repository: { + issueOrPullRequest: { + timelineItems: { + nodes: Array<{ + actor: { login: string; avatarUrl: string; url: string } | null; + createdAt: string; + source: { + __typename: string; + number: number; + title: string; + state: string; + url: string; + repository: { nameWithOwner: string }; + }; + }>; + }; + } | null; + }; +}; + +async function getCrossReferencesViaGraphQL( + context: GitHubContext, + data: { owner: string; repo: string; issueNumber: number }, +): Promise { + try { + const response = await context.octokit.graphql( + `query ($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issueOrPullRequest(number: $number) { + ... on Issue { + timelineItems(first: 100, itemTypes: [CROSS_REFERENCED_EVENT]) { + nodes { + ... on CrossReferencedEvent { + actor { login avatarUrl url } + createdAt + source { + __typename + ... on Issue { + number title state url + repository { nameWithOwner } + } + ... on PullRequest { + number title state url + repository { nameWithOwner } + } + } + } + } + } + } + ... on PullRequest { + timelineItems(first: 100, itemTypes: [CROSS_REFERENCED_EVENT]) { + nodes { + ... on CrossReferencedEvent { + actor { login avatarUrl url } + createdAt + source { + __typename + ... on Issue { + number title state url + repository { nameWithOwner } + } + ... on PullRequest { + number title state url + repository { nameWithOwner } + } + } + } + } + } + } + } + } + }`, + { + owner: data.owner, + repo: data.repo, + number: data.issueNumber, + }, + ); + + const issueOrPR = response.repository.issueOrPullRequest; + const nodes = issueOrPR?.timelineItems.nodes ?? []; + + return nodes + .filter((node) => node.source) + .map((node) => ({ + id: 0, + event: "cross-referenced", + createdAt: node.createdAt, + actor: node.actor + ? { + login: node.actor.login, + avatarUrl: node.actor.avatarUrl, + url: node.actor.url, + type: "User", + } + : null, + source: { + type: + node.source.__typename === "PullRequest" + ? ("pull_request" as const) + : ("issue" as const), + number: node.source.number, + title: node.source.title, + state: node.source.state.toLowerCase(), + url: node.source.url, + repository: node.source.repository.nameWithOwner, + }, + })); + } catch (error) { + console.error( + "[timeline:graphql] ERROR:", + error instanceof Error ? error.message : error, + ); + return []; + } +} + +const TIMELINE_EVENTS_PER_PAGE = 100; + +type TimelineEventsResult = { + events: TimelineEvent[]; + hasMore: boolean; +}; + +async function getTimelineEventsResult( + context: GitHubContext, + data: { owner: string; repo: string; issueNumber: number }, +): Promise { + const [restResult, crossRefs] = await Promise.all([ + getRestTimelineEventsPage(context, data, 1), + getCrossReferencesViaGraphQL(context, data), + ]); + + const restCrossRefKeys = new Set( + restResult.events + .filter((e) => e.event === "cross-referenced" && e.source) + .map((e) => `${e.source?.repository ?? ""}#${e.source?.number}`), + ); + + const uniqueCrossRefs = crossRefs.filter( + (e) => + !restCrossRefKeys.has( + `${e.source?.repository ?? ""}#${e.source?.number}`, + ), + ); + + return { + events: [...restResult.events, ...uniqueCrossRefs], + hasMore: restResult.hasMore, + }; +} + +async function getRestTimelineEventsPage( + context: GitHubContext, + data: { owner: string; repo: string; issueNumber: number }, + page: number, +): Promise { + try { + const response = await context.octokit.request( + "GET /repos/{owner}/{repo}/issues/{issue_number}/timeline", + { + owner: data.owner, + repo: data.repo, + issue_number: data.issueNumber, + per_page: TIMELINE_EVENTS_PER_PAGE, + page, + headers: { + accept: "application/vnd.github.v3+json", + }, + }, + ); + + const items = response.data as unknown[]; + + return { + events: mapTimelineEvents(items), + hasMore: items.length >= TIMELINE_EVENTS_PER_PAGE, + }; + } catch (error) { + console.error( + "[timeline:rest] ERROR:", + error instanceof Error ? error.message : error, + ); + return { events: [], hasMore: false }; + } +} + async function computePullStatus( context: GitHubContext, data: PullFromRepoInput, @@ -1039,15 +1404,42 @@ async function getPullPageDataResult( freshForMs: githubCachePolicy.detail.staleTimeMs, }); - const [comments, commits] = await Promise.all([ - getPullCommentsResult(context, data), + const totalComments = pull.comments ?? 0; + const totalPages = Math.max(1, Math.ceil(totalComments / COMMENTS_PER_PAGE)); + const issueData = { + owner: data.owner, + repo: data.repo, + issueNumber: data.pullNumber, + }; + + const pagesToFetch = totalPages === 1 ? [1] : [1, totalPages]; + + const [commentsPages, commits, timelineResult] = await Promise.all([ + Promise.all( + pagesToFetch.map((p) => + getCommentsPageResult(context, { ...issueData, page: p }), + ), + ), getPullCommitsResult(context, data), + getTimelineEventsResult(context, issueData), ]); + const allComments = commentsPages.flatMap((p) => p.comments); + return { detail: mapPullDetail(pull, buildRepositoryRef(data.owner, data.repo)), - comments, + comments: allComments, commits, + events: timelineResult.events, + commentPagination: { + totalCount: totalComments, + perPage: COMMENTS_PER_PAGE, + loadedPages: pagesToFetch, + }, + eventPagination: { + loadedPages: [1], + hasMore: timelineResult.hasMore, + }, }; } @@ -1136,14 +1528,42 @@ async function getIssuePageDataResult( context: GitHubContext, data: IssueFromRepoInput, ): Promise { - const [detail, comments] = await Promise.all([ - getIssueDetailResult(context, data), - getIssueCommentsResult(context, data), + const detail = await getIssueDetailResult(context, data); + + const totalComments = detail?.comments ?? 0; + const totalPages = Math.max(1, Math.ceil(totalComments / COMMENTS_PER_PAGE)); + const issueData = { + owner: data.owner, + repo: data.repo, + issueNumber: data.issueNumber, + }; + + const pagesToFetch = totalPages === 1 ? [1] : [1, totalPages]; + + const [commentsPages, timelineResult] = await Promise.all([ + Promise.all( + pagesToFetch.map((p) => + getCommentsPageResult(context, { ...issueData, page: p }), + ), + ), + getTimelineEventsResult(context, issueData), ]); + const allComments = commentsPages.flatMap((p) => p.comments); + return { detail, - comments, + comments: allComments, + events: timelineResult.events, + commentPagination: { + totalCount: totalComments, + perPage: COMMENTS_PER_PAGE, + loadedPages: pagesToFetch, + }, + eventPagination: { + loadedPages: [1], + hasMore: timelineResult.hasMore, + }, }; } @@ -1315,7 +1735,10 @@ export const getGitHubAppAccessState = createServerFn({ const appSlug = getGitHubAppSlug(); const publicInstallUrl = buildGitHubAppInstallUrl(appSlug); + // GET /user/installations requires a GitHub App user-to-server token (ghu_). + // With an OAuth App token (gho_), this endpoint returns 403 — expected behavior. let installations: GitHubAppInstallation[] = []; + let installationsAvailable = false; try { const installationsResponse = await context.octokit.request( "GET /user/installations", @@ -1323,6 +1746,7 @@ export const getGitHubAppAccessState = createServerFn({ per_page: 100, }, ); + installationsAvailable = true; const payload = installationsResponse.data as GitHubUserInstallationsPayload; installations = (payload.installations ?? []).flatMap((installation) => { @@ -1352,8 +1776,8 @@ export const getGitHubAppAccessState = createServerFn({ }, ]; }); - } catch (error) { - console.error("[github-access] failed to load installations", error); + } catch { + // Silently ignored — OAuth App tokens cannot list GitHub App installations. } let organizations: GitHubOrganization[] = []; @@ -1403,6 +1827,7 @@ export const getGitHubAppAccessState = createServerFn({ viewerLogin, appSlug, publicInstallUrl, + installationsAvailable, personalInstallation, orgInstallations, organizations, @@ -1592,6 +2017,35 @@ export const getPullsFromRepo = createServerFn({ method: "GET" }) }); }); +export const getCommentPage = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }) => { + const context = await getGitHubContext(); + if (!context) { + return { comments: [], total: 0 }; + } + + return getCommentsPageResult(context, data); + }); + +type TimelineEventPageInput = { + owner: string; + repo: string; + issueNumber: number; + page: number; +}; + +export const getTimelineEventPage = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }) => { + const context = await getGitHubContext(); + if (!context) { + return { events: [], hasMore: false }; + } + + return getRestTimelineEventsPage(context, data, data.page); + }); + export const getPullFromRepo = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index 1c541df..22ccfce 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -1,5 +1,6 @@ import { queryOptions } from "@tanstack/react-query"; import { + getCommentPage, getGitHubViewer, getIssueComments, getIssueFromRepo, @@ -20,6 +21,7 @@ import { getPullsFromUser, getRepoCollaborators, getRepoLabels, + getTimelineEventPage, getUserRepos, } from "./github.functions"; import { githubCachePolicy } from "./github-cache-policy"; @@ -140,6 +142,14 @@ export const githubQueryKeys = { ) => ["github", scope.userId, "repoLabels", input] as const, orgTeams: (scope: GitHubQueryScope, org: string) => ["github", scope.userId, "orgTeams", org] as const, + commentPage: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string; issueNumber: number; page: number }, + ) => ["github", scope.userId, "commentPage", input] as const, + timelineEventPage: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string; issueNumber: number; page: number }, + ) => ["github", scope.userId, "timelineEventPage", input] as const, issues: { mine: (scope: GitHubQueryScope) => ["github", scope.userId, "issues", "mine"] as const, @@ -425,3 +435,29 @@ export function githubIssueCommentsQueryOptions( meta: tabPersistedMeta, }); } + +export function githubCommentPageQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string; issueNumber: number; page: number }, +) { + return queryOptions({ + queryKey: githubQueryKeys.commentPage(scope, input), + queryFn: () => getCommentPage({ data: input }), + staleTime: githubCachePolicy.activity.staleTimeMs, + gcTime: githubCachePolicy.activity.gcTimeMs, + meta: tabPersistedMeta, + }); +} + +export function githubTimelineEventPageQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string; issueNumber: number; page: number }, +) { + return queryOptions({ + queryKey: githubQueryKeys.timelineEventPage(scope, input), + queryFn: () => getTimelineEventPage({ data: input }), + staleTime: githubCachePolicy.activity.staleTimeMs, + gcTime: githubCachePolicy.activity.gcTimeMs, + meta: tabPersistedMeta, + }); +} diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index b878af3..9f23f6c 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -127,9 +127,35 @@ export type IssueComment = { author: GitHubActor | null; }; +export type TimelineEvent = { + id: number; + event: string; + createdAt: string; + actor: GitHubActor | null; + label?: { name: string; color: string }; + assignee?: GitHubActor | null; + requestedReviewer?: GitHubActor | null; + requestedTeam?: { name: string; slug: string } | null; + rename?: { from: string; to: string }; + source?: { + type: "issue" | "pull_request"; + number: number; + title: string; + state: string; + url: string; + repository: string | null; + } | null; + milestone?: { title: string } | null; + reviewState?: string; + body?: string; +}; + export type IssuePageData = { detail: IssueDetail | null; comments: IssueComment[]; + events: TimelineEvent[]; + commentPagination: CommentPagination; + eventPagination: EventPagination; }; export type PullCheckRun = { @@ -173,10 +199,24 @@ export type PullCommit = { author: GitHubActor | null; }; +export type CommentPagination = { + totalCount: number; + perPage: number; + loadedPages: number[]; +}; + +export type EventPagination = { + loadedPages: number[]; + hasMore: boolean; +}; + export type PullPageData = { detail: PullDetail | null; comments: PullComment[]; commits: PullCommit[]; + events: TimelineEvent[]; + commentPagination: CommentPagination; + eventPagination: EventPagination; }; export type PullFile = {