feat: add Azure DevOps integration support (#11243)

Co-authored-by: Graham Neubig <neubig@gmail.com>
This commit is contained in:
Wan Arif 2025-11-23 03:00:24 +08:00 committed by GitHub
parent 1e513ad63f
commit 3504ca7752
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 3108 additions and 96 deletions

View File

@ -71,6 +71,7 @@ beforeEach(() => {
provider_tokens_set: {
github: "some-token",
gitlab: null,
azure_devops: null,
},
});
});

View File

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

View File

@ -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: "" },
});
});

View File

@ -7,6 +7,7 @@ describe("convertRawProvidersToList", () => {
const example1: Partial<Record<Provider, string | null>> | undefined = {
github: "test-token",
gitlab: "test-token",
azure_devops: "test-token",
};
const example2: Partial<Record<Provider, string | null>> | undefined = {
github: "",
@ -14,9 +15,13 @@ describe("convertRawProvidersToList", () => {
const example3: Partial<Record<Provider, string | null>> | undefined = {
gitlab: null,
};
const example4: Partial<Record<Provider, string | null>> | 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"]);
});
});

View File

@ -0,0 +1 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="m22 18-5 4-8-3v3l-4.19-5.75 12.91 1.05v-10.96l4.28-.69zm-17.19-1.75v-7.29l12.91-2.62-7.12-4.34v2.84l-6.63 1.92-1.97 2.62v5.69z"/></svg>

After

Width:  |  Height:  |  Size: 248 B

View File

@ -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<Provider, IconType> = {
const providerIcon: Partial<Record<Provider, IconType>> = {
bitbucket: FaBitbucket,
github: FaGithub,
gitlab: FaGitlab,
@ -26,6 +27,9 @@ export function ConversationRepoLink({
<div className="flex items-center gap-3 flex-1">
<div className="flex items-center gap-1">
{Icon && <Icon size={14} className="text-[#A3A3A3]" />}
{selectedRepository.git_provider === "azure_devops" && (
<AzureDevOpsLogo className="text-[#A3A3A3] w-[14px] h-[14px]" />
)}
<span
data-testid="conversation-card-selected-repository"
className="text-xs text-[#A3A3A3] whitespace-nowrap overflow-hidden text-ellipsis max-w-44"

View File

@ -51,6 +51,8 @@ export function GitProviderDropdown({
return "GitLab";
case "bitbucket":
return "Bitbucket";
case "azure_devops":
return "Azure DevOps";
case "enterprise_sso":
return "Enterprise SSO";
default:

View File

@ -56,6 +56,15 @@ export function TaskCard({ task }: TaskCardProps) {
const issueType =
task.task_type === "OPEN_ISSUE" ? "issues" : "pull-requests";
href = `https://bitbucket.org/${task.repo}/${issueType}/${task.issue_number}`;
} else if (task.git_provider === "azure_devops") {
// Azure DevOps URL format: https://dev.azure.com/{organization}/{project}/_workitems/edit/{id}
// or https://dev.azure.com/{organization}/{project}/_git/{repo}/pullrequest/{id}
const azureDevOpsBaseUrl = "https://dev.azure.com";
if (task.task_type === "OPEN_ISSUE") {
href = `${azureDevOpsBaseUrl}/${task.repo}/_workitems/edit/${task.issue_number}`;
} else {
href = `${azureDevOpsBaseUrl}/${task.repo}/_git/${task.repo.split("/")[1]}/pullrequest/${task.issue_number}`;
}
} else {
const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull";
href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`;

View File

@ -0,0 +1,20 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function AzureDevOpsTokenHelpAnchor() {
const { t } = useTranslation();
return (
<p data-testid="azure-devops-token-help-anchor" className="text-xs">
<a
href="https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
aria-label={t(I18nKey.GIT$AZURE_DEVOPS_TOKEN_HELP)}
>
{t(I18nKey.GIT$AZURE_DEVOPS_TOKEN_HELP)}
</a>
</p>
);
}

View File

@ -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 (
<div className="flex flex-col gap-6">
<SettingsInput
testId={name}
name={name}
onChange={onChange}
label={t(I18nKey.GIT$AZURE_DEVOPS_TOKEN)}
type="password"
className="w-full max-w-[680px]"
placeholder={isAzureDevOpsTokenSet ? "<hidden>" : ""}
startContent={
isAzureDevOpsTokenSet && (
<KeyStatusIcon
testId="azure-devops-set-token-indicator"
isSet={isAzureDevOpsTokenSet}
/>
)
}
/>
<SettingsInput
onChange={onAzureDevOpsHostChange || (() => {})}
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() !== "" && (
<KeyStatusIcon testId="azure-devops-set-host-indicator" isSet />
)
}
/>
<AzureDevOpsTokenHelpAnchor />
</div>
);
}

View File

@ -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 (
<div data-testid="configure-azure-devops-button" className="py-9">
<BrandButton
type="button"
variant="primary"
className="w-55"
onClick={handleOAuthFlow}
>
{t(I18nKey.AZURE_DEVOPS$CONNECT_ACCOUNT)}
</BrandButton>
</div>
);
}

View File

@ -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({
</BrandButton>
)}
{showAzureDevOps && (
<BrandButton
type="button"
variant="primary"
onClick={handleAzureDevOpsAuth}
className="w-full font-semibold"
startContent={<AzureDevOpsLogo width={20} height={20} />}
>
{t(I18nKey.AZURE_DEVOPS$CONNECT_ACCOUNT)}
</BrandButton>
)}
{showEnterpriseSso && (
<BrandButton
type="button"

View File

@ -1,5 +1,6 @@
import { FaBitbucket, FaGithub, FaGitlab } from "react-icons/fa6";
import { Provider } from "#/types/settings";
import AzureDevOpsLogo from "#/assets/branding/azure-devops-logo.svg?react";
interface GitProviderIconProps {
gitProvider: Provider;
@ -13,8 +14,13 @@ export function GitProviderIcon({
return (
<>
{gitProvider === "github" && <FaGithub size={14} className={className} />}
{gitProvider === "gitlab" && <FaGitlab className={className} />}
{gitProvider === "bitbucket" && <FaBitbucket className={className} />}
{gitProvider === "gitlab" && <FaGitlab size={14} className={className} />}
{gitProvider === "bitbucket" && (
<FaBitbucket size={14} className={className} />
)}
{gitProvider === "azure_devops" && (
<AzureDevOpsLogo className={`${className} w-[14px] h-[14px]`} />
)}
</>
);
}

View File

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

View File

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

View File

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

View File

@ -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<string, { token: string; host: string }> = {
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 && (
<>
<div className="pb-1 mt-6 flex flex-col">
<h3 className="text-xl font-medium text-white">
{t(I18nKey.SETTINGS$AZURE_DEVOPS)}
</h3>
<ConfigureAzureDevOpsAnchor />
</div>
<div className="w-1/2 border-b border-gray-200" />
</>
)}
{shouldRenderExternalConfigureButtons && !isLoading && (
<>
<div className="pb-1 mt-6 flex flex-col">
@ -196,6 +225,20 @@ function GitSettingsScreen() {
bitbucketHostSet={existingBitbucketHost}
/>
)}
{!isSaas && (
<AzureDevOpsTokenInput
name="azure-devops-token-input"
isAzureDevOpsTokenSet={isAzureDevOpsTokenSet}
onChange={(value) => {
setAzureDevOpsTokenInputHasValue(!!value);
}}
onAzureDevOpsHostChange={(value) => {
setAzureDevOpsHostInputHasValue(!!value);
}}
azureDevOpsHostSet={existingAzureDevOpsHost}
/>
)}
</div>
</div>
)}
@ -211,7 +254,10 @@ function GitSettingsScreen() {
type="submit"
variant="secondary"
isDisabled={
!isGitHubTokenSet && !isGitLabTokenSet && !isBitbucketTokenSet
!isGitHubTokenSet &&
!isGitLabTokenSet &&
!isBitbucketTokenSet &&
!isAzureDevOpsTokenSet
}
>
{t(I18nKey.GIT$DISCONNECT_TOKENS)}

View File

@ -2,6 +2,7 @@ export const ProviderOptions = {
github: "github",
gitlab: "gitlab",
bitbucket: "bitbucket",
azure_devops: "azure_devops",
enterprise_sso: "enterprise_sso",
} as const;

View File

@ -1,6 +1,6 @@
/**
* Generates a URL to redirect to for OAuth authentication
* @param identityProvider The identity provider to use (e.g., "github", "gitlab", "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
*/

View File

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

View File

@ -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 "";
}

View File

@ -72,7 +72,7 @@ Your primary role is to assist users by executing commands, modifying code, and
</SECURITY_RISK_ASSESSMENT>
<EXTERNAL_SERVICES>
* 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.
</EXTERNAL_SERVICES>

View File

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

View File

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

View File

@ -0,0 +1 @@
# Azure DevOps Service mixins

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,6 +21,7 @@ class ProviderType(Enum):
GITHUB = 'github'
GITLAB = 'gitlab'
BITBUCKET = 'bitbucket'
AZURE_DEVOPS = 'azure_devops'
ENTERPRISE_SSO = 'enterprise_sso'

View File

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

View File

@ -0,0 +1,5 @@
{% if issue_comment %}
{{ issue_comment }}
{% else %}
Please fix work item #{{ issue_number }}.
{% endif %}

View File

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

View File

@ -0,0 +1 @@
{{ pr_comment }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ from openhands.core.config import LLMConfig
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import ProviderType
from openhands.llm.llm import LLM
from openhands.resolver.interfaces.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')

View File

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

View File

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

View File

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

View File

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

View File

@ -57,6 +57,7 @@ OpenHands provides several components that can be extended:
3. Service Integrations:
- GitHub service
- GitLab service
- Azure DevOps service
### Implementation Details

View File

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

52
skills/azure_devops.md Normal file
View File

@ -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.
<IMPORTANT>
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.
</IMPORTANT>
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)

View File

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

View File

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

View File

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

View File

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