Skip to content

Commit e0f3422

Browse files
authored
Merge pull request #7 from btrustteam/feat/contribution-tabs-issues-reviews
feat: split issues tab into authored/reviewed and refine review tabs
2 parents ee75c7e + 375334e commit e0f3422

14 files changed

Lines changed: 334 additions & 25 deletions

File tree

src/app/api/github/contributions/[username]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { GITHUB_USERNAME_RE } from "@/lib/utils";
66
import { RateLimitError } from "@/lib/types";
77
import type { PaginatedContributions, DrillDownTab } from "@/lib/types";
88

9-
const VALID_TABS: DrillDownTab[] = ["prs", "reviews", "issues"];
9+
const VALID_TABS: DrillDownTab[] = ["prs", "reviews", "issues", "reviewed-issues"];
1010
const VALID_STATUSES = ["open", "closed", "merged", "all"];
1111
const CACHE_TTL = 600; // 10 minutes
1212

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { NextResponse } from "next/server";
2+
import { auth } from "@/lib/auth";
3+
import { getCached, setCache } from "@/lib/cache";
4+
import { fetchIssueDetail } from "@/lib/github-rest";
5+
import { RateLimitError } from "@/lib/types";
6+
import type { IssueDetail } from "@/lib/types";
7+
8+
const CACHE_TTL = 600; // 10 minutes
9+
10+
export async function GET(
11+
_request: Request,
12+
{ params }: { params: Promise<{ owner: string; repo: string; number: string }> }
13+
) {
14+
const session = await auth();
15+
16+
if (!session?.accessToken) {
17+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
18+
}
19+
20+
const { owner, repo, number: numStr } = await params;
21+
const issueNumber = parseInt(numStr, 10);
22+
23+
if (isNaN(issueNumber) || issueNumber < 1) {
24+
return NextResponse.json({ error: "Invalid issue number" }, { status: 400 });
25+
}
26+
27+
const cacheKey = `issue-detail:${owner}/${repo}/${issueNumber}`;
28+
29+
const cached = await getCached<IssueDetail>(cacheKey);
30+
if (cached) {
31+
return NextResponse.json(cached);
32+
}
33+
34+
try {
35+
const result = await fetchIssueDetail(owner, repo, issueNumber, session.accessToken);
36+
37+
await setCache(cacheKey, result, CACHE_TTL);
38+
return NextResponse.json(result);
39+
} catch (error) {
40+
if (error instanceof RateLimitError) {
41+
return NextResponse.json(
42+
{ error: "Rate limit exceeded", resetAt: error.resetAt },
43+
{ status: 429 }
44+
);
45+
}
46+
return NextResponse.json(
47+
{ error: "GitHub API error" },
48+
{ status: 502 }
49+
);
50+
}
51+
}

src/components/ContributionCard.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { Card, CardContent } from "@/components/ui/card";
77
import { cn, stateColors } from "@/lib/utils";
88
import { formatDate } from "@/lib/date-utils";
99
import { ExpandedPRDetail } from "@/components/ExpandedPRDetail";
10+
import { ExpandedReviewDetail } from "@/components/ExpandedReviewDetail";
11+
import { ExpandedIssueDetail } from "@/components/ExpandedIssueDetail";
1012
import type { ContributionDetail } from "@/lib/types";
1113

