mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
322 lines
12 KiB
Python
322 lines
12 KiB
Python
"""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'
|
|
)
|