Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/api/github/contributions/[username]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
51 changes: 51 additions & 0 deletions src/app/api/github/issue-detail/[owner]/[repo]/[number]/route.ts
Original file line number Diff line number Diff line change
@@ -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<IssueDetail>(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 }
);
}
}
16 changes: 14 additions & 2 deletions src/components/ContributionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 (
Expand Down Expand Up @@ -65,11 +67,21 @@ export function ContributionCard({ item }: ContributionCardProps) {
<ExternalLink className="h-4 w-4" />
</a>
</div>
{expanded && canExpand && (
{expanded && canExpand && item.type === "pr" && (
<div className="mt-3">
<ExpandedPRDetail owner={owner} repo={repo} number={item.number} />
</div>
)}
{expanded && canExpand && item.type === "review" && (
<div className="mt-3">
<ExpandedReviewDetail owner={owner} repo={repo} number={item.number} />
</div>
)}
{expanded && canExpand && item.type === "issue" && (
<div className="mt-3">
<ExpandedIssueDetail owner={owner} repo={repo} number={item.number} />
</div>
)}
</CardContent>
</Card>
);
Expand Down
11 changes: 6 additions & 5 deletions src/components/ContributionDrillDown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down
12 changes: 10 additions & 2 deletions src/components/ContributionTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -91,9 +93,15 @@ export function ContributionTable({ items }: ContributionTableProps) {
<ExternalLink className="h-4 w-4" />
</a>
</div>
{isExpanded && canExpand && (
{isExpanded && canExpand && item.type === "pr" && (
<ExpandedPRDetail owner={owner} repo={repo} number={item.number} />
)}
{isExpanded && canExpand && item.type === "review" && (
<ExpandedReviewDetail owner={owner} repo={repo} number={item.number} />
)}
{isExpanded && canExpand && item.type === "issue" && (
<ExpandedIssueDetail owner={owner} repo={repo} number={item.number} />
)}
</div>
);
})}
Expand Down
40 changes: 40 additions & 0 deletions src/components/ExpandedIssueDetail.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div role="status" aria-label="Loading issue details" className="animate-pulse p-4">
<div className="h-4 w-24 bg-gray-200 rounded" />
</div>
);
}

if (error || !data) {
return (
<div className="p-4 text-sm text-zinc-500">
Failed to load issue details.
</div>
);
}

return (
<div className="border-t bg-zinc-50 px-4 py-3 dark:bg-zinc-900/50" data-testid="issue-detail">
<div className="flex flex-wrap gap-x-6 gap-y-2 text-sm">
<div>
<span className="text-zinc-500 dark:text-zinc-400">Comments: </span>
<span className="font-medium">{data.commentCount}</span>
</div>
</div>
</div>
);
}
48 changes: 48 additions & 0 deletions src/components/ExpandedReviewDetail.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div role="status" aria-label="Loading review details" className="animate-pulse p-4">
<div className="flex gap-6">
{Array.from({ length: 2 }, (_, i) => (
<div key={i} className="h-4 w-16 bg-gray-200 rounded" />
))}
</div>
</div>
);
}

if (error || !data) {
return (
<div className="p-4 text-sm text-zinc-500">
Failed to load review details.
</div>
);
}

return (
<div className="border-t bg-zinc-50 px-4 py-3 dark:bg-zinc-900/50" data-testid="review-detail">
<div className="flex flex-wrap gap-x-6 gap-y-2 text-sm">
<div>
<span className="text-zinc-500 dark:text-zinc-400">Reviews: </span>
<span className="font-medium">{data.reviewCount}</span>
</div>
<div>
<span className="text-zinc-500 dark:text-zinc-400">Comments: </span>
<span className="font-medium">{data.commentCount}</span>
</div>
</div>
</div>
);
}
18 changes: 15 additions & 3 deletions src/components/__tests__/ContributionCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<div data-testid="pr-detail">PR Detail #{number}</div>
),
}));

vi.mock("@/components/ExpandedReviewDetail", () => ({
ExpandedReviewDetail: ({ number }: { number: number }) => (
<div data-testid="review-detail">Review Detail #{number}</div>
),
}));

vi.mock("@/components/ExpandedIssueDetail", () => ({
ExpandedIssueDetail: ({ number }: { number: number }) => (
<div data-testid="issue-detail">Issue Detail #{number}</div>
),
}));

afterEach(cleanup);

const prItem: ContributionDetail = {
Expand Down Expand Up @@ -67,9 +79,9 @@ describe("ContributionCard", () => {
expect(expandable).toBeInTheDocument();
});

it("issue card has no expand button", () => {
it("issue card has expand button", () => {
render(<ContributionCard item={issueItem} />);

expect(screen.queryByRole("button")).not.toBeInTheDocument();
expect(screen.getByRole("button")).toBeInTheDocument();
});
});
12 changes: 8 additions & 4 deletions src/components/__tests__/ContributionDrillDown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,10 @@ describe("ContributionDrillDown", () => {
it("renders tabs", () => {
render(<ContributionDrillDown username="alice" bitcoinRepos={bitcoinRepos} />);

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", () => {
Expand Down Expand Up @@ -122,8 +123,11 @@ describe("ContributionDrillDown", () => {
it("switches tabs", () => {
render(<ContributionDrillDown username="alice" bitcoinRepos={bitcoinRepos} />);

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", () => {
Expand Down
23 changes: 19 additions & 4 deletions src/components/__tests__/ContributionTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<div data-testid="pr-detail">PR Detail #{number}</div>
),
}));

vi.mock("@/components/ExpandedReviewDetail", () => ({
ExpandedReviewDetail: ({ number }: { number: number }) => (
<div data-testid="review-detail">Review Detail #{number}</div>
),
}));

vi.mock("@/components/ExpandedIssueDetail", () => ({
ExpandedIssueDetail: ({ number }: { number: number }) => (
<div data-testid="issue-detail">Issue Detail #{number}</div>
),
}));

import { vi } from "vitest";

afterEach(cleanup);
Expand Down Expand Up @@ -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(<ContributionTable items={items} />);

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", () => {
Expand Down
Loading
Loading