From 9ebc1bb3d65503a55ccb63413b37c452aacfa55d Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Wed, 8 Apr 2026 00:16:39 -0400 Subject: [PATCH] Add PR and issue detail views with sidebar layout Build detail pages at /$owner/$repo/pull/$id and /$owner/$repo/issues/$id with two-column layout: content on the left (header, description, activity timeline with comments, comment box) and metadata sidebar on the right (reviewers, participants, assignees, milestones, details). PR view includes merge status card with CI checks, review state, branch status, and update branch action. Stacked participant avatars with tooltips. Smaller tooltip styling globally. Issue rows now link internally instead of to GitHub. --- .../src/components/issues/issue-row.tsx | 10 +- apps/dashboard/src/lib/github.functions.ts | 167 ++++ apps/dashboard/src/lib/github.query.ts | 32 + apps/dashboard/src/lib/github.types.ts | 36 + apps/dashboard/src/routeTree.gen.ts | 60 +- .../$owner/$repo/issues.$issueId.tsx | 425 ++++++++++ .../_protected/$owner/$repo/pull.$pullId.tsx | 733 ++++++++++++++++++ packages/ui/src/components/tooltip.tsx | 2 +- 8 files changed, 1457 insertions(+), 8 deletions(-) create mode 100644 apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx create mode 100644 apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx diff --git a/apps/dashboard/src/components/issues/issue-row.tsx b/apps/dashboard/src/components/issues/issue-row.tsx index 875bc9d..a137af0 100644 --- a/apps/dashboard/src/components/issues/issue-row.tsx +++ b/apps/dashboard/src/components/issues/issue-row.tsx @@ -1,5 +1,6 @@ import { CommentIcon, IssuesIcon } from "@quickhub/icons"; import { cn } from "@quickhub/ui/lib/utils"; +import { Link } from "@tanstack/react-router"; import { formatRelativeTime } from "#/components/pulls/pull-request-row"; import type { IssueSummary } from "#/lib/github.types"; @@ -15,12 +16,11 @@ function getIssueStateProps(issue: IssueSummary) { export function IssueRow({ issue }: { issue: IssueSummary }) { const { color } = getIssueStateProps(issue); + const href = `/${issue.repository.owner}/${issue.repository.name}/issues/${issue.number}`; return ( -
@@ -53,6 +53,6 @@ export function IssueRow({ issue }: { issue: IssueSummary }) {
)} -
+ ); } diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 15df96d..1b777ba 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -3,12 +3,14 @@ import { type Octokit as OctokitType, RequestError } from "octokit"; import type { GitHubActor, GitHubLabel, + IssueComment, IssueDetail, IssueSummary, MyIssuesResult, MyPullsResult, PullComment, PullDetail, + PullStatus, PullSummary, RepositoryRef, UserRepoSummary, @@ -1066,3 +1068,168 @@ export const getIssueFromRepo = createServerFn({ method: "GET" }) }, }); }); + +export const getIssueComments = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return []; + } + + type RawIssueComment = Awaited< + ReturnType + >["data"][number]; + + return getCachedGitHubRequest({ + context, + resource: "issues.comments", + params: data, + freshForMs: githubCachePolicy.detail.staleTimeMs, + request: (headers) => + context.octokit.rest.issues.listComments({ + owner: data.owner, + repo: data.repo, + issue_number: data.issueNumber, + per_page: 30, + headers, + }), + mapData: (comments) => + comments.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, + })), + }); + }); + +export const getPullStatus = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return null; + } + + const pullResponse = await context.octokit.rest.pulls.get({ + owner: data.owner, + repo: data.repo, + pull_number: data.pullNumber, + }); + const pull = pullResponse.data; + + const [reviewsResponse, checksResponse] = await Promise.all([ + context.octokit.rest.pulls.listReviews({ + owner: data.owner, + repo: data.repo, + pull_number: data.pullNumber, + per_page: 100, + }), + context.octokit.rest.checks + .listForRef({ + owner: data.owner, + repo: data.repo, + ref: pull.head.sha, + per_page: 100, + }) + .catch(() => null), + ]); + + const reviews = reviewsResponse.data; + + const latestReviews = new Map< + string, + { id: number; state: string; author: GitHubActor | null } + >(); + for (const review of reviews) { + if (!review.user?.login) continue; + if (review.state === "COMMENTED") continue; + latestReviews.set(review.user.login, { + id: review.id, + state: review.state, + author: mapActor(review.user), + }); + } + + const checkRuns = checksResponse?.data.check_runs ?? []; + let passed = 0; + let failed = 0; + let pending = 0; + let skipped = 0; + for (const check of checkRuns) { + if (check.status !== "completed") { + pending += 1; + } else if ( + check.conclusion === "success" || + check.conclusion === "neutral" + ) { + passed += 1; + } else if (check.conclusion === "skipped") { + skipped += 1; + } else { + failed += 1; + } + } + + let behindBy: number | null = null; + try { + const comparison = await context.octokit.rest.repos.compareCommits({ + owner: data.owner, + repo: data.repo, + base: pull.head.sha, + head: pull.base.ref, + }); + behindBy = comparison.data.ahead_by; + } catch { + behindBy = null; + } + + const permissions = pull.base.repo.permissions; + const canUpdateBranch = + permissions?.push === true || permissions?.admin === true; + + return { + reviews: Array.from(latestReviews.values()), + checks: { + total: checkRuns.length, + passed, + failed, + pending, + skipped, + }, + mergeable: pull.mergeable, + mergeableState: + typeof pull.mergeable_state === "string" ? pull.mergeable_state : null, + behindBy, + baseRefName: pull.base.ref, + canUpdateBranch, + }; + }); + +export const updatePullBranch = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return false; + } + + try { + await context.octokit.rest.pulls.updateBranch({ + owner: data.owner, + repo: data.repo, + pull_number: data.pullNumber, + }); + return true; + } catch { + return false; + } + }); diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index bd528e3..c992d8b 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -1,6 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; import { getGitHubViewer, + getIssueComments, getIssueFromRepo, getIssuesFromRepo, getIssuesFromUser, @@ -8,6 +9,7 @@ import { getMyPulls, getPullComments, getPullFromRepo, + getPullStatus, getPullsFromRepo, getPullsFromUser, getUserRepos, @@ -105,6 +107,8 @@ export const githubQueryKeys = { ["github", scope.userId, "pulls", "detail", input] as const, comments: (scope: GitHubQueryScope, input: PullFromRepoQueryInput) => ["github", scope.userId, "pulls", "comments", input] as const, + status: (scope: GitHubQueryScope, input: PullFromRepoQueryInput) => + ["github", scope.userId, "pulls", "status", input] as const, }, issues: { mine: (scope: GitHubQueryScope) => @@ -115,6 +119,8 @@ export const githubQueryKeys = { ["github", scope.userId, "issues", "repo", input] as const, detail: (scope: GitHubQueryScope, input: IssueFromRepoQueryInput) => ["github", scope.userId, "issues", "detail", input] as const, + comments: (scope: GitHubQueryScope, input: IssueFromRepoQueryInput) => + ["github", scope.userId, "issues", "comments", input] as const, }, }; @@ -200,6 +206,19 @@ export function githubPullCommentsQueryOptions( }); } +export function githubPullStatusQueryOptions( + scope: GitHubQueryScope, + input: PullFromRepoQueryInput, +) { + return queryOptions({ + queryKey: githubQueryKeys.pulls.status(scope, input), + queryFn: () => getPullStatus({ data: input }), + staleTime: githubCachePolicy.detail.staleTimeMs, + gcTime: githubCachePolicy.detail.gcTimeMs, + meta: persistedMeta, + }); +} + export function githubMyIssuesQueryOptions(scope: GitHubQueryScope) { return queryOptions({ queryKey: githubQueryKeys.issues.mine(scope), @@ -248,3 +267,16 @@ export function githubIssueDetailQueryOptions( meta: persistedMeta, }); } + +export function githubIssueCommentsQueryOptions( + scope: GitHubQueryScope, + input: IssueFromRepoQueryInput, +) { + return queryOptions({ + queryKey: githubQueryKeys.issues.comments(scope, input), + queryFn: () => getIssueComments({ data: input }), + staleTime: githubCachePolicy.detail.staleTimeMs, + gcTime: githubCachePolicy.detail.gcTimeMs, + meta: persistedMeta, + }); +} diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index dace46b..a3b62e9 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -110,3 +110,39 @@ export type PullComment = { createdAt: string; author: GitHubActor | null; }; + +export type IssueComment = { + id: number; + body: string; + createdAt: string; + author: GitHubActor | null; +}; + +export type PullCheckRun = { + id: number; + name: string; + status: string; + conclusion: string | null; +}; + +export type PullReview = { + id: number; + state: string; + author: GitHubActor | null; +}; + +export type PullStatus = { + reviews: PullReview[]; + checks: { + total: number; + passed: number; + failed: number; + pending: number; + skipped: number; + }; + mergeable: boolean | null; + mergeableState: string | null; + behindBy: number | null; + baseRefName: string; + canUpdateBranch: boolean; +}; diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index f25d007..7e79475 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -16,6 +16,8 @@ import { Route as ProtectedReviewsRouteImport } from './routes/_protected/review import { Route as ProtectedPullsRouteImport } from './routes/_protected/pulls' import { Route as ProtectedIssuesRouteImport } from './routes/_protected/issues' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' +import { Route as ProtectedOwnerRepoPullPullIdRouteImport } from './routes/_protected/$owner/$repo/pull.$pullId' +import { Route as ProtectedOwnerRepoIssuesIssueIdRouteImport } from './routes/_protected/$owner/$repo/issues.$issueId' const LoginRoute = LoginRouteImport.update({ id: '/login', @@ -51,6 +53,18 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ path: '/api/auth/$', getParentRoute: () => rootRouteImport, } as any) +const ProtectedOwnerRepoPullPullIdRoute = + ProtectedOwnerRepoPullPullIdRouteImport.update({ + id: '/$owner/$repo/pull/$pullId', + path: '/$owner/$repo/pull/$pullId', + getParentRoute: () => ProtectedRoute, + } as any) +const ProtectedOwnerRepoIssuesIssueIdRoute = + ProtectedOwnerRepoIssuesIssueIdRouteImport.update({ + id: '/$owner/$repo/issues/$issueId', + path: '/$owner/$repo/issues/$issueId', + getParentRoute: () => ProtectedRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof ProtectedIndexRoute @@ -59,6 +73,8 @@ export interface FileRoutesByFullPath { '/pulls': typeof ProtectedPullsRoute '/reviews': typeof ProtectedReviewsRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute + '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute } export interface FileRoutesByTo { '/login': typeof LoginRoute @@ -67,6 +83,8 @@ export interface FileRoutesByTo { '/reviews': typeof ProtectedReviewsRoute '/': typeof ProtectedIndexRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute + '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -77,12 +95,30 @@ export interface FileRoutesById { '/_protected/reviews': typeof ProtectedReviewsRoute '/_protected/': typeof ProtectedIndexRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/_protected/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute + '/_protected/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/login' | '/issues' | '/pulls' | '/reviews' | '/api/auth/$' + fullPaths: + | '/' + | '/login' + | '/issues' + | '/pulls' + | '/reviews' + | '/api/auth/$' + | '/$owner/$repo/issues/$issueId' + | '/$owner/$repo/pull/$pullId' fileRoutesByTo: FileRoutesByTo - to: '/login' | '/issues' | '/pulls' | '/reviews' | '/' | '/api/auth/$' + to: + | '/login' + | '/issues' + | '/pulls' + | '/reviews' + | '/' + | '/api/auth/$' + | '/$owner/$repo/issues/$issueId' + | '/$owner/$repo/pull/$pullId' id: | '__root__' | '/_protected' @@ -92,6 +128,8 @@ export interface FileRouteTypes { | '/_protected/reviews' | '/_protected/' | '/api/auth/$' + | '/_protected/$owner/$repo/issues/$issueId' + | '/_protected/$owner/$repo/pull/$pullId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -151,6 +189,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAuthSplatRouteImport parentRoute: typeof rootRouteImport } + '/_protected/$owner/$repo/pull/$pullId': { + id: '/_protected/$owner/$repo/pull/$pullId' + path: '/$owner/$repo/pull/$pullId' + fullPath: '/$owner/$repo/pull/$pullId' + preLoaderRoute: typeof ProtectedOwnerRepoPullPullIdRouteImport + parentRoute: typeof ProtectedRoute + } + '/_protected/$owner/$repo/issues/$issueId': { + id: '/_protected/$owner/$repo/issues/$issueId' + path: '/$owner/$repo/issues/$issueId' + fullPath: '/$owner/$repo/issues/$issueId' + preLoaderRoute: typeof ProtectedOwnerRepoIssuesIssueIdRouteImport + parentRoute: typeof ProtectedRoute + } } } @@ -159,6 +211,8 @@ interface ProtectedRouteChildren { ProtectedPullsRoute: typeof ProtectedPullsRoute ProtectedReviewsRoute: typeof ProtectedReviewsRoute ProtectedIndexRoute: typeof ProtectedIndexRoute + ProtectedOwnerRepoIssuesIssueIdRoute: typeof ProtectedOwnerRepoIssuesIssueIdRoute + ProtectedOwnerRepoPullPullIdRoute: typeof ProtectedOwnerRepoPullPullIdRoute } const ProtectedRouteChildren: ProtectedRouteChildren = { @@ -166,6 +220,8 @@ const ProtectedRouteChildren: ProtectedRouteChildren = { ProtectedPullsRoute: ProtectedPullsRoute, ProtectedReviewsRoute: ProtectedReviewsRoute, ProtectedIndexRoute: ProtectedIndexRoute, + ProtectedOwnerRepoIssuesIssueIdRoute: ProtectedOwnerRepoIssuesIssueIdRoute, + ProtectedOwnerRepoPullPullIdRoute: ProtectedOwnerRepoPullPullIdRoute, } const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren( diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx new file mode 100644 index 0000000..3c481a2 --- /dev/null +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx @@ -0,0 +1,425 @@ +import { IssuesIcon } from "@quickhub/icons"; +import { Markdown } from "@quickhub/ui/components/markdown"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@quickhub/ui/components/tooltip"; +import { cn } from "@quickhub/ui/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useState } from "react"; +import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; +import { formatRelativeTime } from "#/components/pulls/pull-request-row"; +import { + githubIssueCommentsQueryOptions, + githubIssueDetailQueryOptions, +} from "#/lib/github.query"; +import type { GitHubActor, IssueDetail } from "#/lib/github.types"; +import { useHasMounted } from "#/lib/use-has-mounted"; + +export const Route = createFileRoute( + "/_protected/$owner/$repo/issues/$issueId", +)({ + component: IssueDetailPage, +}); + +function getIssueStateConfig(issue: IssueDetail) { + if (issue.state === "closed") { + if (issue.stateReason === "not_planned") { + return { + color: "text-muted-foreground", + label: "Closed", + badgeClass: "bg-muted text-muted-foreground", + }; + } + return { + color: "text-purple-500", + label: "Closed", + badgeClass: "bg-purple-500/10 text-purple-500", + }; + } + return { + color: "text-green-500", + label: "Open", + badgeClass: "bg-green-500/10 text-green-500", + }; +} + +function IssueDetailPage() { + const { user } = Route.useRouteContext(); + const { owner, repo, issueId } = Route.useParams(); + const issueNumber = Number(issueId); + const scope = { userId: user.id }; + const hasMounted = useHasMounted(); + + const detailQuery = useQuery({ + ...githubIssueDetailQueryOptions(scope, { owner, repo, issueNumber }), + enabled: hasMounted, + }); + + const commentsQuery = useQuery({ + ...githubIssueCommentsQueryOptions(scope, { owner, repo, issueNumber }), + enabled: hasMounted && detailQuery.data != null, + }); + + if (detailQuery.error) throw detailQuery.error; + + if (hasMounted && detailQuery.isPending) { + return ; + } + + const issue = detailQuery.data; + if (!issue) return null; + + const stateConfig = getIssueStateConfig(issue); + + return ( +
+
+ {/* Left: Issue content */} +
+ {/* Header */} +
+
+ + Issues + + / + + {owner}/{repo} + + / + #{issue.number} +
+ +
+
+ +
+
+

+ {issue.title} +

+
+ + {stateConfig.label} + + {issue.author && ( + + {issue.author.login} + + {issue.author.login} + + opened {formatRelativeTime(issue.createdAt)} + + )} +
+
+
+
+ + {/* Labels */} + {issue.labels.length > 0 && ( +
+ {issue.labels.map((label) => ( + + {label.name} + + ))} +
+ )} + + {/* Body */} + {issue.body ? ( +
+ {issue.body} +
+ ) : ( +
+

+ No description provided. +

+
+ )} + + {/* Activity / Comments */} +
+
+

Activity

+ {commentsQuery.data && ( + + {commentsQuery.data.length} + + )} +
+ + {commentsQuery.isFetching && !commentsQuery.data && ( +
+ +
+ )} + + {commentsQuery.data && commentsQuery.data.length === 0 && ( +

+ No comments yet. +

+ )} + + {commentsQuery.data && commentsQuery.data.length > 0 && ( +
+ {commentsQuery.data.map((comment, i) => ( +
+
+ {comment.author ? ( + {comment.author.login} + ) : ( +
+ )} + + {comment.author?.login ?? "Unknown"} + + + {formatRelativeTime(comment.createdAt)} + +
+ + {comment.body} + +
+ ))} +
+ )} + + {/* Comment input */} +
+ +
+
+
+ + {/* Right sidebar: Metadata */} + +
+
+ ); +} + +function SidebarSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +function DetailRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ {label} + {children} +
+ ); +} + +function ParticipantsList({ + issue, + comments, +}: { + issue: IssueDetail; + comments: Array<{ author: GitHubActor | null }>; +}) { + const seen = new Set(); + const participants: GitHubActor[] = []; + + const addActor = (actor: GitHubActor | null) => { + if (actor && !seen.has(actor.login)) { + seen.add(actor.login); + participants.push(actor); + } + }; + + addActor(issue.author); + for (const assignee of issue.assignees) { + addActor(assignee); + } + for (const comment of comments) { + addActor(comment.author); + } + + if (participants.length === 0) { + return

No participants yet

; + } + + return ( +
+ {participants.map((actor, i) => ( + + + {actor.login} 0 ? { marginLeft: -6 } : undefined} + /> + + {actor.login} + + ))} +
+ ); +} + +function CommentBox() { + const [value, setValue] = useState(""); + + return ( +
+