diff --git a/openhands/integrations/github/github_service.py b/openhands/integrations/github/github_service.py index 11c35c514a..01c28b0fe2 100644 --- a/openhands/integrations/github/github_service.py +++ b/openhands/integrations/github/github_service.py @@ -42,6 +42,7 @@ class GitHubService( """ BASE_URL = 'https://api.github.com' + GRAPHQL_URL = 'https://api.github.com/graphql' token: SecretStr = SecretStr('') refresh = False @@ -62,6 +63,7 @@ class GitHubService( if base_domain and base_domain != 'github.com': self.BASE_URL = f'https://{base_domain}/api/v3' + self.GRAPHQL_URL = f'https://{base_domain}/api/graphql' self.external_auth_id = external_auth_id self.external_auth_token = external_auth_token diff --git a/openhands/integrations/github/service/base.py b/openhands/integrations/github/service/base.py index e7f4b74930..16d4f0dcce 100644 --- a/openhands/integrations/github/service/base.py +++ b/openhands/integrations/github/service/base.py @@ -18,9 +18,11 @@ class GitHubMixinBase(BaseGitService): """ BASE_URL: str + GRAPHQL_URL: str token: SecretStr refresh: bool external_auth_id: str | None + base_domain: str | None async def _get_github_headers(self) -> dict: """Retrieve the GH Token from settings store to construct the headers.""" @@ -86,8 +88,9 @@ class GitHubMixinBase(BaseGitService): try: async with httpx.AsyncClient() as client: github_headers = await self._get_github_headers() + response = await client.post( - f'{self.BASE_URL}/graphql', + self.GRAPHQL_URL, headers=github_headers, json={'query': query, 'variables': variables}, ) diff --git a/tests/unit/integrations/github/test_github_service.py b/tests/unit/integrations/github/test_github_service.py index 5b9b69a2e1..0248e5f396 100644 --- a/tests/unit/integrations/github/test_github_service.py +++ b/tests/unit/integrations/github/test_github_service.py @@ -340,3 +340,93 @@ async def test_github_get_user_organizations_error_handling(): # Should return empty list on error assert orgs == [] + + +@pytest.mark.asyncio +async def test_github_service_base_url_configuration(): + """Test that BASE_URL is correctly configured based on base_domain.""" + # Test default GitHub.com configuration + service = GitHubService(user_id=None, token=SecretStr('test-token')) + assert service.BASE_URL == 'https://api.github.com' + + # Test GitHub Enterprise Server configuration + service = GitHubService( + user_id=None, token=SecretStr('test-token'), base_domain='github.enterprise.com' + ) + assert service.BASE_URL == 'https://github.enterprise.com/api/v3' + + # Test that github.com base_domain doesn't change the URL + service = GitHubService( + user_id=None, token=SecretStr('test-token'), base_domain='github.com' + ) + assert service.BASE_URL == 'https://api.github.com' + + +@pytest.mark.asyncio +async def test_github_service_graphql_url_enterprise_server(): + """Test that GraphQL URL is correctly constructed for GitHub Enterprise Server.""" + # Mock httpx.AsyncClient for testing GraphQL calls + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {'data': {'viewer': {'login': 'test-user'}}} + mock_response.raise_for_status = Mock() + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + with patch('httpx.AsyncClient', return_value=mock_client): + # Test GitHub Enterprise Server + service = GitHubService( + user_id=None, + token=SecretStr('test-token'), + base_domain='github.enterprise.com', + ) + + query = 'query { viewer { login } }' + variables = {} + + await service.execute_graphql_query(query, variables) + + # Verify the GraphQL request was made to the CORRECT URL + # For GitHub Enterprise Server, it should be /api/graphql, not /api/v3/graphql + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + actual_url = call_args[0][0] # First positional argument is the URL + + # After the fix, GraphQL URL should be correctly constructed for GitHub Enterprise Server + # The URL should be /api/graphql, not /api/v3/graphql + assert actual_url == 'https://github.enterprise.com/api/graphql' + + +@pytest.mark.asyncio +async def test_github_service_graphql_url_github_com(): + """Test that GraphQL URL is correctly constructed for GitHub.com.""" + # Mock httpx.AsyncClient for testing GraphQL calls + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {'data': {'viewer': {'login': 'test-user'}}} + mock_response.raise_for_status = Mock() + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + with patch('httpx.AsyncClient', return_value=mock_client): + # Test GitHub.com (should work correctly) + service = GitHubService(user_id=None, token=SecretStr('test-token')) + + query = 'query { viewer { login } }' + variables = {} + + await service.execute_graphql_query(query, variables) + + # Verify the GraphQL request was made to the correct URL for GitHub.com + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + actual_url = call_args[0][0] # First positional argument is the URL + + # This should be correct for GitHub.com + assert actual_url == 'https://api.github.com/graphql'