Wan Arif 3504ca7752
feat: add Azure DevOps integration support (#11243)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-11-22 14:00:24 -05:00

224 lines
10 KiB
Python

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