diff --git a/apps/dashboard/src/components/labels-section.tsx b/apps/dashboard/src/components/labels-section.tsx new file mode 100644 index 0000000..9c2b20f --- /dev/null +++ b/apps/dashboard/src/components/labels-section.tsx @@ -0,0 +1,323 @@ +import { CheckIcon, CloseIcon, PlusSignIcon, SearchIcon } from "@diffkit/icons"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@diffkit/ui/components/popover"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { createRepoLabel, setIssueLabels } from "#/lib/github.functions"; +import { + type GitHubQueryScope, + githubRepoLabelsQueryOptions, +} from "#/lib/github.query"; +import type { GitHubLabel } from "#/lib/github.types"; +import { useOptimisticMutation } from "#/lib/use-optimistic-mutation"; + +function randomLabelColor(): string { + const colors = [ + "0075ca", + "e4e669", + "d73a4a", + "a2eeef", + "7057ff", + "008672", + "e99695", + "d876e3", + "f9d0c4", + "c5def5", + "bfdadc", + "c2e0c6", + ]; + return colors[Math.floor(Math.random() * colors.length)]; +} + +type PageDataWithLabels = { + detail: { labels: GitHubLabel[] } | null; +}; + +export function LabelsSection({ + currentLabels, + owner, + repo, + issueNumber, + scope, + pageQueryKey, +}: { + currentLabels: GitHubLabel[]; + owner: string; + repo: string; + issueNumber: number; + scope: GitHubQueryScope; + pageQueryKey: readonly unknown[]; +}) { + const { mutate } = useOptimisticMutation(); + const [pickerOpen, setPickerOpen] = useState(false); + const [search, setSearch] = useState(""); + const [pending, setPending] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(-1); + const listRef = useRef(null); + + const repoLabelsQuery = useQuery({ + ...githubRepoLabelsQueryOptions(scope, { owner, repo }), + enabled: pickerOpen, + }); + const repoLabels = repoLabelsQuery.data ?? []; + + const activeNames = useMemo( + () => new Set(currentLabels.map((l) => l.name)), + [currentLabels], + ); + + const filtered = useMemo(() => { + if (!search) return repoLabels; + const q = search.toLowerCase(); + return repoLabels.filter((l) => l.name.toLowerCase().includes(q)); + }, [repoLabels, search]); + + const hasExactMatch = useMemo(() => { + if (!search) return true; + const q = search.toLowerCase(); + return repoLabels.some((l) => l.name.toLowerCase() === q); + }, [repoLabels, search]); + + const showCreate = search.trim() !== "" && !hasExactMatch; + const totalItems = filtered.length + (showCreate ? 1 : 0); + + const scrollToFocused = useCallback((index: number) => { + const el = listRef.current?.querySelector(`[data-index="${index}"]`); + if (el) { + el.scrollIntoView({ block: "nearest" }); + } + }, []); + + const labelsUpdater = (nextLabels: GitHubLabel[]) => ({ + queryKey: pageQueryKey, + updater: (prev: PageDataWithLabels) => ({ + ...prev, + detail: prev.detail + ? { ...prev.detail, labels: nextLabels } + : prev.detail, + }), + }); + + const toggleLabel = (labelName: string) => { + const isActive = activeNames.has(labelName); + const nextLabels = isActive + ? currentLabels.filter((l) => l.name !== labelName) + : [ + ...currentLabels, + repoLabels.find((l) => l.name === labelName) ?? { + name: labelName, + color: "000000", + description: null, + }, + ]; + + mutate({ + mutationFn: () => + setIssueLabels({ + data: { + owner, + repo, + issueNumber, + labels: nextLabels.map((l) => l.name), + }, + }), + updates: [labelsUpdater(nextLabels)], + }); + }; + + const createAndAssign = async () => { + const name = search.trim(); + if (!name) return; + + setPending(true); + try { + const label = await createRepoLabel({ + data: { owner, repo, name, color: randomLabelColor() }, + }); + if (label) { + const nextLabels = [...currentLabels, label]; + setSearch(""); + await mutate({ + mutationFn: () => + setIssueLabels({ + data: { + owner, + repo, + issueNumber, + labels: nextLabels.map((l) => l.name), + }, + }), + updates: [labelsUpdater(nextLabels)], + }); + } + } finally { + setPending(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (totalItems === 0) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + const next = focusedIndex < totalItems - 1 ? focusedIndex + 1 : 0; + setFocusedIndex(next); + scrollToFocused(next); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const next = focusedIndex > 0 ? focusedIndex - 1 : totalItems - 1; + setFocusedIndex(next); + scrollToFocused(next); + } else if (e.key === "Enter") { + e.preventDefault(); + if (focusedIndex < 0) return; + if (focusedIndex < filtered.length) { + toggleLabel(filtered[focusedIndex].name); + } else if (showCreate) { + createAndAssign(); + } + } + }; + + return ( +
+
+

