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
58 changed files with 3108 additions and 96 deletions

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