mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-08 22:38:05 -05:00
feat: add Azure DevOps integration support (#11243)
Co-authored-by: Graham Neubig <neubig@gmail.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
127
tests/unit/test_azure_devops.py
Normal file
127
tests/unit/test_azure_devops.py
Normal 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
|
||||
Reference in New Issue
Block a user