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