diff --git a/openhands/integrations/bitbucket/service/repos.py b/openhands/integrations/bitbucket/service/repos.py index b2ab99e380..5b29742b8d 100644 --- a/openhands/integrations/bitbucket/service/repos.py +++ b/openhands/integrations/bitbucket/service/repos.py @@ -13,7 +13,13 @@ class BitBucketReposMixin(BitBucketMixinBase): """ async def search_repositories( - self, query: str, per_page: int, sort: str, order: str, public: bool + self, + query: str, + per_page: int, + sort: str, + order: str, + public: bool, + app_mode: AppMode, ) -> list[Repository]: """Search for repositories.""" repositories = [] diff --git a/openhands/integrations/github/service/repos.py b/openhands/integrations/github/service/repos.py index 89df86fadb..855eb08275 100644 --- a/openhands/integrations/github/service/repos.py +++ b/openhands/integrations/github/service/repos.py @@ -161,6 +161,29 @@ class GitHubReposMixin(GitHubMixinBase): logger.warning(f'Failed to get user organizations: {e}') return [] + async def get_organizations_from_installations(self) -> list[str]: + """Get list of organization logins from GitHub App installations. + + This method provides a more reliable way to get organizations that the + GitHub App has access to, regardless of user membership context. + """ + try: + # Get installations with account details + url = f'{self.BASE_URL}/user/installations' + response, _ = await self._make_request(url) + installations = response.get('installations', []) + + orgs = [] + for installation in installations: + account = installation.get('account', {}) + if account.get('type') == 'Organization': + orgs.append(account.get('login')) + + return orgs + except Exception as e: + logger.warning(f'Failed to get organizations from installations: {e}') + return [] + def _fuzzy_match_org_name(self, query: str, org_name: str) -> bool: """Check if query fuzzy matches organization name.""" query_lower = query.lower().replace('-', '').replace('_', '').replace(' ', '') @@ -181,7 +204,13 @@ class GitHubReposMixin(GitHubMixinBase): return False async def search_repositories( - self, query: str, per_page: int, sort: str, order: str, public: bool + self, + query: str, + per_page: int, + sort: str, + order: str, + public: bool, + app_mode: AppMode, ) -> list[Repository]: url = f'{self.BASE_URL}/search/repositories' params = { @@ -206,9 +235,12 @@ class GitHubReposMixin(GitHubMixinBase): query_with_user = f'org:{org} in:name {repo_query}' params['q'] = query_with_user elif not public: - # Expand search scope to include user's repositories and organizations they're a member of + # Expand search scope to include user's repositories and organizations the app has access to user = await self.get_user() - user_orgs = await self.get_user_organizations() + if app_mode == AppMode.SAAS: + user_orgs = await self.get_organizations_from_installations() + else: + user_orgs = await self.get_user_organizations() # Search in user repos and org repos separately all_repos = [] diff --git a/openhands/integrations/gitlab/service/repos.py b/openhands/integrations/gitlab/service/repos.py index 6cfd95b043..78018c3d9d 100644 --- a/openhands/integrations/gitlab/service/repos.py +++ b/openhands/integrations/gitlab/service/repos.py @@ -75,6 +75,7 @@ class GitLabReposMixin(GitLabMixinBase): sort: str = 'updated', order: str = 'desc', public: bool = False, + app_mode: AppMode = AppMode.OSS, ) -> list[Repository]: if public: # When public=True, query is a GitLab URL that we need to parse diff --git a/openhands/integrations/provider.py b/openhands/integrations/provider.py index 16b6e55891..064309e372 100644 --- a/openhands/integrations/provider.py +++ b/openhands/integrations/provider.py @@ -294,12 +294,13 @@ class ProviderHandler: per_page: int, sort: str, order: str, + app_mode: AppMode, ) -> 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 + query, per_page, sort, order, public, app_mode ) return self._deduplicate_repositories(user_repos) @@ -309,7 +310,7 @@ class ProviderHandler: service = self.get_service(provider) public = self._is_repository_url(query, provider) service_repos = await service.search_repositories( - query, per_page, sort, order, public + query, per_page, sort, order, public, app_mode ) all_repos.extend(service_repos) except Exception as e: diff --git a/openhands/integrations/service_types.py b/openhands/integrations/service_types.py index 9c22c660a8..cfc4839059 100644 --- a/openhands/integrations/service_types.py +++ b/openhands/integrations/service_types.py @@ -458,7 +458,13 @@ class GitService(Protocol): ... async def search_repositories( - self, query: str, per_page: int, sort: str, order: str, public: bool + self, + query: str, + per_page: int, + sort: str, + order: str, + public: bool, + app_mode: AppMode, ) -> list[Repository]: """Search for public repositories""" ... diff --git a/openhands/server/routes/git.py b/openhands/server/routes/git.py index db0f0255b4..753cf6f12f 100644 --- a/openhands/server/routes/git.py +++ b/openhands/server/routes/git.py @@ -148,7 +148,7 @@ async def search_repositories( ) try: repos: list[Repository] = await client.search_repositories( - selected_provider, query, per_page, sort, order + selected_provider, query, per_page, sort, order, server_config.app_mode ) return repos diff --git a/tests/unit/integrations/bitbucket/test_bitbucket_repos.py b/tests/unit/integrations/bitbucket/test_bitbucket_repos.py index 5d8dc630d5..dc7d01c6ac 100644 --- a/tests/unit/integrations/bitbucket/test_bitbucket_repos.py +++ b/tests/unit/integrations/bitbucket/test_bitbucket_repos.py @@ -8,6 +8,7 @@ from pydantic import SecretStr from openhands.integrations.bitbucket.bitbucket_service import BitBucketService from openhands.integrations.service_types import OwnerType, Repository from openhands.integrations.service_types import ProviderType as ServiceProviderType +from openhands.server.types import AppMode @pytest.fixture @@ -37,7 +38,12 @@ async def test_search_repositories_url_parsing_standard_url(bitbucket_service): ) as mock_get_repo: url = 'https://bitbucket.org/workspace/repo' repositories = await bitbucket_service.search_repositories( - query=url, per_page=10, sort='updated', order='desc', public=True + query=url, + per_page=10, + sort='updated', + order='desc', + public=True, + app_mode=AppMode.OSS, ) # Verify the correct workspace/repo combination was extracted and passed @@ -70,7 +76,12 @@ async def test_search_repositories_url_parsing_with_extra_path_segments( # Test complex URL with query params, fragments, and extra paths url = 'https://bitbucket.org/my-workspace/my-repo/src/feature-branch/src/main.py?at=feature-branch&fileviewer=file-view-default#lines-25' repositories = await bitbucket_service.search_repositories( - query=url, per_page=10, sort='updated', order='desc', public=True + query=url, + per_page=10, + sort='updated', + order='desc', + public=True, + app_mode=AppMode.OSS, ) # Verify the correct workspace/repo combination was extracted from complex URL @@ -87,7 +98,12 @@ async def test_search_repositories_url_parsing_invalid_url(bitbucket_service): ) as mock_get_repo: url = 'not-a-valid-url' repositories = await bitbucket_service.search_repositories( - query=url, per_page=10, sort='updated', order='desc', public=True + query=url, + per_page=10, + sort='updated', + order='desc', + public=True, + app_mode=AppMode.OSS, ) # Should return empty list for invalid URL and not call API @@ -105,7 +121,12 @@ async def test_search_repositories_url_parsing_insufficient_path_segments( ) as mock_get_repo: url = 'https://bitbucket.org/workspace' repositories = await bitbucket_service.search_repositories( - query=url, per_page=10, sort='updated', order='desc', public=True + query=url, + per_page=10, + sort='updated', + order='desc', + public=True, + app_mode=AppMode.OSS, ) # Should return empty list for insufficient path segments and not call API diff --git a/tests/unit/integrations/github/test_github_service.py b/tests/unit/integrations/github/test_github_service.py index a60423646e..83ac265ca9 100644 --- a/tests/unit/integrations/github/test_github_service.py +++ b/tests/unit/integrations/github/test_github_service.py @@ -277,7 +277,7 @@ async def test_github_search_repositories_with_organizations(): patch.object(service, 'get_user', return_value=mock_user), patch.object( service, - 'get_user_organizations', + 'get_organizations_from_installations', return_value=['All-Hands-AI', 'example-org'], ), patch.object( @@ -285,7 +285,12 @@ async def test_github_search_repositories_with_organizations(): ) as mock_request, ): repositories = await service.search_repositories( - query='openhands', per_page=10, sort='stars', order='desc', public=False + query='openhands', + per_page=10, + sort='stars', + order='desc', + public=False, + app_mode=AppMode.SAAS, ) # Verify that separate requests were made for user and each organization