From 3504ca77527a86068d15306e2ab7412b8d4d61e8 Mon Sep 17 00:00:00 2001 From: Wan Arif Date: Sun, 23 Nov 2025 03:00:24 +0800 Subject: [PATCH] feat: add Azure DevOps integration support (#11243) Co-authored-by: Graham Neubig --- .../features/home/repo-connector.test.tsx | 1 + .../features/home/task-card.test.tsx | 1 + .../__tests__/routes/git-settings.test.tsx | 35 ++ .../convert-raw-providers-to-list.test.ts | 7 +- .../src/assets/branding/azure-devops-logo.svg | 1 + .../conversation-repo-link.tsx | 6 +- .../git-provider-dropdown.tsx | 2 + .../features/home/tasks/task-card.tsx | 9 + .../azure-devops-token-help-anchor.tsx | 20 + .../git-settings/azure-devops-token-input.tsx | 64 +++ .../configure-azure-devops-anchor.tsx | 37 ++ .../features/waitlist/auth-modal.tsx | 30 ++ .../components/shared/git-provider-icon.tsx | 10 +- .../conversation-subscriptions-provider.tsx | 16 +- frontend/src/i18n/declaration.ts | 6 + frontend/src/i18n/translation.json | 124 ++++- frontend/src/routes/git-settings.tsx | 50 +- frontend/src/types/settings.ts | 1 + frontend/src/utils/generate-auth-url.ts | 2 +- frontend/src/utils/local-storage.ts | 3 +- frontend/src/utils/utils.ts | 32 ++ .../codeact_agent/prompts/system_prompt.j2 | 2 +- openhands/app_server/utils/import_utils.py | 2 +- .../azure_devops/azure_devops_service.py | 249 ++++++++++ .../azure_devops/service/__init__.py | 1 + .../integrations/azure_devops/service/base.py | 67 +++ .../azure_devops/service/branches.py | 195 ++++++++ .../azure_devops/service/features.py | 223 +++++++++ .../integrations/azure_devops/service/prs.py | 321 +++++++++++++ .../azure_devops/service/repos.py | 178 ++++++++ .../azure_devops/service/resolver.py | 166 +++++++ .../azure_devops/service/work_items.py | 129 ++++++ .../integrations/protocols/http_client.py | 2 +- openhands/integrations/provider.py | 78 +++- openhands/integrations/service_types.py | 1 + .../issue_conversation_instructions.j2 | 41 ++ .../resolver/azure_devops/issue_prompt.j2 | 5 + .../pr_update_conversation_instructions.j2 | 38 ++ .../resolver/azure_devops/pr_update_prompt.j2 | 1 + openhands/integrations/utils.py | 20 +- openhands/resolver/README.md | 20 +- openhands/resolver/interfaces/azure_devops.py | 427 ++++++++++++++++++ openhands/resolver/interfaces/issue.py | 2 +- openhands/resolver/issue_handler_factory.py | 42 ++ openhands/resolver/issue_resolver.py | 3 + openhands/resolver/resolve_issue.py | 2 +- openhands/resolver/send_pull_request.py | 56 ++- openhands/resolver/utils.py | 2 +- openhands/runtime/base.py | 51 ++- openhands/server/routes/git.py | 2 + openhands/server/routes/mcp.py | 70 +++ openhands/utils/README.md | 1 + openhands/utils/import_utils.py | 2 +- skills/azure_devops.md | 52 +++ .../resolver/test_issue_handler_factory.py | 41 +- tests/unit/runtime/test_runtime_git_tokens.py | 69 ++- .../test_runtime_gitlab_microagents.py | 59 ++- tests/unit/test_azure_devops.py | 127 ++++++ 58 files changed, 3108 insertions(+), 96 deletions(-) create mode 100644 frontend/src/assets/branding/azure-devops-logo.svg create mode 100644 frontend/src/components/features/settings/git-settings/azure-devops-token-help-anchor.tsx create mode 100644 frontend/src/components/features/settings/git-settings/azure-devops-token-input.tsx create mode 100644 frontend/src/components/features/settings/git-settings/configure-azure-devops-anchor.tsx create mode 100644 openhands/integrations/azure_devops/azure_devops_service.py create mode 100644 openhands/integrations/azure_devops/service/__init__.py create mode 100644 openhands/integrations/azure_devops/service/base.py create mode 100644 openhands/integrations/azure_devops/service/branches.py create mode 100644 openhands/integrations/azure_devops/service/features.py create mode 100644 openhands/integrations/azure_devops/service/prs.py create mode 100644 openhands/integrations/azure_devops/service/repos.py create mode 100644 openhands/integrations/azure_devops/service/resolver.py create mode 100644 openhands/integrations/azure_devops/service/work_items.py create mode 100644 openhands/integrations/templates/resolver/azure_devops/issue_conversation_instructions.j2 create mode 100644 openhands/integrations/templates/resolver/azure_devops/issue_prompt.j2 create mode 100644 openhands/integrations/templates/resolver/azure_devops/pr_update_conversation_instructions.j2 create mode 100644 openhands/integrations/templates/resolver/azure_devops/pr_update_prompt.j2 create mode 100644 openhands/resolver/interfaces/azure_devops.py create mode 100644 skills/azure_devops.md create mode 100644 tests/unit/test_azure_devops.py diff --git a/frontend/__tests__/components/features/home/repo-connector.test.tsx b/frontend/__tests__/components/features/home/repo-connector.test.tsx index 8e186257a0..0500d441a2 100644 --- a/frontend/__tests__/components/features/home/repo-connector.test.tsx +++ b/frontend/__tests__/components/features/home/repo-connector.test.tsx @@ -71,6 +71,7 @@ beforeEach(() => { provider_tokens_set: { github: "some-token", gitlab: null, + azure_devops: null, }, }); }); diff --git a/frontend/__tests__/components/features/home/task-card.test.tsx b/frontend/__tests__/components/features/home/task-card.test.tsx index 6d8fb0ee63..48746270df 100644 --- a/frontend/__tests__/components/features/home/task-card.test.tsx +++ b/frontend/__tests__/components/features/home/task-card.test.tsx @@ -23,6 +23,7 @@ const MOCK_RESPOSITORIES: GitRepository[] = [ { 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: "5", full_name: "repo5", git_provider: "azure_devops", is_public: true }, ]; const renderTaskCard = (task = MOCK_TASK_1) => { diff --git a/frontend/__tests__/routes/git-settings.test.tsx b/frontend/__tests__/routes/git-settings.test.tsx index 8b35abad3f..0c3f77bed0 100644 --- a/frontend/__tests__/routes/git-settings.test.tsx +++ b/frontend/__tests__/routes/git-settings.test.tsx @@ -124,6 +124,9 @@ describe("Content", () => { await screen.findByTestId("bitbucket-token-input"); await screen.findByTestId("bitbucket-token-help-anchor"); + await screen.findByTestId("azure-devops-token-input"); + await screen.findByTestId("azure-devops-token-help-anchor"); + getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG); queryClient.invalidateQueries(); rerender(); @@ -149,6 +152,13 @@ describe("Content", () => { expect( screen.queryByTestId("bitbucket-token-help-anchor"), ).not.toBeInTheDocument(); + + expect( + screen.queryByTestId("azure-devops-token-input"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("azure-devops-token-help-anchor"), + ).not.toBeInTheDocument(); }); }); @@ -287,6 +297,7 @@ describe("Form submission", () => { github: { token: "test-token", host: "" }, gitlab: { token: "", host: "" }, bitbucket: { token: "", host: "" }, + azure_devops: { token: "", host: "" }, }); }); @@ -308,6 +319,7 @@ describe("Form submission", () => { github: { token: "", host: "" }, gitlab: { token: "test-token", host: "" }, bitbucket: { token: "", host: "" }, + azure_devops: { token: "", host: "" }, }); }); @@ -329,6 +341,29 @@ describe("Form submission", () => { github: { token: "", host: "" }, gitlab: { token: "", host: "" }, bitbucket: { token: "test-token", host: "" }, + azure_devops: { token: "", host: "" }, + }); + }); + + it("should save the Azure DevOps token", async () => { + const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider"); + saveProvidersSpy.mockImplementation(() => Promise.resolve(true)); + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG); + + renderGitSettingsScreen(); + + const azureDevOpsInput = await screen.findByTestId("azure-devops-token-input"); + const submit = await screen.findByTestId("submit-button"); + + await userEvent.type(azureDevOpsInput, "test-token"); + await userEvent.click(submit); + + expect(saveProvidersSpy).toHaveBeenCalledWith({ + github: { token: "", host: "" }, + gitlab: { token: "", host: "" }, + bitbucket: { token: "", host: "" }, + azure_devops: { token: "test-token", host: "" }, }); }); diff --git a/frontend/__tests__/utils/convert-raw-providers-to-list.test.ts b/frontend/__tests__/utils/convert-raw-providers-to-list.test.ts index d2a756cbe7..d83280bf1c 100644 --- a/frontend/__tests__/utils/convert-raw-providers-to-list.test.ts +++ b/frontend/__tests__/utils/convert-raw-providers-to-list.test.ts @@ -7,6 +7,7 @@ describe("convertRawProvidersToList", () => { const example1: Partial> | undefined = { github: "test-token", gitlab: "test-token", + azure_devops: "test-token", }; const example2: Partial> | undefined = { github: "", @@ -14,9 +15,13 @@ describe("convertRawProvidersToList", () => { const example3: Partial> | undefined = { gitlab: null, }; + const example4: Partial> | undefined = { + azure_devops: "test-token", + }; - expect(convertRawProvidersToList(example1)).toEqual(["github", "gitlab"]); + expect(convertRawProvidersToList(example1)).toEqual(["github", "gitlab", "azure_devops"]); expect(convertRawProvidersToList(example2)).toEqual(["github"]); expect(convertRawProvidersToList(example3)).toEqual(["gitlab"]); + expect(convertRawProvidersToList(example4)).toEqual(["azure_devops"]); }); }); diff --git a/frontend/src/assets/branding/azure-devops-logo.svg b/frontend/src/assets/branding/azure-devops-logo.svg new file mode 100644 index 0000000000..01ff9f8a90 --- /dev/null +++ b/frontend/src/assets/branding/azure-devops-logo.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-repo-link.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-repo-link.tsx index 2cc937011d..27c50bbcb4 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card/conversation-repo-link.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-repo-link.tsx @@ -3,12 +3,13 @@ import { FaCodeBranch } from "react-icons/fa"; import { IconType } from "react-icons/lib"; import { RepositorySelection } from "#/api/open-hands.types"; import { Provider } from "#/types/settings"; +import AzureDevOpsLogo from "#/assets/branding/azure-devops-logo.svg?react"; interface ConversationRepoLinkProps { selectedRepository: RepositorySelection; } -const providerIcon: Record = { +const providerIcon: Partial> = { bitbucket: FaBitbucket, github: FaGithub, gitlab: FaGitlab, @@ -26,6 +27,9 @@ export function ConversationRepoLink({
{Icon && } + {selectedRepository.git_provider === "azure_devops" && ( + + )} + + {t(I18nKey.GIT$AZURE_DEVOPS_TOKEN_HELP)} + +

+ ); +} diff --git a/frontend/src/components/features/settings/git-settings/azure-devops-token-input.tsx b/frontend/src/components/features/settings/git-settings/azure-devops-token-input.tsx new file mode 100644 index 0000000000..5f8d34094a --- /dev/null +++ b/frontend/src/components/features/settings/git-settings/azure-devops-token-input.tsx @@ -0,0 +1,64 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { SettingsInput } from "../settings-input"; +import { AzureDevOpsTokenHelpAnchor } from "./azure-devops-token-help-anchor"; +import { KeyStatusIcon } from "../key-status-icon"; + +interface AzureDevOpsTokenInputProps { + onChange: (value: string) => void; + onAzureDevOpsHostChange: (value: string) => void; + isAzureDevOpsTokenSet: boolean; + name: string; + azureDevOpsHostSet: string | null | undefined; +} + +export function AzureDevOpsTokenInput({ + onChange, + onAzureDevOpsHostChange, + isAzureDevOpsTokenSet, + name, + azureDevOpsHostSet, +}: AzureDevOpsTokenInputProps) { + const { t } = useTranslation(); + + return ( +
+ " : ""} + startContent={ + isAzureDevOpsTokenSet && ( + + ) + } + /> + + {})} + name="azure-devops-host-input" + testId="azure-devops-host-input" + label={t(I18nKey.GIT$AZURE_DEVOPS_HOST)} + type="text" + className="w-full max-w-[680px]" + placeholder={t(I18nKey.GIT$AZURE_DEVOPS_HOST_PLACEHOLDER)} + defaultValue={azureDevOpsHostSet || undefined} + startContent={ + azureDevOpsHostSet && + azureDevOpsHostSet.trim() !== "" && ( + + ) + } + /> + + +
+ ); +} diff --git a/frontend/src/components/features/settings/git-settings/configure-azure-devops-anchor.tsx b/frontend/src/components/features/settings/git-settings/configure-azure-devops-anchor.tsx new file mode 100644 index 0000000000..c2afd7751c --- /dev/null +++ b/frontend/src/components/features/settings/git-settings/configure-azure-devops-anchor.tsx @@ -0,0 +1,37 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { useConfig } from "#/hooks/query/use-config"; +import { useAuthUrl } from "#/hooks/use-auth-url"; +import { BrandButton } from "../brand-button"; + +export function ConfigureAzureDevOpsAnchor() { + const { t } = useTranslation(); + const { data: config } = useConfig(); + + const authUrl = useAuthUrl({ + appMode: config?.APP_MODE ?? null, + identityProvider: "azure_devops", + authUrl: config?.AUTH_URL, + }); + + const handleOAuthFlow = () => { + if (!authUrl) { + return; + } + + window.location.href = authUrl; + }; + + return ( +
+ + {t(I18nKey.AZURE_DEVOPS$CONNECT_ACCOUNT)} + +
+ ); +} diff --git a/frontend/src/components/features/waitlist/auth-modal.tsx b/frontend/src/components/features/waitlist/auth-modal.tsx index d20ef04a28..2c431fbd95 100644 --- a/frontend/src/components/features/waitlist/auth-modal.tsx +++ b/frontend/src/components/features/waitlist/auth-modal.tsx @@ -8,6 +8,7 @@ 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 AzureDevOpsLogo from "#/assets/branding/azure-devops-logo.svg?react"; import { useAuthUrl } from "#/hooks/use-auth-url"; import { GetConfigResponse } from "#/api/option-service/option.types"; import { Provider } from "#/types/settings"; @@ -41,6 +42,12 @@ export function AuthModal({ authUrl, }); + const azureDevOpsAuthUrl = useAuthUrl({ + appMode: appMode || null, + identityProvider: "azure_devops", + authUrl, + }); + const enterpriseSsoUrl = useAuthUrl({ appMode: appMode || null, identityProvider: "enterprise_sso", @@ -71,6 +78,13 @@ export function AuthModal({ } }; + const handleAzureDevOpsAuth = () => { + if (azureDevOpsAuthUrl) { + // Always start the OIDC flow, let the backend handle TOS check + window.location.href = azureDevOpsAuthUrl; + } + }; + const handleEnterpriseSsoAuth = () => { if (enterpriseSsoUrl) { trackLoginButtonClick({ provider: "enterprise_sso" }); @@ -92,6 +106,10 @@ export function AuthModal({ providersConfigured && providersConfigured.length > 0 && providersConfigured.includes("bitbucket"); + const showAzureDevOps = + providersConfigured && + providersConfigured.length > 0 && + providersConfigured.includes("azure_devops"); const showEnterpriseSso = providersConfigured && providersConfigured.length > 0 && @@ -154,6 +172,18 @@ export function AuthModal({ )} + {showAzureDevOps && ( + } + > + {t(I18nKey.AZURE_DEVOPS$CONNECT_ACCOUNT)} + + )} + {showEnterpriseSso && ( {gitProvider === "github" && } - {gitProvider === "gitlab" && } - {gitProvider === "bitbucket" && } + {gitProvider === "gitlab" && } + {gitProvider === "bitbucket" && ( + + )} + {gitProvider === "azure_devops" && ( + + )} ); } diff --git a/frontend/src/context/conversation-subscriptions-provider.tsx b/frontend/src/context/conversation-subscriptions-provider.tsx index 74217cf513..c83c0d703e 100644 --- a/frontend/src/context/conversation-subscriptions-provider.tsx +++ b/frontend/src/context/conversation-subscriptions-provider.tsx @@ -31,7 +31,13 @@ interface ConversationSubscriptionsContextType { subscribeToConversation: (options: { conversationId: string; sessionApiKey: string | null; - providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[]; + providersSet: ( + | "github" + | "gitlab" + | "bitbucket" + | "azure_devops" + | "enterprise_sso" + )[]; baseUrl: string; socketPath?: string; onEvent?: (event: unknown, conversationId: string) => void; @@ -135,7 +141,13 @@ export function ConversationSubscriptionsProvider({ (options: { conversationId: string; sessionApiKey: string | null; - providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[]; + providersSet: ( + | "github" + | "gitlab" + | "bitbucket" + | "azure_devops" + | "enterprise_sso" + )[]; baseUrl: string; socketPath?: string; onEvent?: (event: unknown, conversationId: string) => void; diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index d6899bb0ef..b62dd9c1d9 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -1,6 +1,11 @@ // this file generate by script, don't modify it manually!!! export enum I18nKey { MAINTENANCE$SCHEDULED_MESSAGE = "MAINTENANCE$SCHEDULED_MESSAGE", + AZURE_DEVOPS$CONNECT_ACCOUNT = "AZURE_DEVOPS$CONNECT_ACCOUNT", + GIT$AZURE_DEVOPS_TOKEN = "GIT$AZURE_DEVOPS_TOKEN", + GIT$AZURE_DEVOPS_HOST = "GIT$AZURE_DEVOPS_HOST", + GIT$AZURE_DEVOPS_HOST_PLACEHOLDER = "GIT$AZURE_DEVOPS_HOST_PLACEHOLDER", + GIT$AZURE_DEVOPS_TOKEN_HELP = "GIT$AZURE_DEVOPS_TOKEN_HELP", MICROAGENT$NO_REPOSITORY_FOUND = "MICROAGENT$NO_REPOSITORY_FOUND", MICROAGENT$ADD_TO_MICROAGENT = "MICROAGENT$ADD_TO_MICROAGENT", MICROAGENT$WHAT_TO_ADD = "MICROAGENT$WHAT_TO_ADD", @@ -117,6 +122,7 @@ export enum I18nKey { SETTINGS$NAV_SECRETS = "SETTINGS$NAV_SECRETS", SETTINGS$NAV_API_KEYS = "SETTINGS$NAV_API_KEYS", SETTINGS$GITHUB = "SETTINGS$GITHUB", + SETTINGS$AZURE_DEVOPS = "SETTINGS$AZURE_DEVOPS", SETTINGS$SLACK = "SETTINGS$SLACK", SETTINGS$NAV_LLM = "SETTINGS$NAV_LLM", GIT$MERGE_REQUEST = "GIT$MERGE_REQUEST", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 99952d7e74..c43bb8dc07 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -15,6 +15,86 @@ "de": "Die geplante Wartung beginnt um {{time}}", "uk": "Планове технічне обслуговування розпочнеться о {{time}}" }, + "AZURE_DEVOPS$CONNECT_ACCOUNT": { + "en": "Connect Azure DevOps Account", + "ja": "Azure DevOps アカウントを接続", + "zh-CN": "连接 Azure DevOps 账户", + "zh-TW": "連接 Azure DevOps 帳戶", + "ko-KR": "Azure DevOps 계정 연결", + "no": "Koble til Azure DevOps-konto", + "it": "Connetti account Azure DevOps", + "pt": "Conectar conta do Azure DevOps", + "es": "Conectar cuenta de Azure DevOps", + "ar": "ربط حساب Azure DevOps", + "fr": "Connecter le compte Azure DevOps", + "tr": "Azure DevOps hesabını bağla", + "de": "Azure DevOps-Konto verbinden", + "uk": "Підключити обліковий запис Azure DevOps" + }, + "GIT$AZURE_DEVOPS_TOKEN": { + "en": "Azure DevOps Personal Access Token", + "ja": "Azure DevOps 個人用アクセス トークン", + "zh-CN": "Azure DevOps 个人访问令牌", + "zh-TW": "Azure DevOps 個人存取權杖", + "ko-KR": "Azure DevOps 개인 액세스 토큰", + "no": "Azure DevOps personlig tilgangstoken", + "it": "Token di accesso personale Azure DevOps", + "pt": "Token de acesso pessoal do Azure DevOps", + "es": "Token de acceso personal de Azure DevOps", + "ar": "رمز الوصول الشخصي لـ Azure DevOps", + "fr": "Jeton d'accès personnel Azure DevOps", + "tr": "Azure DevOps kişisel erişim belirteci", + "de": "Azure DevOps persönliches Zugriffstoken", + "uk": "Персональний токен доступу Azure DevOps" + }, + "GIT$AZURE_DEVOPS_HOST": { + "en": "Azure DevOps Organization", + "ja": "Azure DevOps 組織", + "zh-CN": "Azure DevOps 组织", + "zh-TW": "Azure DevOps 組織", + "ko-KR": "Azure DevOps 조직", + "no": "Azure DevOps organisasjon", + "it": "Organizzazione Azure DevOps", + "pt": "Organização do Azure DevOps", + "es": "Organización de Azure DevOps", + "ar": "مؤسسة Azure DevOps", + "fr": "Organisation Azure DevOps", + "tr": "Azure DevOps kuruluş", + "de": "Azure DevOps Organisation", + "uk": "Організація Azure DevOps" + }, + "GIT$AZURE_DEVOPS_HOST_PLACEHOLDER": { + "en": "organization", + "ja": "組織", + "zh-CN": "组织", + "zh-TW": "組織", + "ko-KR": "조직", + "no": "organisasjon", + "it": "organizzazione", + "pt": "organização", + "es": "organización", + "ar": "مؤسسة", + "fr": "organisation", + "tr": "kuruluş/proje", + "de": "organisation/projekt", + "uk": "організація/проект" + }, + "GIT$AZURE_DEVOPS_TOKEN_HELP": { + "en": "How to create an Azure DevOps token", + "ja": "Azure DevOps トークンの作成方法", + "zh-CN": "如何创建 Azure DevOps 令牌", + "zh-TW": "如何創建 Azure DevOps 權杖", + "ko-KR": "Azure DevOps 토큰 생성 방법", + "no": "Hvordan lage et Azure DevOps-token", + "it": "Come creare un token Azure DevOps", + "pt": "Como criar um token do Azure DevOps", + "es": "Cómo crear un token de Azure DevOps", + "ar": "كيفية إنشاء رمز Azure DevOps", + "fr": "Comment créer un jeton Azure DevOps", + "tr": "Azure DevOps belirteci nasıl oluşturulur", + "de": "Wie man ein Azure DevOps-Token erstellt", + "uk": "Як створити токен Azure DevOps" + }, "MICROAGENT$NO_REPOSITORY_FOUND": { "en": "No repository found to launch microagent", "ja": "マイクロエージェントを起動するためのリポジトリが見つかりません", @@ -1232,20 +1312,20 @@ "uk": "Невірний JSON" }, "HOME$CONNECT_PROVIDER_MESSAGE": { - "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." + "en": "To get started with suggested tasks, please connect your GitHub, GitLab, Bitbucket, or Azure DevOps account.", + "ja": "提案されたタスクを始めるには、GitHub、GitLab、Bitbucket、またはAzure DevOpsアカウントを接続してください。", + "zh-CN": "要开始使用建议的任务,请连接您的GitHub、GitLab、Bitbucket或Azure DevOps账户。", + "zh-TW": "要開始使用建議的任務,請連接您的GitHub、GitLab、Bitbucket或Azure DevOps帳戶。", + "ko-KR": "제안된 작업을 시작하려면 GitHub, GitLab, Bitbucket 또는 Azure DevOps 계정을 연결하세요.", + "no": "For å komme i gang med foreslåtte oppgaver, vennligst koble til GitHub, GitLab, Bitbucket eller Azure DevOps-kontoen din.", + "it": "Per iniziare con le attività suggerite, collega il tuo account GitHub, GitLab, Bitbucket o Azure DevOps.", + "pt": "Para começar com tarefas sugeridas, conecte sua conta GitHub, GitLab, Bitbucket ou Azure DevOps.", + "es": "Para comenzar con las tareas sugeridas, conecte su cuenta de GitHub, GitLab, Bitbucket o Azure DevOps.", + "ar": "للبدء بالمهام المقترحة، يرجى ربط حساب GitHub أو GitLab أو Bitbucket أو Azure DevOps الخاص بك.", + "fr": "Pour commencer avec les tâches suggérées, veuillez connecter votre compte GitHub, GitLab, Bitbucket ou Azure DevOps.", + "tr": "Önerilen görevlerle başlamak için lütfen GitHub, GitLab, Bitbucket veya Azure DevOps hesabınızı bağlayın.", + "de": "Um mit vorgeschlagenen Aufgaben zu beginnen, verbinden Sie bitte Ihr GitHub-, GitLab-, Bitbucket- oder Azure DevOps-Konto.", + "uk": "Щоб розпочати роботу з запропонованими завданнями, підключіть свій обліковий запис GitHub, GitLab, Bitbucket або Azure DevOps." }, "HOME$LETS_START_BUILDING": { "en": "Let's Start Building!", @@ -1871,6 +1951,22 @@ "de": "GitHub", "uk": "GitHub" }, + "SETTINGS$AZURE_DEVOPS": { + "en": "Azure DevOps", + "ja": "Azure DevOps", + "zh-CN": "Azure DevOps", + "zh-TW": "Azure DevOps", + "ko-KR": "Azure DevOps", + "no": "Azure DevOps", + "it": "Azure DevOps", + "pt": "Azure DevOps", + "es": "Azure DevOps", + "ar": "Azure DevOps", + "fr": "Azure DevOps", + "tr": "Azure DevOps", + "de": "Azure DevOps", + "uk": "Azure DevOps" + }, "SETTINGS$SLACK": { "en": "Slack", "ja": "Slack", diff --git a/frontend/src/routes/git-settings.tsx b/frontend/src/routes/git-settings.tsx index 5f898c36a2..89a25b9828 100644 --- a/frontend/src/routes/git-settings.tsx +++ b/frontend/src/routes/git-settings.tsx @@ -7,7 +7,9 @@ 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 { AzureDevOpsTokenInput } from "#/components/features/settings/git-settings/azure-devops-token-input"; import { ConfigureGitHubRepositoriesAnchor } from "#/components/features/settings/git-settings/configure-github-repositories-anchor"; +import { ConfigureAzureDevOpsAnchor } from "#/components/features/settings/git-settings/configure-azure-devops-anchor"; import { InstallSlackAppAnchor } from "#/components/features/settings/git-settings/install-slack-app-anchor"; import { I18nKey } from "#/i18n/declaration"; import { @@ -37,6 +39,8 @@ function GitSettingsScreen() { React.useState(false); const [bitbucketTokenInputHasValue, setBitbucketTokenInputHasValue] = React.useState(false); + const [azureDevOpsTokenInputHasValue, setAzureDevOpsTokenInputHasValue] = + React.useState(false); const [githubHostInputHasValue, setGithubHostInputHasValue] = React.useState(false); @@ -44,15 +48,19 @@ function GitSettingsScreen() { React.useState(false); const [bitbucketHostInputHasValue, setBitbucketHostInputHasValue] = React.useState(false); + const [azureDevOpsHostInputHasValue, setAzureDevOpsHostInputHasValue] = + 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 existingAzureDevOpsHost = settings?.PROVIDER_TOKENS_SET.azure_devops; const isSaas = config?.APP_MODE === "saas"; const isGitHubTokenSet = providers.includes("github"); const isGitLabTokenSet = providers.includes("gitlab"); const isBitbucketTokenSet = providers.includes("bitbucket"); + const isAzureDevOpsTokenSet = providers.includes("azure_devops"); const formAction = async (formData: FormData) => { const disconnectButtonClicked = @@ -67,16 +75,21 @@ function GitSettingsScreen() { const gitlabToken = formData.get("gitlab-token-input")?.toString() || ""; const bitbucketToken = formData.get("bitbucket-token-input")?.toString() || ""; + const azureDevOpsToken = + formData.get("azure-devops-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() || ""; + const azureDevOpsHost = + formData.get("azure-devops-host-input")?.toString() || ""; // Create providers object with all tokens const providerTokens: Record = { github: { token: githubToken, host: githubHost }, gitlab: { token: gitlabToken, host: gitlabHost }, bitbucket: { token: bitbucketToken, host: bitbucketHost }, + azure_devops: { token: azureDevOpsToken, host: azureDevOpsHost }, }; saveGitProviders( @@ -95,9 +108,11 @@ function GitSettingsScreen() { setGithubTokenInputHasValue(false); setGitlabTokenInputHasValue(false); setBitbucketTokenInputHasValue(false); + setAzureDevOpsTokenInputHasValue(false); setGithubHostInputHasValue(false); setGitlabHostInputHasValue(false); setBitbucketHostInputHasValue(false); + setAzureDevOpsHostInputHasValue(false); }, }, ); @@ -107,9 +122,11 @@ function GitSettingsScreen() { !githubTokenInputHasValue && !gitlabTokenInputHasValue && !bitbucketTokenInputHasValue && + !azureDevOpsTokenInputHasValue && !githubHostInputHasValue && !gitlabHostInputHasValue && - !bitbucketHostInputHasValue; + !bitbucketHostInputHasValue && + !azureDevOpsHostInputHasValue; const shouldRenderExternalConfigureButtons = isSaas && config.APP_SLUG; const shouldRenderProjectManagementIntegrations = config?.FEATURE_FLAGS?.ENABLE_JIRA || @@ -136,6 +153,18 @@ function GitSettingsScreen() { )} + {shouldRenderExternalConfigureButtons && !isLoading && ( + <> +
+

+ {t(I18nKey.SETTINGS$AZURE_DEVOPS)} +

+ +
+
+ + )} + {shouldRenderExternalConfigureButtons && !isLoading && ( <>
@@ -196,6 +225,20 @@ function GitSettingsScreen() { bitbucketHostSet={existingBitbucketHost} /> )} + + {!isSaas && ( + { + setAzureDevOpsTokenInputHasValue(!!value); + }} + onAzureDevOpsHostChange={(value) => { + setAzureDevOpsHostInputHasValue(!!value); + }} + azureDevOpsHostSet={existingAzureDevOpsHost} + /> + )}
)} @@ -211,7 +254,10 @@ function GitSettingsScreen() { type="submit" variant="secondary" isDisabled={ - !isGitHubTokenSet && !isGitLabTokenSet && !isBitbucketTokenSet + !isGitHubTokenSet && + !isGitLabTokenSet && + !isBitbucketTokenSet && + !isAzureDevOpsTokenSet } > {t(I18nKey.GIT$DISCONNECT_TOKENS)} diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts index 1d9a542705..f76fcaa19a 100644 --- a/frontend/src/types/settings.ts +++ b/frontend/src/types/settings.ts @@ -2,6 +2,7 @@ export const ProviderOptions = { github: "github", gitlab: "gitlab", bitbucket: "bitbucket", + azure_devops: "azure_devops", enterprise_sso: "enterprise_sso", } as const; diff --git a/frontend/src/utils/generate-auth-url.ts b/frontend/src/utils/generate-auth-url.ts index 0d7990ad81..0fdf71d5b1 100644 --- a/frontend/src/utils/generate-auth-url.ts +++ b/frontend/src/utils/generate-auth-url.ts @@ -1,6 +1,6 @@ /** * Generates a URL to redirect to for OAuth authentication - * @param identityProvider The identity provider to use (e.g., "github", "gitlab", "bitbucket") + * @param identityProvider The identity provider to use (e.g., "github", "gitlab", "bitbucket", "azure_devops") * @param requestUrl The URL of the request * @returns The URL to redirect to for OAuth */ diff --git a/frontend/src/utils/local-storage.ts b/frontend/src/utils/local-storage.ts index ffdf14a164..45a8f924a7 100644 --- a/frontend/src/utils/local-storage.ts +++ b/frontend/src/utils/local-storage.ts @@ -8,12 +8,13 @@ export enum LoginMethod { GITHUB = "github", GITLAB = "gitlab", BITBUCKET = "bitbucket", + AZURE_DEVOPS = "azure_devops", ENTERPRISE_SSO = "enterprise_sso", } /** * Set the login method in local storage - * @param method The login method (github, gitlab, or bitbucket) + * @param method The login method (github, gitlab, bitbucket, or azure_devops) */ export const setLoginMethod = (method: LoginMethod): void => { localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, method); diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index bd915b9f6c..620fb2c444 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -182,6 +182,8 @@ export const shouldUseInstallationRepos = ( return true; case "gitlab": return false; + case "azure_devops": + return false; case "github": return app_mode === "saas"; default: @@ -197,6 +199,8 @@ export const getGitProviderBaseUrl = (gitProvider: Provider): string => { return "https://gitlab.com"; case "bitbucket": return "https://bitbucket.org"; + case "azure_devops": + return "https://dev.azure.com"; default: return ""; } @@ -210,6 +214,7 @@ export const getGitProviderBaseUrl = (gitProvider: Provider): string => { export const getProviderName = (gitProvider: Provider) => { if (gitProvider === "gitlab") return "GitLab"; if (gitProvider === "bitbucket") return "Bitbucket"; + if (gitProvider === "azure_devops") return "Azure DevOps"; return "GitHub"; }; @@ -254,6 +259,15 @@ export const constructPullRequestUrl = ( return `${baseUrl}/${repositoryName}/-/merge_requests/${prNumber}`; case "bitbucket": return `${baseUrl}/${repositoryName}/pull-requests/${prNumber}`; + case "azure_devops": { + // Azure DevOps format: org/project/repo + const parts = repositoryName.split("/"); + if (parts.length === 3) { + const [org, project, repo] = parts; + return `${baseUrl}/${org}/${project}/_git/${repo}/pullrequest/${prNumber}`; + } + return ""; + } default: return ""; } @@ -288,6 +302,15 @@ export const constructMicroagentUrl = ( return `${baseUrl}/${repositoryName}/-/blob/main/${microagentPath}`; case "bitbucket": return `${baseUrl}/${repositoryName}/src/main/${microagentPath}`; + case "azure_devops": { + // Azure DevOps format: org/project/repo + const parts = repositoryName.split("/"); + if (parts.length === 3) { + const [org, project, repo] = parts; + return `${baseUrl}/${org}/${project}/_git/${repo}?path=/${microagentPath}&version=GBmain`; + } + return ""; + } default: return ""; } @@ -357,6 +380,15 @@ export const constructBranchUrl = ( return `${baseUrl}/${repositoryName}/-/tree/${branchName}`; case "bitbucket": return `${baseUrl}/${repositoryName}/src/${branchName}`; + case "azure_devops": { + // Azure DevOps format: org/project/repo + const parts = repositoryName.split("/"); + if (parts.length === 3) { + const [org, project, repo] = parts; + return `${baseUrl}/${org}/${project}/_git/${repo}?version=GB${branchName}`; + } + return ""; + } default: return ""; } diff --git a/openhands/agenthub/codeact_agent/prompts/system_prompt.j2 b/openhands/agenthub/codeact_agent/prompts/system_prompt.j2 index 71478749fa..9be08842d4 100644 --- a/openhands/agenthub/codeact_agent/prompts/system_prompt.j2 +++ b/openhands/agenthub/codeact_agent/prompts/system_prompt.j2 @@ -72,7 +72,7 @@ Your primary role is to assist users by executing commands, modifying code, and -* When interacting with external services like GitHub, GitLab, or Bitbucket, use their respective APIs instead of browser-based interactions whenever possible. +* When interacting with external services like GitHub, GitLab, Bitbucket, or Azure DevOps, use their respective APIs instead of browser-based interactions whenever possible. * Only resort to browser-based interactions with these services if specifically requested by the user or if the required operation cannot be performed via API. diff --git a/openhands/app_server/utils/import_utils.py b/openhands/app_server/utils/import_utils.py index 930db99e7f..325416d309 100644 --- a/openhands/app_server/utils/import_utils.py +++ b/openhands/app_server/utils/import_utils.py @@ -71,7 +71,7 @@ def get_impl(cls: type[T], impl_name: str | None) -> type[T]: Common Use Cases: - Server components (ConversationService, UserAuth, etc.) - Storage implementations (ConversationStore, SettingsStore, etc.) - - Service integrations (GitHub, GitLab, Bitbucket services) + - Service integrations (GitHub, GitLab, Bitbucket, Azure DevOps services) The implementation is cached to avoid repeated imports of the same class. """ diff --git a/openhands/integrations/azure_devops/azure_devops_service.py b/openhands/integrations/azure_devops/azure_devops_service.py new file mode 100644 index 0000000000..8d719cbb54 --- /dev/null +++ b/openhands/integrations/azure_devops/azure_devops_service.py @@ -0,0 +1,249 @@ +import os +from typing import Any + +import httpx +from pydantic import SecretStr + +from openhands.integrations.azure_devops.service.branches import ( + AzureDevOpsBranchesMixin, +) +from openhands.integrations.azure_devops.service.features import ( + AzureDevOpsFeaturesMixin, +) +from openhands.integrations.azure_devops.service.prs import AzureDevOpsPRsMixin +from openhands.integrations.azure_devops.service.repos import AzureDevOpsReposMixin +from openhands.integrations.azure_devops.service.resolver import ( + AzureDevOpsResolverMixin, +) +from openhands.integrations.azure_devops.service.work_items import ( + AzureDevOpsWorkItemsMixin, +) +from openhands.integrations.protocols.http_client import HTTPClient +from openhands.integrations.service_types import ( + BaseGitService, + GitService, + ProviderType, + RequestMethod, +) +from openhands.utils.import_utils import get_impl + + +class AzureDevOpsServiceImpl( + AzureDevOpsResolverMixin, + AzureDevOpsReposMixin, + AzureDevOpsBranchesMixin, + AzureDevOpsPRsMixin, + AzureDevOpsWorkItemsMixin, + AzureDevOpsFeaturesMixin, + BaseGitService, + HTTPClient, + GitService, +): + """Azure DevOps service implementation using modular mixins. + + This class inherits functionality from specialized mixins: + - AzureDevOpsResolverMixin: PR/work item comment resolution + - AzureDevOpsReposMixin: Repository operations + - AzureDevOpsBranchesMixin: Branch operations + - AzureDevOpsPRsMixin: Pull request operations + - AzureDevOpsWorkItemsMixin: Work item operations (unique to Azure DevOps) + - AzureDevOpsFeaturesMixin: Microagents, suggested tasks, user info + + This is an extension point in OpenHands that allows applications to customize Azure DevOps + integration behavior. Applications can substitute their own implementation by: + 1. Creating a class that inherits from GitService + 2. Implementing all required methods + 3. Setting OPENHANDS_AZURE_DEVOPS_SERVICE_CLS environment variable + + The class is instantiated via get_impl() at module load time. + """ + + token: SecretStr = SecretStr('') + refresh = False + organization: str = '' + + 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 + + if token: + self.token = token + + if base_domain: + # Parse organization from base_domain + # Strip URL prefix if present (e.g., "https://dev.azure.com/org" -> "org") + domain_path = base_domain + if '://' in domain_path: + # Remove protocol and domain, keep only path + domain_path = domain_path.split('://', 1)[1] + if '/' in domain_path: + domain_path = domain_path.split('/', 1)[1] + + # Format expected: organization (e.g., "contoso") + # Take first part only (in case user still enters org/project) + parts = domain_path.split('/') + if len(parts) >= 1: + self.organization = parts[0] + + async def get_installations(self) -> list[str]: + """Get Azure DevOps organizations. + + For Azure DevOps, 'installations' are equivalent to organizations. + Since authentication is per-organization, return the current organization. + """ + return [self.organization] + + @property + def provider(self) -> str: + return ProviderType.AZURE_DEVOPS.value + + @property + def base_url(self) -> str: + """Get the base URL for Azure DevOps API calls.""" + return f'https://dev.azure.com/{self.organization}' + + @staticmethod + def _is_oauth_token(token: str) -> bool: + """Check if a token is an OAuth JWT token (from SSO) vs a PAT. + + OAuth tokens from Azure AD/Entra ID are JWTs with the format: + header.payload.signature (three base64url-encoded parts separated by dots) + + PATs are opaque tokens without this structure. + + Args: + token: The token string to check + + Returns: + True if the token appears to be a JWT (OAuth), False if it's a PAT + """ + # JWTs have exactly 3 parts separated by dots + parts = token.split('.') + return len(parts) == 3 and all(len(part) > 0 for part in parts) + + async def _get_azure_devops_headers(self) -> dict[str, Any]: + """Retrieve the Azure DevOps authentication headers. + + Supports two authentication methods: + 1. OAuth 2.0 (Bearer token) - Used for SSO/SaaS mode with Keycloak/Azure AD + 2. Personal Access Token (Basic auth) - Used for self-hosted mode + + The method automatically detects the token type: + - OAuth tokens are JWTs (header.payload.signature format) -> uses Bearer auth + - PATs are opaque strings -> uses Basic auth + + Returns: + dict: HTTP headers with appropriate Authorization header + """ + if not self.token: + latest_token = await self.get_latest_token() + if latest_token: + self.token = latest_token + + token_value = self.token.get_secret_value() + + # Detect token type and use appropriate authentication method + if self._is_oauth_token(token_value): + # OAuth 2.0 access token from SSO (Azure AD/Keycloak broker) + # Use Bearer authentication as per OAuth 2.0 spec + auth_header = f'Bearer {token_value}' + else: + # Personal Access Token (PAT) for self-hosted deployments + # Use Basic authentication with empty username and PAT as password + import base64 + + auth_str = base64.b64encode(f':{token_value}'.encode()).decode() + auth_header = f'Basic {auth_str}' + + return { + 'Authorization': auth_header, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } + + async def _get_headers(self) -> dict[str, Any]: + """Retrieve the Azure DevOps headers.""" + return await self._get_azure_devops_headers() + + def _has_token_expired(self, status_code: int) -> bool: + return status_code == 401 + + async def get_latest_token(self) -> SecretStr | None: + return self.token + + async def _make_request( + self, + url: str, + params: dict | None = None, + method: RequestMethod = RequestMethod.GET, + ) -> tuple[Any, dict]: + try: + async with httpx.AsyncClient() as client: + azure_devops_headers = await self._get_azure_devops_headers() + + # Make initial request + response = await self.execute_request( + client=client, + url=url, + headers=azure_devops_headers, + params=params, + method=method, + ) + + # Handle token refresh if needed + if self.refresh and self._has_token_expired(response.status_code): + await self.get_latest_token() + azure_devops_headers = await self._get_azure_devops_headers() + response = await self.execute_request( + client=client, + url=url, + headers=azure_devops_headers, + params=params, + method=method, + ) + + response.raise_for_status() + headers = {} + if 'Link' in response.headers: + headers['Link'] = response.headers['Link'] + + return response.json(), headers + + except httpx.HTTPStatusError as e: + raise self.handle_http_status_error(e) + except httpx.HTTPError as e: + raise self.handle_http_error(e) + + def _parse_repository(self, repository: str) -> tuple[str, str, str]: + """Parse repository string into organization, project, and repo name. + + Args: + repository: Repository string in format organization/project/repo + + Returns: + Tuple of (organization, project, repo_name) + """ + parts = repository.split('/') + if len(parts) < 3: + raise ValueError( + f'Invalid repository format: {repository}. Expected format: organization/project/repo' + ) + return parts[0], parts[1], parts[2] + + +# Dynamic class loading to support custom implementations (e.g., SaaS) +azure_devops_service_cls = os.environ.get( + 'OPENHANDS_AZURE_DEVOPS_SERVICE_CLS', + 'openhands.integrations.azure_devops.azure_devops_service.AzureDevOpsServiceImpl', +) +AzureDevOpsServiceImpl = get_impl( # type: ignore[misc] + AzureDevOpsServiceImpl, azure_devops_service_cls +) diff --git a/openhands/integrations/azure_devops/service/__init__.py b/openhands/integrations/azure_devops/service/__init__.py new file mode 100644 index 0000000000..fe8e72aec9 --- /dev/null +++ b/openhands/integrations/azure_devops/service/__init__.py @@ -0,0 +1 @@ +# Azure DevOps Service mixins diff --git a/openhands/integrations/azure_devops/service/base.py b/openhands/integrations/azure_devops/service/base.py new file mode 100644 index 0000000000..438308b282 --- /dev/null +++ b/openhands/integrations/azure_devops/service/base.py @@ -0,0 +1,67 @@ +from abc import abstractmethod +from typing import Any +from urllib.parse import quote + +from pydantic import SecretStr + +from openhands.integrations.protocols.http_client import HTTPClient +from openhands.integrations.service_types import ( + BaseGitService, + RequestMethod, +) + + +class AzureDevOpsMixinBase(BaseGitService, HTTPClient): + """Declares common attributes and method signatures used across Azure DevOps mixins.""" + + organization: str + + @property + @abstractmethod + def base_url(self) -> str: + """Get the base URL for Azure DevOps API calls.""" + ... + + async def _get_headers(self) -> dict: + """Retrieve the Azure DevOps token from settings store to construct the headers.""" + if not self.token: + latest_token = await self.get_latest_token() + if latest_token: + self.token = latest_token + + return { + 'Authorization': f'Bearer {self.token.get_secret_value() if self.token else ""}', + 'Content-Type': 'application/json', + } + + async def get_latest_token(self) -> SecretStr | None: # type: ignore[override] + return self.token + + async def _make_request( + self, + url: str, + params: dict | None = None, + method: RequestMethod = RequestMethod.GET, + ) -> tuple[Any, dict]: # type: ignore[override] + """Make HTTP request to Azure DevOps API.""" + raise NotImplementedError('Implemented in AzureDevOpsServiceImpl') + + def _parse_repository(self, repository: str) -> tuple[str, str, str]: + """Parse repository string into organization, project, and repo name.""" + raise NotImplementedError('Implemented in AzureDevOpsServiceImpl') + + def _truncate_comment(self, comment: str, max_length: int = 1000) -> str: + """Truncate comment to max length.""" + raise NotImplementedError('Implemented in AzureDevOpsServiceImpl') + + @staticmethod + def _encode_url_component(component: str) -> str: + """URL-encode a component for use in Azure DevOps API URLs. + + Args: + component: The string component to encode (e.g., repo name, project name, org name) + + Returns: + URL-encoded string with spaces and special characters properly encoded + """ + return quote(component, safe='') diff --git a/openhands/integrations/azure_devops/service/branches.py b/openhands/integrations/azure_devops/service/branches.py new file mode 100644 index 0000000000..84fbe3f7f0 --- /dev/null +++ b/openhands/integrations/azure_devops/service/branches.py @@ -0,0 +1,195 @@ +"""Branch operations for Azure DevOps integration.""" + +from openhands.integrations.azure_devops.service.base import AzureDevOpsMixinBase +from openhands.integrations.service_types import Branch, PaginatedBranchesResponse + + +class AzureDevOpsBranchesMixin(AzureDevOpsMixinBase): + """Mixin for Azure DevOps branch operations.""" + + async def get_branches(self, repository: str) -> list[Branch]: + """Get branches for a repository.""" + # Parse repository string: organization/project/repo + parts = repository.split('/') + if len(parts) < 3: + raise ValueError( + f'Invalid repository format: {repository}. Expected format: organization/project/repo' + ) + + org = parts[0] + project = parts[1] + repo_name = parts[2] + + # URL-encode components to handle spaces and special characters + org_enc = self._encode_url_component(org) + project_enc = self._encode_url_component(project) + repo_enc = self._encode_url_component(repo_name) + + url = f'https://dev.azure.com/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/refs?api-version=7.1&filter=heads/' + + # Set maximum branches to fetch + MAX_BRANCHES = 1000 + + response, _ = await self._make_request(url) + branches_data = response.get('value', []) + + all_branches = [] + + for branch_data in branches_data: + # Extract branch name from the ref (e.g., "refs/heads/main" -> "main") + name = branch_data.get('name', '').replace('refs/heads/', '') + + # Get the commit details for this branch + object_id = branch_data.get('objectId', '') + commit_url = f'https://dev.azure.com/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/commits/{object_id}?api-version=7.1' + commit_data, _ = await self._make_request(commit_url) + + # Check if the branch is protected + name_enc = self._encode_url_component(name) + policy_url = f'https://dev.azure.com/{org_enc}/{project_enc}/_apis/git/policy/configurations?api-version=7.1&repositoryId={repo_enc}&refName=refs/heads/{name_enc}' + policy_data, _ = await self._make_request(policy_url) + is_protected = len(policy_data.get('value', [])) > 0 + + branch = Branch( + name=name, + commit_sha=object_id, + protected=is_protected, + last_push_date=commit_data.get('committer', {}).get('date'), + ) + all_branches.append(branch) + + if len(all_branches) >= MAX_BRANCHES: + break + + return all_branches + + async def get_paginated_branches( + self, repository: str, page: int = 1, per_page: int = 30 + ) -> PaginatedBranchesResponse: + """Get branches for a repository with pagination.""" + # Parse repository string: organization/project/repo + parts = repository.split('/') + if len(parts) < 3: + raise ValueError( + f'Invalid repository format: {repository}. Expected format: organization/project/repo' + ) + + org = parts[0] + project = parts[1] + repo_name = parts[2] + + # URL-encode components to handle spaces and special characters + org_enc = self._encode_url_component(org) + project_enc = self._encode_url_component(project) + repo_enc = self._encode_url_component(repo_name) + + # First, get the repository to get its ID + repo_url = f'https://dev.azure.com/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}?api-version=7.1' + repo_data, _ = await self._make_request(repo_url) + repo_id = repo_data.get( + 'id', repo_name + ) # Fall back to repo_name if ID not found + + url = f'https://dev.azure.com/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/refs?api-version=7.1&filter=heads/' + + response, _ = await self._make_request(url) + branches_data = response.get('value', []) + + # Calculate pagination + start_idx = (page - 1) * per_page + end_idx = start_idx + per_page + paginated_data = branches_data[start_idx:end_idx] + + branches: list[Branch] = [] + for branch_data in paginated_data: + # Extract branch name from the ref (e.g., "refs/heads/main" -> "main") + name = branch_data.get('name', '').replace('refs/heads/', '') + + # Get the commit details for this branch + object_id = branch_data.get('objectId', '') + commit_url = f'https://dev.azure.com/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/commits/{object_id}?api-version=7.1' + commit_data, _ = await self._make_request(commit_url) + + # Check if the branch is protected using repository ID + name_enc = self._encode_url_component(name) + policy_url = f'https://dev.azure.com/{org_enc}/{project_enc}/_apis/git/policy/configurations?api-version=7.1&repositoryId={repo_id}&refName=refs/heads/{name_enc}' + policy_data, _ = await self._make_request(policy_url) + is_protected = len(policy_data.get('value', [])) > 0 + + branch = Branch( + name=name, + commit_sha=object_id, + protected=is_protected, + last_push_date=commit_data.get('committer', {}).get('date'), + ) + branches.append(branch) + + # Determine if there's a next page + has_next_page = end_idx < len(branches_data) + + return PaginatedBranchesResponse( + branches=branches, + has_next_page=has_next_page, + current_page=page, + per_page=per_page, + ) + + async def search_branches( + self, repository: str, query: str, per_page: int = 30 + ) -> list[Branch]: + """Search for branches within a repository.""" + # Parse repository string: organization/project/repo + parts = repository.split('/') + if len(parts) < 3: + raise ValueError( + f'Invalid repository format: {repository}. Expected format: organization/project/repo' + ) + + org = parts[0] + project = parts[1] + repo_name = parts[2] + + # URL-encode components to handle spaces and special characters + org_enc = self._encode_url_component(org) + project_enc = self._encode_url_component(project) + repo_enc = self._encode_url_component(repo_name) + + url = f'https://dev.azure.com/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/refs?api-version=7.1&filter=heads/' + + try: + response, _ = await self._make_request(url) + branches_data = response.get('value', []) + + # Filter branches by query + filtered_branches = [] + for branch_data in branches_data: + # Extract branch name from the ref (e.g., "refs/heads/main" -> "main") + name = branch_data.get('name', '').replace('refs/heads/', '') + + # Check if query matches branch name + if query.lower() in name.lower(): + object_id = branch_data.get('objectId', '') + + # Get commit details for this branch + commit_url = f'https://dev.azure.com/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/commits/{object_id}?api-version=7.1' + try: + commit_data, _ = await self._make_request(commit_url) + last_push_date = commit_data.get('committer', {}).get('date') + except Exception: + last_push_date = None + + branch = Branch( + name=name, + commit_sha=object_id, + protected=False, # Skip protected check for search to improve performance + last_push_date=last_push_date, + ) + filtered_branches.append(branch) + + if len(filtered_branches) >= per_page: + break + + return filtered_branches + except Exception: + # Return empty list on error instead of None + return [] diff --git a/openhands/integrations/azure_devops/service/features.py b/openhands/integrations/azure_devops/service/features.py new file mode 100644 index 0000000000..9f74f21a3a --- /dev/null +++ b/openhands/integrations/azure_devops/service/features.py @@ -0,0 +1,223 @@ +"""Feature operations for Azure DevOps integration (microagents, suggested tasks, user).""" + +from openhands.core.logger import openhands_logger as logger +from openhands.integrations.azure_devops.service.base import AzureDevOpsMixinBase +from openhands.integrations.service_types import ( + MicroagentContentResponse, + ProviderType, + RequestMethod, + SuggestedTask, + TaskType, + User, +) + + +class AzureDevOpsFeaturesMixin(AzureDevOpsMixinBase): + """Mixin for Azure DevOps feature operations (microagents, suggested tasks, user info).""" + + async def get_user(self) -> User: + """Get the authenticated user's information.""" + url = f'{self.base_url}/_apis/connectionData?api-version=7.1-preview.1' + response, _ = await self._make_request(url) + + # Extract authenticated user details + authenticated_user = response.get('authenticatedUser', {}) + user_id = authenticated_user.get('id', '') + display_name = authenticated_user.get('providerDisplayName', '') + + # Get descriptor for potential additional details + authenticated_user.get('descriptor', '') + + return User( + id=str(user_id), + login=display_name, + avatar_url='', + name=display_name, + email='', + company=None, + ) + + async def get_suggested_tasks(self) -> list[SuggestedTask]: + """Get suggested tasks for the authenticated user across all repositories.""" + # Azure DevOps requires querying each project separately for PRs and work items + # Since we no longer specify a single project, we need to query all projects + # Get all projects first + projects_url = f'{self.base_url}/_apis/projects?api-version=7.1' + projects_response, _ = await self._make_request(projects_url) + projects = projects_response.get('value', []) + + # Get user info + user = await self.get_user() + tasks = [] + + # Query each project for pull requests and work items + for project in projects: + project_name = project.get('name') + + try: + # URL-encode project name to handle spaces and special characters + project_enc = self._encode_url_component(project_name) + + # Get pull requests created by the user in this project + url = f'{self.base_url}/{project_enc}/_apis/git/pullrequests?api-version=7.1&searchCriteria.creatorId={user.id}&searchCriteria.status=active' + response, _ = await self._make_request(url) + + pull_requests = response.get('value', []) + + for pr in pull_requests: + repo_name = pr.get('repository', {}).get('name', '') + pr_id = pr.get('pullRequestId') + title = pr.get('title', '') + + # Check for merge conflicts + if pr.get('mergeStatus') == 'conflicts': + tasks.append( + SuggestedTask( + git_provider=ProviderType.AZURE_DEVOPS, + task_type=TaskType.MERGE_CONFLICTS, + repo=f'{self.organization}/{project_name}/{repo_name}', + issue_number=pr_id, + title=title, + ) + ) + # Check for failing checks + elif pr.get('status') == 'failed': + tasks.append( + SuggestedTask( + git_provider=ProviderType.AZURE_DEVOPS, + task_type=TaskType.FAILING_CHECKS, + repo=f'{self.organization}/{project_name}/{repo_name}', + issue_number=pr_id, + title=title, + ) + ) + # Check for unresolved comments + elif pr.get('hasUnresolvedComments', False): + tasks.append( + SuggestedTask( + git_provider=ProviderType.AZURE_DEVOPS, + task_type=TaskType.UNRESOLVED_COMMENTS, + repo=f'{self.organization}/{project_name}/{repo_name}', + issue_number=pr_id, + title=title, + ) + ) + + # Get work items assigned to the user in this project + work_items_url = ( + f'{self.base_url}/{project_enc}/_apis/wit/wiql?api-version=7.1' + ) + wiql_query = { + 'query': "SELECT [System.Id], [System.Title], [System.State] FROM WorkItems WHERE [System.AssignedTo] = @me AND [System.State] = 'Active'" + } + + work_items_response, _ = await self._make_request( + url=work_items_url, params=wiql_query, method=RequestMethod.POST + ) + + work_item_references = work_items_response.get('workItems', []) + + # Get details for each work item + for work_item_ref in work_item_references: + work_item_id = work_item_ref.get('id') + work_item_url = f'{self.base_url}/{project_enc}/_apis/wit/workitems/{work_item_id}?api-version=7.1' + work_item, _ = await self._make_request(work_item_url) + + title = work_item.get('fields', {}).get('System.Title', '') + + tasks.append( + SuggestedTask( + git_provider=ProviderType.AZURE_DEVOPS, + task_type=TaskType.OPEN_ISSUE, + repo=f'{self.organization}/{project_name}', + issue_number=work_item_id, + title=title, + ) + ) + except Exception: + # Skip projects that fail (e.g., no access, no work items enabled) + continue + + return tasks + + async def _get_cursorrules_url(self, repository: str) -> str: + """Get the URL for checking .cursorrules file in Azure DevOps.""" + org, project, repo = self._parse_repository(repository) + # URL-encode components to handle spaces and special characters + org_enc = self._encode_url_component(org) + project_enc = self._encode_url_component(project) + repo_enc = self._encode_url_component(repo) + return f'{self.base_url}/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/items?path=/.cursorrules&api-version=7.1' + + async def _get_microagents_directory_url( + self, repository: str, microagents_path: str + ) -> str: + """Get the URL for checking microagents directory in Azure DevOps. + + Note: For org-level microagents (e.g., 'org/.openhands'), Azure DevOps doesn't support + this concept, so we raise ValueError to let the caller fall back to other providers. + """ + parts = repository.split('/') + if len(parts) < 3: + # Azure DevOps doesn't support org-level configs, only full repo paths + raise ValueError( + f'Invalid repository format: {repository}. Expected format: organization/project/repo' + ) + org, project, repo = parts[0], parts[1], parts[2] + # URL-encode components to handle spaces and special characters + org_enc = self._encode_url_component(org) + project_enc = self._encode_url_component(project) + repo_enc = self._encode_url_component(repo) + return f'{self.base_url}/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/items?path=/{microagents_path}&recursionLevel=OneLevel&api-version=7.1' + + def _get_microagents_directory_params(self, microagents_path: str) -> dict | None: + """Get parameters for the microagents directory request. Return None if no parameters needed.""" + return None + + def _is_valid_microagent_file(self, item: dict) -> bool: + """Check if an item represents a valid microagent file in Azure DevOps.""" + return ( + not item.get('isFolder', False) + and item.get('path', '').endswith('.md') + and not item.get('path', '').endswith('README.md') + ) + + def _get_file_name_from_item(self, item: dict) -> str: + """Extract file name from directory item in Azure DevOps.""" + path = item.get('path', '') + return path.split('/')[-1] if path else '' + + def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str: + """Extract file path from directory item in Azure DevOps.""" + return item.get('path', '').lstrip('/') + + async def get_microagent_content( + self, repository: str, file_path: str + ) -> MicroagentContentResponse: + """Get content of a specific microagent file. + + Args: + repository: Repository name in Azure DevOps format 'org/project/repo' + file_path: Path to the microagent file + + Returns: + MicroagentContentResponse with parsed content and triggers + """ + org, project, repo = self._parse_repository(repository) + # URL-encode components to handle spaces and special characters + org_enc = self._encode_url_component(org) + project_enc = self._encode_url_component(project) + repo_enc = self._encode_url_component(repo) + url = f'{self.base_url}/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/items?path={file_path}&api-version=7.1' + + try: + response, _ = await self._make_request(url) + content = ( + response if isinstance(response, str) else response.get('content', '') + ) + + # Parse the content using the base class method + return self._parse_microagent_content(content, file_path) + except Exception as e: + logger.warning(f'Failed to fetch microagent content from {file_path}: {e}') + raise diff --git a/openhands/integrations/azure_devops/service/prs.py b/openhands/integrations/azure_devops/service/prs.py new file mode 100644 index 0000000000..c4cfefe09c --- /dev/null +++ b/openhands/integrations/azure_devops/service/prs.py @@ -0,0 +1,321 @@ +"""Pull request operations for Azure DevOps integration.""" + +from datetime import datetime + +from openhands.core.logger import openhands_logger as logger +from openhands.integrations.azure_devops.service.base import AzureDevOpsMixinBase +from openhands.integrations.service_types import Comment, RequestMethod + + +class AzureDevOpsPRsMixin(AzureDevOpsMixinBase): + """Mixin for Azure DevOps pull request operations.""" + + def _truncate_comment(self, comment: str, max_length: int = 1000) -> str: + """Truncate comment to max length.""" + if len(comment) <= max_length: + return comment + return comment[:max_length] + '...' + + async def add_pr_thread( + self, + repository: str, + pr_number: int, + comment_text: str, + status: str = 'active', + ) -> dict: + """Create a new thread (comment) in an Azure DevOps pull request. + + Azure DevOps uses 'threads' concept where each thread contains comments. + This creates a new thread with a single comment for general PR discussion. + + API Reference: https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-request-threads/create + + Args: + repository: Repository name in format "organization/project/repo" + pr_number: The pull request number + comment_text: The comment text to post + status: Thread status ('active', 'fixed', 'wontFix', 'closed', 'byDesign', 'pending') + + Returns: + API response with created thread information + + Raises: + HTTPException: If the API request fails + """ + org, project, repo = self._parse_repository(repository) + + # URL-encode components to handle spaces and special characters + org_enc = self._encode_url_component(org) + project_enc = self._encode_url_component(project) + repo_enc = self._encode_url_component(repo) + + url = f'{self.base_url}/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/pullrequests/{pr_number}/threads?api-version=7.1' + + # Create thread payload with a comment + # Reference: https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-request-threads/create + payload = { + 'comments': [ + { + 'parentCommentId': 0, + 'content': comment_text, + 'commentType': 1, # 1 = text comment + } + ], + 'status': status, + } + + response, _ = await self._make_request( + url=url, params=payload, method=RequestMethod.POST + ) + + logger.info(f'Created PR thread in {repository}#{pr_number}') + return response + + async def add_pr_comment_to_thread( + self, + repository: str, + pr_number: int, + thread_id: int, + comment_text: str, + ) -> dict: + """Add a comment to an existing thread in an Azure DevOps pull request. + + API Reference: https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-request-thread-comments/create + + Args: + repository: Repository name in format "organization/project/repo" + pr_number: The pull request number + thread_id: The thread ID to add the comment to + comment_text: The comment text to post + + Returns: + API response with created comment information + + Raises: + HTTPException: If the API request fails + """ + org, project, repo = self._parse_repository(repository) + + # URL-encode components to handle spaces and special characters + org_enc = self._encode_url_component(org) + project_enc = self._encode_url_component(project) + repo_enc = self._encode_url_component(repo) + + url = f'{self.base_url}/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/pullrequests/{pr_number}/threads/{thread_id}/comments?api-version=7.1' + + payload = { + 'content': comment_text, + 'parentCommentId': 1, # Reply to the thread's root comment + 'commentType': 1, # 1 = text comment + } + + response, _ = await self._make_request( + url=url, params=payload, method=RequestMethod.POST + ) + + logger.info( + f'Added comment to thread {thread_id} in PR {repository}#{pr_number}' + ) + return response + + async def get_pr_threads(self, repository: str, pr_number: int) -> list[dict]: + """Get all threads (comment conversations) for a pull request. + + API Reference: https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-request-threads/list + + Args: + repository: Repository name in format "organization/project/repo" + pr_number: The pull request number + + Returns: + List of thread objects containing comments + + Raises: + HTTPException: If the API request fails + """ + org, project, repo = self._parse_repository(repository) + + # URL-encode components to handle spaces and special characters + org_enc = self._encode_url_component(org) + project_enc = self._encode_url_component(project) + repo_enc = self._encode_url_component(repo) + + url = f'{self.base_url}/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/pullrequests/{pr_number}/threads?api-version=7.1' + + response, _ = await self._make_request(url) + + return response.get('value', []) + + async def get_pr_comments( + self, repository: str, pr_number: int, max_comments: int = 100 + ) -> list[Comment]: + """Get all comments from all threads in a pull request. + + Retrieves all threads and extracts comments from them, converting to Comment objects. + + Args: + repository: Repository name in format "organization/project/repo" + pr_number: The pull request number + max_comments: Maximum number of comments to return + + Returns: + List of Comment objects sorted by creation date + """ + threads = await self.get_pr_threads(repository, pr_number) + + all_comments: list[Comment] = [] + + for thread in threads: + comments_data = thread.get('comments', []) + + for comment_data in comments_data: + # Extract author information + author_info = comment_data.get('author', {}) + author = author_info.get('displayName', 'unknown') + + # Parse dates + created_at = ( + datetime.fromisoformat( + comment_data.get('publishedDate', '').replace('Z', '+00:00') + ) + if comment_data.get('publishedDate') + else datetime.fromtimestamp(0) + ) + + updated_at = ( + datetime.fromisoformat( + comment_data.get('lastUpdatedDate', '').replace('Z', '+00:00') + ) + if comment_data.get('lastUpdatedDate') + else created_at + ) + + # Check if it's a system comment + is_system = comment_data.get('commentType', 1) != 1 # 1 = text comment + + comment = Comment( + id=str(comment_data.get('id', 0)), + body=self._truncate_comment(comment_data.get('content', '')), + author=author, + created_at=created_at, + updated_at=updated_at, + system=is_system, + ) + + all_comments.append(comment) + + # Sort by creation date and limit + all_comments.sort(key=lambda c: c.created_at) + return all_comments[:max_comments] + + 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 Azure DevOps. + + Args: + repo_name: The repository name in format "organization/project/repo" + source_branch: The source branch name + target_branch: The target branch name + title: The title of the pull request + body: The description of the pull request + draft: Whether to create a draft pull request + + Returns: + The URL of the created pull request + """ + # Parse repository string: organization/project/repo + parts = repo_name.split('/') + if len(parts) < 3: + raise ValueError( + f'Invalid repository format: {repo_name}. Expected format: organization/project/repo' + ) + + org = parts[0] + project = parts[1] + repo = parts[2] + + # URL-encode components to handle spaces and special characters + org_enc = self._encode_url_component(org) + project_enc = self._encode_url_component(project) + repo_enc = self._encode_url_component(repo) + + url = f'https://dev.azure.com/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/pullrequests?api-version=7.1' + + # Set default body if none provided + if not body: + body = f'Merging changes from {source_branch} into {target_branch}' + + payload = { + 'sourceRefName': f'refs/heads/{source_branch}', + 'targetRefName': f'refs/heads/{target_branch}', + 'title': title, + 'description': body, + 'isDraft': draft, + } + + response, _ = await self._make_request( + url=url, params=payload, method=RequestMethod.POST + ) + + # Return the web URL of the created PR + pr_id = response.get('pullRequestId') + return f'https://dev.azure.com/{org_enc}/{project_enc}/_git/{repo_enc}/pullrequest/{pr_id}' + + async def get_pr_details(self, repository: str, pr_number: int) -> dict: + """Get detailed information about a specific pull request. + + Args: + repository: Repository name in Azure DevOps format 'org/project/repo' + pr_number: The pull request number + + Returns: + Raw API response from Azure DevOps + """ + org, project, repo = self._parse_repository(repository) + + # URL-encode components to handle spaces and special characters + org_enc = self._encode_url_component(org) + project_enc = self._encode_url_component(project) + repo_enc = self._encode_url_component(repo) + + url = f'{self.base_url}/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/pullrequests/{pr_number}?api-version=7.1' + + response, _ = await self._make_request(url) + return response + + async def is_pr_open(self, repository: str, pr_number: int) -> bool: + """Check if a PR is still active (not closed/merged). + + Args: + repository: Repository name in Azure DevOps format 'org/project/repo' + pr_number: The PR number to check + + Returns: + True if PR is active (open), False if closed/merged/abandoned + """ + try: + pr_details = await self.get_pr_details(repository, pr_number) + status = pr_details.get('status', '').lower() + # Azure DevOps PR statuses: active, abandoned, completed + return status == 'active' + except Exception as e: + logger.warning( + f'Failed to check PR status for {repository}#{pr_number}: {e}' + ) + return False + + async def add_pr_reaction( + self, repository: str, pr_number: int, reaction_type: str = ':thumbsup:' + ) -> dict: + org, project, repo = self._parse_repository(repository) + comment_text = f'{reaction_type} OpenHands is processing this PR...' + return await self.add_pr_thread( + repository, pr_number, comment_text, status='closed' + ) diff --git a/openhands/integrations/azure_devops/service/repos.py b/openhands/integrations/azure_devops/service/repos.py new file mode 100644 index 0000000000..ac7930acda --- /dev/null +++ b/openhands/integrations/azure_devops/service/repos.py @@ -0,0 +1,178 @@ +"""Repository operations for Azure DevOps integration.""" + +from openhands.integrations.azure_devops.service.base import AzureDevOpsMixinBase +from openhands.integrations.service_types import ProviderType, Repository +from openhands.server.types import AppMode + + +class AzureDevOpsReposMixin(AzureDevOpsMixinBase): + """Mixin for Azure DevOps repository operations.""" + + async def search_repositories( + self, + query: str, + per_page: int = 30, + sort: str = 'updated', + order: str = 'desc', + public: bool = False, + app_mode: AppMode = AppMode.OSS, + ) -> list[Repository]: + """Search for repositories in Azure DevOps.""" + # Get all repositories across all projects in the organization + url = f'{self.base_url}/_apis/git/repositories?api-version=7.1' + + response, _ = await self._make_request(url) + + # Filter repositories by query if provided + repos = response.get('value', []) + if query: + repos = [ + repo for repo in repos if query.lower() in repo.get('name', '').lower() + ] + + # Limit to per_page + repos = repos[:per_page] + + return [ + Repository( + id=str(repo.get('id')), + full_name=f'{self.organization}/{repo.get("project", {}).get("name", "")}/{repo.get("name")}', + git_provider=ProviderType.AZURE_DEVOPS, + is_public=False, # Azure DevOps repos are private by default + ) + for repo in repos + ] + + async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]: + """Get repositories for the authenticated user.""" + MAX_REPOS = 1000 + + # Get all projects first + projects_url = f'{self.base_url}/_apis/projects?api-version=7.1' + projects_response, _ = await self._make_request(projects_url) + projects = projects_response.get('value', []) + + all_repos = [] + + # For each project, get its repositories + for project in projects: + project_name = project.get('name') + project_enc = self._encode_url_component(project_name) + repos_url = ( + f'{self.base_url}/{project_enc}/_apis/git/repositories?api-version=7.1' + ) + repos_response, _ = await self._make_request(repos_url) + repos = repos_response.get('value', []) + + for repo in repos: + all_repos.append( + { + 'id': repo.get('id'), + 'name': repo.get('name'), + 'project_name': project_name, + 'updated_date': repo.get('lastUpdateTime'), + } + ) + + if len(all_repos) >= MAX_REPOS: + break + + if len(all_repos) >= MAX_REPOS: + break + + # Sort repositories based on the sort parameter + if sort == 'updated': + all_repos.sort(key=lambda r: r.get('updated_date', ''), reverse=True) + elif sort == 'name': + all_repos.sort(key=lambda r: r.get('name', '').lower()) + + return [ + Repository( + id=str(repo.get('id')), + full_name=f'{self.organization}/{repo.get("project_name")}/{repo.get("name")}', + git_provider=ProviderType.AZURE_DEVOPS, + is_public=False, # Azure DevOps repos are private by default + ) + for repo in all_repos[:MAX_REPOS] + ] + + async def get_all_repositories( + self, sort: str, app_mode: AppMode + ) -> list[Repository]: + """Get repositories for the authenticated user (alias for get_repositories).""" + return await self.get_repositories(sort, app_mode) + + def _parse_repository_response( + self, repo: dict, project_name: str, link_header: str | None = None + ) -> Repository: + """Parse an Azure DevOps API repository response into a Repository object. + + Args: + repo: Repository data from Azure DevOps API + project_name: The project name the repository belongs to + link_header: Optional link header for pagination + + Returns: + Repository object + """ + return Repository( + id=str(repo.get('id')), + full_name=f'{self.organization}/{project_name}/{repo.get("name")}', + git_provider=ProviderType.AZURE_DEVOPS, + is_public=False, # Azure DevOps repos are private by default + link_header=link_header, + ) + + async def get_paginated_repos( + self, + page: int, + per_page: int, + sort: str, + installation_id: str | None, + query: str | None = None, + ) -> list[Repository]: + """Get a page of repositories for the authenticated user.""" + # Get all repos first, then paginate manually + # Azure DevOps doesn't have native pagination for repositories + all_repos = await self.get_repositories(sort, AppMode.SAAS) + + # Calculate pagination + start_idx = (page - 1) * per_page + end_idx = start_idx + per_page + + # Filter by query if provided + if query: + query_lower = query.lower() + all_repos = [ + repo for repo in all_repos if query_lower in repo.full_name.lower() + ] + + return all_repos[start_idx:end_idx] + + async def get_repository_details_from_repo_name( + self, repository: str + ) -> Repository: + """Gets all repository details from repository name. + + Args: + repository: Repository name in format 'organization/project/repo' + + Returns: + Repository object with details + """ + org, project, repo = self._parse_repository(repository) + + # URL-encode components to handle spaces and special characters + org_enc = self._encode_url_component(org) + project_enc = self._encode_url_component(project) + repo_enc = self._encode_url_component(repo) + + url = f'https://dev.azure.com/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}?api-version=7.1' + response, _ = await self._make_request(url) + + return Repository( + id=str(response.get('id')), + full_name=f'{org}/{project}/{repo}', + git_provider=ProviderType.AZURE_DEVOPS, + is_public=False, # Azure DevOps repos are private by default + ) diff --git a/openhands/integrations/azure_devops/service/resolver.py b/openhands/integrations/azure_devops/service/resolver.py new file mode 100644 index 0000000000..10bfeaa4b6 --- /dev/null +++ b/openhands/integrations/azure_devops/service/resolver.py @@ -0,0 +1,166 @@ +from datetime import datetime + +from openhands.core.logger import openhands_logger as logger +from openhands.integrations.azure_devops.service.base import AzureDevOpsMixinBase +from openhands.integrations.service_types import Comment + + +class AzureDevOpsResolverMixin(AzureDevOpsMixinBase): + """Helper methods used for the Azure DevOps Resolver.""" + + async def get_issue_or_pr_title_and_body( + self, repository: str, issue_number: int + ) -> tuple[str, str]: + """Get the title and body of a pull request or work item. + + First attempts to get as a PR, then falls back to work item if not found. + + Args: + repository: Repository name in format 'organization/project/repo' + issue_number: The PR number or work item ID + + Returns: + A tuple of (title, body) + """ + org, project, repo = self._parse_repository(repository) + + # URL-encode components to handle spaces and special characters + org_enc = self._encode_url_component(org) + project_enc = self._encode_url_component(project) + repo_enc = self._encode_url_component(repo) + + # Try to get as a pull request first + try: + pr_url = f'{self.base_url}/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/pullrequests/{issue_number}?api-version=7.1' + response, _ = await self._make_request(pr_url) + title = response.get('title') or '' + body = response.get('description') or '' + return title, body + except Exception as pr_error: + logger.debug(f'Failed to get as PR: {pr_error}, trying as work item') + + # Fall back to work item + try: + wi_url = f'{self.base_url}/{org_enc}/{project_enc}/_apis/wit/workitems/{issue_number}?api-version=7.1' + response, _ = await self._make_request(wi_url) + fields = response.get('fields', {}) + title = fields.get('System.Title') or '' + body = fields.get('System.Description') or '' + return title, body + except Exception as wi_error: + logger.error(f'Failed to get as work item: {wi_error}') + return '', '' + + async def get_issue_or_pr_comments( + self, repository: str, issue_number: int, max_comments: int = 10 + ) -> list[Comment]: + """Get comments for a pull request or work item. + + First attempts to get PR comments, then falls back to work item comments if not found. + + Args: + repository: Repository name in format 'organization/project/repo' + issue_number: The PR number or work item ID + max_comments: Maximum number of comments to return + + Returns: + List of Comment objects ordered by creation date + """ + # Try to get PR comments first + try: + comments = await self.get_pr_comments( # type: ignore[attr-defined] + repository, issue_number, max_comments + ) + if comments: + return comments + except Exception as pr_error: + logger.debug(f'Failed to get PR comments: {pr_error}, trying work item') + + # Fall back to work item comments + try: + return await self.get_work_item_comments( # type: ignore[attr-defined] + repository, issue_number, max_comments + ) + except Exception as wi_error: + logger.error(f'Failed to get work item comments: {wi_error}') + return [] + + async def get_review_thread_comments( + self, + thread_id: int, + repository: str, + pr_number: int, + max_comments: int = 10, + ) -> list[Comment]: + """Get all comments in a specific PR review thread. + + Azure DevOps organizes PR comments into threads. This method retrieves + all comments from a specific thread. + + Args: + thread_id: The thread ID + repository: Repository name in format 'organization/project/repo' + pr_number: Pull request number + max_comments: Maximum number of comments to return + + Returns: + List of Comment objects representing the thread + """ + org, project, repo = self._parse_repository(repository) + + # URL-encode components to handle spaces and special characters + org_enc = self._encode_url_component(org) + project_enc = self._encode_url_component(project) + repo_enc = self._encode_url_component(repo) + + url = f'{self.base_url}/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/pullrequests/{pr_number}/threads/{thread_id}?api-version=7.1' + + try: + response, _ = await self._make_request(url) + comments_data = response.get('comments', []) + + all_comments: list[Comment] = [] + + for comment_data in comments_data: + # Extract author information + author_info = comment_data.get('author', {}) + author = author_info.get('displayName', 'unknown') + + # Parse dates + created_at = ( + datetime.fromisoformat( + comment_data.get('publishedDate', '').replace('Z', '+00:00') + ) + if comment_data.get('publishedDate') + else datetime.fromtimestamp(0) + ) + + updated_at = ( + datetime.fromisoformat( + comment_data.get('lastUpdatedDate', '').replace('Z', '+00:00') + ) + if comment_data.get('lastUpdatedDate') + else created_at + ) + + # Check if it's a system comment + is_system = comment_data.get('commentType', 1) != 1 # 1 = text comment + + comment = Comment( + id=str(comment_data.get('id', 0)), + body=self._truncate_comment(comment_data.get('content', '')), + author=author, + created_at=created_at, + updated_at=updated_at, + system=is_system, + ) + + all_comments.append(comment) + + # Sort by creation date and limit + all_comments.sort(key=lambda c: c.created_at) + return all_comments[:max_comments] + + except Exception as error: + logger.error(f'Failed to get thread {thread_id} comments: {error}') + return [] diff --git a/openhands/integrations/azure_devops/service/work_items.py b/openhands/integrations/azure_devops/service/work_items.py new file mode 100644 index 0000000000..fc4a6b6eff --- /dev/null +++ b/openhands/integrations/azure_devops/service/work_items.py @@ -0,0 +1,129 @@ +"""Work item operations for Azure DevOps integration.""" + +from datetime import datetime + +from openhands.core.logger import openhands_logger as logger +from openhands.integrations.azure_devops.service.base import AzureDevOpsMixinBase +from openhands.integrations.service_types import Comment, RequestMethod + + +class AzureDevOpsWorkItemsMixin(AzureDevOpsMixinBase): + """Mixin for Azure DevOps work item operations. + + Work Items are unique to Azure DevOps and represent tasks, bugs, user stories, etc. + in Azure Boards. This mixin provides methods to interact with work item comments. + """ + + def _truncate_comment(self, comment: str, max_length: int = 1000) -> str: + """Truncate comment to max length.""" + if len(comment) <= max_length: + return comment + return comment[:max_length] + '...' + + async def add_work_item_comment( + self, repository: str, work_item_id: int, comment_text: str + ) -> dict: + """Add a comment to an Azure DevOps work item. + + API Reference: https://learn.microsoft.com/en-us/rest/api/azure/devops/wit/comments/add-comment + + Args: + repository: Repository name in format "organization/project/repo" (project extracted) + work_item_id: The work item ID + comment_text: The comment text to post + + Returns: + API response with created comment information + + Raises: + HTTPException: If the API request fails + """ + org, project, _ = self._parse_repository(repository) + + # URL-encode components to handle spaces and special characters + org_enc = self._encode_url_component(org) + project_enc = self._encode_url_component(project) + + url = f'{self.base_url}/{org_enc}/{project_enc}/_apis/wit/workItems/{work_item_id}/comments?api-version=7.1-preview.4' + + payload = { + 'text': comment_text, + } + + response, _ = await self._make_request( + url=url, params=payload, method=RequestMethod.POST + ) + + logger.info(f'Added comment to work item {work_item_id} in project {project}') + return response + + async def get_work_item_comments( + self, repository: str, work_item_id: int, max_comments: int = 100 + ) -> list[Comment]: + """Get all comments from a work item. + + API Reference: https://learn.microsoft.com/en-us/rest/api/azure/devops/wit/comments/get-comments + + Args: + repository: Repository name in format "organization/project/repo" (project extracted) + work_item_id: The work item ID + max_comments: Maximum number of comments to return + + Returns: + List of Comment objects sorted by creation date + """ + org, project, _ = self._parse_repository(repository) + + # URL-encode components to handle spaces and special characters + org_enc = self._encode_url_component(org) + project_enc = self._encode_url_component(project) + + url = f'{self.base_url}/{org_enc}/{project_enc}/_apis/wit/workItems/{work_item_id}/comments?api-version=7.1-preview.4' + + response, _ = await self._make_request(url) + + comments_data = response.get('comments', []) + all_comments: list[Comment] = [] + + for comment_data in comments_data: + # Extract author information + author_info = comment_data.get('createdBy', {}) + author = author_info.get('displayName', 'unknown') + + # Parse dates + created_at = ( + datetime.fromisoformat( + comment_data.get('createdDate', '').replace('Z', '+00:00') + ) + if comment_data.get('createdDate') + else datetime.fromtimestamp(0) + ) + + modified_at = ( + datetime.fromisoformat( + comment_data.get('modifiedDate', '').replace('Z', '+00:00') + ) + if comment_data.get('modifiedDate') + else created_at + ) + + comment = Comment( + id=str(comment_data.get('id', 0)), + body=self._truncate_comment(comment_data.get('text', '')), + author=author, + created_at=created_at, + updated_at=modified_at, + system=False, + ) + + all_comments.append(comment) + + # Sort by creation date and limit + all_comments.sort(key=lambda c: c.created_at) + return all_comments[:max_comments] + + async def add_work_item_reaction( + self, repository: str, work_item_id: int, reaction_type: str = ':thumbsup:' + ) -> dict: + comment_text = f'{reaction_type} OpenHands is processing this work item...' + return await self.add_work_item_comment(repository, work_item_id, comment_text) diff --git a/openhands/integrations/protocols/http_client.py b/openhands/integrations/protocols/http_client.py index 21ec1857e0..5b12da029e 100644 --- a/openhands/integrations/protocols/http_client.py +++ b/openhands/integrations/protocols/http_client.py @@ -20,7 +20,7 @@ class HTTPClient(ABC): """Abstract base class defining the HTTP client interface for Git service integrations. This class abstracts the common HTTP client functionality needed by all - Git service providers (GitHub, GitLab, BitBucket) while keeping inheritance in place. + Git service providers (GitHub, GitLab, Bitbucket, Azure DevOps) while keeping inheritance in place. """ # Default attributes (subclasses may override) diff --git a/openhands/integrations/provider.py b/openhands/integrations/provider.py index 09c1ae7e11..b4289283e7 100644 --- a/openhands/integrations/provider.py +++ b/openhands/integrations/provider.py @@ -3,6 +3,7 @@ from __future__ import annotations import os from types import MappingProxyType from typing import Annotated, Any, Coroutine, Literal, cast, overload +from urllib.parse import quote import httpx from pydantic import ( @@ -17,6 +18,9 @@ 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.azure_devops.azure_devops_service import ( + AzureDevOpsServiceImpl, +) from openhands.integrations.bitbucket.bitbucket_service import BitBucketServiceImpl from openhands.integrations.github.github_service import GithubServiceImpl from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl @@ -109,6 +113,7 @@ class ProviderHandler: ProviderType.GITHUB: 'github.com', ProviderType.GITLAB: 'gitlab.com', ProviderType.BITBUCKET: 'bitbucket.org', + ProviderType.AZURE_DEVOPS: 'dev.azure.com', } def __init__( @@ -129,6 +134,7 @@ class ProviderHandler: ProviderType.GITHUB: GithubServiceImpl, ProviderType.GITLAB: GitLabServiceImpl, ProviderType.BITBUCKET: BitBucketServiceImpl, + ProviderType.AZURE_DEVOPS: AzureDevOpsServiceImpl, } self.external_auth_id = external_auth_id @@ -214,6 +220,17 @@ class ProviderHandler: return [] + async def get_azure_devops_organizations(self) -> list[str]: + service = cast( + InstallationsService, self.get_service(ProviderType.AZURE_DEVOPS) + ) + try: + return await service.get_installations() + except Exception as e: + logger.warning(f'Failed to get azure devops organizations {e}') + + return [] + async def get_repositories( self, sort: str, @@ -658,8 +675,10 @@ class ProviderHandler: domain = self.PROVIDER_DOMAINS[provider] # If provider tokens are provided, use the host from the token if available + # Note: For Azure DevOps, don't use the host field as it may contain org/project path if self.provider_tokens and provider in self.provider_tokens: - domain = self.provider_tokens[provider].host or domain + if provider != ProviderType.AZURE_DEVOPS: + domain = self.provider_tokens[provider].host or domain # Try to use token if available, otherwise use public URL if self.provider_tokens and provider in self.provider_tokens: @@ -678,6 +697,63 @@ class ProviderHandler: else: # Access token format: use x-token-auth remote_url = f'https://x-token-auth:{token_value}@{domain}/{repo_name}.git' + elif provider == ProviderType.AZURE_DEVOPS: + # Azure DevOps uses PAT with Basic auth + # Format: https://{anything}:{PAT}@dev.azure.com/{org}/{project}/_git/{repo} + # The username can be anything (it's ignored), but cannot be empty + # We use the org name as the username for clarity + # repo_name is in format: org/project/repo + logger.info( + f'[Azure DevOps] Constructing authenticated git URL for repository: {repo_name}' + ) + logger.debug(f'[Azure DevOps] Original domain: {domain}') + logger.debug( + f'[Azure DevOps] Token available: {bool(token_value)}, ' + f'Token length: {len(token_value) if token_value else 0}' + ) + + # Remove domain prefix if it exists in domain variable + clean_domain = domain.replace('https://', '').replace('http://', '') + logger.debug(f'[Azure DevOps] Cleaned domain: {clean_domain}') + + parts = repo_name.split('/') + logger.debug( + f'[Azure DevOps] Repository parts: {parts} (length: {len(parts)})' + ) + + if len(parts) >= 3: + org, project, repo = parts[0], parts[1], parts[2] + logger.info( + f'[Azure DevOps] Parsed repository - org: {org}, project: {project}, repo: {repo}' + ) + # URL-encode org, project, and repo to handle spaces and special characters + org_encoded = quote(org, safe='') + project_encoded = quote(project, safe='') + repo_encoded = quote(repo, safe='') + logger.debug( + f'[Azure DevOps] URL-encoded parts - org: {org_encoded}, project: {project_encoded}, repo: {repo_encoded}' + ) + # Use org name as username (it's ignored by Azure DevOps but required for git) + remote_url = f'https://{org}:***@{clean_domain}/{org_encoded}/{project_encoded}/_git/{repo_encoded}' + logger.info( + f'[Azure DevOps] Constructed git URL (token masked): {remote_url}' + ) + # Set the actual URL with token + remote_url = f'https://{org}:{token_value}@{clean_domain}/{org_encoded}/{project_encoded}/_git/{repo_encoded}' + else: + # Fallback if format is unexpected + logger.warning( + f'[Azure DevOps] Unexpected repository format: {repo_name}. ' + f'Expected org/project/repo (3 parts), got {len(parts)} parts. ' + 'Using fallback URL format.' + ) + remote_url = ( + f'https://user:{token_value}@{clean_domain}/{repo_name}.git' + ) + logger.warning( + f'[Azure DevOps] Fallback URL constructed (token masked): ' + f'https://user:***@{clean_domain}/{repo_name}.git' + ) else: # GitHub remote_url = f'https://{token_value}@{domain}/{repo_name}.git' diff --git a/openhands/integrations/service_types.py b/openhands/integrations/service_types.py index cfc4839059..cf76e40479 100644 --- a/openhands/integrations/service_types.py +++ b/openhands/integrations/service_types.py @@ -21,6 +21,7 @@ class ProviderType(Enum): GITHUB = 'github' GITLAB = 'gitlab' BITBUCKET = 'bitbucket' + AZURE_DEVOPS = 'azure_devops' ENTERPRISE_SSO = 'enterprise_sso' diff --git a/openhands/integrations/templates/resolver/azure_devops/issue_conversation_instructions.j2 b/openhands/integrations/templates/resolver/azure_devops/issue_conversation_instructions.j2 new file mode 100644 index 0000000000..c9d22c59b1 --- /dev/null +++ b/openhands/integrations/templates/resolver/azure_devops/issue_conversation_instructions.j2 @@ -0,0 +1,41 @@ +{% if issue_number %} +You are requested to fix work item #{{ issue_number }}: "{{ issue_title }}" in an Azure DevOps repository. +A comment on the work item has been addressed to you. +{% else %} +Your task is to fix the work item: "{{ issue_title }}". +{% endif %} + +# Work Item Description +{{ issue_body }} + +{% if previous_comments %} +# Previous Comments +For reference, here are the previous comments on the work item: + +{% for comment in previous_comments %} +- @{{ comment.author }} said: +{{ comment.body }} +{% if not loop.last %}\n\n{% endif %} +{% endfor %} +{% endif %} + +# Guidelines + +1. Review the task carefully. +2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the work item has been fixed +3. Run the tests, and if they pass you are done! +4. You do NOT need to write new tests if there are only changes to documentation or configuration files. + +# Final Checklist +Re-read the work item title, description, and comments and make sure that you have successfully implemented all requirements. + +Use the Azure DevOps token and Azure DevOps REST APIs to: + +1. Create a new branch using `openhands/` as a prefix (e.g `openhands/update-readme`) +2. Commit your changes with a clear commit message +3. Push the branch to Azure DevOps +4. Use the `create_pr` tool to open a new pull request +5. The PR description should: + - Mention that it "fixes" or "closes" the work item number + - Include a clear summary of the changes + - Reference any related work items diff --git a/openhands/integrations/templates/resolver/azure_devops/issue_prompt.j2 b/openhands/integrations/templates/resolver/azure_devops/issue_prompt.j2 new file mode 100644 index 0000000000..0309c21a02 --- /dev/null +++ b/openhands/integrations/templates/resolver/azure_devops/issue_prompt.j2 @@ -0,0 +1,5 @@ +{% if issue_comment %} +{{ issue_comment }} +{% else %} +Please fix work item #{{ issue_number }}. +{% endif %} diff --git a/openhands/integrations/templates/resolver/azure_devops/pr_update_conversation_instructions.j2 b/openhands/integrations/templates/resolver/azure_devops/pr_update_conversation_instructions.j2 new file mode 100644 index 0000000000..8b9d184e38 --- /dev/null +++ b/openhands/integrations/templates/resolver/azure_devops/pr_update_conversation_instructions.j2 @@ -0,0 +1,38 @@ +You are checked out to branch {{ branch_name }}, which has an open PR #{{ pr_number }}: "{{ pr_title }}". +A comment on the PR has been addressed to you. + +# PR Description +{{ pr_body }} + +{% if comments %} +# Previous Comments +You may find these other comments relevant: +{% for comment in comments %} +- @{{ comment.author }} said at {{ comment.created_at }}: +{{ comment.body }} +{% if not loop.last %}\n\n{% endif %} +{% endfor %} +{% endif %} + +{% if file_location %} +# Comment location +The comment is in the file `{{ file_location }}` on line #{{ line_number }} +{% endif %}. + +# Steps to Handle the Comment + +## Understand the PR Context +Use the Azure DevOps token and Azure DevOps REST APIs to: + 1. Retrieve the diff against the target branch to understand the changes + 2. Fetch the PR description and any linked work items for context + +## Process the Comment +If it's a question: + 1. Answer the question asked + 2. DO NOT leave any comments on the PR + +If it requests a code update: + 1. Modify the code accordingly in the current branch + 2. Commit your changes with a clear commit message + 3. Push the changes to Azure DevOps to update the PR + 4. DO NOT leave any comments on the PR diff --git a/openhands/integrations/templates/resolver/azure_devops/pr_update_prompt.j2 b/openhands/integrations/templates/resolver/azure_devops/pr_update_prompt.j2 new file mode 100644 index 0000000000..987ac3ac59 --- /dev/null +++ b/openhands/integrations/templates/resolver/azure_devops/pr_update_prompt.j2 @@ -0,0 +1 @@ +{{ pr_comment }} diff --git a/openhands/integrations/utils.py b/openhands/integrations/utils.py index eb4114b049..c3a9ee344c 100644 --- a/openhands/integrations/utils.py +++ b/openhands/integrations/utils.py @@ -1,6 +1,9 @@ from pydantic import SecretStr from openhands.core.logger import openhands_logger as logger +from openhands.integrations.azure_devops.azure_devops_service import ( + AzureDevOpsServiceImpl as AzureDevOpsService, +) from openhands.integrations.bitbucket.bitbucket_service import BitBucketService from openhands.integrations.github.github_service import GitHubService from openhands.integrations.gitlab.gitlab_service import GitLabService @@ -10,8 +13,7 @@ from openhands.integrations.provider import ProviderType async def validate_provider_token( token: SecretStr, base_domain: str | None = None ) -> ProviderType | None: - """Determine whether a token is for GitHub, GitLab, or Bitbucket by attempting to get user info - from the services. + """Determine whether a token is for GitHub, GitLab, Bitbucket, or Azure DevOps by attempting to get user info from the services. Args: token: The token to check @@ -21,6 +23,7 @@ async def validate_provider_token( 'github' if it's a GitHub token 'gitlab' if it's a GitLab token 'bitbucket' if it's a Bitbucket token + 'azure_devops' if it's an Azure DevOps token None if the token is invalid for all services """ # Skip validation for empty tokens @@ -45,7 +48,7 @@ async def validate_provider_token( except Exception as e: gitlab_error = e - # Try Bitbucket last + # Try Bitbucket next bitbucket_error = None try: bitbucket_service = BitBucketService(token=token, base_domain=base_domain) @@ -54,8 +57,17 @@ async def validate_provider_token( except Exception as e: bitbucket_error = e + # Try Azure DevOps last + azure_devops_error = None + try: + azure_devops_service = AzureDevOpsService(token=token, base_domain=base_domain) + await azure_devops_service.get_user() + return ProviderType.AZURE_DEVOPS + except Exception as e: + azure_devops_error = e + logger.debug( - f'Failed to validate token: {github_error} \n {gitlab_error} \n {bitbucket_error}' + f'Failed to validate token: {github_error} \n {gitlab_error} \n {bitbucket_error} \n {azure_devops_error}' ) return None diff --git a/openhands/resolver/README.md b/openhands/resolver/README.md index 0bcd5a2307..8c09ce854b 100644 --- a/openhands/resolver/README.md +++ b/openhands/resolver/README.md @@ -1,9 +1,9 @@ -# OpenHands GitHub, GitLab & Bitbucket Issue Resolver 🙌 +# OpenHands GitHub, GitLab, Bitbucket & Azure DevOps Issue Resolver 🙌 -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! +Need help resolving a GitHub, GitLab, Bitbucket, or Azure DevOps 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/openhands/openhands) -to attempt to resolve GitHub, GitLab, and Bitbucket issues automatically. While it can handle multiple issues, it's primarily designed +to attempt to resolve GitHub, GitLab, Bitbucket, and Azure DevOps 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,7 +74,7 @@ If you prefer to run the resolver programmatically instead of using GitHub Actio pip install openhands-ai ``` -2. Create a GitHub, GitLab, or Bitbucket access token: +2. Create a GitHub, GitLab, Bitbucket, or Azure DevOps 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: @@ -103,6 +103,13 @@ pip install openhands-ai - 'Issues: Read' - 'Issues: Write' + - Create an Azure DevOps access token + - Visit [Azure DevOps token settings](https://dev.azure.com/{organization}/_usersSettings/tokens) + - Create a personal access token with these scopes: + - 'Code: Read & Write' + - 'Work Items: Read & Write' + - 'Pull Request: Read & Write' + 3. Set up environment variables: ```bash @@ -122,6 +129,11 @@ export GIT_USERNAME="your-gitlab-username" # Optional, defaults to token owner export BITBUCKET_TOKEN="your-bitbucket-token" export GIT_USERNAME="your-bitbucket-username" # Optional, defaults to token owner +# Azure DevOps credentials if you're using Azure DevOps repo + +export AZURE_DEVOPS_TOKEN="your-azure-devops-token" +export GIT_USERNAME="your-azure-devops-username" # Optional, defaults to token owner + # LLM configuration export LLM_MODEL="anthropic/claude-sonnet-4-20250514" # Recommended diff --git a/openhands/resolver/interfaces/azure_devops.py b/openhands/resolver/interfaces/azure_devops.py new file mode 100644 index 0000000000..94d15d7296 --- /dev/null +++ b/openhands/resolver/interfaces/azure_devops.py @@ -0,0 +1,427 @@ +import base64 +import re +from typing import Any + +import httpx + +from openhands.resolver.interfaces.issue import ( + Issue, + IssueHandlerInterface, + ReviewThread, +) + + +class AzureDevOpsIssueHandler(IssueHandlerInterface): + def __init__( + self, + token: str, + organization: str, + project: str, + repository: str, + ): + self.token = token + self.organization = organization + self.project = project + self.repository = repository + self.owner = f'{organization}/{project}' + self.base_api_url = f'https://dev.azure.com/{organization}/{project}/_apis' + self.repo_api_url = f'{self.base_api_url}/git/repositories/{repository}' + self.work_items_api_url = f'{self.base_api_url}/wit' + self.default_branch = 'main' + + def set_owner(self, owner: str) -> None: + """Set the owner of the repository.""" + self.owner = owner + parts = owner.split('/') + if len(parts) >= 2: + self.organization = parts[0] + self.project = parts[1] + self.base_api_url = ( + f'https://dev.azure.com/{self.organization}/{self.project}/_apis' + ) + self.repo_api_url = ( + f'{self.base_api_url}/git/repositories/{self.repository}' + ) + self.work_items_api_url = f'{self.base_api_url}/wit' + + def get_headers(self) -> dict[str, str]: + """Get the headers for the Azure DevOps API.""" + auth_str = base64.b64encode(f':{self.token}'.encode()).decode() + return { + 'Authorization': f'Basic {auth_str}', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } + + def download_issues(self) -> list[Any]: + """Download issues from Azure DevOps.""" + # Use WIQL to query for active work items + wiql_url = f'{self.work_items_api_url}/wiql?api-version=7.1' + wiql_query = { + 'query': "SELECT [System.Id], [System.Title], [System.State] FROM WorkItems WHERE [System.State] = 'Active' ORDER BY [System.CreatedDate] DESC" + } + + response = httpx.post(wiql_url, headers=self.get_headers(), json=wiql_query) + response.raise_for_status() + + work_item_references = response.json().get('workItems', []) + + # Get details for each work item + work_items = [] + for work_item_ref in work_item_references: + work_item_id = work_item_ref.get('id') + work_item_url = f'{self.work_items_api_url}/workitems/{work_item_id}?api-version=7.1&$expand=all' + + item_response = httpx.get(work_item_url, headers=self.get_headers()) + item_response.raise_for_status() + + work_items.append(item_response.json()) + + return work_items + + def get_issue_comments( + self, issue_number: int, comment_id: int | None = None + ) -> list[str] | None: + """Get comments for an issue.""" + comments_url = f'{self.work_items_api_url}/workitems/{issue_number}/comments?api-version=7.1-preview.3' + + response = httpx.get(comments_url, headers=self.get_headers()) + response.raise_for_status() + + comments_data = response.json().get('comments', []) + + if comment_id is not None: + # Return a specific comment + for comment in comments_data: + if comment.get('id') == comment_id: + return [comment.get('text', '')] + return None + + # Return all comments + return [comment.get('text', '') for comment in comments_data] + + def get_base_url(self) -> str: + """Get the base URL for the Azure DevOps repository.""" + return f'https://dev.azure.com/{self.organization}/{self.project}' + + def get_branch_url(self, branch_name: str) -> str: + """Get the URL for a branch.""" + return f'{self.get_base_url()}/_git/{self.repository}?version=GB{branch_name}' + + def get_download_url(self) -> str: + """Get the download URL for the repository.""" + return f'{self.get_base_url()}/_git/{self.repository}' + + def get_clone_url(self) -> str: + """Get the clone URL for the repository.""" + return f'https://dev.azure.com/{self.organization}/{self.project}/_git/{self.repository}' + + def get_pull_url(self, pr_number: int) -> str: + """Get the URL for a pull request.""" + return f'{self.get_base_url()}/_git/{self.repository}/pullrequest/{pr_number}' + + def get_graphql_url(self) -> str: + """Get the GraphQL URL for Azure DevOps.""" + return f'https://dev.azure.com/{self.organization}/_apis/graphql?api-version=7.1-preview.1' + + def get_compare_url(self, branch_name: str) -> str: + """Get the URL to compare branches.""" + return f'{self.get_base_url()}/_git/{self.repository}/branches?baseVersion=GB{self.default_branch}&targetVersion=GB{branch_name}&_a=files' + + def get_branch_name(self, base_branch_name: str) -> str: + """Generate a branch name for a new pull request.""" + return f'openhands/issue-{base_branch_name}' + + def get_default_branch_name(self) -> str: + """Get the default branch name for the repository.""" + # Get repository details to find the default branch + response = httpx.get( + f'{self.repo_api_url}?api-version=7.1', headers=self.get_headers() + ) + response.raise_for_status() + + repo_data = response.json() + default_branch = repo_data.get('defaultBranch', 'refs/heads/main') + + # Remove 'refs/heads/' prefix + return default_branch.replace('refs/heads/', '') + + def branch_exists(self, branch_name: str) -> bool: + """Check if a branch exists.""" + # List all branches and check if the branch exists + response = httpx.get( + f'{self.repo_api_url}/refs?filter=heads/{branch_name}&api-version=7.1', + headers=self.get_headers(), + ) + response.raise_for_status() + + refs = response.json().get('value', []) + return len(refs) > 0 + + def reply_to_comment(self, pr_number: int, comment_id: str, reply: str) -> None: + """Reply to a comment on a pull request.""" + # Get the thread ID from the comment ID + threads_url = ( + f'{self.repo_api_url}/pullRequests/{pr_number}/threads?api-version=7.1' + ) + + response = httpx.get(threads_url, headers=self.get_headers()) + response.raise_for_status() + + threads = response.json().get('value', []) + thread_id = None + + for thread in threads: + for comment in thread.get('comments', []): + if str(comment.get('id')) == comment_id: + thread_id = thread.get('id') + break + if thread_id: + break + + if not thread_id: + raise ValueError(f'Comment ID {comment_id} not found in PR {pr_number}') + + # Add a comment to the thread + comment_url = f'{self.repo_api_url}/pullRequests/{pr_number}/threads/{thread_id}/comments?api-version=7.1' + + comment_data = { + 'content': reply, + 'parentCommentId': int(comment_id), + } + + response = httpx.post( + comment_url, headers=self.get_headers(), json=comment_data + ) + response.raise_for_status() + + def send_comment_msg(self, issue_number: int, msg: str) -> None: + """Send a comment to an issue.""" + comment_url = f'{self.work_items_api_url}/workitems/{issue_number}/comments?api-version=7.1-preview.3' + + comment_data = { + 'text': msg, + } + + response = httpx.post( + comment_url, headers=self.get_headers(), json=comment_data + ) + response.raise_for_status() + + def get_authorize_url(self) -> str: + """Get the authorization URL for Azure DevOps.""" + return 'https://app.vsaex.visualstudio.com/app/register' + + def create_pull_request(self, data: dict[str, Any] | None = None) -> dict[str, Any]: + """Create a pull request.""" + if data is None: + data = {} + + source_branch = data.get('source_branch') + target_branch = data.get('target_branch', self.default_branch) + title = data.get('title', 'Pull request created by OpenHands') + description = data.get('description', '') + + pr_data = { + 'sourceRefName': f'refs/heads/{source_branch}', + 'targetRefName': f'refs/heads/{target_branch}', + 'title': title, + 'description': description, + } + + response = httpx.post( + f'{self.repo_api_url}/pullrequests?api-version=7.1', + headers=self.get_headers(), + json=pr_data, + ) + response.raise_for_status() + + pr_response = response.json() + + return { + 'id': pr_response.get('pullRequestId'), + 'number': pr_response.get('pullRequestId'), + 'url': pr_response.get('url'), + } + + def request_reviewers(self, reviewer: str, pr_number: int) -> None: + """Request reviewers for a pull request.""" + # Get the reviewer's ID + reviewer_url = f'https://vssps.dev.azure.com/{self.organization}/_apis/graph/users?api-version=7.1-preview.1' + + response = httpx.get(reviewer_url, headers=self.get_headers()) + response.raise_for_status() + + users = response.json().get('value', []) + reviewer_id = None + + for user in users: + if ( + user.get('displayName') == reviewer + or user.get('mailAddress') == reviewer + ): + reviewer_id = user.get('descriptor') + break + + if not reviewer_id: + raise ValueError(f'Reviewer {reviewer} not found') + + # Add reviewer to the pull request + reviewers_url = f'{self.repo_api_url}/pullRequests/{pr_number}/reviewers/{reviewer_id}?api-version=7.1' + + reviewer_data = { + 'vote': 0, # No vote yet + } + + response = httpx.put( + reviewers_url, headers=self.get_headers(), json=reviewer_data + ) + response.raise_for_status() + + 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.""" + context = [] + + # Add issue body + if issue_body: + context.append(f'Issue description:\n{issue_body}') + + # Add thread comments + if thread_comments: + context.append('Thread comments:\n' + '\n'.join(thread_comments)) + + # Add review comments + if review_comments: + context.append('Review comments:\n' + '\n'.join(review_comments)) + + # Add review threads + if review_threads: + for thread in review_threads: + context.append( + f'Review thread for files {", ".join(thread.files)}:\n{thread.comment}' + ) + + return context + + def get_converted_issues( + self, issue_numbers: list[int] | None = None, comment_id: int | None = None + ) -> list[Issue]: + """Download issues from Azure DevOps and convert them to the Issue model.""" + if issue_numbers is None: + # Download all issues + work_items = self.download_issues() + else: + # Download specific issues + work_items = [] + for issue_number in issue_numbers: + work_item_url = f'{self.work_items_api_url}/workitems/{issue_number}?api-version=7.1&$expand=all' + + response = httpx.get(work_item_url, headers=self.get_headers()) + response.raise_for_status() + + work_items.append(response.json()) + + issues = [] + for work_item in work_items: + # Get basic issue information + issue_number = work_item.get('id') + title = work_item.get('fields', {}).get('System.Title', '') + description = work_item.get('fields', {}).get('System.Description', '') + + # Get comments + thread_comments = self.get_issue_comments(issue_number, comment_id) + + # Check if this is a pull request work item + is_pr = False + pr_number = None + head_branch = None + base_branch = None + + # Look for PR links in the work item relations + for relation in work_item.get('relations', []): + if relation.get( + 'rel' + ) == 'ArtifactLink' and 'pullrequest' in relation.get('url', ''): + is_pr = True + # Extract PR number from URL + pr_url = relation.get('url', '') + pr_match = re.search(r'pullRequests/(\d+)', pr_url) + if pr_match: + pr_number = int(pr_match.group(1)) + break + + # If this is a PR, get the branch information + if is_pr and pr_number: + pr_url = f'{self.repo_api_url}/pullRequests/{pr_number}?api-version=7.1' + + pr_response = httpx.get(pr_url, headers=self.get_headers()) + pr_response.raise_for_status() + + pr_data = pr_response.json() + head_branch = pr_data.get('sourceRefName', '').replace( + 'refs/heads/', '' + ) + base_branch = pr_data.get('targetRefName', '').replace( + 'refs/heads/', '' + ) + + # Get PR review comments + review_comments = [] + review_threads = [] + + threads_url = f'{self.repo_api_url}/pullRequests/{pr_number}/threads?api-version=7.1' + + threads_response = httpx.get(threads_url, headers=self.get_headers()) + threads_response.raise_for_status() + + threads = threads_response.json().get('value', []) + + for thread in threads: + thread_comments = [ + comment.get('content', '') + for comment in thread.get('comments', []) + ] + review_comments.extend(thread_comments) + + # Get files associated with this thread + thread_files = [] + if thread.get('threadContext', {}).get('filePath'): + thread_files.append( + thread.get('threadContext', {}).get('filePath') + ) + + if thread_comments: + review_threads.append( + ReviewThread( + comment='\n'.join(thread_comments), + files=thread_files, + ) + ) + + # Create the Issue object + issue = Issue( + owner=self.owner, + repo=self.repository, + number=issue_number, + title=title, + body=description, + thread_comments=thread_comments, + closing_issues=None, + review_comments=review_comments if is_pr else None, + review_threads=review_threads if is_pr else None, + thread_ids=None, + head_branch=head_branch, + base_branch=base_branch, + ) + + issues.append(issue) + + return issues diff --git a/openhands/resolver/interfaces/issue.py b/openhands/resolver/interfaces/issue.py index e293d90950..c491acfd89 100644 --- a/openhands/resolver/interfaces/issue.py +++ b/openhands/resolver/interfaces/issue.py @@ -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 the git provider (GitHub, GitLab, or Bitbucket).""" + """Download issues from the git provider (GitHub, GitLab, Bitbucket, or Azure DevOps).""" pass diff --git a/openhands/resolver/issue_handler_factory.py b/openhands/resolver/issue_handler_factory.py index 528644ed21..45b927f696 100644 --- a/openhands/resolver/issue_handler_factory.py +++ b/openhands/resolver/issue_handler_factory.py @@ -1,5 +1,6 @@ from openhands.core.config import LLMConfig from openhands.integrations.provider import ProviderType +from openhands.resolver.interfaces.azure_devops import AzureDevOpsIssueHandler from openhands.resolver.interfaces.bitbucket import ( BitbucketIssueHandler, BitbucketPRHandler, @@ -68,6 +69,26 @@ class IssueHandlerFactory: ), self.llm_config, ) + elif self.platform == ProviderType.AZURE_DEVOPS: + # Parse owner as organization/project + parts = self.owner.split('/') + if len(parts) < 2: + raise ValueError( + f'Invalid Azure DevOps owner format: {self.owner}. Expected format: organization/project' + ) + + organization = parts[0] + project = parts[1] + + return ServiceContextIssue( + AzureDevOpsIssueHandler( + self.token, + organization, + project, + self.repo, + ), + self.llm_config, + ) else: raise ValueError(f'Unsupported platform: {self.platform}') elif self.issue_type == 'pr': @@ -104,6 +125,27 @@ class IssueHandlerFactory: ), self.llm_config, ) + elif self.platform == ProviderType.AZURE_DEVOPS: + # Parse owner as organization/project + parts = self.owner.split('/') + if len(parts) < 2: + raise ValueError( + f'Invalid Azure DevOps owner format: {self.owner}. Expected format: organization/project' + ) + + organization = parts[0] + project = parts[1] + + # For now, use the same handler for both issues and PRs + return ServiceContextPR( + AzureDevOpsIssueHandler( + self.token, + organization, + project, + self.repo, + ), + self.llm_config, + ) else: raise ValueError(f'Unsupported platform: {self.platform}') else: diff --git a/openhands/resolver/issue_resolver.py b/openhands/resolver/issue_resolver.py index 143553818e..155c5ce2e1 100644 --- a/openhands/resolver/issue_resolver.py +++ b/openhands/resolver/issue_resolver.py @@ -81,6 +81,7 @@ class IssueResolver: or os.getenv('GITHUB_TOKEN') or os.getenv('GITLAB_TOKEN') or os.getenv('BITBUCKET_TOKEN') + or os.getenv('AZURE_DEVOPS_TOKEN') ) username = args.username if args.username else os.getenv('GIT_USERNAME') if not username: @@ -130,6 +131,8 @@ class IssueResolver: else 'gitlab.com' if platform == ProviderType.GITLAB else 'bitbucket.org' + if platform == ProviderType.BITBUCKET + else 'dev.azure.com' ) self.output_dir = args.output_dir diff --git a/openhands/resolver/resolve_issue.py b/openhands/resolver/resolve_issue.py index bd050386e5..cdd6d64943 100644 --- a/openhands/resolver/resolve_issue.py +++ b/openhands/resolver/resolve_issue.py @@ -122,7 +122,7 @@ def main() -> None: '--base-domain', type=str, default=None, - help='Base domain for the git server (defaults to "github.com" for GitHub, "gitlab.com" for GitLab, and "bitbucket.org" for Bitbucket)', + help='Base domain for the git server (defaults to "github.com" for GitHub, "gitlab.com" for GitLab, "bitbucket.org" for Bitbucket, and "dev.azure.com" for Azure DevOps)', ) my_args = parser.parse_args() diff --git a/openhands/resolver/send_pull_request.py b/openhands/resolver/send_pull_request.py index 047592c9cc..d6dd4830db 100644 --- a/openhands/resolver/send_pull_request.py +++ b/openhands/resolver/send_pull_request.py @@ -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.azure_devops import AzureDevOpsIssueHandler from openhands.resolver.interfaces.bitbucket import BitbucketIssueHandler from openhands.resolver.interfaces.github import GithubIssueHandler from openhands.resolver.interfaces.gitlab import GitlabIssueHandler @@ -247,7 +248,7 @@ def send_pull_request( git_user_name: str = 'openhands', git_user_email: str = 'openhands@all-hands.dev', ) -> str: - """Send a pull request to a GitHub, GitLab, or Bitbucket repository. + """Send a pull request to a GitHub, GitLab, Bitbucket, or Azure DevOps repository. Args: issue: The issue to send the pull request for @@ -261,7 +262,7 @@ def send_pull_request( target_branch: The target branch to create the pull request against (defaults to repository default branch) 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, "gitlab.com" for GitLab, and "bitbucket.org" for Bitbucket) + base_domain: The base domain for the git server (defaults to "github.com" for GitHub, "gitlab.com" for GitLab, "bitbucket.org" for Bitbucket, and "dev.azure.com" for Azure DevOps) """ if pr_type not in ['branch', 'draft', 'ready']: raise ValueError(f'Invalid pr_type: {pr_type}') @@ -272,6 +273,8 @@ def send_pull_request( base_domain = 'github.com' elif platform == ProviderType.GITLAB: base_domain = 'gitlab.com' + elif platform == ProviderType.AZURE_DEVOPS: + base_domain = 'dev.azure.com' else: # platform == ProviderType.BITBUCKET base_domain = 'bitbucket.org' @@ -294,6 +297,13 @@ def send_pull_request( ), None, ) + elif platform == ProviderType.AZURE_DEVOPS: + # For Azure DevOps, owner is "organization/project" + organization, project = issue.owner.split('/') + handler = ServiceContextIssue( + AzureDevOpsIssueHandler(token, organization, project, issue.repo), + None, + ) else: raise ValueError(f'Unsupported platform: {platform}') @@ -413,13 +423,19 @@ def update_existing_pull_request( llm_config: The LLM configuration to use for summarizing changes. comment_message: The main message to post as a comment on the PR. additional_message: The additional messages to post as a comment on the PR in json list format. - 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 "dev.azure.com" for Azure DevOps) """ # Set up headers and base URL for GitHub or GitLab API # Determine default base_domain based on platform if base_domain is None: - base_domain = 'github.com' if platform == ProviderType.GITHUB else 'gitlab.com' + base_domain = ( + 'github.com' + if platform == ProviderType.GITHUB + else 'gitlab.com' + if platform == ProviderType.GITLAB + else 'dev.azure.com' + ) handler = None if platform == ProviderType.GITHUB: @@ -427,7 +443,14 @@ def update_existing_pull_request( GithubIssueHandler(issue.owner, issue.repo, token, username, base_domain), llm_config, ) - else: # platform == Platform.GITLAB + elif platform == ProviderType.AZURE_DEVOPS: + # For Azure DevOps, owner is "organization/project" + organization, project = issue.owner.split('/') + handler = ServiceContextIssue( + AzureDevOpsIssueHandler(token, organization, project, issue.repo), + llm_config, + ) + else: # platform == ProviderType.GITLAB handler = ServiceContextIssue( GitlabIssueHandler(issue.owner, issue.repo, token, username, base_domain), llm_config, @@ -519,7 +542,13 @@ def process_single_issue( ) -> None: # Determine default base_domain based on platform if base_domain is None: - base_domain = 'github.com' if platform == ProviderType.GITHUB else 'gitlab.com' + base_domain = ( + 'github.com' + if platform == ProviderType.GITHUB + else 'gitlab.com' + if platform == ProviderType.GITLAB + else 'dev.azure.com' + ) if not resolver_output.success and not send_on_failure: logger.info( f'Issue {resolver_output.issue.number} was not successfully resolved. Skipping PR creation.' @@ -587,7 +616,7 @@ def process_single_issue( def main() -> None: parser = argparse.ArgumentParser( - description='Send a pull request to Github or Gitlab.' + description='Send a pull request to Github, Gitlab, or Azure DevOps.' ) parser.add_argument( '--selected-repo', @@ -664,7 +693,7 @@ def main() -> None: parser.add_argument( '--reviewer', type=str, - help='GitHub or GitLab username of the person to request review from', + help='GitHub, GitLab, or Azure DevOps username of the person to request review from', default=None, ) parser.add_argument( @@ -677,7 +706,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 "dev.azure.com" for Azure DevOps)', ) parser.add_argument( '--git-user-name', @@ -693,10 +722,15 @@ def main() -> None: ) my_args = parser.parse_args() - token = my_args.token or os.getenv('GITHUB_TOKEN') or os.getenv('GITLAB_TOKEN') + token = ( + my_args.token + or os.getenv('GITHUB_TOKEN') + or os.getenv('GITLAB_TOKEN') + or os.getenv('AZURE_DEVOPS_TOKEN') + ) if not token: raise ValueError( - 'token is not set, set via --token or GITHUB_TOKEN or GITLAB_TOKEN environment variable.' + 'token is not set, set via --token or GITHUB_TOKEN, GITLAB_TOKEN, or AZURE_DEVOPS_TOKEN environment variable.' ) username = my_args.username if my_args.username else os.getenv('GIT_USERNAME') diff --git a/openhands/resolver/utils.py b/openhands/resolver/utils.py index 527727fba3..cc92223937 100644 --- a/openhands/resolver/utils.py +++ b/openhands/resolver/utils.py @@ -16,7 +16,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, GitLab, or Bitbucket. + """Identifies whether a token belongs to GitHub, GitLab, Bitbucket, or Azure DevOps. Parameters: token (str): The personal access token to check. base_domain (str): Custom base domain for provider (e.g GitHub Enterprise) diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index 2c271cc8f1..5eb5429f71 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -700,6 +700,29 @@ fi # This is a safe fallback since we'll just use the default .openhands return False + def _is_azure_devops_repository(self, repo_name: str) -> bool: + """Check if a repository is hosted on Azure DevOps. + + Args: + repo_name: Repository name (e.g., "org/project/repo") + + Returns: + True if the repository is hosted on Azure DevOps, False otherwise + """ + try: + provider_handler = ProviderHandler( + self.git_provider_tokens or MappingProxyType({}) + ) + repository = call_async_from_sync( + provider_handler.verify_repo_provider, + GENERAL_TIMEOUT, + repo_name, + ) + return repository.git_provider == ProviderType.AZURE_DEVOPS + except Exception: + # If we can't determine the provider, assume it's not Azure DevOps + return False + def get_microagents_from_org_or_user( self, selected_repository: str ) -> list[BaseMicroagent]: @@ -713,6 +736,9 @@ fi since GitLab doesn't support repository names starting with non-alphanumeric characters. + For Azure DevOps repositories, it will use org/openhands-config/openhands-config + format to match Azure DevOps's three-part repository structure (org/project/repo). + Args: selected_repository: The repository path (e.g., "github.com/acme-co/api") @@ -735,24 +761,35 @@ fi ) return loaded_microagents - # Extract the domain and org/user name - org_name = repo_parts[-2] + # Determine repository type + is_azure_devops = self._is_azure_devops_repository(selected_repository) + is_gitlab = self._is_gitlab_repository(selected_repository) + + # Extract the org/user name + # Azure DevOps format: org/project/repo (3 parts) - extract org (first part) + # GitHub/GitLab/Bitbucket format: owner/repo (2 parts) - extract owner (first part) + if is_azure_devops and len(repo_parts) >= 3: + org_name = repo_parts[0] # Get org from org/project/repo + else: + org_name = repo_parts[-2] # Get owner from owner/repo + self.log( 'info', f'Extracted org/user name: {org_name}', ) - - # Determine if this is a GitLab repository - is_gitlab = self._is_gitlab_repository(selected_repository) self.log( 'debug', - f'Repository type detection - is_gitlab: {is_gitlab}', + f'Repository type detection - is_gitlab: {is_gitlab}, is_azure_devops: {is_azure_devops}', ) - # For GitLab, use openhands-config (since .openhands is not a valid repo name) + # For GitLab and Azure DevOps, use openhands-config (since .openhands is not a valid repo name) # For other providers, use .openhands if is_gitlab: org_openhands_repo = f'{org_name}/openhands-config' + elif is_azure_devops: + # Azure DevOps format: org/project/repo + # For org-level config, use: org/openhands-config/openhands-config + org_openhands_repo = f'{org_name}/openhands-config/openhands-config' else: org_openhands_repo = f'{org_name}/.openhands' diff --git a/openhands/server/routes/git.py b/openhands/server/routes/git.py index d5db83999a..1401bb0dcd 100644 --- a/openhands/server/routes/git.py +++ b/openhands/server/routes/git.py @@ -55,6 +55,8 @@ async def get_user_installations( return await client.get_github_installations() elif provider == ProviderType.BITBUCKET: return await client.get_bitbucket_workspaces() + elif provider == ProviderType.AZURE_DEVOPS: + return await client.get_azure_devops_organizations() else: return JSONResponse( content=f"Provider {provider} doesn't support installations", diff --git a/openhands/server/routes/mcp.py b/openhands/server/routes/mcp.py index b6426bffb1..929c66af5b 100644 --- a/openhands/server/routes/mcp.py +++ b/openhands/server/routes/mcp.py @@ -8,6 +8,9 @@ from fastmcp.server.dependencies import get_http_request from pydantic import Field from openhands.core.logger import openhands_logger as logger +from openhands.integrations.azure_devops.azure_devops_service import ( + AzureDevOpsServiceImpl, +) from openhands.integrations.bitbucket.bitbucket_service import BitBucketServiceImpl from openhands.integrations.github.github_service import GithubServiceImpl from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl @@ -286,3 +289,70 @@ async def create_bitbucket_pr( raise ToolError(str(error)) return response + + +@mcp_server.tool() +async def create_azure_devops_pr( + repo_name: Annotated[ + str, Field(description='Azure DevOps repository (organization/project/repo)') + ], + 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 Azure DevOps""" + logger.info('Calling OpenHands MCP create_azure_devops_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) + + azure_devops_token = ( + provider_tokens.get(ProviderType.AZURE_DEVOPS, ProviderToken()) + if provider_tokens + else ProviderToken() + ) + + azure_devops_service = AzureDevOpsServiceImpl( + user_id=azure_devops_token.user_id, + external_auth_id=user_id, + external_auth_token=access_token, + token=azure_devops_token.token, + base_domain=azure_devops_token.host, + ) + + try: + description = await get_conversation_link( + azure_devops_service, conversation_id, description or '' + ) + except Exception as e: + logger.warning(f'Failed to append conversation link: {e}') + + try: + response = await azure_devops_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 diff --git a/openhands/utils/README.md b/openhands/utils/README.md index 634a9b1424..37b208dcf5 100644 --- a/openhands/utils/README.md +++ b/openhands/utils/README.md @@ -57,6 +57,7 @@ OpenHands provides several components that can be extended: 3. Service Integrations: - GitHub service - GitLab service + - Azure DevOps service ### Implementation Details diff --git a/openhands/utils/import_utils.py b/openhands/utils/import_utils.py index 905904c3ff..de2bd0ca85 100644 --- a/openhands/utils/import_utils.py +++ b/openhands/utils/import_utils.py @@ -66,7 +66,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, Bitbucket services) + - Service integrations (GitHub, GitLab, Bitbucket, Azure DevOps services) The implementation is cached to avoid repeated imports of the same class. """ diff --git a/skills/azure_devops.md b/skills/azure_devops.md new file mode 100644 index 0000000000..5064cc9bd4 --- /dev/null +++ b/skills/azure_devops.md @@ -0,0 +1,52 @@ +--- +name: azure_devops +type: knowledge +version: 1.0.0 +agent: CodeActAgent +triggers: +- azure_devops +- azure +--- + +You have access to an environment variable, `AZURE_DEVOPS_TOKEN`, which allows you to interact with +the Azure DevOps API. + + +You can use `curl` with the `AZURE_DEVOPS_TOKEN` to interact with Azure DevOps's API. +ALWAYS use the Azure DevOps API for operations instead of a web browser. + + +If you encounter authentication issues when pushing to Azure DevOps (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://${AZURE_DEVOPS_TOKEN}@dev.azure.com/organization/project/_git/repository` + +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. +* 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 +``` + +## Azure DevOps API Usage + +When working with Azure DevOps API, you need to use Basic authentication with your Personal Access Token (PAT). The username is ignored (empty string), and the password is the PAT. + +Here's how to authenticate with curl: +```bash +# Convert PAT to base64 +AUTH=$(echo -n ":$AZURE_DEVOPS_TOKEN" | base64) + +# Make API call +curl -H "Authorization: Basic $AUTH" -H "Content-Type: application/json" https://dev.azure.com/{organization}/{project}/_apis/git/repositories?api-version=7.1 +``` + +Common API endpoints: +- List repositories: `https://dev.azure.com/{organization}/{project}/_apis/git/repositories?api-version=7.1` +- Get repository details: `https://dev.azure.com/{organization}/{project}/_apis/git/repositories/{repositoryId}?api-version=7.1` +- List pull requests: `https://dev.azure.com/{organization}/{project}/_apis/git/pullrequests?api-version=7.1` +- Create pull request: `https://dev.azure.com/{organization}/{project}/_apis/git/repositories/{repositoryId}/pullrequests?api-version=7.1` (POST) diff --git a/tests/unit/resolver/test_issue_handler_factory.py b/tests/unit/resolver/test_issue_handler_factory.py index 1e994bfa71..12932a1282 100644 --- a/tests/unit/resolver/test_issue_handler_factory.py +++ b/tests/unit/resolver/test_issue_handler_factory.py @@ -3,6 +3,7 @@ from pydantic import SecretStr from openhands.core.config import LLMConfig from openhands.integrations.provider import ProviderType +from openhands.resolver.interfaces.azure_devops import AzureDevOpsIssueHandler from openhands.resolver.interfaces.github import GithubIssueHandler, GithubPRHandler from openhands.resolver.interfaces.gitlab import GitlabIssueHandler, GitlabPRHandler from openhands.resolver.interfaces.issue_definitions import ( @@ -32,28 +33,50 @@ def factory_params(llm_config): } +@pytest.fixture +def azure_factory_params(llm_config): + return { + 'owner': 'test-org/test-project', + 'repo': 'test-repo', + 'token': 'test-token', + 'username': 'test-user', + 'base_domain': 'dev.azure.com', + 'llm_config': llm_config, + } + + test_cases = [ - # platform, issue_type, expected_context_type, expected_handler_type - (ProviderType.GITHUB, 'issue', ServiceContextIssue, GithubIssueHandler), - (ProviderType.GITHUB, 'pr', ServiceContextPR, GithubPRHandler), - (ProviderType.GITLAB, 'issue', ServiceContextIssue, GitlabIssueHandler), - (ProviderType.GITLAB, 'pr', ServiceContextPR, GitlabPRHandler), + # platform, issue_type, expected_context_type, expected_handler_type, use_azure_params + (ProviderType.GITHUB, 'issue', ServiceContextIssue, GithubIssueHandler, False), + (ProviderType.GITHUB, 'pr', ServiceContextPR, GithubPRHandler, False), + (ProviderType.GITLAB, 'issue', ServiceContextIssue, GitlabIssueHandler, False), + (ProviderType.GITLAB, 'pr', ServiceContextPR, GitlabPRHandler, False), + ( + ProviderType.AZURE_DEVOPS, + 'issue', + ServiceContextIssue, + AzureDevOpsIssueHandler, + True, + ), + (ProviderType.AZURE_DEVOPS, 'pr', ServiceContextPR, AzureDevOpsIssueHandler, True), ] @pytest.mark.parametrize( - 'platform,issue_type,expected_context_type,expected_handler_type', test_cases + 'platform,issue_type,expected_context_type,expected_handler_type,use_azure_params', + test_cases, ) def test_handler_creation( factory_params, + azure_factory_params, platform: ProviderType, issue_type: str, expected_context_type: type, expected_handler_type: type, + use_azure_params: bool, ): - factory = IssueHandlerFactory( - **factory_params, platform=platform, issue_type=issue_type - ) + params = azure_factory_params if use_azure_params else factory_params + factory = IssueHandlerFactory(**params, platform=platform, issue_type=issue_type) handler = factory.create() diff --git a/tests/unit/runtime/test_runtime_git_tokens.py b/tests/unit/runtime/test_runtime_git_tokens.py index 4b4d27650b..2d5f9ec40d 100644 --- a/tests/unit/runtime/test_runtime_git_tokens.py +++ b/tests/unit/runtime/test_runtime_git_tokens.py @@ -147,9 +147,11 @@ def runtime(temp_dir): return runtime -def mock_repo_and_patch(monkeypatch, provider=ProviderType.GITHUB, is_public=True): +def mock_repo_and_patch( + monkeypatch, provider=ProviderType.GITHUB, is_public=True, full_name='owner/repo' +): repo = Repository( - id='123', full_name='owner/repo', git_provider=provider, is_public=is_public + id='123', full_name=full_name, git_provider=provider, is_public=is_public ) async def mock_verify_repo_provider(*_args, **_kwargs): @@ -216,11 +218,14 @@ async def test_export_latest_git_provider_tokens_success(runtime): async def test_export_latest_git_provider_tokens_multiple_refs(temp_dir): """Test token export with multiple token references""" config = OpenHandsConfig() - # Initialize with both GitHub and GitLab tokens + # Initialize with GitHub, GitLab, and Azure DevOps tokens git_provider_tokens = MappingProxyType( { ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')), ProviderType.GITLAB: ProviderToken(token=SecretStr('gitlab_token')), + ProviderType.AZURE_DEVOPS: ProviderToken( + token=SecretStr('azure_devops_token') + ), } ) file_store = get_file_store('local', temp_dir) @@ -234,15 +239,18 @@ async def test_export_latest_git_provider_tokens_multiple_refs(temp_dir): ) # Create a command that references multiple tokens - cmd = CmdRunAction(command='echo $GITHUB_TOKEN && echo $GITLAB_TOKEN') + cmd = CmdRunAction( + command='echo $GITHUB_TOKEN && echo $GITLAB_TOKEN && echo $AZURE_DEVOPS_TOKEN' + ) # Export the tokens await runtime._export_latest_git_provider_tokens(cmd) - # Verify that both tokens were exported + # Verify that all tokens were exported assert event_stream.secrets == { 'github_token': 'github_token', 'gitlab_token': 'gitlab_token', + 'azure_devops_token': 'azure_devops_token', } @@ -478,6 +486,57 @@ async def test_clone_or_init_repo_gitlab_with_token(temp_dir, monkeypatch): assert result == 'repo' +@pytest.mark.asyncio +async def test_clone_or_init_repo_azure_devops_with_token(temp_dir, monkeypatch): + """Test cloning Azure DevOps repository with token""" + config = OpenHandsConfig() + + # Set up Azure DevOps token + azure_devops_token = 'azure_devops_test_token' + git_provider_tokens = MappingProxyType( + {ProviderType.AZURE_DEVOPS: ProviderToken(token=SecretStr(azure_devops_token))} + ) + + file_store = get_file_store('local', temp_dir) + event_stream = EventStream('abc', file_store) + runtime = MockRuntime( + config=config, + event_stream=event_stream, + user_id='test_user', + git_provider_tokens=git_provider_tokens, + ) + + # Mock the repository to be Azure DevOps with 3-part format: org/project/repo + azure_repo_name = 'testorg/testproject/testrepo' + mock_repo_and_patch( + monkeypatch, provider=ProviderType.AZURE_DEVOPS, full_name=azure_repo_name + ) + + # Call the method with Azure DevOps 3-part format: org/project/repo + result = await runtime.clone_or_init_repo( + git_provider_tokens=git_provider_tokens, + selected_repository=azure_repo_name, + selected_branch=None, + ) + + # Check that the first command is the git clone with the correct URL format with token + # Azure DevOps uses Basic auth format: https://org:token@dev.azure.com/org/project/_git/repo + clone_cmd = runtime.run_action_calls[0].command + expected_repo_path = str(runtime.workspace_root / 'testrepo') + assert ( + f'https://testorg:{azure_devops_token}@dev.azure.com/testorg/testproject/_git/testrepo' + in clone_cmd + ) + assert expected_repo_path in clone_cmd + + # Check that the second command is the checkout + checkout_cmd = runtime.run_action_calls[1].command + assert f'cd {expected_repo_path}' in checkout_cmd + assert 'git checkout -b openhands-workspace-' in checkout_cmd + + assert result == 'testrepo' + + @pytest.mark.asyncio async def test_clone_or_init_repo_with_branch(temp_dir, monkeypatch): """Test cloning a repository with a specified branch""" diff --git a/tests/unit/runtime/test_runtime_gitlab_microagents.py b/tests/unit/runtime/test_runtime_gitlab_microagents.py index c4a108386b..8226fde19b 100644 --- a/tests/unit/runtime/test_runtime_gitlab_microagents.py +++ b/tests/unit/runtime/test_runtime_gitlab_microagents.py @@ -238,16 +238,19 @@ def test_get_microagents_from_org_or_user_github(temp_workspace): # Mock the provider detection to return GitHub with patch.object(runtime, '_is_gitlab_repository', return_value=False): - # Mock the _get_authenticated_git_url to simulate failure (no org repo) - with patch('openhands.runtime.base.call_async_from_sync') as mock_async: - mock_async.side_effect = Exception('Repository not found') + with patch.object(runtime, '_is_azure_devops_repository', return_value=False): + # Mock the _get_authenticated_git_url to simulate failure (no org repo) + with patch('openhands.runtime.base.call_async_from_sync') as mock_async: + mock_async.side_effect = Exception('Repository not found') - result = runtime.get_microagents_from_org_or_user('github.com/owner/repo') + result = runtime.get_microagents_from_org_or_user( + 'github.com/owner/repo' + ) - # Should only try .openhands, not openhands-config - assert len(result) == 0 - # Check that only one attempt was made (for .openhands) - assert mock_async.call_count == 1 + # Should only try .openhands, not openhands-config + assert len(result) == 0 + # Check that only one attempt was made (for .openhands) + assert mock_async.call_count == 1 def test_get_microagents_from_org_or_user_gitlab_success_with_config(temp_workspace): @@ -260,16 +263,21 @@ def test_get_microagents_from_org_or_user_gitlab_success_with_config(temp_worksp # Mock the provider detection to return GitLab with patch.object(runtime, '_is_gitlab_repository', return_value=True): - # Mock successful cloning for openhands-config - with patch('openhands.runtime.base.call_async_from_sync') as mock_async: - mock_async.return_value = 'https://gitlab.com/owner/openhands-config.git' + with patch.object(runtime, '_is_azure_devops_repository', return_value=False): + # Mock successful cloning for openhands-config + with patch('openhands.runtime.base.call_async_from_sync') as mock_async: + mock_async.return_value = ( + 'https://gitlab.com/owner/openhands-config.git' + ) - result = runtime.get_microagents_from_org_or_user('gitlab.com/owner/repo') + result = runtime.get_microagents_from_org_or_user( + 'gitlab.com/owner/repo' + ) - # Should succeed with openhands-config - assert len(result) >= 0 # May be empty if no microagents found - # Should only try once for openhands-config - assert mock_async.call_count == 1 + # Should succeed with openhands-config + assert len(result) >= 0 # May be empty if no microagents found + # Should only try once for openhands-config + assert mock_async.call_count == 1 def test_get_microagents_from_org_or_user_gitlab_failure(temp_workspace): @@ -278,16 +286,19 @@ def test_get_microagents_from_org_or_user_gitlab_failure(temp_workspace): # Mock the provider detection to return GitLab with patch.object(runtime, '_is_gitlab_repository', return_value=True): - # Mock the _get_authenticated_git_url to fail for openhands-config - with patch('openhands.runtime.base.call_async_from_sync') as mock_async: - mock_async.side_effect = Exception('openhands-config not found') + with patch.object(runtime, '_is_azure_devops_repository', return_value=False): + # Mock the _get_authenticated_git_url to fail for openhands-config + with patch('openhands.runtime.base.call_async_from_sync') as mock_async: + mock_async.side_effect = Exception('openhands-config not found') - result = runtime.get_microagents_from_org_or_user('gitlab.com/owner/repo') + result = runtime.get_microagents_from_org_or_user( + 'gitlab.com/owner/repo' + ) - # Should return empty list when repository doesn't exist - assert len(result) == 0 - # Should only try once for openhands-config - assert mock_async.call_count == 1 + # Should return empty list when repository doesn't exist + assert len(result) == 0 + # Should only try once for openhands-config + assert mock_async.call_count == 1 def test_get_microagents_from_selected_repo_gitlab_uses_openhands(temp_workspace): diff --git a/tests/unit/test_azure_devops.py b/tests/unit/test_azure_devops.py new file mode 100644 index 0000000000..32d275c91f --- /dev/null +++ b/tests/unit/test_azure_devops.py @@ -0,0 +1,127 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from openhands.integrations.azure_devops.azure_devops_service import ( + AzureDevOpsServiceImpl as AzureDevOpsService, +) +from openhands.integrations.service_types import ProviderType + + +@pytest.mark.asyncio +async def test_azure_devops_service_init(): + """Test that the Azure DevOps service initializes correctly.""" + service = AzureDevOpsService( + user_id='test_user', + token=None, + base_domain='myorg', + ) + + assert service.organization == 'myorg' + assert service.provider == ProviderType.AZURE_DEVOPS.value + + +@pytest.mark.asyncio +async def test_azure_devops_get_repositories(): + """Test that the Azure DevOps service can get repositories.""" + with patch('httpx.AsyncClient') as mock_client: + # Mock the response for projects + mock_projects_response = MagicMock() + mock_projects_response.json.return_value = { + 'value': [ + {'name': 'Project1'}, + ] + } + mock_projects_response.raise_for_status = AsyncMock() + + # Mock the response for repositories + mock_repos_response = MagicMock() + mock_repos_response.json.return_value = { + 'value': [ + { + 'id': 'repo1', + 'name': 'Repo1', + 'project': {'name': 'Project1'}, + 'lastUpdateTime': '2023-01-01T00:00:00Z', + }, + { + 'id': 'repo2', + 'name': 'Repo2', + 'project': {'name': 'Project1'}, + 'lastUpdateTime': '2023-01-02T00:00:00Z', + }, + ] + } + mock_repos_response.raise_for_status = AsyncMock() + + # Set up the mock client to return our mock responses + # First call: get projects, Second call: get repos for Project1 + mock_client_instance = MagicMock() + mock_client_instance.get = AsyncMock( + side_effect=[ + mock_projects_response, + mock_repos_response, + ] + ) + mock_client.return_value.__aenter__.return_value = mock_client_instance + + # Create the service and call get_repositories + service = AzureDevOpsService( + user_id='test_user', + token=None, + base_domain='myorg', + ) + + # Mock the _get_azure_devops_headers method + service._get_azure_devops_headers = AsyncMock(return_value={}) + + # Call the method + repos = await service.get_repositories('updated', None) + + # Verify the results (sorted by lastUpdateTime descending, so repo2 first) + assert len(repos) == 2 + assert repos[0].id == 'repo2' + assert repos[0].full_name == 'myorg/Project1/Repo2' + assert repos[0].git_provider == ProviderType.AZURE_DEVOPS + assert repos[1].id == 'repo1' + assert repos[1].full_name == 'myorg/Project1/Repo1' + assert repos[1].git_provider == ProviderType.AZURE_DEVOPS + + +@pytest.mark.asyncio +async def test_azure_devops_get_repository_details(): + """Test that the Azure DevOps service can get repository details.""" + with patch('httpx.AsyncClient') as mock_client: + # Mock the response + mock_response = MagicMock() + mock_response.json.return_value = { + 'id': 'repo1', + 'name': 'Repo1', + 'project': {'name': 'Project1'}, + } + mock_response.raise_for_status = AsyncMock() + + # Set up the mock client to return our mock response + mock_client_instance = MagicMock() + mock_client_instance.get = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value = mock_client_instance + + # Create the service and call get_repository_details_from_repo_name + service = AzureDevOpsService( + user_id='test_user', + token=None, + base_domain='myorg', + ) + + # Mock the _get_azure_devops_headers method + service._get_azure_devops_headers = AsyncMock(return_value={}) + + # Call the method + repo = await service.get_repository_details_from_repo_name( + 'myorg/Project1/Repo1' + ) + + # Verify the results + assert repo.id == 'repo1' + assert repo.full_name == 'myorg/Project1/Repo1' + assert repo.git_provider == ProviderType.AZURE_DEVOPS