From 5c8bdd364e92fe3520272577fefbf62946263fb0 Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Tue, 24 Jun 2025 21:40:58 +0200 Subject: [PATCH] [Feat]: BitBucket integration for Cloud OpenHands (#9225) Co-authored-by: chuckbutkus --- .../features/waitlist/auth-modal.tsx | 23 +++++++++++++++++++ frontend/src/hooks/use-auth-callback.ts | 5 +--- .../bitbucket/bitbucket_service.py | 11 ++++++++- openhands/integrations/provider.py | 4 ++-- openhands/integrations/utils.py | 4 ++-- openhands/server/routes/mcp.py | 4 ++-- tests/unit/test_bitbucket.py | 14 ++++------- 7 files changed, 45 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/features/waitlist/auth-modal.tsx b/frontend/src/components/features/waitlist/auth-modal.tsx index 3fbafea011..2bba95560b 100644 --- a/frontend/src/components/features/waitlist/auth-modal.tsx +++ b/frontend/src/components/features/waitlist/auth-modal.tsx @@ -7,6 +7,7 @@ import { ModalBody } from "#/components/shared/modals/modal-body"; import { BrandButton } from "../settings/brand-button"; import GitHubLogo from "#/assets/branding/github-logo.svg?react"; import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react"; +import BitbucketLogo from "#/assets/branding/bitbucket-logo.svg?react"; import { useAuthUrl } from "#/hooks/use-auth-url"; import { GetConfigResponse } from "#/api/open-hands.types"; @@ -23,6 +24,11 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) { identityProvider: "gitlab", }); + const bitbucketAuthUrl = useAuthUrl({ + appMode: appMode || null, + identityProvider: "bitbucket", + }); + const handleGitHubAuth = () => { if (githubAuthUrl) { // Always start the OIDC flow, let the backend handle TOS check @@ -37,6 +43,13 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) { } }; + const handleBitbucketAuth = () => { + if (bitbucketAuthUrl) { + // Always start the OIDC flow, let the backend handle TOS check + window.location.href = bitbucketAuthUrl; + } + }; + return ( @@ -67,6 +80,16 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) { > {t(I18nKey.GITLAB$CONNECT_TO_GITLAB)} + + } + > + {t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)} + diff --git a/frontend/src/hooks/use-auth-callback.ts b/frontend/src/hooks/use-auth-callback.ts index e38767dcbf..421163ee64 100644 --- a/frontend/src/hooks/use-auth-callback.ts +++ b/frontend/src/hooks/use-auth-callback.ts @@ -34,10 +34,7 @@ export const useAuthCallback = () => { const loginMethod = searchParams.get("login_method"); // Set the login method if it's valid - if ( - loginMethod === LoginMethod.GITHUB || - loginMethod === LoginMethod.GITLAB - ) { + if (Object.values(LoginMethod).includes(loginMethod as LoginMethod)) { setLoginMethod(loginMethod as LoginMethod); // Clean up the URL by removing the login_method parameter diff --git a/openhands/integrations/bitbucket/bitbucket_service.py b/openhands/integrations/bitbucket/bitbucket_service.py index 25a0611e2c..674c851b9d 100644 --- a/openhands/integrations/bitbucket/bitbucket_service.py +++ b/openhands/integrations/bitbucket/bitbucket_service.py @@ -1,4 +1,5 @@ import base64 +import os from typing import Any import httpx @@ -15,9 +16,10 @@ from openhands.integrations.service_types import ( User, ) from openhands.server.types import AppMode +from openhands.utils.import_utils import get_impl -class BitbucketService(BaseGitService, GitService): +class BitBucketService(BaseGitService, GitService): """Default implementation of GitService for Bitbucket integration. This is an extension point in OpenHands that allows applications to customize Bitbucket @@ -300,3 +302,10 @@ class BitbucketService(BaseGitService, GitService): # Return the URL to the pull request return data.get('links', {}).get('html', {}).get('href', '') + + +bitbucket_service_cls = os.environ.get( + 'OPENHANDS_BITBUCKET_SERVICE_CLS', + 'openhands.integrations.bitbucket.bitbucket_service.BitBucketService', +) +BitBucketServiceImpl = get_impl(BitBucketService, bitbucket_service_cls) diff --git a/openhands/integrations/provider.py b/openhands/integrations/provider.py index e35c924391..127f717ca9 100644 --- a/openhands/integrations/provider.py +++ b/openhands/integrations/provider.py @@ -15,7 +15,7 @@ from openhands.core.logger import openhands_logger as logger from openhands.events.action.action import Action from openhands.events.action.commands import CmdRunAction from openhands.events.stream import EventStream -from openhands.integrations.bitbucket.bitbucket_service import BitbucketService +from openhands.integrations.bitbucket.bitbucket_service import BitBucketServiceImpl from openhands.integrations.github.github_service import GithubServiceImpl from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl from openhands.integrations.service_types import ( @@ -110,7 +110,7 @@ class ProviderHandler: self.service_class_map: dict[ProviderType, type[GitService]] = { ProviderType.GITHUB: GithubServiceImpl, ProviderType.GITLAB: GitLabServiceImpl, - ProviderType.BITBUCKET: BitbucketService, + ProviderType.BITBUCKET: BitBucketServiceImpl, } self.external_auth_id = external_auth_id diff --git a/openhands/integrations/utils.py b/openhands/integrations/utils.py index a617747bda..0522acf2b6 100644 --- a/openhands/integrations/utils.py +++ b/openhands/integrations/utils.py @@ -1,7 +1,7 @@ from pydantic import SecretStr from openhands.core.logger import openhands_logger as logger -from openhands.integrations.bitbucket.bitbucket_service import BitbucketService +from openhands.integrations.bitbucket.bitbucket_service import BitBucketService from openhands.integrations.github.github_service import GitHubService from openhands.integrations.gitlab.gitlab_service import GitLabService from openhands.integrations.provider import ProviderType @@ -49,7 +49,7 @@ async def validate_provider_token( # Try Bitbucket last bitbucket_error = None try: - bitbucket_service = BitbucketService(token=token, base_domain=base_domain) + bitbucket_service = BitBucketService(token=token, base_domain=base_domain) await bitbucket_service.get_user() return ProviderType.BITBUCKET except Exception as e: diff --git a/openhands/server/routes/mcp.py b/openhands/server/routes/mcp.py index 813f057705..35ce7c2c08 100644 --- a/openhands/server/routes/mcp.py +++ b/openhands/server/routes/mcp.py @@ -8,7 +8,7 @@ from fastmcp.server.dependencies import get_http_request from pydantic import Field from openhands.core.logger import openhands_logger as logger -from openhands.integrations.bitbucket.bitbucket_service import BitbucketService +from openhands.integrations.bitbucket.bitbucket_service import BitBucketServiceImpl from openhands.integrations.github.github_service import GithubServiceImpl from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl from openhands.integrations.provider import ProviderToken @@ -242,7 +242,7 @@ async def create_bitbucket_pr( else ProviderToken() ) - bitbucket_service = BitbucketService( + bitbucket_service = BitBucketServiceImpl( user_id=bitbucket_token.user_id, external_auth_id=user_id, external_auth_token=access_token, diff --git a/tests/unit/test_bitbucket.py b/tests/unit/test_bitbucket.py index 3600ad5a62..26f826df50 100644 --- a/tests/unit/test_bitbucket.py +++ b/tests/unit/test_bitbucket.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from pydantic import SecretStr +from openhands.integrations.bitbucket.bitbucket_service import BitBucketService from openhands.integrations.provider import ProviderToken, ProviderType from openhands.integrations.service_types import ProviderType as ServiceProviderType from openhands.integrations.service_types import Repository @@ -18,6 +19,7 @@ from openhands.resolver.send_pull_request import send_pull_request from openhands.runtime.base import Runtime from openhands.server.routes.secrets import check_provider_tokens from openhands.server.settings import POSTProviderModel +from openhands.server.types import AppMode # BitbucketIssueHandler Tests @@ -360,7 +362,7 @@ async def test_validate_provider_token_with_bitbucket_token(): patch('openhands.integrations.utils.GitHubService') as mock_github_service, patch('openhands.integrations.utils.GitLabService') as mock_gitlab_service, patch( - 'openhands.integrations.utils.BitbucketService' + 'openhands.integrations.utils.BitBucketService' ) as mock_bitbucket_service, ): # Set up the mocks @@ -433,15 +435,9 @@ async def test_bitbucket_sort_parameter_mapping(): """ Test that the Bitbucket service correctly maps sort parameters. """ - from unittest.mock import patch - - from pydantic import SecretStr - - from openhands.integrations.bitbucket.bitbucket_service import BitbucketService - from openhands.server.types import AppMode # Create a service instance - service = BitbucketService(token=SecretStr('test-token')) + service = BitBucketService(token=SecretStr('test-token')) # Mock the _make_request method to avoid actual API calls with patch.object(service, '_make_request') as mock_request: @@ -478,7 +474,7 @@ async def test_validate_provider_token_with_empty_tokens(): patch('openhands.integrations.utils.GitHubService') as mock_github_service, patch('openhands.integrations.utils.GitLabService') as mock_gitlab_service, patch( - 'openhands.integrations.utils.BitbucketService' + 'openhands.integrations.utils.BitBucketService' ) as mock_bitbucket_service, ): # Configure mocks to raise exceptions for invalid tokens