From e7934ea6e57c9097d85bcc07ac7827e301225acd Mon Sep 17 00:00:00 2001 From: Pierrick Hymbert Date: Tue, 3 Mar 2026 16:51:43 +0100 Subject: [PATCH] feat(bitbucket): supports cloud and server APIs (#11052) Co-authored-by: Ray Myers Co-authored-by: Chris Bagwell Co-authored-by: CHANGE Co-authored-by: Joe Laverty --- .../features/auth/login-content.test.tsx | 20 + .../chat/git-control-bar-repo-button.test.tsx | 4 + .../__tests__/routes/git-settings.test.tsx | 4 + .../features/auth/login-content.tsx | 31 ++ .../chat/git-control-bar-branch-button.tsx | 13 +- .../chat/git-control-bar-repo-button.tsx | 9 +- .../conversation-repo-link.tsx | 1 + .../git-provider-dropdown.tsx | 2 + .../bitbucket-dc-token-help-anchor.tsx | 27 ++ .../bitbucket-dc-token-help-input.tsx | 67 ++++ .../components/shared/git-provider-icon.tsx | 3 + frontend/src/hooks/use-auto-login.ts | 8 + frontend/src/i18n/declaration.ts | 5 + frontend/src/i18n/translation.json | 80 ++++ frontend/src/routes.ts | 1 + frontend/src/routes/git-settings.tsx | 34 ++ frontend/src/types/settings.ts | 1 + frontend/src/utils/local-storage.ts | 1 + frontend/src/utils/utils.ts | 49 ++- .../bitbucket_dc_service.py | 107 ++++++ .../bitbucket_data_center/service/__init__.py | 15 + .../bitbucket_data_center/service/base.py | 333 ++++++++++++++++ .../bitbucket_data_center/service/branches.py | 136 +++++++ .../bitbucket_data_center/service/features.py | 96 +++++ .../bitbucket_data_center/service/prs.py | 134 +++++++ .../bitbucket_data_center/service/repos.py | 203 ++++++++++ .../bitbucket_data_center/service/resolver.py | 113 ++++++ openhands/integrations/provider.py | 41 +- openhands/integrations/service_types.py | 11 + openhands/integrations/utils.py | 19 +- .../interfaces/bitbucket_data_center.py | 357 ++++++++++++++++++ openhands/resolver/issue_handler_factory.py | 26 ++ openhands/resolver/issue_resolver.py | 2 + openhands/resolver/send_pull_request.py | 30 ++ openhands/server/routes/git.py | 2 + openhands/server/routes/mcp.py | 74 ++++ skills/bitbucket_data_center.md | 41 ++ .../test_bitbucket_dc.py | 258 +++++++++++++ .../test_bitbucket_dc_branches.py | 139 +++++++ .../test_bitbucket_dc_prs.py | 138 +++++++ .../test_bitbucket_dc_repos.py | 355 +++++++++++++++++ .../test_bitbucket_dc_resolver.py | 179 +++++++++ .../test_bitbucket_dc_issue_handler.py | 357 ++++++++++++++++++ 43 files changed, 3514 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/features/settings/git-settings/bitbucket-dc-token-help-anchor.tsx create mode 100644 frontend/src/components/features/settings/git-settings/bitbucket-dc-token-help-input.tsx create mode 100644 openhands/integrations/bitbucket_data_center/bitbucket_dc_service.py create mode 100644 openhands/integrations/bitbucket_data_center/service/__init__.py create mode 100644 openhands/integrations/bitbucket_data_center/service/base.py create mode 100644 openhands/integrations/bitbucket_data_center/service/branches.py create mode 100644 openhands/integrations/bitbucket_data_center/service/features.py create mode 100644 openhands/integrations/bitbucket_data_center/service/prs.py create mode 100644 openhands/integrations/bitbucket_data_center/service/repos.py create mode 100644 openhands/integrations/bitbucket_data_center/service/resolver.py create mode 100644 openhands/resolver/interfaces/bitbucket_data_center.py create mode 100644 skills/bitbucket_data_center.md create mode 100644 tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc.py create mode 100644 tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_branches.py create mode 100644 tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_prs.py create mode 100644 tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_repos.py create mode 100644 tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_resolver.py create mode 100644 tests/unit/resolver/bitbucket_dc/test_bitbucket_dc_issue_handler.py diff --git a/frontend/__tests__/components/features/auth/login-content.test.tsx b/frontend/__tests__/components/features/auth/login-content.test.tsx index f45e89bbb7..efedb93164 100644 --- a/frontend/__tests__/components/features/auth/login-content.test.tsx +++ b/frontend/__tests__/components/features/auth/login-content.test.tsx @@ -13,6 +13,8 @@ vi.mock("#/hooks/use-auth-url", () => ({ const urls: Record = { gitlab: "https://gitlab.com/oauth/authorize", bitbucket: "https://bitbucket.org/site/oauth2/authorize", + bitbucket_data_center: + "https://bitbucket-dc.example.com/site/oauth2/authorize", }; if (config.appMode === "saas") { return urls[config.identityProvider] || null; @@ -297,6 +299,24 @@ describe("LoginContent", () => { }); }); + it("should display Bitbucket Data Center button when configured", () => { + render( + + + , + ); + + expect( + screen.getByRole("button", { + name: /BITBUCKET_DATA_CENTER\$CONNECT_TO_BITBUCKET_DATA_CENTER/i, + }), + ).toBeInTheDocument(); + }); + it("should encode state with invitation token when buildOAuthStateData provides token", async () => { const user = userEvent.setup(); const mockBuildOAuthStateData = vi.fn((baseState) => ({ diff --git a/frontend/__tests__/components/features/chat/git-control-bar-repo-button.test.tsx b/frontend/__tests__/components/features/chat/git-control-bar-repo-button.test.tsx index 01890d1b8b..5a6d15e84b 100644 --- a/frontend/__tests__/components/features/chat/git-control-bar-repo-button.test.tsx +++ b/frontend/__tests__/components/features/chat/git-control-bar-repo-button.test.tsx @@ -32,6 +32,10 @@ vi.mock("#/icons/repo-forked.svg?react", () => ({ default: () => forked, })); +vi.mock("#/hooks/query/use-settings", () => ({ + useSettings: () => ({ data: { provider_tokens_set: {} } }), +})); + // Mock constructRepositoryUrl vi.mock("#/utils/utils", async (importOriginal) => { const actual = await importOriginal(); diff --git a/frontend/__tests__/routes/git-settings.test.tsx b/frontend/__tests__/routes/git-settings.test.tsx index 4466436534..2f903aa3a2 100644 --- a/frontend/__tests__/routes/git-settings.test.tsx +++ b/frontend/__tests__/routes/git-settings.test.tsx @@ -321,6 +321,7 @@ describe("Form submission", () => { github: { token: "test-token", host: "" }, gitlab: { token: "", host: "" }, bitbucket: { token: "", host: "" }, + bitbucket_data_center: { token: "", host: "" }, azure_devops: { token: "", host: "" }, forgejo: { token: "", host: "" }, }); @@ -344,6 +345,7 @@ describe("Form submission", () => { github: { token: "", host: "" }, gitlab: { token: "test-token", host: "" }, bitbucket: { token: "", host: "" }, + bitbucket_data_center: { token: "", host: "" }, azure_devops: { token: "", host: "" }, forgejo: { token: "", host: "" }, }); @@ -367,6 +369,7 @@ describe("Form submission", () => { github: { token: "", host: "" }, gitlab: { token: "", host: "" }, bitbucket: { token: "test-token", host: "" }, + bitbucket_data_center: { token: "", host: "" }, azure_devops: { token: "", host: "" }, forgejo: { token: "", host: "" }, }); @@ -392,6 +395,7 @@ describe("Form submission", () => { github: { token: "", host: "" }, gitlab: { token: "", host: "" }, bitbucket: { token: "", host: "" }, + bitbucket_data_center: { token: "", host: "" }, azure_devops: { token: "test-token", host: "" }, forgejo: { token: "", host: "" }, }); diff --git a/frontend/src/components/features/auth/login-content.tsx b/frontend/src/components/features/auth/login-content.tsx index 47db28f245..30da67c301 100644 --- a/frontend/src/components/features/auth/login-content.tsx +++ b/frontend/src/components/features/auth/login-content.tsx @@ -59,6 +59,12 @@ export function LoginContent({ authUrl, }); + const bitbucketDataCenterAuthUrl = useAuthUrl({ + appMode: appMode || null, + identityProvider: "bitbucket_data_center", + authUrl, + }); + const handleAuthRedirect = async ( redirectUrl: string, provider: Provider, @@ -115,6 +121,12 @@ export function LoginContent({ } }; + const handleBitbucketDataCenterAuth = () => { + if (bitbucketDataCenterAuthUrl) { + handleAuthRedirect(bitbucketDataCenterAuthUrl, "bitbucket_data_center"); + } + }; + const showGithub = providersConfigured && providersConfigured.length > 0 && @@ -127,6 +139,10 @@ export function LoginContent({ providersConfigured && providersConfigured.length > 0 && providersConfigured.includes("bitbucket"); + const showBitbucketDataCenter = + providersConfigured && + providersConfigured.length > 0 && + providersConfigured.includes("bitbucket_data_center"); const noProvidersConfigured = !providersConfigured || providersConfigured.length === 0; @@ -230,6 +246,21 @@ export function LoginContent({ )} + + {showBitbucketDataCenter && ( + + )} )} diff --git a/frontend/src/components/features/chat/git-control-bar-branch-button.tsx b/frontend/src/components/features/chat/git-control-bar-branch-button.tsx index bd256fac0d..e873da3735 100644 --- a/frontend/src/components/features/chat/git-control-bar-branch-button.tsx +++ b/frontend/src/components/features/chat/git-control-bar-branch-button.tsx @@ -4,6 +4,7 @@ import { constructBranchUrl, cn } from "#/utils/utils"; import { Provider } from "#/types/settings"; import { I18nKey } from "#/i18n/declaration"; import { GitExternalLinkIcon } from "./git-external-link-icon"; +import { useSettings } from "#/hooks/query/use-settings"; interface GitControlBarBranchButtonProps { selectedBranch: string | null | undefined; @@ -17,10 +18,20 @@ export function GitControlBarBranchButton({ gitProvider, }: GitControlBarBranchButtonProps) { const { t } = useTranslation(); + const { data: settings } = useSettings(); + + const providerHost = gitProvider + ? settings?.provider_tokens_set[gitProvider] + : null; const hasBranch = selectedBranch && selectedRepository && gitProvider; const branchUrl = hasBranch - ? constructBranchUrl(gitProvider, selectedRepository, selectedBranch) + ? constructBranchUrl( + gitProvider, + selectedRepository, + selectedBranch, + providerHost, + ) : undefined; const buttonText = hasBranch ? selectedBranch : t(I18nKey.COMMON$NO_BRANCH); diff --git a/frontend/src/components/features/chat/git-control-bar-repo-button.tsx b/frontend/src/components/features/chat/git-control-bar-repo-button.tsx index bd6159c11b..fcc2f7fb5f 100644 --- a/frontend/src/components/features/chat/git-control-bar-repo-button.tsx +++ b/frontend/src/components/features/chat/git-control-bar-repo-button.tsx @@ -5,6 +5,7 @@ import { I18nKey } from "#/i18n/declaration"; import { GitProviderIcon } from "#/components/shared/git-provider-icon"; import { GitExternalLinkIcon } from "./git-external-link-icon"; import RepoForkedIcon from "#/icons/repo-forked.svg?react"; +import { useSettings } from "#/hooks/query/use-settings"; interface GitControlBarRepoButtonProps { selectedRepository: string | null | undefined; @@ -20,11 +21,17 @@ export function GitControlBarRepoButton({ disabled, }: GitControlBarRepoButtonProps) { const { t } = useTranslation(); + const { data: settings } = useSettings(); const hasRepository = selectedRepository && gitProvider; + // Get the host for the current provider from settings + const providerHost = gitProvider + ? settings?.provider_tokens_set[gitProvider] + : null; + const repositoryUrl = hasRepository - ? constructRepositoryUrl(gitProvider, selectedRepository) + ? constructRepositoryUrl(gitProvider, selectedRepository, providerHost) : undefined; const buttonText = hasRepository diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-repo-link.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-repo-link.tsx index 27c50bbcb4..5e1c15099a 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card/conversation-repo-link.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-repo-link.tsx @@ -11,6 +11,7 @@ interface ConversationRepoLinkProps { const providerIcon: Partial> = { bitbucket: FaBitbucket, + bitbucket_data_center: FaBitbucket, github: FaGithub, gitlab: FaGitlab, enterprise_sso: FaUserShield, diff --git a/frontend/src/components/features/home/git-provider-dropdown/git-provider-dropdown.tsx b/frontend/src/components/features/home/git-provider-dropdown/git-provider-dropdown.tsx index 53696b1ecb..c119a4a861 100644 --- a/frontend/src/components/features/home/git-provider-dropdown/git-provider-dropdown.tsx +++ b/frontend/src/components/features/home/git-provider-dropdown/git-provider-dropdown.tsx @@ -51,6 +51,8 @@ export function GitProviderDropdown({ return "GitLab"; case "bitbucket": return "Bitbucket"; + case "bitbucket_data_center": + return "Bitbucket Data Center"; case "azure_devops": return "Azure DevOps"; case "enterprise_sso": diff --git a/frontend/src/components/features/settings/git-settings/bitbucket-dc-token-help-anchor.tsx b/frontend/src/components/features/settings/git-settings/bitbucket-dc-token-help-anchor.tsx new file mode 100644 index 0000000000..1d74b94b58 --- /dev/null +++ b/frontend/src/components/features/settings/git-settings/bitbucket-dc-token-help-anchor.tsx @@ -0,0 +1,27 @@ +import { Trans, useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; + +export function BitbucketDCTokenHelpAnchor() { + const { t } = useTranslation(); + + return ( +

+ , + ]} + /> +

