mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-25 21:36:52 +08:00
224 lines
10 KiB
Python
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
|