From 8c5980b6d379d61d3493322e00135a346d00882d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 30 May 2025 20:00:41 +0000 Subject: [PATCH] feat: Add inspectable pull requests to your profile This feature enhances your profile page by adding a section to display and inspect individual pull requests associated with you. Key changes: 1. **New Data Type**: I defined `PullRequestData` in `src/lib/data/types.ts` for consistent PR representation. 2. **New Query Function**: I implemented `getUserPullRequests` in `src/app/profile/[username]/queries.ts` to fetch paginated and filterable pull requests (by status: OPEN, MERGED, CLOSED). 3. **Server Action**: I created `fetchUserPullRequestsAction` in `src/app/profile/[username]/actions.ts` to allow client components to fetch PR data. 4. **New UI Component**: I developed `PullRequestList.tsx` in `src/app/profile/[username]/components/`. This client component displays PRs with: * Tabs for filtering by status (ALL, OPEN, MERGED, CLOSED). * A list of PRs showing title (linked to GitHub), author, number, creation date, and status. * Pagination controls. 5. **Profile Page Update**: * I modified `src/app/profile/[username]/components/UserProfile.tsx` to include the `PullRequestList` component. * I updated `src/app/profile/[username]/page.tsx` to fetch the initial set of PRs and total count for the "ALL" filter to pass to `PullRequestList`. 6. **Testing**: * I added unit tests for `getUserPullRequests` in `src/app/profile/[username]/queries.test.ts`, covering various filtering and pagination scenarios. * I added component tests for `PullRequestList.tsx` in `src/app/profile/[username]/components/PullRequestList.test.tsx`, covering UI interactions, data fetching calls, and rendering. This provides you with a more detailed view of your contributions and activities related to pull requests directly on your profile page. --- src/app/profile/[username]/actions.ts | 13 + .../components/PullRequestList.test.tsx | 277 ++++++++++++++++++ .../[username]/components/PullRequestList.tsx | 162 ++++++++++ .../[username]/components/UserProfile.tsx | 19 ++ src/app/profile/[username]/page.tsx | 20 +- src/app/profile/[username]/queries.test.ts | 175 +++++++++++ src/app/profile/[username]/queries.ts | 79 +++++ src/lib/data/types.ts | 10 + 8 files changed, 753 insertions(+), 2 deletions(-) create mode 100644 src/app/profile/[username]/actions.ts create mode 100644 src/app/profile/[username]/components/PullRequestList.test.tsx create mode 100644 src/app/profile/[username]/components/PullRequestList.tsx create mode 100644 src/app/profile/[username]/queries.test.ts diff --git a/src/app/profile/[username]/actions.ts b/src/app/profile/[username]/actions.ts new file mode 100644 index 000000000..336e3bde6 --- /dev/null +++ b/src/app/profile/[username]/actions.ts @@ -0,0 +1,13 @@ +'use server'; + +import { getUserPullRequests } from './queries'; +import { PullRequestData } from '@/lib/data/types'; + +export async function fetchUserPullRequestsAction( + username: string, + status?: 'OPEN' | 'MERGED' | 'CLOSED', + page?: number, + pageSize?: number, +): Promise<{ pullRequests: PullRequestData[]; totalCount: number }> { + return getUserPullRequests(username, status, page, pageSize); +} diff --git a/src/app/profile/[username]/components/PullRequestList.test.tsx b/src/app/profile/[username]/components/PullRequestList.test.tsx new file mode 100644 index 000000000..21adcc3b9 --- /dev/null +++ b/src/app/profile/[username]/components/PullRequestList.test.tsx @@ -0,0 +1,277 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { PullRequestList } from "./PullRequestList"; +import { fetchUserPullRequestsAction } from "../actions"; +import { PullRequestData } from "@/lib/data/types"; + +// Mock the server action +jest.mock("../actions", () => ({ + fetchUserPullRequestsAction: jest.fn(), +})); + +const mockFetchUserPullRequestsAction = + fetchUserPullRequestsAction as jest.Mock; + +const createMockPrData = (id: number, title: string, status: "OPEN" | "MERGED" | "CLOSED", number: number = id ): PullRequestData => ({ + id, + title, + status, + url: `https://github.com/pull/${number}`, + createdAt: new Date().toISOString(), + author: "testuser", + number, +}); + +const initialPrs: PullRequestData[] = [ + createMockPrData(1, "Initial PR 1", "OPEN", 101), + createMockPrData(2, "Initial PR 2", "MERGED", 102), +]; + +describe("PullRequestList", () => { + const username = "testuser"; + const defaultPageSize = 5; + + beforeEach(() => { + jest.clearAllMocks(); + mockFetchUserPullRequestsAction.mockResolvedValue({ + pullRequests: [], + totalCount: 0, + }); + }); + + const renderComponent = ( + initialPullRequests: PullRequestData[] = initialPrs, + totalInitialPullRequests: number = initialPrs.length, + pageSize: number = defaultPageSize, + ) => { + return render( + , + ); + }; + + it("renders with initial pull requests", () => { + renderComponent(); + expect(screen.getByText("Initial PR 1 (#101)")).toBeInTheDocument(); + expect(screen.getByText("Initial PR 2 (#102)")).toBeInTheDocument(); + expect( + screen.getByRole("tab", { name: /All \(\d+\)/i, selected: true }), + ).toHaveTextContent(`All (${initialPrs.length})`); + }); + + it("switches tabs and calls server action with correct status", async () => { + renderComponent(); + const openTab = screen.getByRole("tab", { name: "Open" }); + fireEvent.click(openTab); + + await waitFor(() => { + expect(mockFetchUserPullRequestsAction).toHaveBeenCalledWith( + username, + "OPEN", + 1, + defaultPageSize, + ); + }); + expect(screen.getByRole('tab', {selected: true})).toHaveTextContent("Open"); + }); + + it("calls server action with MERGED status", async () => { + renderComponent(); + const mergedTab = screen.getByRole("tab", { name: "Merged" }); + fireEvent.click(mergedTab); + + await waitFor(() => { + expect(mockFetchUserPullRequestsAction).toHaveBeenCalledWith( + username, + "MERGED", + 1, + defaultPageSize, + ); + }); + expect(screen.getByRole('tab', {selected: true})).toHaveTextContent("Merged"); + }); + + it("calls server action with CLOSED status", async () => { + renderComponent(); + const closedTab = screen.getByRole("tab", { name: "Closed" }); + fireEvent.click(closedTab); + + await waitFor(() => { + expect(mockFetchUserPullRequestsAction).toHaveBeenCalledWith( + username, + "CLOSED", + 1, + defaultPageSize, + ); + }); + expect(screen.getByRole('tab', {selected: true})).toHaveTextContent("Closed"); + }); + + it("calls server action with ALL (undefined) status and resets page", async () => { + renderComponent(); + // First, switch to another tab and page + const openTab = screen.getByRole("tab", { name: "Open" }); + fireEvent.click(openTab); + await waitFor(() => expect(mockFetchUserPullRequestsAction).toHaveBeenCalledTimes(1)); + + // Mock response for Open tab to enable pagination + mockFetchUserPullRequestsAction.mockResolvedValueOnce({ + pullRequests: [createMockPrData(3, "Open PR Page 1", "OPEN", 301)], + totalCount: defaultPageSize + 1, // More than one page + }); + fireEvent.click(openTab); // Re-click to trigger fetch with new mock + await waitFor(() => { + expect(screen.getByText("Open PR Page 1 (#301)")).toBeInTheDocument(); + }); + const nextButton = screen.getByRole("button", { name: "Next" }); + fireEvent.click(nextButton); + await waitFor(() => { + expect(mockFetchUserPullRequestsAction).toHaveBeenCalledWith(username, "OPEN", 2, defaultPageSize); + }); + + // Now switch back to ALL tab + const allTab = screen.getByRole("tab", { name: /All/i }); + fireEvent.click(allTab); + + // Should use initial PRs for ALL, page 1 if available and no other fetch should be made + // unless initialPullRequests is empty + if (initialPrs.length > 0) { + expect(mockFetchUserPullRequestsAction).toHaveBeenCalledTimes(3); // Open, Open Page 2 + expect(screen.getByText("Initial PR 1 (#101)")).toBeInTheDocument(); + } else { + await waitFor(() => { + expect(mockFetchUserPullRequestsAction).toHaveBeenCalledWith( + username, + undefined, // ALL + 1, // Reset to page 1 + defaultPageSize, + ); + }); + } + expect(screen.getByRole('tab', {selected: true})).toHaveTextContent(/All/); + }); + + + it("handles pagination: Next and Previous buttons", async () => { + mockFetchUserPullRequestsAction.mockResolvedValue({ + pullRequests: initialPrs, // Assume these are for page 1 + totalCount: defaultPageSize * 2, // Enough for two pages + }); + renderComponent(initialPrs, defaultPageSize * 2); + + const nextButton = screen.getByRole("button", { name: "Next" }); + fireEvent.click(nextButton); + + await waitFor(() => { + expect(mockFetchUserPullRequestsAction).toHaveBeenCalledWith( + username, + undefined, // Current tab is ALL + 2, // Next page + defaultPageSize, + ); + }); + + const prevButton = screen.getByRole("button", { name: "Previous" }); + fireEvent.click(prevButton); + + await waitFor(() => { + expect(mockFetchUserPullRequestsAction).toHaveBeenCalledWith( + username, + undefined, // Current tab is ALL + 1, // Previous page + defaultPageSize, + ); + }); + }); + + it("renders pull request items correctly", () => { + renderComponent([createMockPrData(10, "Test PR Item", "OPEN", 110)], 1); + const prLink = screen.getByText("Test PR Item (#110)"); + expect(prLink).toBeInTheDocument(); + expect(prLink.closest("a")).toHaveAttribute("href", "https://github.com/pull/110"); + expect(screen.getByText("OPEN")).toBeInTheDocument(); // Badge + // Check for date and author (might need more specific query) + expect(screen.getByText(/By testuser on/)).toBeInTheDocument(); + }); + + it("shows loading state", async () => { + mockFetchUserPullRequestsAction.mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve({ pullRequests: [], totalCount: 0 }), 100)) + ); + renderComponent([], 0); // No initial PRs to show loading immediately for "ALL" + + // Click any tab to trigger fetch + const openTab = screen.getByRole("tab", { name: "Open" }); + fireEvent.click(openTab); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + }); + }); + + it("shows empty state when no pull requests are found", async () => { + mockFetchUserPullRequestsAction.mockResolvedValue({ + pullRequests: [], + totalCount: 0, + }); + renderComponent([], 0); // Start with no initial PRs + + // Click any tab to trigger fetch + const openTab = screen.getByRole("tab", { name: "Open" }); + fireEvent.click(openTab); + + await waitFor(() => { + expect( + screen.getByText("No pull requests found for this filter."), + ).toBeInTheDocument(); + }); + }); + + it("disables Previous button on page 1 and Next button on last page", async () => { + mockFetchUserPullRequestsAction.mockResolvedValue({ + pullRequests: initialPrs, + totalCount: initialPrs.length, // Only one page + }); + renderComponent(initialPrs, initialPrs.length, defaultPageSize); + + await waitFor(() => { // Ensure initial PRs are processed + expect(screen.getByText("Initial PR 1 (#101)")).toBeInTheDocument(); + }); + + expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); + + // Test with multiple pages + mockFetchUserPullRequestsAction.mockResolvedValue({ + pullRequests: initialPrs, + totalCount: defaultPageSize * 2, + }); + // Re-render or switch tab to re-evaluate pagination based on new totalCount + const openTab = screen.getByRole("tab", { name: "Open" }); + fireEvent.click(openTab); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Next" })).not.toBeDisabled(); + }); + + // Go to next page + fireEvent.click(screen.getByRole("button", { name: "Next" })); + mockFetchUserPullRequestsAction.mockResolvedValueOnce({ // For page 2 + pullRequests: [createMockPrData(3, "Page 2 PR", "OPEN")], + totalCount: defaultPageSize * 2, + }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Previous" })).not.toBeDisabled(); + expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); // Now on last page + }); + + }); +}); diff --git a/src/app/profile/[username]/components/PullRequestList.tsx b/src/app/profile/[username]/components/PullRequestList.tsx new file mode 100644 index 000000000..713f730cd --- /dev/null +++ b/src/app/profile/[username]/components/PullRequestList.tsx @@ -0,0 +1,162 @@ +"use client"; + +import * as React from "react"; +import { PullRequestData } from "@/lib/data/types"; +import { fetchUserPullRequestsAction } from "../actions"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { ExternalLinkIcon } from "lucide-react"; // Using lucide-react for consistency + +interface PullRequestListProps { + initialPullRequests: PullRequestData[]; + totalInitialPullRequests: number; // Total PRs for the initial "ALL" filter + username: string; + pageSize?: number; +} + +const DEFAULT_PAGE_SIZE = 10; + +export function PullRequestList({ + initialPullRequests, + totalInitialPullRequests, + username, + pageSize = DEFAULT_PAGE_SIZE, +}: PullRequestListProps) { + const [currentPage, setCurrentPage] = React.useState(1); + const [activeStatusTab, setActiveStatusTab] = React.useState< + "ALL" | "OPEN" | "MERGED" | "CLOSED" + >("ALL"); + const [pullRequests, setPullRequests] = + React.useState(initialPullRequests); + const [totalCount, setTotalCount] = React.useState(totalInitialPullRequests); + const [isLoading, setIsLoading] = React.useState(false); + + const fetchPullRequests = async ( + page: number, + status?: "OPEN" | "MERGED" | "CLOSED", + ) => { + setIsLoading(true); + try { + const result = await fetchUserPullRequestsAction( + username, + status, + page, + pageSize, + ); + setPullRequests(result.pullRequests); + setTotalCount(result.totalCount); + } catch (error) { + console.error("Failed to fetch pull requests:", error); + // Handle error display in UI if necessary + setPullRequests([]); + setTotalCount(0); + } finally { + setIsLoading(false); + } + }; + + React.useEffect(() => { + // Don't refetch for "ALL" on page 1 if initialPullRequests are already there + if (activeStatusTab === "ALL" && currentPage === 1 && initialPullRequests.length > 0) { + setPullRequests(initialPullRequests); + setTotalCount(totalInitialPullRequests); + return; + } + + fetchPullRequests( + currentPage, + activeStatusTab === "ALL" ? undefined : activeStatusTab, + ); + }, [currentPage, activeStatusTab, username, pageSize]); + + const handleTabChange = (value: string) => { + setActiveStatusTab(value as "ALL" | "OPEN" | "MERGED" | "CLOSED"); + setCurrentPage(1); // Reset to page 1 when tab changes + }; + + const totalPages = Math.ceil(totalCount / pageSize); + + return ( + + + Pull Requests + + + + + All ({totalInitialPullRequests}) {/* Show initial total for ALL */} + Open + Merged + Closed + + + {isLoading &&

