diff --git a/src/app/api/github/contributions/[username]/route.ts b/src/app/api/github/contributions/[username]/route.ts index 00720bd..aff44f7 100644 --- a/src/app/api/github/contributions/[username]/route.ts +++ b/src/app/api/github/contributions/[username]/route.ts @@ -6,7 +6,7 @@ import { GITHUB_USERNAME_RE } from "@/lib/utils"; import { RateLimitError } from "@/lib/types"; import type { PaginatedContributions, DrillDownTab } from "@/lib/types"; -const VALID_TABS: DrillDownTab[] = ["prs", "reviews", "issues"]; +const VALID_TABS: DrillDownTab[] = ["prs", "reviews", "issues", "reviewed-issues"]; const VALID_STATUSES = ["open", "closed", "merged", "all"]; const CACHE_TTL = 600; // 10 minutes diff --git a/src/app/api/github/issue-detail/[owner]/[repo]/[number]/route.ts b/src/app/api/github/issue-detail/[owner]/[repo]/[number]/route.ts new file mode 100644 index 0000000..2ddb20b --- /dev/null +++ b/src/app/api/github/issue-detail/[owner]/[repo]/[number]/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { getCached, setCache } from "@/lib/cache"; +import { fetchIssueDetail } from "@/lib/github-rest"; +import { RateLimitError } from "@/lib/types"; +import type { IssueDetail } from "@/lib/types"; + +const CACHE_TTL = 600; // 10 minutes + +export async function GET( + _request: Request, + { params }: { params: Promise<{ owner: string; repo: string; number: string }> } +) { + const session = await auth(); + + if (!session?.accessToken) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { owner, repo, number: numStr } = await params; + const issueNumber = parseInt(numStr, 10); + + if (isNaN(issueNumber) || issueNumber < 1) { + return NextResponse.json({ error: "Invalid issue number" }, { status: 400 }); + } + + const cacheKey = `issue-detail:${owner}/${repo}/${issueNumber}`; + + const cached = await getCached(cacheKey); + if (cached) { + return NextResponse.json(cached); + } + + try { + const result = await fetchIssueDetail(owner, repo, issueNumber, session.accessToken); + + await setCache(cacheKey, result, CACHE_TTL); + return NextResponse.json(result); + } catch (error) { + if (error instanceof RateLimitError) { + return NextResponse.json( + { error: "Rate limit exceeded", resetAt: error.resetAt }, + { status: 429 } + ); + } + return NextResponse.json( + { error: "GitHub API error" }, + { status: 502 } + ); + } +} diff --git a/src/components/ContributionCard.tsx b/src/components/ContributionCard.tsx index 46d3aaf..c8165c8 100644 --- a/src/components/ContributionCard.tsx +++ b/src/components/ContributionCard.tsx @@ -7,6 +7,8 @@ import { Card, CardContent } from "@/components/ui/card"; import { cn, stateColors } from "@/lib/utils"; import { formatDate } from "@/lib/date-utils"; import { ExpandedPRDetail } from "@/components/ExpandedPRDetail"; +import { ExpandedReviewDetail } from "@/components/ExpandedReviewDetail"; +import { ExpandedIssueDetail } from "@/components/ExpandedIssueDetail"; import type { ContributionDetail } from "@/lib/types"; interface ContributionCardProps { @@ -15,7 +17,7 @@ interface ContributionCardProps { export function ContributionCard({ item }: ContributionCardProps) { const [expanded, setExpanded] = useState(false); - const canExpand = item.type === "pr"; + const canExpand = item.type === "pr" || item.type === "review" || item.type === "issue"; const [owner, repo] = item.repoNameWithOwner.split("/"); return ( @@ -65,11 +67,21 @@ export function ContributionCard({ item }: ContributionCardProps) { - {expanded && canExpand && ( + {expanded && canExpand && item.type === "pr" && (
)} + {expanded && canExpand && item.type === "review" && ( +
+ +
+ )} + {expanded && canExpand && item.type === "issue" && ( +
+ +
+ )} ); diff --git a/src/components/ContributionDrillDown.tsx b/src/components/ContributionDrillDown.tsx index 4a2423b..6cbbbb0 100644 --- a/src/components/ContributionDrillDown.tsx +++ b/src/components/ContributionDrillDown.tsx @@ -20,12 +20,13 @@ interface ContributionDrillDownProps { } const tabLabels = { - prs: "Pull Requests", - reviews: "Reviews", - issues: "Issues", + prs: "Authored Pull Requests", + reviews: "Reviewed Pull Requests", + issues: "Authored Issues", + "reviewed-issues": "Reviewed Issues", } as const; -const tabs = ["prs", "reviews", "issues"] as const; +const tabs = ["prs", "reviews", "issues", "reviewed-issues"] as const; export function ContributionDrillDown({ username, @@ -84,7 +85,7 @@ export function ContributionDrillDown({ role="tab" aria-selected={filters.tab === tab} onClick={() => setTab(tab)} - className={`whitespace-nowrap flex-shrink-0 px-4 py-2 text-sm font-medium transition-colors ${ + className={`cursor-pointer whitespace-nowrap flex-shrink-0 px-4 py-2 text-sm font-medium transition-colors ${ filters.tab === tab ? "border-b-2 border-zinc-900 text-zinc-900 dark:border-zinc-100 dark:text-zinc-100" : "text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200" diff --git a/src/components/ContributionTable.tsx b/src/components/ContributionTable.tsx index e793a9a..c7df760 100644 --- a/src/components/ContributionTable.tsx +++ b/src/components/ContributionTable.tsx @@ -6,6 +6,8 @@ import { Badge } from "@/components/ui/badge"; import { cn, stateColors } from "@/lib/utils"; import { formatDate } from "@/lib/date-utils"; import { ExpandedPRDetail } from "@/components/ExpandedPRDetail"; +import { ExpandedReviewDetail } from "@/components/ExpandedReviewDetail"; +import { ExpandedIssueDetail } from "@/components/ExpandedIssueDetail"; import { EmptyState } from "@/components/EmptyState"; import type { ContributionDetail } from "@/lib/types"; @@ -35,7 +37,7 @@ export function ContributionTable({ items }: ContributionTableProps) { {/* Data rows */} {items.map((item) => { const isExpanded = expandedId === item.id; - const canExpand = item.type === "pr"; + const canExpand = item.type === "pr" || item.type === "review" || item.type === "issue"; const [owner, repo] = item.repoNameWithOwner.split("/"); return ( @@ -91,9 +93,15 @@ export function ContributionTable({ items }: ContributionTableProps) { - {isExpanded && canExpand && ( + {isExpanded && canExpand && item.type === "pr" && ( )} + {isExpanded && canExpand && item.type === "review" && ( + + )} + {isExpanded && canExpand && item.type === "issue" && ( + + )} ); })} diff --git a/src/components/ExpandedIssueDetail.tsx b/src/components/ExpandedIssueDetail.tsx new file mode 100644 index 0000000..23db7eb --- /dev/null +++ b/src/components/ExpandedIssueDetail.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useIssueDetail } from "@/hooks/use-issue-detail"; + +interface ExpandedIssueDetailProps { + owner: string; + repo: string; + number: number; +} + +export function ExpandedIssueDetail({ owner, repo, number }: ExpandedIssueDetailProps) { + const { data, error, isLoading } = useIssueDetail(owner, repo, number); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error || !data) { + return ( +
+ Failed to load issue details. +
+ ); + } + + return ( +
+
+
+ Comments: + {data.commentCount} +
+
+
+ ); +} diff --git a/src/components/ExpandedReviewDetail.tsx b/src/components/ExpandedReviewDetail.tsx new file mode 100644 index 0000000..b87902c --- /dev/null +++ b/src/components/ExpandedReviewDetail.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { usePRDetail } from "@/hooks/use-pr-detail"; + +interface ExpandedReviewDetailProps { + owner: string; + repo: string; + number: number; +} + +export function ExpandedReviewDetail({ owner, repo, number }: ExpandedReviewDetailProps) { + const { data, error, isLoading } = usePRDetail(owner, repo, number); + + if (isLoading) { + return ( +
+
+ {Array.from({ length: 2 }, (_, i) => ( +
+ ))} +
+
+ ); + } + + if (error || !data) { + return ( +
+ Failed to load review details. +
+ ); + } + + return ( +
+
+
+ Reviews: + {data.reviewCount} +
+
+ Comments: + {data.commentCount} +
+
+
+ ); +} diff --git a/src/components/__tests__/ContributionCard.test.tsx b/src/components/__tests__/ContributionCard.test.tsx index 6c44c0d..65e0337 100644 --- a/src/components/__tests__/ContributionCard.test.tsx +++ b/src/components/__tests__/ContributionCard.test.tsx @@ -3,13 +3,25 @@ import { render, screen, cleanup } from "@testing-library/react"; import { ContributionCard } from "@/components/ContributionCard"; import type { ContributionDetail } from "@/lib/types"; -// Mock ExpandedPRDetail +// Mock ExpandedPRDetail and ExpandedIssueDetail vi.mock("@/components/ExpandedPRDetail", () => ({ ExpandedPRDetail: ({ number }: { number: number }) => (
PR Detail #{number}
), })); +vi.mock("@/components/ExpandedReviewDetail", () => ({ + ExpandedReviewDetail: ({ number }: { number: number }) => ( +
Review Detail #{number}
+ ), +})); + +vi.mock("@/components/ExpandedIssueDetail", () => ({ + ExpandedIssueDetail: ({ number }: { number: number }) => ( +
Issue Detail #{number}
+ ), +})); + afterEach(cleanup); const prItem: ContributionDetail = { @@ -67,9 +79,9 @@ describe("ContributionCard", () => { expect(expandable).toBeInTheDocument(); }); - it("issue card has no expand button", () => { + it("issue card has expand button", () => { render(); - expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.getByRole("button")).toBeInTheDocument(); }); }); diff --git a/src/components/__tests__/ContributionDrillDown.test.tsx b/src/components/__tests__/ContributionDrillDown.test.tsx index b75b011..f1216ba 100644 --- a/src/components/__tests__/ContributionDrillDown.test.tsx +++ b/src/components/__tests__/ContributionDrillDown.test.tsx @@ -86,9 +86,10 @@ describe("ContributionDrillDown", () => { it("renders tabs", () => { render(); - expect(screen.getByText("Pull Requests")).toBeInTheDocument(); - expect(screen.getByText("Reviews")).toBeInTheDocument(); - expect(screen.getByText("Issues")).toBeInTheDocument(); + expect(screen.getByText("Authored Pull Requests")).toBeInTheDocument(); + expect(screen.getByText("Reviewed Pull Requests")).toBeInTheDocument(); + expect(screen.getByText("Authored Issues")).toBeInTheDocument(); + expect(screen.getByText("Reviewed Issues")).toBeInTheDocument(); }); it("renders date filter bar", () => { @@ -122,8 +123,11 @@ describe("ContributionDrillDown", () => { it("switches tabs", () => { render(); - fireEvent.click(screen.getByText("Reviews")); + fireEvent.click(screen.getByText("Reviewed Pull Requests")); expect(mockSetTab).toHaveBeenCalledWith("reviews"); + + fireEvent.click(screen.getByText("Reviewed Issues")); + expect(mockSetTab).toHaveBeenCalledWith("reviewed-issues"); }); it("shows load more when hasMore", () => { diff --git a/src/components/__tests__/ContributionTable.test.tsx b/src/components/__tests__/ContributionTable.test.tsx index 7fac8e2..3debda2 100644 --- a/src/components/__tests__/ContributionTable.test.tsx +++ b/src/components/__tests__/ContributionTable.test.tsx @@ -3,13 +3,25 @@ import { render, screen, cleanup, fireEvent } from "@testing-library/react"; import { ContributionTable } from "@/components/ContributionTable"; import type { ContributionDetail } from "@/lib/types"; -// Mock the ExpandedPRDetail since it uses SWR +// Mock the ExpandedPRDetail and ExpandedIssueDetail since they use SWR vi.mock("@/components/ExpandedPRDetail", () => ({ ExpandedPRDetail: ({ number }: { number: number }) => (
PR Detail #{number}
), })); +vi.mock("@/components/ExpandedReviewDetail", () => ({ + ExpandedReviewDetail: ({ number }: { number: number }) => ( +
Review Detail #{number}
+ ), +})); + +vi.mock("@/components/ExpandedIssueDetail", () => ({ + ExpandedIssueDetail: ({ number }: { number: number }) => ( +
Issue Detail #{number}
+ ), +})); + import { vi } from "vitest"; afterEach(cleanup); @@ -68,11 +80,14 @@ describe("ContributionTable", () => { expect(screen.getByTestId("pr-detail")).toBeInTheDocument(); }); - it("does not expand issue rows", () => { + it("expands issue row on click", () => { render(); - const issueRow = screen.getByText("Add docs").closest("div"); - expect(issueRow).not.toHaveAttribute("role", "button"); + const row = screen.getByText("Add docs").closest("[role='button']"); + expect(row).toBeInTheDocument(); + + fireEvent.click(row!); + expect(screen.getByTestId("issue-detail")).toBeInTheDocument(); }); it("renders column headers", () => { diff --git a/src/hooks/use-issue-detail.ts b/src/hooks/use-issue-detail.ts new file mode 100644 index 0000000..7f61a26 --- /dev/null +++ b/src/hooks/use-issue-detail.ts @@ -0,0 +1,42 @@ +"use client"; + +import useSWR from "swr"; +import type { IssueDetail } from "@/lib/types"; + +type IssueDetailError = { + status: number; + message: string; + resetAt?: number; +}; + +async function fetcher(url: string): Promise { + const res = await fetch(url); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + const err: IssueDetailError = { + status: res.status, + message: body.error ?? "Failed to fetch issue detail", + resetAt: body.resetAt, + }; + throw err; + } + return res.json(); +} + +export function useIssueDetail( + owner: string | undefined, + repo: string | undefined, + number: number | undefined +) { + const shouldFetch = owner && repo && number; + const key = shouldFetch + ? `/api/github/issue-detail/${owner}/${repo}/${number}` + : null; + + const { data, error, isLoading } = useSWR(key, fetcher, { + revalidateOnFocus: false, + dedupingInterval: 600_000, + }); + + return { data, error, isLoading }; +} diff --git a/src/lib/__tests__/github-rest.test.ts b/src/lib/__tests__/github-rest.test.ts index 8694c86..adc065e 100644 --- a/src/lib/__tests__/github-rest.test.ts +++ b/src/lib/__tests__/github-rest.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { searchContributions, fetchPRDetail, + fetchIssueDetail, buildSearchQuery, mapSearchItem, repoNameFromUrl, @@ -26,16 +27,21 @@ describe("buildSearchQuery", () => { expect(q).toBe("author:alice type:pr"); }); - it("builds review query with reviewed-by", () => { + it("builds review query excluding self-authored PRs", () => { const q = buildSearchQuery("alice", { tab: "reviews" }); - expect(q).toBe("reviewed-by:alice type:pr"); + expect(q).toBe("reviewed-by:alice type:pr -author:alice"); }); - it("builds issue query", () => { + it("builds authored issue query", () => { const q = buildSearchQuery("alice", { tab: "issues" }); expect(q).toBe("author:alice type:issue"); }); + it("builds reviewed-issues query excluding self-authored", () => { + const q = buildSearchQuery("alice", { tab: "reviewed-issues" }); + expect(q).toBe("commenter:alice type:issue -author:alice"); + }); + it("adds date range", () => { const q = buildSearchQuery("alice", { tab: "prs", @@ -113,6 +119,12 @@ describe("mapSearchItem", () => { expect(result.state).toBe("closed"); }); + it("maps reviewed-issues type", () => { + const item = { ...baseItem, pull_request: undefined }; + const result = mapSearchItem(item, "reviewed-issues"); + expect(result.type).toBe("issue"); + }); + it("maps open state", () => { const item = { ...baseItem, state: "open", pull_request: undefined }; const result = mapSearchItem(item, "prs"); @@ -210,6 +222,7 @@ describe("fetchPRDetail", () => { deletions: 20, changed_files: 5, commits: 3, + comments: 4, merged_at: "2024-06-02T00:00:00Z", created_at: "2024-06-01T00:00:00Z", }; @@ -224,6 +237,7 @@ describe("fetchPRDetail", () => { expect(result.changedFiles).toBe(5); expect(result.commits).toBe(3); expect(result.reviewCount).toBe(2); + expect(result.commentCount).toBe(4); expect(result.mergedAt).toBe("2024-06-02T00:00:00Z"); expect(result.timeToMerge).toBe(86400000); // 1 day in ms }); @@ -236,6 +250,7 @@ describe("fetchPRDetail", () => { deletions: 5, changed_files: 1, commits: 1, + comments: 0, merged_at: null, created_at: "2024-06-01T00:00:00Z", }; @@ -246,3 +261,26 @@ describe("fetchPRDetail", () => { expect(result.mergedAt).toBeNull(); }); }); + +describe("fetchIssueDetail", () => { + it("fetches issue and returns comment count", async () => { + mockGithubFetch.mockResolvedValue({ + comments: 7, + }); + + const result = await fetchIssueDetail("bitcoin", "bitcoin", 99, "token"); + + expect(result.number).toBe(99); + expect(result.repoNameWithOwner).toBe("bitcoin/bitcoin"); + expect(result.commentCount).toBe(7); + }); + + it("handles issue with zero comments", async () => { + mockGithubFetch.mockResolvedValue({ + comments: 0, + }); + + const result = await fetchIssueDetail("bitcoin", "bitcoin", 50, "token"); + expect(result.commentCount).toBe(0); + }); +}); diff --git a/src/lib/github-rest.ts b/src/lib/github-rest.ts index e592595..ac3b825 100644 --- a/src/lib/github-rest.ts +++ b/src/lib/github-rest.ts @@ -2,6 +2,7 @@ import { githubFetch } from "./github-search"; import type { ContributionDetail, PRDetail, + IssueDetail, PaginatedContributions, DrillDownTab, } from "./types"; @@ -41,6 +42,7 @@ interface GitHubPR { deletions: number; changed_files: number; commits: number; + comments: number; merged_at: string | null; created_at: string; } @@ -59,9 +61,14 @@ function buildSearchQuery( if (filters.tab === "reviews") { parts.push(`reviewed-by:${username}`); parts.push("type:pr"); + parts.push(`-author:${username}`); } else if (filters.tab === "prs") { parts.push(`author:${username}`); parts.push("type:pr"); + } else if (filters.tab === "reviewed-issues") { + parts.push(`commenter:${username}`); + parts.push("type:issue"); + parts.push(`-author:${username}`); } else { parts.push(`author:${username}`); parts.push("type:issue"); @@ -97,6 +104,7 @@ function repoNameFromUrl(repositoryUrl: string): string { function mapItemType(tab: DrillDownTab): "pr" | "issue" | "review" { if (tab === "prs") return "pr"; if (tab === "reviews") return "review"; + if (tab === "reviewed-issues") return "issue"; return "issue"; } @@ -178,6 +186,29 @@ export async function fetchPRDetail( mergedAt, timeToMerge, reviewCount: reviewsData.length, + commentCount: prData.comments, + }; +} + +interface GitHubIssue { + comments: number; +} + +export async function fetchIssueDetail( + owner: string, + repo: string, + number: number, + token: string +): Promise { + const data = (await githubFetch( + `/repos/${owner}/${repo}/issues/${number}`, + token + )) as GitHubIssue; + + return { + number, + repoNameWithOwner: `${owner}/${repo}`, + commentCount: data.comments, }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 84c2a91..8f72522 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -85,6 +85,13 @@ export interface PRDetail { mergedAt: string | null; timeToMerge: number | null; // milliseconds reviewCount: number; + commentCount: number; +} + +export interface IssueDetail { + number: number; + repoNameWithOwner: string; + commentCount: number; } export interface PaginatedContributions { @@ -94,7 +101,7 @@ export interface PaginatedContributions { page: number; } -export type DrillDownTab = "prs" | "reviews" | "issues"; +export type DrillDownTab = "prs" | "reviews" | "issues" | "reviewed-issues"; export interface ContributionFilters { tab: DrillDownTab;