From 4c0fd57bba6db8ec0baeced3a8b17193dff8728a Mon Sep 17 00:00:00 2001 From: Nam P Tran Date: Sat, 29 Mar 2025 13:58:37 +0700 Subject: [PATCH 01/10] Add more unit test --- pyproject.toml | 1 + .../features/pull_requests/__init__.py | 11 + .../features/pull_requests/common.py | 319 +++++++++ .../features/pull_requests/tools.py | 625 ++++++++++++++++++ tests/features/pull_requests/__init__.py | 3 + tests/features/pull_requests/test_tools.py | 420 ++++++++++++ 6 files changed, 1379 insertions(+) create mode 100644 src/mcp_azure_devops/features/pull_requests/__init__.py create mode 100644 src/mcp_azure_devops/features/pull_requests/common.py create mode 100644 src/mcp_azure_devops/features/pull_requests/tools.py create mode 100644 tests/features/pull_requests/__init__.py create mode 100644 tests/features/pull_requests/test_tools.py diff --git a/pyproject.toml b/pyproject.toml index 8581880..fcea2fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dev = [ "ruff>=0.0.267", "anyio>=3.6.2", "pyright>=1.1.350", + "trio>=0.22.0", ] [tool.setuptools] diff --git a/src/mcp_azure_devops/features/pull_requests/__init__.py b/src/mcp_azure_devops/features/pull_requests/__init__.py new file mode 100644 index 0000000..23fca2d --- /dev/null +++ b/src/mcp_azure_devops/features/pull_requests/__init__.py @@ -0,0 +1,11 @@ +# Pull Requests feature package for Azure DevOps MCP +from mcp_azure_devops.features.work_items import tools + +def register(mcp): + """ + Register all pull requests components with the MCP server. + + Args: + mcp: The FastMCP server instance + """ + tools.register_tools(mcp) diff --git a/src/mcp_azure_devops/features/pull_requests/common.py b/src/mcp_azure_devops/features/pull_requests/common.py new file mode 100644 index 0000000..7a02b2d --- /dev/null +++ b/src/mcp_azure_devops/features/pull_requests/common.py @@ -0,0 +1,319 @@ +""" +Common utilities for Azure DevOps pull request features. + +This module provides shared functionality used by both tools and resources. +""" +from typing import Dict, List, Optional, Any, Union +import base64 +import requests +import json +from urllib.parse import quote +from mcp_azure_devops.utils.azure_client import get_connection + + +class AzureDevOpsClientError(Exception): + """Exception raised for errors in Azure DevOps client operations.""" + pass + + +def get_pull_request_client(): + """ + Get the pull request client for Azure DevOps. + + Returns: + Git client instance + + Raises: + AzureDevOpsClientError: If connection or client creation fails + """ + # Get connection to Azure DevOps + connection = get_connection() + + if not connection: + raise AzureDevOpsClientError( + "Azure DevOps PAT or organization URL not found in environment variables." + ) + + # Get the git client + git_client = connection.clients.get_git_client() + + if git_client is None: + raise AzureDevOpsClientError("Failed to get git client.") + + return git_client + +class AzureDevOpsClient: + """Client for Azure DevOps API operations related to Pull Requests.""" + + def __init__(self, organization: str, project: str, repo: str, personal_access_token: str): + """ + Initialize the Azure DevOps client. + + Args: + organization: Azure DevOps organization name + project: Azure DevOps project name + repo: Azure DevOps repository name + personal_access_token: PAT with appropriate permissions + """ + self.organization = organization + self.project = project + self.repo = repo + self.personal_access_token = personal_access_token + self.base_url = f"https://dev.azure.com/{organization}/{project}/_apis/git/repositories/{repo}" + self.api_version = "api-version=7.1" + self.headers = { + "Content-Type": "application/json", + "Authorization": f"Basic {self._encode_pat(personal_access_token)}" + } + + def _encode_pat(self, pat: str) -> str: + """ + Encode the Personal Access Token for API authentication. + + Args: + pat: Personal Access Token + + Returns: + Encoded PAT for use in Authorization header + """ + return base64.b64encode(f":{pat}".encode()).decode() + + def _handle_response(self, response: requests.Response) -> Dict[str, Any]: + """ + Handle API response and raise appropriate exceptions. + + Args: + response: Response object from requests + + Returns: + JSON response if successful + + Raises: + AzureDevOpsClientError: With appropriate error message on failure + """ + if response.status_code >= 200 and response.status_code < 300: + return response.json() + + error_message = f"API request failed with status code {response.status_code}" + try: + error_details = response.json() + if "message" in error_details: + error_message += f": {error_details['message']}" + except Exception: + error_message += f": {response.text}" + + raise AzureDevOpsClientError(error_message) + + def create_pull_request(self, title: str, description: str, source_branch: str, + target_branch: str, reviewers: Optional[List[str]] = None) -> Dict[str, Any]: + """ + Create a new Pull Request. + + Args: + title: PR title + description: PR description + source_branch: Source branch name + target_branch: Target branch name + reviewers: List of reviewer emails or IDs + + Returns: + Details of the created PR + + Raises: + AzureDevOpsClientError: If request fails + """ + url = f"{self.base_url}/pullrequests?{self.api_version}" + + # Build the request body as a string + json_data = { + "sourceRefName": f"refs/heads/{source_branch}", + "targetRefName": f"refs/heads/{target_branch}", + "title": title, + "description": description + } + + # Add reviewers as a separate step if needed + if reviewers: + reviewer_objects = [] + for reviewer in reviewers: + reviewer_objects.append({"id": str(reviewer)}) + + json_str = json.dumps(json_data) + json_dict = json.loads(json_str) + json_dict["reviewers"] = reviewer_objects + + # Send the request with the modified JSON + response = requests.post(url, headers=self.headers, json=json_dict) + else: + # Send the request without reviewers + response = requests.post(url, headers=self.headers, json=json_data) + + return self._handle_response(response) + + def update_pull_request(self, pull_request_id: int, + update_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Update an existing Pull Request. + + Args: + pull_request_id: ID of the PR to update + update_data: Dictionary of fields to update + + Returns: + Updated PR details + + Raises: + AzureDevOpsClientError: If request fails + """ + url = f"{self.base_url}/pullrequests/{pull_request_id}?{self.api_version}" + + response = requests.patch(url, headers=self.headers, json=update_data) + return self._handle_response(response) + + def get_pull_requests(self, status: Optional[str] = None, + creator: Optional[str] = None, + reviewer: Optional[str] = None, + target_branch: Optional[str] = None) -> List[Dict[str, Any]]: + """ + List Pull Requests with optional filtering. + + Args: + status: Filter by status (active, abandoned, completed, all) + creator: Filter by creator ID + reviewer: Filter by reviewer ID + target_branch: Filter by target branch name + + Returns: + List of matching PRs + + Raises: + AzureDevOpsClientError: If request fails + """ + url = f"{self.base_url}/pullrequests?{self.api_version}" + + params = [] + if status: + params.append(f"searchCriteria.status={status}") + if creator: + params.append(f"searchCriteria.creatorId={creator}") + if reviewer: + params.append(f"searchCriteria.reviewerId={reviewer}") + if target_branch: + params.append(f"searchCriteria.targetRefName=refs/heads/{quote(target_branch)}") + + if params: + url += "&" + "&".join(params) + + response = requests.get(url, headers=self.headers) + result = self._handle_response(response) + return result.get("value", []) + + def get_pull_request(self, pull_request_id: int) -> Dict[str, Any]: + """ + Get details of a specific Pull Request. + + Args: + pull_request_id: ID of the PR + + Returns: + Pull Request details + + Raises: + AzureDevOpsClientError: If request fails + """ + url = f"{self.base_url}/pullrequests/{pull_request_id}?{self.api_version}" + + response = requests.get(url, headers=self.headers) + return self._handle_response(response) + + def add_comment(self, pull_request_id: int, content: str, + comment_thread_id: Optional[int] = None, + parent_comment_id: Optional[int] = None) -> Dict[str, Any]: + """ + Add a comment to a Pull Request. + + Args: + pull_request_id: ID of the PR + content: Comment text + comment_thread_id: ID of existing thread (for replies) + parent_comment_id: ID of parent comment (for replies) + + Returns: + Comment details + + Raises: + AzureDevOpsClientError: If request fails + """ + if comment_thread_id: + # Add to existing thread + url = f"{self.base_url}/pullrequests/{pull_request_id}/threads/{comment_thread_id}/comments?{self.api_version}" + data = { + "content": content + } + if parent_comment_id: + data["parentCommentId"] = str(parent_comment_id) + else: + # Create new thread + url = f"{self.base_url}/pullrequests/{pull_request_id}/threads?{self.api_version}" + data = { + "comments": [{ + "content": content + }], + "status": "active" + } + + response = requests.post(url, headers=self.headers, json=data) + return self._handle_response(response) + + def set_vote(self, pull_request_id: int, vote: int) -> Dict[str, Any]: + """ + Vote on a Pull Request. + + Args: + pull_request_id: ID of the PR + vote: Vote value (10=approve, 5=approve with suggestions, 0=no vote, -5=wait for author, -10=reject) + + Returns: + Updated reviewer details + + Raises: + AzureDevOpsClientError: If request fails + """ + url = f"{self.base_url}/pullrequests/{pull_request_id}/reviewers/me?{self.api_version}" + + data = { + "vote": vote + } + + response = requests.put(url, headers=self.headers, json=data) + return self._handle_response(response) + + def complete_pull_request(self, pull_request_id: int, + merge_strategy: str = "squash", + delete_source_branch: bool = False) -> Dict[str, Any]: + """ + Complete (merge) a Pull Request. + + Args: + pull_request_id: ID of the PR + merge_strategy: Strategy to use (squash, rebase, rebaseMerge, merge) + delete_source_branch: Whether to delete source branch after merge + + Returns: + Completed PR details + + Raises: + AzureDevOpsClientError: If request fails + """ + url = f"{self.base_url}/pullrequests/{pull_request_id}?{self.api_version}" + + data = { + "status": "completed", + "completionOptions": { + "mergeStrategy": merge_strategy, + "deleteSourceBranch": delete_source_branch + } + } + + response = requests.patch(url, headers=self.headers, json=data) + return self._handle_response(response) \ No newline at end of file diff --git a/src/mcp_azure_devops/features/pull_requests/tools.py b/src/mcp_azure_devops/features/pull_requests/tools.py new file mode 100644 index 0000000..77dda9e --- /dev/null +++ b/src/mcp_azure_devops/features/pull_requests/tools.py @@ -0,0 +1,625 @@ +""" +Pull request tools for Azure DevOps. + +This module provides MCP tools for working with Azure DevOps pull requests. +""" +from typing import Dict, List, Optional, Any +from mcp_azure_devops.features.pull_requests.common import AzureDevOpsClient, AzureDevOpsClientError + + +def _format_pull_request(pr: Dict[str, Any]) -> str: + """ + Format pull request information. + + Args: + pr: Pull request object to format + + Returns: + String with pull request details + """ + formatted_info = [f"# Pull Request: {pr.get('title')}"] + formatted_info.append(f"ID: {pr.get('pullRequestId')}") + + if pr.get('status'): + formatted_info.append(f"Status: {pr.get('status')}") + + source_branch = pr.get('sourceRefName', '').replace('refs/heads/', '') + target_branch = pr.get('targetRefName', '').replace('refs/heads/', '') + formatted_info.append(f"Source Branch: {source_branch}") + formatted_info.append(f"Target Branch: {target_branch}") + + created_by = pr.get('createdBy') + if created_by is not None and 'displayName' in created_by: + formatted_info.append(f"Creator: {created_by.get('displayName')}") + + if pr.get('creationDate'): + formatted_info.append(f"Creation Date: {pr.get('creationDate')}") + + description = pr.get('description') + if description is not None: + if len(description) > 100: + description = description[:97] + "..." + formatted_info.append(f"Description: {description}") + + if pr.get('url'): + formatted_info.append(f"URL: {pr.get('url')}") + + return "\n".join(formatted_info) + + +def _create_pull_request_impl( + client: AzureDevOpsClient, + title: str, + description: str, + source_branch: str, + target_branch: str, + reviewers: Optional[List[str]] = None +) -> str: + """ + Implementation of pull request creation. + + Args: + client: Azure DevOps client + title: PR title + description: PR description + source_branch: Source branch name + target_branch: Target branch name + reviewers: List of reviewer emails or IDs + + Returns: + Formatted string containing pull request information + """ + try: + result = client.create_pull_request( + title=title, + description=description, + source_branch=source_branch, + target_branch=target_branch, + reviewers=reviewers + ) + + return _format_pull_request(result) + except Exception as e: + return f"Error creating pull request: {str(e)}" + + +def _update_pull_request_impl( + client: AzureDevOpsClient, + pull_request_id: int, + title: Optional[str] = None, + description: Optional[str] = None, + status: Optional[str] = None +) -> str: + """ + Implementation of pull request update. + + Args: + client: Azure DevOps client + pull_request_id: ID of the PR to update + title: New PR title (optional) + description: New PR description (optional) + status: New PR status (optional) + + Returns: + Formatted string containing updated pull request information + """ + update_data = {} + if title is not None: + update_data["title"] = title + if description is not None: + update_data["description"] = description + if status is not None: + update_data["status"] = status + + if not update_data: + return "Error: No update parameters provided" + + try: + result = client.update_pull_request( + pull_request_id=pull_request_id, + update_data=update_data + ) + + return _format_pull_request(result) + except Exception as e: + return f"Error updating pull request: {str(e)}" + + +def _list_pull_requests_impl( + client: AzureDevOpsClient, + status: Optional[str] = None, + creator: Optional[str] = None, + reviewer: Optional[str] = None, + target_branch: Optional[str] = None +) -> str: + """ + Implementation of pull requests listing. + + Args: + client: Azure DevOps client + status: Filter by status (active, abandoned, completed, all) + creator: Filter by creator ID + reviewer: Filter by reviewer ID + target_branch: Filter by target branch name + + Returns: + Formatted string containing pull request information + """ + try: + prs = client.get_pull_requests( + status=status, + creator=creator, + reviewer=reviewer, + target_branch=target_branch + ) + + if not prs: + return "No pull requests found." + + formatted_prs = [] + for pr in prs: + formatted_prs.append(_format_pull_request(pr)) + + return "\n\n".join(formatted_prs) + except Exception as e: + return f"Error retrieving pull requests: {str(e)}" + + +def _get_pull_request_impl( + client: AzureDevOpsClient, + pull_request_id: int +) -> str: + """ + Implementation of pull request retrieval. + + Args: + client: Azure DevOps client + pull_request_id: ID of the PR + + Returns: + Formatted string containing pull request information + """ + try: + pr = client.get_pull_request(pull_request_id=pull_request_id) + return _format_pull_request(pr) + except Exception as e: + return f"Error retrieving pull request: {str(e)}" + + +def _add_comment_impl( + client: AzureDevOpsClient, + pull_request_id: int, + content: str +) -> str: + """ + Implementation of comment addition. + + Args: + client: Azure DevOps client + pull_request_id: ID of the PR + content: Comment text + + Returns: + Formatted string containing comment information + """ + try: + result = client.add_comment( + pull_request_id=pull_request_id, + content=content + ) + + thread_id = result.get("id") + comment_id = result.get("comments", [{}])[0].get("id") if result.get("comments") else None + + return f"Comment added successfully:\nThread ID: {thread_id}\nComment ID: {comment_id}\nContent: {content}" + except Exception as e: + return f"Error adding comment: {str(e)}" + + +def _approve_pull_request_impl( + client: AzureDevOpsClient, + pull_request_id: int +) -> str: + """ + Implementation of pull request approval. + + Args: + client: Azure DevOps client + pull_request_id: ID of the PR + + Returns: + Formatted string containing approval information + """ + try: + result = client.set_vote( + pull_request_id=pull_request_id, + vote=10 # 10 = Approve + ) + + return f"Pull request {pull_request_id} approved by {result.get('displayName', 'user')}" + except Exception as e: + return f"Error approving pull request: {str(e)}" + + +def _reject_pull_request_impl( + client: AzureDevOpsClient, + pull_request_id: int +) -> str: + """ + Implementation of pull request rejection. + + Args: + client: Azure DevOps client + pull_request_id: ID of the PR + + Returns: + Formatted string containing rejection information + """ + try: + result = client.set_vote( + pull_request_id=pull_request_id, + vote=-10 # -10 = Reject + ) + + return f"Pull request {pull_request_id} rejected by {result.get('displayName', 'user')}" + except Exception as e: + return f"Error rejecting pull request: {str(e)}" + + +def _complete_pull_request_impl( + client: AzureDevOpsClient, + pull_request_id: int, + merge_strategy: str = "squash", + delete_source_branch: bool = False +) -> str: + """ + Implementation of pull request completion. + + Args: + client: Azure DevOps client + pull_request_id: ID of the PR + merge_strategy: Strategy to use (squash, rebase, rebaseMerge, merge) + delete_source_branch: Whether to delete source branch after merge + + Returns: + Formatted string containing completion information + """ + try: + result = client.complete_pull_request( + pull_request_id=pull_request_id, + merge_strategy=merge_strategy, + delete_source_branch=delete_source_branch + ) + + completed_by = result.get("closedBy", {}).get("displayName", "user") + return f"Pull request {pull_request_id} completed successfully by {completed_by}\nMerge strategy: {merge_strategy}\nSource branch deleted: {delete_source_branch}" + except Exception as e: + return f"Error completing pull request: {str(e)}" + + +def register_tools(mcp) -> None: + """ + Register pull request tools with the MCP server. + + Args: + mcp: The FastMCP server instance + """ + + @mcp.tool() + def create_pull_request( + organization: str, + project: str, + repo: str, + personal_access_token: str, + title: str, + description: str, + source_branch: str, + target_branch: str, + reviewers: Optional[List[str]] = None + ) -> str: + """ + Create a new Pull Request in Azure DevOps. + + Args: + organization: Azure DevOps organization name + project: Azure DevOps project name + repo: Azure DevOps repository name + personal_access_token: PAT with appropriate permissions + title: PR title + description: PR description + source_branch: Source branch name + target_branch: Target branch name + reviewers: List of reviewer emails or IDs (optional) + + Returns: + Formatted string containing pull request information + """ + try: + client = AzureDevOpsClient( + organization=organization, + project=project, + repo=repo, + personal_access_token=personal_access_token + ) + return _create_pull_request_impl( + client=client, + title=title, + description=description, + source_branch=source_branch, + target_branch=target_branch, + reviewers=reviewers + ) + except AzureDevOpsClientError as e: + return f"Error: {str(e)}" + + @mcp.tool() + def update_pull_request( + organization: str, + project: str, + repo: str, + personal_access_token: str, + pull_request_id: int, + title: Optional[str] = None, + description: Optional[str] = None, + status: Optional[str] = None + ) -> str: + """ + Update an existing Pull Request in Azure DevOps. + + Args: + organization: Azure DevOps organization name + project: Azure DevOps project name + repo: Azure DevOps repository name + personal_access_token: PAT with appropriate permissions + pull_request_id: ID of the PR to update + title: New PR title (optional) + description: New PR description (optional) + status: New PR status (active, abandoned) (optional) + + Returns: + Formatted string containing updated pull request information + """ + try: + client = AzureDevOpsClient( + organization=organization, + project=project, + repo=repo, + personal_access_token=personal_access_token + ) + return _update_pull_request_impl( + client=client, + pull_request_id=pull_request_id, + title=title, + description=description, + status=status + ) + except AzureDevOpsClientError as e: + return f"Error: {str(e)}" + + @mcp.tool() + def list_pull_requests( + organization: str, + project: str, + repo: str, + personal_access_token: str, + status: Optional[str] = None, + creator: Optional[str] = None, + reviewer: Optional[str] = None, + target_branch: Optional[str] = None + ) -> str: + """ + List Pull Requests in Azure DevOps with optional filtering. + + Args: + organization: Azure DevOps organization name + project: Azure DevOps project name + repo: Azure DevOps repository name + personal_access_token: PAT with appropriate permissions + status: Filter by status (active, abandoned, completed, all) (optional) + creator: Filter by creator ID (optional) + reviewer: Filter by reviewer ID (optional) + target_branch: Filter by target branch name (optional) + + Returns: + Formatted string containing pull request information + """ + try: + client = AzureDevOpsClient( + organization=organization, + project=project, + repo=repo, + personal_access_token=personal_access_token + ) + return _list_pull_requests_impl( + client=client, + status=status, + creator=creator, + reviewer=reviewer, + target_branch=target_branch + ) + except AzureDevOpsClientError as e: + return f"Error: {str(e)}" + + @mcp.tool() + def get_pull_request( + organization: str, + project: str, + repo: str, + personal_access_token: str, + pull_request_id: int + ) -> str: + """ + Get details of a specific Pull Request in Azure DevOps. + + Args: + organization: Azure DevOps organization name + project: Azure DevOps project name + repo: Azure DevOps repository name + personal_access_token: PAT with appropriate permissions + pull_request_id: ID of the PR + + Returns: + Formatted string containing pull request information + """ + try: + client = AzureDevOpsClient( + organization=organization, + project=project, + repo=repo, + personal_access_token=personal_access_token + ) + return _get_pull_request_impl( + client=client, + pull_request_id=pull_request_id + ) + except AzureDevOpsClientError as e: + return f"Error: {str(e)}" + + @mcp.tool() + def add_comment( + organization: str, + project: str, + repo: str, + personal_access_token: str, + pull_request_id: int, + content: str + ) -> str: + """ + Add a comment to a Pull Request in Azure DevOps. + + Args: + organization: Azure DevOps organization name + project: Azure DevOps project name + repo: Azure DevOps repository name + personal_access_token: PAT with appropriate permissions + pull_request_id: ID of the PR + content: Comment text + + Returns: + Formatted string containing comment information + """ + try: + client = AzureDevOpsClient( + organization=organization, + project=project, + repo=repo, + personal_access_token=personal_access_token + ) + return _add_comment_impl( + client=client, + pull_request_id=pull_request_id, + content=content + ) + except AzureDevOpsClientError as e: + return f"Error: {str(e)}" + + @mcp.tool() + def approve_pull_request( + organization: str, + project: str, + repo: str, + personal_access_token: str, + pull_request_id: int + ) -> str: + """ + Approve a Pull Request in Azure DevOps. + + Args: + organization: Azure DevOps organization name + project: Azure DevOps project name + repo: Azure DevOps repository name + personal_access_token: PAT with appropriate permissions + pull_request_id: ID of the PR + + Returns: + Formatted string containing approval information + """ + try: + client = AzureDevOpsClient( + organization=organization, + project=project, + repo=repo, + personal_access_token=personal_access_token + ) + return _approve_pull_request_impl( + client=client, + pull_request_id=pull_request_id + ) + except AzureDevOpsClientError as e: + return f"Error: {str(e)}" + + @mcp.tool() + def reject_pull_request( + organization: str, + project: str, + repo: str, + personal_access_token: str, + pull_request_id: int + ) -> str: + """ + Reject a Pull Request in Azure DevOps. + + Args: + organization: Azure DevOps organization name + project: Azure DevOps project name + repo: Azure DevOps repository name + personal_access_token: PAT with appropriate permissions + pull_request_id: ID of the PR + + Returns: + Formatted string containing rejection information + """ + try: + client = AzureDevOpsClient( + organization=organization, + project=project, + repo=repo, + personal_access_token=personal_access_token + ) + return _reject_pull_request_impl( + client=client, + pull_request_id=pull_request_id + ) + except AzureDevOpsClientError as e: + return f"Error: {str(e)}" + + @mcp.tool() + def complete_pull_request( + organization: str, + project: str, + repo: str, + personal_access_token: str, + pull_request_id: int, + merge_strategy: str = "squash", + delete_source_branch: bool = False + ) -> str: + """ + Complete (merge) a Pull Request in Azure DevOps. + + Args: + organization: Azure DevOps organization name + project: Azure DevOps project name + repo: Azure DevOps repository name + personal_access_token: PAT with appropriate permissions + pull_request_id: ID of the PR + merge_strategy: Merge strategy (squash, rebase, rebaseMerge, merge) (optional) + delete_source_branch: Whether to delete source branch after merge (optional) + + Returns: + Formatted string containing completion information + """ + try: + client = AzureDevOpsClient( + organization=organization, + project=project, + repo=repo, + personal_access_token=personal_access_token + ) + return _complete_pull_request_impl( + client=client, + pull_request_id=pull_request_id, + merge_strategy=merge_strategy, + delete_source_branch=delete_source_branch + ) + except AzureDevOpsClientError as e: + return f"Error: {str(e)}" \ No newline at end of file diff --git a/tests/features/pull_requests/__init__.py b/tests/features/pull_requests/__init__.py new file mode 100644 index 0000000..aab3ed7 --- /dev/null +++ b/tests/features/pull_requests/__init__.py @@ -0,0 +1,3 @@ +""" +Tests for Azure DevOps teams features. +""" diff --git a/tests/features/pull_requests/test_tools.py b/tests/features/pull_requests/test_tools.py new file mode 100644 index 0000000..b582bdf --- /dev/null +++ b/tests/features/pull_requests/test_tools.py @@ -0,0 +1,420 @@ +import unittest +from unittest.mock import MagicMock, patch +import pytest + +# Import the module to test +from mcp_azure_devops.features.pull_requests.tools import ( + _format_pull_request, + _create_pull_request_impl, + _update_pull_request_impl, + _list_pull_requests_impl, + _get_pull_request_impl, + _add_comment_impl, + _approve_pull_request_impl, + _reject_pull_request_impl, + _complete_pull_request_impl, +) + +from mcp_azure_devops.features.pull_requests.common import AzureDevOpsClient, AzureDevOpsClientError + + +class TestPRFormatting(unittest.TestCase): + def test_format_pull_request_basic(self): + """Test formatting with basic PR information.""" + pr = { + "title": "Test PR", + "pullRequestId": 123, + "sourceRefName": "refs/heads/feature/branch", + "targetRefName": "refs/heads/main", + "url": "https://dev.azure.com/org/project/_git/repo/pullrequest/123" + } + + result = _format_pull_request(pr) + + self.assertIn("Pull Request: Test PR", result) + self.assertIn("ID: 123", result) + self.assertIn("Source Branch: feature/branch", result) + self.assertIn("Target Branch: main", result) + self.assertIn("URL: https://dev.azure.com/org/project/_git/repo/pullrequest/123", result) + + def test_format_pull_request_full(self): + """Test formatting with all PR details.""" + pr = { + "title": "Test PR", + "pullRequestId": 123, + "status": "active", + "sourceRefName": "refs/heads/feature/branch", + "targetRefName": "refs/heads/main", + "createdBy": {"displayName": "John Doe"}, + "creationDate": "2025-03-28T12:00:00Z", + "description": "This is a test pull request description", + "url": "https://dev.azure.com/org/project/_git/repo/pullrequest/123" + } + + result = _format_pull_request(pr) + + self.assertIn("Pull Request: Test PR", result) + self.assertIn("ID: 123", result) + self.assertIn("Status: active", result) + self.assertIn("Source Branch: feature/branch", result) + self.assertIn("Target Branch: main", result) + self.assertIn("Creation", result) # Test for creationDate + self.assertIn("Description: This is a test pull request description", result) + + def test_format_pull_request_long_description(self): + """Test formatting with truncated description.""" + long_description = "x" * 200 # Create a string longer than 100 chars + pr = { + "title": "Test PR", + "pullRequestId": 123, + "description": long_description, + } + + result = _format_pull_request(pr) + + self.assertIn("Description:", result) + self.assertIn("...", result) + self.assertTrue(len(result.split("Description: ")[1]) < len(long_description)) + + +class TestCreatePullRequest(unittest.TestCase): + def setUp(self): + self.client = MagicMock(spec=AzureDevOpsClient) + + def test_create_pull_request_success(self): + """Test successful PR creation.""" + self.client.create_pull_request.return_value = { + "title": "Test PR", + "pullRequestId": 123, + "sourceRefName": "refs/heads/feature/branch", + "targetRefName": "refs/heads/main" + } + + result = _create_pull_request_impl( + client=self.client, + title="Test PR", + description="Test description", + source_branch="feature/branch", + target_branch="main", + reviewers=["user@example.com"] + ) + + self.client.create_pull_request.assert_called_once_with( + title="Test PR", + description="Test description", + source_branch="feature/branch", + target_branch="main", + reviewers=["user@example.com"] + ) + self.assertIn("Pull Request: Test PR", result) + self.assertIn("ID: 123", result) + + def test_create_pull_request_error(self): + """Test error handling in PR creation.""" + self.client.create_pull_request.side_effect = Exception("API Error") + + result = _create_pull_request_impl( + client=self.client, + title="Test PR", + description="Test description", + source_branch="feature/branch", + target_branch="main" + ) + + self.assertIn("Error creating pull request", result) + self.assertIn("API Error", result) + + +class TestUpdatePullRequest(unittest.TestCase): + def setUp(self): + self.client = MagicMock(spec=AzureDevOpsClient) + + def test_update_pull_request_success(self): + """Test successful PR update.""" + self.client.update_pull_request.return_value = { + "title": "Updated Title", + "pullRequestId": 123, + "description": "Updated description", + "status": "active" + } + + result = _update_pull_request_impl( + client=self.client, + pull_request_id=123, + title="Updated Title", + description="Updated description" + ) + + self.client.update_pull_request.assert_called_once_with( + pull_request_id=123, + update_data={"title": "Updated Title", "description": "Updated description"} + ) + self.assertIn("Pull Request: Updated Title", result) + self.assertIn("ID: 123", result) + + def test_update_pull_request_no_params(self): + """Test PR update with no parameters.""" + result = _update_pull_request_impl( + client=self.client, + pull_request_id=123 + ) + + self.assertIn("Error: No update parameters", result) + self.client.update_pull_request.assert_not_called() + + def test_update_pull_request_error(self): + """Test error handling in PR update.""" + self.client.update_pull_request.side_effect = Exception("API Error") + + result = _update_pull_request_impl( + client=self.client, + pull_request_id=123, + title="Updated Title" + ) + + self.assertIn("Error updating pull request", result) + self.assertIn("API Error", result) + + +class TestListPullRequests(unittest.TestCase): + def setUp(self): + self.client = MagicMock(spec=AzureDevOpsClient) + + def test_list_pull_requests_success(self): + """Test successful PR listing.""" + self.client.get_pull_requests.return_value = [ + { + "title": "PR 1", + "pullRequestId": 123, + "status": "active" + }, + { + "title": "PR 2", + "pullRequestId": 124, + "status": "completed" + } + ] + + result = _list_pull_requests_impl( + client=self.client, + status="all" + ) + + self.client.get_pull_requests.assert_called_once_with( + status="all", + creator=None, + reviewer=None, + target_branch=None + ) + self.assertIn("PR 1", result) + self.assertIn("PR 2", result) + self.assertIn("ID: 123", result) + self.assertIn("ID: 124", result) + + def test_list_pull_requests_empty(self): + """Test PR listing with no results.""" + self.client.get_pull_requests.return_value = [] + + result = _list_pull_requests_impl( + client=self.client, + status="all" + ) + + self.assertIn("No pull requests found", result) + + def test_list_pull_requests_error(self): + """Test error handling in PR listing.""" + self.client.get_pull_requests.side_effect = Exception("API Error") + + result = _list_pull_requests_impl( + client=self.client, + status="active" + ) + + self.assertIn("Error retrieving pull requests", result) + self.assertIn("API Error", result) + + +class TestGetPullRequest(unittest.TestCase): + def setUp(self): + self.client = MagicMock(spec=AzureDevOpsClient) + + def test_get_pull_request_success(self): + """Test successful PR retrieval.""" + self.client.get_pull_request.return_value = { + "title": "Test PR", + "pullRequestId": 123, + "status": "active" + } + + result = _get_pull_request_impl( + client=self.client, + pull_request_id=123 + ) + + self.client.get_pull_request.assert_called_once_with( + pull_request_id=123 + ) + self.assertIn("Pull Request: Test PR", result) + self.assertIn("ID: 123", result) + + def test_get_pull_request_error(self): + """Test error handling in PR retrieval.""" + self.client.get_pull_request.side_effect = Exception("API Error") + + result = _get_pull_request_impl( + client=self.client, + pull_request_id=123 + ) + + self.assertIn("Error retrieving pull request", result) + self.assertIn("API Error", result) + + +class TestAddComment(unittest.TestCase): + def setUp(self): + self.client = MagicMock(spec=AzureDevOpsClient) + + def test_add_comment_success(self): + """Test successful comment addition.""" + self.client.add_comment.return_value = { + "id": 456, + "comments": [{"id": 789}] + } + + result = _add_comment_impl( + client=self.client, + pull_request_id=123, + content="Test comment" + ) + + self.client.add_comment.assert_called_once_with( + pull_request_id=123, + content="Test comment" + ) + self.assertIn("Comment added successfully", result) + self.assertIn("Thread ID: 456", result) + self.assertIn("Comment ID: 789", result) + + def test_add_comment_error(self): + """Test error handling in comment addition.""" + self.client.add_comment.side_effect = Exception("API Error") + + result = _add_comment_impl( + client=self.client, + pull_request_id=123, + content="Test comment" + ) + + self.assertIn("Error adding comment", result) + self.assertIn("API Error", result) + + +class TestApprovePullRequest(unittest.TestCase): + def setUp(self): + self.client = MagicMock(spec=AzureDevOpsClient) + + def test_approve_pull_request_success(self): + """Test successful PR approval.""" + self.client.set_vote.return_value = { + "displayName": "John Doe" + } + + result = _approve_pull_request_impl( + client=self.client, + pull_request_id=123 + ) + + self.client.set_vote.assert_called_once_with( + pull_request_id=123, + vote=10 + ) + self.assertIn("Pull request 123 approved by John Doe", result) + + def test_approve_pull_request_error(self): + """Test error handling in PR approval.""" + self.client.set_vote.side_effect = Exception("API Error") + + result = _approve_pull_request_impl( + client=self.client, + pull_request_id=123 + ) + + self.assertIn("Error approving pull request", result) + self.assertIn("API Error", result) + + +class TestRejectPullRequest(unittest.TestCase): + def setUp(self): + self.client = MagicMock(spec=AzureDevOpsClient) + + def test_reject_pull_request_success(self): + """Test successful PR rejection.""" + self.client.set_vote.return_value = { + "displayName": "John Doe" + } + + result = _reject_pull_request_impl( + client=self.client, + pull_request_id=123 + ) + + self.client.set_vote.assert_called_once_with( + pull_request_id=123, + vote=-10 + ) + self.assertIn("Pull request 123 rejected by John Doe", result) + + def test_reject_pull_request_error(self): + """Test error handling in PR rejection.""" + self.client.set_vote.side_effect = Exception("API Error") + + result = _reject_pull_request_impl( + client=self.client, + pull_request_id=123 + ) + + self.assertIn("Error rejecting pull request", result) + self.assertIn("API Error", result) + + +class TestCompletePullRequest(unittest.TestCase): + def setUp(self): + self.client = MagicMock(spec=AzureDevOpsClient) + + def test_complete_pull_request_success(self): + """Test successful PR completion.""" + self.client.complete_pull_request.return_value = { + "closedBy": {"displayName": "John Doe"} + } + + result = _complete_pull_request_impl( + client=self.client, + pull_request_id=123, + merge_strategy="squash", + delete_source_branch=True + ) + + self.client.complete_pull_request.assert_called_once_with( + pull_request_id=123, + merge_strategy="squash", + delete_source_branch=True + ) + self.assertIn("Pull request 123 completed successfully by John Doe", result) + self.assertIn("Merge strategy: squash", result) + self.assertIn("Source branch deleted: True", result) + + def test_complete_pull_request_error(self): + """Test error handling in PR completion.""" + self.client.complete_pull_request.side_effect = Exception("API Error") + + result = _complete_pull_request_impl( + client=self.client, + pull_request_id=123 + ) + + self.assertIn("Error completing pull request", result) + self.assertIn("API Error", result) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file From b0a60afc3b655a3547688632bb3d83cd1d63acac Mon Sep 17 00:00:00 2001 From: Nam P Tran Date: Sat, 29 Mar 2025 14:04:37 +0700 Subject: [PATCH 02/10] Add more unit test --- tests/features/pull_requests/test_tools.py | 290 +++++++++++---------- 1 file changed, 147 insertions(+), 143 deletions(-) diff --git a/tests/features/pull_requests/test_tools.py b/tests/features/pull_requests/test_tools.py index b582bdf..964cb3d 100644 --- a/tests/features/pull_requests/test_tools.py +++ b/tests/features/pull_requests/test_tools.py @@ -1,6 +1,5 @@ -import unittest -from unittest.mock import MagicMock, patch import pytest +from unittest.mock import MagicMock, patch # Import the module to test from mcp_azure_devops.features.pull_requests.tools import ( @@ -18,7 +17,7 @@ from mcp_azure_devops.features.pull_requests.common import AzureDevOpsClient, AzureDevOpsClientError -class TestPRFormatting(unittest.TestCase): +class TestPRFormatting: def test_format_pull_request_basic(self): """Test formatting with basic PR information.""" pr = { @@ -31,11 +30,11 @@ def test_format_pull_request_basic(self): result = _format_pull_request(pr) - self.assertIn("Pull Request: Test PR", result) - self.assertIn("ID: 123", result) - self.assertIn("Source Branch: feature/branch", result) - self.assertIn("Target Branch: main", result) - self.assertIn("URL: https://dev.azure.com/org/project/_git/repo/pullrequest/123", result) + assert "Pull Request: Test PR" in result + assert "ID: 123" in result + assert "Source Branch: feature/branch" in result + assert "Target Branch: main" in result + assert "URL: https://dev.azure.com/org/project/_git/repo/pullrequest/123" in result def test_format_pull_request_full(self): """Test formatting with all PR details.""" @@ -53,13 +52,13 @@ def test_format_pull_request_full(self): result = _format_pull_request(pr) - self.assertIn("Pull Request: Test PR", result) - self.assertIn("ID: 123", result) - self.assertIn("Status: active", result) - self.assertIn("Source Branch: feature/branch", result) - self.assertIn("Target Branch: main", result) - self.assertIn("Creation", result) # Test for creationDate - self.assertIn("Description: This is a test pull request description", result) + assert "Pull Request: Test PR" in result + assert "ID: 123" in result + assert "Status: active" in result + assert "Source Branch: feature/branch" in result + assert "Target Branch: main" in result + assert "Creation" in result # Test for creationDate + assert "Description: This is a test pull request description" in result def test_format_pull_request_long_description(self): """Test formatting with truncated description.""" @@ -72,18 +71,19 @@ def test_format_pull_request_long_description(self): result = _format_pull_request(pr) - self.assertIn("Description:", result) - self.assertIn("...", result) - self.assertTrue(len(result.split("Description: ")[1]) < len(long_description)) + assert "Description:" in result + assert "..." in result + assert len(result.split("Description: ")[1]) < len(long_description) -class TestCreatePullRequest(unittest.TestCase): - def setUp(self): - self.client = MagicMock(spec=AzureDevOpsClient) +class TestCreatePullRequest: + @pytest.fixture + def client(self): + return MagicMock(spec=AzureDevOpsClient) - def test_create_pull_request_success(self): + def test_create_pull_request_success(self, client): """Test successful PR creation.""" - self.client.create_pull_request.return_value = { + client.create_pull_request.return_value = { "title": "Test PR", "pullRequestId": 123, "sourceRefName": "refs/heads/feature/branch", @@ -91,7 +91,7 @@ def test_create_pull_request_success(self): } result = _create_pull_request_impl( - client=self.client, + client=client, title="Test PR", description="Test description", source_branch="feature/branch", @@ -99,39 +99,40 @@ def test_create_pull_request_success(self): reviewers=["user@example.com"] ) - self.client.create_pull_request.assert_called_once_with( + client.create_pull_request.assert_called_once_with( title="Test PR", description="Test description", source_branch="feature/branch", target_branch="main", reviewers=["user@example.com"] ) - self.assertIn("Pull Request: Test PR", result) - self.assertIn("ID: 123", result) + assert "Pull Request: Test PR" in result + assert "ID: 123" in result - def test_create_pull_request_error(self): + def test_create_pull_request_error(self, client): """Test error handling in PR creation.""" - self.client.create_pull_request.side_effect = Exception("API Error") + client.create_pull_request.side_effect = Exception("API Error") result = _create_pull_request_impl( - client=self.client, + client=client, title="Test PR", description="Test description", source_branch="feature/branch", target_branch="main" ) - self.assertIn("Error creating pull request", result) - self.assertIn("API Error", result) + assert "Error creating pull request" in result + assert "API Error" in result -class TestUpdatePullRequest(unittest.TestCase): - def setUp(self): - self.client = MagicMock(spec=AzureDevOpsClient) +class TestUpdatePullRequest: + @pytest.fixture + def client(self): + return MagicMock(spec=AzureDevOpsClient) - def test_update_pull_request_success(self): + def test_update_pull_request_success(self, client): """Test successful PR update.""" - self.client.update_pull_request.return_value = { + client.update_pull_request.return_value = { "title": "Updated Title", "pullRequestId": 123, "description": "Updated description", @@ -139,50 +140,51 @@ def test_update_pull_request_success(self): } result = _update_pull_request_impl( - client=self.client, + client=client, pull_request_id=123, title="Updated Title", description="Updated description" ) - self.client.update_pull_request.assert_called_once_with( + client.update_pull_request.assert_called_once_with( pull_request_id=123, update_data={"title": "Updated Title", "description": "Updated description"} ) - self.assertIn("Pull Request: Updated Title", result) - self.assertIn("ID: 123", result) + assert "Pull Request: Updated Title" in result + assert "ID: 123" in result - def test_update_pull_request_no_params(self): + def test_update_pull_request_no_params(self, client): """Test PR update with no parameters.""" result = _update_pull_request_impl( - client=self.client, + client=client, pull_request_id=123 ) - self.assertIn("Error: No update parameters", result) - self.client.update_pull_request.assert_not_called() + assert "Error: No update parameters" in result + client.update_pull_request.assert_not_called() - def test_update_pull_request_error(self): + def test_update_pull_request_error(self, client): """Test error handling in PR update.""" - self.client.update_pull_request.side_effect = Exception("API Error") + client.update_pull_request.side_effect = Exception("API Error") result = _update_pull_request_impl( - client=self.client, + client=client, pull_request_id=123, title="Updated Title" ) - self.assertIn("Error updating pull request", result) - self.assertIn("API Error", result) + assert "Error updating pull request" in result + assert "API Error" in result -class TestListPullRequests(unittest.TestCase): - def setUp(self): - self.client = MagicMock(spec=AzureDevOpsClient) +class TestListPullRequests: + @pytest.fixture + def client(self): + return MagicMock(spec=AzureDevOpsClient) - def test_list_pull_requests_success(self): + def test_list_pull_requests_success(self, client): """Test successful PR listing.""" - self.client.get_pull_requests.return_value = [ + client.get_pull_requests.return_value = [ { "title": "PR 1", "pullRequestId": 123, @@ -196,225 +198,227 @@ def test_list_pull_requests_success(self): ] result = _list_pull_requests_impl( - client=self.client, + client=client, status="all" ) - self.client.get_pull_requests.assert_called_once_with( + client.get_pull_requests.assert_called_once_with( status="all", creator=None, reviewer=None, target_branch=None ) - self.assertIn("PR 1", result) - self.assertIn("PR 2", result) - self.assertIn("ID: 123", result) - self.assertIn("ID: 124", result) + assert "PR 1" in result + assert "PR 2" in result + assert "ID: 123" in result + assert "ID: 124" in result - def test_list_pull_requests_empty(self): + def test_list_pull_requests_empty(self, client): """Test PR listing with no results.""" - self.client.get_pull_requests.return_value = [] + client.get_pull_requests.return_value = [] result = _list_pull_requests_impl( - client=self.client, + client=client, status="all" ) - self.assertIn("No pull requests found", result) + assert "No pull requests found" in result - def test_list_pull_requests_error(self): + def test_list_pull_requests_error(self, client): """Test error handling in PR listing.""" - self.client.get_pull_requests.side_effect = Exception("API Error") + client.get_pull_requests.side_effect = Exception("API Error") result = _list_pull_requests_impl( - client=self.client, + client=client, status="active" ) - self.assertIn("Error retrieving pull requests", result) - self.assertIn("API Error", result) + assert "Error retrieving pull requests" in result + assert "API Error" in result -class TestGetPullRequest(unittest.TestCase): - def setUp(self): - self.client = MagicMock(spec=AzureDevOpsClient) +class TestGetPullRequest: + @pytest.fixture + def client(self): + return MagicMock(spec=AzureDevOpsClient) - def test_get_pull_request_success(self): + def test_get_pull_request_success(self, client): """Test successful PR retrieval.""" - self.client.get_pull_request.return_value = { + client.get_pull_request.return_value = { "title": "Test PR", "pullRequestId": 123, "status": "active" } result = _get_pull_request_impl( - client=self.client, + client=client, pull_request_id=123 ) - self.client.get_pull_request.assert_called_once_with( + client.get_pull_request.assert_called_once_with( pull_request_id=123 ) - self.assertIn("Pull Request: Test PR", result) - self.assertIn("ID: 123", result) + assert "Pull Request: Test PR" in result + assert "ID: 123" in result - def test_get_pull_request_error(self): + def test_get_pull_request_error(self, client): """Test error handling in PR retrieval.""" - self.client.get_pull_request.side_effect = Exception("API Error") + client.get_pull_request.side_effect = Exception("API Error") result = _get_pull_request_impl( - client=self.client, + client=client, pull_request_id=123 ) - self.assertIn("Error retrieving pull request", result) - self.assertIn("API Error", result) + assert "Error retrieving pull request" in result + assert "API Error" in result -class TestAddComment(unittest.TestCase): - def setUp(self): - self.client = MagicMock(spec=AzureDevOpsClient) +class TestAddComment: + @pytest.fixture + def client(self): + return MagicMock(spec=AzureDevOpsClient) - def test_add_comment_success(self): + def test_add_comment_success(self, client): """Test successful comment addition.""" - self.client.add_comment.return_value = { + client.add_comment.return_value = { "id": 456, "comments": [{"id": 789}] } result = _add_comment_impl( - client=self.client, + client=client, pull_request_id=123, content="Test comment" ) - self.client.add_comment.assert_called_once_with( + client.add_comment.assert_called_once_with( pull_request_id=123, content="Test comment" ) - self.assertIn("Comment added successfully", result) - self.assertIn("Thread ID: 456", result) - self.assertIn("Comment ID: 789", result) + assert "Comment added successfully" in result + assert "Thread ID: 456" in result + assert "Comment ID: 789" in result - def test_add_comment_error(self): + def test_add_comment_error(self, client): """Test error handling in comment addition.""" - self.client.add_comment.side_effect = Exception("API Error") + client.add_comment.side_effect = Exception("API Error") result = _add_comment_impl( - client=self.client, + client=client, pull_request_id=123, content="Test comment" ) - self.assertIn("Error adding comment", result) - self.assertIn("API Error", result) + assert "Error adding comment" in result + assert "API Error" in result -class TestApprovePullRequest(unittest.TestCase): - def setUp(self): - self.client = MagicMock(spec=AzureDevOpsClient) +class TestApprovePullRequest: + @pytest.fixture + def client(self): + return MagicMock(spec=AzureDevOpsClient) - def test_approve_pull_request_success(self): + def test_approve_pull_request_success(self, client): """Test successful PR approval.""" - self.client.set_vote.return_value = { + client.set_vote.return_value = { "displayName": "John Doe" } result = _approve_pull_request_impl( - client=self.client, + client=client, pull_request_id=123 ) - self.client.set_vote.assert_called_once_with( + client.set_vote.assert_called_once_with( pull_request_id=123, vote=10 ) - self.assertIn("Pull request 123 approved by John Doe", result) + assert "Pull request 123 approved by John Doe" in result - def test_approve_pull_request_error(self): + def test_approve_pull_request_error(self, client): """Test error handling in PR approval.""" - self.client.set_vote.side_effect = Exception("API Error") + client.set_vote.side_effect = Exception("API Error") result = _approve_pull_request_impl( - client=self.client, + client=client, pull_request_id=123 ) - self.assertIn("Error approving pull request", result) - self.assertIn("API Error", result) + assert "Error approving pull request" in result + assert "API Error" in result -class TestRejectPullRequest(unittest.TestCase): - def setUp(self): - self.client = MagicMock(spec=AzureDevOpsClient) +class TestRejectPullRequest: + @pytest.fixture + def client(self): + return MagicMock(spec=AzureDevOpsClient) - def test_reject_pull_request_success(self): + def test_reject_pull_request_success(self, client): """Test successful PR rejection.""" - self.client.set_vote.return_value = { + client.set_vote.return_value = { "displayName": "John Doe" } result = _reject_pull_request_impl( - client=self.client, + client=client, pull_request_id=123 ) - self.client.set_vote.assert_called_once_with( + client.set_vote.assert_called_once_with( pull_request_id=123, vote=-10 ) - self.assertIn("Pull request 123 rejected by John Doe", result) + assert "Pull request 123 rejected by John Doe" in result - def test_reject_pull_request_error(self): + def test_reject_pull_request_error(self, client): """Test error handling in PR rejection.""" - self.client.set_vote.side_effect = Exception("API Error") + client.set_vote.side_effect = Exception("API Error") result = _reject_pull_request_impl( - client=self.client, + client=client, pull_request_id=123 ) - self.assertIn("Error rejecting pull request", result) - self.assertIn("API Error", result) + assert "Error rejecting pull request" in result + assert "API Error" in result -class TestCompletePullRequest(unittest.TestCase): - def setUp(self): - self.client = MagicMock(spec=AzureDevOpsClient) +class TestCompletePullRequest: + @pytest.fixture + def client(self): + return MagicMock(spec=AzureDevOpsClient) - def test_complete_pull_request_success(self): + def test_complete_pull_request_success(self, client): """Test successful PR completion.""" - self.client.complete_pull_request.return_value = { + client.complete_pull_request.return_value = { "closedBy": {"displayName": "John Doe"} } result = _complete_pull_request_impl( - client=self.client, + client=client, pull_request_id=123, merge_strategy="squash", delete_source_branch=True ) - self.client.complete_pull_request.assert_called_once_with( + client.complete_pull_request.assert_called_once_with( pull_request_id=123, merge_strategy="squash", delete_source_branch=True ) - self.assertIn("Pull request 123 completed successfully by John Doe", result) - self.assertIn("Merge strategy: squash", result) - self.assertIn("Source branch deleted: True", result) + assert "Pull request 123 completed successfully by John Doe" in result + assert "Merge strategy: squash" in result + assert "Source branch deleted: True" in result - def test_complete_pull_request_error(self): + def test_complete_pull_request_error(self, client): """Test error handling in PR completion.""" - self.client.complete_pull_request.side_effect = Exception("API Error") + client.complete_pull_request.side_effect = Exception("API Error") result = _complete_pull_request_impl( - client=self.client, + client=client, pull_request_id=123 ) - self.assertIn("Error completing pull request", result) - self.assertIn("API Error", result) - -if __name__ == '__main__': - unittest.main() \ No newline at end of file + assert "Error completing pull request" in result + assert "API Error" in result \ No newline at end of file From 1eb363672cc70590648790c57c98ddc9ab85a0c2 Mon Sep 17 00:00:00 2001 From: Nam P Tran Date: Sat, 29 Mar 2025 19:41:50 +0700 Subject: [PATCH 03/10] Add more features for the pull requests --- .../features/pull_requests/common.py | 559 +++++++++++++----- .../features/pull_requests/tools.py | 451 ++++++++++++++ 2 files changed, 869 insertions(+), 141 deletions(-) diff --git a/src/mcp_azure_devops/features/pull_requests/common.py b/src/mcp_azure_devops/features/pull_requests/common.py index 7a02b2d..e79c94a 100644 --- a/src/mcp_azure_devops/features/pull_requests/common.py +++ b/src/mcp_azure_devops/features/pull_requests/common.py @@ -3,13 +3,18 @@ This module provides shared functionality used by both tools and resources. """ -from typing import Dict, List, Optional, Any, Union -import base64 -import requests -import json -from urllib.parse import quote -from mcp_azure_devops.utils.azure_client import get_connection +from typing import Dict, List, Optional, Any +from azure.devops.connection import Connection +from msrest.authentication import BasicAuthentication +from azure.devops.v7_1.git.models import ( + GitPullRequest, + GitPullRequestSearchCriteria, + Comment, + GitPullRequestCommentThread, + IdentityRefWithVote +) +from mcp_azure_devops.utils.azure_client import get_connection class AzureDevOpsClientError(Exception): """Exception raised for errors in Azure DevOps client operations.""" @@ -42,6 +47,7 @@ def get_pull_request_client(): return git_client + class AzureDevOpsClient: """Client for Azure DevOps API operations related to Pull Requests.""" @@ -58,51 +64,15 @@ def __init__(self, organization: str, project: str, repo: str, personal_access_t self.organization = organization self.project = project self.repo = repo - self.personal_access_token = personal_access_token - self.base_url = f"https://dev.azure.com/{organization}/{project}/_apis/git/repositories/{repo}" - self.api_version = "api-version=7.1" - self.headers = { - "Content-Type": "application/json", - "Authorization": f"Basic {self._encode_pat(personal_access_token)}" - } - - def _encode_pat(self, pat: str) -> str: - """ - Encode the Personal Access Token for API authentication. - - Args: - pat: Personal Access Token - - Returns: - Encoded PAT for use in Authorization header - """ - return base64.b64encode(f":{pat}".encode()).decode() - - def _handle_response(self, response: requests.Response) -> Dict[str, Any]: - """ - Handle API response and raise appropriate exceptions. - Args: - response: Response object from requests - - Returns: - JSON response if successful - - Raises: - AzureDevOpsClientError: With appropriate error message on failure - """ - if response.status_code >= 200 and response.status_code < 300: - return response.json() - - error_message = f"API request failed with status code {response.status_code}" - try: - error_details = response.json() - if "message" in error_details: - error_message += f": {error_details['message']}" - except Exception: - error_message += f": {response.text}" - - raise AzureDevOpsClientError(error_message) + # Create connection using SDK + credentials = BasicAuthentication('', personal_access_token) + connection = Connection(base_url=f'https://dev.azure.com/{organization}', creds=credentials) + if not connection: + raise AzureDevOpsClientError( + "Azure DevOps PAT or organization URL not found in environment variables." + ) + self.git_client = get_pull_request_client() def create_pull_request(self, title: str, description: str, source_branch: str, target_branch: str, reviewers: Optional[List[str]] = None) -> Dict[str, Any]: @@ -122,33 +92,32 @@ def create_pull_request(self, title: str, description: str, source_branch: str, Raises: AzureDevOpsClientError: If request fails """ - url = f"{self.base_url}/pullrequests?{self.api_version}" - - # Build the request body as a string - json_data = { - "sourceRefName": f"refs/heads/{source_branch}", - "targetRefName": f"refs/heads/{target_branch}", - "title": title, - "description": description - } - - # Add reviewers as a separate step if needed - if reviewers: - reviewer_objects = [] - for reviewer in reviewers: - reviewer_objects.append({"id": str(reviewer)}) - - json_str = json.dumps(json_data) - json_dict = json.loads(json_str) - json_dict["reviewers"] = reviewer_objects - - # Send the request with the modified JSON - response = requests.post(url, headers=self.headers, json=json_dict) - else: - # Send the request without reviewers - response = requests.post(url, headers=self.headers, json=json_data) - - return self._handle_response(response) + try: + # Create the PR object + pull_request = GitPullRequest( + source_ref_name=f"refs/heads/{source_branch}", + target_ref_name=f"refs/heads/{target_branch}", + title=title, + description=description + ) + + # Add reviewers if provided + if reviewers: + reviewer_refs = [] + for reviewer in reviewers: + reviewer_refs.append(IdentityRefWithVote(id=reviewer)) + pull_request.reviewers = reviewer_refs + + # Create the pull request + result = self.git_client.create_pull_request( + git_pull_request=pull_request, + repository_id=self.repo, + project=self.project + ) + + return result.__dict__ + except Exception as e: + raise AzureDevOpsClientError(f"Failed to create pull request: {str(e)}") def update_pull_request(self, pull_request_id: int, update_data: Dict[str, Any]) -> Dict[str, Any]: @@ -165,10 +134,29 @@ def update_pull_request(self, pull_request_id: int, Raises: AzureDevOpsClientError: If request fails """ - url = f"{self.base_url}/pullrequests/{pull_request_id}?{self.api_version}" - - response = requests.patch(url, headers=self.headers, json=update_data) - return self._handle_response(response) + try: + # First get the existing PR + existing_pr = self.git_client.get_pull_request( + repository_id=self.repo, + project=self.project, + pull_request_id=pull_request_id + ) + + # Update the PR object with new values + for key, value in update_data.items(): + setattr(existing_pr, key, value) + + # Send the update + result = self.git_client.update_pull_request( + git_pull_request=existing_pr, + repository_id=self.repo, + project=self.project, + pull_request_id=pull_request_id + ) + + return result.__dict__ + except Exception as e: + raise AzureDevOpsClientError(f"Failed to update pull request: {str(e)}") def get_pull_requests(self, status: Optional[str] = None, creator: Optional[str] = None, @@ -189,24 +177,30 @@ def get_pull_requests(self, status: Optional[str] = None, Raises: AzureDevOpsClientError: If request fails """ - url = f"{self.base_url}/pullrequests?{self.api_version}" - - params = [] - if status: - params.append(f"searchCriteria.status={status}") - if creator: - params.append(f"searchCriteria.creatorId={creator}") - if reviewer: - params.append(f"searchCriteria.reviewerId={reviewer}") - if target_branch: - params.append(f"searchCriteria.targetRefName=refs/heads/{quote(target_branch)}") - - if params: - url += "&" + "&".join(params) - - response = requests.get(url, headers=self.headers) - result = self._handle_response(response) - return result.get("value", []) + try: + # Create search criteria + search_criteria = GitPullRequestSearchCriteria() + + if status: + search_criteria.status = status + if creator: + search_criteria.creator_id = creator + if reviewer: + search_criteria.reviewer_id = reviewer + if target_branch: + search_criteria.target_ref_name = f"refs/heads/{target_branch}" + + # Get pull requests + pull_requests = self.git_client.get_pull_requests( + repository_id=self.repo, + search_criteria=search_criteria, + project=self.project + ) + + # Convert to list of dictionaries + return [pr.__dict__ for pr in pull_requests] + except Exception as e: + raise AzureDevOpsClientError(f"Failed to get pull requests: {str(e)}") def get_pull_request(self, pull_request_id: int) -> Dict[str, Any]: """ @@ -221,10 +215,16 @@ def get_pull_request(self, pull_request_id: int) -> Dict[str, Any]: Raises: AzureDevOpsClientError: If request fails """ - url = f"{self.base_url}/pullrequests/{pull_request_id}?{self.api_version}" - - response = requests.get(url, headers=self.headers) - return self._handle_response(response) + try: + result = self.git_client.get_pull_request( + repository_id=self.repo, + project=self.project, + pull_request_id=pull_request_id + ) + + return result.__dict__ + except Exception as e: + raise AzureDevOpsClientError(f"Failed to get pull request: {str(e)}") def add_comment(self, pull_request_id: int, content: str, comment_thread_id: Optional[int] = None, @@ -244,26 +244,35 @@ def add_comment(self, pull_request_id: int, content: str, Raises: AzureDevOpsClientError: If request fails """ - if comment_thread_id: - # Add to existing thread - url = f"{self.base_url}/pullrequests/{pull_request_id}/threads/{comment_thread_id}/comments?{self.api_version}" - data = { - "content": content - } - if parent_comment_id: - data["parentCommentId"] = str(parent_comment_id) - else: - # Create new thread - url = f"{self.base_url}/pullrequests/{pull_request_id}/threads?{self.api_version}" - data = { - "comments": [{ - "content": content - }], - "status": "active" - } - - response = requests.post(url, headers=self.headers, json=data) - return self._handle_response(response) + try: + if comment_thread_id: + # Add comment to existing thread + comment = Comment(content=content) + if parent_comment_id: + comment.parent_comment_id = parent_comment_id + + result = self.git_client.create_comment( + comment=comment, + repository_id=self.repo, + pull_request_id=pull_request_id, + thread_id=comment_thread_id, + project=self.project + ) + else: + # Create new thread with comment + comment = Comment(content=content) + thread = GitPullRequestCommentThread(comments=[comment], status="active") + + result = self.git_client.create_thread( + comment_thread=thread, + repository_id=self.repo, + pull_request_id=pull_request_id, + project=self.project + ) + + return result.__dict__ + except Exception as e: + raise AzureDevOpsClientError(f"Failed to add comment: {str(e)}") def set_vote(self, pull_request_id: int, vote: int) -> Dict[str, Any]: """ @@ -279,14 +288,31 @@ def set_vote(self, pull_request_id: int, vote: int) -> Dict[str, Any]: Raises: AzureDevOpsClientError: If request fails """ - url = f"{self.base_url}/pullrequests/{pull_request_id}/reviewers/me?{self.api_version}" - - data = { - "vote": vote - } - - response = requests.put(url, headers=self.headers, json=data) - return self._handle_response(response) + try: + # First get the current user's identity + connection = get_connection() + if not connection: + raise AzureDevOpsClientError( + "Azure DevOps PAT or organization URL not found in environment variables." + ) + identity_client = connection.clients.get_identity_client() + self_identity = identity_client.get_self() + + # Create reviewer object with vote + reviewer = IdentityRefWithVote(id=self_identity.id, vote=vote) + + # Update the vote + result = self.git_client.create_pull_request_reviewer( + reviewer=reviewer, + repository_id=self.repo, + pull_request_id=pull_request_id, + reviewer_id=self_identity.id, + project=self.project + ) + + return result.__dict__ + except Exception as e: + raise AzureDevOpsClientError(f"Failed to set vote: {str(e)}") def complete_pull_request(self, pull_request_id: int, merge_strategy: str = "squash", @@ -305,15 +331,266 @@ def complete_pull_request(self, pull_request_id: int, Raises: AzureDevOpsClientError: If request fails """ - url = f"{self.base_url}/pullrequests/{pull_request_id}?{self.api_version}" - - data = { - "status": "completed", - "completionOptions": { - "mergeStrategy": merge_strategy, - "deleteSourceBranch": delete_source_branch + try: + # First get the current PR + pull_request = self.git_client.get_pull_request( + repository_id=self.repo, + project=self.project, + pull_request_id=pull_request_id + ) + + # Update status and completion options + pull_request.status = "completed" + pull_request.completion_options = { + "merge_strategy": merge_strategy, + "delete_source_branch": delete_source_branch } - } + + # Complete the PR + result = self.git_client.update_pull_request( + git_pull_request=pull_request, + repository_id=self.repo, + project=self.project, + pull_request_id=pull_request_id + ) + + return result.__dict__ + except Exception as e: + raise AzureDevOpsClientError(f"Failed to complete pull request: {str(e)}") - response = requests.patch(url, headers=self.headers, json=data) - return self._handle_response(response) \ No newline at end of file + def get_pull_request_work_items(self, pull_request_id: int) -> List[Dict[str, Any]]: + """ + Get work items linked to a Pull Request. + + Args: + pull_request_id: ID of the PR + + Returns: + List of work items linked to the PR + + Raises: + AzureDevOpsClientError: If request fails + """ + try: + work_items = self.git_client.get_pull_request_work_items( + repository_id=self.repo, + pull_request_id=pull_request_id, + project=self.project + ) + + return [work_item.__dict__ for work_item in work_items] + except Exception as e: + raise AzureDevOpsClientError(f"Failed to get pull request work items: {str(e)}") + + def add_work_items_to_pull_request(self, pull_request_id: int, work_item_ids: List[int]) -> bool: + """ + Link work items to a Pull Request. + + Args: + pull_request_id: ID of the PR + work_item_ids: List of work item IDs to link + + Returns: + True if successful + + Raises: + AzureDevOpsClientError: If request fails + """ + try: + for work_item_id in work_item_ids: + self.git_client.create_pull_request_work_item_refs( + repository_id=self.repo, + pull_request_id=pull_request_id, + project=self.project, + work_item_ids=[work_item_id] + ) + + return True + except Exception as e: + raise AzureDevOpsClientError(f"Failed to link work items to pull request: {str(e)}") + + def get_pull_request_commits(self, pull_request_id: int) -> List[Dict[str, Any]]: + """ + Get all commits in a Pull Request. + + Args: + pull_request_id: ID of the PR + + Returns: + List of commits in the PR + + Raises: + AzureDevOpsClientError: If request fails + """ + try: + commits = self.git_client.get_pull_request_commits( + repository_id=self.repo, + pull_request_id=pull_request_id, + project=self.project + ) + + return [commit.__dict__ for commit in commits] + except Exception as e: + raise AzureDevOpsClientError(f"Failed to get pull request commits: {str(e)}") + + + def get_pull_request_changes(self, pull_request_id: int) -> Dict[str, Any]: + """ + Get all changes (files) in a Pull Request. + + Args: + pull_request_id: ID of the PR + + Returns: + Changes in the PR + + Raises: + AzureDevOpsClientError: If request fails + """ + try: + changes = self.git_client.get_pull_request_iterations_changes( + repository_id=self.repo, + pull_request_id=pull_request_id, + project=self.project + ) + + return changes.__dict__ + except Exception as e: + raise AzureDevOpsClientError(f"Failed to get pull request changes: {str(e)}") + + def get_pull_request_thread_comments(self, pull_request_id: int, thread_id: int) -> List[Dict[str, Any]]: + """ + Get all comments in a PR thread. + + Args: + pull_request_id: ID of the PR + thread_id: ID of the thread + + Returns: + List of comments in the thread + + Raises: + AzureDevOpsClientError: If request fails + """ + try: + comments = self.git_client.get_comments( + repository_id=self.repo, + pull_request_id=pull_request_id, + thread_id=thread_id, + project=self.project + ) + + return [comment.__dict__ for comment in comments] + except Exception as e: + raise AzureDevOpsClientError(f"Failed to get thread comments: {str(e)}") + + def abandon_pull_request(self, pull_request_id: int) -> Dict[str, Any]: + """ + Abandon a Pull Request. + + Args: + pull_request_id: ID of the PR + + Returns: + Updated PR details + + Raises: + AzureDevOpsClientError: If request fails + """ + try: + # First get the current PR + pull_request = self.git_client.get_pull_request( + repository_id=self.repo, + project=self.project, + pull_request_id=pull_request_id + ) + + # Update status to abandoned + pull_request.status = "abandoned" + + # Abandon the PR + result = self.git_client.update_pull_request( + git_pull_request=pull_request, + repository_id=self.repo, + project=self.project, + pull_request_id=pull_request_id + ) + + return result.__dict__ + except Exception as e: + raise AzureDevOpsClientError(f"Failed to abandon pull request: {str(e)}") + + def reactivate_pull_request(self, pull_request_id: int) -> Dict[str, Any]: + """ + Reactivate an abandoned Pull Request. + + Args: + pull_request_id: ID of the PR + + Returns: + Updated PR details + + Raises: + AzureDevOpsClientError: If request fails + """ + try: + # First get the current PR + pull_request = self.git_client.get_pull_request( + repository_id=self.repo, + project=self.project, + pull_request_id=pull_request_id + ) + + # Update status to active + pull_request.status = "active" + + # Reactivate the PR + result = self.git_client.update_pull_request( + git_pull_request=pull_request, + repository_id=self.repo, + project=self.project, + pull_request_id=pull_request_id + ) + + return result.__dict__ + except Exception as e: + raise AzureDevOpsClientError(f"Failed to reactivate pull request: {str(e)}") + + def get_pull_request_policy_evaluations(self, pull_request_id: int) -> List[Dict[str, Any]]: + """ + Get policy evaluations for a Pull Request. + + Args: + pull_request_id: ID of the PR + + Returns: + List of policy evaluations + + Raises: + AzureDevOpsClientError: If request fails + """ + try: + # Get the PR details first + pr = self.git_client.get_pull_request( + repository_id=self.repo, + project=self.project, + pull_request_id=pull_request_id + ) + + # Get the policy client + connection = get_connection() + if not connection: + raise AzureDevOpsClientError( + "Azure DevOps PAT or organization URL not found in environment variables." + ) + policy_client = connection.clients.get_policy_client() + + # Get policy evaluations + evaluations = policy_client.get_policy_evaluations( + project=self.project, + artifact_id=f"vstfs:///Git/PullRequestId/{pr.repository.project.id}/{pull_request_id}" + ) + + return [evaluation.__dict__ for evaluation in evaluations] + except Exception as e: + raise AzureDevOpsClientError(f"Failed to get policy evaluations: {str(e)}") \ No newline at end of file diff --git a/src/mcp_azure_devops/features/pull_requests/tools.py b/src/mcp_azure_devops/features/pull_requests/tools.py index 77dda9e..e2df0bd 100644 --- a/src/mcp_azure_devops/features/pull_requests/tools.py +++ b/src/mcp_azure_devops/features/pull_requests/tools.py @@ -295,6 +295,165 @@ def _complete_pull_request_impl( return f"Pull request {pull_request_id} completed successfully by {completed_by}\nMerge strategy: {merge_strategy}\nSource branch deleted: {delete_source_branch}" except Exception as e: return f"Error completing pull request: {str(e)}" + +def _get_pull_request_work_items_impl(client: AzureDevOpsClient, pull_request_id: int) -> str: + """Implementation for getting work items linked to a PR.""" + work_items = client.get_pull_request_work_items(pull_request_id=pull_request_id) + + if not work_items: + return "No work items are linked to this pull request." + + result = f"Work Items linked to PR #{pull_request_id}:\n\n" + for i, item in enumerate(work_items, 1): + result += f"{i}. ID: {item.get('id', 'N/A')}\n" + result += f" Title: {item.get('title', 'N/A')}\n" + result += f" Type: {item.get('work_item_type', 'N/A')}\n" + result += f" State: {item.get('state', 'N/A')}\n\n" + + return result + +def _add_work_items_to_pull_request_impl( + client: AzureDevOpsClient, + pull_request_id: int, + work_item_ids: List[int] +) -> str: + """Implementation for linking work items to a PR.""" + result = client.add_work_items_to_pull_request( + pull_request_id=pull_request_id, + work_item_ids=work_item_ids + ) + + if result: + work_items_str = ", ".join([str(id) for id in work_item_ids]) + return f"Successfully linked work item(s) #{work_items_str} to pull request #{pull_request_id}." + else: + return f"Failed to link work items to pull request #{pull_request_id}." + +def _get_pull_request_commits_impl(client: AzureDevOpsClient, pull_request_id: int) -> str: + """Implementation for getting commits in a PR.""" + commits = client.get_pull_request_commits(pull_request_id=pull_request_id) + + if not commits: + return f"No commits found in pull request #{pull_request_id}." + + result = f"Commits in PR #{pull_request_id}:\n\n" + for i, commit in enumerate(commits, 1): + result += f"{i}. Commit ID: {commit.get('commit_id', 'N/A')[:8]}\n" + result += f" Author: {commit.get('author', {}).get('name', 'N/A')}\n" + result += f" Date: {commit.get('author', {}).get('date', 'N/A')}\n" + result += f" Comment: {commit.get('comment', 'N/A')[:100]}" + if len(commit.get('comment', '')) > 100: + result += "..." + result += "\n\n" + + return result + +def _get_pull_request_changes_impl(client: AzureDevOpsClient, pull_request_id: int) -> str: + """Implementation for getting changes in a PR.""" + changes = client.get_pull_request_changes(pull_request_id=pull_request_id) + + if not changes or not changes.get('changes'): + return f"No file changes found in pull request #{pull_request_id}." + + result = f"File changes in PR #{pull_request_id}:\n\n" + + for i, change in enumerate(changes.get('changes', []), 1): + change_type = change.get('change_type', 'N/A') + item = change.get('item', {}) + path = item.get('path', 'N/A') + + result += f"{i}. {path}\n" + result += f" Change type: {change_type}\n" + + if change_type == 'edit': + result += f" Additions: {change.get('line_count_additions', 0)}\n" + result += f" Deletions: {change.get('line_count_deletions', 0)}\n" + + result += "\n" + + # Add summary + additions = sum(change.get('line_count_additions', 0) for change in changes.get('changes', [])) + deletions = sum(change.get('line_count_deletions', 0) for change in changes.get('changes', [])) + files_changed = len(changes.get('changes', [])) + + result += f"Summary: {files_changed} files changed, {additions} additions, {deletions} deletions." + + return result + +def _get_pull_request_thread_comments_impl( + client: AzureDevOpsClient, + pull_request_id: int, + thread_id: int +) -> str: + """Implementation for getting comments in a PR thread.""" + comments = client.get_pull_request_thread_comments( + pull_request_id=pull_request_id, + thread_id=thread_id + ) + + if not comments: + return f"No comments found in thread #{thread_id} of pull request #{pull_request_id}." + + result = f"Comments in thread #{thread_id} of PR #{pull_request_id}:\n\n" + + for i, comment in enumerate(comments, 1): + author = comment.get('author', {}).get('display_name', 'Unknown') + content = comment.get('content', 'N/A') + date = comment.get('published_date', 'N/A') + + result += f"{i}. Author: {author}\n" + result += f" Date: {date}\n" + result += f" Content: {content}\n\n" + + return result + +def _abandon_pull_request_impl(client: AzureDevOpsClient, pull_request_id: int) -> str: + """Implementation for abandoning a PR.""" + result = client.abandon_pull_request(pull_request_id=pull_request_id) + + if result and result.get('status') == 'abandoned': + return f"Successfully abandoned pull request #{pull_request_id}." + else: + return f"Failed to abandon pull request #{pull_request_id}." + +def _reactivate_pull_request_impl(client: AzureDevOpsClient, pull_request_id: int) -> str: + """Implementation for reactivating a PR.""" + result = client.reactivate_pull_request(pull_request_id=pull_request_id) + + if result and result.get('status') == 'active': + return f"Successfully reactivated pull request #{pull_request_id}." + else: + return f"Failed to reactivate pull request #{pull_request_id}." + +def _get_pull_request_policy_evaluations_impl(client: AzureDevOpsClient, pull_request_id: int) -> str: + """Implementation for getting policy evaluations for a PR.""" + evaluations = client.get_pull_request_policy_evaluations(pull_request_id=pull_request_id) + + if not evaluations: + return f"No policy evaluations found for pull request #{pull_request_id}." + + result = f"Policy evaluations for PR #{pull_request_id}:\n\n" + + for i, eval in enumerate(evaluations, 1): + policy_type = eval.get('configuration', {}).get('type', {}).get('display_name', 'Unknown Policy') + status = eval.get('status', 'Unknown') + + result += f"{i}. Policy: {policy_type}\n" + result += f" Status: {status}\n" + + if status == 'rejected': + result += f" Reason: {eval.get('context', {}).get('error_message', 'N/A')}\n" + + result += "\n" + + # Add summary + approved = sum(1 for eval in evaluations if eval.get('status') == 'approved') + rejected = sum(1 for eval in evaluations if eval.get('status') == 'rejected') + pending = sum(1 for eval in evaluations if eval.get('status') == 'queued' or eval.get('status') == 'running') + + result += f"Summary: {approved} approved, {rejected} rejected, {pending} pending." + + return result def register_tools(mcp) -> None: @@ -621,5 +780,297 @@ def complete_pull_request( merge_strategy=merge_strategy, delete_source_branch=delete_source_branch ) + except AzureDevOpsClientError as e: + return f"Error: {str(e)}" + + @mcp.tool() + def get_pull_request_work_items( + organization: str, + project: str, + repo: str, + personal_access_token: str, + pull_request_id: int + ) -> str: + """ + Get work items linked to a Pull Request in Azure DevOps. + + Args: + organization: Azure DevOps organization name + project: Azure DevOps project name + repo: Azure DevOps repository name + personal_access_token: PAT with appropriate permissions + pull_request_id: ID of the PR + + Returns: + Formatted string containing linked work items information + """ + try: + client = AzureDevOpsClient( + organization=organization, + project=project, + repo=repo, + personal_access_token=personal_access_token + ) + return _get_pull_request_work_items_impl( + client=client, + pull_request_id=pull_request_id + ) + except AzureDevOpsClientError as e: + return f"Error: {str(e)}" + + @mcp.tool() + def add_work_items_to_pull_request( + organization: str, + project: str, + repo: str, + personal_access_token: str, + pull_request_id: int, + work_item_ids: str + ) -> str: + """ + Link work items to a Pull Request in Azure DevOps. + + Args: + organization: Azure DevOps organization name + project: Azure DevOps project name + repo: Azure DevOps repository name + personal_access_token: PAT with appropriate permissions + pull_request_id: ID of the PR + work_item_ids: Comma-separated list of work item IDs to link + + Returns: + Formatted string indicating success or failure + """ + try: + client = AzureDevOpsClient( + organization=organization, + project=project, + repo=repo, + personal_access_token=personal_access_token + ) + + # Parse the comma-separated list of work item IDs + work_item_id_list = [int(id.strip()) for id in work_item_ids.split(",")] + + return _add_work_items_to_pull_request_impl( + client=client, + pull_request_id=pull_request_id, + work_item_ids=work_item_id_list + ) + except AzureDevOpsClientError as e: + return f"Error: {str(e)}" + except ValueError: + return "Error: Work item IDs must be valid integers separated by commas." + + @mcp.tool() + def get_pull_request_commits( + organization: str, + project: str, + repo: str, + personal_access_token: str, + pull_request_id: int + ) -> str: + """ + Get all commits in a Pull Request in Azure DevOps. + + Args: + organization: Azure DevOps organization name + project: Azure DevOps project name + repo: Azure DevOps repository name + personal_access_token: PAT with appropriate permissions + pull_request_id: ID of the PR + + Returns: + Formatted string containing commit information + """ + try: + client = AzureDevOpsClient( + organization=organization, + project=project, + repo=repo, + personal_access_token=personal_access_token + ) + return _get_pull_request_commits_impl( + client=client, + pull_request_id=pull_request_id + ) + except AzureDevOpsClientError as e: + return f"Error: {str(e)}" + + @mcp.tool() + def get_pull_request_changes( + organization: str, + project: str, + repo: str, + personal_access_token: str, + pull_request_id: int + ) -> str: + """ + Get all file changes in a Pull Request in Azure DevOps. + + Args: + organization: Azure DevOps organization name + project: Azure DevOps project name + repo: Azure DevOps repository name + personal_access_token: PAT with appropriate permissions + pull_request_id: ID of the PR + + Returns: + Formatted string containing file change information + """ + try: + client = AzureDevOpsClient( + organization=organization, + project=project, + repo=repo, + personal_access_token=personal_access_token + ) + return _get_pull_request_changes_impl( + client=client, + pull_request_id=pull_request_id + ) + except AzureDevOpsClientError as e: + return f"Error: {str(e)}" + + @mcp.tool() + def get_pull_request_thread_comments( + organization: str, + project: str, + repo: str, + personal_access_token: str, + pull_request_id: int, + thread_id: int + ) -> str: + """ + Get all comments in a Pull Request thread in Azure DevOps. + + Args: + organization: Azure DevOps organization name + project: Azure DevOps project name + repo: Azure DevOps repository name + personal_access_token: PAT with appropriate permissions + pull_request_id: ID of the PR + thread_id: ID of the comment thread + + Returns: + Formatted string containing comment information + """ + try: + client = AzureDevOpsClient( + organization=organization, + project=project, + repo=repo, + personal_access_token=personal_access_token + ) + return _get_pull_request_thread_comments_impl( + client=client, + pull_request_id=pull_request_id, + thread_id=thread_id + ) + except AzureDevOpsClientError as e: + return f"Error: {str(e)}" + + @mcp.tool() + def abandon_pull_request( + organization: str, + project: str, + repo: str, + personal_access_token: str, + pull_request_id: int + ) -> str: + """ + Abandon a Pull Request in Azure DevOps. + + Args: + organization: Azure DevOps organization name + project: Azure DevOps project name + repo: Azure DevOps repository name + personal_access_token: PAT with appropriate permissions + pull_request_id: ID of the PR + + Returns: + Formatted string indicating success or failure + """ + try: + client = AzureDevOpsClient( + organization=organization, + project=project, + repo=repo, + personal_access_token=personal_access_token + ) + return _abandon_pull_request_impl( + client=client, + pull_request_id=pull_request_id + ) + except AzureDevOpsClientError as e: + return f"Error: {str(e)}" + + @mcp.tool() + def reactivate_pull_request( + organization: str, + project: str, + repo: str, + personal_access_token: str, + pull_request_id: int + ) -> str: + """ + Reactivate an abandoned Pull Request in Azure DevOps. + + Args: + organization: Azure DevOps organization name + project: Azure DevOps project name + repo: Azure DevOps repository name + personal_access_token: PAT with appropriate permissions + pull_request_id: ID of the PR + + Returns: + Formatted string indicating success or failure + """ + try: + client = AzureDevOpsClient( + organization=organization, + project=project, + repo=repo, + personal_access_token=personal_access_token + ) + return _reactivate_pull_request_impl( + client=client, + pull_request_id=pull_request_id + ) + except AzureDevOpsClientError as e: + return f"Error: {str(e)}" + + @mcp.tool() + def get_pull_request_policy_evaluations( + organization: str, + project: str, + repo: str, + personal_access_token: str, + pull_request_id: int + ) -> str: + """ + Get policy evaluations for a Pull Request in Azure DevOps. + + Args: + organization: Azure DevOps organization name + project: Azure DevOps project name + repo: Azure DevOps repository name + personal_access_token: PAT with appropriate permissions + pull_request_id: ID of the PR + + Returns: + Formatted string containing policy evaluation information + """ + try: + client = AzureDevOpsClient( + organization=organization, + project=project, + repo=repo, + personal_access_token=personal_access_token + ) + return _get_pull_request_policy_evaluations_impl( + client=client, + pull_request_id=pull_request_id + ) except AzureDevOpsClientError as e: return f"Error: {str(e)}" \ No newline at end of file From fa6164c320e7da3f4be1267ecf9ede99d7be32bd Mon Sep 17 00:00:00 2001 From: Nam P Tran Date: Sat, 29 Mar 2025 19:57:05 +0700 Subject: [PATCH 04/10] Register the pull request feature --- src/mcp_azure_devops/features/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/mcp_azure_devops/features/__init__.py b/src/mcp_azure_devops/features/__init__.py index e808cd6..043b886 100644 --- a/src/mcp_azure_devops/features/__init__.py +++ b/src/mcp_azure_devops/features/__init__.py @@ -2,6 +2,7 @@ from mcp_azure_devops.features import work_items from mcp_azure_devops.features import projects from mcp_azure_devops.features import teams +from mcp_azure_devops.features import pull_requests def register_all(mcp): """ @@ -13,3 +14,4 @@ def register_all(mcp): work_items.register(mcp) projects.register(mcp) teams.register(mcp) + pull_requests.register(mcp) From 955a0674353791645b6aa8e1c16f378e72e345b4 Mon Sep 17 00:00:00 2001 From: Nam P Tran Date: Sat, 29 Mar 2025 20:29:31 +0700 Subject: [PATCH 05/10] commit uv.lock --- uv.lock | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/uv.lock b/uv.lock index 9bcd575..c30592e 100644 --- a/uv.lock +++ b/uv.lock @@ -26,6 +26,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, ] +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, +] + [[package]] name = "azure-core" version = "1.32.0" @@ -61,6 +70,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, ] +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + [[package]] name = "charset-normalizer" version = "3.4.1" @@ -279,6 +307,7 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, + { name = "trio" }, ] [package.metadata] @@ -291,6 +320,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.0.267" }, + { name = "trio", marker = "extra == 'dev'", specifier = ">=0.22.0" }, ] provides-extras = ["dev"] @@ -337,6 +367,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/80/cab10959dc1faead58dc8384a781dfbf93cb4d33d50988f7a69f1b7c9bbe/oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca", size = 151688 }, ] +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692 }, +] + [[package]] name = "packaging" version = "24.2" @@ -355,6 +397,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, ] +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + [[package]] name = "pydantic" version = "2.10.6" @@ -611,6 +662,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, +] + [[package]] name = "sse-starlette" version = "2.2.1" @@ -675,6 +735,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, ] +[[package]] +name = "trio" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/47/f62e62a1a6f37909aed0bf8f5d5411e06fa03846cfcb64540cd1180ccc9f/trio-0.29.0.tar.gz", hash = "sha256:ea0d3967159fc130acb6939a0be0e558e364fee26b5deeecc893a6b08c361bdf", size = 588952 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/55/c4d9bea8b3d7937901958f65124123512419ab0eb73695e5f382521abbfb/trio-0.29.0-py3-none-any.whl", hash = "sha256:d8c463f1a9cc776ff63e331aba44c125f423a5a13c684307e828d930e625ba66", size = 492920 }, +] + [[package]] name = "typer" version = "0.15.2" From dc98110fe17c10d7d2eccb4edce8d99dbbf19f40 Mon Sep 17 00:00:00 2001 From: Nam P Tran Date: Sat, 29 Mar 2025 21:10:37 +0700 Subject: [PATCH 06/10] fixing issue wrong name space --- src/mcp_azure_devops/features/pull_requests/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp_azure_devops/features/pull_requests/__init__.py b/src/mcp_azure_devops/features/pull_requests/__init__.py index 23fca2d..e264674 100644 --- a/src/mcp_azure_devops/features/pull_requests/__init__.py +++ b/src/mcp_azure_devops/features/pull_requests/__init__.py @@ -1,5 +1,5 @@ # Pull Requests feature package for Azure DevOps MCP -from mcp_azure_devops.features.work_items import tools +from mcp_azure_devops.features.pull_requests import tools def register(mcp): """ From 4c6081a02cc82254a79c34b4a5db1bd6bd9899c0 Mon Sep 17 00:00:00 2001 From: Nam P Tran Date: Sat, 29 Mar 2025 22:23:36 +0700 Subject: [PATCH 07/10] Rese PAT from utility that mean get PAT from environments --- .../features/pull_requests/common.py | 11 +-- .../features/pull_requests/tools.py | 81 ++++--------------- 2 files changed, 18 insertions(+), 74 deletions(-) diff --git a/src/mcp_azure_devops/features/pull_requests/common.py b/src/mcp_azure_devops/features/pull_requests/common.py index e79c94a..5bf45cf 100644 --- a/src/mcp_azure_devops/features/pull_requests/common.py +++ b/src/mcp_azure_devops/features/pull_requests/common.py @@ -51,7 +51,7 @@ def get_pull_request_client(): class AzureDevOpsClient: """Client for Azure DevOps API operations related to Pull Requests.""" - def __init__(self, organization: str, project: str, repo: str, personal_access_token: str): + def __init__(self, organization: str, project: str, repo: str): """ Initialize the Azure DevOps client. @@ -59,19 +59,10 @@ def __init__(self, organization: str, project: str, repo: str, personal_access_t organization: Azure DevOps organization name project: Azure DevOps project name repo: Azure DevOps repository name - personal_access_token: PAT with appropriate permissions """ self.organization = organization self.project = project self.repo = repo - - # Create connection using SDK - credentials = BasicAuthentication('', personal_access_token) - connection = Connection(base_url=f'https://dev.azure.com/{organization}', creds=credentials) - if not connection: - raise AzureDevOpsClientError( - "Azure DevOps PAT or organization URL not found in environment variables." - ) self.git_client = get_pull_request_client() def create_pull_request(self, title: str, description: str, source_branch: str, diff --git a/src/mcp_azure_devops/features/pull_requests/tools.py b/src/mcp_azure_devops/features/pull_requests/tools.py index e2df0bd..1728a4f 100644 --- a/src/mcp_azure_devops/features/pull_requests/tools.py +++ b/src/mcp_azure_devops/features/pull_requests/tools.py @@ -483,7 +483,6 @@ def create_pull_request( organization: Azure DevOps organization name project: Azure DevOps project name repo: Azure DevOps repository name - personal_access_token: PAT with appropriate permissions title: PR title description: PR description source_branch: Source branch name @@ -497,8 +496,7 @@ def create_pull_request( client = AzureDevOpsClient( organization=organization, project=project, - repo=repo, - personal_access_token=personal_access_token + repo=repo ) return _create_pull_request_impl( client=client, @@ -516,7 +514,6 @@ def update_pull_request( organization: str, project: str, repo: str, - personal_access_token: str, pull_request_id: int, title: Optional[str] = None, description: Optional[str] = None, @@ -529,7 +526,6 @@ def update_pull_request( organization: Azure DevOps organization name project: Azure DevOps project name repo: Azure DevOps repository name - personal_access_token: PAT with appropriate permissions pull_request_id: ID of the PR to update title: New PR title (optional) description: New PR description (optional) @@ -542,8 +538,7 @@ def update_pull_request( client = AzureDevOpsClient( organization=organization, project=project, - repo=repo, - personal_access_token=personal_access_token + repo=repo ) return _update_pull_request_impl( client=client, @@ -560,7 +555,6 @@ def list_pull_requests( organization: str, project: str, repo: str, - personal_access_token: str, status: Optional[str] = None, creator: Optional[str] = None, reviewer: Optional[str] = None, @@ -573,7 +567,6 @@ def list_pull_requests( organization: Azure DevOps organization name project: Azure DevOps project name repo: Azure DevOps repository name - personal_access_token: PAT with appropriate permissions status: Filter by status (active, abandoned, completed, all) (optional) creator: Filter by creator ID (optional) reviewer: Filter by reviewer ID (optional) @@ -586,8 +579,7 @@ def list_pull_requests( client = AzureDevOpsClient( organization=organization, project=project, - repo=repo, - personal_access_token=personal_access_token + repo=repo ) return _list_pull_requests_impl( client=client, @@ -604,7 +596,6 @@ def get_pull_request( organization: str, project: str, repo: str, - personal_access_token: str, pull_request_id: int ) -> str: """ @@ -614,7 +605,6 @@ def get_pull_request( organization: Azure DevOps organization name project: Azure DevOps project name repo: Azure DevOps repository name - personal_access_token: PAT with appropriate permissions pull_request_id: ID of the PR Returns: @@ -624,8 +614,7 @@ def get_pull_request( client = AzureDevOpsClient( organization=organization, project=project, - repo=repo, - personal_access_token=personal_access_token + repo=repo ) return _get_pull_request_impl( client=client, @@ -639,7 +628,6 @@ def add_comment( organization: str, project: str, repo: str, - personal_access_token: str, pull_request_id: int, content: str ) -> str: @@ -650,7 +638,6 @@ def add_comment( organization: Azure DevOps organization name project: Azure DevOps project name repo: Azure DevOps repository name - personal_access_token: PAT with appropriate permissions pull_request_id: ID of the PR content: Comment text @@ -661,8 +648,7 @@ def add_comment( client = AzureDevOpsClient( organization=organization, project=project, - repo=repo, - personal_access_token=personal_access_token + repo=repo ) return _add_comment_impl( client=client, @@ -676,8 +662,7 @@ def add_comment( def approve_pull_request( organization: str, project: str, - repo: str, - personal_access_token: str, + repo: str, pull_request_id: int ) -> str: """ @@ -687,7 +672,6 @@ def approve_pull_request( organization: Azure DevOps organization name project: Azure DevOps project name repo: Azure DevOps repository name - personal_access_token: PAT with appropriate permissions pull_request_id: ID of the PR Returns: @@ -697,8 +681,7 @@ def approve_pull_request( client = AzureDevOpsClient( organization=organization, project=project, - repo=repo, - personal_access_token=personal_access_token + repo=repo ) return _approve_pull_request_impl( client=client, @@ -712,7 +695,6 @@ def reject_pull_request( organization: str, project: str, repo: str, - personal_access_token: str, pull_request_id: int ) -> str: """ @@ -722,7 +704,6 @@ def reject_pull_request( organization: Azure DevOps organization name project: Azure DevOps project name repo: Azure DevOps repository name - personal_access_token: PAT with appropriate permissions pull_request_id: ID of the PR Returns: @@ -733,7 +714,6 @@ def reject_pull_request( organization=organization, project=project, repo=repo, - personal_access_token=personal_access_token ) return _reject_pull_request_impl( client=client, @@ -747,7 +727,6 @@ def complete_pull_request( organization: str, project: str, repo: str, - personal_access_token: str, pull_request_id: int, merge_strategy: str = "squash", delete_source_branch: bool = False @@ -759,7 +738,6 @@ def complete_pull_request( organization: Azure DevOps organization name project: Azure DevOps project name repo: Azure DevOps repository name - personal_access_token: PAT with appropriate permissions pull_request_id: ID of the PR merge_strategy: Merge strategy (squash, rebase, rebaseMerge, merge) (optional) delete_source_branch: Whether to delete source branch after merge (optional) @@ -771,8 +749,7 @@ def complete_pull_request( client = AzureDevOpsClient( organization=organization, project=project, - repo=repo, - personal_access_token=personal_access_token + repo=repo ) return _complete_pull_request_impl( client=client, @@ -788,7 +765,6 @@ def get_pull_request_work_items( organization: str, project: str, repo: str, - personal_access_token: str, pull_request_id: int ) -> str: """ @@ -797,8 +773,7 @@ def get_pull_request_work_items( Args: organization: Azure DevOps organization name project: Azure DevOps project name - repo: Azure DevOps repository name - personal_access_token: PAT with appropriate permissions + repo: Azure DevOps repository name pull_request_id: ID of the PR Returns: @@ -808,8 +783,7 @@ def get_pull_request_work_items( client = AzureDevOpsClient( organization=organization, project=project, - repo=repo, - personal_access_token=personal_access_token + repo=repo ) return _get_pull_request_work_items_impl( client=client, @@ -823,7 +797,6 @@ def add_work_items_to_pull_request( organization: str, project: str, repo: str, - personal_access_token: str, pull_request_id: int, work_item_ids: str ) -> str: @@ -834,7 +807,6 @@ def add_work_items_to_pull_request( organization: Azure DevOps organization name project: Azure DevOps project name repo: Azure DevOps repository name - personal_access_token: PAT with appropriate permissions pull_request_id: ID of the PR work_item_ids: Comma-separated list of work item IDs to link @@ -845,8 +817,7 @@ def add_work_items_to_pull_request( client = AzureDevOpsClient( organization=organization, project=project, - repo=repo, - personal_access_token=personal_access_token + repo=repo ) # Parse the comma-separated list of work item IDs @@ -867,7 +838,6 @@ def get_pull_request_commits( organization: str, project: str, repo: str, - personal_access_token: str, pull_request_id: int ) -> str: """ @@ -877,7 +847,6 @@ def get_pull_request_commits( organization: Azure DevOps organization name project: Azure DevOps project name repo: Azure DevOps repository name - personal_access_token: PAT with appropriate permissions pull_request_id: ID of the PR Returns: @@ -887,8 +856,7 @@ def get_pull_request_commits( client = AzureDevOpsClient( organization=organization, project=project, - repo=repo, - personal_access_token=personal_access_token + repo=repo ) return _get_pull_request_commits_impl( client=client, @@ -902,7 +870,6 @@ def get_pull_request_changes( organization: str, project: str, repo: str, - personal_access_token: str, pull_request_id: int ) -> str: """ @@ -912,7 +879,6 @@ def get_pull_request_changes( organization: Azure DevOps organization name project: Azure DevOps project name repo: Azure DevOps repository name - personal_access_token: PAT with appropriate permissions pull_request_id: ID of the PR Returns: @@ -922,8 +888,7 @@ def get_pull_request_changes( client = AzureDevOpsClient( organization=organization, project=project, - repo=repo, - personal_access_token=personal_access_token + repo=repo ) return _get_pull_request_changes_impl( client=client, @@ -937,7 +902,6 @@ def get_pull_request_thread_comments( organization: str, project: str, repo: str, - personal_access_token: str, pull_request_id: int, thread_id: int ) -> str: @@ -948,7 +912,6 @@ def get_pull_request_thread_comments( organization: Azure DevOps organization name project: Azure DevOps project name repo: Azure DevOps repository name - personal_access_token: PAT with appropriate permissions pull_request_id: ID of the PR thread_id: ID of the comment thread @@ -959,8 +922,7 @@ def get_pull_request_thread_comments( client = AzureDevOpsClient( organization=organization, project=project, - repo=repo, - personal_access_token=personal_access_token + repo=repo ) return _get_pull_request_thread_comments_impl( client=client, @@ -975,7 +937,6 @@ def abandon_pull_request( organization: str, project: str, repo: str, - personal_access_token: str, pull_request_id: int ) -> str: """ @@ -985,7 +946,6 @@ def abandon_pull_request( organization: Azure DevOps organization name project: Azure DevOps project name repo: Azure DevOps repository name - personal_access_token: PAT with appropriate permissions pull_request_id: ID of the PR Returns: @@ -995,8 +955,7 @@ def abandon_pull_request( client = AzureDevOpsClient( organization=organization, project=project, - repo=repo, - personal_access_token=personal_access_token + repo=repo ) return _abandon_pull_request_impl( client=client, @@ -1010,7 +969,6 @@ def reactivate_pull_request( organization: str, project: str, repo: str, - personal_access_token: str, pull_request_id: int ) -> str: """ @@ -1020,7 +978,6 @@ def reactivate_pull_request( organization: Azure DevOps organization name project: Azure DevOps project name repo: Azure DevOps repository name - personal_access_token: PAT with appropriate permissions pull_request_id: ID of the PR Returns: @@ -1030,8 +987,7 @@ def reactivate_pull_request( client = AzureDevOpsClient( organization=organization, project=project, - repo=repo, - personal_access_token=personal_access_token + repo=repo ) return _reactivate_pull_request_impl( client=client, @@ -1045,7 +1001,6 @@ def get_pull_request_policy_evaluations( organization: str, project: str, repo: str, - personal_access_token: str, pull_request_id: int ) -> str: """ @@ -1055,7 +1010,6 @@ def get_pull_request_policy_evaluations( organization: Azure DevOps organization name project: Azure DevOps project name repo: Azure DevOps repository name - personal_access_token: PAT with appropriate permissions pull_request_id: ID of the PR Returns: @@ -1065,8 +1019,7 @@ def get_pull_request_policy_evaluations( client = AzureDevOpsClient( organization=organization, project=project, - repo=repo, - personal_access_token=personal_access_token + repo=repo ) return _get_pull_request_policy_evaluations_impl( client=client, From def5f3e06f747c58b9629d530efc60f08245dcc2 Mon Sep 17 00:00:00 2001 From: Nam P Tran Date: Sun, 30 Mar 2025 21:18:36 +0700 Subject: [PATCH 08/10] Fix bug for get all commit for a pull request --- .../features/pull_requests/common.py | 43 +++++++++++++- .../features/pull_requests/tools.py | 56 +++++++++++++------ 2 files changed, 79 insertions(+), 20 deletions(-) diff --git a/src/mcp_azure_devops/features/pull_requests/common.py b/src/mcp_azure_devops/features/pull_requests/common.py index 5bf45cf..c52c69c 100644 --- a/src/mcp_azure_devops/features/pull_requests/common.py +++ b/src/mcp_azure_devops/features/pull_requests/common.py @@ -406,7 +406,7 @@ def get_pull_request_commits(self, pull_request_id: int) -> List[Dict[str, Any]] Args: pull_request_id: ID of the PR - + Returns: List of commits in the PR @@ -414,15 +414,52 @@ def get_pull_request_commits(self, pull_request_id: int) -> List[Dict[str, Any]] AzureDevOpsClientError: If request fails """ try: + # Print debug information + print(f"Fetching commits for PR #{pull_request_id}") + print(f"Repository ID: {self.repo}") + print(f"Project: {self.project}") + + # Make the API call with correct parameters commits = self.git_client.get_pull_request_commits( repository_id=self.repo, pull_request_id=pull_request_id, project=self.project ) - return [commit.__dict__ for commit in commits] + # Convert the GitCommitRef objects to dictionaries properly + result = [] + for commit in commits: + # Convert the commit to a dictionary in a safer way + commit_dict = {} + + # Common properties in GitCommitRef + if hasattr(commit, 'commit_id'): + commit_dict['commitId'] = commit.commit_id + + if hasattr(commit, 'author'): + commit_dict['author'] = { + 'name': getattr(commit.author, 'name', None), + 'email': getattr(commit.author, 'email', None), + 'date': getattr(commit.author, 'date', None) + } + + if hasattr(commit, 'committer'): + commit_dict['committer'] = { + 'name': getattr(commit.committer, 'name', None), + 'date': getattr(commit.committer, 'date', None) + } + + if hasattr(commit, 'comment'): + commit_dict['comment'] = commit.comment + + result.append(commit_dict) + + return result except Exception as e: - raise AzureDevOpsClientError(f"Failed to get pull request commits: {str(e)}") + # Add more details to help with debugging + error_message = f"Failed to get pull request commits: {str(e)}" + print(error_message) # Print for immediate debugging + raise AzureDevOpsClientError(error_message) def get_pull_request_changes(self, pull_request_id: int) -> Dict[str, Any]: diff --git a/src/mcp_azure_devops/features/pull_requests/tools.py b/src/mcp_azure_devops/features/pull_requests/tools.py index 1728a4f..198314b 100644 --- a/src/mcp_azure_devops/features/pull_requests/tools.py +++ b/src/mcp_azure_devops/features/pull_requests/tools.py @@ -331,22 +331,45 @@ def _add_work_items_to_pull_request_impl( def _get_pull_request_commits_impl(client: AzureDevOpsClient, pull_request_id: int) -> str: """Implementation for getting commits in a PR.""" - commits = client.get_pull_request_commits(pull_request_id=pull_request_id) - - if not commits: - return f"No commits found in pull request #{pull_request_id}." - - result = f"Commits in PR #{pull_request_id}:\n\n" - for i, commit in enumerate(commits, 1): - result += f"{i}. Commit ID: {commit.get('commit_id', 'N/A')[:8]}\n" - result += f" Author: {commit.get('author', {}).get('name', 'N/A')}\n" - result += f" Date: {commit.get('author', {}).get('date', 'N/A')}\n" - result += f" Comment: {commit.get('comment', 'N/A')[:100]}" - if len(commit.get('comment', '')) > 100: - result += "..." - result += "\n\n" - - return result + try: + commits = client.get_pull_request_commits(pull_request_id=pull_request_id) + + if not commits: + return f"No commits found in pull request #{pull_request_id}." + + result = f"Commits in PR #{pull_request_id}:\n\n" + for i, commit in enumerate(commits, 1): + # Handle commit ID + commit_id = commit.get('commitId', commit.get('commit_id', 'N/A')) + + # Handle author information with safer access + author = commit.get('author', {}) + author_name = 'N/A' + author_date = 'N/A' + + if isinstance(author, dict): + author_name = author.get('name', 'N/A') + # Safely get date and don't try to format it + author_date = author.get('date', 'N/A') + + # Handle comment/message + comment = commit.get('comment', commit.get('commentTruncated', + commit.get('message', 'N/A'))) + + # Format output + result += f"{i}. Commit ID: {commit_id[:8] if commit_id != 'N/A' else 'N/A'}\n" + result += f" Author: {author_name}\n" + result += f" Date: {author_date}\n" + result += f" Comment: {comment[:100]}" + if comment != 'N/A' and len(comment) > 100: + result += "..." + result += "\n\n" + + return result + except Exception as e: + # Add more detailed error info + import traceback + return f"Error processing commits for PR #{pull_request_id}: {str(e)}\n{traceback.format_exc()}" def _get_pull_request_changes_impl(client: AzureDevOpsClient, pull_request_id: int) -> str: """Implementation for getting changes in a PR.""" @@ -469,7 +492,6 @@ def create_pull_request( organization: str, project: str, repo: str, - personal_access_token: str, title: str, description: str, source_branch: str, From 5a26bec6130696650c0dc0cfdfd5962f65dae021 Mon Sep 17 00:00:00 2001 From: Vortiago Date: Tue, 27 May 2025 21:50:55 +0200 Subject: [PATCH 09/10] Refactor pull request methods to improve error handling and support for different response types --- .../features/pull_requests/common.py | 14 ++++++++++++-- .../features/pull_requests/tools.py | 18 ++++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/mcp_azure_devops/features/pull_requests/common.py b/src/mcp_azure_devops/features/pull_requests/common.py index c52c69c..eabaddf 100644 --- a/src/mcp_azure_devops/features/pull_requests/common.py +++ b/src/mcp_azure_devops/features/pull_requests/common.py @@ -363,7 +363,7 @@ def get_pull_request_work_items(self, pull_request_id: int) -> List[Dict[str, An AzureDevOpsClientError: If request fails """ try: - work_items = self.git_client.get_pull_request_work_items( + work_items = self.git_client.get_pull_request_work_item_refs( repository_id=self.repo, pull_request_id=pull_request_id, project=self.project @@ -476,12 +476,22 @@ def get_pull_request_changes(self, pull_request_id: int) -> Dict[str, Any]: AzureDevOpsClientError: If request fails """ try: - changes = self.git_client.get_pull_request_iterations_changes( + iterations = self.git_client.get_pull_request_iterations( repository_id=self.repo, pull_request_id=pull_request_id, project=self.project ) + latest_iteration = max(iterations, key=lambda i: i.id) + + changes = self.git_client.get_pull_request_iteration_changes( + repository_id=self.repo, + pull_request_id=pull_request_id, + project=self.project, + compare_to=0, # Compare to base commit, we might want to allow specifying this in the future. + iteration_id=latest_iteration.id # We might want to allow specifying iterations in the future. + ) + return changes.__dict__ except Exception as e: raise AzureDevOpsClientError(f"Failed to get pull request changes: {str(e)}") diff --git a/src/mcp_azure_devops/features/pull_requests/tools.py b/src/mcp_azure_devops/features/pull_requests/tools.py index 198314b..586c073 100644 --- a/src/mcp_azure_devops/features/pull_requests/tools.py +++ b/src/mcp_azure_devops/features/pull_requests/tools.py @@ -208,8 +208,22 @@ def _add_comment_impl( content=content ) - thread_id = result.get("id") - comment_id = result.get("comments", [{}])[0].get("id") if result.get("comments") else None + # Handle both dict and object types + if isinstance(result, dict): + thread_id = result.get("id") + comments = result.get("comments", []) + else: + thread_id = getattr(result, 'id', None) + comments = getattr(result, 'comments', []) + + # Get the first comment's ID + comment_id = None + if comments and len(comments) > 0: + first_comment = comments[0] + if isinstance(first_comment, dict): + comment_id = first_comment.get("id") + else: + comment_id = getattr(first_comment, 'id', None) return f"Comment added successfully:\nThread ID: {thread_id}\nComment ID: {comment_id}\nContent: {content}" except Exception as e: From 7997d418f0e307baf85474c34a29612542999b0d Mon Sep 17 00:00:00 2001 From: Vortiago Date: Tue, 27 May 2025 22:25:07 +0200 Subject: [PATCH 10/10] Enhance work item client functionality and update pull request linking method to return detailed response --- .../features/pull_requests/common.py | 76 ++++++++++++++++--- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/src/mcp_azure_devops/features/pull_requests/common.py b/src/mcp_azure_devops/features/pull_requests/common.py index eabaddf..a250023 100644 --- a/src/mcp_azure_devops/features/pull_requests/common.py +++ b/src/mcp_azure_devops/features/pull_requests/common.py @@ -11,8 +11,10 @@ GitPullRequestSearchCriteria, Comment, GitPullRequestCommentThread, - IdentityRefWithVote + IdentityRefWithVote, + ResourceRef ) +from azure.devops.v7_1.work_item_tracking.models import JsonPatchOperation from mcp_azure_devops.utils.azure_client import get_connection @@ -47,6 +49,32 @@ def get_pull_request_client(): return git_client +def get_work_item_client(): + """ + Get the work item client for Azure DevOps. + + Returns: + Work Item Tracking client instance + + Raises: + AzureDevOpsClientError: If connection or client creation fails + """ + # Get connection to Azure DevOps + connection = get_connection() + + if not connection: + raise AzureDevOpsClientError( + "Azure DevOps PAT or organization URL not found in environment variables." + ) + + # Get the work item tracking client + work_item_client = connection.clients.get_work_item_tracking_client() + + if work_item_client is None: + raise AzureDevOpsClientError("Failed to get work item tracking client.") + + return work_item_client + class AzureDevOpsClient: """Client for Azure DevOps API operations related to Pull Requests.""" @@ -373,7 +401,7 @@ def get_pull_request_work_items(self, pull_request_id: int) -> List[Dict[str, An except Exception as e: raise AzureDevOpsClientError(f"Failed to get pull request work items: {str(e)}") - def add_work_items_to_pull_request(self, pull_request_id: int, work_item_ids: List[int]) -> bool: + def add_work_items_to_pull_request(self, pull_request_id: int, work_item_ids: List[int]) -> Dict[str, Any]: """ Link work items to a Pull Request. @@ -382,21 +410,51 @@ def add_work_items_to_pull_request(self, pull_request_id: int, work_item_ids: Li work_item_ids: List of work item IDs to link Returns: - True if successful + Updated PR details Raises: AzureDevOpsClientError: If request fails """ try: + # Get the PR to extract necessary IDs + pr = self.git_client.get_pull_request( + repository_id=self.repo, + project=self.project, + pull_request_id=pull_request_id + ) + + # Extract project and repository IDs for artifact URL + project_id = pr.repository.project.id + repo_id = pr.repository.id + + # Create the artifact URL for the pull request + artifact_url = f"vstfs:///Git/PullRequestId/{project_id}%2F{repo_id}%2F{pull_request_id}" + + # Get work item tracking client + wit_client = get_work_item_client() + + # Update each work item to add the artifact link for work_item_id in work_item_ids: - self.git_client.create_pull_request_work_item_refs( - repository_id=self.repo, - pull_request_id=pull_request_id, - project=self.project, - work_item_ids=[work_item_id] + patch_doc = [ + JsonPatchOperation( + op="add", + path="/relations/-", + value={ + "rel": "ArtifactLink", + "url": artifact_url, + "attributes": {"name": "Pull Request"} + } + ) + ] + + wit_client.update_work_item( + document=patch_doc, + id=work_item_id, + project=self.project ) - return True + return {"message": f"Successfully linked {len(work_item_ids)} work items to PR #{pull_request_id}"} + except Exception as e: raise AzureDevOpsClientError(f"Failed to link work items to pull request: {str(e)}")