Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 55 additions & 39 deletions openhands/app_server/git/git_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
)
from openhands.app_server.integrations.provider import ProviderHandler
from openhands.app_server.integrations.service_types import (
Branch,
ProviderType,
Repository,
SuggestedTask,
Expand All @@ -46,6 +45,19 @@
user_context_dependency = depends_user_context()


def _provider_page_number(page_id: str | None) -> int:
"""Decode a provider page token, rejecting values this API never emits."""
if page_id is None:
return 1
page = decode_page_id(page_id)
if page is None or page <= 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid page_id.',
)
return page


@router.get('/installations/search')
async def search_user_installations(
provider: ProviderType,
Expand Down Expand Up @@ -138,6 +150,11 @@ async def search_repositories(
status_code=status.HTTP_403_FORBIDDEN, # 403 not 401 to avoid frontend logout
detail='Git provider token required (such as GitHub).',
)
if provider not in provider_tokens:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Requested Git provider is not connected.',
)

user_id = await user_context.get_user_id()
# Cast to the expected type since we validated provider_tokens exists
Expand All @@ -147,10 +164,7 @@ async def search_repositories(
external_auth_id=user_id,
)

page = 1
decoded_page_id = decode_page_id(page_id)
if decoded_page_id is not None:
page = decoded_page_id
page = _provider_page_number(page_id)

# If query is provided, use search; otherwise get user's repositories
if query:
Expand All @@ -164,11 +178,17 @@ async def search_repositories(
repos: list[Repository] = await client.search_repositories(
selected_provider=provider,
query=query,
per_page=limit + 1,
per_page=limit,
sort=search_sort,
order=order,
app_mode=get_global_config().app_mode,
page=page,
)
# A limit+1 look-ahead changes the provider's numbered page width and
# drops that boundary item when page+1 is requested. A full page uses
# an optimistic token; the harmless terminal page may therefore be empty.
has_next_page = len(repos) >= limit
repos = repos[:limit]
else:
if sort_order:
# TODO: This is a temporary state until we refactor the underlying API.
Expand All @@ -188,11 +208,11 @@ async def search_repositories(
per_page=limit + 1,
installation_id=installation_id,
)
has_next_page = len(repos) > limit
if has_next_page:
repos = repos[:-1]

next_page_id = None
if len(repos) > limit:
repos = repos[:-1]
next_page_id = encode_page_id(page + 1)
next_page_id = encode_page_id(page + 1) if has_next_page else None

return RepositoryPage(items=repos, next_page_id=next_page_id)

Expand Down Expand Up @@ -229,6 +249,11 @@ async def search_branches(
status_code=status.HTTP_403_FORBIDDEN, # 403 not 401 to avoid frontend logout
detail='Git provider token required (such as GitHub).',
)
if provider not in provider_tokens:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Requested Git provider is not connected.',
)

user_id = await user_context.get_user_id()
# Cast to the expected type since we validated provider_tokens exists
Expand All @@ -238,41 +263,32 @@ async def search_branches(
external_auth_id=user_id,
)

page = 1
decoded_page_id = decode_page_id(page_id)
if decoded_page_id is not None:
page = decoded_page_id
page = _provider_page_number(page_id)

if query:
if page != 1:
# TODO(#13883): Support pagination for branch search after refactoring.
# The search_branches method does not support paging in the same way as
# get_branches - those should be merged into a single paginated method
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Pagination not yet supported for branch search queries. Use empty query to list all branches with pagination.',
)
# Get search results - we'll handle pagination ourselves
branches: list[Branch] = await client.search_branches(
selected_provider=provider,
repository=repository,
query=query,
per_page=limit + 1,
)
else:
try:
current_page = await client.get_branches(
repository=repository,
specified_provider=provider,
page=page,
per_page=limit + 1,
per_page=limit,
raise_on_error=True,
)
branches = current_page.branches

next_page_id = None
if len(branches) > limit:
branches = branches[:-1]
next_page_id = encode_page_id(page + 1)

except Exception:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Git branch search is temporarily unavailable.',
) from None
branches = current_page.branches
if query:
normalized_query = query.casefold()
branches = [
branch
for branch in branches
if normalized_query in branch.name.casefold()
or branch.commit_sha.casefold().startswith(normalized_query)
]

next_page_id = encode_page_id(page + 1) if current_page.has_next_page else None
return BranchPage(items=branches, next_page_id=next_page_id)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ async def search_repositories(
order: str = 'desc',
public: bool = False,
app_mode: AppMode = AppMode.OPENHANDS,
page: int = 1,
) -> list[Repository]:
"""Search for repositories in Azure DevOps."""
# Get all repositories across all projects in the organization
Expand All @@ -32,8 +33,8 @@ async def search_repositories(
repo for repo in repos if query.lower() in repo.get('name', '').lower()
]

# Limit to per_page
repos = repos[:per_page]
start = (page - 1) * per_page
repos = repos[start : start + per_page]