Loading...

} + {!isLoading && pullRequests.length === 0 && ( +

No pull requests found for this filter.

+ )} + {!isLoading && pullRequests.length > 0 && ( +
+ {pullRequests.map((pr) => ( +
+
+ + {pr.title} (#{pr.number}) + + +

+ By {pr.author} on{" "} + {new Date(pr.createdAt).toLocaleDateString()} +

+
+ + {pr.status} + +
+ ))} +
+ )} + {totalPages > 1 && ( +
+ + + Page {currentPage} of {totalPages} + + +
+ )} +
+
+
+
+ ); +} diff --git a/src/app/profile/[username]/components/UserProfile.tsx b/src/app/profile/[username]/components/UserProfile.tsx index fb7e2309b..6aeeddd53 100644 --- a/src/app/profile/[username]/components/UserProfile.tsx +++ b/src/app/profile/[username]/components/UserProfile.tsx @@ -7,10 +7,13 @@ import { Github } from "lucide-react"; import { formatCompactNumber } from "@/lib/format-number"; import { DailyActivity } from "@/components/daily-activity"; import { UserActivityHeatmap } from "@/lib/scoring/queries"; +// PullRequestData import will be removed as it's no longer directly used here import { SummaryCard, Summary } from "@/components/summary-card"; import EthereumIcon from "@/components/icons/EthereumIcon"; import SolanaIcon from "@/components/icons/SolanaIcon"; import { WalletAddressBadge } from "@/components/ui/WalletAddressBadge"; +import { PullRequestList } from "./PullRequestList"; // Import the new component +import { PullRequestData } from "@/lib/data/types"; // Keep for initial PRs if passed from page export interface UserStats { totalPrs: number; @@ -34,6 +37,10 @@ type UserProfileProps = { dailyActivity: UserActivityHeatmap[]; ethAddress?: string; solAddress?: string; + // pullRequests prop is removed, initial PRs and total will be passed to PullRequestList directly + initialPullRequests?: PullRequestData[]; // For initial load from page.tsx + totalInitialPullRequests?: number; // For initial load from page.tsx + prPageSize?: number; // To pass to PullRequestList }; export default function UserProfile({ @@ -49,6 +56,10 @@ export default function UserProfile({ dailyActivity, ethAddress, solAddress, + // pullRequests, // Removed from here + initialPullRequests, + totalInitialPullRequests, + prPageSize, }: UserProfileProps) { return (
@@ -237,6 +248,14 @@ export default function UserProfile({
+ + {/* Add the PullRequestList section */} + ); } diff --git a/src/app/profile/[username]/page.tsx b/src/app/profile/[username]/page.tsx index 0e573ae2d..273c832fb 100644 --- a/src/app/profile/[username]/page.tsx +++ b/src/app/profile/[username]/page.tsx @@ -1,9 +1,11 @@ import UserProfile from "@/app/profile/[username]/components/UserProfile"; import { Metadata } from "next"; import { notFound } from "next/navigation"; -import { getUserProfile } from "./queries"; +import { getUserProfile, getUserPullRequests } from "./queries"; // Import getUserPullRequests import { db } from "@/lib/data/db"; +const DEFAULT_PR_PAGE_SIZE = 10; // Define page size constant + type ProfilePageProps = { params: Promise<{ username: string }>; }; @@ -47,9 +49,23 @@ export default async function ProfilePage({ params }: ProfilePageProps) { notFound(); } + // Fetch initial pull requests for the "ALL" filter, page 1 + // We need total count for "ALL" for the tab display, and the first page of "ALL" + const initialPrData = await getUserPullRequests( + username, + undefined, // All statuses + 1, + DEFAULT_PR_PAGE_SIZE, + ); + return (
- +
); } diff --git a/src/app/profile/[username]/queries.test.ts b/src/app/profile/[username]/queries.test.ts new file mode 100644 index 000000000..9c3c90179 --- /dev/null +++ b/src/app/profile/[username]/queries.test.ts @@ -0,0 +1,175 @@ +import { getUserPullRequests } from "./queries"; +import { db } from "@/lib/data/db"; +import { PullRequestData } from "@/lib/data/types"; + +// Mock the db module +jest.mock("@/lib/data/db", () => ({ + db: { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + get: jest.fn(), // For totalCount query + // Mock for the main data query, potentially different from .get() if chain ends differently + // For simplicity, let's assume the main query also ends with .get() or similar + // If it's a different method like .all() or .execute(), mock that instead. + // Based on queries.ts, it seems the main query is executed by awaiting the dbQuery object + // which implies it might be thenable or has a specific execution method. + // For now, we'll assume `await dbQuery` resolves to the results. + // This might need adjustment based on actual drizzle-orm behavior in tests. + }, +})); + +const mockDbExecution = (data: any[], totalCount: number) => { + const getMock = jest.fn(); + // Mock for the count query + (db.select as jest.Mock).mockImplementationOnce(() => ({ + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + get: getMock.mockResolvedValueOnce({ count: totalCount }), + })); + + // Mock for the data query + (db.select as jest.Mock).mockImplementationOnce(() => ({ + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + // Simulating the await dbQuery behavior + then: (resolve: any) => resolve(data), + })); +}; + + +describe("getUserPullRequests", () => { + const username = "testuser"; + const defaultPageSize = 10; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const createMockPr = (id: number, title: string, state: string, merged: boolean, author: string = username, number: number = id, createdAt: string = new Date().toISOString(), html_url: string = `http://github.com/pull/${id}`): any => ({ + id, + title, + state, + merged, + author, + number, + created_at: createdAt, + html_url, + }); + + it("should fetch pull requests without status filter", async () => { + const mockPrs = [ + createMockPr(1, "PR 1", "OPEN", false), + createMockPr(2, "PR 2", "MERGED", true), + ]; + mockDbExecution(mockPrs, mockPrs.length); + + const result = await getUserPullRequests(username, undefined, 1, defaultPageSize); + + expect(db.select).toHaveBeenCalledTimes(2); // Once for count, once for data + expect(result.pullRequests.length).toBe(2); + expect(result.totalCount).toBe(2); + expect(result.pullRequests[0].title).toBe("PR 1"); + expect(result.pullRequests[0].status).toBe("OPEN"); + expect(result.pullRequests[1].status).toBe("MERGED"); + }); + + it("should filter by 'OPEN' status", async () => { + const mockPrs = [createMockPr(1, "Open PR", "OPEN", false)]; + mockDbExecution(mockPrs, mockPrs.length); + + const result = await getUserPullRequests(username, "OPEN", 1, defaultPageSize); + expect(result.pullRequests.length).toBe(1); + expect(result.totalCount).toBe(1); + expect(result.pullRequests[0].status).toBe("OPEN"); + // TODO: Add expect(db.where) to have been called with the correct status filter + }); + + it("should filter by 'MERGED' status", async () => { + const mockPrs = [createMockPr(1, "Merged PR", "MERGED", true)]; // State might be CLOSED or OPEN in raw data but merged is true + mockDbExecution([{...mockPrs[0], state: "CLOSED", merged: true}], mockPrs.length); + + + const result = await getUserPullRequests(username, "MERGED", 1, defaultPageSize); + expect(result.pullRequests.length).toBe(1); + expect(result.totalCount).toBe(1); + expect(result.pullRequests[0].status).toBe("MERGED"); + }); + + it("should filter by 'CLOSED' status (and not merged)", async () => { + const mockPrs = [createMockPr(1, "Closed PR", "CLOSED", false)]; + mockDbExecution(mockPrs, mockPrs.length); + + const result = await getUserPullRequests(username, "CLOSED", 1, defaultPageSize); + expect(result.pullRequests.length).toBe(1); + expect(result.totalCount).toBe(1); + expect(result.pullRequests[0].status).toBe("CLOSED"); + }); + + it("should handle pagination correctly", async () => { + const pageSize = 1; + const mockPrsPage1 = [createMockPr(1, "PR 1", "OPEN", false)]; + const mockPrsPage2 = [createMockPr(2, "PR 2", "OPEN", false)]; + + // Mock for page 1 + mockDbExecution(mockPrsPage1, 2); + let result = await getUserPullRequests(username, undefined, 1, pageSize); + expect(result.pullRequests.length).toBe(1); + expect(result.pullRequests[0].id).toBe(1); + expect(result.totalCount).toBe(2); + expect((db.offset as jest.Mock).mock.calls[0][0]).toBe(0); // (1-1)*pageSize + + // Mock for page 2 + mockDbExecution(mockPrsPage2, 2); + result = await getUserPullRequests(username, undefined, 2, pageSize); + expect(result.pullRequests.length).toBe(1); + expect(result.pullRequests[0].id).toBe(2); + expect(result.totalCount).toBe(2); + expect((db.offset as jest.Mock).mock.calls[0][0]).toBe(pageSize); // (2-1)*pageSize + }); + + it("should return empty array and zero count for user with no pull requests", async () => { + mockDbExecution([], 0); + + const result = await getUserPullRequests(username, undefined, 1, defaultPageSize); + expect(result.pullRequests.length).toBe(0); + expect(result.totalCount).toBe(0); + }); + + it("should correctly map raw data to PullRequestData", async () => { + const rawPr = createMockPr(101, "Complex PR", "OPEN", false, "another-user", 101, "2023-01-01T10:00:00Z", "http://example.com/pr/101"); + mockDbExecution([rawPr], 1); + + const result = await getUserPullRequests("another-user", "OPEN", 1, defaultPageSize); + expect(result.pullRequests.length).toBe(1); + expect(result.totalCount).toBe(1); + const prData = result.pullRequests[0]; + + expect(prData.id).toBe(rawPr.id); + expect(prData.title).toBe(rawPr.title); + expect(prData.status).toBe("OPEN"); // Derived + expect(prData.url).toBe(rawPr.html_url); + expect(prData.createdAt).toBe(rawPr.created_at); + expect(prData.author).toBe(rawPr.author); + expect(prData.number).toBe(rawPr.number); + }); + + it("should derive MERGED status correctly even if raw state is OPEN/CLOSED", async () => { + const rawPrMergedOpen = createMockPr(201, "Merged but state OPEN", "OPEN", true); + const rawPrMergedClosed = createMockPr(202, "Merged and state CLOSED", "CLOSED", true); + + mockDbExecution([rawPrMergedOpen], 1); + let result = await getUserPullRequests(username, "MERGED", 1, defaultPageSize); + expect(result.pullRequests[0].status).toBe("MERGED"); + + mockDbExecution([rawPrMergedClosed], 1); + result = await getUserPullRequests(username, "MERGED", 1, defaultPageSize); + expect(result.pullRequests[0].status).toBe("MERGED"); + }); +}); diff --git a/src/app/profile/[username]/queries.ts b/src/app/profile/[username]/queries.ts index 0ba676521..f5288783c 100644 --- a/src/app/profile/[username]/queries.ts +++ b/src/app/profile/[username]/queries.ts @@ -1,5 +1,6 @@ import { and, count, desc, eq, sql } from "drizzle-orm"; import { db } from "@/lib/data/db"; +import { PullRequestData } from "@/lib/data/types"; import { rawPullRequests, users, @@ -201,3 +202,81 @@ export async function getUserProfile( dailyActivity, }; } + +export async function getUserPullRequests( + username: string, + status?: 'OPEN' | 'MERGED' | 'CLOSED', + page: number = 1, + pageSize: number = 10, +): Promise<{ pullRequests: PullRequestData[]; totalCount: number }> { + const whereClauses = [eq(rawPullRequests.author, username)]; + + if (status) { + if (status === 'OPEN') { + whereClauses.push(eq(rawPullRequests.state, 'OPEN')); + } else if (status === 'MERGED') { + whereClauses.push(eq(rawPullRequests.merged, true)); + } else if (status === 'CLOSED') { + whereClauses.push( + and( + eq(rawPullRequests.state, 'CLOSED'), + eq(rawPullRequests.merged, false), + ), + ); + } + } + + const dbQuery = db + .select({ + id: rawPullRequests.id, + title: rawPullRequests.title, + url: rawPullRequests.html_url, // Assuming html_url is the correct field for the PR's web URL + createdAt: rawPullRequests.created_at, + author: rawPullRequests.author, + number: rawPullRequests.number, + state: rawPullRequests.state, // Needed to determine PullRequestData.status + merged: rawPullRequests.merged, // Needed to determine PullRequestData.status + }) + .from(rawPullRequests) + .where(and(...whereClauses)); + + // Get total count before pagination + const totalCountResult = await db + .select({ count: count() }) + .from(rawPullRequests) + .where(and(...whereClauses)) + .get(); + + const totalCount = totalCountResult?.count || 0; + + // Apply pagination and ordering + const results = await dbQuery + .orderBy(desc(rawPullRequests.created_at)) + .limit(pageSize) + .offset((page - 1) * pageSize); + + const pullRequests: PullRequestData[] = results.map((pr) => { + let derivedStatus: 'OPEN' | 'MERGED' | 'CLOSED' = 'OPEN'; + if (pr.merged) { + derivedStatus = 'MERGED'; + } else if (pr.state === 'CLOSED') { + derivedStatus = 'CLOSED'; + } else if (pr.state === 'OPEN') { + derivedStatus = 'OPEN'; + } + return { + id: pr.id, // Assuming rawPullRequests.id is a number. If it's a string, it needs conversion or type adjustment. + title: pr.title, + status: derivedStatus, + url: pr.url, + createdAt: pr.createdAt, + author: pr.author, + number: pr.number, + }; + }); + + return { + pullRequests, + totalCount, + }; +} diff --git a/src/lib/data/types.ts b/src/lib/data/types.ts index 4dc6df93e..d4374729c 100644 --- a/src/lib/data/types.ts +++ b/src/lib/data/types.ts @@ -172,3 +172,13 @@ export interface DateRange { startDate?: string; endDate?: string; } + +export type PullRequestData = { + id: number; + title: string; + status: 'OPEN' | 'MERGED' | 'CLOSED'; + url: string; + createdAt: string | Date; + author: string; + number: number; +};