From 69b15a9f7789268b6dc25a423c640ce0023b9e09 Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Wed, 11 Jun 2025 17:36:23 -0700 Subject: [PATCH] feat: add pagination support for GitHub repositories and branches (#122) * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * cleanup & fix build * fix: always get default branch for first page * cr * cr * cr * cr * cr * cr --- .../app/api/github/proxy/[..._path]/route.ts | 5 + .../src/app/api/github/repositories/route.ts | 39 ++- apps/web/src/app/page.tsx | 17 +- .../repo-branch-selectors/branch-selector.tsx | 22 +- .../repository-selector.tsx | 22 +- .../src/components/github/repository-list.tsx | 4 +- .../src/components/tasks/use-task-plan.tsx | 15 -- apps/web/src/components/thread/index.tsx | 10 +- apps/web/src/hooks/useGitHubApp.ts | 234 +++++++++++++----- apps/web/src/providers/GitHubApp.tsx | 27 ++ apps/web/src/utils/github.ts | 124 ++++++---- 11 files changed, 367 insertions(+), 152 deletions(-) create mode 100644 apps/web/src/providers/GitHubApp.tsx diff --git a/apps/web/src/app/api/github/proxy/[..._path]/route.ts b/apps/web/src/app/api/github/proxy/[..._path]/route.ts index 6dbf8e9a..077901f9 100644 --- a/apps/web/src/app/api/github/proxy/[..._path]/route.ts +++ b/apps/web/src/app/api/github/proxy/[..._path]/route.ts @@ -37,6 +37,11 @@ async function handler(req: NextRequest) { const targetUrl = new URL(`${GITHUB_API_URL}/${path}`); + // Forward query parameters from the original request + req.nextUrl.searchParams.forEach((value, key) => { + targetUrl.searchParams.append(key, value); + }); + const headers = new Headers(); headers.set("Authorization", `Bearer ${token}`); headers.set("Accept", "application/vnd.github.v3+json"); diff --git a/apps/web/src/app/api/github/repositories/route.ts b/apps/web/src/app/api/github/repositories/route.ts index d4a4f063..54c0816a 100644 --- a/apps/web/src/app/api/github/repositories/route.ts +++ b/apps/web/src/app/api/github/repositories/route.ts @@ -8,7 +8,7 @@ import { GITHUB_INSTALLATION_ID_COOKIE } from "@/lib/auth"; /** * Fetches repositories accessible to the GitHub App installation - * Requires a valid GitHub installation ID in the cookies + * Requires a valid GitHub installation ID in the cookies. Supports pagination via 'page' query parameter. */ export async function GET(request: NextRequest) { try { @@ -27,6 +27,19 @@ export async function GET(request: NextRequest) { ); } + // Get pagination parameters from query string + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get("page") || "1", 10); + const perPage = 30; // Fixed at 30 repositories per page + + // Validate page parameter + if (page < 1 || isNaN(page)) { + return NextResponse.json( + { error: "Invalid page parameter. Must be a positive integer." }, + { status: 400 }, + ); + } + // Get GitHub App credentials from environment variables const appId = process.env.GITHUB_APP_ID; const privateKey = process.env.GITHUB_APP_PRIVATE_KEY?.replace( @@ -58,9 +71,17 @@ export async function GET(request: NextRequest) { } // Fetch repositories accessible to this installation - let repositories: Repository[]; + let repositoryData: { + repositories: Repository[]; + hasMore: boolean; + totalCount: number; + }; try { - repositories = await getInstallationRepositories(installationToken); + repositoryData = await getInstallationRepositories( + installationToken, + page, + perPage, + ); } catch (error) { console.error("Failed to fetch repositories:", error); return NextResponse.json( @@ -70,7 +91,7 @@ export async function GET(request: NextRequest) { } // Transform the response to include only the data we need - const transformedRepos = repositories.map((repo) => ({ + const transformedRepos = repositoryData.repositories.map((repo) => ({ id: repo.id, name: repo.name, full_name: repo.full_name, @@ -81,7 +102,15 @@ export async function GET(request: NextRequest) { permissions: repo.permissions, })); - return NextResponse.json({ repositories: transformedRepos }); + return NextResponse.json({ + repositories: transformedRepos, + pagination: { + page, + perPage, + hasMore: repositoryData.hasMore, + totalCount: repositoryData.totalCount, + }, + }); } catch (error) { console.error("Error fetching GitHub repositories:", error); return NextResponse.json( diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 19675ded..c94f6705 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -6,18 +6,21 @@ import { ThreadProvider } from "@/providers/Thread"; import { ArtifactProvider } from "@/components/thread/artifact"; import { Toaster } from "@/components/ui/sonner"; import React from "react"; +import { GitHubAppProvider } from "@/providers/GitHubApp"; export default function DemoPage(): React.ReactNode { return ( Loading (layout)...}> - - - - - - - + + + + + + + + + ); } diff --git a/apps/web/src/components/github/repo-branch-selectors/branch-selector.tsx b/apps/web/src/components/github/repo-branch-selectors/branch-selector.tsx index 3c5c1fb0..8e3b1464 100644 --- a/apps/web/src/components/github/repo-branch-selectors/branch-selector.tsx +++ b/apps/web/src/components/github/repo-branch-selectors/branch-selector.tsx @@ -15,7 +15,7 @@ import { Button } from "@/components/ui/button"; import { Check, ChevronsUpDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { useState, useEffect } from "react"; -import { useGitHubApp } from "@/hooks/useGitHubApp"; +import { useGitHubAppProvider } from "@/providers/GitHubApp"; import { GitBranch, Shield } from "lucide-react"; interface BranchSelectorProps { @@ -39,8 +39,11 @@ export function BranchSelector({ selectedBranch, setSelectedBranch, selectedRepository, + branchesHasMore, + branchesLoadingMore, + loadMoreBranches, defaultBranch, - } = useGitHubApp(); + } = useGitHubAppProvider(); // Auto-select default branch when repository changes and branches are loaded useEffect(() => { @@ -232,6 +235,21 @@ export function BranchSelector({ ); })} + {branchesHasMore && ( + + { + loadMoreBranches(); + }} + disabled={branchesLoadingMore} + className="justify-center" + > + {branchesLoadingMore + ? "Loading more..." + : "Load more branches"} + + + )} diff --git a/apps/web/src/components/github/repo-branch-selectors/repository-selector.tsx b/apps/web/src/components/github/repo-branch-selectors/repository-selector.tsx index 0f4a5071..b3262287 100644 --- a/apps/web/src/components/github/repo-branch-selectors/repository-selector.tsx +++ b/apps/web/src/components/github/repo-branch-selectors/repository-selector.tsx @@ -16,7 +16,7 @@ import { Check, ChevronsUpDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { useState } from "react"; import type { TargetRepository } from "@open-swe/shared/open-swe/types"; -import { useGitHubApp } from "@/hooks/useGitHubApp"; +import { useGitHubAppProvider } from "@/providers/GitHubApp"; import type { Repository } from "@/utils/github"; import { GitHubSVG } from "@/components/icons/github"; @@ -50,7 +50,10 @@ export function RepositorySelector({ isLoading, error, isInstalled, - } = useGitHubApp(); + repositoriesHasMore, + repositoriesLoadingMore, + loadMoreRepositories, + } = useGitHubAppProvider(); const handleSelect = (repositoryKey: string) => { const repository = repositories.find( @@ -210,6 +213,21 @@ export function RepositorySelector({ ); })} + {repositoriesHasMore && ( + + { + loadMoreRepositories(); + }} + disabled={repositoriesLoadingMore} + className="justify-center" + > + {repositoriesLoadingMore + ? "Loading more..." + : "Load more repositories"} + + + )} diff --git a/apps/web/src/components/github/repository-list.tsx b/apps/web/src/components/github/repository-list.tsx index e80ac7b6..9bc0110f 100644 --- a/apps/web/src/components/github/repository-list.tsx +++ b/apps/web/src/components/github/repository-list.tsx @@ -1,6 +1,6 @@ "use client"; -import { useGitHubApp } from "@/hooks/useGitHubApp"; +import { useGitHubAppProvider } from "@/providers/GitHubApp"; import { InstallAppButton } from "./install-app-button"; import { Button } from "@/components/ui/button"; import { RefreshCw } from "lucide-react"; @@ -11,7 +11,7 @@ interface RepositoryListProps { export function RepositoryList({ className = "" }: RepositoryListProps) { const { isInstalled, isLoading, error, repositories, refreshRepositories } = - useGitHubApp(); + useGitHubAppProvider(); if (isLoading) { return ( diff --git a/apps/web/src/components/tasks/use-task-plan.tsx b/apps/web/src/components/tasks/use-task-plan.tsx index f2e6e624..e684d613 100644 --- a/apps/web/src/components/tasks/use-task-plan.tsx +++ b/apps/web/src/components/tasks/use-task-plan.tsx @@ -14,14 +14,6 @@ export function useTaskPlan() { } }, [values?.plan]); - const handleTaskChange = (taskId: string) => { - console.log(`Switched to task: ${taskId}`); - }; - - const handleRevisionChange = (taskId: string, revisionIndex: number) => { - console.log(`Task ${taskId} switched to revision ${revisionIndex}`); - }; - const handleEditPlanItem = ( taskId: string, planItemIndex: number, @@ -52,9 +44,6 @@ export function useTaskPlan() { }), }; }); - console.log( - `Edited plan item ${planItemIndex} in task ${taskId}: ${newPlan}`, - ); }; const handleAddPlanItem = (taskId: string, plan: string) => { @@ -89,7 +78,6 @@ export function useTaskPlan() { }), }; }); - console.log(`Added new plan item to task ${taskId}: ${plan}`); }; const handleDeletePlanItem = (taskId: string, planItemIndex: number) => { @@ -116,13 +104,10 @@ export function useTaskPlan() { }), }; }); - console.log(`Deleted plan item ${planItemIndex} from task ${taskId}`); }; return { taskPlan, - handleTaskChange, - handleRevisionChange, handleEditPlanItem, handleAddPlanItem, handleDeletePlanItem, diff --git a/apps/web/src/components/thread/index.tsx b/apps/web/src/components/thread/index.tsx index da523710..f9acf742 100644 --- a/apps/web/src/components/thread/index.tsx +++ b/apps/web/src/components/thread/index.tsx @@ -39,7 +39,7 @@ import { useArtifactContext, } from "./artifact"; import { GitHubOAuthButton } from "../github/github-oauth-button"; -import { useGitHubApp } from "@/hooks/useGitHubApp"; +import { useGitHubAppProvider } from "@/providers/GitHubApp"; import TaskList from "../task-list"; import { ConfigurationSidebar } from "../configuration-sidebar"; import { DEFAULT_CONFIG_KEY, useConfigStore } from "@/hooks/use-config-store"; @@ -100,12 +100,10 @@ export function Thread() { const { push } = useRouter(); const [artifactContext, setArtifactContext] = useArtifactContext(); const [artifactOpen, closeArtifact] = useArtifactOpen(); - const { selectedRepository } = useGitHubApp(); + const { selectedRepository } = useGitHubAppProvider(); const { getConfig } = useConfigStore(); const { taskPlan, - handleTaskChange, - handleRevisionChange, handleEditPlanItem, handleAddPlanItem, handleDeletePlanItem, @@ -437,8 +435,8 @@ export function Thread() { {taskPlan && ( {}} + onRevisionChange={() => {}} onEditPlanItem={handleEditPlanItem} onAddPlanItem={handleAddPlanItem} onDeletePlanItem={handleDeletePlanItem} diff --git a/apps/web/src/hooks/useGitHubApp.ts b/apps/web/src/hooks/useGitHubApp.ts index a3040846..b5720a4e 100644 --- a/apps/web/src/hooks/useGitHubApp.ts +++ b/apps/web/src/hooks/useGitHubApp.ts @@ -1,33 +1,68 @@ -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useQueryState } from "nuqs"; import { Repository, getRepositoryBranches, Branch } from "@/utils/github"; import type { TargetRepository } from "@open-swe/shared/open-swe/types"; interface UseGitHubAppReturn { + // Installation and general state isInstalled: boolean | null; isLoading: boolean; error: string | null; + + // Repository state and pagination repositories: Repository[]; + repositoriesPage: number; + repositoriesHasMore: boolean; + repositoriesLoadingMore: boolean; refreshRepositories: () => Promise; + loadMoreRepositories: () => Promise; + + // Repository selection selectedRepository: TargetRepository | null; setSelectedRepository: (repo: TargetRepository | null) => void; + + // Branch state and pagination branches: Branch[]; + branchesPage: number; + branchesHasMore: boolean; branchesLoading: boolean; + branchesLoadingMore: boolean; branchesError: string | null; + loadMoreBranches: () => Promise; + fetchBranches: () => Promise; + setBranchesPage: (page: number) => void; + setBranches: (branches: Branch[]) => void; + + // Branch selection selectedBranch: string | null; setSelectedBranch: (branch: string | null) => void; refreshBranches: () => Promise; + + // Repository metadata defaultBranch: string | null; } export function useGitHubApp(): UseGitHubAppReturn { + // Installation and general state const [isInstalled, setIsInstalled] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + + // Repository state and pagination const [repositories, setRepositories] = useState([]); + const [repositoriesPage, setRepositoriesPage] = useState(1); + const [repositoriesHasMore, setRepositoriesHasMore] = useState(false); + const [repositoriesLoadingMore, setRepositoriesLoadingMore] = useState(false); + + // Branch state and pagination const [branches, setBranches] = useState([]); + const [branchesPage, setBranchesPage] = useState(1); + const [branchesHasMore, setBranchesHasMore] = useState(false); const [branchesLoading, setBranchesLoading] = useState(false); + const [branchesLoadingMore, setBranchesLoadingMore] = useState(false); const [branchesError, setBranchesError] = useState(null); + + // URL state management const [selectedRepositoryParam, setSelectedRepositoryParam] = useQueryState("repo"); const [selectedBranchParam, setSelectedBranchParam] = useQueryState("branch"); @@ -35,24 +70,34 @@ export function useGitHubApp(): UseGitHubAppReturn { // Track if auto-selection has been attempted to prevent re-triggering const hasAutoSelectedRef = useRef(false); - const selectedRepository = selectedRepositoryParam - ? (() => { - try { - // Parse "owner/repo" format instead of JSON - const parts = selectedRepositoryParam.split("/"); - if (parts.length === 2) { - return { - owner: parts[0], - repo: parts[1], - branch: selectedBranchParam || undefined, - } as TargetRepository; - } - return null; - } catch { - return null; - } - })() - : null; + const selectedRepository = useMemo(() => { + if (!selectedRepositoryParam) return null; + try { + // Parse "owner/repo" format instead of JSON + const parts = selectedRepositoryParam.split("/"); + if (parts.length === 2) { + return { + owner: parts[0], + repo: parts[1], + branch: selectedBranchParam || undefined, + } as TargetRepository; + } + return null; + } catch { + return null; + } + }, [selectedRepositoryParam, selectedBranchParam]); + + useEffect(() => { + if (selectedRepository && !branchesLoading) { + fetchBranches(); + // Reset branch pagination when repository changes + setBranchesPage(1); + } else if (!selectedRepository) { + setBranches([]); + setSelectedBranchParam(null); + } + }, [selectedRepository]); const selectedBranch = selectedBranchParam; @@ -62,6 +107,8 @@ export function useGitHubApp(): UseGitHubAppReturn { if (!repo) { setSelectedBranchParam(null); setBranches([]); + setBranchesPage(1); + setBranchesHasMore(false); } }, [setSelectedRepositoryParam, setSelectedBranchParam], @@ -71,16 +118,29 @@ export function useGitHubApp(): UseGitHubAppReturn { setSelectedBranchParam(branch); }; - const checkInstallation = async () => { - setIsLoading(true); + const checkInstallation = async ( + page: number = 1, + append: boolean = false, + ) => { + if (!append) setIsLoading(true); + if (append) setRepositoriesLoadingMore(true); setError(null); try { - const response = await fetch("/api/github/repositories"); + const response = await fetch(`/api/github/repositories?page=${page}`); if (response.ok) { const data = await response.json(); - setRepositories(data.repositories || []); + const newRepositories = data.repositories || []; + + if (append) { + setRepositories((prev) => [...prev, ...newRepositories]); + } else { + setRepositories(newRepositories); + } + + setRepositoriesPage(data.pagination?.page || page); + setRepositoriesHasMore(data.pagination?.hasMore || false); setIsInstalled(true); } else { const errorData = await response.json(); @@ -95,56 +155,78 @@ export function useGitHubApp(): UseGitHubAppReturn { setError("Failed to check GitHub App installation status"); setIsInstalled(false); } finally { - setIsLoading(false); + if (!append) setIsLoading(false); + if (append) setRepositoriesLoadingMore(false); } }; - const fetchBranches = useCallback(async () => { - if (!selectedRepository) { - setBranches([]); - return; - } + const fetchBranches = useCallback( + async (page: number = 1, append: boolean = false) => { + if (!selectedRepository) { + setBranches([]); + setBranchesPage(1); + setBranchesHasMore(false); + return; + } - setBranchesLoading(true); - setBranchesError(null); + if (!append) setBranchesLoading(true); + if (append) setBranchesLoadingMore(true); + setBranchesError(null); - try { - const branchData = await getRepositoryBranches( - selectedRepository.owner, - selectedRepository.repo, - ); - setBranches(branchData || []); - } catch (err) { - const errorMessage = - err instanceof Error ? err.message : "Failed to fetch branches"; - console.error( - `Error fetching branches for ${selectedRepository.owner}/${selectedRepository.repo}:`, - err, - ); - setBranchesError(errorMessage); - } finally { - setBranchesLoading(false); + try { + const branchData = await getRepositoryBranches( + selectedRepository.owner, + selectedRepository.repo, + page, + ); + + if (append) { + setBranches((prev) => { + // Avoid adding duplicates + const newBranches = branchData.branches.filter( + (branch) => !prev.some((b) => b.name === branch.name), + ); + return [...prev, ...newBranches]; + }); + } else { + setBranches(branchData.branches); + } + + setBranchesPage(page); + setBranchesHasMore(branchData.hasMore); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to fetch branches"; + console.error( + `Error fetching branches for ${selectedRepository.owner}/${selectedRepository.repo}:`, + err, + ); + setBranchesError(errorMessage); + } finally { + if (!append) setBranchesLoading(false); + if (append) setBranchesLoadingMore(false); + } + }, + [selectedRepository?.owner, selectedRepository?.repo], + ); + + // Load more functions + const loadMoreRepositories = useCallback(async () => { + if (repositoriesHasMore && !repositoriesLoadingMore) { + await checkInstallation(repositoriesPage + 1, true); } - }, [selectedRepository?.owner, selectedRepository?.repo]); + }, [repositoriesHasMore, repositoriesLoadingMore, repositoriesPage]); + + const loadMoreBranches = useCallback(async () => { + if (branchesHasMore && !branchesLoadingMore) { + await fetchBranches(branchesPage + 1, true); + } + }, [branchesHasMore, branchesLoadingMore, branchesPage, fetchBranches]); useEffect(() => { checkInstallation(); }, []); - useEffect(() => { - if (selectedRepository) { - fetchBranches(); - } else { - setBranches([]); - setSelectedBranchParam(null); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - selectedRepository?.owner, - selectedRepository?.repo, - setSelectedBranchParam, - ]); - // Auto-select first repository on initial page load useEffect(() => { if ( @@ -173,10 +255,16 @@ export function useGitHubApp(): UseGitHubAppReturn { ]); const refreshRepositories = async () => { + // Reset pagination state on refresh + setRepositoriesPage(1); + setRepositoriesHasMore(false); await checkInstallation(); }; const refreshBranches = async () => { + // Reset pagination state on refresh + setBranchesPage(1); + setBranchesHasMore(false); await fetchBranches(); }; @@ -190,19 +278,41 @@ export function useGitHubApp(): UseGitHubAppReturn { : null; return { + // Installation and general state isInstalled, isLoading, error, + + // Repository state and pagination repositories, + repositoriesPage, + repositoriesHasMore, + repositoriesLoadingMore, refreshRepositories, + loadMoreRepositories, + + // Repository selection selectedRepository, setSelectedRepository, + + // Branch state and pagination branches, + branchesPage, + branchesHasMore, branchesLoading, + branchesLoadingMore, branchesError, + loadMoreBranches, + fetchBranches, + + // Branch selection selectedBranch, setSelectedBranch, refreshBranches, + setBranchesPage, + setBranches, + + // Repository metadata defaultBranch, }; } diff --git a/apps/web/src/providers/GitHubApp.tsx b/apps/web/src/providers/GitHubApp.tsx new file mode 100644 index 00000000..eb71cc00 --- /dev/null +++ b/apps/web/src/providers/GitHubApp.tsx @@ -0,0 +1,27 @@ +import { useGitHubApp } from "@/hooks/useGitHubApp"; +import { createContext, useContext, ReactNode } from "react"; + +type GitHubAppContextType = ReturnType; + +const GitHubAppContext = createContext( + undefined, +); + +export function GitHubAppProvider({ children }: { children: ReactNode }) { + const value = useGitHubApp(); + return ( + + {children} + + ); +} + +export function useGitHubAppProvider() { + const context = useContext(GitHubAppContext); + if (context === undefined) { + throw new Error( + "useGitHubAppProvider must be used within a GitHubAppProvider", + ); + } + return context; +} diff --git a/apps/web/src/utils/github.ts b/apps/web/src/utils/github.ts index 6f77f161..06e2a60c 100644 --- a/apps/web/src/utils/github.ts +++ b/apps/web/src/utils/github.ts @@ -1,4 +1,3 @@ -import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants"; import * as jwt from "jsonwebtoken"; function getBaseApiUrl(): string { @@ -41,7 +40,7 @@ export async function getInstallationToken( headers: { Authorization: `Bearer ${jwtToken}`, Accept: "application/vnd.github.v3+json", - "User-Agent": "YourAppName", + "User-Agent": "OpenSWE-Agent", }, }, ); @@ -62,17 +61,24 @@ export async function getInstallationToken( */ export async function getInstallationRepositories( installationToken: string, -): Promise { - const response = await fetch( - "https://api.github.com/installation/repositories", - { - headers: { - Authorization: `Bearer ${installationToken}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "YourAppName", - }, + page: number = 1, + perPage: number = 30, +): Promise<{ + repositories: Repository[]; + hasMore: boolean; + totalCount: number; +}> { + const url = new URL("https://api.github.com/installation/repositories"); + url.searchParams.set("page", page.toString()); + url.searchParams.set("per_page", perPage.toString()); + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${installationToken}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "YourAppName", }, - ); + }); if (!response.ok) { const errorData = await response.json(); @@ -82,7 +88,12 @@ export async function getInstallationRepositories( } const data = await response.json(); - return data.repositories; + + return { + repositories: data.repositories, + hasMore: data.repositories.length === perPage, + totalCount: data.total_count, + }; } /** @@ -91,11 +102,9 @@ export async function getInstallationRepositories( export async function getRepositoryBranches( owner: string, repo: string, -): Promise { - const allBranches: Branch[] = []; - let page = 1; - const perPage = 100; // Maximum allowed by GitHub API - + page: number = 1, + perPage: number = 30, +): Promise<{ branches: Branch[]; hasMore: boolean; totalCount?: number }> { // First, get repository info to ensure we have the default branch const repoResponse = await fetch( @@ -114,52 +123,65 @@ export async function getRepositoryBranches( defaultBranch = repoData.default_branch; } - // Fetch all branches with pagination - while (true) { - const response = await fetch( - `${getBaseApiUrl()}github/proxy/repos/${owner}/${repo}/branches?per_page=${perPage}&page=${page}`, - { - headers: { - Accept: "application/vnd.github.v3+json", - "User-Agent": "OpenSWE-Agent", - }, + // Fetch first 30 branches only + const response = await fetch( + `${getBaseApiUrl()}github/proxy/repos/${owner}/${repo}/branches?per_page=${perPage}&page=${page}`, + { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "OpenSWE-Agent", }, - ); + }, + ); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(`Failed to fetch branches: ${JSON.stringify(errorData)}`); - } + if (!response.ok) { + const errorData = await response.json(); + throw new Error(`Failed to fetch branches: ${JSON.stringify(errorData)}`); + } - const data = await response.json(); + const data = await response.json(); + const branches: Branch[] = Array.isArray(data) ? data : []; - if (!Array.isArray(data) || data.length === 0) { - break; - } - - allBranches.push(...data); - - // If we got less than the requested amount, we've reached the end - if (data.length < perPage) { - break; - } - - page++; + if (branches.length === 0) { + return { branches: [], hasMore: false }; } // Ensure default branch is at the beginning if it exists - if (defaultBranch) { - const defaultBranchIndex = allBranches.findIndex( + if (defaultBranch && page === 1) { + const defaultBranchIndex = branches.findIndex( (branch) => branch.name === defaultBranch, ); if (defaultBranchIndex > 0) { - const defaultBranchData = allBranches[defaultBranchIndex]; - allBranches.splice(defaultBranchIndex, 1); - allBranches.unshift(defaultBranchData); + const defaultBranchData = branches[defaultBranchIndex]; + branches.splice(defaultBranchIndex, 1); + branches.unshift(defaultBranchData); + } else { + // Need to fetch default branch + const defaultBranchResponse = await fetch( + `${getBaseApiUrl()}github/proxy/repos/${owner}/${repo}/branches/${defaultBranch}`, + { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "OpenSWE-Agent", + }, + }, + ); + if (!defaultBranchResponse.ok) { + const errorData = await defaultBranchResponse.json(); + throw new Error( + `Failed to fetch default branch: ${JSON.stringify(errorData)}`, + ); + } + const defaultBranchData = await defaultBranchResponse.json(); + branches.unshift(defaultBranchData); } } - return allBranches; + return { + branches, + hasMore: branches.length >= perPage, + totalCount: branches.length, + }; } /**