return [
Repository(
Expand Down
9 changes: 6 additions & 3 deletions openhands/app_server/integrations/bitbucket/service/repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@ async def search_repositories(
order: str,
public: bool,
app_mode: AppMode,
page: int = 1,
) -> list[Repository]:
"""Search for repositories."""
repositories = []

if public:
if page != 1:
return []
# Extract workspace and repo from URL using robust URL parsing
# URL format: https://{domain}/{workspace}/{repo}/{additional_params}
try:
Expand Down Expand Up @@ -54,7 +57,7 @@ async def search_repositories(
if '/' in query:
workspace_slug, repo_query = query.split('/', 1)
return await self.get_paginated_repos(
1, per_page, sort, workspace_slug, repo_query
page, per_page, sort, workspace_slug, repo_query
)

all_installations = await self.get_installations()
Expand All @@ -67,7 +70,7 @@ async def search_repositories(
# Get repositories where query matches workspace name
try:
repos = await self.get_paginated_repos(
1, per_page, sort, workspace_slug
page, per_page, sort, workspace_slug
)
repositories.extend(repos)
except Exception:
Expand All @@ -77,7 +80,7 @@ async def search_repositories(
# Get repositories in all workspaces where query matches repo name
try:
repos = await self.get_paginated_repos(
1, per_page, sort, workspace_slug, query
page, per_page, sort, workspace_slug, query
)
repositories.extend(repos)
except Exception:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@ async def search_repositories(
order: str,
public: bool,
app_mode: AppMode,
page: int = 1,
) -> list[Repository]:
"""Search for repositories."""
repositories = []

if public:
if page != 1:
return []
try:
parsed_url = urlparse(query)
path_segments = [
Expand Down Expand Up @@ -80,14 +83,16 @@ async def search_repositories(
if repo_query.lower() in r.get('slug', '').lower()
or repo_query.lower() in r.get('name', '').lower()
]
start = (page - 1) * per_page
raw_repos = raw_repos[start : start + per_page]
return [await self._parse_repository(repo) for repo in raw_repos]

# No '/' in query, search across all projects
all_projects = await self.get_installations()
for project_key in all_projects:
try:
repos = await self.get_paginated_repos(
1, per_page, sort, project_key, query
page, per_page, sort, project_key, query
)
repositories.extend(repos)
except Exception:
Expand Down
2 changes: 2 additions & 0 deletions openhands/app_server/integrations/forgejo/service/repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ async def search_repositories(
order: str,
public: bool,
app_mode: AppMode,
page: int = 1,
) -> list[Repository]: # type: ignore[override]
url = f'{self.BASE_URL}/repos/search'
params = {
Expand All @@ -24,6 +25,7 @@ async def search_repositories(
'sort': sort,
'order': order,
'mode': 'source',
'page': page,
}

response, _ = await self._make_request(url, params)
Expand Down
2 changes: 2 additions & 0 deletions openhands/app_server/integrations/github/service/repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,14 @@ async def search_repositories(
order: str,
public: bool,
app_mode: AppMode,
page: int = 1,
) -> list[Repository]:
url = f'{self.BASE_URL}/search/repositories'
params = {
'per_page': per_page,
'sort': sort,
'order': order,
'page': page,
}

if public:
Expand Down
3 changes: 2 additions & 1 deletion openhands/app_server/integrations/gitlab/service/repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ async def search_repositories(
order: str = 'desc',
public: bool = False,
app_mode: AppMode = AppMode.OPENHANDS,
page: int = 1,
) -> list[Repository]:
if public:
# When public=True, query is a GitLab URL that we need to parse
Expand All @@ -91,7 +92,7 @@ async def search_repositories(
repository = await self.get_repository_details_from_repo_name(repo_path)
return [repository]

return await self.get_paginated_repos(1, per_page, sort, None, query)
return await self.get_paginated_repos(page, per_page, sort, None, query)

async def get_paginated_repos(
self,
Expand Down
9 changes: 7 additions & 2 deletions openhands/app_server/integrations/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,12 +368,13 @@ async def search_repositories(
sort: str,
order: str,
app_mode: AppMode,
page: int = 1,
) -> list[Repository]:
if selected_provider:
service = self.get_service(selected_provider)
public = self._is_repository_url(query, selected_provider)
user_repos = await service.search_repositories(
query, per_page, sort, order, public, app_mode
query, per_page, sort, order, public, app_mode, page
)
return self._deduplicate_repositories(user_repos)

Expand All @@ -383,7 +384,7 @@ async def search_repositories(
service = self.get_service(provider)
public = self._is_repository_url(query, provider)
service_repos = await service.search_repositories(
query, per_page, sort, order, public, app_mode
query, per_page, sort, order, public, app_mode, page
)
all_repos.extend(service_repos)
except Exception as e:
Expand Down Expand Up @@ -470,6 +471,7 @@ async def get_branches(
specified_provider: ProviderType | None = None,
page: int = 1,
per_page: int = 30,
raise_on_error: bool = False,
) -> PaginatedBranchesResponse:
"""Get branches for a repository

Expand All @@ -487,6 +489,9 @@ async def get_branches(
service = self.get_service(specified_provider)
return await service.get_paginated_branches(repository, page, per_page)
except Exception as e:
if raise_on_error:
logger.warning(f'Error fetching branches from {specified_provider}')
raise
logger.warning(
f'Error fetching branches from {specified_provider}: {e}'
)
Expand Down
1 change: 1 addition & 0 deletions openhands/app_server/integrations/service_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ async def search_repositories(
order: str,
public: bool,
app_mode: AppMode,
page: int = 1,
) -> list[Repository]:
"""Search for public repositories"""
...
Expand Down
Loading