[Feat]: Add GitLab support for repo list in Cloud Openhands (#7633)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Rohit Malhotra 2025-04-03 19:01:22 -04:00 committed by GitHub
parent cc1aadaba5
commit a4ebb5bf85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 80 additions and 152 deletions

View File

@ -54,14 +54,12 @@ export const retrieveGitHubAppRepositories = async (
* Given a PAT, retrieves the repositories of the user
* @returns A list of repositories
*/
export const retrieveUserGitRepositories = async (page = 1, per_page = 30) => {
export const retrieveUserGitRepositories = async () => {
const response = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
params: {
sort: "pushed",
page,
per_page,
},
},
);

View File

@ -323,11 +323,6 @@ class OpenHands {
return user;
}
static async getGitHubUserInstallationIds(): Promise<number[]> {
const response = await openHands.get<number[]>("/api/user/installations");
return response.data;
}
static async searchGitRepositories(
query: string,
per_page = 5,

View File

@ -4,7 +4,6 @@ import { useNavigate } from "react-router";
import { I18nKey } from "#/i18n/declaration";
import { SuggestionBox } from "#/components/features/suggestions/suggestion-box";
import { GitRepositorySelector } from "./git-repo-selector";
import { useAppRepositories } from "#/hooks/query/use-app-repositories";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { sanitizeQuery } from "#/utils/sanitize-query";
@ -31,20 +30,15 @@ export function GitRepositoriesSuggestionBox({
const debouncedSearchQuery = useDebounce(searchQuery, 300);
// TODO: Use `useQueries` to fetch all repositories in parallel
const { data: appRepositories, isLoading: isAppReposLoading } =
useAppRepositories();
const { data: userRepositories, isLoading: isUserReposLoading } =
useUserRepositories();
const { data: searchedRepos, isLoading: isSearchReposLoading } =
useSearchRepositories(sanitizeQuery(debouncedSearchQuery));
const isLoading =
isAppReposLoading || isUserReposLoading || isSearchReposLoading;
const isLoading = isUserReposLoading || isSearchReposLoading;
const repositories =
userRepositories?.pages.flatMap((page) => page.data) ||
appRepositories?.pages.flatMap((page) => page.data) ||
[];
userRepositories?.pages.flatMap((page) => page.data) || [];
const handleConnectToGitHub = () => {
if (gitHubAuthUrl) {

View File

@ -1,20 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
export const useAppInstallations = () => {
const { data: config } = useConfig();
const { providersAreSet } = useAuth();
return useQuery({
queryKey: ["installations", providersAreSet, config?.GITHUB_CLIENT_ID],
queryFn: OpenHands.getGitHubUserInstallationIds,
enabled:
providersAreSet &&
!!config?.GITHUB_CLIENT_ID &&
config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
};

View File

@ -1,67 +0,0 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import React from "react";
import { retrieveGitHubAppRepositories } from "#/api/git";
import { useAppInstallations } from "./use-app-installations";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const useAppRepositories = () => {
const { providersAreSet } = useAuth();
const { data: config } = useConfig();
const { data: installations } = useAppInstallations();
const repos = useInfiniteQuery({
queryKey: ["repositories", providersAreSet, installations],
queryFn: async ({
pageParam,
}: {
pageParam: { installationIndex: number | null; repoPage: number | null };
}) => {
const { repoPage, installationIndex } = pageParam;
if (!installations) {
throw new Error("Missing installation list");
}
return retrieveGitHubAppRepositories(
installationIndex || 0,
installations,
repoPage || 1,
30,
);
},
initialPageParam: { installationIndex: 0, repoPage: 1 },
getNextPageParam: (lastPage) => {
if (lastPage.nextPage) {
return {
installationIndex: lastPage.installationIndex,
repoPage: lastPage.nextPage,
};
}
if (lastPage.installationIndex !== null) {
return { installationIndex: lastPage.installationIndex, repoPage: 1 };
}
return null;
},
enabled:
providersAreSet &&
Array.isArray(installations) &&
installations.length > 0 &&
config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
// TODO: Once we create our custom dropdown component, we should fetch data onEndReached
// (nextui autocomplete doesn't support onEndReached nor is it compatible for extending)
const { isSuccess, isFetchingNextPage, hasNextPage, fetchNextPage } = repos;
React.useEffect(() => {
if (!isFetchingNextPage && isSuccess && hasNextPage) {
fetchNextPage();
}
}, [isFetchingNextPage, isSuccess, hasNextPage, fetchNextPage]);
return repos;
};

View File

@ -1,20 +1,17 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import React from "react";
import { retrieveUserGitRepositories } from "#/api/git";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const useUserRepositories = () => {
const { providerTokensSet, providersAreSet } = useAuth();
const { data: config } = useConfig();
const repos = useInfiniteQuery({
queryKey: ["repositories", providerTokensSet],
queryFn: async ({ pageParam }) =>
retrieveUserGitRepositories(pageParam, 100),
queryFn: async () => retrieveUserGitRepositories(),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
enabled: providersAreSet && config?.APP_MODE === "oss",
enabled: providersAreSet,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});

View File

@ -16,6 +16,7 @@ from openhands.integrations.service_types import (
UnknownException,
User,
)
from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
@ -99,31 +100,36 @@ class GitHubService(GitService):
email=response.get('email'),
)
async def get_repositories(
self, sort: str, installation_id: int | None
) -> list[Repository]:
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitHub API
all_repos: list[dict] = []
async def _fetch_paginated_repos(
self, url: str, params: dict, max_repos: int, extract_key: str | None = None
) -> list[dict]:
"""
Fetch repositories with pagination support.
Args:
url: The API endpoint URL
params: Query parameters for the request
max_repos: Maximum number of repositories to fetch
extract_key: If provided, extract repositories from this key in the response
Returns:
List of repository dictionaries
"""
repos: list[dict] = []
page = 1
while len(all_repos) < MAX_REPOS:
params = {'page': str(page), 'per_page': str(PER_PAGE)}
if installation_id:
url = (
f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
)
response, headers = await self._fetch_data(url, params)
response = response.get('repositories', [])
else:
url = f'{self.BASE_URL}/user/repos'
params['sort'] = sort
response, headers = await self._fetch_data(url, params)
while len(repos) < max_repos:
page_params = {**params, 'page': str(page)}
response, headers = await self._fetch_data(url, page_params)
if not response: # No more repositories
# Extract repositories from response
page_repos = response.get(extract_key, []) if extract_key else response
if not page_repos: # No more repositories
break
all_repos.extend(response)
repos.extend(page_repos)
page += 1
# Check if we've reached the last page
@ -131,8 +137,43 @@ class GitHubService(GitService):
if 'rel="next"' not in link_header:
break
# Trim to MAX_REPOS if needed and convert to Repository objects
all_repos = all_repos[:MAX_REPOS]
return repos[:max_repos] # Trim to max_repos if needed
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitHub API
all_repos: list[dict] = []
if app_mode == AppMode.SAAS:
# Get all installation IDs and fetch repos for each one
installation_ids = await self.get_installation_ids()
# Iterate through each installation ID
for installation_id in installation_ids:
params = {'per_page': str(PER_PAGE)}
url = (
f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
)
# Fetch repositories for this installation
installation_repos = await self._fetch_paginated_repos(
url, params, MAX_REPOS - len(all_repos), extract_key='repositories'
)
all_repos.extend(installation_repos)
# If we've already reached MAX_REPOS, no need to check other installations
if len(all_repos) >= MAX_REPOS:
break
else:
# Original behavior for non-SaaS mode
params = {'per_page': str(PER_PAGE), 'sort': sort}
url = f'{self.BASE_URL}/user/repos'
# Fetch user repositories
all_repos = await self._fetch_paginated_repos(url, params, MAX_REPOS)
# Convert to Repository objects
return [
Repository(
id=repo.get('id'),

View File

@ -1,5 +1,6 @@
import os
from typing import Any
from urllib.parse import quote_plus
import httpx
from pydantic import SecretStr
@ -12,6 +13,7 @@ from openhands.integrations.service_types import (
UnknownException,
User,
)
from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
@ -119,12 +121,7 @@ class GitLabService(GitService):
return repos
async def get_repositories(
self, sort: str, installation_id: int | None
) -> list[Repository]:
if installation_id:
return [] # Not implementing installation_token case yet
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitLab API
all_repos: list[dict] = []

View File

@ -26,6 +26,7 @@ from openhands.integrations.service_types import (
Repository,
User,
)
from openhands.server.types import AppMode
class ProviderToken(BaseModel):
@ -187,11 +188,7 @@ class ProviderHandler:
service = self._get_service(provider)
return await service.get_latest_token()
async def get_repositories(
self,
sort: str,
installation_id: int | None,
) -> list[Repository]:
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
"""
Get repositories from a selected providers with pagination support
"""
@ -200,7 +197,7 @@ class ProviderHandler:
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
service_repos = await service.get_repositories(sort, installation_id)
service_repos = await service.get_repositories(sort, app_mode)
all_repos.extend(service_repos)
except Exception:
continue

View File

@ -3,6 +3,8 @@ from typing import Protocol
from pydantic import BaseModel, SecretStr
from openhands.server.types import AppMode
class ProviderType(Enum):
GITHUB = 'github'
@ -86,10 +88,6 @@ class GitService(Protocol):
"""Search for repositories"""
...
async def get_repositories(
self,
sort: str,
installation_id: int | None,
) -> list[Repository]:
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
"""Get repositories for the authenticated user"""
...

View File

@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, status
from fastapi.responses import JSONResponse
from pydantic import SecretStr
from openhands.server.shared import server_config
from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
@ -16,6 +16,7 @@ from openhands.integrations.service_types import (
User,
)
from openhands.server.auth import get_access_token, get_provider_tokens
from openhands.server.types import AppMode
app = APIRouter(prefix='/api/user')
@ -23,7 +24,6 @@ app = APIRouter(prefix='/api/user')
@app.get('/repositories', response_model=list[Repository])
async def get_user_repositories(
sort: str = 'pushed',
installation_id: int | None = None,
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
):
@ -33,9 +33,7 @@ async def get_user_repositories(
)
try:
repos: list[Repository] = await client.get_repositories(
sort, installation_id
)
repos: list[Repository] = await client.get_repositories(sort, server_config.app_mode)
return repos
except AuthenticationError as e: