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

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