Add Bitbucket microagent and backend implementation (#9021)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
This commit is contained in:
Graham Neubig 2025-06-18 00:04:29 -04:00 committed by GitHub
parent b7efeb11d9
commit e074b2d36f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 2174 additions and 115 deletions

View File

@ -50,13 +50,13 @@ const renderRepoConnector = () => {
const MOCK_RESPOSITORIES: GitRepository[] = [
{
id: 1,
id: "1",
full_name: "rbren/polaris",
git_provider: "github",
is_public: true,
},
{
id: 2,
id: "2",
full_name: "All-Hands-AI/OpenHands",
git_provider: "github",
is_public: true,

View File

@ -94,13 +94,13 @@ describe("RepositorySelectionForm", () => {
it("shows loading indicator when repositories are being fetched", () => {
const MOCK_REPOS: GitRepository[] = [
{
id: 1,
id: "1",
full_name: "user/repo1",
git_provider: "github",
is_public: true,
},
{
id: 2,
id: "2",
full_name: "user/repo2",
git_provider: "github",
is_public: true,
@ -122,13 +122,13 @@ describe("RepositorySelectionForm", () => {
it("shows dropdown when repositories are loaded", async () => {
const MOCK_REPOS: GitRepository[] = [
{
id: 1,
id: "1",
full_name: "user/repo1",
git_provider: "github",
is_public: true,
},
{
id: 2,
id: "2",
full_name: "user/repo2",
git_provider: "github",
is_public: true,
@ -166,13 +166,13 @@ describe("RepositorySelectionForm", () => {
it("should call the search repos API when searching a URL", async () => {
const MOCK_REPOS: GitRepository[] = [
{
id: 1,
id: "1",
full_name: "user/repo1",
git_provider: "github",
is_public: true,
},
{
id: 2,
id: "2",
full_name: "user/repo2",
git_provider: "github",
is_public: true,
@ -181,7 +181,7 @@ describe("RepositorySelectionForm", () => {
const MOCK_SEARCH_REPOS: GitRepository[] = [
{
id: 3,
id: "3",
full_name: "kubernetes/kubernetes",
git_provider: "github",
is_public: true,
@ -228,7 +228,7 @@ describe("RepositorySelectionForm", () => {
it("should call onRepoSelection when a searched repository is selected", async () => {
const MOCK_SEARCH_REPOS: GitRepository[] = [
{
id: 3,
id: "3",
full_name: "kubernetes/kubernetes",
git_provider: "github",
is_public: true,

View File

@ -19,10 +19,10 @@ const MOCK_TASK_1: SuggestedTask = {
};
const MOCK_RESPOSITORIES: GitRepository[] = [
{ id: 1, full_name: "repo1", git_provider: "github", is_public: true },
{ id: 2, full_name: "repo2", git_provider: "github", is_public: true },
{ id: 3, full_name: "repo3", git_provider: "gitlab", is_public: true },
{ id: 4, full_name: "repo4", git_provider: "gitlab", is_public: true },
{ id: "1", full_name: "repo1", git_provider: "github", is_public: true },
{ id: "2", full_name: "repo2", git_provider: "github", is_public: true },
{ id: "3", full_name: "repo3", git_provider: "gitlab", is_public: true },
{ id: "4", full_name: "repo4", git_provider: "gitlab", is_public: true },
];
const renderTaskCard = (task = MOCK_TASK_1) => {

View File

@ -89,6 +89,9 @@ describe("Content", () => {
await screen.findByTestId("gitlab-token-input");
await screen.findByTestId("gitlab-token-help-anchor");
await screen.findByTestId("bitbucket-token-input");
await screen.findByTestId("bitbucket-token-help-anchor");
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
queryClient.invalidateQueries();
rerender();
@ -107,6 +110,13 @@ describe("Content", () => {
expect(
screen.queryByTestId("gitlab-token-help-anchor"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("bitbucket-token-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("bitbucket-token-help-anchor"),
).not.toBeInTheDocument();
});
});
@ -229,6 +239,7 @@ describe("Content", () => {
describe("Form submission", () => {
it("should save the GitHub token", async () => {
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
@ -243,15 +254,49 @@ describe("Form submission", () => {
expect(saveProvidersSpy).toHaveBeenCalledWith({
github: { token: "test-token", host: "" },
gitlab: { token: "", host: "" },
bitbucket: { token: "", host: "" },
});
});
it("should save GitLab tokens", async () => {
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
const gitlabInput = await screen.findByTestId("gitlab-token-input");
const submit = await screen.findByTestId("submit-button");
await userEvent.type(gitlabInput, "test-token");
await userEvent.click(submit);
expect(saveProvidersSpy).toHaveBeenCalledWith({
github: { token: "test-token", host: "" },
github: { token: "", host: "" },
gitlab: { token: "test-token", host: "" },
bitbucket: { token: "", host: "" },
});
});
it("should save the Bitbucket token", async () => {
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
const bitbucketInput = await screen.findByTestId("bitbucket-token-input");
const submit = await screen.findByTestId("submit-button");
await userEvent.type(bitbucketInput, "test-token");
await userEvent.click(submit);
expect(saveProvidersSpy).toHaveBeenCalledWith({
github: { token: "", host: "" },
gitlab: { token: "", host: "" },
bitbucket: { token: "test-token", host: "" },
});
});

View File

@ -45,13 +45,13 @@ const renderHomeScreen = () =>
const MOCK_RESPOSITORIES: GitRepository[] = [
{
id: 1,
id: "1",
full_name: "octocat/hello-world",
git_provider: "github",
is_public: true,
},
{
id: 2,
id: "2",
full_name: "octocat/earth",
git_provider: "github",
is_public: true,

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bitbucket</title><path d="M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z"/></svg>

After

Width:  |  Height:  |  Size: 285 B

View File

@ -20,19 +20,22 @@ export function ActionSuggestions({
const providersAreSet = providers.length > 0;
const isGitLab = providers.includes("gitlab");
const isBitbucket = providers.includes("bitbucket");
const pr = isGitLab ? "merge request" : "pull request";
const prShort = isGitLab ? "MR" : "PR";
const getProviderName = () => {
if (isGitLab) return "GitLab";
if (isBitbucket) return "Bitbucket";
return "GitHub";
};
const terms = {
pr,
prShort,
pushToBranch: `Please push the changes to a remote branch on ${
isGitLab ? "GitLab" : "GitHub"
}, but do NOT create a ${pr}. Please use the exact SAME branch name as the one you are currently on.`,
createPR: `Please push the changes to ${
isGitLab ? "GitLab" : "GitHub"
} and open a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`,
pushToBranch: `Please push the changes to a remote branch on ${getProviderName()}, but do NOT create a ${pr}. Please use the exact SAME branch name as the one you are currently on.`,
createPR: `Please push the changes to ${getProviderName()} and open a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`,
pushToPR: `Please push the latest changes to the existing ${pr}.`,
};

View File

@ -93,9 +93,7 @@ export function RepositorySelectionForm({
}));
const handleRepoSelection = (key: React.Key | null) => {
const selectedRepo = allRepositories?.find(
(repo) => repo.id.toString() === key,
);
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
if (selectedRepo) onRepoSelection(selectedRepo.full_name);
setSelectedRepository(selectedRepo || null);

View File

@ -54,6 +54,10 @@ export function TaskCard({ task }: TaskCardProps) {
const issueType =
task.task_type === "OPEN_ISSUE" ? "issues" : "merge_requests";
href = `https://gitlab.com/${task.repo}/-/${issueType}/${task.issue_number}`;
} else if (task.git_provider === "bitbucket") {
const issueType =
task.task_type === "OPEN_ISSUE" ? "issues" : "pull-requests";
href = `https://bitbucket.org/${task.repo}/${issueType}/${task.issue_number}`;
} else {
const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull";
href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`;

View File

@ -0,0 +1,30 @@
import { Trans } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function BitbucketTokenHelpAnchor() {
return (
<p data-testid="bitbucket-token-help-anchor" className="text-xs">
<Trans
i18nKey={I18nKey.BITBUCKET$TOKEN_HELP_TEXT}
components={[
<a
key="bitbucket-token-help-anchor-link"
aria-label="Bitbucket token help link"
href="https://bitbucket.org/account/settings/app-passwords/new?scopes=repository:write,pullrequest:write,issue:write"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
<a
key="bitbucket-token-help-anchor-link-2"
aria-label="Bitbucket token see more link"
href="https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
]}
/>
</p>
);
}

View File

@ -0,0 +1,64 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { BitbucketTokenHelpAnchor } from "./bitbucket-token-help-anchor";
import { KeyStatusIcon } from "../key-status-icon";
interface BitbucketTokenInputProps {
onChange: (value: string) => void;
onBitbucketHostChange: (value: string) => void;
isBitbucketTokenSet: boolean;
name: string;
bitbucketHostSet: string | null | undefined;
}
export function BitbucketTokenInput({
onChange,
onBitbucketHostChange,
isBitbucketTokenSet,
name,
bitbucketHostSet,
}: BitbucketTokenInputProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6">
<SettingsInput
testId={name}
name={name}
onChange={onChange}
label={t(I18nKey.BITBUCKET$TOKEN_LABEL)}
type="password"
className="w-full max-w-[680px]"
placeholder={isBitbucketTokenSet ? "<hidden>" : "username:app_password"}
startContent={
isBitbucketTokenSet && (
<KeyStatusIcon
testId="bb-set-token-indicator"
isSet={isBitbucketTokenSet}
/>
)
}
/>
<SettingsInput
onChange={onBitbucketHostChange || (() => {})}
name="bitbucket-host-input"
testId="bitbucket-host-input"
label={t(I18nKey.BITBUCKET$HOST_LABEL)}
type="text"
className="w-full max-w-[680px]"
placeholder="bitbucket.org"
defaultValue={bitbucketHostSet || undefined}
startContent={
bitbucketHostSet &&
bitbucketHostSet.trim() !== "" && (
<KeyStatusIcon testId="bb-set-host-indicator" isSet />
)
}
/>
<BitbucketTokenHelpAnchor />
</div>
);
}

View File

@ -7,6 +7,7 @@ import { ModalBody } from "#/components/shared/modals/modal-body";
import { BrandButton } from "../settings/brand-button";
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react";
import BitbucketLogo from "#/assets/branding/bitbucket-logo.svg?react";
import { useAuthUrl } from "#/hooks/use-auth-url";
import { GetConfigResponse } from "#/api/open-hands.types";
@ -23,6 +24,11 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
identityProvider: "gitlab",
});
const bitbucketAuthUrl = useAuthUrl({
appMode: appMode || null,
identityProvider: "bitbucket",
});
const handleGitHubAuth = () => {
if (githubAuthUrl) {
// Always start the OIDC flow, let the backend handle TOS check
@ -37,6 +43,13 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
}
};
const handleBitbucketAuth = () => {
if (bitbucketAuthUrl) {
// Always start the OIDC flow, let the backend handle TOS check
window.location.href = bitbucketAuthUrl;
}
};
return (
<ModalBackdrop>
<ModalBody className="border border-tertiary">
@ -67,6 +80,16 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
>
{t(I18nKey.GITLAB$CONNECT_TO_GITLAB)}
</BrandButton>
<BrandButton
type="button"
variant="primary"
onClick={handleBitbucketAuth}
className="w-full"
startContent={<BitbucketLogo width={20} height={20} />}
>
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
</BrandButton>
</div>
</ModalBody>
</ModalBackdrop>

View File

@ -15,7 +15,7 @@ export const useAutoLogin = () => {
// Get the stored login method
const loginMethod = getLoginMethod();
// Get the auth URLs for both providers
// Get the auth URLs for all providers
const githubAuthUrl = useAuthUrl({
appMode: config?.APP_MODE || null,
identityProvider: "github",
@ -26,6 +26,11 @@ export const useAutoLogin = () => {
identityProvider: "gitlab",
});
const bitbucketAuthUrl = useAuthUrl({
appMode: config?.APP_MODE || null,
identityProvider: "bitbucket",
});
useEffect(() => {
// Only auto-login in SAAS mode
if (config?.APP_MODE !== "saas") {
@ -48,8 +53,14 @@ export const useAutoLogin = () => {
}
// Get the appropriate auth URL based on the stored login method
const authUrl =
loginMethod === LoginMethod.GITHUB ? githubAuthUrl : gitlabAuthUrl;
let authUrl: string | null = null;
if (loginMethod === LoginMethod.GITHUB) {
authUrl = githubAuthUrl;
} else if (loginMethod === LoginMethod.GITLAB) {
authUrl = gitlabAuthUrl;
} else if (loginMethod === LoginMethod.BITBUCKET) {
authUrl = bitbucketAuthUrl;
}
// If we have an auth URL, redirect to it
if (authUrl) {
@ -68,5 +79,6 @@ export const useAutoLogin = () => {
loginMethod,
githubAuthUrl,
gitlabAuthUrl,
bitbucketAuthUrl,
]);
};

View File

@ -508,6 +508,7 @@ export enum I18nKey {
SETTINGS_FORM$BASE_URL = "SETTINGS_FORM$BASE_URL",
GITHUB$CONNECT_TO_GITHUB = "GITHUB$CONNECT_TO_GITHUB",
GITLAB$CONNECT_TO_GITLAB = "GITLAB$CONNECT_TO_GITLAB",
BITBUCKET$CONNECT_TO_BITBUCKET = "BITBUCKET$CONNECT_TO_BITBUCKET",
AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER = "AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER",
WAITLIST$JOIN_WAITLIST = "WAITLIST$JOIN_WAITLIST",
ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS = "ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS",
@ -524,6 +525,12 @@ export enum I18nKey {
GITLAB$TOKEN_HELP_TEXT = "GITLAB$TOKEN_HELP_TEXT",
GITLAB$TOKEN_LINK_TEXT = "GITLAB$TOKEN_LINK_TEXT",
GITLAB$INSTRUCTIONS_LINK_TEXT = "GITLAB$INSTRUCTIONS_LINK_TEXT",
BITBUCKET$TOKEN_LABEL = "BITBUCKET$TOKEN_LABEL",
BITBUCKET$HOST_LABEL = "BITBUCKET$HOST_LABEL",
BITBUCKET$GET_TOKEN = "BITBUCKET$GET_TOKEN",
BITBUCKET$TOKEN_HELP_TEXT = "BITBUCKET$TOKEN_HELP_TEXT",
BITBUCKET$TOKEN_LINK_TEXT = "BITBUCKET$TOKEN_LINK_TEXT",
BITBUCKET$INSTRUCTIONS_LINK_TEXT = "BITBUCKET$INSTRUCTIONS_LINK_TEXT",
GITLAB$OR_SEE = "GITLAB$OR_SEE",
COMMON$DOCUMENTATION = "COMMON$DOCUMENTATION",
AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED = "AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED",

View File

@ -816,20 +816,20 @@
"uk": "{\n \"sse_servers\": [],\n \"stdio_servers\": []\n}"
},
"HOME$CONNECT_PROVIDER_MESSAGE": {
"en": "To get started with suggested tasks, please connect your GitHub or GitLab account.",
"ja": "提案されたタスクを始めるには、GitHubまたはGitLabアカウントを接続してください。",
"zh-CN": "要开始使用建议的任务请连接您的GitHub或GitLab账户。",
"zh-TW": "要開始使用建議的任務請連接您的GitHub或GitLab帳戶。",
"ko-KR": "제안된 작업을 시작하려면 GitHub 또는 GitLab 계정을 연결하세요.",
"no": "For å komme i gang med foreslåtte oppgaver, vennligst koble til GitHub eller GitLab-kontoen din.",
"it": "Per iniziare con le attività suggerite, collega il tuo account GitHub o GitLab.",
"pt": "Para começar com tarefas sugeridas, conecte sua conta GitHub ou GitLab.",
"es": "Para comenzar con las tareas sugeridas, conecte su cuenta de GitHub o GitLab.",
"ar": "للبدء بالمهام المقترحة، يرجى ربط حساب GitHub أو GitLab الخاص بك.",
"fr": "Pour commencer avec les tâches suggérées, veuillez connecter votre compte GitHub ou GitLab.",
"tr": "Önerilen görevlerle başlamak için lütfen GitHub veya GitLab hesabınızı bağlayın.",
"de": "Um mit vorgeschlagenen Aufgaben zu beginnen, verbinden Sie bitte Ihr GitHub- oder GitLab-Konto.",
"uk": "Щоб розпочати роботу з запропонованими завданнями, підключіть свій обліковий запис GitHub або GitLab."
"en": "To get started with suggested tasks, please connect your GitHub, GitLab, or Bitbucket account.",
"ja": "提案されたタスクを始めるには、GitHub、GitLab、またはBitbucketアカウントを接続してください。",
"zh-CN": "要开始使用建议的任务请连接您的GitHub、GitLab或Bitbucket账户。",
"zh-TW": "要開始使用建議的任務請連接您的GitHub、GitLab或Bitbucket帳戶。",
"ko-KR": "제안된 작업을 시작하려면 GitHub, GitLab 또는 Bitbucket 계정을 연결하세요.",
"no": "For å komme i gang med foreslåtte oppgaver, vennligst koble til GitHub, GitLab eller Bitbucket-kontoen din.",
"it": "Per iniziare con le attività suggerite, collega il tuo account GitHub, GitLab o Bitbucket.",
"pt": "Para começar com tarefas sugeridas, conecte sua conta GitHub, GitLab ou Bitbucket.",
"es": "Para comenzar con las tareas sugeridas, conecte su cuenta de GitHub, GitLab o Bitbucket.",
"ar": "للبدء بالمهام المقترحة، يرجى ربط حساب GitHub أو GitLab أو Bitbucket الخاص بك.",
"fr": "Pour commencer avec les tâches suggérées, veuillez connecter votre compte GitHub, GitLab ou Bitbucket.",
"tr": "Önerilen görevlerle başlamak için lütfen GitHub, GitLab veya Bitbucket hesabınızı bağlayın.",
"de": "Um mit vorgeschlagenen Aufgaben zu beginnen, verbinden Sie bitte Ihr GitHub-, GitLab- oder Bitbucket-Konto.",
"uk": "Щоб розпочати роботу з запропонованими завданнями, підключіть свій обліковий запис GitHub, GitLab або Bitbucket."
},
"HOME$LETS_START_BUILDING": {
"en": "Let's Start Building!",
@ -8127,6 +8127,22 @@
"tr": "GitLab'a bağlan",
"uk": "Увійти за допомогою GitLab"
},
"BITBUCKET$CONNECT_TO_BITBUCKET": {
"en": "Log in with Bitbucket",
"ja": "Bitbucketに接続",
"zh-CN": "连接到Bitbucket",
"zh-TW": "連接到Bitbucket",
"ko-KR": "Bitbucket에 연결",
"de": "Mit Bitbucket verbinden",
"no": "Koble til Bitbucket",
"it": "Connetti a Bitbucket",
"pt": "Conectar ao Bitbucket",
"es": "Conectar a Bitbucket",
"ar": "الاتصال بـ Bitbucket",
"fr": "Se connecter à Bitbucket",
"tr": "Bitbucket'a bağlan",
"uk": "Увійти за допомогою Bitbucket"
},
"AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER": {
"en": "Log in to OpenHands",
"ja": "IDプロバイダーでサインイン",
@ -8383,6 +8399,102 @@
"de": "klicken Sie hier für Anweisungen",
"uk": "натисніть тут, щоб отримати інструкції"
},
"BITBUCKET$TOKEN_LABEL": {
"en": "Bitbucket Token",
"ja": "Bitbucketトークン",
"zh-CN": "Bitbucket令牌",
"zh-TW": "Bitbucket權杖",
"ko-KR": "Bitbucket 토큰",
"no": "Bitbucket-token",
"it": "Token Bitbucket",
"pt": "Token do Bitbucket",
"es": "Token de Bitbucket",
"ar": "رمز Bitbucket",
"fr": "Jeton Bitbucket",
"tr": "Bitbucket Token",
"de": "Bitbucket-Token",
"uk": "Токен Bitbucket"
},
"BITBUCKET$HOST_LABEL": {
"en": "Bitbucket Host",
"ja": "Bitbucketホスト",
"zh-CN": "Bitbucket主机",
"zh-TW": "Bitbucket主機",
"ko-KR": "Bitbucket 호스트",
"no": "Bitbucket-vert",
"it": "Host Bitbucket",
"pt": "Host do Bitbucket",
"es": "Host de Bitbucket",
"ar": "مضيف Bitbucket",
"fr": "Hôte Bitbucket",
"tr": "Bitbucket Sunucu",
"de": "Bitbucket-Host",
"uk": "Хост Bitbucket"
},
"BITBUCKET$GET_TOKEN": {
"en": "Get a Bitbucket token",
"ja": "Bitbucketトークンを取得",
"zh-CN": "获取Bitbucket令牌",
"zh-TW": "獲取Bitbucket權杖",
"ko-KR": "Bitbucket 토큰 받기",
"no": "Få et Bitbucket-token",
"it": "Ottieni un token Bitbucket",
"pt": "Obter um token do Bitbucket",
"es": "Obtener un token de Bitbucket",
"ar": "الحصول على رمز Bitbucket",
"fr": "Obtenir un jeton Bitbucket",
"tr": "Bitbucket token al",
"de": "Bitbucket-Token erhalten",
"uk": "Отримати токен Bitbucket"
},
"BITBUCKET$TOKEN_HELP_TEXT": {
"en": "Get your <0>Bitbucket app password</0> or <1>click here for instructions</1>. Enter it in the format 'username:app_password'.",
"ja": "<0>Bitbucketアプリパスワード</0>を取得するか、<1>手順についてはここをクリック</1>。'ユーザー名:アプリパスワード'の形式で入力してください。",
"zh-CN": "获取您的<0>Bitbucket应用密码</0>或<1>点击此处获取说明</1>。请以'用户名:应用密码'的格式输入。",
"zh-TW": "取得您的<0>Bitbucket應用密碼</0>或<1>點擊此處獲取說明</1>。請以'用戶名:應用密碼'的格式輸入。",
"ko-KR": "<0>Bitbucket 앱 비밀번호</0>를 받거나 <1>지침을 보려면 여기를 클릭</1>하세요. '사용자 이름:앱 비밀번호' 형식으로 입력하세요.",
"no": "Få ditt <0>Bitbucket app-passord</0> eller <1>klikk her for instruksjoner</1>. Skriv det inn i formatet 'brukernavn:app-passord'.",
"it": "Ottieni la tua <0>password dell'app Bitbucket</0> o <1>clicca qui per istruzioni</1>. Inseriscila nel formato 'nome utente:password dell'app'.",
"pt": "Obtenha sua <0>senha de aplicativo do Bitbucket</0> ou <1>clique aqui para instruções</1>. Digite-a no formato 'nome de usuário:senha do aplicativo'.",
"es": "Obtenga su <0>contraseña de aplicación de Bitbucket</0> o <1>haga clic aquí para obtener instrucciones</1>. Ingrésela en el formato 'nombre de usuario:contraseña de aplicación'.",
"ar": "احصل على <0>كلمة مرور تطبيق Bitbucket</0> الخاصة بك أو <1>انقر هنا للحصول على تعليمات</1>. أدخلها بتنسيق 'اسم المستخدم:كلمة مرور التطبيق'.",
"fr": "Obtenez votre <0>mot de passe d'application Bitbucket</0> ou <1>cliquez ici pour les instructions</1>. Saisissez-le au format 'nom d'utilisateur:mot de passe d'application'.",
"tr": "<0>Bitbucket uygulama şifrenizi</0> alın veya <1>talimatlar için buraya tıklayın</1>. 'kullanıcı adı:uygulama şifresi' formatında girin.",
"de": "Holen Sie sich Ihr <0>Bitbucket App-Passwort</0> oder <1>klicken Sie hier für Anweisungen</1>. Geben Sie es im Format 'Benutzername:App-Passwort' ein.",
"uk": "Отримайте свій <0>пароль додатка Bitbucket</0> або <1>натисніть тут, щоб отримати інструкції</1>. Введіть його у форматі 'ім'я користувача:пароль додатка'."
},
"BITBUCKET$TOKEN_LINK_TEXT": {
"en": "Bitbucket app password",
"ja": "Bitbucketアプリパスワード",
"zh-CN": "Bitbucket应用密码",
"zh-TW": "Bitbucket應用密碼",
"ko-KR": "Bitbucket 앱 비밀번호",
"no": "Bitbucket app-passord",
"it": "password dell'app Bitbucket",
"pt": "senha de aplicativo do Bitbucket",
"es": "contraseña de aplicación de Bitbucket",
"ar": "كلمة مرور تطبيق Bitbucket",
"fr": "mot de passe d'application Bitbucket",
"tr": "Bitbucket uygulama şifresi",
"de": "Bitbucket App-Passwort",
"uk": "пароль додатка Bitbucket"
},
"BITBUCKET$INSTRUCTIONS_LINK_TEXT": {
"en": "click here for instructions",
"ja": "手順についてはここをクリック",
"zh-CN": "点击此处获取说明",
"zh-TW": "點擊此處獲取說明",
"ko-KR": "지침을 보려면 여기를 클릭",
"no": "klikk her for instruksjoner",
"it": "clicca qui per istruzioni",
"pt": "clique aqui para instruções",
"es": "haga clic aquí para obtener instrucciones",
"ar": "انقر هنا للحصول على تعليمات",
"fr": "cliquez ici pour les instructions",
"tr": "talimatlar için buraya tıklayın",
"de": "klicken Sie hier für Anweisungen",
"uk": "натисніть тут, щоб отримати інструкції"
},
"GITLAB$OR_SEE": {
"en": "or see the",
"ja": "または参照",

View File

@ -140,13 +140,13 @@ export const handlers = [
http.get("/api/user/repositories", () => {
const data: GitRepository[] = [
{
id: 1,
id: "1",
full_name: "octocat/hello-world",
git_provider: "github",
is_public: true,
},
{
id: 2,
id: "2",
full_name: "octocat/earth",
git_provider: "github",
is_public: true,
@ -157,7 +157,7 @@ export const handlers = [
}),
http.get("/api/user/info", () => {
const user: GitUser = {
id: 1,
id: "1",
login: "octocat",
avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4",
company: "GitHub",

View File

@ -6,6 +6,7 @@ import { BrandButton } from "#/components/features/settings/brand-button";
import { useLogout } from "#/hooks/mutation/use-logout";
import { GitHubTokenInput } from "#/components/features/settings/git-settings/github-token-input";
import { GitLabTokenInput } from "#/components/features/settings/git-settings/gitlab-token-input";
import { BitbucketTokenInput } from "#/components/features/settings/git-settings/bitbucket-token-input";
import { ConfigureGitHubRepositoriesAnchor } from "#/components/features/settings/git-settings/configure-github-repositories-anchor";
import { InstallSlackAppAnchor } from "#/components/features/settings/git-settings/install-slack-app-anchor";
import { I18nKey } from "#/i18n/declaration";
@ -33,18 +34,24 @@ function GitSettingsScreen() {
React.useState(false);
const [gitlabTokenInputHasValue, setGitlabTokenInputHasValue] =
React.useState(false);
const [bitbucketTokenInputHasValue, setBitbucketTokenInputHasValue] =
React.useState(false);
const [githubHostInputHasValue, setGithubHostInputHasValue] =
React.useState(false);
const [gitlabHostInputHasValue, setGitlabHostInputHasValue] =
React.useState(false);
const [bitbucketHostInputHasValue, setBitbucketHostInputHasValue] =
React.useState(false);
const existingGithubHost = settings?.PROVIDER_TOKENS_SET.github;
const existingGitlabHost = settings?.PROVIDER_TOKENS_SET.gitlab;
const existingBitbucketHost = settings?.PROVIDER_TOKENS_SET.bitbucket;
const isSaas = config?.APP_MODE === "saas";
const isGitHubTokenSet = providers.includes("github");
const isGitLabTokenSet = providers.includes("gitlab");
const isBitbucketTokenSet = providers.includes("bitbucket");
const formAction = async (formData: FormData) => {
const disconnectButtonClicked =
@ -57,15 +64,23 @@ function GitSettingsScreen() {
const githubToken = formData.get("github-token-input")?.toString() || "";
const gitlabToken = formData.get("gitlab-token-input")?.toString() || "";
const bitbucketToken =
formData.get("bitbucket-token-input")?.toString() || "";
const githubHost = formData.get("github-host-input")?.toString() || "";
const gitlabHost = formData.get("gitlab-host-input")?.toString() || "";
const bitbucketHost =
formData.get("bitbucket-host-input")?.toString() || "";
// Create providers object with all tokens
const providerTokens: Record<string, { token: string; host: string }> = {
github: { token: githubToken, host: githubHost },
gitlab: { token: gitlabToken, host: gitlabHost },
bitbucket: { token: bitbucketToken, host: bitbucketHost },
};
saveGitProviders(
{
providers: {
github: { token: githubToken, host: githubHost },
gitlab: { token: gitlabToken, host: gitlabHost },
},
providers: providerTokens,
},
{
onSuccess: () => {
@ -78,8 +93,10 @@ function GitSettingsScreen() {
onSettled: () => {
setGithubTokenInputHasValue(false);
setGitlabTokenInputHasValue(false);
setBitbucketTokenInputHasValue(false);
setGithubHostInputHasValue(false);
setGitlabHostInputHasValue(false);
setBitbucketHostInputHasValue(false);
},
},
);
@ -88,8 +105,10 @@ function GitSettingsScreen() {
const formIsClean =
!githubTokenInputHasValue &&
!gitlabTokenInputHasValue &&
!bitbucketTokenInputHasValue &&
!githubHostInputHasValue &&
!gitlabHostInputHasValue;
!gitlabHostInputHasValue &&
!bitbucketHostInputHasValue;
const shouldRenderExternalConfigureButtons = isSaas && config.APP_SLUG;
return (
@ -116,7 +135,7 @@ function GitSettingsScreen() {
setGithubTokenInputHasValue(!!value);
}}
onGitHubHostChange={(value) => {
setGitlabHostInputHasValue(!!value);
setGithubHostInputHasValue(!!value);
}}
githubHostSet={existingGithubHost}
/>
@ -135,6 +154,20 @@ function GitSettingsScreen() {
gitlabHostSet={existingGitlabHost}
/>
)}
{!isSaas && (
<BitbucketTokenInput
name="bitbucket-token-input"
isBitbucketTokenSet={isBitbucketTokenSet}
onChange={(value) => {
setBitbucketTokenInputHasValue(!!value);
}}
onBitbucketHostChange={(value) => {
setBitbucketHostInputHasValue(!!value);
}}
bitbucketHostSet={existingBitbucketHost}
/>
)}
</div>
)}
@ -148,7 +181,9 @@ function GitSettingsScreen() {
name="disconnect-tokens-button"
type="submit"
variant="secondary"
isDisabled={!isGitHubTokenSet && !isGitLabTokenSet}
isDisabled={
!isGitHubTokenSet && !isGitLabTokenSet && !isBitbucketTokenSet
}
>
Disconnect Tokens
</BrandButton>

View File

@ -7,7 +7,7 @@ interface GitHubErrorReponse {
}
interface GitUser {
id: number;
id: string;
login: string;
avatar_url: string;
company: string | null;
@ -23,7 +23,7 @@ interface Branch {
}
interface GitRepository {
id: number;
id: string;
full_name: string;
git_provider: Provider;
is_public: boolean;

View File

@ -1,6 +1,7 @@
export const ProviderOptions = {
github: "github",
gitlab: "gitlab",
bitbucket: "bitbucket",
} as const;
export type Provider = keyof typeof ProviderOptions;

View File

@ -1,6 +1,6 @@
/**
* Generates a URL to redirect to for OAuth authentication
* @param identityProvider The identity provider to use (e.g., "github", "gitlab")
* @param identityProvider The identity provider to use (e.g., "github", "gitlab", "bitbucket")
* @param requestUrl The URL of the request
* @returns The URL to redirect to for OAuth
*/

View File

@ -7,11 +7,12 @@ export const LOCAL_STORAGE_KEYS = {
export enum LoginMethod {
GITHUB = "github",
GITLAB = "gitlab",
BITBUCKET = "bitbucket",
}
/**
* Set the login method in local storage
* @param method The login method (github or gitlab)
* @param method The login method (github, gitlab, or bitbucket)
*/
export const setLoginMethod = (method: LoginMethod): void => {
localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, method);

34
microagents/bitbucket.md Normal file
View File

@ -0,0 +1,34 @@
---
name: bitbucket
type: knowledge
version: 1.0.0
agent: CodeActAgent
triggers:
- bitbucket
---
You have access to an environment variable, `BITBUCKET_TOKEN`, which allows you to interact with
the Bitbucket API.
<IMPORTANT>
You can use `curl` with the `BITBUCKET_TOKEN` to interact with Bitbucket's API.
ALWAYS use the Bitbucket API for operations instead of a web browser.
ALWAYS use the `create_bitbucket_pr` tool to open a pull request
</IMPORTANT>
If you encounter authentication issues when pushing to Bitbucket (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://x-token-auth:${BITBUCKET_TOKEN}@bitbucket.org/username/repo.git`
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_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
```

View File

@ -107,6 +107,10 @@ def initialize_repository_for_runtime(
gitlab_token = SecretStr(os.environ['GITLAB_TOKEN'])
provider_tokens[ProviderType.GITLAB] = ProviderToken(token=gitlab_token)
if 'BITBUCKET_TOKEN' in os.environ:
bitbucket_token = SecretStr(os.environ['BITBUCKET_TOKEN'])
provider_tokens[ProviderType.BITBUCKET] = ProviderToken(token=bitbucket_token)
secret_store = (
UserSecrets(provider_tokens=provider_tokens) if provider_tokens else None
)

View File

@ -0,0 +1,302 @@
import base64
from typing import Any
import httpx
from pydantic import SecretStr
from openhands.integrations.service_types import (
BaseGitService,
Branch,
GitService,
ProviderType,
Repository,
RequestMethod,
SuggestedTask,
User,
)
from openhands.server.types import AppMode
class BitbucketService(BaseGitService, GitService):
"""Default implementation of GitService for Bitbucket integration.
This is an extension point in OpenHands that allows applications to customize Bitbucket
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.
"""
BASE_URL = 'https://api.bitbucket.org/2.0'
token: SecretStr = SecretStr('')
refresh = False
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,
):
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 or 'bitbucket.org'
if token:
self.token = token
if base_domain:
self.BASE_URL = f'https://api.{base_domain}/2.0'
@property
def provider(self) -> str:
return ProviderType.BITBUCKET.value
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 status_code == 401
async def _get_bitbucket_headers(self) -> dict[str, str]:
"""Get headers for Bitbucket API requests."""
token_value = self.token.get_secret_value()
# Check if the token contains a colon, which indicates it's in username:password format
if ':' in token_value:
auth_str = base64.b64encode(token_value.encode()).decode()
return {
'Authorization': f'Basic {auth_str}',
'Accept': 'application/json',
}
else:
return {
'Authorization': f'Bearer {token_value}',
'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 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() as client:
bitbucket_headers = await self._get_bitbucket_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_bitbucket_headers()
response = await self.execute_request(
client=client,
url=url,
headers=bitbucket_headers,
params=params,
method=method,
)
response.raise_for_status()
return response.json(), 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 get_user(self) -> User:
"""Get the authenticated user's information."""
url = f'{self.BASE_URL}/user'
data, _ = await self._make_request(url)
account_id = data.get('account_id', '')
return User(
id=account_id,
login=data.get('username', ''),
avatar_url=data.get('links', {}).get('avatar', {}).get('href', ''),
name=data.get('display_name'),
email=None, # Bitbucket API doesn't return email in this endpoint
)
async def search_repositories(
self,
query: str,
per_page: int,
sort: str,
order: str,
) -> list[Repository]:
"""Search for repositories."""
# Bitbucket doesn't have a dedicated search endpoint like GitHub
return []
async def get_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.
"""
repositories = []
# Get user's workspaces
workspaces_url = f'{self.BASE_URL}/workspaces'
workspaces_data, _ = await self._make_request(workspaces_url)
for workspace in workspaces_data.get('values', []):
workspace_slug = workspace.get('slug')
if not workspace_slug:
continue
# Get repositories for this workspace
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
# Map sort parameter to Bitbucket API compatible values
bitbucket_sort = sort
if sort == 'pushed':
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
bitbucket_sort = 'updated_on'
params = {
'pagelen': 100,
'sort': bitbucket_sort,
}
repos_data, headers = await self._make_request(workspace_repos_url, params)
for repo in repos_data.get('values', []):
uuid = repo.get('uuid', '')
repositories.append(
Repository(
id=uuid,
full_name=f'{repo.get("workspace", {}).get("slug", "")}/{repo.get("slug", "")}',
git_provider=ProviderType.BITBUCKET,
is_public=repo.get('is_private', True) is False,
stargazers_count=None, # Bitbucket doesn't have stars
link_header=headers.get('Link', ''),
pushed_at=repo.get('updated_on'),
)
)
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 []
async def get_repository_details_from_repo_name(
self, repository: str
) -> Repository:
"""Gets all repository details from repository name."""
# Extract owner and repo from the repository string (e.g., "owner/repo")
parts = repository.split('/')
if len(parts) < 2:
raise ValueError(f'Invalid repository name: {repository}')
owner = parts[-2]
repo = parts[-1]
url = f'{self.BASE_URL}/repositories/{owner}/{repo}'
data, _ = await self._make_request(url)
uuid = data.get('uuid', '')
return Repository(
id=uuid,
full_name=f'{data.get("workspace", {}).get("slug", "")}/{data.get("slug", "")}',
git_provider=ProviderType.BITBUCKET,
is_public=data.get('is_private', True) is False,
stargazers_count=None, # Bitbucket doesn't have stars
pushed_at=data.get('updated_on'),
)
async def get_branches(self, repository: str) -> list[Branch]:
"""Get branches for a repository."""
# Extract owner and repo from the repository string (e.g., "owner/repo")
parts = repository.split('/')
if len(parts) < 2:
raise ValueError(f'Invalid repository name: {repository}')
owner = parts[-2]
repo = parts[-1]
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
data, _ = await self._make_request(url)
branches = []
for branch in data.get('values', []):
branches.append(
Branch(
name=branch.get('name', ''),
commit_sha=branch.get('target', {}).get('hash', ''),
protected=False, # Bitbucket doesn't expose this in the API
last_push_date=branch.get('target', {}).get('date', None),
)
)
return branches
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.
Args:
repo_name: The repository name in the format "workspace/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
"""
# Extract owner and repo from the repository string (e.g., "owner/repo")
parts = repo_name.split('/')
if len(parts) < 2:
raise ValueError(f'Invalid repository name: {repo_name}')
owner = parts[-2]
repo = parts[-1]
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/pullrequests'
payload = {
'title': title,
'description': body or '',
'source': {'branch': {'name': source_branch}},
'destination': {'branch': {'name': target_branch}},
'close_source_branch': False,
'draft': draft,
}
data, _ = await self._make_request(
url=url, params=payload, method=RequestMethod.POST
)
# Return the URL to the pull request
return data.get('links', {}).get('html', {}).get('href', '')

View File

@ -133,7 +133,7 @@ class GitHubService(BaseGitService, GitService):
response, _ = await self._make_request(url)
return User(
id=response.get('id'),
id=str(response.get('id', '')),
login=response.get('login'),
avatar_url=response.get('avatar_url'),
company=response.get('company'),
@ -229,7 +229,7 @@ class GitHubService(BaseGitService, GitService):
# Convert to Repository objects
return [
Repository(
id=repo.get('id'),
id=str(repo.get('id')),
full_name=repo.get('full_name'),
stargazers_count=repo.get('stargazers_count'),
git_provider=ProviderType.GITHUB,
@ -262,7 +262,7 @@ class GitHubService(BaseGitService, GitService):
repos = [
Repository(
id=repo.get('id'),
id=str(repo.get('id')),
full_name=repo.get('full_name'),
stargazers_count=repo.get('stargazers_count'),
git_provider=ProviderType.GITHUB,
@ -407,7 +407,7 @@ class GitHubService(BaseGitService, GitService):
repo, _ = await self._make_request(url)
return Repository(
id=repo.get('id'),
id=str(repo.get('id')),
full_name=repo.get('full_name'),
stargazers_count=repo.get('stargazers_count'),
git_provider=ProviderType.GITHUB,

View File

@ -184,13 +184,12 @@ class GitLabService(BaseGitService, GitService):
avatar_url = response.get('avatar_url') or ''
return User(
id=response.get('id'),
username=response.get('username'),
id=str(response.get('id', '')),
login=response.get('username'),
avatar_url=avatar_url,
name=response.get('name'),
email=response.get('email'),
company=response.get('organization'),
login=response.get('username'),
)
async def search_repositories(
@ -208,7 +207,7 @@ class GitLabService(BaseGitService, GitService):
response, _ = await self._make_request(url, params)
repos = [
Repository(
id=repo.get('id'),
id=str(repo.get('id')),
full_name=repo.get('path_with_namespace'),
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
@ -259,7 +258,7 @@ class GitLabService(BaseGitService, GitService):
all_repos = all_repos[:MAX_REPOS]
return [
Repository(
id=repo.get('id'),
id=str(repo.get('id')),
full_name=repo.get('path_with_namespace'),
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
@ -409,7 +408,7 @@ class GitLabService(BaseGitService, GitService):
repo, _ = await self._make_request(url)
return Repository(
id=repo.get('id'),
id=str(repo.get('id')),
full_name=repo.get('path_with_namespace'),
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,

View File

@ -14,6 +14,7 @@ from openhands.core.logger import openhands_logger as logger
from openhands.events.action.action import Action
from openhands.events.action.commands import CmdRunAction
from openhands.events.stream import EventStream
from openhands.integrations.bitbucket.bitbucket_service import BitbucketService
from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.service_types import (
@ -108,6 +109,7 @@ class ProviderHandler:
self.service_class_map: dict[ProviderType, type[GitService]] = {
ProviderType.GITHUB: GithubServiceImpl,
ProviderType.GITLAB: GitLabServiceImpl,
ProviderType.BITBUCKET: BitbucketService,
}
self.external_auth_id = external_auth_id

View File

@ -13,6 +13,7 @@ from openhands.server.types import AppMode
class ProviderType(Enum):
GITHUB = 'github'
GITLAB = 'gitlab'
BITBUCKET = 'bitbucket'
class TaskType(str, Enum):
@ -51,6 +52,16 @@ class SuggestedTask(BaseModel):
'ciProvider': 'GitHub',
'requestVerb': 'pull request',
}
elif self.git_provider == ProviderType.BITBUCKET:
return {
'requestType': 'Pull Request',
'requestTypeShort': 'PR',
'apiName': 'Bitbucket API',
'tokenEnvVar': 'BITBUCKET_TOKEN',
'ciSystem': 'Bitbucket Pipelines',
'ciProvider': 'Bitbucket',
'requestVerb': 'pull request',
}
raise ValueError(f'Provider {self.git_provider} for suggested task prompts')
@ -83,7 +94,7 @@ class SuggestedTask(BaseModel):
class User(BaseModel):
id: int
id: str
login: str
avatar_url: str
company: str | None = None
@ -99,7 +110,7 @@ class Branch(BaseModel):
class Repository(BaseModel):
id: int
id: str
full_name: str
git_provider: ProviderType
is_public: bool

View File

@ -1,8 +1,7 @@
import traceback
from pydantic import SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.bitbucket.bitbucket_service import BitbucketService
from openhands.integrations.github.github_service import GitHubService
from openhands.integrations.gitlab.gitlab_service import GitLabService
from openhands.integrations.provider import ProviderType
@ -12,35 +11,52 @@ async def validate_provider_token(
token: SecretStr, base_domain: str | None = None
) -> ProviderType | None:
"""
Determine whether a token is for GitHub or GitLab by attempting to get user info
from both services.
Determine whether a token is for GitHub, GitLab, or Bitbucket by attempting to get user info
from the services.
Args:
token: The token to check
base_domain: Optional base domain for the service
Returns:
'github' if it's a GitHub token
'gitlab' if it's a GitLab token
None if the token is invalid for both services
'bitbucket' if it's a Bitbucket token
None if the token is invalid for all services
"""
# Skip validation for empty tokens
if token is None:
return None
# Try GitHub first
github_error = None
try:
github_service = GitHubService(token=token, base_domain=base_domain)
await github_service.verify_access()
return ProviderType.GITHUB
except Exception as e:
logger.debug(
f'Failed to validate Github token: {e} \n {traceback.format_exc()}'
)
github_error = e
# Try GitLab next
gitlab_error = None
try:
gitlab_service = GitLabService(token=token, base_domain=base_domain)
await gitlab_service.get_user()
return ProviderType.GITLAB
except Exception as e:
logger.debug(
f'Failed to validate GitLab token: {e} \n {traceback.format_exc()}'
)
gitlab_error = e
# Try Bitbucket last
bitbucket_error = None
try:
bitbucket_service = BitbucketService(token=token, base_domain=base_domain)
await bitbucket_service.get_user()
return ProviderType.BITBUCKET
except Exception as e:
bitbucket_error = e
logger.debug(
f'Failed to validate token: {github_error} \n {gitlab_error} \n {bitbucket_error}'
)
return None

View File

@ -1,9 +1,9 @@
# OpenHands Github & Gitlab Issue Resolver 🙌
# OpenHands GitHub, GitLab & Bitbucket Issue Resolver 🙌
Need help resolving a GitHub issue but don't have the time to do it yourself? Let an AI agent help you out!
Need help resolving a GitHub, GitLab, or Bitbucket issue but don't have the time to do it yourself? Let an AI agent help you out!
This tool allows you to use open-source AI agents based on [OpenHands](https://github.com/all-hands-ai/openhands)
to attempt to resolve GitHub issues automatically. While it can handle multiple issues, it's primarily designed
to attempt to resolve GitHub, GitLab, and Bitbucket issues automatically. While it can handle multiple issues, it's primarily designed
to help you resolve one issue at a time with high quality.
Getting started is simple - just follow the instructions below.
@ -74,8 +74,8 @@ If you prefer to run the resolver programmatically instead of using GitHub Actio
pip install openhands-ai
```
2. Create a GitHub or GitLab access token:
- Create a GitHub acces token
2. Create a GitHub, GitLab, or Bitbucket access token:
- Create a GitHub access token
- Visit [GitHub's token settings](https://github.com/settings/personal-access-tokens/new)
- Create a fine-grained token with these scopes:
- "Content"
@ -84,7 +84,7 @@ pip install openhands-ai
- "Workflows"
- If you don't have push access to the target repo, you can fork it first
- Create a GitLab acces token
- Create a GitLab access token
- Visit [GitLab's token settings](https://gitlab.com/-/user_settings/personal_access_tokens)
- Create a fine-grained token with these scopes:
- 'api'
@ -93,6 +93,16 @@ pip install openhands-ai
- 'read_repository'
- 'write_repository'
- Create a Bitbucket access token
- Visit [Bitbucket's app passwords settings](https://bitbucket.org/account/settings/app-passwords/)
- Create an app password with these scopes:
- 'Repositories: Read'
- 'Repositories: Write'
- 'Pull requests: Read'
- 'Pull requests: Write'
- 'Issues: Read'
- 'Issues: Write'
3. Set up environment variables:
```bash
@ -107,6 +117,11 @@ export GIT_USERNAME="your-github-username" # Optional, defaults to token owner
export GITLAB_TOKEN="your-gitlab-token"
export GIT_USERNAME="your-gitlab-username" # Optional, defaults to token owner
# Bitbucket credentials if you're using Bitbucket repo
export BITBUCKET_TOKEN="your-bitbucket-token"
export GIT_USERNAME="your-bitbucket-username" # Optional, defaults to token owner
# LLM configuration
export LLM_MODEL="anthropic/claude-sonnet-4-20250514" # Recommended
@ -172,13 +187,13 @@ There are three ways you can upload:
3. `ready` - create a non-draft PR that's ready for review
```bash
python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --username YOUR_GITHUB_OR_GITLAB_USERNAME --pr-type draft
python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --username YOUR_GIT_USERNAME --pr-type draft
```
If you want to upload to a fork, you can do so by specifying the `fork-owner`:
```bash
python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --username YOUR_GITHUB_OR_GITLAB_USERNAME --pr-type draft --fork-owner YOUR_GITHUB_OR_GITLAB_USERNAME
python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --username YOUR_GIT_USERNAME --pr-type draft --fork-owner YOUR_GIT_USERNAME
```
## Providing Custom Instructions
@ -187,5 +202,5 @@ You can customize how the AI agent approaches issue resolution by adding a repos
## Troubleshooting
If you have any issues, please open an issue on this github or gitlab repo, we're happy to help!
If you have any issues, please open an issue on this GitHub, GitLab, or Bitbucket repo, we're happy to help!
Alternatively, you can [email us](mailto:contact@all-hands.dev) or join the OpenHands Slack workspace (see [the README](/README.md) for an invite link).

View File

@ -0,0 +1,519 @@
import base64
from typing import Any
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
class BitbucketIssueHandler(IssueHandlerInterface):
def __init__(
self,
owner: str,
repo: str,
token: str,
username: str | None = None,
base_domain: str = 'bitbucket.org',
):
"""Initialize a Bitbucket issue handler.
Args:
owner: The workspace of the repository
repo: The name of the repository
token: The Bitbucket API token
username: Optional Bitbucket username
base_domain: The domain for Bitbucket Server (default: "bitbucket.org")
"""
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]:
# Check if the token contains a colon, which indicates it's in username:password format
if ':' in self.token:
auth_str = base64.b64encode(self.token.encode()).decode()
return {
'Authorization': f'Basic {auth_str}',
'Accept': 'application/json',
}
else:
return {
'Authorization': f'Bearer {self.token}',
'Accept': 'application/json',
}
def get_base_url(self) -> str:
"""Get the base URL for the Bitbucket API."""
return f'https://api.{self.base_domain}/2.0'
def get_download_url(self) -> str:
"""Get the download URL for the repository."""
return f'https://{self.base_domain}/{self.owner}/{self.repo}/get/master.zip'
def get_clone_url(self) -> str:
"""Get the clone URL for the repository."""
return f'https://{self.base_domain}/{self.owner}/{self.repo}.git'
def get_repo_url(self) -> str:
"""Get the URL for the repository."""
return f'https://{self.base_domain}/{self.owner}/{self.repo}'
def get_issue_url(self, issue_number: int) -> str:
"""Get the URL for an issue."""
return f'{self.get_repo_url()}/issues/{issue_number}'
def get_pr_url(self, pr_number: int) -> str:
"""Get the URL for a pull request."""
return f'{self.get_repo_url()}/pull-requests/{pr_number}'
async def get_issue(self, issue_number: int) -> Issue:
"""Get an issue from Bitbucket.
Args:
issue_number: The issue number
Returns:
An Issue object
"""
url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/issues/{issue_number}'
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=self.headers)
response.raise_for_status()
data = response.json()
# Create a basic Issue object with required fields
issue = Issue(
owner=self.owner,
repo=self.repo,
number=data.get('id'),
title=data.get('title', ''),
body=data.get('content', {}).get('raw', ''),
)
return issue
def create_pr(
self,
title: str,
body: str,
head: str,
base: str,
) -> str:
"""Create a pull request.
Args:
title: The title of the pull request
body: The body of the pull request
head: The head branch
base: The base branch
Returns:
The URL of the created pull request
"""
url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/pullrequests'
payload = {
'title': title,
'description': body,
'source': {'branch': {'name': head}},
'destination': {'branch': {'name': base}},
'close_source_branch': False,
}
response = httpx.post(url, headers=self.headers, json=payload)
response.raise_for_status()
data = response.json()
return data.get('links', {}).get('html', {}).get('href', '')
def download_issues(self) -> list[Any]:
"""Download all issues from the repository.
Returns:
A list of issues
"""
logger.warning('BitbucketIssueHandler.download_issues not implemented')
return []
def get_issue_comments(
self, issue_number: int, comment_id: int | None = None
) -> list[str] | None:
"""Get comments for an issue.
Args:
issue_number: The issue number
comment_id: The comment ID (optional)
Returns:
A list of comments
"""
logger.warning('BitbucketIssueHandler.get_issue_comments not implemented')
return []
def get_branch_url(self, branch_name: str) -> str:
"""Get the URL for a branch.
Args:
branch_name: The branch name
Returns:
The URL for the branch
"""
return (
f'https://{self.base_domain}/{self.owner}/{self.repo}/branch/{branch_name}'
)
def get_compare_url(self, branch_name: str) -> str:
"""Get the URL for comparing branches.
Args:
branch_name: The branch name
Returns:
The URL for comparing branches
"""
return f'https://{self.base_domain}/{self.owner}/{self.repo}/compare/master...{branch_name}'
def get_authorize_url(self) -> str:
"""Get the URL for authorization.
Returns:
The URL for authorization
"""
return f'https://oauth2:{self.token}@{self.base_domain}/'
def get_pull_url(self, pr_number: int) -> str:
"""Get the URL for a pull request.
Args:
pr_number: The pull request number
Returns:
The URL for the pull request
"""
return f'https://{self.base_domain}/{self.owner}/{self.repo}/pull-requests/{pr_number}'
def get_branch_name(self, base_branch_name: str) -> str:
"""Get a unique branch name.
Args:
base_branch_name: The base branch name
Returns:
A unique branch name
"""
return f'{base_branch_name}-{self.owner}'
def branch_exists(self, branch_name: str) -> bool:
"""Check if a branch exists.
Args:
branch_name: The branch name
Returns:
True if the branch exists, False otherwise
"""
logger.warning('BitbucketIssueHandler.branch_exists not implemented')
return False
def get_default_branch_name(self) -> str:
"""Get the default branch name.
Returns:
The default branch name
"""
return 'master'
def create_pull_request(self, data: dict[str, Any] | None = None) -> dict[str, Any]:
"""Create a pull request.
Args:
data: The pull request data
Returns:
The created pull request
"""
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.base_url}/repositories/{self.owner}/{self.repo}/pullrequests'
payload = {
'title': title,
'description': description,
'source': {'branch': {'name': source_branch}},
'destination': {'branch': {'name': target_branch}},
'close_source_branch': False,
}
response = httpx.post(url, headers=self.headers, json=payload)
response.raise_for_status()
data = response.json()
# Ensure data is not None before accessing it
if data is None:
data = {}
return {
'html_url': data.get('links', {}).get('html', {}).get('href', ''),
'number': data.get('id', 0),
}
def request_reviewers(self, reviewer: str, pr_number: int) -> None:
"""Request reviewers for a pull request.
Args:
reviewer: The reviewer
pr_number: The pull request number
"""
logger.warning('BitbucketIssueHandler.request_reviewers not implemented')
def send_comment_msg(self, issue_number: int, msg: str) -> None:
"""Send a comment to an issue.
Args:
issue_number: The issue number
msg: The message
"""
url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/pullrequests/{issue_number}/comments'
payload = {'content': {'raw': msg}}
response = httpx.post(url, headers=self.headers, json=payload)
response.raise_for_status()
def get_issue_thread_comments(self, issue_number: int) -> list[str]:
"""Get thread comments for an issue.
Args:
issue_number: The issue number
Returns:
A list of thread comments
"""
logger.warning(
'BitbucketIssueHandler.get_issue_thread_comments not implemented'
)
return []
def get_issue_review_comments(self, issue_number: int) -> list[str]:
"""Get review comments for an issue.
Args:
issue_number: The issue number
Returns:
A list of review comments
"""
logger.warning(
'BitbucketIssueHandler.get_issue_review_comments not implemented'
)
return []
def get_issue_review_threads(self, issue_number: int) -> list[ReviewThread]:
"""Get review threads for an issue.
Args:
issue_number: The issue number
Returns:
A list of review threads
"""
logger.warning('BitbucketIssueHandler.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]:
"""Get context from external issue references.
Args:
closing_issues: List of closing issue references
closing_issue_numbers: List of closing issue numbers
issue_body: The issue body
review_comments: List of review comments
review_threads: List of review threads
thread_comments: List of thread comments
Returns:
Context from external issue references
"""
new_issue_references = []
if issue_body:
new_issue_references.extend(extract_issue_references(issue_body))
if review_comments:
for comment in review_comments:
new_issue_references.extend(extract_issue_references(comment))
if review_threads:
for review_thread in review_threads:
new_issue_references.extend(
extract_issue_references(review_thread.comment)
)
if thread_comments:
for thread_comment in thread_comments:
new_issue_references.extend(extract_issue_references(thread_comment))
non_duplicate_references = set(new_issue_references)
unique_issue_references = non_duplicate_references.difference(
closing_issue_numbers
)
for issue_number in unique_issue_references:
try:
url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/issues/{issue_number}'
response = httpx.get(url, headers=self.headers)
response.raise_for_status()
issue_data = response.json()
issue_body = issue_data.get('content', {}).get('raw', '')
if issue_body:
closing_issues.append(issue_body)
except httpx.HTTPError as e:
logger.warning(f'Failed to fetch issue {issue_number}: {str(e)}')
return closing_issues
def get_converted_issues(
self, issue_numbers: list[int] | None = None, comment_id: int | None = None
) -> list[Issue]:
"""Get converted issues.
Args:
issue_numbers: List of issue numbers
comment_id: The comment ID
Returns:
A list of converted issues
"""
if not issue_numbers:
raise ValueError('Unspecified issue numbers')
all_issues = self.download_issues()
logger.info(f'Limiting resolving to issues {issue_numbers}.')
all_issues = [issue for issue in all_issues if issue.get('id') in issue_numbers]
converted_issues = []
for issue in all_issues:
# For PRs, body can be None
if any([issue.get(key) is None for key in ['id', 'title']]):
logger.warning(f'Skipping #{issue} as it is missing id or title.')
continue
# Handle None body for PRs
body = (
issue.get('content', {}).get('raw', '')
if issue.get('content') is not None
else ''
)
# Placeholder for PR metadata
closing_issues: list[str] = []
review_comments: list[str] = []
review_threads: list[ReviewThread] = []
thread_ids: list[str] = []
head_branch = issue.get('source', {}).get('branch', {}).get('name', '')
thread_comments: list[str] = []
issue_details = Issue(
owner=self.owner,
repo=self.repo,
number=issue['id'],
title=issue['title'],
body=body,
closing_issues=closing_issues,
review_comments=review_comments,
review_threads=review_threads,
thread_ids=thread_ids,
head_branch=head_branch,
thread_comments=thread_comments,
)
converted_issues.append(issue_details)
return converted_issues
def get_graphql_url(self) -> str:
"""Get the GraphQL URL.
Returns:
The GraphQL URL
"""
return f'https://api.{self.base_domain}/graphql'
def reply_to_comment(self, pr_number: int, comment_id: str, reply: str) -> None:
"""Reply to a comment.
Args:
pr_number: The pull request number
comment_id: The comment ID
reply: The reply message
"""
url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/pullrequests/{pr_number}/comments/{comment_id}'
payload = {'content': {'raw': reply}}
response = httpx.post(url, headers=self.headers, json=payload)
response.raise_for_status()
def get_issue_references(self, body: str) -> list[int]:
"""Extract issue references from a string.
Args:
body: The string to extract issue references from
Returns:
A list of issue numbers
"""
return extract_issue_references(body)
class BitbucketPRHandler(BitbucketIssueHandler):
"""Handler for Bitbucket pull requests, extending the issue handler."""
def __init__(
self,
owner: str,
repo: str,
token: str,
username: str | None = None,
base_domain: str = 'bitbucket.org',
):
"""Initialize a Bitbucket PR handler.
Args:
owner: The workspace of the repository
repo: The name of the repository
token: The Bitbucket API token
username: Optional Bitbucket username
base_domain: The domain for Bitbucket Server (default: "bitbucket.org")
"""
super().__init__(owner, repo, token, username, base_domain)

View File

@ -121,5 +121,5 @@ class IssueHandlerInterface(ABC):
def get_converted_issues(
self, issue_numbers: list[int] | None = None, comment_id: int | None = None
) -> list[Issue]:
"""Download issues from Gitlab."""
"""Download issues from the git provider (GitHub, GitLab, or Bitbucket)."""
pass

View File

@ -1,5 +1,9 @@
from openhands.core.config import LLMConfig
from openhands.integrations.provider import ProviderType
from openhands.resolver.interfaces.bitbucket import (
BitbucketIssueHandler,
BitbucketPRHandler,
)
from openhands.resolver.interfaces.github import GithubIssueHandler, GithubPRHandler
from openhands.resolver.interfaces.gitlab import GitlabIssueHandler, GitlabPRHandler
from openhands.resolver.interfaces.issue_definitions import (
@ -42,7 +46,7 @@ class IssueHandlerFactory:
),
self.llm_config,
)
else: # platform == Platform.GITLAB
elif self.platform == ProviderType.GITLAB:
return ServiceContextIssue(
GitlabIssueHandler(
self.owner,
@ -53,6 +57,19 @@ class IssueHandlerFactory:
),
self.llm_config,
)
elif self.platform == ProviderType.BITBUCKET:
return ServiceContextIssue(
BitbucketIssueHandler(
self.owner,
self.repo,
self.token,
self.username,
self.base_domain,
),
self.llm_config,
)
else:
raise ValueError(f'Unsupported platform: {self.platform}')
elif self.issue_type == 'pr':
if self.platform == ProviderType.GITHUB:
return ServiceContextPR(
@ -65,7 +82,7 @@ class IssueHandlerFactory:
),
self.llm_config,
)
else: # platform == Platform.GITLAB
elif self.platform == ProviderType.GITLAB:
return ServiceContextPR(
GitlabPRHandler(
self.owner,
@ -76,5 +93,18 @@ class IssueHandlerFactory:
),
self.llm_config,
)
elif self.platform == ProviderType.BITBUCKET:
return ServiceContextPR(
BitbucketPRHandler(
self.owner,
self.repo,
self.token,
self.username,
self.base_domain,
),
self.llm_config,
)
else:
raise ValueError(f'Unsupported platform: {self.platform}')
else:
raise ValueError(f'Invalid issue type: {self.issue_type}')

View File

@ -76,7 +76,12 @@ class IssueResolver:
raise ValueError('Invalid repository format. Expected owner/repo')
owner, repo = parts
token = args.token or os.getenv('GITHUB_TOKEN') or os.getenv('GITLAB_TOKEN')
token = (
args.token
or os.getenv('GITHUB_TOKEN')
or os.getenv('GITLAB_TOKEN')
or os.getenv('BITBUCKET_TOKEN')
)
username = args.username if args.username else os.getenv('GIT_USERNAME')
if not username:
raise ValueError('Username is required.')
@ -120,7 +125,11 @@ class IssueResolver:
base_domain = args.base_domain
if base_domain is None:
base_domain = (
'github.com' if platform == ProviderType.GITHUB else 'gitlab.com'
'github.com'
if platform == ProviderType.GITHUB
else 'gitlab.com'
if platform == ProviderType.GITLAB
else 'bitbucket.org'
)
self.output_dir = args.output_dir

View File

@ -116,7 +116,7 @@ def main() -> None:
'--base-domain',
type=str,
default=None,
help='Base domain for the git server (defaults to "github.com" for GitHub and "gitlab.com" for GitLab)',
help='Base domain for the git server (defaults to "github.com" for GitHub, "gitlab.com" for GitLab, and "bitbucket.org" for Bitbucket)',
)
my_args = parser.parse_args()

View File

@ -11,6 +11,7 @@ from openhands.core.config import LLMConfig
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import ProviderType
from openhands.llm.llm import LLM
from openhands.resolver.interfaces.bitbucket import BitbucketIssueHandler
from openhands.resolver.interfaces.github import GithubIssueHandler
from openhands.resolver.interfaces.gitlab import GitlabIssueHandler
from openhands.resolver.interfaces.issue import Issue
@ -235,40 +236,55 @@ def send_pull_request(
pr_title: str | None = None,
base_domain: str | None = None,
) -> str:
"""Send a pull request to a GitHub or Gitlab repository.
"""Send a pull request to a GitHub, GitLab, or Bitbucket repository.
Args:
issue: The issue to send the pull request for
token: The GitHub or Gitlab token to use for authentication
username: The GitHub or Gitlab username, if provided
token: The token to use for authentication
username: The username, if provided
platform: The platform of the repository.
patch_dir: The directory containing the patches to apply
pr_type: The type: branch (no PR created), draft or ready (regular PR created)
fork_owner: The owner of the fork to push changes to (if different from the original repo owner)
additional_message: The additional messages to post as a comment on the PR in json list format
target_branch: The target branch to create the pull request against (defaults to repository default branch)
reviewer: The GitHub or Gitlab username of the reviewer to assign
reviewer: The username of the reviewer to assign
pr_title: Custom title for the pull request (optional)
base_domain: The base domain for the git server (defaults to "github.com" for GitHub and "gitlab.com" for GitLab)
base_domain: The base domain for the git server (defaults to "github.com" for GitHub, "gitlab.com" for GitLab, and "bitbucket.org" for Bitbucket)
"""
if pr_type not in ['branch', 'draft', 'ready']:
raise ValueError(f'Invalid pr_type: {pr_type}')
# Determine default base_domain based on platform
if base_domain is None:
base_domain = 'github.com' if platform == ProviderType.GITHUB else 'gitlab.com'
if platform == ProviderType.GITHUB:
base_domain = 'github.com'
elif platform == ProviderType.GITLAB:
base_domain = 'gitlab.com'
else: # platform == ProviderType.BITBUCKET
base_domain = 'bitbucket.org'
# Create the appropriate handler based on platform
handler = None
if platform == ProviderType.GITHUB:
handler = ServiceContextIssue(
GithubIssueHandler(issue.owner, issue.repo, token, username, base_domain),
None,
)
else: # platform == Platform.GITLAB
elif platform == ProviderType.GITLAB:
handler = ServiceContextIssue(
GitlabIssueHandler(issue.owner, issue.repo, token, username, base_domain),
None,
)
elif platform == ProviderType.BITBUCKET:
handler = ServiceContextIssue(
BitbucketIssueHandler(
issue.owner, issue.repo, token, username, base_domain
),
None,
)
else:
raise ValueError(f'Unsupported platform: {platform}')
# Create a new branch with a unique name
base_branch_name = f'openhands-fix-issue-{issue.number}'

View File

@ -17,7 +17,7 @@ from openhands.integrations.utils import validate_provider_token
async def identify_token(token: str, base_domain: str | None) -> ProviderType:
"""
Identifies whether a token belongs to GitHub or GitLab.
Identifies whether a token belongs to GitHub, GitLab, or Bitbucket.
Parameters:
token (str): The personal access token to check.
base_domain (str): Custom base domain for provider (e.g GitHub Enterprise)

View File

@ -617,6 +617,7 @@ fi
provider_domains = {
ProviderType.GITHUB: 'github.com',
ProviderType.GITLAB: 'gitlab.com',
ProviderType.BITBUCKET: 'bitbucket.org',
}
domain = provider_domains[provider]
@ -629,10 +630,22 @@ fi
if git_provider_tokens and provider in git_provider_tokens:
git_token = git_provider_tokens[provider].token
if git_token:
token_value = git_token.get_secret_value()
if provider == ProviderType.GITLAB:
remote_url = f'https://oauth2:{git_token.get_secret_value()}@{domain}/{repo_name}.git'
remote_url = (
f'https://oauth2:{token_value}@{domain}/{repo_name}.git'
)
elif provider == ProviderType.BITBUCKET:
# For Bitbucket, handle username:app_password format
if ':' in token_value:
# App token format: username:app_password
remote_url = f'https://{token_value}@{domain}/{repo_name}.git'
else:
# Access token format: use x-token-auth
remote_url = f'https://x-token-auth:{token_value}@{domain}/{repo_name}.git'
else:
remote_url = f'https://{git_token.get_secret_value()}@{domain}/{repo_name}.git'
# GitHub
remote_url = f'https://{token_value}@{domain}/{repo_name}.git'
else:
remote_url = f'https://{domain}/{repo_name}.git'
else:

View File

@ -8,6 +8,7 @@ from fastmcp.server.dependencies import get_http_request
from pydantic import Field
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.bitbucket.bitbucket_service import BitbucketService
from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.provider import ProviderToken
@ -206,3 +207,71 @@ async def create_mr(
raise ToolError(str(error))
return response
@mcp_server.tool()
async def create_bitbucket_pr(
repo_name: Annotated[
str, Field(description='Bitbucket repository (workspace/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"""
logger.info('Calling OpenHands MCP create_bitbucket_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_token = (
provider_tokens.get(ProviderType.BITBUCKET, ProviderToken())
if provider_tokens
else ProviderToken()
)
bitbucket_service = BitbucketService(
user_id=bitbucket_token.user_id,
external_auth_id=user_id,
external_auth_token=access_token,
token=bitbucket_token.token,
base_domain=bitbucket_token.host,
)
try:
description = await get_convo_link(
bitbucket_service, conversation_id, description or ''
)
except Exception as e:
logger.warning(f'Failed to append convo link: {e}')
try:
response = await bitbucket_service.create_pr(
repo_name=repo_name,
source_branch=source_branch,
target_branch=target_branch,
title=title,
body=description,
)
if conversation_id and user_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

View File

@ -330,10 +330,8 @@ class AgentSession:
if runtime_cls == RemoteRuntime:
# If provider tokens is passed in custom secrets, then remove provider from provider tokens
# We prioritize provider tokens set in custom secrets
provider_tokens_without_gitlab = (
self.override_provider_tokens_with_custom_secret(
git_provider_tokens, custom_secrets
)
overrided_tokens = self.override_provider_tokens_with_custom_secret(
git_provider_tokens, custom_secrets
)
self.runtime = runtime_cls(
@ -344,7 +342,7 @@ class AgentSession:
status_callback=self._status_callback,
headless_mode=False,
attach_to_existing=False,
git_provider_tokens=provider_tokens_without_gitlab,
git_provider_tokens=overrided_tokens,
env_vars=env_vars,
user_id=self.user_id,
)

View File

@ -58,7 +58,7 @@ def get_impl(cls: type[T], impl_name: str | None) -> type[T]:
Common Use Cases:
- Server components (ConversationManager, UserAuth, etc.)
- Storage implementations (ConversationStore, SettingsStore, etc.)
- Service integrations (GitHub, GitLab services)
- Service integrations (GitHub, GitLab, Bitbucket services)
The implementation is cached to avoid repeated imports of the same class.
"""

View File

@ -16,7 +16,7 @@ def test_initialize_repository_for_runtime(temp_dir, runtime_cls, run_as_openhan
"""Test that the initialize_repository_for_runtime function works."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
mock_repo = Repository(
id=1232,
id='1232',
full_name='All-Hands-AI/OpenHands',
git_provider=ProviderType.GITHUB,
is_public=True,

View File

@ -12,12 +12,13 @@ def assert_sandbox_config(
base_container_image=SandboxConfig.model_fields['base_container_image'].default,
runtime_container_image='ghcr.io/all-hands-ai/runtime:mock-nikolaik', # Default to mock version
local_runtime_url=SandboxConfig.model_fields['local_runtime_url'].default,
enable_auto_lint=False,
):
"""Helper function to assert the properties of the SandboxConfig object."""
assert isinstance(config, SandboxConfig)
assert config.base_container_image == base_container_image
assert config.runtime_container_image == runtime_container_image
assert config.enable_auto_lint is False
assert config.enable_auto_lint is enable_auto_lint
assert config.use_host_network is False
assert config.timeout == 300
assert config.local_runtime_url == local_runtime_url

View File

@ -0,0 +1,685 @@
"""Tests for Bitbucket integration."""
import os
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import SecretStr
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.integrations.service_types import ProviderType as ServiceProviderType
from openhands.integrations.service_types import Repository
from openhands.integrations.utils import validate_provider_token
from openhands.resolver.interfaces.bitbucket import BitbucketIssueHandler
from openhands.resolver.interfaces.issue import Issue
from openhands.resolver.interfaces.issue_definitions import ServiceContextIssue
from openhands.resolver.send_pull_request import send_pull_request
from openhands.runtime.base import Runtime
from openhands.server.routes.secrets import check_provider_tokens
from openhands.server.settings import POSTProviderModel
# BitbucketIssueHandler Tests
@pytest.fixture
def bitbucket_handler():
return BitbucketIssueHandler(
owner='test-workspace',
repo='test-repo',
token='test-token',
username='test-user',
)
def test_init():
handler = BitbucketIssueHandler(
owner='test-workspace',
repo='test-repo',
token='test-token',
username='test-user',
)
assert handler.owner == 'test-workspace'
assert handler.repo == 'test-repo'
assert handler.token == 'test-token'
assert handler.username == 'test-user'
assert handler.base_domain == 'bitbucket.org'
assert handler.base_url == 'https://api.bitbucket.org/2.0'
assert (
handler.download_url
== 'https://bitbucket.org/test-workspace/test-repo/get/master.zip'
)
assert handler.clone_url == 'https://bitbucket.org/test-workspace/test-repo.git'
assert handler.headers == {
'Authorization': 'Bearer test-token',
'Accept': 'application/json',
}
def test_get_repo_url(bitbucket_handler):
assert (
bitbucket_handler.get_repo_url()
== 'https://bitbucket.org/test-workspace/test-repo'
)
def test_get_issue_url(bitbucket_handler):
assert (
bitbucket_handler.get_issue_url(123)
== 'https://bitbucket.org/test-workspace/test-repo/issues/123'
)
def test_get_pr_url(bitbucket_handler):
assert (
bitbucket_handler.get_pr_url(123)
== 'https://bitbucket.org/test-workspace/test-repo/pull-requests/123'
)
@pytest.mark.asyncio
@patch('httpx.AsyncClient')
async def test_get_issue(mock_client, bitbucket_handler):
mock_response = MagicMock()
mock_response.raise_for_status = AsyncMock()
mock_response.json.return_value = {
'id': 123,
'title': 'Test Issue',
'content': {'raw': 'Test Issue Body'},
'links': {
'html': {
'href': 'https://bitbucket.org/test-workspace/test-repo/issues/123'
}
},
'state': 'open',
'reporter': {'display_name': 'Test User'},
'assignee': [{'display_name': 'Assignee User'}],
}
mock_client_instance = AsyncMock()
mock_client_instance.get.return_value = mock_response
mock_client.return_value.__aenter__.return_value = mock_client_instance
issue = await bitbucket_handler.get_issue(123)
assert issue.number == 123
assert issue.title == 'Test Issue'
assert issue.body == 'Test Issue Body'
# We don't test for html_url, state, user, or assignees as they're not part of the Issue model
@patch('httpx.post')
def test_create_pr(mock_post, bitbucket_handler):
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {
'links': {
'html': {
'href': 'https://bitbucket.org/test-workspace/test-repo/pull-requests/123'
}
},
}
mock_post.return_value = mock_response
pr_url = bitbucket_handler.create_pr(
title='Test PR',
body='Test PR Body',
head='feature-branch',
base='main',
)
assert pr_url == 'https://bitbucket.org/test-workspace/test-repo/pull-requests/123'
expected_payload = {
'title': 'Test PR',
'description': 'Test PR Body',
'source': {'branch': {'name': 'feature-branch'}},
'destination': {'branch': {'name': 'main'}},
'close_source_branch': False,
}
mock_post.assert_called_once_with(
'https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests',
headers=bitbucket_handler.headers,
json=expected_payload,
)
# Bitbucket Send Pull Request Tests
@patch('openhands.resolver.send_pull_request.ServiceContextIssue')
@patch('openhands.resolver.send_pull_request.BitbucketIssueHandler')
@patch('subprocess.run')
def test_send_pull_request_bitbucket(
mock_run, mock_bitbucket_handler, mock_service_context
):
# Mock subprocess.run to avoid actual git operations
mock_run.return_value = MagicMock(returncode=0)
# Mock the BitbucketIssueHandler instance
mock_instance = MagicMock(spec=BitbucketIssueHandler)
mock_bitbucket_handler.return_value = mock_instance
# Mock the ServiceContextIssue instance
mock_service = MagicMock(spec=ServiceContextIssue)
mock_service.get_branch_name.return_value = 'openhands-fix-123'
mock_service.branch_exists.return_value = True
mock_service.get_default_branch_name.return_value = 'main'
mock_service.get_clone_url.return_value = (
'https://bitbucket.org/test-workspace/test-repo.git'
)
mock_service.create_pull_request.return_value = {
'html_url': 'https://bitbucket.org/test-workspace/test-repo/pull-requests/123'
}
# Add _strategy attribute to mock
mock_strategy = MagicMock()
mock_service._strategy = mock_strategy
mock_service_context.return_value = mock_service
# Create a mock Issue
mock_issue = Issue(
number=123,
title='Test Issue',
owner='test-workspace',
repo='test-repo',
body='Test body',
created_at='2023-01-01T00:00:00Z',
updated_at='2023-01-01T00:00:00Z',
closed_at=None,
head_branch='feature-branch',
thread_ids=None,
)
# Call send_pull_request
result = send_pull_request(
issue=mock_issue,
token='test-token',
username=None,
platform=ServiceProviderType.BITBUCKET,
patch_dir='/tmp', # Use /tmp instead of /tmp/repo to avoid directory not found error
pr_type='ready',
pr_title='Test PR',
target_branch='main',
)
# Verify the result
assert result == 'https://bitbucket.org/test-workspace/test-repo/pull-requests/123'
# Verify the handler was created correctly
mock_bitbucket_handler.assert_called_once_with(
'test-workspace',
'test-repo',
'test-token',
None,
'bitbucket.org',
)
# Verify ServiceContextIssue was created correctly
mock_service_context.assert_called_once()
# Verify create_pull_request was called with the correct data
expected_body = 'This pull request fixes #123.\n\nAutomatic fix generated by [OpenHands](https://github.com/All-Hands-AI/OpenHands/) 🙌'
mock_service.create_pull_request.assert_called_once_with(
{
'title': 'Test PR',
'description': expected_body,
'source_branch': 'openhands-fix-123',
'target_branch': 'main',
'draft': False,
}
)
# Bitbucket Provider Domain Tests
class TestBitbucketProviderDomain(unittest.TestCase):
"""Test that Bitbucket provider domain is properly handled in Runtime.clone_or_init_repo."""
@patch('openhands.runtime.base.Runtime.__abstractmethods__', set())
@patch(
'openhands.runtime.utils.edit.FileEditRuntimeMixin.__init__', return_value=None
)
@patch('openhands.runtime.base.ProviderHandler')
@pytest.mark.asyncio
async def test_get_authenticated_git_url_bitbucket(
self, mock_provider_handler, mock_file_edit_init, *args
):
"""Test that _get_authenticated_git_url correctly handles Bitbucket repositories."""
# Mock the provider handler to return a repository with Bitbucket as the provider
mock_repository = Repository(
id='1',
full_name='workspace/repo',
git_provider=ServiceProviderType.BITBUCKET,
is_public=True,
)
mock_provider_instance = MagicMock()
mock_provider_instance.verify_repo_provider.return_value = mock_repository
mock_provider_handler.return_value = mock_provider_instance
# Create a minimal runtime instance with abstract methods patched
config = MagicMock()
config.get_llm_config.return_value.model = 'test_model'
runtime = Runtime(config=config, event_stream=MagicMock(), sid='test_sid')
# Test with no token
url = await runtime._get_authenticated_git_url('workspace/repo', None)
self.assertEqual(url, 'https://bitbucket.org/workspace/repo.git')
# Test with username:password format token
git_provider_tokens = {
ProviderType.BITBUCKET: ProviderToken(
token=SecretStr('username:app_password'), host='bitbucket.org'
)
}
url = await runtime._get_authenticated_git_url(
'workspace/repo', git_provider_tokens
)
# Bitbucket tokens with colon are used directly as username:password
self.assertEqual(
url, 'https://username:app_password@bitbucket.org/workspace/repo.git'
)
# Test with email:password format token (more realistic)
git_provider_tokens = {
ProviderType.BITBUCKET: ProviderToken(
token=SecretStr('user@example.com:app_password'), host='bitbucket.org'
)
}
url = await runtime._get_authenticated_git_url(
'workspace/repo', git_provider_tokens
)
# Email addresses in tokens are used as-is (no URL encoding in our implementation)
self.assertEqual(
url,
'https://user@example.com:app_password@bitbucket.org/workspace/repo.git',
)
# Test with simple token format (access token)
git_provider_tokens = {
ProviderType.BITBUCKET: ProviderToken(
token=SecretStr('simple_token'), host='bitbucket.org'
)
}
url = await runtime._get_authenticated_git_url(
'workspace/repo', git_provider_tokens
)
# Simple tokens use x-token-auth format
self.assertEqual(
url, 'https://x-token-auth:simple_token@bitbucket.org/workspace/repo.git'
)
@patch('openhands.runtime.base.ProviderHandler')
@patch.object(Runtime, 'run_action')
async def test_bitbucket_provider_domain(
self, mock_run_action, mock_provider_handler
):
# Mock the provider handler to return a repository with Bitbucket as the provider
mock_repository = Repository(
id='1',
full_name='test/repo',
git_provider=ServiceProviderType.BITBUCKET,
is_public=True,
)
mock_provider_instance = MagicMock()
mock_provider_instance.verify_repo_provider.return_value = mock_repository
mock_provider_handler.return_value = mock_provider_instance
# Create a minimal runtime instance
runtime = Runtime(config=MagicMock(), event_stream=MagicMock(), sid='test_sid')
# Mock the workspace_root property to avoid AttributeError
runtime.workspace_root = '/workspace'
# Call clone_or_init_repo with a Bitbucket repository
# This should now succeed with our fix
await runtime.clone_or_init_repo(
git_provider_tokens=None,
selected_repository='test/repo',
selected_branch=None,
)
# Verify that run_action was called at least once (for git clone)
self.assertTrue(mock_run_action.called)
# Verify that the domain used was 'bitbucket.org'
# Extract the command from the first call to run_action
args, _ = mock_run_action.call_args
action = args[0]
self.assertIn('bitbucket.org', action.command)
# Provider Token Validation Tests
@pytest.mark.asyncio
async def test_validate_provider_token_with_bitbucket_token():
"""
Test that validate_provider_token correctly identifies a Bitbucket token
and doesn't try to validate it as GitHub or GitLab.
"""
# Mock the service classes to avoid actual API calls
with (
patch('openhands.integrations.utils.GitHubService') as mock_github_service,
patch('openhands.integrations.utils.GitLabService') as mock_gitlab_service,
patch(
'openhands.integrations.utils.BitbucketService'
) as mock_bitbucket_service,
):
# Set up the mocks
github_instance = AsyncMock()
github_instance.verify_access.side_effect = Exception('Invalid GitHub token')
mock_github_service.return_value = github_instance
gitlab_instance = AsyncMock()
gitlab_instance.get_user.side_effect = Exception('Invalid GitLab token')
mock_gitlab_service.return_value = gitlab_instance
bitbucket_instance = AsyncMock()
bitbucket_instance.get_user.return_value = {'username': 'test_user'}
mock_bitbucket_service.return_value = bitbucket_instance
# Test with a Bitbucket token
token = SecretStr('username:app_password')
result = await validate_provider_token(token)
# Verify that all services were tried
mock_github_service.assert_called_once()
mock_gitlab_service.assert_called_once()
mock_bitbucket_service.assert_called_once()
# Verify that the token was identified as a Bitbucket token
assert result == ProviderType.BITBUCKET
@pytest.mark.asyncio
async def test_check_provider_tokens_with_only_bitbucket():
"""
Test that check_provider_tokens doesn't try to validate GitHub or GitLab tokens
when only a Bitbucket token is provided.
"""
# Create a mock validate_provider_token function
mock_validate = AsyncMock()
mock_validate.return_value = ProviderType.BITBUCKET
# Create provider tokens with only Bitbucket
provider_tokens = {
ProviderType.BITBUCKET: ProviderToken(
token=SecretStr('username:app_password'), host='bitbucket.org'
),
ProviderType.GITHUB: ProviderToken(token=SecretStr(''), host='github.com'),
ProviderType.GITLAB: ProviderToken(token=SecretStr(''), host='gitlab.com'),
}
# Create the POST model
post_model = POSTProviderModel(provider_tokens=provider_tokens)
# Call check_provider_tokens with the patched validate_provider_token
with patch(
'openhands.server.routes.secrets.validate_provider_token', mock_validate
):
result = await check_provider_tokens(post_model, None)
# Verify that validate_provider_token was called only once (for Bitbucket)
assert mock_validate.call_count == 1
# Verify that the token passed to validate_provider_token was the Bitbucket token
args, kwargs = mock_validate.call_args
assert args[0].get_secret_value() == 'username:app_password'
# Verify that no error message was returned
assert result == ''
@pytest.mark.asyncio
async def test_bitbucket_sort_parameter_mapping():
"""
Test that the Bitbucket service correctly maps sort parameters.
"""
from unittest.mock import patch
from pydantic import SecretStr
from openhands.integrations.bitbucket.bitbucket_service import BitbucketService
from openhands.server.types import AppMode
# Create a service instance
service = BitbucketService(token=SecretStr('test-token'))
# Mock the _make_request method to avoid actual API calls
with patch.object(service, '_make_request') as mock_request:
# Mock workspaces response
mock_request.side_effect = [
# First call: workspaces
({'values': [{'slug': 'test-workspace', 'name': 'Test Workspace'}]}, {}),
# Second call: repositories with mapped sort parameter
({'values': []}, {}),
]
# Call get_repositories with sort='pushed'
await service.get_repositories('pushed', AppMode.SAAS)
# Verify that the second call used 'updated_on' instead of 'pushed'
assert mock_request.call_count == 2
# Check the second call (repositories call)
second_call_args = mock_request.call_args_list[1]
url, params = second_call_args[0]
# Verify the sort parameter was mapped correctly
assert params['sort'] == 'updated_on'
assert 'repositories/test-workspace' in url
@pytest.mark.asyncio
async def test_validate_provider_token_with_empty_tokens():
"""
Test that validate_provider_token handles empty tokens correctly.
"""
# Create a mock for each service
with (
patch('openhands.integrations.utils.GitHubService') as mock_github_service,
patch('openhands.integrations.utils.GitLabService') as mock_gitlab_service,
patch(
'openhands.integrations.utils.BitbucketService'
) as mock_bitbucket_service,
):
# Configure mocks to raise exceptions for invalid tokens
mock_github_service.return_value.verify_access.side_effect = Exception(
'Invalid token'
)
mock_gitlab_service.return_value.verify_access.side_effect = Exception(
'Invalid token'
)
mock_bitbucket_service.return_value.verify_access.side_effect = Exception(
'Invalid token'
)
# Test with an empty token
token = SecretStr('')
result = await validate_provider_token(token)
# Services should be tried but fail with empty tokens
mock_github_service.assert_called_once()
mock_gitlab_service.assert_called_once()
mock_bitbucket_service.assert_called_once()
# Result should be None for invalid tokens
assert result is None
# Reset mocks for second test
mock_github_service.reset_mock()
mock_gitlab_service.reset_mock()
mock_bitbucket_service.reset_mock()
# Test with a whitespace-only token
token = SecretStr(' ')
result = await validate_provider_token(token)
# Services should be tried but fail with whitespace tokens
mock_github_service.assert_called_once()
mock_gitlab_service.assert_called_once()
mock_bitbucket_service.assert_called_once()
# Result should be None for invalid tokens
assert result is None
# Setup.py Bitbucket Token Tests
@patch('openhands.core.setup.call_async_from_sync')
@patch('openhands.core.setup.get_file_store')
@patch('openhands.core.setup.EventStream')
def test_initialize_repository_for_runtime_with_bitbucket_token(
mock_event_stream, mock_get_file_store, mock_call_async_from_sync
):
"""Test that initialize_repository_for_runtime properly handles BITBUCKET_TOKEN."""
from openhands.core.setup import initialize_repository_for_runtime
from openhands.integrations.provider import ProviderType
# Mock runtime
mock_runtime = MagicMock()
mock_runtime.clone_or_init_repo = AsyncMock(return_value='test-repo')
mock_runtime.maybe_run_setup_script = MagicMock()
mock_runtime.maybe_setup_git_hooks = MagicMock()
# Mock call_async_from_sync to return the expected result
mock_call_async_from_sync.return_value = 'test-repo'
# Set up environment with BITBUCKET_TOKEN
with patch.dict(os.environ, {'BITBUCKET_TOKEN': 'username:app_password'}):
result = initialize_repository_for_runtime(
runtime=mock_runtime, selected_repository='all-hands-ai/test-repo'
)
# Verify the result
assert result == 'test-repo'
# Verify that call_async_from_sync was called with the correct arguments
mock_call_async_from_sync.assert_called_once()
args, kwargs = mock_call_async_from_sync.call_args
# Check that the function called was clone_or_init_repo
assert args[0] == mock_runtime.clone_or_init_repo
# Check that provider tokens were passed correctly
provider_tokens = args[2] # Third argument is immutable_provider_tokens
assert provider_tokens is not None
assert ProviderType.BITBUCKET in provider_tokens
assert (
provider_tokens[ProviderType.BITBUCKET].token.get_secret_value()
== 'username:app_password'
)
# Check that the repository was passed correctly
assert args[3] == 'all-hands-ai/test-repo' # selected_repository
assert args[4] is None # selected_branch
@patch('openhands.core.setup.call_async_from_sync')
@patch('openhands.core.setup.get_file_store')
@patch('openhands.core.setup.EventStream')
def test_initialize_repository_for_runtime_with_multiple_tokens(
mock_event_stream, mock_get_file_store, mock_call_async_from_sync
):
"""Test that initialize_repository_for_runtime handles multiple provider tokens including Bitbucket."""
from openhands.core.setup import initialize_repository_for_runtime
from openhands.integrations.provider import ProviderType
# Mock runtime
mock_runtime = MagicMock()
mock_runtime.clone_or_init_repo = AsyncMock(return_value='test-repo')
mock_runtime.maybe_run_setup_script = MagicMock()
mock_runtime.maybe_setup_git_hooks = MagicMock()
# Mock call_async_from_sync to return the expected result
mock_call_async_from_sync.return_value = 'test-repo'
# Set up environment with multiple tokens
with patch.dict(
os.environ,
{
'GITHUB_TOKEN': 'github_token_123',
'GITLAB_TOKEN': 'gitlab_token_456',
'BITBUCKET_TOKEN': 'username:bitbucket_app_password',
},
):
result = initialize_repository_for_runtime(
runtime=mock_runtime, selected_repository='all-hands-ai/test-repo'
)
# Verify the result
assert result == 'test-repo'
# Verify that call_async_from_sync was called
mock_call_async_from_sync.assert_called_once()
args, kwargs = mock_call_async_from_sync.call_args
# Check that provider tokens were passed correctly
provider_tokens = args[2] # Third argument is immutable_provider_tokens
assert provider_tokens is not None
# Verify all three provider types are present
assert ProviderType.GITHUB in provider_tokens
assert ProviderType.GITLAB in provider_tokens
assert ProviderType.BITBUCKET in provider_tokens
# Verify token values
assert (
provider_tokens[ProviderType.GITHUB].token.get_secret_value()
== 'github_token_123'
)
assert (
provider_tokens[ProviderType.GITLAB].token.get_secret_value()
== 'gitlab_token_456'
)
assert (
provider_tokens[ProviderType.BITBUCKET].token.get_secret_value()
== 'username:bitbucket_app_password'
)
@patch('openhands.core.setup.call_async_from_sync')
@patch('openhands.core.setup.get_file_store')
@patch('openhands.core.setup.EventStream')
def test_initialize_repository_for_runtime_without_bitbucket_token(
mock_event_stream, mock_get_file_store, mock_call_async_from_sync
):
"""Test that initialize_repository_for_runtime works without BITBUCKET_TOKEN."""
from openhands.core.setup import initialize_repository_for_runtime
from openhands.integrations.provider import ProviderType
# Mock runtime
mock_runtime = MagicMock()
mock_runtime.clone_or_init_repo = AsyncMock(return_value='test-repo')
mock_runtime.maybe_run_setup_script = MagicMock()
mock_runtime.maybe_setup_git_hooks = MagicMock()
# Mock call_async_from_sync to return the expected result
mock_call_async_from_sync.return_value = 'test-repo'
# Set up environment without BITBUCKET_TOKEN but with other tokens
with patch.dict(
os.environ,
{'GITHUB_TOKEN': 'github_token_123', 'GITLAB_TOKEN': 'gitlab_token_456'},
clear=False,
):
# Ensure BITBUCKET_TOKEN is not in environment
if 'BITBUCKET_TOKEN' in os.environ:
del os.environ['BITBUCKET_TOKEN']
result = initialize_repository_for_runtime(
runtime=mock_runtime, selected_repository='all-hands-ai/test-repo'
)
# Verify the result
assert result == 'test-repo'
# Verify that call_async_from_sync was called
mock_call_async_from_sync.assert_called_once()
args, kwargs = mock_call_async_from_sync.call_args
# Check that provider tokens were passed correctly
provider_tokens = args[2] # Third argument is immutable_provider_tokens
assert provider_tokens is not None
# Verify only GitHub and GitLab are present, not Bitbucket
assert ProviderType.GITHUB in provider_tokens
assert ProviderType.GITLAB in provider_tokens
assert ProviderType.BITBUCKET not in provider_tokens

View File

@ -101,7 +101,7 @@ def runtime(temp_dir):
def mock_repo_and_patch(monkeypatch, provider=ProviderType.GITHUB, is_public=True):
repo = Repository(
id=123, full_name='owner/repo', git_provider=provider, is_public=is_public
id='123', full_name='owner/repo', git_provider=provider, is_public=is_public
)
async def mock_verify_repo_provider(*_args, **_kwargs):

View File

@ -10,7 +10,7 @@ from openhands.integrations.service_types import TaskType, User
async def test_get_suggested_tasks():
# Mock responses
mock_user = User(
id=1,
id='1',
login='test-user',
avatar_url='https://example.com/avatar.jpg',
name='Test User',