+ Labels +

+ { + setPickerOpen(open); + if (!open) { + setSearch(""); + setFocusedIndex(-1); + } + }} + > + + + + +
+ + { + setSearch(e.target.value); + setFocusedIndex(-1); + }} + onKeyDown={handleKeyDown} + placeholder="Search labels..." + className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+
+ {repoLabelsQuery.isLoading ? ( +

+ Loading… +

+ ) : ( + <> + {filtered.map((label, i) => { + const isSelected = activeNames.has(label.name); + return ( + + ); + })} + {showCreate && ( + + )} + {filtered.length === 0 && !search.trim() && ( +

+ No labels found +

+ )} + + )} +
+
+
+
+ {currentLabels.length > 0 ? ( +
+ {currentLabels.map((label) => ( + + {label.name} + + + ))} +
+ ) : ( +

No labels

+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/pulls/pull-request-row.tsx b/apps/dashboard/src/components/pulls/pull-request-row.tsx index 2f90745..0e09ad6 100644 --- a/apps/dashboard/src/components/pulls/pull-request-row.tsx +++ b/apps/dashboard/src/components/pulls/pull-request-row.tsx @@ -7,6 +7,7 @@ import { ViewIcon, } from "@diffkit/icons"; import { Markdown } from "@diffkit/ui/components/markdown"; +import { Spinner } from "@diffkit/ui/components/spinner"; import { cn } from "@diffkit/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { Link, useRouter } from "@tanstack/react-router"; @@ -116,27 +117,7 @@ export function PullRequestRow({ className="flex items-center gap-1 rounded-md border bg-surface-1 px-2 py-0.5 text-xs font-medium text-muted-foreground opacity-0 transition-opacity hover:bg-surface-2 hover:text-foreground group-hover:opacity-100" > {expanded && commentsQuery.isPending ? ( - + ) : ( )} diff --git a/apps/dashboard/src/lib/github-cache.ts b/apps/dashboard/src/lib/github-cache.ts index d5304da..0cc471a 100644 --- a/apps/dashboard/src/lib/github-cache.ts +++ b/apps/dashboard/src/lib/github-cache.ts @@ -184,6 +184,17 @@ async function getGitHubCacheStore(): Promise { }; } +export async function bustGitHubCache( + userId: string, + resource: string, + params?: unknown, +): Promise { + const store = await getGitHubCacheStore(); + const paramsJson = stableSerialize(params); + const cacheKey = buildGitHubCacheKey({ userId, resource, paramsJson }); + await store.delete(cacheKey); +} + export function createGitHubResponseMetadata( statusCode: number, headers: Record, diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index ebb05ee..21c4d21 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -1,6 +1,7 @@ import { createServerFn } from "@tanstack/react-start"; import { type Octokit as OctokitType, RequestError } from "octokit"; import type { + CreateLabelInput, CreateReviewCommentInput, GitHubActor, GitHubLabel, @@ -10,6 +11,7 @@ import type { IssueSummary, MyIssuesResult, MyPullsResult, + OrgTeam, PullComment, PullCommit, PullDetail, @@ -18,11 +20,15 @@ import type { PullReviewComment, PullStatus, PullSummary, + RepoCollaborator, RepositoryRef, + RequestReviewersInput, + SetLabelsInput, SubmitReviewInput, UserRepoSummary, } from "./github.types"; import { + bustGitHubCache, createGitHubResponseMetadata, type GitHubConditionalHeaders, type GitHubFetchResult, @@ -76,6 +82,33 @@ type RepoState = "all" | "closed" | "open"; type PullSort = "created" | "long-running" | "popularity" | "updated"; type IssueSort = "comments" | "created" | "updated"; +// --------------------------------------------------------------------------- +// Entity-scoped cache busting helpers +// --------------------------------------------------------------------------- +// Each helper busts all server-side D1 cache entries related to an entity so +// the next React Query refetch hits GitHub's API and returns fresh data. +// Add new resource keys here as new cached endpoints are introduced. +// --------------------------------------------------------------------------- + +type PullCacheParams = { owner: string; repo: string; pullNumber: number }; +type IssueCacheParams = { owner: string; repo: string; issueNumber: number }; + +async function bustPullDetailCaches(userId: string, params: PullCacheParams) { + await Promise.all([ + bustGitHubCache(userId, "pulls.detail.raw", params), + bustGitHubCache(userId, "pulls.status.raw", params), + bustGitHubCache(userId, "pulls.status.v1", params), + ]); +} + +async function bustPullReviewCaches(userId: string, params: PullCacheParams) { + await bustGitHubCache(userId, "pulls.reviewComments", params); +} + +async function bustIssueCaches(userId: string, params: IssueCacheParams) { + await bustGitHubCache(userId, "issues.detail", params); +} + type GitHubApiUser = { login?: string; avatar_url?: string; @@ -313,6 +346,11 @@ function mapPullDetail( requestedReviewers: (pull.requested_reviewers ?? []) .map((reviewer) => mapActor(reviewer)) .filter((reviewer): reviewer is GitHubActor => Boolean(reviewer)), + requestedTeams: (pull.requested_teams ?? []).map((team) => ({ + slug: team.slug, + name: team.name, + url: team.html_url, + })), }; } @@ -1398,6 +1436,30 @@ export const getPullPageData = createServerFn({ method: "GET" }) return getPullPageDataResult(context, data); }); +type UpdatePullBodyInput = PullFromRepoInput & { body: string }; + +export const updatePullBody = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return false; + } + + try { + await context.octokit.rest.pulls.update({ + owner: data.owner, + repo: data.repo, + pull_number: data.pullNumber, + body: data.body, + }); + await bustPullDetailCaches(context.session.user.id, data); + return true; + } catch { + return false; + } + }); + export const updatePullBranch = createServerFn({ method: "POST" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { @@ -1412,6 +1474,7 @@ export const updatePullBranch = createServerFn({ method: "POST" }) repo: data.repo, pull_number: data.pullNumber, }); + await bustPullDetailCaches(context.session.user.id, data); return true; } catch { return false; @@ -1536,6 +1599,11 @@ export const submitPullReview = createServerFn({ method: "POST" }) : {}), })), }); + await bustPullDetailCaches(context.session.user.id, { + owner: data.owner, + repo: data.repo, + pullNumber: data.pullNumber, + }); return true; } catch { return false; @@ -1563,6 +1631,12 @@ export const createReviewComment = createServerFn({ method: "POST" }) }); const comment = response.data; + await bustPullReviewCaches(context.session.user.id, { + owner: data.owner, + repo: data.repo, + pullNumber: data.pullNumber, + }); + return { id: comment.id, body: comment.body, @@ -1586,3 +1660,216 @@ export const createReviewComment = createServerFn({ method: "POST" }) return null; } }); + +export type RepoCollaboratorsInput = { + owner: string; + repo: string; +}; + +export const getRepoCollaborators = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return []; + } + + try { + const allCollaborators = await context.octokit.paginate( + context.octokit.rest.repos.listCollaborators, + { + owner: data.owner, + repo: data.repo, + per_page: 100, + }, + ); + + return allCollaborators.map((c) => ({ + login: c.login, + avatarUrl: c.avatar_url, + permissions: { + admin: c.permissions?.admin ?? false, + push: c.permissions?.push ?? false, + pull: c.permissions?.pull ?? false, + }, + })); + } catch { + return []; + } + }); + +export type OrgTeamsInput = { + org: string; +}; + +export const getOrgTeams = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return []; + } + + try { + const allTeams = await context.octokit.paginate( + context.octokit.rest.teams.list, + { + org: data.org, + per_page: 100, + }, + ); + + return allTeams.map((t) => ({ + slug: t.slug, + name: t.name, + })); + } catch { + return []; + } + }); + +export const requestPullReviewers = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return false; + } + + try { + await context.octokit.rest.pulls.requestReviewers({ + owner: data.owner, + repo: data.repo, + pull_number: data.pullNumber, + reviewers: data.reviewers ?? [], + team_reviewers: data.teamReviewers ?? [], + }); + await bustPullDetailCaches(context.session.user.id, { + owner: data.owner, + repo: data.repo, + pullNumber: data.pullNumber, + }); + return true; + } catch { + return false; + } + }); + +export const removeReviewRequest = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return false; + } + + try { + await context.octokit.rest.pulls.removeRequestedReviewers({ + owner: data.owner, + repo: data.repo, + pull_number: data.pullNumber, + reviewers: data.reviewers ?? [], + team_reviewers: data.teamReviewers ?? [], + }); + await bustPullDetailCaches(context.session.user.id, { + owner: data.owner, + repo: data.repo, + pullNumber: data.pullNumber, + }); + return true; + } catch { + return false; + } + }); + +export type RepoLabelsInput = { + owner: string; + repo: string; +}; + +export const getRepoLabels = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return []; + } + + try { + const allLabels = await context.octokit.paginate( + context.octokit.rest.issues.listLabelsForRepo, + { + owner: data.owner, + repo: data.repo, + per_page: 100, + }, + ); + + return allLabels.map((l) => ({ + name: l.name, + color: l.color ?? "000000", + description: l.description ?? null, + })); + } catch { + return []; + } + }); + +export const setIssueLabels = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return false; + } + + try { + await context.octokit.rest.issues.setLabels({ + owner: data.owner, + repo: data.repo, + issue_number: data.issueNumber, + labels: data.labels, + }); + const userId = context.session.user.id; + await Promise.all([ + bustPullDetailCaches(userId, { + owner: data.owner, + repo: data.repo, + pullNumber: data.issueNumber, + }), + bustIssueCaches(userId, { + owner: data.owner, + repo: data.repo, + issueNumber: data.issueNumber, + }), + ]); + return true; + } catch { + return false; + } + }); + +export const createRepoLabel = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return null; + } + + try { + const response = await context.octokit.rest.issues.createLabel({ + owner: data.owner, + repo: data.repo, + name: data.name, + color: data.color, + }); + return { + name: response.data.name, + color: response.data.color ?? "000000", + description: response.data.description ?? null, + }; + } catch { + return null; + } + }); diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index 8ec4c43..eb68a19 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -8,6 +8,7 @@ import { getIssuesFromUser, getMyIssues, getMyPulls, + getOrgTeams, getPullComments, getPullFiles, getPullFromRepo, @@ -16,6 +17,8 @@ import { getPullStatus, getPullsFromRepo, getPullsFromUser, + getRepoCollaborators, + getRepoLabels, getUserRepos, } from "./github.functions"; import { githubCachePolicy } from "./github-cache-policy"; @@ -124,6 +127,16 @@ export const githubQueryKeys = { reviewComments: (scope: GitHubQueryScope, input: PullFromRepoQueryInput) => ["github", scope.userId, "pulls", "reviewComments", input] as const, }, + collaborators: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, + ) => ["github", scope.userId, "collaborators", input] as const, + repoLabels: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, + ) => ["github", scope.userId, "repoLabels", input] as const, + orgTeams: (scope: GitHubQueryScope, org: string) => + ["github", scope.userId, "orgTeams", org] as const, issues: { mine: (scope: GitHubQueryScope) => ["github", scope.userId, "issues", "mine"] as const, @@ -281,6 +294,42 @@ export function githubPullReviewCommentsQueryOptions( }); } +export function githubRepoCollaboratorsQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, +) { + return queryOptions({ + queryKey: githubQueryKeys.collaborators(scope, input), + queryFn: () => getRepoCollaborators({ data: input }), + staleTime: githubCachePolicy.viewer.staleTimeMs, + gcTime: githubCachePolicy.viewer.gcTimeMs, + }); +} + +export function githubRepoLabelsQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, +) { + return queryOptions({ + queryKey: githubQueryKeys.repoLabels(scope, input), + queryFn: () => getRepoLabels({ data: input }), + staleTime: githubCachePolicy.viewer.staleTimeMs, + gcTime: githubCachePolicy.viewer.gcTimeMs, + }); +} + +export function githubOrgTeamsQueryOptions( + scope: GitHubQueryScope, + org: string, +) { + return queryOptions({ + queryKey: githubQueryKeys.orgTeams(scope, org), + queryFn: () => getOrgTeams({ data: { org } }), + staleTime: githubCachePolicy.viewer.staleTimeMs, + gcTime: githubCachePolicy.viewer.gcTimeMs, + }); +} + export function githubMyIssuesQueryOptions(scope: GitHubQueryScope) { return queryOptions({ queryKey: githubQueryKeys.issues.mine(scope), diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index 81fea7b..071c9bb 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -48,6 +48,12 @@ export type PullSummary = { repository: RepositoryRef; }; +export type RequestedTeam = { + slug: string; + name: string; + url: string; +}; + export type PullDetail = PullSummary & { body: string; additions: number; @@ -62,6 +68,7 @@ export type PullDetail = PullSummary & { mergeable: boolean | null; mergeableState?: string | null; requestedReviewers: GitHubActor[]; + requestedTeams: RequestedTeam[]; }; export type IssueSummary = { @@ -212,6 +219,43 @@ export type SubmitReviewInput = { }>; }; +export type RepoCollaborator = { + login: string; + avatarUrl: string; + permissions: { + admin: boolean; + push: boolean; + pull: boolean; + }; +}; + +export type RequestReviewersInput = { + owner: string; + repo: string; + pullNumber: number; + reviewers?: string[]; + teamReviewers?: string[]; +}; + +export type CreateLabelInput = { + owner: string; + repo: string; + name: string; + color: string; +}; + +export type SetLabelsInput = { + owner: string; + repo: string; + issueNumber: number; + labels: string[]; +}; + +export type OrgTeam = { + slug: string; + name: string; +}; + export type CreateReviewCommentInput = { owner: string; repo: string; diff --git a/apps/dashboard/src/lib/use-optimistic-mutation.ts b/apps/dashboard/src/lib/use-optimistic-mutation.ts new file mode 100644 index 0000000..2c746b0 --- /dev/null +++ b/apps/dashboard/src/lib/use-optimistic-mutation.ts @@ -0,0 +1,77 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; + +type OptimisticUpdate = { + queryKey: readonly unknown[]; + // biome-ignore lint/suspicious/noExplicitAny: updater must accept any cached data shape + updater: (current: any) => any; +}; + +type MutateOptions = { + /** The server function call, e.g. () => removeReviewRequest({ data: ... }) */ + mutationFn: () => Promise; + /** One or more optimistic cache updates to apply before the server call */ + updates?: OptimisticUpdate[]; + /** + * Query key prefix to invalidate on success. + * Defaults to ["github"]. + */ + invalidateQueryKey?: readonly unknown[]; + /** + * Custom success check. Defaults to Boolean(result). + */ + isSuccess?: (result: TResult) => boolean; +}; + +export function useOptimisticMutation() { + const queryClient = useQueryClient(); + + const mutate = useCallback( + async ( + options: MutateOptions, + ): Promise => { + const { + mutationFn, + updates = [], + invalidateQueryKey = ["github"], + isSuccess = (result: TResult) => Boolean(result), + } = options; + + const snapshots = updates.map((u) => ({ + queryKey: u.queryKey, + data: queryClient.getQueryData(u.queryKey), + })); + + for (const update of updates) { + queryClient.setQueryData(update.queryKey, (current: unknown) => { + if (current === undefined) return current; + return update.updater(current); + }); + } + + try { + const result = await mutationFn(); + + if (isSuccess(result)) { + await queryClient.invalidateQueries({ + queryKey: invalidateQueryKey, + }); + return result; + } + + for (const snapshot of snapshots) { + queryClient.setQueryData(snapshot.queryKey, snapshot.data); + } + return result; + } catch { + for (const snapshot of snapshots) { + queryClient.setQueryData(snapshot.queryKey, snapshot.data); + } + return undefined; + } + }, + [queryClient], + ); + + return { mutate }; +} diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx index 6bdb3a9..eb235df 100644 --- a/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx @@ -10,8 +10,12 @@ import { cn } from "@diffkit/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; import { useState } from "react"; +import { LabelsSection } from "#/components/labels-section"; import { formatRelativeTime } from "#/components/pulls/pull-request-row"; -import { githubIssuePageQueryOptions } from "#/lib/github.query"; +import { + githubIssuePageQueryOptions, + githubQueryKeys, +} from "#/lib/github.query"; import type { GitHubActor, IssueDetail } from "#/lib/github.types"; import { useHasMounted } from "#/lib/use-has-mounted"; import { useRegisterTab } from "#/lib/use-register-tab"; @@ -274,27 +278,18 @@ function IssueDetailPage() { {/* Labels */} - - {issue.labels.length > 0 ? ( -
- {issue.labels.map((label) => ( - - {label.name} - - ))} -
- ) : ( -

No labels

- )} -
+ {/* Participants */} diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx index acb31c0..4ed4705 100644 --- a/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx @@ -1,8 +1,11 @@ import { CalendarIcon, + CheckIcon, ClockIcon, CloseIcon, CommentIcon, + CopyIcon, + EditIcon, FileIcon, GitCommitIcon, GitMergeIcon, @@ -10,33 +13,62 @@ import { GitPullRequestDraftIcon, GitPullRequestIcon, MessageIcon, + MoreHorizontalIcon, + PlusSignIcon, + ReviewsIcon, + SearchIcon, } from "@diffkit/icons"; -import { Markdown } from "@diffkit/ui/components/markdown"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@diffkit/ui/components/dropdown-menu"; +import { highlightCode, Markdown } from "@diffkit/ui/components/markdown"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@diffkit/ui/components/popover"; import { Skeleton } from "@diffkit/ui/components/skeleton"; +import { Spinner } from "@diffkit/ui/components/spinner"; import { Tooltip, TooltipContent, + TooltipProvider, TooltipTrigger, } from "@diffkit/ui/components/tooltip"; import { cn } from "@diffkit/ui/lib/utils"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { LabelsSection } from "#/components/labels-section"; import { formatRelativeTime } from "#/components/pulls/pull-request-row"; -import { updatePullBranch } from "#/lib/github.functions"; import { + removeReviewRequest, + requestPullReviewers, + updatePullBody, + updatePullBranch, +} from "#/lib/github.functions"; +import { + githubOrgTeamsQueryOptions, githubPullPageQueryOptions, githubPullStatusQueryOptions, + githubQueryKeys, + githubRepoCollaboratorsQueryOptions, + githubViewerQueryOptions, } from "#/lib/github.query"; import type { GitHubActor, PullComment, PullCommit, PullDetail, + PullPageData, PullStatus, } from "#/lib/github.types"; import { githubCachePolicy } from "#/lib/github-cache-policy"; import { useHasMounted } from "#/lib/use-has-mounted"; +import { useOptimisticMutation } from "#/lib/use-optimistic-mutation"; import { useRegisterTab } from "#/lib/use-register-tab"; export const Route = createFileRoute("/_protected/$owner/$repo/pull/$pullId")({ @@ -114,10 +146,16 @@ function PullDetailPage() { refetchInterval: githubCachePolicy.status.staleTimeMs, }); + const viewerQuery = useQuery({ + ...githubViewerQueryOptions(scope), + enabled: hasMounted, + }); + const pr = pageQuery.data?.detail; const comments = pageQuery.data?.comments; const commits = pageQuery.data?.commits; const status = statusQuery.data ?? null; + const viewer = viewerQuery.data ?? null; useRegisterTab( pr @@ -198,55 +236,81 @@ function PullDetailPage() { - {/* Stats bar */} -
- - - - {pr.commits} - {" "} - {pr.commits === 1 ? "commit" : "commits"} - - · - - - - {pr.changedFiles} - {" "} - {pr.changedFiles === 1 ? "file" : "files"} changed - - +
+ {/* Review request banner */} + {viewer && + pr.requestedReviewers.some((r) => r.login === viewer.login) && ( +
+ + + Your review has been requested + + + Review changes + +
+ )} + + {/* Stats bar */} +
- - +{pr.additions} - - - -{pr.deletions} + + + {pr.commits} + {" "} + {pr.commits === 1 ? "commit" : "commits"} + + · + + + + {pr.changedFiles} + {" "} + {pr.changedFiles === 1 ? "file" : "files"} changed + + + + + +{pr.additions} + + + -{pr.deletions} + + - + {!pr.isMerged && + !( + viewer && + pr.requestedReviewers.some((r) => r.login === viewer.login) + ) && ( + + Review changes + + )} - - Review changes - - +
{/* Body */} - {pr.body ? ( -
- {pr.body} -
- ) : ( -
-

- No description provided. -

-
- )} + {/* Activity */}
@@ -325,42 +389,27 @@ function PullDetailPage() { {/* Right sidebar: Metadata */}