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
This commit is contained in:
Brace Sproul 2025-06-11 17:36:23 -07:00 • committed by GitHub
parent 30881a5d92
commit 69b15a9f77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 367 additions and 152 deletions

View file

@ -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");

View file

@ -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(

View file

@ -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 (
<React.Suspense fallback={<div>Loading (layout)...</div>}>
<Toaster />
<ThreadProvider>
<StreamProvider>
<ArtifactProvider>
<Thread />
</ArtifactProvider>
</StreamProvider>
</ThreadProvider>
<GitHubAppProvider>
<ThreadProvider>
<StreamProvider>
<ArtifactProvider>
<Thread />
</ArtifactProvider>
</StreamProvider>
</ThreadProvider>
</GitHubAppProvider>
</React.Suspense>
);
}

View file

@ -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({
);
})}
</CommandGroup>
{branchesHasMore && (
<CommandGroup>
<CommandItem
onSelect={() => {
loadMoreBranches();
}}
disabled={branchesLoadingMore}
className="justify-center"
>
{branchesLoadingMore
? "Loading more..."
: "Load more branches"}
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>

View file

@ -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({
);
})}
</CommandGroup>
{repositoriesHasMore && (
<CommandGroup>
<CommandItem
onSelect={() => {
loadMoreRepositories();
}}
disabled={repositoriesLoadingMore}
className="justify-center"
>
{repositoriesLoadingMore
? "Loading more..."
: "Load more repositories"}
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>

View file

@ -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 (

View file

@ -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,

View file

@ -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 && (
<TaskPlanView
taskPlan={taskPlan}
onTaskChange={handleTaskChange}
onRevisionChange={handleRevisionChange}
onTaskChange={() => {}}
onRevisionChange={() => {}}
onEditPlanItem={handleEditPlanItem}
onAddPlanItem={handleAddPlanItem}
onDeletePlanItem={handleDeletePlanItem}

View file

@ -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<void>;
loadMoreRepositories: () => Promise<void>;
// 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<void>;
fetchBranches: () => Promise<void>;
setBranchesPage: (page: number) => void;
setBranches: (branches: Branch[]) => void;
// Branch selection
selectedBranch: string | null;
setSelectedBranch: (branch: string | null) => void;
refreshBranches: () => Promise<void>;
// Repository metadata
defaultBranch: string | null;
}
export function useGitHubApp(): UseGitHubAppReturn {
// Installation and general state
const [isInstalled, setIsInstalled] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Repository state and pagination
const [repositories, setRepositories] = useState<Repository[]>([]);
const [repositoriesPage, setRepositoriesPage] = useState(1);
const [repositoriesHasMore, setRepositoriesHasMore] = useState(false);
const [repositoriesLoadingMore, setRepositoriesLoadingMore] = useState(false);
// Branch state and pagination
const [branches, setBranches] = useState<Branch[]>([]);
const [branchesPage, setBranchesPage] = useState(1);
const [branchesHasMore, setBranchesHasMore] = useState(false);
const [branchesLoading, setBranchesLoading] = useState(false);
const [branchesLoadingMore, setBranchesLoadingMore] = useState(false);
const [branchesError, setBranchesError] = useState<string | null>(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,
};
}

View file

@ -0,0 +1,27 @@
import { useGitHubApp } from "@/hooks/useGitHubApp";
import { createContext, useContext, ReactNode } from "react";
type GitHubAppContextType = ReturnType<typeof useGitHubApp>;
const GitHubAppContext = createContext<GitHubAppContextType | undefined>(
undefined,
);
export function GitHubAppProvider({ children }: { children: ReactNode }) {
const value = useGitHubApp();
return (
<GitHubAppContext.Provider value={value}>
{children}
</GitHubAppContext.Provider>
);
}
export function useGitHubAppProvider() {
const context = useContext(GitHubAppContext);
if (context === undefined) {
throw new Error(
"useGitHubAppProvider must be used within a GitHubAppProvider",
);
}
return context;
}

View file

@ -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<Repository[]> {
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<Branch[]> {
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,
};
}
/**