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 9aeb6c53..9c78dd40 100644 --- a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts @@ -19,7 +19,7 @@ import { DEFAULT_FEATURE_THINKING, } from "../../../shared/constants"; import type { AuthFailureInfo } from "../../../shared/types/terminal"; -import { getGitHubConfig, githubFetch } from "./utils"; +import { getGitHubConfig, githubFetch, normalizeRepoReference } from "./utils"; import { readSettingsFile } from "../../settings-utils"; import { getAugmentedEnv } from "../../env-utils"; import { getMemoryService, getDefaultDbPath } from "../../memory-service"; @@ -36,6 +36,105 @@ import { buildRunnerArgs, } from "./utils/subprocess-runner"; +/** + * GraphQL response type for PR list query + * Note: repository can be null if the repo doesn't exist or user lacks access + */ +interface GraphQLPRListResponse { + data: { + repository: { + pullRequests: { + pageInfo: { + 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; + }>; + }; + } | null; + }; + errors?: Array<{ message: string }>; +} + +/** + * Make a GraphQL request to GitHub API + */ +async function githubGraphQL( + token: string, + query: string, + variables: Record = {} +): Promise { + const response = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "Auto-Claude-UI", + }, + body: JSON.stringify({ query, variables }), + }); + + if (!response.ok) { + // Log detailed error for debugging, throw generic message for safety + console.error(`GitHub GraphQL HTTP error: ${response.status} ${response.statusText}`); + throw new Error("Failed to connect to GitHub API"); + } + + const result = await response.json() as T & { errors?: Array<{ message: string }> }; + + // Check for GraphQL-level errors + if (result.errors && result.errors.length > 0) { + // Log detailed errors for debugging, throw generic message for safety + console.error(`GitHub GraphQL errors: ${result.errors.map(e => e.message).join(", ")}`); + throw new Error("GitHub API request failed"); + } + + return result; +} + +/** + * GraphQL query to fetch PRs with diff stats + */ +const LIST_PRS_QUERY = ` +query($owner: String!, $repo: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $repo) { + pullRequests(states: OPEN, first: $first, after: $after, orderBy: {field: UPDATED_AT, direction: DESC}) { + pageInfo { hasNextPage endCursor } + nodes { + number + title + body + state + author { login } + headRefName + baseRefName + additions + deletions + changedFiles + assignees(first: 10) { nodes { login } } + createdAt + updatedAt + url + } + } + } +} +`; + /** * Sanitize network data before writing to file * Removes potentially dangerous characters and limits length @@ -382,6 +481,14 @@ export interface PRData { htmlUrl: string; } +/** + * PR list result with pagination info + */ +export interface PRListResult { + prs: PRData[]; + hasNextPage: boolean; // True if more PRs exist beyond the 100 limit +} + /** * PR review progress status */ @@ -1231,66 +1338,78 @@ async function runPRReview( export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): void { debugLog("Registering PR handlers"); - // List open PRs with pagination support + // List open PRs - fetches up to 100 open PRs at once, returns hasNextPage from API ipcMain.handle( IPC_CHANNELS.GITHUB_PR_LIST, - async (_, projectId: string, page: number = 1): Promise => { - debugLog("listPRs handler called", { projectId, page }); + async (_, projectId: string): Promise => { + debugLog("listPRs handler called", { projectId }); const result = await withProjectOrNull(projectId, async (project) => { const config = getGitHubConfig(project); if (!config) { debugLog("No GitHub config found for project"); - return []; + return { prs: [], hasNextPage: false }; } try { - // Use pagination: per_page=100 (GitHub max), page=1,2,3... - const prs = (await githubFetch( - config.token, - `/repos/${config.repo}/pulls?state=open&per_page=100&page=${page}` - )) as Array<{ - number: number; - title: string; - body?: string; - state: string; - user: { login: string }; - head: { ref: string }; - base: { ref: string }; - additions: number; - deletions: number; - changed_files: number; - assignees?: Array<{ login: string }>; - created_at: string; - updated_at: string; - html_url: string; - }>; + // 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; - debugLog("Fetched PRs", { count: prs.length, page, samplePr: prs[0] }); - return prs.map((pr) => ({ - number: pr.number, - title: pr.title, - body: pr.body ?? "", - state: pr.state, - author: { login: pr.user.login }, - headRefName: pr.head.ref, - baseRefName: pr.base.ref, - additions: pr.additions ?? 0, - deletions: pr.deletions ?? 0, - changedFiles: pr.changed_files ?? 0, - assignees: pr.assignees?.map((a: { login: string }) => ({ login: a.login })) ?? [], - files: [], - createdAt: pr.created_at, - updatedAt: pr.updated_at, - htmlUrl: pr.html_url, - })); + // 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 []; + return { prs: [], hasNextPage: false }; } }); - return result ?? []; + return result ?? { prs: [], hasNextPage: false }; } ); diff --git a/apps/frontend/src/preload/api/modules/github-api.ts b/apps/frontend/src/preload/api/modules/github-api.ts index a814fdae..1260b516 100644 --- a/apps/frontend/src/preload/api/modules/github-api.ts +++ b/apps/frontend/src/preload/api/modules/github-api.ts @@ -267,8 +267,8 @@ export interface GitHubAPI { callback: (projectId: string, error: { error: string }) => void ) => IpcListenerCleanup; - // PR operations - listPRs: (projectId: string, page?: number) => Promise; + // PR operations (fetches up to 100 open PRs at once - GitHub GraphQL limit) + listPRs: (projectId: string) => Promise; getPR: (projectId: string, prNumber: number) => Promise; runPRReview: (projectId: string, prNumber: number) => void; cancelPRReview: (projectId: string, prNumber: number) => Promise; @@ -332,6 +332,14 @@ export interface PRData { htmlUrl: string; } +/** + * PR list result with pagination info + */ +export interface PRListResult { + prs: PRData[]; + hasNextPage: boolean; // True if more PRs exist beyond the 100 limit +} + /** * PR review finding */ @@ -663,8 +671,9 @@ export const createGitHubAPI = (): GitHubAPI => ({ createIpcListener(IPC_CHANNELS.GITHUB_AUTOFIX_ANALYZE_PREVIEW_ERROR, callback), // PR operations - listPRs: (projectId: string, page: number = 1): Promise => - invokeIpc(IPC_CHANNELS.GITHUB_PR_LIST, projectId, page), + // Fetches up to 100 open PRs at once (GitHub GraphQL limit) + listPRs: (projectId: string): Promise => + invokeIpc(IPC_CHANNELS.GITHUB_PR_LIST, projectId), 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 1b6f406d..9baf7d69 100644 --- a/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx +++ b/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx @@ -58,7 +58,6 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps) const { prs, isLoading, - isLoadingMore, isLoadingPRDetails, error, selectedPRNumber, @@ -79,7 +78,6 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps) assignPR, markReviewPosted, refresh, - loadMore, isConnected, repoFullName, getReviewStateForPR, @@ -234,12 +232,10 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps) prs={filteredPRs} selectedPRNumber={selectedPRNumber} isLoading={isLoading} - isLoadingMore={isLoadingMore} hasMore={hasMore} error={error} getReviewStateForPR={getReviewStateForPR} onSelectPR={selectPR} - onLoadMore={loadMore} /> } 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 9d5d4f65..b0170b29 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx @@ -1,5 +1,4 @@ -import { useRef, useEffect, useCallback, useState } from 'react'; -import { GitPullRequest, User, Clock, FileDiff, Loader2 } from 'lucide-react'; +import { GitPullRequest, User, Clock, FileDiff } from 'lucide-react'; import { ScrollArea } from '../../ui/scroll-area'; import { Badge } from '../../ui/badge'; import { cn } from '../../../lib/utils'; @@ -167,12 +166,10 @@ interface PRListProps { prs: PRData[]; selectedPRNumber: number | null; isLoading: boolean; - isLoadingMore: boolean; - hasMore: boolean; + hasMore: boolean; // True when 100 PRs returned (GitHub limit) - more may exist error: string | null; getReviewStateForPR: (prNumber: number) => PRReviewInfo | null; onSelectPR: (prNumber: number) => void; - onLoadMore: () => void; } function formatDate(dateString: string): string { @@ -199,41 +196,12 @@ export function PRList({ prs, selectedPRNumber, isLoading, - isLoadingMore, hasMore, error, getReviewStateForPR, onSelectPR, - onLoadMore }: PRListProps) { const { t } = useTranslation('common'); - const loadMoreTriggerRef = useRef(null); - const [viewportElement, setViewportElement] = useState(null); - - // Intersection Observer for infinite scroll - const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => { - const [entry] = entries; - if (entry.isIntersecting && hasMore && !isLoadingMore && !isLoading) { - onLoadMore(); - } - }, [hasMore, isLoadingMore, isLoading, onLoadMore]); - - useEffect(() => { - const trigger = loadMoreTriggerRef.current; - if (!trigger || !viewportElement) return; - - const observer = new IntersectionObserver(handleIntersection, { - root: viewportElement, - rootMargin: '100px', - threshold: 0 - }); - - observer.observe(trigger); - - return () => { - observer.disconnect(); - }; - }, [handleIntersection, onLoadMore, viewportElement]); if (isLoading && prs.length === 0) { return ( @@ -268,7 +236,7 @@ export function PRList({ } return ( - +
{prs.map((pr) => { const reviewState = getReviewStateForPR(pr.number); @@ -338,23 +306,14 @@ export function PRList({ ); })} - {/* Load more trigger / Loading indicator */} -
- {isLoadingMore ? ( -
- - {t('prReview.loadingMore')} -
- ) : hasMore ? ( + {/* Status indicator */} + {prs.length > 0 && ( +
- {t('prReview.scrollForMore')} + {hasMore ? t('prReview.maxPRsShown') : t('prReview.allPRsLoaded')} - ) : prs.length > 0 ? ( - - {t('prReview.allPRsLoaded')} - - ) : null} -
+
+ )}
); 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 c2e6ec1a..204b79c5 100644 --- a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts +++ b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts @@ -23,7 +23,6 @@ interface UseGitHubPRsOptions { interface UseGitHubPRsResult { prs: PRData[]; isLoading: boolean; - isLoadingMore: boolean; isLoadingPRDetails: boolean; // Loading full PR details including files error: string | null; selectedPR: PRData | null; @@ -36,10 +35,9 @@ interface UseGitHubPRsResult { isConnected: boolean; repoFullName: string | null; activePRReviews: number[]; // PR numbers currently being reviewed - hasMore: boolean; // Whether there are more PRs to load + hasMore: boolean; // True when 100 PRs returned (GitHub limit) - more may exist selectPR: (prNumber: number | null) => void; refresh: () => Promise; - loadMore: () => Promise; runReview: (prNumber: number) => void; runFollowupReview: (prNumber: number) => void; checkNewCommits: (prNumber: number) => Promise; @@ -71,15 +69,13 @@ export function useGitHubPRs( const { isActive = true } = options; const [prs, setPrs] = useState([]); const [isLoading, setIsLoading] = useState(false); - const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingPRDetails, setIsLoadingPRDetails] = useState(false); const [error, setError] = useState(null); const [selectedPRNumber, setSelectedPRNumber] = useState(null); const [selectedPRDetails, setSelectedPRDetails] = useState(null); const [isConnected, setIsConnected] = useState(false); const [repoFullName, setRepoFullName] = useState(null); - const [currentPage, setCurrentPage] = useState(1); - const [hasMore, setHasMore] = useState(true); + const [hasMore, setHasMore] = useState(false); // Track previous isActive state to detect tab navigation const wasActiveRef = useRef(isActive); @@ -144,14 +140,10 @@ export function useGitHubPRs( // Check connection and fetch PRs const fetchPRs = useCallback( - async (page: number = 1, append: boolean = false) => { + async () => { if (!projectId) return; - if (append) { - setIsLoadingMore(true); - } else { - setIsLoading(true); - } + setIsLoading(true); setError(null); try { @@ -162,27 +154,16 @@ export function useGitHubPRs( setRepoFullName(connectionResult.data.repoFullName || null); if (connectionResult.data.connected) { - // Fetch PRs with pagination - const result = await window.electronAPI.github.listPRs(projectId, page); + // Fetch PRs (returns up to 100 open PRs at once - GitHub GraphQL limit) + const result = await window.electronAPI.github.listPRs(projectId); if (result) { - // Check if there are more PRs to load (GitHub returns up to 100 per page) - setHasMore(result.length === 100); - setCurrentPage(page); - - if (append) { - // Append to existing PRs, deduplicating by PR number - setPrs((prevPrs) => { - const existingNumbers = new Set(prevPrs.map((pr) => pr.number)); - const newPrs = result.filter((pr) => !existingNumbers.has(pr.number)); - return [...prevPrs, ...newPrs]; - }); - } else { - setPrs(result); - } + // Use hasNextPage from API to determine if more PRs exist + setHasMore(result.hasNextPage); + setPrs(result.prs); // Batch preload review results for PRs not in store (single IPC call) // Skip PRs that are currently being reviewed - their state is managed by IPC listeners - const prsNeedingPreload = result.filter((pr) => { + const prsNeedingPreload = result.prs.filter((pr) => { const existingState = getPRReviewState(projectId, pr.number); return !existingState?.result && !existingState?.isReviewing; }); @@ -218,7 +199,6 @@ export function useGitHubPRs( setIsConnected(false); } finally { setIsLoading(false); - setIsLoadingMore(false); } }, [projectId, getPRReviewState] @@ -228,7 +208,7 @@ export function useGitHubPRs( useEffect(() => { if (projectId && !hasLoadedRef.current) { hasLoadedRef.current = true; - fetchPRs(1, false); + fetchPRs(); } }, [projectId, fetchPRs]); @@ -236,19 +216,15 @@ export function useGitHubPRs( useEffect(() => { // Only refresh if transitioning from inactive to active AND we've loaded before if (isActive && !wasActiveRef.current && hasLoadedRef.current) { - // Reset to first page and refresh - setCurrentPage(1); - setHasMore(true); - fetchPRs(1, false); + fetchPRs(); } wasActiveRef.current = isActive; }, [isActive, fetchPRs]); - // Reset pagination and selected PR when project changes + // Reset state and selected PR when project changes useEffect(() => { hasLoadedRef.current = false; - setCurrentPage(1); - setHasMore(true); + setHasMore(false); setPrs([]); setSelectedPRNumber(null); setSelectedPRDetails(null); @@ -398,16 +374,9 @@ export function useGitHubPRs( ); const refresh = useCallback(async () => { - setCurrentPage(1); - setHasMore(true); - await fetchPRs(1, false); + await fetchPRs(); }, [fetchPRs]); - const loadMore = useCallback(async () => { - if (!hasMore || isLoadingMore || isLoading) return; - await fetchPRs(currentPage + 1, true); - }, [fetchPRs, hasMore, isLoadingMore, isLoading, currentPage]); - const runReview = useCallback( (prNumber: number) => { if (!projectId) return; @@ -596,7 +565,6 @@ export function useGitHubPRs( return { prs, isLoading, - isLoadingMore, isLoadingPRDetails, error, selectedPR, @@ -612,7 +580,6 @@ 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 83824e8f..2aaa0c90 100644 --- a/apps/frontend/src/renderer/lib/browser-mock.ts +++ b/apps/frontend/src/renderer/lib/browser-mock.ts @@ -202,7 +202,7 @@ const browserMockAPI: ElectronAPI = { onAutoFixProgress: () => () => {}, onAutoFixComplete: () => () => {}, onAutoFixError: () => () => {}, - listPRs: async () => [], + listPRs: 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 7c8a640a..ae7b5549 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -339,9 +339,8 @@ "updatingBranch": "Updating...", "branchUpdated": "Branch updated", "branchUpdateFailed": "Failed to update branch", - "loadingMore": "Loading more PRs...", - "scrollForMore": "Scroll for more", "allPRsLoaded": "All PRs loaded", + "maxPRsShown": "Showing first 100 PRs", "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 e090a832..e4c4f693 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -348,9 +348,8 @@ "updatingBranch": "Mise à jour...", "branchUpdated": "Branche mise à jour", "branchUpdateFailed": "Échec de la mise à jour de la branche", - "loadingMore": "Chargement des PRs...", - "scrollForMore": "Défiler pour plus", "allPRsLoaded": "Tous les PRs chargés", + "maxPRsShown": "Affichage des 100 premières PRs", "workflowsAwaitingApproval": "{{count}} workflow en attente d'approbation", "workflowsAwaitingApproval_plural": "{{count}} workflows en attente d'approbation", "blockedByWorkflows": "Bloqué",