1214
interface ContributionCardProps {
@@ -15,7 +17,7 @@ interface ContributionCardProps {
1517

1618
export function ContributionCard({ item }: ContributionCardProps) {
1719
const [expanded, setExpanded] = useState(false);
18-
const canExpand = item.type === "pr";
20+
const canExpand = item.type === "pr" || item.type === "review" || item.type === "issue";
1921
const [owner, repo] = item.repoNameWithOwner.split("/");
2022

2123
return (
@@ -65,11 +67,21 @@ export function ContributionCard({ item }: ContributionCardProps) {
6567
<ExternalLink className="h-4 w-4" />
6668
</a>
6769
</div>
68-
{expanded && canExpand && (
70+
{expanded && canExpand && item.type === "pr" && (
6971
<div className="mt-3">
7072
<ExpandedPRDetail owner={owner} repo={repo} number={item.number} />
7173
</div>
7274
)}
75+
{expanded && canExpand && item.type === "review" && (
76+
<div className="mt-3">
77+
<ExpandedReviewDetail owner={owner} repo={repo} number={item.number} />
78+
</div>
79+
)}
80+
{expanded && canExpand && item.type === "issue" && (
81+
<div className="mt-3">
82+
<ExpandedIssueDetail owner={owner} repo={repo} number={item.number} />
83+
</div>
84+
)}
7385
</CardContent>
7486
</Card>
7587
);

src/components/ContributionDrillDown.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@ interface ContributionDrillDownProps {
2020
}
2121

2222
const tabLabels = {
23-
prs: "Pull Requests",
24-
reviews: "Reviews",
25-
issues: "Issues",
23+
prs: "Authored Pull Requests",
24+
reviews: "Reviewed Pull Requests",
25+
issues: "Authored Issues",
26+
"reviewed-issues": "Reviewed Issues",
2627
} as const;
2728

28-
const tabs = ["prs", "reviews", "issues"] as const;
29+
const tabs = ["prs", "reviews", "issues", "reviewed-issues"] as const;
2930

3031
export function ContributionDrillDown({
3132
username,
@@ -84,7 +85,7 @@ export function ContributionDrillDown({
8485
role="tab"
8586
aria-selected={filters.tab === tab}
8687
onClick={() => setTab(tab)}
87-
className={`whitespace-nowrap flex-shrink-0 px-4 py-2 text-sm font-medium transition-colors ${
88+
className={`cursor-pointer whitespace-nowrap flex-shrink-0 px-4 py-2 text-sm font-medium transition-colors ${
8889
filters.tab === tab
8990
? "border-b-2 border-zinc-900 text-zinc-900 dark:border-zinc-100 dark:text-zinc-100"
9091
: "text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"

src/components/ContributionTable.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { Badge } from "@/components/ui/badge";
66
import { cn, stateColors } from "@/lib/utils";
77
import { formatDate } from "@/lib/date-utils";
88
import { ExpandedPRDetail } from "@/components/ExpandedPRDetail";
9+
import { ExpandedReviewDetail } from "@/components/ExpandedReviewDetail";
10+
import { ExpandedIssueDetail } from "@/components/ExpandedIssueDetail";
911
import { EmptyState } from "@/components/EmptyState";
1012
import type { ContributionDetail } from "@/lib/types";
1113

@@ -35,7 +37,7 @@ export function ContributionTable({ items }: ContributionTableProps) {
3537
{/* Data rows */}
3638
{items.map((item) => {
3739
const isExpanded = expandedId === item.id;
38-
const canExpand = item.type === "pr";
40+
const canExpand = item.type === "pr" || item.type === "review" || item.type === "issue";
3941
const [owner, repo] = item.repoNameWithOwner.split("/");
4042

4143
return (
@@ -91,9 +93,15 @@ export function ContributionTable({ items }: ContributionTableProps) {
9193
<ExternalLink className="h-4 w-4" />
9294
</a>
9395
</div>
94-
{isExpanded && canExpand && (
96+
{isExpanded && canExpand && item.type === "pr" && (
9597
<ExpandedPRDetail owner={owner} repo={repo} number={item.number} />
9698
)}
99+
{isExpanded && canExpand && item.type === "review" && (
100+
<ExpandedReviewDetail owner={owner} repo={repo} number={item.number} />
101+
)}
102+
{isExpanded && canExpand && item.type === "issue" && (
103+
<ExpandedIssueDetail owner={owner} repo={repo} number={item.number} />
104+
)}
97105
</div>
98106
);
99107
})}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"use client";
2+
3+
import { useIssueDetail } from "@/hooks/use-issue-detail";
4+
5+
interface ExpandedIssueDetailProps {
6+
owner: string;
7+
repo: string;
8+
number: number;
9+
}
10+
11+
export function ExpandedIssueDetail({ owner, repo, number }: ExpandedIssueDetailProps) {
12+
const { data, error, isLoading } = useIssueDetail(owner, repo, number);
13+
14+
if (isLoading) {
15+
return (
16+
<div role="status" aria-label="Loading issue details" className="animate-pulse p-4">
17+
<div className="h-4 w-24 bg-gray-200 rounded" />
18+
</div>
19+
);
20+
}
21+
22+
if (error || !data) {
23+
return (
24+
<div className="p-4 text-sm text-zinc-500">
25+
Failed to load issue details.
26+
</div>
27+
);
28+
}
29+
30+
return (
31+
<div className="border-t bg-zinc-50 px-4 py-3 dark:bg-zinc-900/50" data-testid="issue-detail">
32+
<div className="flex flex-wrap gap-x-6 gap-y-2 text-sm">
33+
<div>
34+
<span className="text-zinc-500 dark:text-zinc-400">Comments: </span>
35+
<span className="font-medium">{data.commentCount}</span>
36+
</div>
37+
</div>
38+
</div>
39+
);
40+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"use client";
2+
3+
import { usePRDetail } from "@/hooks/use-pr-detail";
4+
5+
interface ExpandedReviewDetailProps {
6+
owner: string;
7+
repo: string;
8+
number: number;
9+
}
10+
11+
export function ExpandedReviewDetail({ owner, repo, number }: ExpandedReviewDetailProps) {
12+
const { data, error, isLoading } = usePRDetail(owner, repo, number);
13+
14+
if (isLoading) {
15+
return (
16+
<div role="status" aria-label="Loading review details" className="animate-pulse p-4">
17+
<div className="flex gap-6">
18+
{Array.from({ length: 2 }, (_, i) => (
19+
<div key={i} className="h-4 w-16 bg-gray-200 rounded" />
20+
))}
21+
</div>
22+
</div>
23+
);
24+
}
25+
26+
if (error || !data) {
27+
return (
28+
<div className="p-4 text-sm text-zinc-500">
29+
Failed to load review details.
30+
</div>
31+
);
32+
}
33+
34+
return (
35+
<div className="border-t bg-zinc-50 px-4 py-3 dark:bg-zinc-900/50" data-testid="review-detail">
36+
<div className="flex flex-wrap gap-x-6 gap-y-2 text-sm">
37+
<div>
38+
<span className="text-zinc-500 dark:text-zinc-400">Reviews: </span>
39+
<span className="font-medium">{data.reviewCount}</span>
40+
</div>
41+
<div>
42+
<span className="text-zinc-500 dark:text-zinc-400">Comments: </span>
43+
<span className="font-medium">{data.commentCount}</span>
44+
</div>
45+
</div>
46+
</div>
47+
);
48+
}

src/components/__tests__/ContributionCard.test.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,25 @@ import { render, screen, cleanup } from "@testing-library/react";
33
import { ContributionCard } from "@/components/ContributionCard";
44
import type { ContributionDetail } from "@/lib/types";
55

6-
// Mock ExpandedPRDetail
6+
// Mock ExpandedPRDetail and ExpandedIssueDetail
77
vi.mock("@/components/ExpandedPRDetail", () => ({
88
ExpandedPRDetail: ({ number }: { number: number }) => (
99
<div data-testid="pr-detail">PR Detail #{number}</div>
1010
),
1111
}));
1212

13+
vi.mock("@/components/ExpandedReviewDetail", () => ({
14+
ExpandedReviewDetail: ({ number }: { number: number }) => (
15+
<div data-testid="review-detail">Review Detail #{number}</div>
16+
),
17+
}));
18+
19+
vi.mock("@/components/ExpandedIssueDetail", () => ({
20+
ExpandedIssueDetail: ({ number }: { number: number }) => (
21+
<div data-testid="issue-detail">Issue Detail #{number}</div>
22+
),
23+
}));
24+
1325
afterEach(cleanup);
1426

1527
const prItem: ContributionDetail = {
@@ -67,9 +79,9 @@ describe("ContributionCard", () => {
6779
expect(expandable).toBeInTheDocument();
6880
});
6981

70-
it("issue card has no expand button", () => {
82+
it("issue card has expand button", () => {
7183
render(<ContributionCard item={issueItem} />);
7284

73-
expect(screen.queryByRole("button")).not.toBeInTheDocument();
85+
expect(screen.getByRole("button")).toBeInTheDocument();
7486
});
7587
});

src/components/__tests__/ContributionDrillDown.test.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,10 @@ describe("ContributionDrillDown", () => {
8686
it("renders tabs", () => {
8787
render(<ContributionDrillDown username="alice" bitcoinRepos={bitcoinRepos} />);
8888

89-
expect(screen.getByText("Pull Requests")).toBeInTheDocument();
90-
expect(screen.getByText("Reviews")).toBeInTheDocument();
91-
expect(screen.getByText("Issues")).toBeInTheDocument();
89+
expect(screen.getByText("Authored Pull Requests")).toBeInTheDocument();
90+
expect(screen.getByText("Reviewed Pull Requests")).toBeInTheDocument();
91+
expect(screen.getByText("Authored Issues")).toBeInTheDocument();
92+
expect(screen.getByText("Reviewed Issues")).toBeInTheDocument();
9293
});
9394

9495
it("renders date filter bar", () => {
@@ -122,8 +123,11 @@ describe("ContributionDrillDown", () => {
122123
it("switches tabs", () => {
123124
render(<ContributionDrillDown username="alice" bitcoinRepos={bitcoinRepos} />);
124125

125-
fireEvent.click(screen.getByText("Reviews"));
126+
fireEvent.click(screen.getByText("Reviewed Pull Requests"));
126127
expect(mockSetTab).toHaveBeenCalledWith("reviews");
128+
129+
fireEvent.click(screen.getByText("Reviewed Issues"));
130+
expect(mockSetTab).toHaveBeenCalledWith("reviewed-issues");
127131
});
128132

129133
it("shows load more when hasMore", () => {

src/components/__tests__/ContributionTable.test.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,25 @@ import { render, screen, cleanup, fireEvent } from "@testing-library/react";
33
import { ContributionTable } from "@/components/ContributionTable";
44
import type { ContributionDetail } from "@/lib/types";
55

6-
// Mock the ExpandedPRDetail since it uses SWR
6+
// Mock the ExpandedPRDetail and ExpandedIssueDetail since they use SWR
77
vi.mock("@/components/ExpandedPRDetail", () => ({
88
ExpandedPRDetail: ({ number }: { number: number }) => (
99
<div data-testid="pr-detail">PR Detail #{number}</div>
1010
),
1111
}));
1212

13+
vi.mock("@/components/ExpandedReviewDetail", () => ({
14+
ExpandedReviewDetail: ({ number }: { number: number }) => (
15+
<div data-testid="review-detail">Review Detail #{number}</div>
16+
),
17+
}));
18+
19+
vi.mock("@/components/ExpandedIssueDetail", () => ({
20+
ExpandedIssueDetail: ({ number }: { number: number }) => (
21+
<div data-testid="issue-detail">Issue Detail #{number}</div>
22+
),
23+
}));
24+
1325
import { vi } from "vitest";
1426

1527
afterEach(cleanup);
@@ -68,11 +80,14 @@ describe("ContributionTable", () => {
6880
expect(screen.getByTestId("pr-detail")).toBeInTheDocument();
6981
});
7082

71-
it("does not expand issue rows", () => {
83+
it("expands issue row on click", () => {
7284
render(<ContributionTable items={items} />);
7385

74-
const issueRow = screen.getByText("Add docs").closest("div");
75-
expect(issueRow).not.toHaveAttribute("role", "button");
86+
const row = screen.getByText("Add docs").closest("[role='button']");
87+
expect(row).toBeInTheDocument();
88+
89+
fireEvent.click(row!);
90+
expect(screen.getByTestId("issue-detail")).toBeInTheDocument();
7691
});
7792

7893
it("renders column headers", () => {

0 commit comments

Comments
 (0)