mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
feat: add Azure DevOps integration support (#11243)
Co-authored-by: Graham Neubig <neubig@gmail.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
249
openhands/integrations/azure_devops/azure_devops_service.py
Normal file
249
openhands/integrations/azure_devops/azure_devops_service.py
Normal 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
|
||||
)
|
||||
1
openhands/integrations/azure_devops/service/__init__.py
Normal file
1
openhands/integrations/azure_devops/service/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Azure DevOps Service mixins
|
||||
67
openhands/integrations/azure_devops/service/base.py
Normal file
67
openhands/integrations/azure_devops/service/base.py
Normal 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='')
|
||||
195
openhands/integrations/azure_devops/service/branches.py
Normal file
195
openhands/integrations/azure_devops/service/branches.py
Normal 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 []
|
||||
223
openhands/integrations/azure_devops/service/features.py
Normal file
223
openhands/integrations/azure_devops/service/features.py
Normal 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
|
||||
321
openhands/integrations/azure_devops/service/prs.py
Normal file
321
openhands/integrations/azure_devops/service/prs.py
Normal 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'
|
||||
)
|
||||
178
openhands/integrations/azure_devops/service/repos.py
Normal file
178
openhands/integrations/azure_devops/service/repos.py
Normal 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
|
||||
)
|
||||
166
openhands/integrations/azure_devops/service/resolver.py
Normal file
166
openhands/integrations/azure_devops/service/resolver.py
Normal 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 []
|
||||
129
openhands/integrations/azure_devops/service/work_items.py
Normal file
129
openhands/integrations/azure_devops/service/work_items.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -21,6 +21,7 @@ class ProviderType(Enum):
|
||||
GITHUB = 'github'
|
||||
GITLAB = 'gitlab'
|
||||
BITBUCKET = 'bitbucket'
|
||||
AZURE_DEVOPS = 'azure_devops'
|
||||
ENTERPRISE_SSO = 'enterprise_sso'
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
{% if issue_comment %}
|
||||
{{ issue_comment }}
|
||||
{% else %}
|
||||
Please fix work item #{{ issue_number }}.
|
||||
{% endif %}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
{{ pr_comment }}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
427
openhands/resolver/interfaces/azure_devops.py
Normal file
427
openhands/resolver/interfaces/azure_devops.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -57,6 +57,7 @@ OpenHands provides several components that can be extended:
|
||||
3. Service Integrations:
|
||||
- GitHub service
|
||||
- GitLab service
|
||||
- Azure DevOps service
|
||||
|
||||
### Implementation Details
|
||||
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user