diff --git a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts index 82e0c06e..c3e7edfb 100644 --- a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts @@ -225,6 +225,23 @@ query($owner: String!, $repo: String!, $first: Int!, $after: String) { * GraphQL response type for PR list query * Note: repository can be null if the repo doesn't exist or user lacks access */ +interface GraphQLPRNode { + number: number; + title: string; + body: string | null; + state: string; + author: { login: string } | null; + headRefName: string; + baseRefName: string; + additions: number; + deletions: number; + changedFiles: number; + assignees: { nodes: Array<{ login: string }> }; + createdAt: string; + updatedAt: string; + url: string; +} + interface GraphQLPRListResponse { data: { repository: { @@ -233,28 +250,37 @@ interface GraphQLPRListResponse { hasNextPage: boolean; endCursor: string | null; }; - nodes: Array<{ - number: number; - title: string; - body: string | null; - state: string; - author: { login: string } | null; - headRefName: string; - baseRefName: string; - additions: number; - deletions: number; - changedFiles: number; - assignees: { nodes: Array<{ login: string }> }; - createdAt: string; - updatedAt: string; - url: string; - }>; + nodes: GraphQLPRNode[]; }; } | null; }; errors?: Array<{ message: string }>; } +/** + * Maps a GraphQL PR node to the frontend PRData format. + * Shared between listPRs and listMorePRs handlers. + */ +function mapGraphQLPRToData(pr: GraphQLPRNode): PRData { + return { + number: pr.number, + title: pr.title, + body: pr.body ?? "", + state: pr.state.toLowerCase(), + author: { login: pr.author?.login ?? "unknown" }, + headRefName: pr.headRefName, + baseRefName: pr.baseRefName, + additions: pr.additions, + deletions: pr.deletions, + changedFiles: pr.changedFiles, + assignees: pr.assignees.nodes.map((a) => ({ login: a.login })), + files: [], + createdAt: pr.createdAt, + updatedAt: pr.updatedAt, + htmlUrl: pr.url, + }; +} + /** * Make a GraphQL request to GitHub API */ @@ -724,6 +750,7 @@ export interface PRData { export interface PRListResult { prs: PRData[]; hasNextPage: boolean; // True if more PRs exist beyond the 100 limit + endCursor?: string | null; // Cursor for fetching next page (null if no more pages) } /** @@ -1821,7 +1848,7 @@ async function fetchPRsFromGraphQL( export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): void { debugLog("Registering PR handlers"); - // List open PRs - fetches up to 100 open PRs at once, returns hasNextPage from API + // List open PRs - fetches up to 100 open PRs at once, returns hasNextPage and endCursor from API ipcMain.handle( IPC_CHANNELS.GITHUB_PR_LIST, async (_, projectId: string): Promise => { @@ -1830,70 +1857,28 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v const config = getGitHubConfig(project); if (!config) { debugLog("No GitHub config found for project"); - return { prs: [], hasNextPage: false }; - } - - try { - // Parse owner/repo from config - must be exactly "owner/repo" format - const normalizedRepo = normalizeRepoReference(config.repo); - const repoParts = normalizedRepo.split("/"); - if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) { - debugLog("Invalid repo format - expected 'owner/repo'", { repo: config.repo, normalized: normalizedRepo }); - return { prs: [], hasNextPage: false }; - } - const [owner, repo] = repoParts; - - // Use GraphQL API to get PRs with diff stats (REST list endpoint doesn't include them) - // Fetches up to 100 open PRs (GitHub GraphQL max per request) - const response = await githubGraphQL( - config.token, - LIST_PRS_QUERY, - { - owner, - repo, - first: 100, // GitHub GraphQL max is 100 - after: null, // Start from beginning - } - ); - - // Handle case where repository doesn't exist or user lacks access - if (!response.data.repository) { - debugLog("Repository not found or access denied", { owner, repo }); - return { prs: [], hasNextPage: false }; - } - - const { nodes: prNodes, pageInfo } = response.data.repository.pullRequests; - - debugLog("Fetched PRs via GraphQL", { count: prNodes.length, hasNextPage: pageInfo.hasNextPage }); - return { - prs: prNodes.map((pr) => ({ - number: pr.number, - title: pr.title, - body: pr.body ?? "", - state: pr.state.toLowerCase(), - author: { login: pr.author?.login ?? "unknown" }, - headRefName: pr.headRefName, - baseRefName: pr.baseRefName, - additions: pr.additions, - deletions: pr.deletions, - changedFiles: pr.changedFiles, - assignees: pr.assignees.nodes.map((a) => ({ login: a.login })), - files: [], - createdAt: pr.createdAt, - updatedAt: pr.updatedAt, - htmlUrl: pr.url, - })), - hasNextPage: pageInfo.hasNextPage, - }; - } catch (error) { - debugLog("Failed to fetch PRs", { - error: error instanceof Error ? error.message : error, - }); - return { prs: [], hasNextPage: false }; + return { prs: [], hasNextPage: false, endCursor: null }; } return fetchPRsFromGraphQL(config, null, "initial"); }); - return result ?? { prs: [], hasNextPage: false }; + return result ?? { prs: [], hasNextPage: false, endCursor: null }; + } + ); + + // Load more PRs (pagination) - fetches next page of PRs using cursor + ipcMain.handle( + IPC_CHANNELS.GITHUB_PR_LIST_MORE, + async (_, projectId: string, cursor: string): Promise => { + debugLog("listMorePRs handler called", { projectId, cursor }); + const result = await withProjectOrNull(projectId, async (project) => { + const config = getGitHubConfig(project); + if (!config) { + debugLog("No GitHub config found for project"); + return { prs: [], hasNextPage: false, endCursor: null }; + } + return fetchPRsFromGraphQL(config, cursor, "pagination"); + }); + return result ?? { prs: [], hasNextPage: false, endCursor: null }; } ); diff --git a/apps/frontend/src/preload/api/modules/github-api.ts b/apps/frontend/src/preload/api/modules/github-api.ts index 30f41645..e3a81116 100644 --- a/apps/frontend/src/preload/api/modules/github-api.ts +++ b/apps/frontend/src/preload/api/modules/github-api.ts @@ -271,6 +271,8 @@ export interface GitHubAPI { // PR operations (fetches up to 100 open PRs at once - GitHub GraphQL limit) listPRs: (projectId: string) => Promise; + /** Load more PRs using cursor-based pagination */ + listMorePRs: (projectId: string, cursor: string) => Promise; getPR: (projectId: string, prNumber: number) => Promise; runPRReview: (projectId: string, prNumber: number) => void; cancelPRReview: (projectId: string, prNumber: number) => Promise; @@ -357,6 +359,7 @@ export interface PRData { export interface PRListResult { prs: PRData[]; hasNextPage: boolean; // True if more PRs exist beyond the 100 limit + endCursor?: string | null; // Cursor for fetching next page (null if no more pages) } /** @@ -694,6 +697,10 @@ export const createGitHubAPI = (): GitHubAPI => ({ listPRs: (projectId: string): Promise => invokeIpc(IPC_CHANNELS.GITHUB_PR_LIST, projectId), + // Load more PRs using cursor-based pagination + listMorePRs: (projectId: string, cursor: string): Promise => + invokeIpc(IPC_CHANNELS.GITHUB_PR_LIST_MORE, projectId, cursor), + getPR: (projectId: string, prNumber: number): Promise => invokeIpc(IPC_CHANNELS.GITHUB_PR_GET, projectId, prNumber), diff --git a/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx b/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx index dec5a8f8..9f0d186a 100644 --- a/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx +++ b/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx @@ -58,6 +58,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps) const { prs, isLoading, + isLoadingMore, isLoadingPRDetails, error, selectedPRNumber, @@ -78,6 +79,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps) assignPR, markReviewPosted, refresh, + loadMore, isConnected, repoFullName, getReviewStateForPR, @@ -252,6 +254,8 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps) error={error} getReviewStateForPR={getReviewStateForPR} onSelectPR={selectPR} + onLoadMore={loadMore} + isLoadingMore={isLoadingMore} /> } diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx index 093520dd..bb64682c 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx @@ -1,4 +1,4 @@ -import { GitPullRequest, User, Clock, FileDiff } from 'lucide-react'; +import { GitPullRequest, User, Clock, FileDiff, Loader2 } from 'lucide-react'; import { ScrollArea } from '../../ui/scroll-area'; import { Badge } from '../../ui/badge'; import { Button } from '../../ui/button'; @@ -179,6 +179,10 @@ interface PRListProps { error: string | null; getReviewStateForPR: (prNumber: number) => PRReviewInfo | null; onSelectPR: (prNumber: number) => void; + /** Callback to load more PRs when hasMore is true */ + onLoadMore?: () => void; + /** Whether additional PRs are currently being loaded */ + isLoadingMore?: boolean; } function formatDate(dateString: string): string { @@ -209,6 +213,8 @@ export function PRList({ error, getReviewStateForPR, onSelectPR, + onLoadMore, + isLoadingMore, }: PRListProps) { const { t } = useTranslation('common'); @@ -322,12 +328,30 @@ export function PRList({ ); })} - {/* Status indicator */} + {/* Status indicator / Load More button */} {prs.length > 0 && (
- - {hasMore ? t('prReview.maxPRsShown') : t('prReview.allPRsLoaded')} - + {hasMore && onLoadMore ? ( + + ) : ( + + {t('prReview.allPRsLoaded')} + + )}
)} diff --git a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts index 956454f4..156fa2c6 100644 --- a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts +++ b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts @@ -23,6 +23,7 @@ interface UseGitHubPRsOptions { interface UseGitHubPRsResult { prs: PRData[]; isLoading: boolean; + isLoadingMore: boolean; // Loading additional PRs via pagination isLoadingPRDetails: boolean; // Loading full PR details including files error: string | null; selectedPR: PRData | null; @@ -38,6 +39,7 @@ interface UseGitHubPRsResult { hasMore: boolean; // True when 100 PRs returned (GitHub limit) - more may exist selectPR: (prNumber: number | null) => void; refresh: () => Promise; + loadMore: () => Promise; // Load next page of PRs runReview: (prNumber: number) => void; runFollowupReview: (prNumber: number) => void; checkNewCommits: (prNumber: number) => Promise; @@ -76,6 +78,8 @@ export function useGitHubPRs( const [isConnected, setIsConnected] = useState(false); const [repoFullName, setRepoFullName] = useState(null); const [hasMore, setHasMore] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [endCursor, setEndCursor] = useState(null); // Track previous isActive state to detect tab navigation const wasActiveRef = useRef(isActive); @@ -158,6 +162,9 @@ export function useGitHubPRs( async () => { if (!projectId) return; + // Increment generation to invalidate any in-flight loadMore requests + fetchGenerationRef.current += 1; + setIsLoading(true); setError(null); @@ -174,6 +181,8 @@ export function useGitHubPRs( if (result) { // Use hasNextPage from API to determine if more PRs exist setHasMore(result.hasNextPage); + // Store endCursor for pagination + setEndCursor(result.endCursor ?? null); setPrs(result.prs); // Batch preload review results for PRs not in store (single IPC call) @@ -242,6 +251,7 @@ export function useGitHubPRs( fetchGenerationRef.current += 1; hasLoadedRef.current = false; setHasMore(false); + setEndCursor(null); setPrs([]); setSelectedPRNumber(null); setSelectedPRDetails(null); @@ -433,6 +443,91 @@ export function useGitHubPRs( await fetchPRs(); }, [fetchPRs]); + // Load more PRs using cursor-based pagination + const loadMore = useCallback(async () => { + if (!projectId || !endCursor || !hasMore || isLoadingMore) return; + + // Capture current state for staleness checks + const requestProjectId = projectId; + const requestGeneration = fetchGenerationRef.current; + + setIsLoadingMore(true); + setError(null); + + try { + const result = await window.electronAPI.github.listMorePRs(projectId, endCursor); + + // Discard response if project changed or a refresh happened while loading + if ( + requestProjectId !== currentProjectIdRef.current || + requestGeneration !== fetchGenerationRef.current + ) { + return; + } + + if (result) { + // Check if this is a failure response (empty result with no next page) + // In this case, preserve existing pagination state to allow retry + const isFailureResponse = result.prs.length === 0 && !result.hasNextPage && !result.endCursor; + + if (!isFailureResponse) { + // Update pagination state only on successful response + setHasMore(result.hasNextPage); + setEndCursor(result.endCursor ?? null); + + // Append new PRs to existing list, deduplicating by PR number + // (handles edge case where PR shifts position between pagination requests) + setPrs((prevPrs) => { + const existingNumbers = new Set(prevPrs.map((pr) => pr.number)); + const newPrs = result.prs.filter((pr) => !existingNumbers.has(pr.number)); + return [...prevPrs, ...newPrs]; + }); + } + + // Batch preload review results for new PRs not in store + const prsNeedingPreload = result.prs.filter((pr) => { + const existingState = getPRReviewState(requestProjectId, pr.number); + return !existingState?.result && !existingState?.isReviewing; + }); + + if (prsNeedingPreload.length > 0) { + const prNumbers = prsNeedingPreload.map((pr) => pr.number); + const batchReviews = await window.electronAPI.github.getPRReviewsBatch( + requestProjectId, + prNumbers + ); + + // Check staleness again after async batch fetch + if ( + requestProjectId !== currentProjectIdRef.current || + requestGeneration !== fetchGenerationRef.current + ) { + return; + } + + // Update store with loaded results + for (const reviewResult of Object.values(batchReviews)) { + if (reviewResult) { + usePRReviewStore.getState().setPRReviewResult(requestProjectId, reviewResult, { + preserveNewCommitsCheck: true, + }); + } + } + } + } + } catch (err) { + // Only show error if still relevant + if ( + requestProjectId === currentProjectIdRef.current && + requestGeneration === fetchGenerationRef.current + ) { + setError(err instanceof Error ? err.message : "Failed to load more PRs"); + } + } finally { + setIsLoadingMore(false); + } + }, [projectId, endCursor, hasMore, isLoadingMore, getPRReviewState]); + const runReview = useCallback( (prNumber: number) => { if (!projectId) return; @@ -623,6 +718,7 @@ export function useGitHubPRs( return { prs, isLoading, + isLoadingMore, isLoadingPRDetails, error, selectedPR, @@ -638,6 +734,7 @@ export function useGitHubPRs( hasMore, selectPR, refresh, + loadMore, runReview, runFollowupReview, checkNewCommits, diff --git a/apps/frontend/src/renderer/lib/browser-mock.ts b/apps/frontend/src/renderer/lib/browser-mock.ts index fe6fdbea..e74793bd 100644 --- a/apps/frontend/src/renderer/lib/browser-mock.ts +++ b/apps/frontend/src/renderer/lib/browser-mock.ts @@ -203,6 +203,7 @@ const browserMockAPI: ElectronAPI = { onAutoFixComplete: () => () => {}, onAutoFixError: () => () => {}, listPRs: async () => ({ prs: [], hasNextPage: false }), + listMorePRs: async () => ({ prs: [], hasNextPage: false }), getPR: async () => null, runPRReview: () => {}, cancelPRReview: async () => true, diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index e7c62a92..52d380b9 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -367,6 +367,8 @@ "branchUpdateFailed": "Failed to update branch", "allPRsLoaded": "All PRs loaded", "maxPRsShown": "Showing first 100 PRs", + "loadMore": "Load More", + "loadingMore": "Loading...", "workflowsAwaitingApproval": "{{count}} Workflow Awaiting Approval", "workflowsAwaitingApproval_plural": "{{count}} Workflows Awaiting Approval", "blockedByWorkflows": "Blocked", diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index db16d709..ca99cc40 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -376,6 +376,8 @@ "branchUpdateFailed": "Échec de la mise à jour de la branche", "allPRsLoaded": "Tous les PRs chargés", "maxPRsShown": "Affichage des 100 premières PRs", + "loadMore": "Charger plus", + "loadingMore": "Chargement...", "workflowsAwaitingApproval": "{{count}} workflow en attente d'approbation", "workflowsAwaitingApproval_plural": "{{count}} workflows en attente d'approbation", "blockedByWorkflows": "Bloqué",