+ ); +} diff --git a/frontend/src/components/features/settings/git-settings/bitbucket-dc-token-help-input.tsx b/frontend/src/components/features/settings/git-settings/bitbucket-dc-token-help-input.tsx new file mode 100644 index 0000000000..3cb87c1b2f --- /dev/null +++ b/frontend/src/components/features/settings/git-settings/bitbucket-dc-token-help-input.tsx @@ -0,0 +1,67 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { SettingsInput } from "../settings-input"; +import { BitbucketDCTokenHelpAnchor } from "./bitbucket-dc-token-help-anchor"; +import { KeyStatusIcon } from "../key-status-icon"; +import { cn } from "#/utils/utils"; + +interface BitbucketDCTokenInputProps { + onChange: (value: string) => void; + onBitbucketDCHostChange: (value: string) => void; + isBitbucketDCTokenSet: boolean; + name: string; + bitbucketDCHostSet: string | null | undefined; + className?: string; +} + +export function BitbucketDCTokenInput({ + onChange, + onBitbucketDCHostChange, + isBitbucketDCTokenSet, + name, + bitbucketDCHostSet, + className, +}: BitbucketDCTokenInputProps) { + const { t } = useTranslation(); + + return ( +
+ " : "username:token"} + startContent={ + isBitbucketDCTokenSet && ( + + ) + } + /> + + {})} + name="bitbucket-dc-host-input" + testId="bitbucket-dc-host-input" + label={t(I18nKey.BITBUCKET_DATA_CENTER$HOST_LABEL)} + type="text" + className="w-full max-w-[680px]" + placeholder="bitbucket.your-company.com" + defaultValue={bitbucketDCHostSet || undefined} + startContent={ + bitbucketDCHostSet && + bitbucketDCHostSet.trim() !== "" && ( + + ) + } + /> + + +
+ ); +} diff --git a/frontend/src/components/shared/git-provider-icon.tsx b/frontend/src/components/shared/git-provider-icon.tsx index efa9481ad1..ee8c869595 100644 --- a/frontend/src/components/shared/git-provider-icon.tsx +++ b/frontend/src/components/shared/git-provider-icon.tsx @@ -18,6 +18,9 @@ export function GitProviderIcon({ {gitProvider === "bitbucket" && ( )} + {gitProvider === "bitbucket_data_center" && ( + + )} {gitProvider === "azure_devops" && ( )} diff --git a/frontend/src/hooks/use-auto-login.ts b/frontend/src/hooks/use-auto-login.ts index 1d9f766b0e..8d4cd2dee7 100644 --- a/frontend/src/hooks/use-auto-login.ts +++ b/frontend/src/hooks/use-auto-login.ts @@ -34,6 +34,12 @@ export const useAutoLogin = () => { authUrl: config?.auth_url, }); + const bitbucketDataCenterUrl = useAuthUrl({ + appMode: config?.app_mode || null, + identityProvider: "bitbucket_data_center", + authUrl: config?.auth_url, + }); + const enterpriseSsoUrl = useAuthUrl({ appMode: config?.app_mode || null, identityProvider: "enterprise_sso", @@ -69,6 +75,8 @@ export const useAutoLogin = () => { authUrl = gitlabAuthUrl; } else if (loginMethod === LoginMethod.BITBUCKET) { authUrl = bitbucketAuthUrl; + } else if (loginMethod === LoginMethod.BITBUCKET_DATA_CENTER) { + authUrl = bitbucketDataCenterUrl; } else if (loginMethod === LoginMethod.ENTERPRISE_SSO) { authUrl = enterpriseSsoUrl; } diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 062112460b..a42047bb84 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -610,6 +610,7 @@ export enum I18nKey { GITHUB$CONNECT_TO_GITHUB = "GITHUB$CONNECT_TO_GITHUB", GITLAB$CONNECT_TO_GITLAB = "GITLAB$CONNECT_TO_GITLAB", BITBUCKET$CONNECT_TO_BITBUCKET = "BITBUCKET$CONNECT_TO_BITBUCKET", + BITBUCKET_DATA_CENTER$CONNECT_TO_BITBUCKET_DATA_CENTER = "BITBUCKET_DATA_CENTER$CONNECT_TO_BITBUCKET_DATA_CENTER", ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO = "ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO", AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER = "AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER", WAITLIST$JOIN_WAITLIST = "WAITLIST$JOIN_WAITLIST", @@ -648,6 +649,9 @@ export enum I18nKey { BITBUCKET$TOKEN_HELP_TEXT = "BITBUCKET$TOKEN_HELP_TEXT", BITBUCKET$TOKEN_LINK_TEXT = "BITBUCKET$TOKEN_LINK_TEXT", BITBUCKET$INSTRUCTIONS_LINK_TEXT = "BITBUCKET$INSTRUCTIONS_LINK_TEXT", + BITBUCKET_DATA_CENTER$TOKEN_LABEL = "BITBUCKET_DATA_CENTER$TOKEN_LABEL", + BITBUCKET_DATA_CENTER$HOST_LABEL = "BITBUCKET_DATA_CENTER$HOST_LABEL", + BITBUCKET_DATA_CENTER$TOKEN_HELP_TEXT = "BITBUCKET_DATA_CENTER$TOKEN_HELP_TEXT", GITLAB$OR_SEE = "GITLAB$OR_SEE", AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_STOPPED = "AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_STOPPED", AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_ERROR = "AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_ERROR", @@ -732,6 +736,7 @@ export enum I18nKey { PAYMENT$SPECIFY_AMOUNT_USD = "PAYMENT$SPECIFY_AMOUNT_USD", GIT$BITBUCKET_TOKEN_HELP_LINK = "GIT$BITBUCKET_TOKEN_HELP_LINK", GIT$BITBUCKET_TOKEN_SEE_MORE_LINK = "GIT$BITBUCKET_TOKEN_SEE_MORE_LINK", + GIT$BITBUCKET_DC_TOKEN_HELP_LINK = "GIT$BITBUCKET_DC_TOKEN_HELP_LINK", GIT$GITHUB_TOKEN_HELP_LINK = "GIT$GITHUB_TOKEN_HELP_LINK", GIT$GITHUB_TOKEN_SEE_MORE_LINK = "GIT$GITHUB_TOKEN_SEE_MORE_LINK", GIT$GITLAB_TOKEN_HELP_LINK = "GIT$GITLAB_TOKEN_HELP_LINK", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index b0c4a0f5ae..868eee4a94 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -9761,6 +9761,22 @@ "tr": "Bitbucket'a bağlan", "uk": "Увійти за допомогою Bitbucket" }, + "BITBUCKET_DATA_CENTER$CONNECT_TO_BITBUCKET_DATA_CENTER": { + "en": "Log in with Bitbucket Data Center", + "ja": "Bitbucket Data Centerに接続", + "zh-CN": "连接到Bitbucket Data Center", + "zh-TW": "連接到Bitbucket Data Center", + "ko-KR": "Bitbucket Data Center에 연결", + "de": "Mit Bitbucket Data Center verbinden", + "no": "Koble til Bitbucket Data Center", + "it": "Connetti a Bitbucket Data Center", + "pt": "Conectar ao Bitbucket Data Center", + "es": "Conectar a Bitbucket Data Center", + "ar": "الاتصال بـ Bitbucket Data Center", + "fr": "Se connecter à Bitbucket Data Center", + "tr": "Bitbucket Data Center'a bağlan", + "uk": "Увійти за допомогою Bitbucket Data Center" + }, "ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO": { "en": "Login with Enterprise SSO", "ja": "エンタープライズSSOでログイン", @@ -10368,6 +10384,54 @@ "tr": "talimatlar için buraya tıklayın", "de": "klicken Sie hier für Anweisungen", "uk": "натисніть тут, щоб отримати інструкції" + }, + "BITBUCKET_DATA_CENTER$TOKEN_LABEL": { + "en": "Bitbucket Data Center Token", + "ja": "Bitbucket Data Centerトークン", + "zh-CN": "Bitbucket Data Center令牌", + "zh-TW": "Bitbucket Data Center權杖", + "ko-KR": "Bitbucket Data Center 토큰", + "no": "Bitbucket Data Center-token", + "ar": "رمز Bitbucket Data Center", + "de": "Bitbucket Data Center-Token", + "fr": "Jeton Bitbucket Data Center", + "it": "Token Bitbucket Data Center", + "pt": "Token do Bitbucket Data Center", + "es": "Token de Bitbucket Data Center", + "tr": "Bitbucket Data Center Token", + "uk": "Токен Bitbucket Data Center" + }, + "BITBUCKET_DATA_CENTER$HOST_LABEL": { + "en": "Bitbucket Data Center Host", + "ja": "Bitbucket Data Centerホスト", + "zh-CN": "Bitbucket Data Center主机", + "zh-TW": "Bitbucket Data Center主機", + "ko-KR": "Bitbucket Data Center 호스트", + "no": "Bitbucket Data Center-vert", + "ar": "مضيف Bitbucket Data Center", + "de": "Bitbucket Data Center-Host", + "fr": "Hôte Bitbucket Data Center", + "it": "Host Bitbucket Data Center", + "pt": "Host do Bitbucket Data Center", + "es": "Host de Bitbucket Data Center", + "tr": "Bitbucket Data Center Sunucu", + "uk": "Хост Bitbucket Data Center" + }, + "BITBUCKET_DATA_CENTER$TOKEN_HELP_TEXT": { + "en": "Create an <0>HTTP access token in your Bitbucket Data Center instance with repository read/write and pull request read/write permissions. For personal access tokens, use the format 'username:token'. For project tokens, use the format 'x-token-auth:your-token'.", + "ja": "リポジトリの読み取り/書き込みおよびプルリクエストの読み取り/書き込み権限を持つBitbucket Data Centerインスタンスで<0>HTTPアクセストークンを作成してください。個人アクセストークンの場合は、'username:token'の形式を使用してください。プロジェクトトークンの場合は、'x-token-auth:your-token'の形式を使用してください。", + "zh-CN": "在您的Bitbucket Data Center实例中创建一个具有仓库读写和拉取请求读写权限的<0>HTTP访问令牌。对于个人访问令牌,请使用格式'username:token'。对于项目令牌,请使用格式'x-token-auth:your-token'。", + "zh-TW": "在您的Bitbucket Data Center實例中建立一個具有儲存庫讀寫和拉取請求讀寫權限的<0>HTTP存取權杖。對於個人存取權杖,請使用格式'username:token'。對於專案權杖,請使用格式'x-token-auth:your-token'。", + "ko-KR": "Bitbucket Data Center 인스턴스에서 저장소 읽기/쓰기 및 풀 리퀘스트 읽기/쓰기 권한이 있는 <0>HTTP 액세스 토큰을 생성하세요. 개인 액세스 토큰의 경우 'username:token' 형식을 사용하세요. 프로젝트 토큰의 경우 'x-token-auth:your-token' 형식을 사용하세요.", + "no": "Opprett et <0>HTTP-tilgangstoken i din Bitbucket Data Center-instans med lese-/skrivetilgang til repositorier og pull-forespørsler. For personlige tilgangstokener, bruk formatet 'username:token'. For prosjekttokener, bruk formatet 'x-token-auth:your-token'.", + "ar": "أنشئ <0>رمز وصول HTTP في مثيل Bitbucket Data Center الخاص بك مع أذونات قراءة/كتابة للمستودع وقراءة/كتابة لطلبات السحب. لرموز الوصول الشخصية، استخدم التنسيق 'username:token'. لرموز المشروع، استخدم التنسيق 'x-token-auth:your-token'.", + "de": "Erstellen Sie ein <0>HTTP-Zugriffstoken in Ihrer Bitbucket Data Center-Instanz mit Lese-/Schreibberechtigungen für Repositories und Pull Requests. Geben Sie für persönliche Zugriffstoken das Format 'username:token' ein. Geben Sie für Projekt-Token das Format 'x-token-auth:your-token' ein.", + "fr": "Créez un <0>jeton d'accès HTTP dans votre instance Bitbucket Data Center avec des permissions de lecture/écriture sur les dépôts et les pull requests. Pour les jetons d'accès personnels, utilisez le format 'username:token'. Pour les jetons de projet, utilisez le format 'x-token-auth:your-token'.", + "it": "Crea un <0>token di accesso HTTP nella tua istanza Bitbucket Data Center con permessi di lettura/scrittura per repository e pull request. Per i token di accesso personali, usa il formato 'username:token'. Per i token di progetto, usa il formato 'x-token-auth:your-token'.", + "pt": "Crie um <0>token de acesso HTTP na sua instância do Bitbucket Data Center com permissões de leitura/gravação de repositório e pull request. Para tokens de acesso pessoal, use o formato 'username:token'. Para tokens de projeto, use o formato 'x-token-auth:your-token'.", + "es": "Cree un <0>token de acceso HTTP en su instancia de Bitbucket Data Center con permisos de lectura/escritura de repositorio y pull request. Para los tokens de acceso personal, use el formato 'username:token'. Para los tokens de proyecto, use el formato 'x-token-auth:your-token'.", + "tr": "Bitbucket Data Center örneğinizde depo okuma/yazma ve pull request okuma/yazma izinlerine sahip bir <0>HTTP erişim jetonu oluşturun. Kişisel erişim jetonları için 'username:token' biçimini kullanın. Proje jetonları için 'x-token-auth:your-token' biçimini kullanın.", + "uk": "Створіть <0>HTTP-токен доступу у вашому екземплярі Bitbucket Data Center з правами читання/запису репозиторію та pull request. Для особистих токенів доступу використовуйте формат 'username:token'. Для токенів проекту використовуйте формат 'x-token-auth:your-token'." }, "GITLAB$OR_SEE": { "en": "or see the", @@ -11715,6 +11779,22 @@ "de": "Bitbucket-Token mehr sehen Link", "uk": "Посилання для перегляду більше про токен Bitbucket" }, + "GIT$BITBUCKET_DC_TOKEN_HELP_LINK": { + "en": "Bitbucket Data Center HTTP access token docs", + "ja": "Bitbucket Data Center HTTPアクセストークンドキュメント", + "zh-CN": "Bitbucket Data Center HTTP访问令牌文档", + "zh-TW": "Bitbucket Data Center HTTP存取權杖文件", + "ko-KR": "Bitbucket Data Center HTTP 액세스 토큰 문서", + "no": "Bitbucket Data Center HTTP-tilgangstoken-dokumentasjon", + "ar": "وثائق رمز وصول HTTP لـ Bitbucket Data Center", + "de": "Bitbucket Data Center HTTP-Zugriffstoken-Dokumentation", + "fr": "Documentation du jeton d'accès HTTP Bitbucket Data Center", + "it": "Documentazione token di accesso HTTP Bitbucket Data Center", + "pt": "Documentação do token de acesso HTTP do Bitbucket Data Center", + "es": "Documentación del token de acceso HTTP de Bitbucket Data Center", + "tr": "Bitbucket Data Center HTTP erişim jetonu belgeleri", + "uk": "Документація HTTP-токена доступу Bitbucket Data Center" + }, "GIT$GITHUB_TOKEN_HELP_LINK": { "en": "GitHub token help link", "ja": "GitHubトークンヘルプリンク", diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 3c884347d3..b50091dc3c 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -22,6 +22,7 @@ export default [ route("api-keys", "routes/api-keys.tsx"), ]), route("conversations/:conversationId", "routes/conversation.tsx"), + route("microagent-management", "routes/microagent-management.tsx"), route("oauth/device/verify", "routes/device-verify.tsx"), ]), // Shared routes that don't require authentication diff --git a/frontend/src/routes/git-settings.tsx b/frontend/src/routes/git-settings.tsx index ad692f301a..1b07e081dc 100644 --- a/frontend/src/routes/git-settings.tsx +++ b/frontend/src/routes/git-settings.tsx @@ -8,6 +8,7 @@ import { GitHubTokenInput } from "#/components/features/settings/git-settings/gi import { GitLabTokenInput } from "#/components/features/settings/git-settings/gitlab-token-input"; import { GitLabWebhookManager } from "#/components/features/settings/git-settings/gitlab-webhook-manager"; import { BitbucketTokenInput } from "#/components/features/settings/git-settings/bitbucket-token-input"; +import { BitbucketDCTokenInput } from "#/components/features/settings/git-settings/bitbucket-dc-token-help-input"; import { AzureDevOpsTokenInput } from "#/components/features/settings/git-settings/azure-devops-token-input"; import { ForgejoTokenInput } from "#/components/features/settings/git-settings/forgejo-token-input"; import { ConfigureGitHubRepositoriesAnchor } from "#/components/features/settings/git-settings/configure-github-repositories-anchor"; @@ -42,6 +43,8 @@ function GitSettingsScreen() { React.useState(false); const [bitbucketTokenInputHasValue, setBitbucketTokenInputHasValue] = React.useState(false); + const [bitbucketDCTokenInputHasValue, setBitbucketDCTokenInputHasValue] = + React.useState(false); const [azureDevOpsTokenInputHasValue, setAzureDevOpsTokenInputHasValue] = React.useState(false); const [forgejoTokenInputHasValue, setForgejoTokenInputHasValue] = @@ -53,6 +56,8 @@ function GitSettingsScreen() { React.useState(false); const [bitbucketHostInputHasValue, setBitbucketHostInputHasValue] = React.useState(false); + const [bitbucketDCHostInputHasValue, setBitbucketDCHostInputHasValue] = + React.useState(false); const [azureDevOpsHostInputHasValue, setAzureDevOpsHostInputHasValue] = React.useState(false); const [forgejoHostInputHasValue, setForgejoHostInputHasValue] = @@ -61,6 +66,8 @@ function GitSettingsScreen() { const existingGithubHost = settings?.provider_tokens_set.github; const existingGitlabHost = settings?.provider_tokens_set.gitlab; const existingBitbucketHost = settings?.provider_tokens_set.bitbucket; + const existingBitbucketDCHost = + settings?.provider_tokens_set.bitbucket_data_center; const existingAzureDevOpsHost = settings?.provider_tokens_set.azure_devops; const existingForgejoHost = settings?.provider_tokens_set.forgejo; @@ -68,6 +75,7 @@ function GitSettingsScreen() { const isGitHubTokenSet = providers.includes("github"); const isGitLabTokenSet = providers.includes("gitlab"); const isBitbucketTokenSet = providers.includes("bitbucket"); + const isBitbucketDCTokenSet = providers.includes("bitbucket_data_center"); const isAzureDevOpsTokenSet = providers.includes("azure_devops"); const isForgejoTokenSet = providers.includes("forgejo"); @@ -89,6 +97,9 @@ function GitSettingsScreen() { const bitbucketToken = ( formData.get("bitbucket-token-input")?.toString() || "" ).trim(); + const bitbucketDCToken = ( + formData.get("bitbucket-dc-token-input")?.toString() || "" + ).trim(); const azureDevOpsToken = ( formData.get("azure-devops-token-input")?.toString() || "" ).trim(); @@ -104,6 +115,9 @@ function GitSettingsScreen() { const bitbucketHost = ( formData.get("bitbucket-host-input")?.toString() || "" ).trim(); + const bitbucketDCHost = ( + formData.get("bitbucket-dc-host-input")?.toString() || "" + ).trim(); const azureDevOpsHost = ( formData.get("azure-devops-host-input")?.toString() || "" ).trim(); @@ -116,6 +130,7 @@ function GitSettingsScreen() { github: { token: githubToken, host: githubHost }, gitlab: { token: gitlabToken, host: gitlabHost }, bitbucket: { token: bitbucketToken, host: bitbucketHost }, + bitbucket_data_center: { token: bitbucketDCToken, host: bitbucketDCHost }, azure_devops: { token: azureDevOpsToken, host: azureDevOpsHost }, forgejo: { token: forgejoToken, host: forgejoHost }, }; @@ -136,11 +151,13 @@ function GitSettingsScreen() { setGithubTokenInputHasValue(false); setGitlabTokenInputHasValue(false); setBitbucketTokenInputHasValue(false); + setBitbucketDCTokenInputHasValue(false); setAzureDevOpsTokenInputHasValue(false); setForgejoTokenInputHasValue(false); setGithubHostInputHasValue(false); setGitlabHostInputHasValue(false); setBitbucketHostInputHasValue(false); + setBitbucketDCHostInputHasValue(false); setAzureDevOpsHostInputHasValue(false); setForgejoHostInputHasValue(false); }, @@ -152,11 +169,13 @@ function GitSettingsScreen() { !githubTokenInputHasValue && !gitlabTokenInputHasValue && !bitbucketTokenInputHasValue && + !bitbucketDCTokenInputHasValue && !azureDevOpsTokenInputHasValue && !forgejoTokenInputHasValue && !githubHostInputHasValue && !gitlabHostInputHasValue && !bitbucketHostInputHasValue && + !bitbucketDCHostInputHasValue && !azureDevOpsHostInputHasValue && !forgejoHostInputHasValue; const shouldRenderExternalConfigureButtons = @@ -276,6 +295,20 @@ function GitSettingsScreen() { /> )} + {!isSaas && ( + { + setBitbucketDCTokenInputHasValue(!!value); + }} + onBitbucketDCHostChange={(value) => { + setBitbucketDCHostInputHasValue(!!value); + }} + bitbucketDCHostSet={existingBitbucketDCHost} + /> + )} + {!isSaas && ( { +export const getGitProviderBaseUrl = ( + gitProvider: Provider, + host?: string | null, +): string => { + // If custom host provided, use it (with https:// prefix if needed) + if (host && host.trim() !== "") { + return host.startsWith("http") ? host : `https://${host}`; + } + + // Fall back to defaults switch (gitProvider) { case "github": return "https://github.com"; @@ -249,6 +259,7 @@ export const getGitProviderBaseUrl = (gitProvider: Provider): string => { export const getProviderName = (gitProvider: Provider) => { if (gitProvider === "gitlab") return "GitLab"; if (gitProvider === "bitbucket") return "Bitbucket"; + if (gitProvider === "bitbucket_data_center") return "Bitbucket Data Center"; if (gitProvider === "azure_devops") return "Azure DevOps"; if (gitProvider === "forgejo") return "Forgejo"; return "GitHub"; @@ -280,13 +291,15 @@ export const getPRShort = (isGitLab: boolean) => (isGitLab ? "MR" : "PR"); * constructPullRequestUrl(123, "github", "owner/repo") // "https://github.com/owner/repo/pull/123" * constructPullRequestUrl(456, "gitlab", "owner/repo") // "https://gitlab.com/owner/repo/-/merge_requests/456" * constructPullRequestUrl(789, "bitbucket", "owner/repo") // "https://bitbucket.org/owner/repo/pull-requests/789" + * constructPullRequestUrl(789, "bitbucket", "PROJECT/repo", "server.com") // "https://server.com/projects/PROJECT/repos/repo/pull-requests/789" */ export const constructPullRequestUrl = ( prNumber: number, provider: Provider, repositoryName: string, + host?: string | null, ): string => { - const baseUrl = getGitProviderBaseUrl(provider); + const baseUrl = getGitProviderBaseUrl(provider, host); switch (provider) { case "github": @@ -297,6 +310,10 @@ export const constructPullRequestUrl = ( return `${baseUrl}/${repositoryName}/-/merge_requests/${prNumber}`; case "bitbucket": return `${baseUrl}/${repositoryName}/pull-requests/${prNumber}`; + case "bitbucket_data_center": { + const [project, repo] = repositoryName.split("/"); + return `${baseUrl}/projects/${project}/repos/${repo}/pull-requests/${prNumber}`; + } case "azure_devops": { // Azure DevOps format: org/project/repo const parts = repositoryName.split("/"); @@ -330,8 +347,9 @@ export const constructMicroagentUrl = ( gitProvider: Provider, repositoryName: string, microagentPath: string, + host?: string | null, ): string => { - const baseUrl = getGitProviderBaseUrl(gitProvider); + const baseUrl = getGitProviderBaseUrl(gitProvider, host); switch (gitProvider) { case "github": @@ -342,6 +360,10 @@ export const constructMicroagentUrl = ( return `${baseUrl}/${repositoryName}/-/blob/main/${microagentPath}`; case "bitbucket": return `${baseUrl}/${repositoryName}/src/main/${microagentPath}`; + case "bitbucket_data_center": { + const [project, repo] = repositoryName.split("/"); + return `${baseUrl}/projects/${project}/repos/${repo}/browse/${microagentPath}?at=refs/heads/main`; + } case "azure_devops": { // Azure DevOps format: org/project/repo const parts = repositoryName.split("/"); @@ -389,8 +411,13 @@ export const extractRepositoryInfo = ( export const constructRepositoryUrl = ( provider: Provider, repositoryName: string, + host?: string | null, ): string => { - const baseUrl = getGitProviderBaseUrl(provider); + const baseUrl = getGitProviderBaseUrl(provider, host); + if (provider === "bitbucket_data_center") { + const [project, repo] = repositoryName.split("/"); + return `${baseUrl}/projects/${project}/repos/${repo}`; + } return `${baseUrl}/${repositoryName}`; }; @@ -399,19 +426,22 @@ export const constructRepositoryUrl = ( * @param provider The git provider * @param repositoryName The repository name in format "owner/repo" * @param branchName The branch name + * @param host Optional custom host for self-hosted instances * @returns The branch URL * * @example * constructBranchUrl("github", "owner/repo", "main") // "https://github.com/owner/repo/tree/main" * constructBranchUrl("gitlab", "owner/repo", "develop") // "https://gitlab.com/owner/repo/-/tree/develop" * constructBranchUrl("bitbucket", "owner/repo", "feature") // "https://bitbucket.org/owner/repo/src/feature" + * constructBranchUrl("bitbucket", "PROJECT/repo", "feature", "server.com") // "https://server.com/projects/PROJECT/repos/repo/browse?at=refs/heads/feature" */ export const constructBranchUrl = ( provider: Provider, repositoryName: string, branchName: string, + host?: string | null, ): string => { - const baseUrl = getGitProviderBaseUrl(provider); + const baseUrl = getGitProviderBaseUrl(provider, host); switch (provider) { case "github": @@ -422,6 +452,15 @@ export const constructBranchUrl = ( return `${baseUrl}/${repositoryName}/-/tree/${branchName}`; case "bitbucket": return `${baseUrl}/${repositoryName}/src/${branchName}`; + case "bitbucket_data_center": { + // Bitbucket Server format: /projects/{PROJECT}/repos/{repo}/browse?at=refs/heads/{branch} + const parts = repositoryName.split("/"); + if (parts.length >= 2) { + const [project, repo] = parts; + return `${baseUrl}/projects/${project}/repos/${repo}/browse?at=refs/heads/${branchName}`; + } + return ""; + } case "azure_devops": { // Azure DevOps format: org/project/repo const parts = repositoryName.split("/"); diff --git a/openhands/integrations/bitbucket_data_center/bitbucket_dc_service.py b/openhands/integrations/bitbucket_data_center/bitbucket_dc_service.py new file mode 100644 index 0000000000..890540777d --- /dev/null +++ b/openhands/integrations/bitbucket_data_center/bitbucket_dc_service.py @@ -0,0 +1,107 @@ +import os + +from pydantic import SecretStr + +from openhands.integrations.bitbucket_data_center.service import ( + BitbucketDCBranchesMixin, + BitbucketDCFeaturesMixin, + BitbucketDCPRsMixin, + BitbucketDCReposMixin, + BitbucketDCResolverMixin, +) +from openhands.integrations.service_types import ( + GitService, + InstallationsService, + ProviderType, +) +from openhands.utils.import_utils import get_impl + + +class BitbucketDCService( + BitbucketDCResolverMixin, + BitbucketDCBranchesMixin, + BitbucketDCFeaturesMixin, + BitbucketDCPRsMixin, + BitbucketDCReposMixin, + GitService, + InstallationsService, +): + """Default implementation of GitService for Bitbucket data center integration. + + This is an extension point in OpenHands that allows applications to customize Bitbucket data center + integration behavior. Applications can substitute their own implementation by: + 1. Creating a class that inherits from GitService + 2. Implementing all required methods + 3. Setting server_config.bitbucket_service_class to the fully qualified name of the class + + The class is instantiated via get_impl() in openhands.server.shared.py. + """ + + def __init__( + self, + user_id: str | None = None, + external_auth_id: str | None = None, + external_auth_token: SecretStr | None = None, + token: SecretStr | None = None, + external_token_manager: bool = False, + base_domain: str | None = None, + ) -> None: + self.user_id = user_id + self.external_token_manager = external_token_manager + self.external_auth_id = external_auth_id + self.external_auth_token = external_auth_token + self.base_domain = base_domain + self.BASE_URL = f'https://{base_domain}/rest/api/1.0' if base_domain else '' + + if token: + token_val = token.get_secret_value() + if ':' not in token_val: + token = SecretStr(f'x-token-auth:{token_val}') + self.token = token + + # Derive user_id from token when not explicitly provided. + if not user_id and token: + token_val = token.get_secret_value() + if not token_val.startswith('x-token-auth:'): + user_id = token_val.split(':', 1)[0] + + self.user_id = user_id + + @property + def provider(self) -> str: + return ProviderType.BITBUCKET_DATA_CENTER.value + + +bitbucket_dc_service_cls = os.environ.get( + 'OPENHANDS_BITBUCKET_DATA_CENTER_SERVICE_CLS', + 'openhands.integrations.bitbucket_data_center.bitbucket_dc_service.BitbucketDCService', +) + +# Lazy loading to avoid circular imports +_bitbucket_dc_service_impl = None + + +def get_bitbucket_dc_service_impl(): + """Get the BitBucket data center service implementation with lazy loading.""" + global _bitbucket_dc_service_impl + if _bitbucket_dc_service_impl is None: + _bitbucket_dc_service_impl = get_impl( + BitbucketDCService, bitbucket_dc_service_cls + ) + return _bitbucket_dc_service_impl + + +# For backward compatibility, provide the implementation as a property +class _BitbucketDCServiceImplProxy: + """Proxy class to provide lazy loading for BitbucketDCServiceImpl.""" + + def __getattr__(self, name): + impl = get_bitbucket_dc_service_impl() + return getattr(impl, name) + + def __call__(self, *args, **kwargs): + impl = get_bitbucket_dc_service_impl() + return impl(*args, **kwargs) + + +BitbucketDCServiceImpl: type[BitbucketDCService] = _BitbucketDCServiceImplProxy() # type: ignore[assignment] diff --git a/openhands/integrations/bitbucket_data_center/service/__init__.py b/openhands/integrations/bitbucket_data_center/service/__init__.py new file mode 100644 index 0000000000..63c4e54ae9 --- /dev/null +++ b/openhands/integrations/bitbucket_data_center/service/__init__.py @@ -0,0 +1,15 @@ +from .base import BitbucketDCMixinBase +from .branches import BitbucketDCBranchesMixin +from .features import BitbucketDCFeaturesMixin +from .prs import BitbucketDCPRsMixin +from .repos import BitbucketDCReposMixin +from .resolver import BitbucketDCResolverMixin + +__all__ = [ + 'BitbucketDCMixinBase', + 'BitbucketDCBranchesMixin', + 'BitbucketDCFeaturesMixin', + 'BitbucketDCPRsMixin', + 'BitbucketDCReposMixin', + 'BitbucketDCResolverMixin', +] diff --git a/openhands/integrations/bitbucket_data_center/service/base.py b/openhands/integrations/bitbucket_data_center/service/base.py new file mode 100644 index 0000000000..bb53d9102f --- /dev/null +++ b/openhands/integrations/bitbucket_data_center/service/base.py @@ -0,0 +1,333 @@ +import base64 +from typing import Any + +import httpx +from pydantic import SecretStr + +from openhands.core.logger import openhands_logger as logger +from openhands.integrations.protocols.http_client import HTTPClient +from openhands.integrations.service_types import ( + AuthenticationError, + BaseGitService, + OwnerType, + ProviderType, + Repository, + RequestMethod, + ResourceNotFoundError, + User, +) +from openhands.utils.http_session import httpx_verify_option + + +class BitbucketDCMixinBase(BaseGitService, HTTPClient): + """ + Base mixin for BitBucket data center service containing common functionality + """ + + BASE_URL: str = '' # Set dynamically from domain in __init__ + user_id: str | None + + def _repo_api_base(self, owner: str, repo: str) -> str: + return f'{self.BASE_URL}/projects/{owner}/repos/{repo}' + + @staticmethod + def _resolve_primary_email(emails: list[dict]) -> str | None: + """Find the primary confirmed email from a list of Bitbucket data center email objects. + + Bitbucket data center's /user/emails endpoint returns objects with + 'email', 'is_primary', and 'is_confirmed' keys. + """ + for entry in emails: + if entry.get('is_primary') and entry.get('is_confirmed'): + return entry.get('email') + return None + + def _extract_owner_and_repo(self, repository: str) -> tuple[str, str]: + """Extract owner and repo from repository string. + + Args: + repository: Repository name in format 'project/repo_slug' + + Returns: + Tuple of (owner, repo) + + Raises: + ValueError: If repository format is invalid + """ + parts = repository.split('/') + if len(parts) < 2: + raise ValueError(f'Invalid repository name: {repository}') + + return parts[-2], parts[-1] + + async def get_latest_token(self) -> SecretStr | None: + """Get latest working token of the user.""" + return self.token + + def _has_token_expired(self, status_code: int) -> bool: + return False # DC tokens cannot be refreshed programmatically + + async def _get_headers(self) -> dict[str, str]: + """Get headers for Bitbucket data center API requests.""" + token_value = self.token.get_secret_value() + + auth_str = base64.b64encode(token_value.encode()).decode() + return { + 'Authorization': f'Basic {auth_str}', + 'Accept': 'application/json', + } + + async def _make_request( + self, + url: str, + params: dict | None = None, + method: RequestMethod = RequestMethod.GET, + ) -> tuple[Any, dict]: + """Make a request to the Bitbucket data center API. + + Args: + url: The URL to request + params: Optional parameters for the request + method: The HTTP method to use + + Returns: + A tuple of (response_data, response_headers) + + """ + try: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: + bitbucket_headers = await self._get_headers() + response = await self.execute_request( + client, url, bitbucket_headers, params, method + ) + if self.refresh and self._has_token_expired(response.status_code): + await self.get_latest_token() + bitbucket_headers = await self._get_headers() + response = await self.execute_request( + client=client, + url=url, + headers=bitbucket_headers, + params=params, + method=method, + ) + response.raise_for_status() + try: + data = response.json() + except ValueError: + data = response.text + return data, dict(response.headers) + except httpx.HTTPStatusError as e: + raise self.handle_http_status_error(e) + except httpx.HTTPError as e: + raise self.handle_http_error(e) + + async def verify_access(self) -> None: + """Verify that the token and host are valid by making a lightweight API call. + Raises an exception if the token is invalid or the host is unreachable. + """ + url = f'{self.BASE_URL}/repos' + await self._make_request(url, {'limit': '1'}) + + async def _fetch_paginated_data( + self, url: str, params: dict, max_items: int + ) -> list[dict]: + """Fetch data with pagination support for Bitbucket data center API. + + Args: + url: The API endpoint URL + params: Query parameters for the request + max_items: Maximum number of items to fetch + + Returns: + List of data items from all pages + """ + all_items: list[dict] = [] + current_url = url + base_endpoint = url + + while current_url and len(all_items) < max_items: + response, _ = await self._make_request(current_url, params) + + # Extract items from response + page_items = response.get('values', []) + all_items.extend(page_items) + + if response.get('isLastPage', True): + break + next_start = response.get('nextPageStart') + if next_start is None: + break + params = params or {} + params = dict(params) + params['start'] = next_start + current_url = base_endpoint + + return all_items[:max_items] + + async def get_user_emails(self) -> list[dict]: + """Fetch the authenticated user's email addresses from Bitbucket data center. + + Calls GET /user/emails which returns a paginated response with a + 'values' list of email objects containing 'email', 'is_primary', + and 'is_confirmed' fields. + """ + url = f'{self.BASE_URL}/user/emails' + response, _ = await self._make_request(url) + return response.get('values', []) + + async def get_user(self) -> User: + """Get the authenticated user's information.""" + + if not self.user_id: + # HTTP Access tokens (x-token-auth) don't have user info. + # For OAuth, the user_id should be set. + return User( + id='', + login='', + avatar_url='', + name=None, + email=None, + ) + + # Basic auth - extract username and query users API to get slug + users_url = f'{self.BASE_URL}/users' + data, _ = await self._make_request( + users_url, {'filter': self.user_id, 'avatarSize': 64} + ) + users = data.get('values', []) + if not users: + raise AuthenticationError(f'User not found: {self.user_id}') + + user_data = users[0] + avatar = user_data.get('avatarUrl', '') + # Handle relative avatar URLs (Server returns /users/... paths) + if avatar.startswith('/users'): + # Strip /rest/api/1.0 from BASE_URL to get the base server URL + base_server_url = self.BASE_URL.rsplit('/rest/api/1.0', 1)[0] + avatar = f'{base_server_url}{avatar}' + display_name = user_data.get('displayName') + email = user_data.get('emailAddress') + return User( + id=str(user_data.get('id') or user_data.get('slug') or self.user_id), + login=user_data.get('name') or self.user_id, + avatar_url=avatar, + name=display_name, + email=email, + ) + + async def _parse_repository( + self, repo: dict, link_header: str | None = None + ) -> Repository: + """Parse a Bitbucket data center API repository response into a Repository object. + + Args: + repo: Repository data from Bitbucket data center API + link_header: Optional link header for pagination + + Returns: + Repository object + """ + project_key = repo.get('project', {}).get('key', '') + repo_slug = repo.get('slug', '') + + if not project_key or not repo_slug: + raise ValueError( + f'Cannot parse repository: missing project key or slug. ' + f'Got project_key={project_key!r}, repo_slug={repo_slug!r}' + ) + + full_name = f'{project_key}/{repo_slug}' + is_public = repo.get('public', False) + + main_branch: str | None = None + try: + default_branch_url = ( + f'{self._repo_api_base(project_key, repo_slug)}/default-branch' + ) + default_branch_data, _ = await self._make_request(default_branch_url) + main_branch = default_branch_data.get('displayId') or None + except Exception as e: + logger.debug(f'Could not fetch default branch for {full_name}: {e}') + + return Repository( + id=str(repo.get('id', '')), + full_name=full_name, + git_provider=ProviderType.BITBUCKET_DATA_CENTER, + is_public=is_public, + stargazers_count=None, + pushed_at=None, + owner_type=OwnerType.ORGANIZATION, + link_header=link_header, + main_branch=main_branch, + ) + + async def get_repository_details_from_repo_name( + self, repository: str + ) -> Repository: + """Get repository details from repository name. + + Args: + repository: Repository name in format 'project/repo_slug' + + Returns: + Repository object with details + """ + owner, repo = self._extract_owner_and_repo(repository) + url = self._repo_api_base(owner, repo) + data, _ = await self._make_request(url) + return await self._parse_repository(data) + + async def _get_cursorrules_url(self, repository: str) -> str: + """Get the URL for checking .cursorrules file.""" + # Get repository details to get the main branch + repo_details = await self.get_repository_details_from_repo_name(repository) + if not repo_details.main_branch: + raise ResourceNotFoundError( + f'Main branch not found for repository {repository}. ' + f'This repository may be empty or have no default branch configured.' + ) + owner, repo = self._extract_owner_and_repo(repository) + return ( + f'{self.BASE_URL}/projects/{owner}/repos/{repo}/browse/.cursorrules' + f'?at=refs/heads/{repo_details.main_branch}' + ) + + async def _get_microagents_directory_url( + self, repository: str, microagents_path: str + ) -> str: + """Get the URL for checking microagents directory.""" + # Get repository details to get the main branch + repo_details = await self.get_repository_details_from_repo_name(repository) + if not repo_details.main_branch: + raise ResourceNotFoundError( + f'Main branch not found for repository {repository}. ' + f'This repository may be empty or have no default branch configured.' + ) + + owner, repo = self._extract_owner_and_repo(repository) + return ( + f'{self.BASE_URL}/projects/{owner}/repos/{repo}/browse/{microagents_path}' + f'?at=refs/heads/{repo_details.main_branch}' + ) + + def _get_microagents_directory_params(self, microagents_path: str) -> dict | None: + """Get parameters for the microagents directory request. Return None if no parameters needed.""" + return None + + def _is_valid_microagent_file(self, item: dict) -> bool: + """Check if an item represents a valid microagent file.""" + file_name = item.get('path', {}).get('name', '') + return ( + item.get('type') == 'FILE' + and file_name.endswith('.md') + and file_name != 'README.md' + ) + + def _get_file_name_from_item(self, item: dict) -> str: + """Extract file name from directory item.""" + return item.get('path', {}).get('name', '') + + def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str: + """Extract file path from directory item.""" + file_name = self._get_file_name_from_item(item) + return f'{microagents_path}/{file_name}' diff --git a/openhands/integrations/bitbucket_data_center/service/branches.py b/openhands/integrations/bitbucket_data_center/service/branches.py new file mode 100644 index 0000000000..bbf4be1996 --- /dev/null +++ b/openhands/integrations/bitbucket_data_center/service/branches.py @@ -0,0 +1,136 @@ +from datetime import datetime, timezone + +from openhands.integrations.bitbucket_data_center.service.base import ( + BitbucketDCMixinBase, +) +from openhands.integrations.service_types import Branch, PaginatedBranchesResponse + + +class BitbucketDCBranchesMixin(BitbucketDCMixinBase): + """ + Mixin for BitBucket data center branch-related operations + """ + + async def get_branches(self, repository: str) -> list[Branch]: + """Get branches for a repository.""" + owner, repo = self._extract_owner_and_repo(repository) + + url = f'{self._repo_api_base(owner, repo)}/branches' + + # Set maximum branches to fetch (similar to GitHub/GitLab implementations) + MAX_BRANCHES = 1000 + PER_PAGE = 100 + + params = { + 'limit': PER_PAGE, + 'orderBy': 'MODIFICATION', + } + + # Fetch all branches with pagination + branch_data = await self._fetch_paginated_data(url, params, MAX_BRANCHES) + + return [self._parse_branch(branch) for branch in branch_data] + + async def get_paginated_branches( + self, repository: str, page: int = 1, per_page: int = 30 + ) -> PaginatedBranchesResponse: + """Get branches for a repository with pagination.""" + # Extract owner and repo from the repository string (e.g., "owner/repo") + owner, repo = self._extract_owner_and_repo(repository) + parts = repository.split('/') + if len(parts) < 2: + raise ValueError(f'Invalid repository name: {repository}') + + owner = parts[-2] + repo = parts[-1] + + url = f'{self._repo_api_base(owner, repo)}/branches' + + start = max((page - 1) * per_page, 0) + params = { + 'limit': per_page, + 'start': start, + 'orderBy': 'MODIFICATION', + } + + response, _ = await self._make_request(url, params) + + branches = [self._parse_branch(branch) for branch in response.get('values', [])] + + has_next_page = not response.get('isLastPage', True) + total_count = response.get('size') + + return PaginatedBranchesResponse( + branches=branches, + has_next_page=has_next_page, + current_page=page, + per_page=per_page, + total_count=total_count, + ) + + async def search_branches( + self, repository: str, query: str, per_page: int = 30 + ) -> list[Branch]: + """Search branches by name using Bitbucket data center API with `q` param.""" + owner, repo = self._extract_owner_and_repo(repository) + + url = f'{self._repo_api_base(owner, repo)}/branches' + params = { + 'limit': per_page, + 'filterText': query, + 'orderBy': 'MODIFICATION', + } + + response, _ = await self._make_request(url, params) + + return [self._parse_branch(branch) for branch in response.get('values', [])] + + def _parse_branch(self, branch: dict) -> Branch: + """Normalize Bitbucket branch representations across Cloud and Server.""" + + name = branch.get('displayId') or '' + if not name: + branch_id = branch.get('id', '') + if isinstance(branch_id, str) and branch_id.startswith('refs/heads/'): + name = branch_id.split('refs/heads/', 1)[-1] + elif isinstance(branch_id, str): + name = branch_id + + commit_sha = branch.get('latestCommit', '') + last_push_date = self._extract_server_branch_last_modified(branch) + + return Branch( + name=name, + commit_sha=commit_sha, + protected=False, # Bitbucket doesn't expose branch protection via these endpoints + last_push_date=last_push_date, + ) + + def _extract_server_branch_last_modified(self, branch: dict) -> str | None: + """Extract the last modified timestamp from a Bitbucket Server branch payload.""" + + metadata = branch.get('metadata') + if not isinstance(metadata, dict): + return None + + for value in metadata.values(): + if not isinstance(value, list): + continue + for entry in value: + if not isinstance(entry, dict): + continue + timestamp = ( + entry.get('authorTimestamp') + or entry.get('committerTimestamp') + or entry.get('timestamp') + or entry.get('lastModified') + ) + if isinstance(timestamp, (int, float)): + return datetime.fromtimestamp( + timestamp / 1000, tz=timezone.utc + ).isoformat() + if isinstance(timestamp, str): + # Some Bitbucket instances might already return ISO 8601 strings + return timestamp + + return None diff --git a/openhands/integrations/bitbucket_data_center/service/features.py b/openhands/integrations/bitbucket_data_center/service/features.py new file mode 100644 index 0000000000..daa48f8b2f --- /dev/null +++ b/openhands/integrations/bitbucket_data_center/service/features.py @@ -0,0 +1,96 @@ +from openhands.core.logger import openhands_logger as logger +from openhands.integrations.bitbucket_data_center.service.base import ( + BitbucketDCMixinBase, +) +from openhands.integrations.service_types import ResourceNotFoundError +from openhands.microagent.types import MicroagentContentResponse, MicroagentResponse + + +class BitbucketDCFeaturesMixin(BitbucketDCMixinBase): + """ + Mixin for BitBucket data center feature operations (microagents, cursor rules, etc.) + """ + + async def get_microagent_content( + self, repository: str, file_path: str + ) -> MicroagentContentResponse: + """Fetch individual file content from Bitbucket data center repository. + + Args: + repository: Repository name in format 'project/repo_slug' + file_path: Path to the file within the repository + + Returns: + MicroagentContentResponse with parsed content and triggers + + Raises: + RuntimeError: If file cannot be fetched or doesn't exist + """ + # Step 1: Get repository details using existing method + repo_details = await self.get_repository_details_from_repo_name(repository) + + if not repo_details.main_branch: + logger.warning( + f'No main branch found in repository info for {repository}. ' + f'Repository response: mainbranch field missing' + ) + raise ResourceNotFoundError( + f'Main branch not found for repository {repository}. ' + f'This repository may be empty or have no default branch configured.' + ) + + # Step 2: Get file content using the main branch + owner, repo = self._extract_owner_and_repo(repository) + repo_base = self._repo_api_base(owner, repo) + + file_url = f'{repo_base}/browse/{file_path}' + params = {'at': f'refs/heads/{repo_details.main_branch}'} + response, _ = await self._make_request(file_url, params=params) + if isinstance(response, dict): + lines = response.get('lines') + if isinstance(lines, list): + content = '\n'.join( + line.get('text', '') for line in lines if isinstance(line, dict) + ) + else: + content = response.get('content', '') + else: + content = str(response) + + # Parse the content to extract triggers from frontmatter + return self._parse_microagent_content(content, file_path) + + async def _process_microagents_directory( + self, repository: str, microagents_path: str + ) -> list[MicroagentResponse]: + microagents = [] + try: + directory_url = await self._get_microagents_directory_url( + repository, microagents_path + ) + directory_params = self._get_microagents_directory_params(microagents_path) + response, _ = await self._make_request(directory_url, directory_params) + + # Bitbucket DC browse endpoint nests items under response['children']['values'] + items = response.get('children', {}).get('values', []) + + for item in items: + if self._is_valid_microagent_file(item): + try: + file_name = self._get_file_name_from_item(item) + file_path = self._get_file_path_from_item( + item, microagents_path + ) + microagents.append( + self._create_microagent_response(file_name, file_path) + ) + except Exception as e: + logger.warning(f'Error processing microagent {item}: {str(e)}') + except ResourceNotFoundError: + logger.info( + f'No microagents directory found in {repository} at {microagents_path}' + ) + except Exception as e: + logger.warning(f'Error fetching microagents directory: {str(e)}') + + return microagents diff --git a/openhands/integrations/bitbucket_data_center/service/prs.py b/openhands/integrations/bitbucket_data_center/service/prs.py new file mode 100644 index 0000000000..14844eb4f2 --- /dev/null +++ b/openhands/integrations/bitbucket_data_center/service/prs.py @@ -0,0 +1,134 @@ +from typing import Any + +from openhands.core.logger import openhands_logger as logger +from openhands.integrations.bitbucket_data_center.service.base import ( + BitbucketDCMixinBase, +) +from openhands.integrations.service_types import RequestMethod + + +class BitbucketDCPRsMixin(BitbucketDCMixinBase): + """ + Mixin for BitBucket data center pull request operations + """ + + async def create_pr( + self, + repo_name: str, + source_branch: str, + target_branch: str, + title: str, + body: str | None = None, + draft: bool = False, + ) -> str: + """Creates a pull request in Bitbucket data center. + + Args: + repo_name: The repository name in the format "project/repo" + source_branch: The source branch name + target_branch: The target branch name + title: The title of the pull request + body: The description of the pull request + draft: Whether to create a draft pull request + + Returns: + The URL of the created pull request + """ + owner, repo = self._extract_owner_and_repo(repo_name) + repo_base = self._repo_api_base(owner, repo) + + payload: dict[str, Any] + + url = f'{repo_base}/pull-requests' + payload = { + 'title': title, + 'description': body or '', + 'fromRef': { + 'id': f'refs/heads/{source_branch}', + 'repository': {'slug': repo, 'project': {'key': owner}}, + }, + 'toRef': { + 'id': f'refs/heads/{target_branch}', + 'repository': {'slug': repo, 'project': {'key': owner}}, + }, + } + + data, _ = await self._make_request( + url=url, params=payload, method=RequestMethod.POST + ) + + # Return the URL to the pull request + links = data.get('links', {}) if isinstance(data, dict) else {} + + if isinstance(links, dict): + html_link = links.get('html') + if isinstance(html_link, dict): + href = html_link.get('href') + if href: + return href + if isinstance(html_link, list) and html_link: + href = html_link[0].get('href') + if href: + return href + self_link = links.get('self') + if isinstance(self_link, dict): + href = self_link.get('href') + if href: + return href + if isinstance(self_link, list) and self_link: + href = self_link[0].get('href') + if href: + return href + + return '' + + async def get_pr_details(self, repository: str, pr_number: int) -> dict: + """Get detailed information about a specific pull request + + Args: + repository: Repository name in format 'owner/repo' + pr_number: The pull request number + + Returns: + Raw Bitbucket data center API response for the pull request + """ + owner, repo = self._extract_owner_and_repo(repository) + repo_base = self._repo_api_base(owner, repo) + url = f'{repo_base}/pull-requests/{pr_number}' + + pr_data, _ = await self._make_request(url) + + return pr_data + + async def is_pr_open(self, repository: str, pr_number: int) -> bool: + """Check if a Bitbucket data center pull request is still active (not closed/merged). + + Args: + repository: Repository name in format 'owner/repo' + pr_number: The PR number to check + + Returns: + True if PR is active (OPEN), False if closed/merged + """ + try: + pr_details = await self.get_pr_details(repository, pr_number) + + # Bitbucket data center API response structure + if 'state' in pr_details: + # Bitbucket data center state values: OPEN, MERGED, DECLINED, SUPERSEDED + return pr_details['state'] == 'OPEN' + + # If we can't determine the state, assume it's active (safer default) + logger.warning( + f'Could not determine Bitbucket PR status for {repository}#{pr_number}. ' + f'Response keys: {list(pr_details.keys())}. Assuming PR is active.' + ) + return True + + except Exception as e: + logger.warning( + f'Could not determine Bitbucket PR status for {repository}#{pr_number}: {e}. ' + f'Including conversation to be safe.' + ) + # If we can't determine the PR status, include the conversation to be safe + return True diff --git a/openhands/integrations/bitbucket_data_center/service/repos.py b/openhands/integrations/bitbucket_data_center/service/repos.py new file mode 100644 index 0000000000..5a587cd81e --- /dev/null +++ b/openhands/integrations/bitbucket_data_center/service/repos.py @@ -0,0 +1,203 @@ +from typing import Any +from urllib.parse import urlparse + +from openhands.integrations.bitbucket_data_center.service.base import ( + BitbucketDCMixinBase, +) +from openhands.integrations.service_types import Repository, SuggestedTask +from openhands.server.types import AppMode + + +class BitbucketDCReposMixin(BitbucketDCMixinBase): + """ + Mixin for BitBucket data center repository-related operations + """ + + async def search_repositories( + self, + query: str, + per_page: int, + sort: str, + order: str, + public: bool, + app_mode: AppMode, + ) -> list[Repository]: + """Search for repositories.""" + repositories = [] + + if public: + try: + parsed_url = urlparse(query) + path_segments = [ + segment for segment in parsed_url.path.split('/') if segment + ] + + if 'projects' in path_segments: + idx = path_segments.index('projects') + if ( + len(path_segments) > idx + 2 + and path_segments[idx + 1] + and path_segments[idx + 2] == 'repos' + ): + project_key = path_segments[idx + 1] + repo_name = ( + path_segments[idx + 3] + if len(path_segments) > idx + 3 + else '' + ) + elif len(path_segments) > idx + 2: + project_key = path_segments[idx + 1] + repo_name = path_segments[idx + 2] + else: + project_key = '' + repo_name = '' + else: + project_key = path_segments[0] if len(path_segments) >= 1 else '' + repo_name = path_segments[1] if len(path_segments) >= 2 else '' + + if project_key and repo_name: + repo = await self.get_repository_details_from_repo_name( + f'{project_key}/{repo_name}' + ) + repositories.append(repo) + except (ValueError, IndexError): + pass + + return repositories + + MAX_REPOS = 1000 + # Search for repos once project prefix exists + if '/' in query: + project_slug, repo_query = query.split('/', 1) + project_repos_url = f'{self.BASE_URL}/projects/{project_slug}/repos' + raw_repos = await self._fetch_paginated_data( + project_repos_url, {'limit': per_page}, MAX_REPOS + ) + if repo_query: + raw_repos = [ + r + for r in raw_repos + if repo_query.lower() in r.get('slug', '').lower() + or repo_query.lower() in r.get('name', '').lower() + ] + 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 + ) + repositories.extend(repos) + except Exception: + continue + return repositories + + async def _get_user_projects(self) -> list[dict[str, Any]]: + """Get all projects the user has access to""" + projects_url = f'{self.BASE_URL}/projects' + projects = await self._fetch_paginated_data(projects_url, {}, 100) + return projects + + async def get_installations( + self, query: str | None = None, limit: int = 100 + ) -> list[str]: + projects_url = f'{self.BASE_URL}/projects' + params: dict[str, Any] = {'limit': limit} + projects = await self._fetch_paginated_data(projects_url, params, limit) + project_keys: list[str] = [] + for project in projects: + key = project.get('key') + name = project.get('name', '') + if not key: + continue + if query and query.lower() not in f'{key}{name}'.lower(): + continue + project_keys.append(key) + return project_keys + + async def get_paginated_repos( + self, + page: int, + per_page: int, + sort: str, + installation_id: str | None, + query: str | None = None, + ) -> list[Repository]: + """Get paginated repositories for a specific project. + + Args: + page: The page number to fetch + per_page: The number of repositories per page + sort: The sort field ('pushed', 'updated', 'created', 'full_name') + installation_id: The project slug to fetch repositories from (as int, will be converted to string) + + Returns: + A list of Repository objects + """ + if not installation_id: + return [] + + # Convert installation_id to string for use as project_slug + project_slug = installation_id + + project_repos_url = f'{self.BASE_URL}/projects/{project_slug}/repos' + # Calculate start offset from page number (Bitbucket Server uses 0-based start index) + start = (page - 1) * per_page + params: dict[str, Any] = {'limit': per_page, 'start': start} + response, _ = await self._make_request(project_repos_url, params) + repos = response.get('values', []) + if query: + repos = [ + repo + for repo in repos + if query.lower() in repo.get('slug', '').lower() + or query.lower() in repo.get('name', '').lower() + ] + formatted_link_header = '' + if not response.get('isLastPage', True): + next_page = page + 1 + # Use 'page=' format for frontend compatibility with extractNextPageFromLink + formatted_link_header = ( + f'<{project_repos_url}?page={next_page}>; rel="next"' + ) + return [ + await self._parse_repository(repo, link_header=formatted_link_header) + for repo in repos + ] + + async def get_all_repositories( + self, sort: str, app_mode: AppMode + ) -> list[Repository]: + """Get repositories for the authenticated user using workspaces endpoint. + + This method gets all repositories (both public and private) that the user has access to + by iterating through their workspaces and fetching repositories from each workspace. + This approach is more comprehensive and efficient than the previous implementation + that made separate calls for public and private repositories. + """ + MAX_REPOS = 1000 + PER_PAGE = 100 # Maximum allowed by Bitbucket data center API + repositories: list[Repository] = [] + + projects = await self.get_installations(limit=MAX_REPOS) + for project_key in projects: + project_repos_url = f'{self.BASE_URL}/projects/{project_key}/repos' + project_repos = await self._fetch_paginated_data( + project_repos_url, + {'limit': PER_PAGE}, + MAX_REPOS - len(repositories), + ) + for repo in project_repos: + repositories.append(await self._parse_repository(repo)) + if len(repositories) >= MAX_REPOS: + break + if len(repositories) >= MAX_REPOS: + break + return repositories + + async def get_suggested_tasks(self) -> list[SuggestedTask]: + """Get suggested tasks for the authenticated user across all repositories.""" + # TODO: implemented suggested tasks + return [] diff --git a/openhands/integrations/bitbucket_data_center/service/resolver.py b/openhands/integrations/bitbucket_data_center/service/resolver.py new file mode 100644 index 0000000000..1559eba191 --- /dev/null +++ b/openhands/integrations/bitbucket_data_center/service/resolver.py @@ -0,0 +1,113 @@ +from datetime import datetime, timezone + +from openhands.integrations.bitbucket_data_center.service.base import ( + BitbucketDCMixinBase, +) +from openhands.integrations.service_types import Comment + + +class BitbucketDCResolverMixin(BitbucketDCMixinBase): + """ + Helper methods used for the Bitbucket Data Center Resolver + """ + + async def get_pr_title_and_body( + self, owner: str, repo_slug: str, pr_id: int + ) -> tuple[str, str]: + """Get the title and body of a pull request. + + Args: + owner: Project key (e.g. 'PROJ') + repo_slug: Repository slug + pr_id: Pull request ID + + Returns: + A tuple of (title, body) + """ + url = ( + f'{self.BASE_URL}/projects/{owner}/repos/{repo_slug}/pull-requests/{pr_id}' + ) + response, _ = await self._make_request(url) + title = response.get('title') or '' + body = response.get('description') or '' + return title, body + + async def get_pr_comments( + self, owner: str, repo_slug: str, pr_id: int, max_comments: int = 10 + ) -> list[Comment]: + """Get comments for a pull request. + + Uses the pull-requests/{id}/activities endpoint, filtering for + COMMENTED actions — the same approach used by the resolver interface. + + Args: + owner: Project key (e.g. 'PROJ') + repo_slug: Repository slug + pr_id: Pull request ID + max_comments: Maximum number of comments to retrieve + + Returns: + List of Comment objects ordered by creation date + """ + url = f'{self.BASE_URL}/projects/{owner}/repos/{repo_slug}/pull-requests/{pr_id}/activities' + all_raw: list[dict] = [] + + params: dict = {'limit': 100, 'start': 0} + while len(all_raw) < max_comments: + response, _ = await self._make_request(url, params) + for activity in response.get('values', []): + if activity.get('action') == 'COMMENTED': + comment = activity.get('comment', {}) + if comment: + all_raw.append(comment) + + if response.get('isLastPage', True): + break + + next_start = response.get('nextPageStart') + if next_start is None: + break + params = {'limit': 100, 'start': next_start} + + return self._process_raw_comments(all_raw, max_comments) + + def _process_raw_comments( + self, comments: list, max_comments: int = 10 + ) -> list[Comment]: + """Convert raw Bitbucket DC comment dicts to Comment objects.""" + all_comments: list[Comment] = [] + for comment_data in comments: + # Bitbucket DC activities use epoch milliseconds for createdDate/updatedDate + created_ms = comment_data.get('createdDate') + updated_ms = comment_data.get('updatedDate') + + created_at = ( + datetime.fromtimestamp(created_ms / 1000, tz=timezone.utc) + if created_ms is not None + else datetime.fromtimestamp(0, tz=timezone.utc) + ) + updated_at = ( + datetime.fromtimestamp(updated_ms / 1000, tz=timezone.utc) + if updated_ms is not None + else datetime.fromtimestamp(0, tz=timezone.utc) + ) + + author = ( + comment_data.get('author', {}).get('slug') + or comment_data.get('author', {}).get('name') + or 'unknown' + ) + + all_comments.append( + Comment( + id=str(comment_data.get('id', 'unknown')), + body=self._truncate_comment(comment_data.get('text', '')), + author=author, + created_at=created_at, + updated_at=updated_at, + system=False, + ) + ) + + all_comments.sort(key=lambda c: c.created_at) + return all_comments[-max_comments:] diff --git a/openhands/integrations/provider.py b/openhands/integrations/provider.py index d162298811..ad94305b3c 100644 --- a/openhands/integrations/provider.py +++ b/openhands/integrations/provider.py @@ -22,6 +22,9 @@ from openhands.integrations.azure_devops.azure_devops_service import ( AzureDevOpsServiceImpl, ) from openhands.integrations.bitbucket.bitbucket_service import BitBucketServiceImpl +from openhands.integrations.bitbucket_data_center.bitbucket_dc_service import ( + BitbucketDCServiceImpl, +) from openhands.integrations.forgejo.forgejo_service import ForgejoServiceImpl from openhands.integrations.github.github_service import GithubServiceImpl from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl @@ -128,6 +131,7 @@ class ProviderHandler: ProviderType.GITHUB: GithubServiceImpl, ProviderType.GITLAB: GitLabServiceImpl, ProviderType.BITBUCKET: BitBucketServiceImpl, + ProviderType.BITBUCKET_DATA_CENTER: BitbucketDCServiceImpl, ProviderType.FORGEJO: ForgejoServiceImpl, ProviderType.AZURE_DEVOPS: AzureDevOpsServiceImpl, } @@ -222,6 +226,18 @@ class ProviderHandler: return [] + async def get_bitbucket_dc_projects(self) -> list[str]: + service = cast( + InstallationsService, + self.get_service(ProviderType.BITBUCKET_DATA_CENTER), + ) + try: + return await service.get_installations() + except Exception as e: + logger.warning(f'Failed to get bitbucket data center projects {e}') + + return [] + async def get_azure_devops_organizations(self) -> list[str]: service = cast( InstallationsService, self.get_service(ProviderType.AZURE_DEVOPS) @@ -341,8 +357,9 @@ class ProviderHandler: def _is_repository_url(self, query: str, provider: ProviderType) -> bool: """Check if the query is a repository URL.""" custom_host = self.provider_tokens[provider].host - custom_host_exists = custom_host and custom_host in query - default_host_exists = self.PROVIDER_DOMAINS[provider] in query + custom_host_exists = bool(custom_host and custom_host in query) + default_domain = self.PROVIDER_DOMAINS.get(provider) + default_host_exists = default_domain is not None and default_domain in query return query.startswith(('http://', 'https://')) and ( custom_host_exists or default_host_exists @@ -673,7 +690,7 @@ class ProviderHandler: provider = repository.git_provider repo_name = repository.full_name - domain = self.PROVIDER_DOMAINS[provider] + domain = self.PROVIDER_DOMAINS.get(provider, '') # If provider tokens are provided, use the host from the token if available # Note: For Azure DevOps, don't use the host field as it may contain org/project path @@ -724,6 +741,24 @@ class ProviderHandler: else: # Access token format: use x-token-auth remote_url = f'{protocol}://x-token-auth:{token_value}@{domain}/{repo_name}.git' + elif provider == ProviderType.BITBUCKET_DATA_CENTER: + # DC uses HTTP Basic auth — token must be in username:token format + project, repo_slug = ( + repo_name.split('/', 1) + if '/' in repo_name + else (repo_name, repo_name) + ) + scm_path = f'scm/{project.lower()}/{repo_slug}.git' + # Percent-encode each credential part so special characters + # (e.g. @, #, /) don't break the URL. + if ':' in token_value: + dc_user, dc_pass = token_value.split(':', 1) + url_creds = ( + f'{quote(dc_user, safe="")}:{quote(dc_pass, safe="")}' + ) + else: + url_creds = f'x-token-auth:{quote(token_value, safe="")}' + remote_url = f'{protocol}://{url_creds}@{domain}/{scm_path}' elif provider == ProviderType.AZURE_DEVOPS: # Azure DevOps uses PAT with Basic auth # Format: https://{anything}:{PAT}@dev.azure.com/{org}/{project}/_git/{repo} diff --git a/openhands/integrations/service_types.py b/openhands/integrations/service_types.py index 9ee250085d..27ae0e5edb 100644 --- a/openhands/integrations/service_types.py +++ b/openhands/integrations/service_types.py @@ -21,6 +21,7 @@ class ProviderType(Enum): GITHUB = 'github' GITLAB = 'gitlab' BITBUCKET = 'bitbucket' + BITBUCKET_DATA_CENTER = 'bitbucket_data_center' FORGEJO = 'forgejo' AZURE_DEVOPS = 'azure_devops' ENTERPRISE_SSO = 'enterprise_sso' @@ -78,6 +79,16 @@ class SuggestedTask(BaseModel): 'ciProvider': 'Bitbucket', 'requestVerb': 'pull request', } + elif self.git_provider == ProviderType.BITBUCKET_DATA_CENTER: + return { + 'requestType': 'Pull Request', + 'requestTypeShort': 'PR', + 'apiName': 'Bitbucket Data Center API', + 'tokenEnvVar': 'BITBUCKET_DATA_CENTER_TOKEN', + 'ciSystem': 'Bitbucket Pipelines', + 'ciProvider': 'Bitbucket Data Center', + 'requestVerb': 'pull request', + } raise ValueError(f'Provider {self.git_provider} for suggested task prompts') diff --git a/openhands/integrations/utils.py b/openhands/integrations/utils.py index cbda2b06e7..d7446597c1 100644 --- a/openhands/integrations/utils.py +++ b/openhands/integrations/utils.py @@ -5,6 +5,9 @@ from openhands.integrations.azure_devops.azure_devops_service import ( AzureDevOpsServiceImpl as AzureDevOpsService, ) from openhands.integrations.bitbucket.bitbucket_service import BitBucketService +from openhands.integrations.bitbucket_data_center.bitbucket_dc_service import ( + BitbucketDCService, +) from openhands.integrations.forgejo.forgejo_service import ForgejoService from openhands.integrations.github.github_service import GitHubService from openhands.integrations.gitlab.gitlab_service import GitLabService @@ -14,7 +17,7 @@ from openhands.integrations.provider import ProviderType async def validate_provider_token( token: SecretStr, base_domain: str | None = None ) -> ProviderType | None: - """Determine whether a token is for GitHub, GitLab, Bitbucket, or Azure DevOps by attempting to get user info from the services. + """Determine whether a token is for GitHub, GitLab, Bitbucket, Bitbucket Data Center, or Azure DevOps by attempting to get user info from the services. Args: token: The token to check @@ -69,6 +72,18 @@ async def validate_provider_token( except Exception as e: bitbucket_error = e + # Try Bitbucket Data Center if a base_domain was provided (always self-hosted) + bitbucket_dc_error = None + if base_domain: + try: + bitbucket_dc_service = BitbucketDCService( + token=token, base_domain=base_domain + ) + await bitbucket_dc_service.verify_access() + return ProviderType.BITBUCKET_DATA_CENTER + except Exception as e: + bitbucket_dc_error = e + # Try Azure DevOps last azure_devops_error = None try: @@ -79,7 +94,7 @@ async def validate_provider_token( azure_devops_error = e logger.debug( - f'Failed to validate token: {github_error} \n {gitlab_error} \n {forgejo_error} \n {bitbucket_error} \n {azure_devops_error}' + f'Failed to validate token: {github_error} \n {gitlab_error} \n {forgejo_error} \n {bitbucket_error} \n {bitbucket_dc_error} \n {azure_devops_error}' ) return None diff --git a/openhands/resolver/interfaces/bitbucket_data_center.py b/openhands/resolver/interfaces/bitbucket_data_center.py new file mode 100644 index 0000000000..c5bf4f9552 --- /dev/null +++ b/openhands/resolver/interfaces/bitbucket_data_center.py @@ -0,0 +1,357 @@ +import base64 +from typing import Any +from urllib.parse import quote + +import httpx + +from openhands.core.logger import openhands_logger as logger +from openhands.resolver.interfaces.issue import ( + Issue, + IssueHandlerInterface, + ReviewThread, +) +from openhands.resolver.utils import extract_issue_references +from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync +from openhands.utils.http_session import httpx_verify_option + + +class BitbucketDCIssueHandler(IssueHandlerInterface): + def __init__( + self, + owner: str, + repo: str, + token: str, + username: str | None = None, + base_domain: str = 'bitbucket.example.com', + ): + """Initialize a Bitbucket Data Center issue handler. + + Args: + owner: The project key of the repository + repo: The slug of the repository + token: The Bitbucket DC API token (user:password or user:token format) + username: Optional username (used when token is a bare API token) + base_domain: The hostname of the Bitbucket DC instance + """ + self.owner = owner + self.repo = repo + self.token = token + self.username = username + self.base_domain = base_domain + self.base_url = self.get_base_url() + self.download_url = self.get_download_url() + self.clone_url = self.get_clone_url() + self.headers = self.get_headers() + + def set_owner(self, owner: str) -> None: + self.owner = owner + + def get_headers(self) -> dict[str, str]: + # DC always uses HTTP Basic auth + if ':' in self.token: + auth_str = base64.b64encode(self.token.encode()).decode() + elif self.username: + creds = f'{self.username}:{self.token}' + auth_str = base64.b64encode(creds.encode()).decode() + else: + auth_str = base64.b64encode(self.token.encode()).decode() + return { + 'Authorization': f'Basic {auth_str}', + 'Accept': 'application/json', + } + + def get_base_url(self) -> str: + return f'https://{self.base_domain}/rest/api/1.0' + + def _get_repo_api_base(self) -> str: + return f'{self.base_url}/projects/{self.owner}/repos/{self.repo}' + + def get_download_url(self) -> str: + return ( + f'https://{self.base_domain}/rest/api/latest' + f'/projects/{self.owner}/repos/{self.repo}/archive?format=zip' + ) + + def get_clone_url(self) -> str: + return f'https://{self.base_domain}/scm/{self.owner.lower()}/{self.repo}.git' + + def get_repo_url(self) -> str: + return f'https://{self.base_domain}/projects/{self.owner}/repos/{self.repo}' + + def get_issue_url(self, issue_number: int) -> str: + # DC has no issue tracker; use pull-requests URL + return f'{self.get_repo_url()}/pull-requests/{issue_number}' + + def get_pr_url(self, pr_number: int) -> str: + return f'{self.get_repo_url()}/pull-requests/{pr_number}' + + def get_pull_url(self, pr_number: int) -> str: + return f'{self.get_repo_url()}/pull-requests/{pr_number}' + + def get_branch_url(self, branch_name: str) -> str: + return f'{self.get_repo_url()}/browse?at=refs/heads/{branch_name}' + + def get_compare_url(self, branch_name: str) -> str: + default_branch = self.get_default_branch_name() + return ( + f'{self.get_repo_url()}/compare/commits' + f'?sourceBranch=refs/heads/{branch_name}' + f'&targetBranch=refs/heads/{default_branch}' + ) + + def get_authorize_url(self) -> str: + if ':' in self.token: + user, _, token = self.token.partition(':') + creds = f'{quote(user, safe="")}:{quote(token, safe="")}' + elif self.username: + creds = f'{quote(self.username, safe="")}:{quote(self.token, safe="")}' + else: + creds = quote(self.token, safe='') + return f'https://{creds}@{self.base_domain}/' + + def get_graphql_url(self) -> str: + # DC has no GraphQL API; return a placeholder + return f'https://{self.base_domain}/rest/api/1.0' + + def get_branch_name(self, base_branch_name: str) -> str: + return f'{base_branch_name}-{self.owner}' + + async def get_issue(self, issue_number: int) -> Issue: + """Fetch a Bitbucket DC pull request as an Issue. + + Args: + issue_number: The pull request ID + + Returns: + An Issue object populated from the DC pull request response + """ + url = f'{self._get_repo_api_base()}/pull-requests/{issue_number}' + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: + response = await client.get(url, headers=self.headers) + response.raise_for_status() + data = response.json() + + head_branch = data.get('fromRef', {}).get('displayId', '') + base_branch = data.get('toRef', {}).get('displayId', '') + + return Issue( + owner=self.owner, + repo=self.repo, + number=data.get('id'), + title=data.get('title', ''), + body=data.get('description', ''), + head_branch=head_branch, + base_branch=base_branch, + ) + + def create_pr( + self, + title: str, + body: str, + head: str, + base: str, + ) -> str: + """Create a pull request on Bitbucket DC. + + Args: + title: PR title + body: PR description + head: Source branch name + base: Target branch name + + Returns: + The URL of the created pull request + """ + result = self.create_pull_request( + { + 'title': title, + 'description': body, + 'source_branch': head, + 'target_branch': base, + } + ) + return result.get('html_url', '') + + def create_pull_request(self, data: dict[str, Any] | None = None) -> dict[str, Any]: + """Create a pull request and return html_url and number. + + Args: + data: Dict with keys title, description, source_branch, target_branch + + Returns: + Dict with 'html_url' and 'number' keys + """ + if data is None: + data = {} + + title = data.get('title', '') + description = data.get('description', '') + source_branch = data.get('source_branch', '') + target_branch = data.get('target_branch', '') + + url = f'{self._get_repo_api_base()}/pull-requests' + payload = { + 'title': title, + 'description': description, + 'fromRef': { + 'id': f'refs/heads/{source_branch}', + 'repository': { + 'slug': self.repo, + 'project': {'key': self.owner}, + }, + }, + 'toRef': { + 'id': f'refs/heads/{target_branch}', + 'repository': { + 'slug': self.repo, + 'project': {'key': self.owner}, + }, + }, + } + response = httpx.post( + url, headers=self.headers, json=payload, verify=httpx_verify_option() + ) + response.raise_for_status() + resp_data = response.json() + + links = resp_data.get('links', {}).get('self', []) + html_url = links[0].get('href', '') if links else '' + + return { + 'html_url': html_url, + 'number': resp_data.get('id', 0), + } + + def send_comment_msg(self, issue_number: int, msg: str) -> None: + url = f'{self._get_repo_api_base()}/pull-requests/{issue_number}/comments' + payload = {'text': msg} + response = httpx.post( + url, headers=self.headers, json=payload, verify=httpx_verify_option() + ) + response.raise_for_status() + + def reply_to_comment(self, pr_number: int, comment_id: str, reply: str) -> None: + url = f'{self._get_repo_api_base()}/pull-requests/{pr_number}/comments' + payload = { + 'text': reply, + 'parent': {'id': int(comment_id)}, + } + response = httpx.post( + url, headers=self.headers, json=payload, verify=httpx_verify_option() + ) + response.raise_for_status() + + def branch_exists(self, branch_name: str) -> bool: + url = f'{self._get_repo_api_base()}/branches' + params = {'filterText': branch_name, 'limit': 1} + try: + response = httpx.get( + url, headers=self.headers, params=params, verify=httpx_verify_option() + ) + response.raise_for_status() + data = response.json() + values = data.get('values', []) + return any(v.get('displayId') == branch_name for v in values) + except httpx.HTTPError as e: + logger.warning(f'Failed to check branch existence: {e}') + return False + + def get_default_branch_name(self) -> str: + url = self._get_repo_api_base() + try: + response = httpx.get( + url, headers=self.headers, verify=httpx_verify_option() + ) + response.raise_for_status() + data = response.json() + default_branch = data.get('defaultBranch', {}) + if default_branch: + display_id = default_branch.get('displayId', '') + if display_id: + if display_id.startswith('refs/heads/'): + return display_id[len('refs/heads/') :] + return display_id + except httpx.HTTPError as e: + logger.warning(f'Failed to get default branch name: {e}') + return 'master' + + def download_issues(self) -> list[Any]: + logger.warning( + 'BitbucketDCIssueHandler.download_issues not implemented; ' + 'use get_issue() to fetch individual pull requests' + ) + return [] + + def get_issue_comments( + self, issue_number: int, comment_id: int | None = None + ) -> list[str] | None: + logger.warning('BitbucketDCIssueHandler.get_issue_comments not implemented') + return [] + + def get_issue_thread_comments(self, issue_number: int) -> list[str]: + logger.warning( + 'BitbucketDCIssueHandler.get_issue_thread_comments not implemented' + ) + return [] + + def get_issue_review_comments(self, issue_number: int) -> list[str]: + logger.warning( + 'BitbucketDCIssueHandler.get_issue_review_comments not implemented' + ) + return [] + + def get_issue_review_threads(self, issue_number: int) -> list[ReviewThread]: + logger.warning( + 'BitbucketDCIssueHandler.get_issue_review_threads not implemented' + ) + return [] + + def get_context_from_external_issues_references( + self, + closing_issues: list[str], + closing_issue_numbers: list[int], + issue_body: str, + review_comments: list[str] | None, + review_threads: list[ReviewThread], + thread_comments: list[str] | None, + ) -> list[str]: + # DC has no issue tracker; return closing_issues immediately without API calls + return closing_issues + + def request_reviewers(self, reviewer: str, pr_number: int) -> None: + logger.warning('BitbucketDCIssueHandler.request_reviewers not implemented') + + def get_issue_references(self, body: str) -> list[int]: + return extract_issue_references(body) + + def get_converted_issues( + self, issue_numbers: list[int] | None = None, comment_id: int | None = None + ) -> list[Issue]: + if not issue_numbers: + raise ValueError('Unspecified issue numbers') + + converted_issues = [] + for issue_number in issue_numbers: + try: + issue = call_async_from_sync( + self.get_issue, GENERAL_TIMEOUT, issue_number + ) + converted_issues.append(issue) + except Exception as e: + logger.warning(f'Failed to fetch pull request {issue_number}: {e}') + + return converted_issues + + +class BitbucketDCPRHandler(BitbucketDCIssueHandler): + """Handler for Bitbucket Data Center pull requests, extending the issue handler.""" + + def __init__( + self, + owner: str, + repo: str, + token: str, + username: str | None = None, + base_domain: str = 'bitbucket.example.com', + ): + super().__init__(owner, repo, token, username, base_domain) diff --git a/openhands/resolver/issue_handler_factory.py b/openhands/resolver/issue_handler_factory.py index f1e38e35b0..ddab05432c 100644 --- a/openhands/resolver/issue_handler_factory.py +++ b/openhands/resolver/issue_handler_factory.py @@ -12,6 +12,10 @@ from openhands.resolver.interfaces.bitbucket import ( BitbucketIssueHandler, BitbucketPRHandler, ) +from openhands.resolver.interfaces.bitbucket_data_center import ( + BitbucketDCIssueHandler, + BitbucketDCPRHandler, +) from openhands.resolver.interfaces.forgejo import ( ForgejoIssueHandler, ForgejoPRHandler, @@ -80,6 +84,17 @@ class IssueHandlerFactory: ), self.llm_config, ) + elif self.platform == ProviderType.BITBUCKET_DATA_CENTER: + return ServiceContextIssue( + BitbucketDCIssueHandler( + self.owner, + self.repo, + self.token, + self.username, + self.base_domain, + ), + self.llm_config, + ) elif self.platform == ProviderType.FORGEJO: return ServiceContextIssue( ForgejoIssueHandler( @@ -147,6 +162,17 @@ class IssueHandlerFactory: ), self.llm_config, ) + elif self.platform == ProviderType.BITBUCKET_DATA_CENTER: + return ServiceContextPR( + BitbucketDCPRHandler( + self.owner, + self.repo, + self.token, + self.username, + self.base_domain, + ), + self.llm_config, + ) elif self.platform == ProviderType.FORGEJO: return ServiceContextPR( ForgejoPRHandler( diff --git a/openhands/resolver/issue_resolver.py b/openhands/resolver/issue_resolver.py index 68abc4bc1b..1d1821b66b 100644 --- a/openhands/resolver/issue_resolver.py +++ b/openhands/resolver/issue_resolver.py @@ -141,6 +141,8 @@ class IssueResolver: if platform == ProviderType.GITLAB else 'bitbucket.org' if platform == ProviderType.BITBUCKET + else 'bitbucket.example.com' + if platform == ProviderType.BITBUCKET_DATA_CENTER else 'dev.azure.com' ) diff --git a/openhands/resolver/send_pull_request.py b/openhands/resolver/send_pull_request.py index c59b95c998..d2ffc7f8a8 100644 --- a/openhands/resolver/send_pull_request.py +++ b/openhands/resolver/send_pull_request.py @@ -20,6 +20,7 @@ from openhands.integrations.service_types import ProviderType from openhands.llm.llm import LLM from openhands.resolver.interfaces.azure_devops import AzureDevOpsIssueHandler from openhands.resolver.interfaces.bitbucket import BitbucketIssueHandler +from openhands.resolver.interfaces.bitbucket_data_center import BitbucketDCIssueHandler from openhands.resolver.interfaces.forgejo import ForgejoIssueHandler from openhands.resolver.interfaces.github import GithubIssueHandler from openhands.resolver.interfaces.gitlab import GitlabIssueHandler @@ -310,6 +311,13 @@ def send_pull_request( ), None, ) + elif platform == ProviderType.BITBUCKET_DATA_CENTER: + handler = ServiceContextIssue( + BitbucketDCIssueHandler( + issue.owner, issue.repo, token, username, base_domain + ), + None, + ) elif platform == ProviderType.FORGEJO: handler = ServiceContextIssue( ForgejoIssueHandler(issue.owner, issue.repo, token, username, base_domain), @@ -416,6 +424,14 @@ def send_pull_request( 'target_branch': base_branch, 'draft': pr_type == 'draft', } + elif platform == ProviderType.BITBUCKET_DATA_CENTER: + data = { + 'title': final_pr_title, + 'description': pr_body, + 'source_branch': head_branch, + 'target_branch': base_branch, + 'draft': pr_type == 'draft', + } elif platform == ProviderType.FORGEJO: data = { 'title': final_pr_title, @@ -476,6 +492,7 @@ def update_existing_pull_request( ProviderType.AZURE_DEVOPS: 'dev.azure.com', ProviderType.BITBUCKET: 'bitbucket.org', ProviderType.FORGEJO: 'codeberg.org', + ProviderType.BITBUCKET_DATA_CENTER: 'bitbucket.example.com', }.get(platform, 'github.com') handler = None @@ -503,6 +520,13 @@ def update_existing_pull_request( ), llm_config, ) + elif platform == ProviderType.BITBUCKET_DATA_CENTER: + handler = ServiceContextIssue( + BitbucketDCIssueHandler( + issue.owner, issue.repo, token, username, base_domain + ), + llm_config, + ) elif platform == ProviderType.FORGEJO: handler = ServiceContextIssue( ForgejoIssueHandler(issue.owner, issue.repo, token, username, base_domain), @@ -606,6 +630,12 @@ def process_single_issue( else 'gitlab.com' if platform == ProviderType.GITLAB else 'dev.azure.com' + if platform == ProviderType.AZURE_DEVOPS + else 'bitbucket.org' + if platform == ProviderType.BITBUCKET + else 'bitbucket.example.com' + if platform == ProviderType.BITBUCKET_DATA_CENTER + else 'github.com' ) if not resolver_output.success and not send_on_failure: logger.info( diff --git a/openhands/server/routes/git.py b/openhands/server/routes/git.py index 76ce6906ef..411d90ba79 100644 --- a/openhands/server/routes/git.py +++ b/openhands/server/routes/git.py @@ -61,6 +61,8 @@ async def get_user_installations( return await client.get_github_installations() elif provider == ProviderType.BITBUCKET: return await client.get_bitbucket_workspaces() + elif provider == ProviderType.BITBUCKET_DATA_CENTER: + return await client.get_bitbucket_dc_projects() elif provider == ProviderType.AZURE_DEVOPS: return await client.get_azure_devops_organizations() else: diff --git a/openhands/server/routes/mcp.py b/openhands/server/routes/mcp.py index df7c978de6..6cd62b2712 100644 --- a/openhands/server/routes/mcp.py +++ b/openhands/server/routes/mcp.py @@ -12,6 +12,9 @@ from openhands.integrations.azure_devops.azure_devops_service import ( AzureDevOpsServiceImpl, ) from openhands.integrations.bitbucket.bitbucket_service import BitBucketServiceImpl +from openhands.integrations.bitbucket_data_center.bitbucket_dc_service import ( + BitbucketDCServiceImpl, +) from openhands.integrations.github.github_service import GithubServiceImpl from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl from openhands.integrations.provider import ProviderToken @@ -61,16 +64,20 @@ async def save_pr_metadata( pull_pattern = r'pull/(\d+)' merge_request_pattern = r'merge_requests/(\d+)' + pull_requests_pattern = r'pull-requests/(\d+)' # Check if the tool_result contains the PR number pr_number = None match_pull = re.search(pull_pattern, tool_result) match_merge_request = re.search(merge_request_pattern, tool_result) + match_pull_requests = re.search(pull_requests_pattern, tool_result) if match_pull: pr_number = int(match_pull.group(1)) elif match_merge_request: pr_number = int(match_merge_request.group(1)) + elif match_pull_requests: + pr_number = int(match_pull_requests.group(1)) if pr_number: logger.info(f'Saving PR number: {pr_number} for conversation {conversation_id}') @@ -292,6 +299,73 @@ async def create_bitbucket_pr( return response +@mcp_server.tool() +async def create_bitbucket_data_center_pr( + repo_name: Annotated[ + str, Field(description='Bitbucket Data Center repository (PROJECT/repo_slug)') + ], + source_branch: Annotated[str, Field(description='Source branch on repo')], + target_branch: Annotated[str, Field(description='Target branch on repo')], + title: Annotated[ + str, + Field( + description='PR Title. Start title with `DRAFT:` or `WIP:` if applicable.' + ), + ], + description: Annotated[str | None, Field(description='PR description')], +) -> str: + """Open a PR in Bitbucket Data Center""" + logger.info('Calling OpenHands MCP create_bitbucket_data_center_pr') + + request = get_http_request() + headers = request.headers + conversation_id = headers.get('X-OpenHands-ServerConversation-ID', None) + + provider_tokens = await get_provider_tokens(request) + access_token = await get_access_token(request) + user_id = await get_user_id(request) + + bitbucket_dc_token = ( + provider_tokens.get(ProviderType.BITBUCKET_DATA_CENTER, ProviderToken()) + if provider_tokens + else ProviderToken() + ) + + bitbucket_dc_service = BitbucketDCServiceImpl( + user_id=bitbucket_dc_token.user_id, + external_auth_id=user_id, + external_auth_token=access_token, + token=bitbucket_dc_token.token, + base_domain=bitbucket_dc_token.host, + ) + + try: + description = await get_conversation_link( + bitbucket_dc_service, conversation_id, description or '' + ) + except Exception as e: + logger.warning(f'Failed to append conversation link: {e}') + + try: + response = await bitbucket_dc_service.create_pr( + repo_name=repo_name, + source_branch=source_branch, + target_branch=target_branch, + title=title, + body=description, + ) + + if conversation_id: + await save_pr_metadata(user_id, conversation_id, response) + + except Exception as e: + error = f'Error creating pull request: {e}' + logger.error(error) + raise ToolError(str(error)) + + return response + + @mcp_server.tool() async def create_azure_devops_pr( repo_name: Annotated[ diff --git a/skills/bitbucket_data_center.md b/skills/bitbucket_data_center.md new file mode 100644 index 0000000000..147ce18624 --- /dev/null +++ b/skills/bitbucket_data_center.md @@ -0,0 +1,41 @@ +--- +name: bitbucket_data_center +type: knowledge +version: 1.0.0 +agent: CodeActAgent +triggers: +- bitbucket_data_center +- bitbucket data center +--- + +You have access to an environment variable, `BITBUCKET_DATA_CENTER_TOKEN`, which contains +a basic auth token in the format `username:your-token` that allows you to interact with the git repository. + +You can also use this token to interact with Bitbucket Data Center's REST API: +```bash +curl -u "${BITBUCKET_DATA_CENTER_TOKEN}" https://{domain}/rest/api/1.0/... +``` + + +ALWAYS use the Bitbucket Data Center API for operations instead of a web browser. +ALWAYS use the `create_bitbucket_data_center_pr` tool to open a pull request + + +If you encounter authentication issues when pushing to Bitbucket Data Center (such as password prompts or permission errors), the old token may have expired. In such case, update the remote URL to include the current token: `git remote set-url origin https://${BITBUCKET_DATA_CENTER_TOKEN}@{domain}/scm/{project_lower}/{repo}.git` + +The repository format for Bitbucket Data Center is `PROJECT/repo_slug` (project key, slash, repo slug). + +Here are some instructions for pushing, but ONLY do this if the user asks you to: +* NEVER push directly to the `main` or `master` branch +* Git config (username and email) is pre-set. Do not modify. +* You may already be on a branch starting with `openhands-workspace`. Create a new branch with a better name before pushing. +* Use the `create_bitbucket_data_center_pr` tool to create a pull request, if you haven't already +* Once you've created your own branch or a pull request, continue to update it. Do NOT create a new one unless you are explicitly asked to. Update the PR title and description as necessary, but don't change the branch name. +* Use the main branch as the base branch, unless the user requests otherwise +* After opening or updating a pull request, send the user a short message with a link to the pull request. +* Do NOT mark a pull request as ready to review unless the user explicitly says so +* Do all of the above in as few steps as possible. E.g. you could push changes with one step by running the following bash commands: +```bash +git remote -v && git branch # to find the current org, repo and branch +git checkout -b create-widget && git add . && git commit -m "Create widget" && git push -u origin create-widget +``` diff --git a/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc.py b/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc.py new file mode 100644 index 0000000000..8df2783cf9 --- /dev/null +++ b/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc.py @@ -0,0 +1,258 @@ +"""Tests for BitbucketDCService core: init, headers, get_user, pagination, email.""" + +import base64 +from unittest.mock import patch + +import pytest +from pydantic import SecretStr + +from openhands.integrations.bitbucket_data_center.bitbucket_dc_service import ( + BitbucketDCService, +) +from openhands.integrations.service_types import AuthenticationError, User +from openhands.server.types import AppMode + +# ── init / BASE_URL ─────────────────────────────────────────────────────────── + + +def test_init_plain_domain(): + svc = BitbucketDCService(token=SecretStr('tok'), base_domain='host.example.com') + assert svc.BASE_URL == 'https://host.example.com/rest/api/1.0' + + +def test_init_no_domain(): + svc = BitbucketDCService(token=SecretStr('tok'), base_domain=None) + assert svc.BASE_URL == '' + + +# ── token wrapping ──────────────────────────────────────────────────────────── + + +def test_token_wraps_simple_token(): + svc = BitbucketDCService(token=SecretStr('mytoken')) + assert svc.token.get_secret_value() == 'x-token-auth:mytoken' + + +def test_token_preserves_colon_token(): + svc = BitbucketDCService(token=SecretStr('alice:secret')) + assert svc.token.get_secret_value() == 'alice:secret' + + +# ── user_id derivation ──────────────────────────────────────────────────────── + + +def test_user_id_derived_from_username_password_token(): + svc = BitbucketDCService(token=SecretStr('alice:secret')) + assert svc.user_id == 'alice' + + +def test_user_id_not_derived_from_xtoken_auth_token(): + svc = BitbucketDCService(token=SecretStr('x-token-auth:mytoken')) + assert svc.user_id is None + + +def test_explicit_user_id_not_overridden(): + svc = BitbucketDCService(token=SecretStr('alice:secret'), user_id='bob') + assert svc.user_id == 'bob' + + +# ── _get_headers ────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_get_headers_basic_auth(): + svc = BitbucketDCService( + token=SecretStr('user:pass'), base_domain='host.example.com' + ) + headers = await svc._get_headers() + expected = 'Basic ' + base64.b64encode(b'user:pass').decode() + assert headers['Authorization'] == expected + + +@pytest.mark.asyncio +async def test_get_headers_xtoken_auth(): + svc = BitbucketDCService( + token=SecretStr('plaintoken'), base_domain='host.example.com' + ) + # plaintoken has no ':' so it gets wrapped as x-token-auth:plaintoken + headers = await svc._get_headers() + expected = 'Basic ' + base64.b64encode(b'x-token-auth:plaintoken').decode() + assert headers['Authorization'] == expected + + +# ── get_user ────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_get_user_with_user_id(): + svc = BitbucketDCService( + token=SecretStr('tok'), + base_domain='host.example.com', + user_id='jdoe', + ) + mock_response = { + 'values': [ + { + 'id': 5, + 'slug': 'jdoe', + 'name': 'jdoe', + 'displayName': 'J Doe', + 'emailAddress': 'j@example.com', + 'avatarUrl': '', + } + ] + } + with patch.object(svc, '_make_request', return_value=(mock_response, {})): + user = await svc.get_user() + + assert user.id == '5' + assert user.login == 'jdoe' + assert user.name == 'J Doe' + assert user.email == 'j@example.com' + + +@pytest.mark.asyncio +async def test_get_user_without_user_id(): + # x-token-auth tokens don't have a derivable username, so user_id stays None + svc = BitbucketDCService( + token=SecretStr('x-token-auth:mytoken'), base_domain='host.example.com' + ) + with patch.object(svc, '_make_request') as mock_req: + user = await svc.get_user() + mock_req.assert_not_called() + + assert isinstance(user, User) + assert user.id == '' + assert user.login == '' + + +@pytest.mark.asyncio +async def test_get_user_raises_when_not_found(): + svc = BitbucketDCService( + token=SecretStr('tok'), + base_domain='host.example.com', + user_id='jdoe', + ) + mock_response = {'values': []} + with patch.object(svc, '_make_request', return_value=(mock_response, {})): + with pytest.raises(AuthenticationError): + await svc.get_user() + + +# ── _resolve_primary_email ──────────────────────────────────────────────────── + + +def test_resolve_primary_email_selects_primary_confirmed(): + from openhands.integrations.bitbucket_data_center.service.base import ( + BitbucketDCMixinBase, + ) + + emails = [ + {'email': 'secondary@example.com', 'is_primary': False, 'is_confirmed': True}, + {'email': 'primary@example.com', 'is_primary': True, 'is_confirmed': True}, + { + 'email': 'unconfirmed@example.com', + 'is_primary': False, + 'is_confirmed': False, + }, + ] + result = BitbucketDCMixinBase._resolve_primary_email(emails) + assert result == 'primary@example.com' + + +def test_resolve_primary_email_returns_none_when_no_primary(): + from openhands.integrations.bitbucket_data_center.service.base import ( + BitbucketDCMixinBase, + ) + + emails = [ + {'email': 'a@example.com', 'is_primary': False, 'is_confirmed': True}, + {'email': 'b@example.com', 'is_primary': False, 'is_confirmed': True}, + ] + result = BitbucketDCMixinBase._resolve_primary_email(emails) + assert result is None + + +def test_resolve_primary_email_returns_none_when_primary_not_confirmed(): + from openhands.integrations.bitbucket_data_center.service.base import ( + BitbucketDCMixinBase, + ) + + emails = [ + {'email': 'primary@example.com', 'is_primary': True, 'is_confirmed': False}, + {'email': 'other@example.com', 'is_primary': False, 'is_confirmed': True}, + ] + result = BitbucketDCMixinBase._resolve_primary_email(emails) + assert result is None + + +def test_resolve_primary_email_returns_none_for_empty_list(): + from openhands.integrations.bitbucket_data_center.service.base import ( + BitbucketDCMixinBase, + ) + + result = BitbucketDCMixinBase._resolve_primary_email([]) + assert result is None + + +# ── get_user_emails ─────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_get_user_emails(): + svc = BitbucketDCService(token=SecretStr('tok'), base_domain='host.example.com') + mock_response = { + 'values': [ + {'email': 'primary@example.com', 'is_primary': True, 'is_confirmed': True}, + { + 'email': 'secondary@example.com', + 'is_primary': False, + 'is_confirmed': True, + }, + ] + } + with patch.object(svc, '_make_request', return_value=(mock_response, {})): + emails = await svc.get_user_emails() + + assert emails == mock_response['values'] + + +# ── pagination (get_all_repositories iterates projects) ────────────────────── + + +@pytest.mark.asyncio +async def test_pagination_iterates_projects(): + svc = BitbucketDCService(token=SecretStr('tok'), base_domain='host.example.com') + + def _repo_dict(key='PROJ', slug='myrepo'): + return {'id': 1, 'slug': slug, 'project': {'key': key}, 'public': False} + + async def fake_fetch(url, params, max_items): + if '/projects' in url and '/repos' not in url: + return [{'key': 'PROJ1'}, {'key': 'PROJ2'}] + if 'PROJ1' in url: + return [_repo_dict('PROJ1', 'repo1')] + if 'PROJ2' in url: + return [_repo_dict('PROJ2', 'repo2')] + return [] + + with patch.object(svc, '_fetch_paginated_data', side_effect=fake_fetch): + repos = await svc.get_all_repositories('name', AppMode.SAAS) + + full_names = {r.full_name for r in repos} + assert 'PROJ1/repo1' in full_names + assert 'PROJ2/repo2' in full_names + + +# ── verify_access ───────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_verify_access_makes_request(): + svc = BitbucketDCService(token=SecretStr('tok'), base_domain='host.example.com') + with patch.object(svc, '_make_request', return_value=({}, {})) as mock_req: + await svc.verify_access() + + mock_req.assert_called_once() + call_url = mock_req.call_args[0][0] + assert call_url.endswith('/repos') diff --git a/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_branches.py b/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_branches.py new file mode 100644 index 0000000000..2e829110a9 --- /dev/null +++ b/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_branches.py @@ -0,0 +1,139 @@ +"""Tests for BitbucketDCBranchesMixin: get_paginated_branches, search_branches, get_branches.""" + +from unittest.mock import patch + +import pytest +from pydantic import SecretStr + +from openhands.integrations.bitbucket_data_center.bitbucket_dc_service import ( + BitbucketDCService, +) +from openhands.integrations.service_types import Branch, PaginatedBranchesResponse + + +def make_service(): + return BitbucketDCService(token=SecretStr('tok'), base_domain='host.example.com') + + +def _dc_branch(display_id='main', commit='abc123'): + return {'displayId': display_id, 'latestCommit': commit} + + +# ── get_paginated_branches ──────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_get_paginated_branches_parses_display_id_and_commit(): + svc = make_service() + mock_response = { + 'values': [ + _dc_branch('main', 'abc'), + _dc_branch('feature/x', 'def'), + ], + 'isLastPage': True, + 'size': 2, + } + + with patch.object( + svc, '_make_request', return_value=(mock_response, {}) + ) as mock_req: + res = await svc.get_paginated_branches('PROJ/myrepo', page=1, per_page=30) + + # Verify the URL uses the DC format + call_url = mock_req.call_args[0][0] + assert '/projects/PROJ/repos/myrepo/branches' in call_url + + assert isinstance(res, PaginatedBranchesResponse) + assert res.branches == [ + Branch(name='main', commit_sha='abc', protected=False, last_push_date=None), + Branch( + name='feature/x', commit_sha='def', protected=False, last_push_date=None + ), + ] + + +@pytest.mark.asyncio +async def test_get_paginated_branches_has_next_page(): + svc = make_service() + mock_response = { + 'values': [_dc_branch()], + 'isLastPage': False, + 'nextPageStart': 30, + 'size': 100, + } + + with patch.object(svc, '_make_request', return_value=(mock_response, {})): + res = await svc.get_paginated_branches('PROJ/myrepo', page=1, per_page=30) + + assert res.has_next_page is True + + +@pytest.mark.asyncio +async def test_get_paginated_branches_last_page(): + svc = make_service() + mock_response = { + 'values': [_dc_branch()], + 'isLastPage': True, + 'size': 1, + } + + with patch.object(svc, '_make_request', return_value=(mock_response, {})): + res = await svc.get_paginated_branches('PROJ/myrepo', page=1, per_page=30) + + assert res.has_next_page is False + + +@pytest.mark.asyncio +async def test_get_paginated_branches_total_count(): + svc = make_service() + mock_response = { + 'values': [_dc_branch()], + 'isLastPage': True, + 'size': 42, + } + + with patch.object(svc, '_make_request', return_value=(mock_response, {})): + res = await svc.get_paginated_branches('PROJ/myrepo', page=1, per_page=30) + + assert res.total_count == 42 + + +# ── search_branches ─────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_search_branches_uses_filter_text(): + svc = make_service() + mock_response = {'values': [_dc_branch('feature/my-thing', 'sha1')]} + + with patch.object( + svc, '_make_request', return_value=(mock_response, {}) + ) as mock_req: + branches = await svc.search_branches( + 'PROJ/myrepo', query='my-thing', per_page=15 + ) + + call_url, call_params = mock_req.call_args[0] + assert 'filterText' in call_params + assert call_params['filterText'] == 'my-thing' + assert 'q' not in call_params + assert len(branches) == 1 + assert branches[0].name == 'feature/my-thing' + + +# ── get_branches (all pages via _fetch_paginated_data) ─────────────────────── + + +@pytest.mark.asyncio +async def test_get_branches_returns_all_pages(): + svc = make_service() + + async def fake_fetch(url, params, max_items): + return [_dc_branch('main', 'a'), _dc_branch('dev', 'b')] + + with patch.object(svc, '_fetch_paginated_data', side_effect=fake_fetch): + branches = await svc.get_branches('PROJ/myrepo') + + assert len(branches) == 2 + assert branches[0].name == 'main' + assert branches[1].name == 'dev' diff --git a/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_prs.py b/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_prs.py new file mode 100644 index 0000000000..ad217821d6 --- /dev/null +++ b/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_prs.py @@ -0,0 +1,138 @@ +"""Tests for BitbucketDCPRsMixin: create_pr, get_pr_details, is_pr_open.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from pydantic import SecretStr + +from openhands.integrations.bitbucket_data_center.bitbucket_dc_service import ( + BitbucketDCService, +) + + +def make_service(): + return BitbucketDCService(token=SecretStr('tok'), base_domain='host.example.com') + + +# ── create_pr ───────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_create_pr_payload_structure(): + svc = make_service() + mock_response = { + 'id': 1, + 'links': {'self': [{'href': 'https://host.example.com/pr/1'}]}, + } + + with patch.object( + svc, '_make_request', return_value=(mock_response, {}) + ) as mock_req: + await svc.create_pr('PROJ/myrepo', 'feature', 'main', 'My PR') + + # The payload is passed as the 'params' positional arg + payload = mock_req.call_args[1].get('params') or mock_req.call_args[0][1] + assert payload['fromRef']['id'] == 'refs/heads/feature' + assert payload['toRef']['id'] == 'refs/heads/main' + assert payload['fromRef']['repository']['slug'] == 'myrepo' + assert payload['fromRef']['repository']['project']['key'] == 'PROJ' + + +@pytest.mark.asyncio +async def test_create_pr_returns_href(): + svc = make_service() + mock_response = { + 'id': 5, + 'links': {'self': [{'href': 'https://host.example.com/pr/5'}]}, + } + + with patch.object(svc, '_make_request', return_value=(mock_response, {})): + url = await svc.create_pr('PROJ/myrepo', 'feature', 'main', 'My PR') + + assert url == 'https://host.example.com/pr/5' + + +@pytest.mark.asyncio +async def test_create_pr_html_link_dict(): + svc = make_service() + mock_response = { + 'id': 5, + 'links': {'html': {'href': 'https://host.example.com/pr/5/html'}}, + } + + with patch.object(svc, '_make_request', return_value=(mock_response, {})): + url = await svc.create_pr('PROJ/myrepo', 'feature', 'main', 'My PR') + + assert url == 'https://host.example.com/pr/5/html' + + +@pytest.mark.asyncio +async def test_create_pr_no_link_returns_empty_string(): + svc = make_service() + mock_response = {'id': 5, 'links': {}} + + with patch.object(svc, '_make_request', return_value=(mock_response, {})): + url = await svc.create_pr('PROJ/myrepo', 'feature', 'main', 'My PR') + + assert url == '' + + +# ── get_pr_details ──────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_get_pr_details_returns_raw_data(): + svc = make_service() + mock_data = {'id': 3, 'state': 'OPEN', 'title': 'A PR'} + + with patch.object(svc, '_make_request', return_value=(mock_data, {})): + result = await svc.get_pr_details('PROJ/myrepo', 3) + + assert result == mock_data + + +# ── is_pr_open ──────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_is_pr_open_returns_true(): + svc = make_service() + + with patch.object( + svc, 'get_pr_details', new=AsyncMock(return_value={'state': 'OPEN'}) + ): + assert await svc.is_pr_open('PROJ/myrepo', 1) is True + + +@pytest.mark.asyncio +async def test_is_pr_open_returns_false_for_merged(): + svc = make_service() + + with patch.object( + svc, 'get_pr_details', new=AsyncMock(return_value={'state': 'MERGED'}) + ): + assert await svc.is_pr_open('PROJ/myrepo', 1) is False + + +@pytest.mark.asyncio +async def test_is_pr_open_returns_false_for_declined(): + svc = make_service() + + with patch.object( + svc, 'get_pr_details', new=AsyncMock(return_value={'state': 'DECLINED'}) + ): + assert await svc.is_pr_open('PROJ/myrepo', 1) is False + + +@pytest.mark.asyncio +async def test_is_pr_open_returns_true_on_exception(): + """Current implementation catches all exceptions and returns True.""" + svc = make_service() + + with patch.object( + svc, + 'get_pr_details', + new=AsyncMock(side_effect=Exception('Some error')), + ): + result = await svc.is_pr_open('PROJ/myrepo', 999) + assert result is True diff --git a/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_repos.py b/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_repos.py new file mode 100644 index 0000000000..521dd61455 --- /dev/null +++ b/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_repos.py @@ -0,0 +1,355 @@ +"""Tests for BitbucketDCReposMixin: URL parsing, get_paginated_repos, get_all_repositories.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from pydantic import SecretStr + +from openhands.integrations.bitbucket_data_center.bitbucket_dc_service import ( + BitbucketDCService, +) +from openhands.server.types import AppMode + + +def make_service(): + return BitbucketDCService(token=SecretStr('tok'), base_domain='host.example.com') + + +def _repo_dict(key='PROJ', slug='myrepo', name='My Repository'): + return { + 'id': 1, + 'slug': slug, + 'name': name, + 'project': {'key': key}, + 'public': False, + } + + +# ── search_repositories URL parsing ────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_search_repositories_projects_url(): + svc = make_service() + query = 'https://host.example.com/projects/PROJ/repos/myrepo' + + mock_repo_data = _repo_dict('PROJ', 'myrepo') + mock_response = {'id': 1, **mock_repo_data} + mock_default_branch = {'displayId': 'main'} + + with patch.object( + svc, + '_make_request', + side_effect=[ + (mock_response, {}), + (mock_default_branch, {}), + ], + ): + repos = await svc.search_repositories( + query, 25, 'name', 'asc', True, AppMode.SAAS + ) + + assert len(repos) == 1 + assert repos[0].full_name == 'PROJ/myrepo' + + +@pytest.mark.asyncio +async def test_search_repositories_projects_url_with_extra_segments(): + svc = make_service() + # URL with extra segments after repo name + query = 'https://host.example.com/projects/PROJ/repos/myrepo/browse/src/main.py' + + mock_repo_data = _repo_dict('PROJ', 'myrepo') + mock_default_branch = {'displayId': 'main'} + + with patch.object( + svc, + '_make_request', + side_effect=[ + (mock_repo_data, {}), + (mock_default_branch, {}), + ], + ): + repos = await svc.search_repositories( + query, 25, 'name', 'asc', True, AppMode.SAAS + ) + + assert len(repos) == 1 + assert repos[0].full_name == 'PROJ/myrepo' + + +@pytest.mark.asyncio +async def test_search_repositories_invalid_url(): + svc = make_service() + with patch.object(svc, '_make_request') as mock_req: + repos = await svc.search_repositories( + 'not-a-valid-url', 25, 'name', 'asc', True, AppMode.SAAS + ) + assert repos == [] + mock_req.assert_not_called() + + +@pytest.mark.asyncio +async def test_search_repositories_insufficient_path_segments(): + svc = make_service() + # URL with only one path segment (just a project, no repo) + with patch.object(svc, '_make_request') as mock_req: + repos = await svc.search_repositories( + 'https://host.example.com/projects/PROJ', + 25, + 'name', + 'asc', + True, + AppMode.SAAS, + ) + assert repos == [] + mock_req.assert_not_called() + + +@pytest.mark.asyncio +async def test_search_repositories_slash_query(): + svc = make_service() + query = 'PROJ/myrepo' + + mock_repo = _repo_dict('PROJ', slug='myrepo', name='My Repository') + mock_default_branch = {'displayId': 'main'} + + with patch.object( + svc, + '_fetch_paginated_data', + new=AsyncMock(return_value=[mock_repo]), + ) as mock_fetch: + with patch.object( + svc, + '_make_request', + new=AsyncMock(return_value=(mock_default_branch, {})), + ): + repos = await svc.search_repositories( + query, 25, 'name', 'asc', False, AppMode.SAAS + ) + + mock_fetch.assert_called_once_with( + 'https://host.example.com/rest/api/1.0/projects/PROJ/repos', + {'limit': 25}, + 1000, + ) + assert len(repos) == 1 + assert repos[0].full_name == 'PROJ/myrepo' + + +@pytest.mark.asyncio +async def test_search_repositories_slash_query_filters_by_name(): + """Filter matches the human-readable name when slug doesn't match.""" + svc = make_service() + matching = _repo_dict('PROJ', slug='proj-alpha', name='My Repository') + non_matching = _repo_dict('PROJ', slug='proj-beta', name='Other Repo') + mock_default_branch = {'displayId': 'main'} + + with patch.object( + svc, + '_fetch_paginated_data', + new=AsyncMock(return_value=[matching, non_matching]), + ): + with patch.object( + svc, + '_make_request', + new=AsyncMock(return_value=(mock_default_branch, {})), + ): + repos = await svc.search_repositories( + 'PROJ/my repository', 25, 'name', 'asc', False, AppMode.SAAS + ) + + assert len(repos) == 1 + assert repos[0].full_name == 'PROJ/proj-alpha' + + +@pytest.mark.asyncio +async def test_search_repositories_slash_query_filters_by_slug(): + """Filter matches the slug when the human-readable name doesn't match.""" + svc = make_service() + matching = _repo_dict('PROJ', slug='my-repo', name='My Repository') + non_matching = _repo_dict('PROJ', slug='other-repo', name='Other Repository') + mock_default_branch = {'displayId': 'main'} + + with patch.object( + svc, + '_fetch_paginated_data', + new=AsyncMock(return_value=[matching, non_matching]), + ): + with patch.object( + svc, + '_make_request', + new=AsyncMock(return_value=(mock_default_branch, {})), + ): + repos = await svc.search_repositories( + 'PROJ/my-repo', 25, 'name', 'asc', False, AppMode.SAAS + ) + + assert len(repos) == 1 + assert repos[0].full_name == 'PROJ/my-repo' + + +# ── get_paginated_repos ─────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_get_paginated_repos_parses_values(): + svc = make_service() + mock_response = { + 'values': [_repo_dict()], + 'isLastPage': True, + } + mock_default_branch = {'displayId': 'main'} + + with patch.object( + svc, + '_make_request', + side_effect=[(mock_response, {}), (mock_default_branch, {})], + ): + repos = await svc.get_paginated_repos(1, 25, 'name', 'PROJ') + + assert len(repos) == 1 + assert repos[0].full_name == 'PROJ/myrepo' + assert repos[0].link_header == '' + + +@pytest.mark.asyncio +async def test_get_paginated_repos_has_next_page(): + svc = make_service() + mock_response = { + 'values': [_repo_dict()], + 'isLastPage': False, + 'nextPageStart': 25, + } + mock_default_branch = {'displayId': 'main'} + + with patch.object( + svc, + '_make_request', + side_effect=[(mock_response, {}), (mock_default_branch, {})], + ): + repos = await svc.get_paginated_repos(1, 25, 'name', 'PROJ') + + assert len(repos) == 1 + assert 'rel="next"' in repos[0].link_header + + +@pytest.mark.asyncio +async def test_get_paginated_repos_last_page(): + svc = make_service() + mock_response = { + 'values': [_repo_dict()], + 'isLastPage': True, + } + mock_default_branch = {'displayId': 'main'} + + with patch.object( + svc, + '_make_request', + side_effect=[(mock_response, {}), (mock_default_branch, {})], + ): + repos = await svc.get_paginated_repos(1, 25, 'name', 'PROJ') + + assert len(repos) == 1 + assert repos[0].link_header == '' + + +@pytest.mark.asyncio +async def test_get_paginated_repos_filters_by_slug(): + """Query matches slug when name doesn't contain the search term.""" + svc = make_service() + mock_response = { + 'values': [ + _repo_dict('PROJ', slug='my-repo', name='My Repository'), + _repo_dict('PROJ', slug='other-repo', name='Other Repository'), + ], + 'isLastPage': True, + } + mock_default_branch = {'displayId': 'main'} + + with patch.object( + svc, + '_make_request', + side_effect=[(mock_response, {}), (mock_default_branch, {})], + ): + repos = await svc.get_paginated_repos(1, 25, 'name', 'PROJ', query='my-repo') + + assert len(repos) == 1 + assert repos[0].full_name == 'PROJ/my-repo' + + +@pytest.mark.asyncio +async def test_get_paginated_repos_filters_by_name(): + """Query matches human-readable name when slug doesn't contain the search term.""" + svc = make_service() + mock_response = { + 'values': [ + _repo_dict('PROJ', slug='proj-alpha', name='My Repository'), + _repo_dict('PROJ', slug='proj-beta', name='Other Repository'), + ], + 'isLastPage': True, + } + mock_default_branch = {'displayId': 'main'} + + with patch.object( + svc, + '_make_request', + side_effect=[(mock_response, {}), (mock_default_branch, {})], + ): + repos = await svc.get_paginated_repos( + 1, 25, 'name', 'PROJ', query='my repository' + ) + + assert len(repos) == 1 + assert repos[0].full_name == 'PROJ/proj-alpha' + + +# ── get_all_repositories ────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_get_all_repositories_iterates_projects(): + svc = make_service() + + async def fake_fetch(url, params, max_items): + if '/projects' in url and '/repos' not in url: + return [{'key': 'PROJ1'}, {'key': 'PROJ2'}] + if 'PROJ1' in url: + return [_repo_dict('PROJ1', 'repo1')] + if 'PROJ2' in url: + return [_repo_dict('PROJ2', 'repo2')] + return [] + + mock_default_branch = {'displayId': 'main'} + with patch.object(svc, '_fetch_paginated_data', side_effect=fake_fetch): + with patch.object(svc, '_make_request', return_value=(mock_default_branch, {})): + repos = await svc.get_all_repositories('name', AppMode.SAAS) + + full_names = {r.full_name for r in repos} + assert 'PROJ1/repo1' in full_names + assert 'PROJ2/repo2' in full_names + + +# ── get_installations ───────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_get_installations_returns_project_keys(): + svc = make_service() + + async def fake_fetch(url, params, max_items): + return [{'key': 'PROJ1'}, {'key': 'PROJ2'}, {'name': 'no-key'}] + + with patch.object(svc, '_fetch_paginated_data', side_effect=fake_fetch): + keys = await svc.get_installations() + + assert keys == ['PROJ1', 'PROJ2'] + + +# ── helper ──────────────────────────────────────────────────────────────────── + + +async def _make_parsed_repo(svc, repo_dict): + """Helper to create a parsed Repository from a repo dict (with mocked default branch).""" + with patch.object(svc, '_make_request', return_value=({'displayId': 'main'}, {})): + return await svc._parse_repository(repo_dict) diff --git a/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_resolver.py b/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_resolver.py new file mode 100644 index 0000000000..fbc7078976 --- /dev/null +++ b/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_resolver.py @@ -0,0 +1,179 @@ +"""Tests for BitbucketDCResolverMixin: get_pr_title_and_body, get_pr_comments, _process_raw_comments.""" + +from unittest.mock import patch + +import pytest +from pydantic import SecretStr + +from openhands.integrations.bitbucket_data_center.bitbucket_dc_service import ( + BitbucketDCService, +) +from openhands.integrations.service_types import Comment + + +@pytest.fixture +def svc(): + return BitbucketDCService( + token=SecretStr('user:pass'), base_domain='host.example.com' + ) + + +# ── get_pr_title_and_body ───────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_get_pr_title_and_body(svc): + mock_response = {'title': 'Fix the bug', 'description': 'Detailed description'} + with patch.object( + svc, '_make_request', return_value=(mock_response, {}) + ) as mock_req: + title, body = await svc.get_pr_title_and_body('PROJ', 'myrepo', 42) + + assert title == 'Fix the bug' + assert body == 'Detailed description' + called_url = mock_req.call_args[0][0] + assert '/projects/PROJ/repos/myrepo/pull-requests/42' in called_url + + +@pytest.mark.asyncio +async def test_get_pr_title_and_body_missing_fields(svc): + with patch.object(svc, '_make_request', return_value=({}, {})): + title, body = await svc.get_pr_title_and_body('PROJ', 'myrepo', 1) + + assert title == '' + assert body == '' + + +# ── get_pr_comments ─────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_get_pr_comments_returns_comments(svc): + activities = { + 'values': [ + { + 'action': 'COMMENTED', + 'comment': { + 'id': 10, + 'text': 'Looks good!', + 'author': {'slug': 'alice', 'name': 'Alice'}, + 'createdDate': 1_700_000_000_000, + 'updatedDate': 1_700_000_000_000, + }, + }, + { + 'action': 'APPROVED', # should be ignored + 'comment': {}, + }, + { + 'action': 'COMMENTED', + 'comment': { + 'id': 11, + 'text': 'Please fix tests', + 'author': {'slug': 'bob', 'name': 'Bob'}, + 'createdDate': 1_700_000_001_000, + 'updatedDate': 1_700_000_001_000, + }, + }, + ], + 'isLastPage': True, + } + + with patch.object(svc, '_make_request', return_value=(activities, {})): + comments = await svc.get_pr_comments('PROJ', 'myrepo', 42, max_comments=10) + + assert len(comments) == 2 + assert all(isinstance(c, Comment) for c in comments) + assert comments[0].author == 'alice' + assert comments[0].body == 'Looks good!' + assert comments[1].author == 'bob' + + +@pytest.mark.asyncio +async def test_get_pr_comments_respects_max(svc): + activities = { + 'values': [ + { + 'action': 'COMMENTED', + 'comment': { + 'id': i, + 'text': f'comment {i}', + 'author': {'slug': f'user{i}'}, + 'createdDate': 1_700_000_000_000 + i * 1000, + 'updatedDate': 1_700_000_000_000 + i * 1000, + }, + } + for i in range(10) + ], + 'isLastPage': True, + } + + with patch.object(svc, '_make_request', return_value=(activities, {})): + comments = await svc.get_pr_comments('PROJ', 'myrepo', 1, max_comments=3) + + assert len(comments) == 3 + + +@pytest.mark.asyncio +async def test_get_pr_comments_empty(svc): + with patch.object( + svc, '_make_request', return_value=({'values': [], 'isLastPage': True}, {}) + ): + comments = await svc.get_pr_comments('PROJ', 'myrepo', 1) + + assert comments == [] + + +# ── _process_raw_comments ───────────────────────────────────────────────────── + + +def test_process_raw_comments_sorts_by_date(svc): + raw = [ + { + 'id': 2, + 'text': 'second', + 'author': {'slug': 'bob'}, + 'createdDate': 1_700_000_002_000, + 'updatedDate': 1_700_000_002_000, + }, + { + 'id': 1, + 'text': 'first', + 'author': {'slug': 'alice'}, + 'createdDate': 1_700_000_001_000, + 'updatedDate': 1_700_000_001_000, + }, + ] + comments = svc._process_raw_comments(raw, max_comments=10) + assert comments[0].id == '1' + assert comments[1].id == '2' + + +def test_process_raw_comments_missing_timestamps(svc): + raw = [{'id': 5, 'text': 'no dates', 'author': {'slug': 'eve'}}] + comments = svc._process_raw_comments(raw) + assert len(comments) == 1 + assert comments[0].id == '5' + + +# ── MRO check ───────────────────────────────────────────────────────────────── + + +def test_mro_includes_resolver_mixin_and_base_git_service(): + from openhands.integrations.bitbucket_data_center.service.resolver import ( + BitbucketDCResolverMixin, + ) + from openhands.integrations.service_types import BaseGitService + + mro_names = [cls.__name__ for cls in BitbucketDCService.__mro__] + assert 'BitbucketDCResolverMixin' in mro_names + assert 'BaseGitService' in mro_names + + # Resolver mixin should appear before BaseGitService + assert mro_names.index('BitbucketDCResolverMixin') < mro_names.index( + 'BaseGitService' + ) + + # Verify instances + assert issubclass(BitbucketDCService, BitbucketDCResolverMixin) + assert issubclass(BitbucketDCService, BaseGitService) diff --git a/tests/unit/resolver/bitbucket_dc/test_bitbucket_dc_issue_handler.py b/tests/unit/resolver/bitbucket_dc/test_bitbucket_dc_issue_handler.py new file mode 100644 index 0000000000..617a0db8f7 --- /dev/null +++ b/tests/unit/resolver/bitbucket_dc/test_bitbucket_dc_issue_handler.py @@ -0,0 +1,357 @@ +import base64 +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from pydantic import SecretStr + +from openhands.core.config import LLMConfig +from openhands.integrations.service_types import ProviderType +from openhands.resolver.interfaces.bitbucket_data_center import ( + BitbucketDCIssueHandler, + BitbucketDCPRHandler, +) +from openhands.resolver.interfaces.issue_definitions import ( + ServiceContextIssue, + ServiceContextPR, +) +from openhands.resolver.issue_handler_factory import IssueHandlerFactory + + +@pytest.fixture +def handler(): + return BitbucketDCIssueHandler( + owner='PROJ', + repo='my-repo', + token='user:secret', + base_domain='bitbucket.example.com', + ) + + +@pytest.fixture +def llm_config(): + return LLMConfig(model='test-model', api_key=SecretStr('test-key')) + + +# --------------------------------------------------------------------------- +# URL / attribute tests +# --------------------------------------------------------------------------- + + +def test_init_sets_correct_urls(handler): + assert handler.base_url == 'https://bitbucket.example.com/rest/api/1.0' + assert ( + handler.download_url + == 'https://bitbucket.example.com/rest/api/latest/projects/PROJ/repos/my-repo/archive?format=zip' + ) + assert handler.clone_url == 'https://bitbucket.example.com/scm/proj/my-repo.git' + + +def test_get_headers_returns_basic_auth(handler): + expected = base64.b64encode(b'user:secret').decode() + headers = handler.get_headers() + assert headers['Authorization'] == f'Basic {expected}' + assert headers['Accept'] == 'application/json' + + +def test_get_headers_bare_token_with_username(): + h = BitbucketDCIssueHandler( + owner='PROJ', + repo='my-repo', + token='mytoken', + username='myuser', + base_domain='dc.example.com', + ) + expected = base64.b64encode(b'myuser:mytoken').decode() + assert h.headers['Authorization'] == f'Basic {expected}' + + +def test_get_repo_url(handler): + assert ( + handler.get_repo_url() + == 'https://bitbucket.example.com/projects/PROJ/repos/my-repo' + ) + + +def test_get_issue_url_returns_pr_url(handler): + assert ( + handler.get_issue_url(42) + == 'https://bitbucket.example.com/projects/PROJ/repos/my-repo/pull-requests/42' + ) + + +def test_get_branch_url(handler): + assert ( + handler.get_branch_url('feature/x') + == 'https://bitbucket.example.com/projects/PROJ/repos/my-repo/browse?at=refs/heads/feature/x' + ) + + +def test_get_authorize_url_with_colon_token(handler): + url = handler.get_authorize_url() + assert url == 'https://user:secret@bitbucket.example.com/' + + +def test_get_authorize_url_with_username_and_bare_token(): + h = BitbucketDCIssueHandler( + owner='PROJ', + repo='my-repo', + token='baretoken', + username='john', + base_domain='dc.example.com', + ) + assert h.get_authorize_url() == 'https://john:baretoken@dc.example.com/' + + +# --------------------------------------------------------------------------- +# get_compare_url (requires get_default_branch_name) +# --------------------------------------------------------------------------- + + +def test_get_compare_url(handler): + with patch.object(handler, 'get_default_branch_name', return_value='main'): + url = handler.get_compare_url('feature/fix') + assert url == ( + 'https://bitbucket.example.com/projects/PROJ/repos/my-repo/compare/commits' + '?sourceBranch=refs/heads/feature/fix&targetBranch=refs/heads/main' + ) + + +# --------------------------------------------------------------------------- +# API methods (mock httpx) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_issue_fetches_pr_endpoint(handler): + mock_response = MagicMock() + mock_response.json.return_value = { + 'id': 7, + 'title': 'Fix the thing', + 'description': 'Some body', + 'fromRef': {'displayId': 'feature/fix'}, + 'toRef': {'displayId': 'main'}, + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch('httpx.AsyncClient', return_value=mock_client): + issue = await handler.get_issue(7) + + expected_url = ( + 'https://bitbucket.example.com/rest/api/1.0' + '/projects/PROJ/repos/my-repo/pull-requests/7' + ) + mock_client.get.assert_called_once_with(expected_url, headers=handler.headers) + assert issue.number == 7 + assert issue.title == 'Fix the thing' + assert issue.body == 'Some body' + assert issue.head_branch == 'feature/fix' + assert issue.base_branch == 'main' + + +def test_create_pr_uses_from_to_ref(handler): + mock_response = MagicMock() + mock_response.json.return_value = { + 'id': 3, + 'links': { + 'self': [ + { + 'href': 'https://bitbucket.example.com/projects/PROJ/repos/my-repo/pull-requests/3' + } + ] + }, + } + mock_response.raise_for_status = MagicMock() + + with patch('httpx.post', return_value=mock_response) as mock_post: + url = handler.create_pr('Title', 'Body', 'feature/src', 'main') + + _, kwargs = mock_post.call_args + payload = kwargs['json'] + assert payload['fromRef']['id'] == 'refs/heads/feature/src' + assert payload['toRef']['id'] == 'refs/heads/main' + assert payload['fromRef']['repository']['slug'] == 'my-repo' + assert payload['fromRef']['repository']['project']['key'] == 'PROJ' + assert ( + url + == 'https://bitbucket.example.com/projects/PROJ/repos/my-repo/pull-requests/3' + ) + + +def test_create_pull_request_returns_html_url_and_number(handler): + mock_response = MagicMock() + mock_response.json.return_value = { + 'id': 5, + 'links': { + 'self': [ + { + 'href': 'https://bitbucket.example.com/projects/PROJ/repos/my-repo/pull-requests/5' + } + ] + }, + } + mock_response.raise_for_status = MagicMock() + + with patch('httpx.post', return_value=mock_response): + result = handler.create_pull_request( + { + 'title': 'My PR', + 'description': 'desc', + 'source_branch': 'feature/x', + 'target_branch': 'main', + } + ) + + assert result['number'] == 5 + assert result['html_url'] == ( + 'https://bitbucket.example.com/projects/PROJ/repos/my-repo/pull-requests/5' + ) + + +def test_send_comment_msg_uses_text_field(handler): + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + + with patch('httpx.post', return_value=mock_response) as mock_post: + handler.send_comment_msg(7, 'Hello from OpenHands') + + _, kwargs = mock_post.call_args + assert kwargs['json'] == {'text': 'Hello from OpenHands'} + + +def test_reply_to_comment_posts_with_parent_id(handler): + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + + with patch('httpx.post', return_value=mock_response) as mock_post: + handler.reply_to_comment(7, '42', 'My reply') + + _, kwargs = mock_post.call_args + assert kwargs['json']['text'] == 'My reply' + assert kwargs['json']['parent'] == {'id': 42} + + +def test_branch_exists_true(handler): + mock_response = MagicMock() + mock_response.json.return_value = { + 'values': [{'displayId': 'feature/fix', 'id': 'refs/heads/feature/fix'}] + } + mock_response.raise_for_status = MagicMock() + + with patch('httpx.get', return_value=mock_response): + assert handler.branch_exists('feature/fix') is True + + +def test_branch_exists_false(handler): + mock_response = MagicMock() + mock_response.json.return_value = {'values': []} + mock_response.raise_for_status = MagicMock() + + with patch('httpx.get', return_value=mock_response): + assert handler.branch_exists('nonexistent') is False + + +def test_branch_exists_no_match(handler): + mock_response = MagicMock() + # filterText returns similar but not exact match + mock_response.json.return_value = { + 'values': [{'displayId': 'feature/fix-extended'}] + } + mock_response.raise_for_status = MagicMock() + + with patch('httpx.get', return_value=mock_response): + assert handler.branch_exists('feature/fix') is False + + +def test_get_default_branch_name_reads_display_id(handler): + mock_response = MagicMock() + mock_response.json.return_value = { + 'defaultBranch': {'displayId': 'main', 'id': 'refs/heads/main'} + } + mock_response.raise_for_status = MagicMock() + + with patch('httpx.get', return_value=mock_response): + assert handler.get_default_branch_name() == 'main' + + +def test_get_default_branch_name_strips_refs_heads_prefix(handler): + mock_response = MagicMock() + mock_response.json.return_value = { + 'defaultBranch': {'displayId': 'refs/heads/develop'} + } + mock_response.raise_for_status = MagicMock() + + with patch('httpx.get', return_value=mock_response): + assert handler.get_default_branch_name() == 'develop' + + +def test_get_default_branch_name_fallback_to_master(handler): + import httpx as httpx_module + + with patch('httpx.get', side_effect=httpx_module.HTTPError('connection refused')): + assert handler.get_default_branch_name() == 'master' + + +def test_get_context_returns_early(handler): + """get_context_from_external_issues_references should return closing_issues without API calls.""" + closing_issues = ['issue body 1'] + with patch('httpx.get') as mock_get: + result = handler.get_context_from_external_issues_references( + closing_issues=closing_issues, + closing_issue_numbers=[1], + issue_body='some body', + review_comments=None, + review_threads=[], + thread_comments=None, + ) + mock_get.assert_not_called() + assert result == ['issue body 1'] + + +def test_download_issues_returns_empty(handler): + result = handler.download_issues() + assert result == [] + + +# --------------------------------------------------------------------------- +# Factory integration tests +# --------------------------------------------------------------------------- + + +def test_factory_creates_dc_issue_handler(llm_config): + factory = IssueHandlerFactory( + owner='PROJ', + repo='my-repo', + token='user:secret', + username='user', + platform=ProviderType.BITBUCKET_DATA_CENTER, + base_domain='bitbucket.example.com', + issue_type='issue', + llm_config=llm_config, + ) + ctx = factory.create() + assert isinstance(ctx, ServiceContextIssue) + assert isinstance(ctx._strategy, BitbucketDCIssueHandler) + assert ctx._strategy.owner == 'PROJ' + assert ctx._strategy.repo == 'my-repo' + assert ctx._strategy.base_domain == 'bitbucket.example.com' + + +def test_factory_creates_dc_pr_handler(llm_config): + factory = IssueHandlerFactory( + owner='PROJ', + repo='my-repo', + token='user:secret', + username='user', + platform=ProviderType.BITBUCKET_DATA_CENTER, + base_domain='bitbucket.example.com', + issue_type='pr', + llm_config=llm_config, + ) + ctx = factory.create() + assert isinstance(ctx, ServiceContextPR) + assert isinstance(ctx._strategy, BitbucketDCPRHandler)