[Feat]: BitBucket integration for Cloud OpenHands (#9225)

Co-authored-by: chuckbutkus <chuck@all-hands.dev>
This commit is contained in:
Rohit Malhotra 2025-06-24 21:40:58 +02:00 committed by GitHub
parent 0c1c570dac
commit 5c8bdd364e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 45 additions and 20 deletions

View File

@ -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 (
<ModalBackdrop>
<ModalBody className="border border-tertiary">
@ -67,6 +80,16 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
>
{t(I18nKey.GITLAB$CONNECT_TO_GITLAB)}
</BrandButton>
<BrandButton
type="button"
variant="primary"
onClick={handleBitbucketAuth}
className="w-full"
startContent={<BitbucketLogo width={20} height={20} />}
>
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
</BrandButton>
</div>
</ModalBody>
</ModalBackdrop>

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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:

View File

@ -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,

View File

@ -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