diff --git a/README.md b/README.md index 7f4ce65..e097faf 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ If you're using SkyDeck AI Helper app, you can search for "SkyDeckAI Code" and i - Code linting and issue detection for Python and JavaScript/TypeScript - Code content searching with regex pattern matching - Multi-language code execution with safety measures +- GitHub integration for pull request management and code reviews - Web content fetching from APIs and websites with HTML-to-markdown conversion - Multi-engine web search with reliable fallback mechanisms - Batch operations for parallel and serial tool execution @@ -51,36 +52,39 @@ If you're using SkyDeck AI Helper app, you can search for "SkyDeckAI Code" and i - Screenshot and screen context tools - Image handling tools -## Available Tools (26) - -| Category | Tool Name | Description | -| ---------------- | -------------------------- | -------------------------------------------- | -| **File System** | `get_allowed_directory` | Get the current working directory path | -| | `update_allowed_directory` | Change the working directory | -| | `create_directory` | Create a new directory or nested directories | -| | `write_file` | Create or overwrite a file with new content | -| | `edit_file` | Make line-based edits to a text file | -| | `read_file` | Read the contents of one or more files | -| | `list_directory` | Get listing of files and directories | -| | `move_file` | Move or rename a file or directory | -| | `copy_file` | Copy a file or directory to a new location | -| | `search_files` | Search for files matching a name pattern | -| | `delete_file` | Delete a file or empty directory | -| | `get_file_info` | Get detailed file metadata | -| | `directory_tree` | Get a recursive tree view of directories | -| | `read_image_file` | Read an image file as base64 data | -| **Code Tools** | `codebase_mapper` | Analyze code structure across files | -| | `search_code` | Find text patterns in code files | -| | `execute_code` | Run code in various languages | -| | `execute_shell_script` | Run shell/bash scripts | -| **Web Tools** | `web_fetch` | Get content from a URL | -| | `web_search` | Perform a web search | -| **Screen Tools** | `capture_screenshot` | Take a screenshot of screen or window | -| | `get_active_apps` | List running applications | -| | `get_available_windows` | List all open windows | -| **System** | `get_system_info` | Get detailed system information | -| **Utility** | `batch_tools` | Run multiple tool operations together | -| | `think` | Document reasoning without making changes | +## Available Tools (29) + +| Category | Tool Name | Description | +| ---------------- | ------------------------------ | -------------------------------------------- | +| **File System** | `get_allowed_directory` | Get the current working directory path | +| | `update_allowed_directory` | Change the working directory | +| | `create_directory` | Create a new directory or nested directories | +| | `write_file` | Create or overwrite a file with new content | +| | `edit_file` | Make line-based edits to a text file | +| | `read_file` | Read the contents of one or more files | +| | `list_directory` | Get listing of files and directories | +| | `move_file` | Move or rename a file or directory | +| | `copy_file` | Copy a file or directory to a new location | +| | `search_files` | Search for files matching a name pattern | +| | `delete_file` | Delete a file or empty directory | +| | `get_file_info` | Get detailed file metadata | +| | `directory_tree` | Get a recursive tree view of directories | +| | `read_image_file` | Read an image file as base64 data | +| **Code Tools** | `codebase_mapper` | Analyze code structure across files | +| | `search_code` | Find text patterns in code files | +| | `execute_code` | Run code in various languages | +| | `execute_shell_script` | Run shell/bash scripts | +| **GitHub** | `list_pull_requests` | List and filter repository pull requests | +| | `create_pull_request_review` | Create a review for a pull request | +| | `get_pull_request_files` | Get files changed in a pull request | +| **Web Tools** | `web_fetch` | Get content from a URL | +| | `web_search` | Perform a web search | +| **Screen Tools** | `capture_screenshot` | Take a screenshot of screen or window | +| | `get_active_apps` | List running applications | +| | `get_available_windows` | List all open windows | +| **System** | `get_system_info` | Get detailed system information | +| **Utility** | `batch_tools` | Run multiple tool operations together | +| | `think` | Document reasoning without making changes | ## Detailed Tool Documentation @@ -551,6 +555,139 @@ skydeckai-code-cli --tool web_search --args '{ }' ``` +### GitHub Tools + +#### list_pull_requests + +Lists and filters pull requests for a GitHub repository: + +```json +{ + "owner": "octocat", + "repo": "hello-world", + "state": "open", + "sort": "created", + "direction": "desc", + "per_page": 10 +} +``` + +**Parameters:** +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| owner | string | Yes | Repository owner (username or organization) | +| repo | string | Yes | Repository name | +| state | string | No | Filter by pull request state: "open", "closed", or "all" (default: "open") | +| head | string | No | Filter by head user/org and branch name (format: "user:ref-name" or "org:ref-name") | +| base | string | No | Filter by base branch name | +| sort | string | No | How to sort the results: "created", "updated", "popularity", "long-running" (default: "created") | +| direction | string | No | Sort direction: "asc" or "desc" (default: "desc") | +| per_page | number | No | Results per page, max 100 (default: 30) | + +**Returns:** +A formatted list of pull requests with details including number, title, state, creator, branch information, and creation date. + +**CLI Usage:** + +```bash +# List open pull requests in a repository +skydeckai-code-cli --tool list_pull_requests --args '{ + "owner": "octocat", + "repo": "hello-world" +}' + +# List closed pull requests sorted by update time +skydeckai-code-cli --tool list_pull_requests --args '{ + "owner": "octocat", + "repo": "hello-world", + "state": "closed", + "sort": "updated" +}' +``` + +#### create_pull_request_review + +Creates a review for a GitHub pull request: + +```json +{ + "owner": "octocat", + "repo": "hello-world", + "pull_number": 42, + "event": "APPROVE", + "body": "LGTM! The code looks great." +} +``` + +**Parameters:** +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| owner | string | Yes | Repository owner (username or organization) | +| repo | string | Yes | Repository name | +| pull_number | number | Yes | Pull request number | +| event | string | Yes | Review action: "APPROVE", "REQUEST_CHANGES", or "COMMENT" | +| body | string | No | Text of the review | +| comments | array | No | Array of review comments (for inline comments) | +| commit_id | string | No | SHA of commit to review | + +**Returns:** +Confirmation of the created review with details from the GitHub API response. + +**CLI Usage:** + +```bash +# Approve a pull request +skydeckai-code-cli --tool create_pull_request_review --args '{ + "owner": "octocat", + "repo": "hello-world", + "pull_number": 42, + "event": "APPROVE", + "body": "The changes look good!" +}' + +# Request changes on a pull request +skydeckai-code-cli --tool create_pull_request_review --args '{ + "owner": "octocat", + "repo": "hello-world", + "pull_number": 42, + "event": "REQUEST_CHANGES", + "body": "Please add tests for this feature." +}' +``` + +#### get_pull_request_files + +Retrieves the list of files changed in a GitHub pull request: + +```json +{ + "owner": "octocat", + "repo": "hello-world", + "pull_number": 42 +} +``` + +**Parameters:** +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| owner | string | Yes | Repository owner (username or organization) | +| repo | string | Yes | Repository name | +| pull_number | number | Yes | Pull request number | + +**Returns:** +A formatted list of changed files with details including filename, status (added, modified, removed), additions, deletions, total changes, and file URLs. Also includes a diff patch for each file and summary statistics. + +**CLI Usage:** + +```bash +# Get files changed in a pull request +skydeckai-code-cli --tool get_pull_request_files --args '{ + "owner": "octocat", + "repo": "hello-world", + "pull_number": 42 +}' +``` + ### Utility Tools #### batch_tools @@ -845,10 +982,10 @@ npx @modelcontextprotocol/inspector run ## Upcoming Features -- GitHub tools: +- Additional GitHub tools: - PR Description Generator - - Code Review - Actions Manager + - Issue Management - Pivotal Tracker tools: - Story Generator - Story Manager diff --git a/src/aidd/tools/__init__.py b/src/aidd/tools/__init__.py index 95a2e64..9be59b6 100644 --- a/src/aidd/tools/__init__.py +++ b/src/aidd/tools/__init__.py @@ -1,3 +1,11 @@ +from .github_tools import ( + create_pull_request_review_tool, + get_pull_request_files_tool, + list_pull_requests_tool, + handle_create_pull_request_review, + handle_get_pull_request_files, + handle_list_pull_requests +) from .code_analysis import handle_codebase_mapper, codebase_mapper_tool from .code_execution import ( execute_code_tool, @@ -82,6 +90,10 @@ web_search_tool(), # System tools get_system_info_tool(), + # Github tools + list_pull_requests_tool(), + create_pull_request_review_tool(), + get_pull_request_files_tool(), ] # Export all handlers @@ -116,4 +128,8 @@ # Web handlers "web_fetch": handle_web_fetch, "web_search": handle_web_search, + # Github handlers + "list_pull_requests": handle_list_pull_requests, + "create_pull_request_review": handle_create_pull_request_review, + "get_pull_request_files": handle_get_pull_request_files, } diff --git a/src/aidd/tools/github_tools.py b/src/aidd/tools/github_tools.py new file mode 100644 index 0000000..ac57906 --- /dev/null +++ b/src/aidd/tools/github_tools.py @@ -0,0 +1,326 @@ +import http.client +import json +import os +from typing import Any, Dict, List +from mcp.types import TextContent + + +def list_pull_requests_tool() -> Dict[str, Any]: + return { + "name": "list_pull_requests", + "description": "List and filter repository pull requests", + "inputSchema": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "state": { + "type": "string", + "description": "Filter by pull request state: open, closed, or all", + "enum": ["open", "closed", "all"], + "default": "open" + }, + "head": { + "type": "string", + "description": "Filter by head user/org and branch name (user:ref-name or org:ref-name)", + "nullable": True + }, + "base": { + "type": "string", + "description": "Filter by base branch name", + "nullable": True + }, + "sort": { + "type": "string", + "description": "How to sort the results", + "enum": ["created", "updated", "popularity", "long-running"], + "default": "created" + }, + "direction": { + "type": "string", + "description": "Sort direction: asc or desc", + "enum": ["asc", "desc"], + "default": "desc" + }, + "per_page": { + "type": "number", + "description": "Results per page (max 100)", + "default": 30, + "minimum": 1, + "maximum": 100 + } + }, + "required": ["owner", "repo"] + }, + } + +def create_pull_request_review_tool() -> Dict[str, Any]: + return { + "name": "create_pull_request_review", + "description": "Create a review for a pull request.", + "inputSchema": { + "type": "object", + "properties": { + "owner": {"type": "string", "description": "Repository owner"}, + "repo": {"type": "string", "description": "Repository name"}, + "pull_number": {"type": "number", "description": "Pull request number"}, + "event": {"type": "string", "description": "Review action: APPROVE, REQUEST_CHANGES, or COMMENT"}, + "body": {"type": "string", "description": "Text of the review (optional)", "nullable": True}, + "comments": {"type": "array", "items": {"type": "object"}, "description": "Array of review comments (optional) use this for inline comments", "nullable": True}, + "commit_id": {"type": "string", "description": "SHA of commit to review (optional)", "nullable": True}, + }, + "required": ["owner", "repo", "pull_number", "event"] + }, + } + +# Handler for creating a pull request review +async def handle_create_pull_request_review(args: Dict[str, Any]) -> List[TextContent]: + owner = args.get("owner") + repo = args.get("repo") + pull_number = args.get("pull_number") + event = args.get("event") + body_value = args.get("body") + comments_value = args.get("comments") + commit_id = args.get("commit_id") + + if not all([owner, repo, pull_number, event]): + return [TextContent(type="text", text="Error: Missing required parameters: owner, repo, pull_number, event.")] + + path = f"/repos/{owner}/{repo}/pulls/{pull_number}/reviews" + conn = None + try: + conn = http.client.HTTPSConnection("api.github.com") + headers = { + "Accept": "application/vnd.github.v3+json", + "User-Agent": "Python-MCP-Server", + } + if token := os.environ.get("GITHUB_TOKEN"): + headers["Authorization"] = f"token {token}" + + payload = {"event": event} + if body_value: + payload["body"] = body_value + if commit_id: + payload["commit_id"] = commit_id + if comments_value: + payload["comments"] = comments_value + + conn.request("POST", path, body=json.dumps(payload), headers=headers) + response = conn.getresponse() + response_body = response.read() + if response.status not in (200, 201): + return [TextContent(type="text", text=f"Error creating pull request review: {response.status} {response.reason}\n{response_body.decode()}")] + review_data = json.loads(response_body) + return [TextContent(type="text", text=f"Pull request review created: {json.dumps(review_data, indent=2)}")] + except Exception as e: + return [TextContent(type="text", text=f"Exception occurred: {str(e)}")] + finally: + if conn: + conn.close() + +def get_pull_request_files_tool() -> Dict[str, Any]: + return { + "name": "get_pull_request_files", + "description": "Retrieves the list of files changed in a GitHub pull request.", + "inputSchema": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "pull_number": { + "type": "number", + "description": "Pull request number" + } + }, + "required": ["owner", "repo", "pull_number"] + }, + } + +async def handle_get_pull_request_files(args: Dict[str, Any]) -> List[TextContent]: + owner = args.get("owner") + repo = args.get("repo") + pull_number = args.get("pull_number") + + if not all([owner, repo, pull_number]): + return [TextContent(type="text", text="Error: Missing required parameters. Required parameters are owner, repo, and pull_number.")] + + # GitHub API URL for PR files + path = f"/repos/{owner}/{repo}/pulls/{pull_number}/files" + conn = None + + try: + # Create connection + conn = http.client.HTTPSConnection("api.github.com") + + # Set headers + headers = { + "Accept": "application/vnd.github.v3+json", + "User-Agent": "Python-MCP-Server" + } + + # Add GitHub token if available + if token := os.environ.get("GITHUB_TOKEN"): + headers["Authorization"] = f"token {token}" + + # Make request + conn.request("GET", path, headers=headers) + response = conn.getresponse() + if response.status == 404: + return [TextContent(type="text", text=f"Pull request #{pull_number} not found in repository {owner}/{repo}. " + "Please set GITHUB_TOKEN environment variable if you are searching for private repositories.")] + + if response.status != 200: + return [TextContent(type="text", text=f"Error fetching pull request files: {response.status} {response.reason}")] + + # Read and parse response + files_data = json.loads(response.read()) + + # Format the files data for display + if not files_data: + return [TextContent(type="text", text=f"No files found in pull request #{pull_number}.")] + + files_info = f"# Files Changed in Pull Request #{pull_number}\n\n" + + for file in files_data: + filename = file.get("filename", "Unknown file") + status = file.get("status", "unknown") + additions = file.get("additions", 0) + deletions = file.get("deletions", 0) + changes = file.get("changes", 0) + + status_emoji = "🆕" if status == "added" else "✏️" if status == "modified" else "🗑️" if status == "removed" else "📄" + files_info += f"{status_emoji} **{filename}** ({status})\n" + files_info += f" - Additions: +{additions}, Deletions: -{deletions}, Total Changes: {changes}\n" + + # Add file URL if available + if blob_url := file.get("blob_url"): + files_info += f" - [View File]({blob_url})\n" + + # Add patch information if available and not too large + if patch := file.get("patch"): + files_info += f"```diff\n{patch}\n```\n" + + files_info += "\n" + + # Add summary statistics + total_files = len(files_data) + total_additions = sum(file.get("additions", 0) for file in files_data) + total_deletions = sum(file.get("deletions", 0) for file in files_data) + + files_info += f"\n## Summary\n" + files_info += f"- Total Files Changed: {total_files}\n" + files_info += f"- Total Additions: +{total_additions}\n" + files_info += f"- Total Deletions: -{total_deletions}\n" + files_info += f"- Total Changes: {total_additions + total_deletions}\n" + + return [TextContent(type="text", text=files_info)] + + except Exception as e: + return [TextContent(type="text", text=f"Error fetching pull request files: {str(e)}")] + finally: + if conn is not None: + conn.close() + +async def handle_list_pull_requests(args: Dict[str, Any]) -> List[TextContent]: + owner = args.get("owner") + repo = args.get("repo") + state = args.get("state", "open") + head = args.get("head") + base = args.get("base") + sort = args.get("sort", "created") + direction = args.get("direction", "desc") + per_page = args.get("per_page", 30) + + # Validate required parameters + if not all([owner, repo]): + return [TextContent(type="text", text="Error: Missing required parameters. Required parameters are owner and repo.")] + + # Build query parameters + query_params = f"state={state}&sort={sort}&direction={direction}&per_page={per_page}" + if head: + query_params += f"&head={head}" + if base: + query_params += f"&base={base}" + + # GitHub API URL + path = f"/repos/{owner}/{repo}/pulls?{query_params}" + conn = None + + try: + # Create connection + conn = http.client.HTTPSConnection("api.github.com") + + # Set headers + headers = { + "Accept": "application/vnd.github.v3+json", + "User-Agent": "Python-MCP-Server" + } + + # Add GitHub token if available + if token := os.environ.get("GITHUB_TOKEN"): + headers["Authorization"] = f"token {token}" + + # Make request + conn.request("GET", path, headers=headers) + response = conn.getresponse() + if response.status == 404: + return [TextContent(type="text", text=f"Repository {owner}/{repo} not found. " + "Please set GITHUB_TOKEN environment variable if you are searching for private repositories.")] + + if response.status != 200: + return [TextContent(type="text", text=f"Error fetching pull requests: {response.status} {response.reason}")] + + # Read and parse response + pr_data = json.loads(response.read()) + + # Handle empty results + if not pr_data: + status_label = "open" if state == "open" else ( + "closed" if state == "closed" else "matching your criteria") + return [TextContent(type="text", text=f"No {status_label} pull requests found in repository {owner}/{repo}.")] + + # Format the pull requests data for display + pr_info = f"# Pull Requests in {owner}/{repo} ({state})\n\n" + + # Create a table header + pr_info += "| Number | Title | State | Creator | Head → Base | Created At |\n" + pr_info += "|--------|-------|-------|---------|-------------|------------|\n" + + for pr in pr_data: + number = pr.get("number", "N/A") + title = pr.get("title", "No title") + pr_state = pr.get("state", "unknown") + creator = pr.get("user", {}).get("login", "unknown") + created_at = pr.get("created_at", "unknown") + + # Get branch information + head_branch = f"{pr.get('head', {}).get('label', 'unknown')}" + base_branch = f"{pr.get('base', {}).get('label', 'unknown')}" + branch_info = f"{head_branch} → {base_branch}" + + # Add row to table + pr_info += f"| [{number}]({pr.get('html_url', '')}) | {title} | {pr_state} | {creator} | {branch_info} | {created_at} |\n" + + # Add summary + pr_info += f"\n\n**Total pull requests found: {len(pr_data)}**\n" + pr_info += f"View all pull requests: https://github.com/{owner}/{repo}/pulls\n" + + return [TextContent(type="text", text=pr_info)] + + except Exception as e: + return [TextContent(type="text", text=f"Error fetching pull requests: {str(e)}")] + finally: + if conn is not None: + conn.close() diff --git a/src/aidd/tools/test_github_tools.py b/src/aidd/tools/test_github_tools.py new file mode 100644 index 0000000..9d1d971 --- /dev/null +++ b/src/aidd/tools/test_github_tools.py @@ -0,0 +1,347 @@ +import pytest +from unittest.mock import patch, MagicMock +from typing import Dict, Any +import mcp.types as types +from .github_tools import handle_create_pull_request_review, handle_get_pull_request_files, handle_list_pull_requests + + +@pytest.mark.asyncio +async def test_handle_create_pull_request_review_missing_params(): + """Return error if required args missing""" + result = await handle_create_pull_request_review({"repo": "repo", "pull_number": 1, "event": "COMMENT"}) + assert isinstance(result, list) + assert "Missing required parameters" in result[0].text + +@pytest.mark.asyncio +async def test_handle_create_pull_request_review_success(): + mock_response = MagicMock() + mock_response.status = 201 + mock_response.read.return_value = b'{"id": 123, "body": "Review posted!", "event": "COMMENT"}' + + with patch('http.client.HTTPSConnection') as mock_conn: + mock_conn.return_value.getresponse.return_value = mock_response + result = await handle_create_pull_request_review({ + "owner": "test", + "repo": "repo", + "pull_number": 1, + "event": "COMMENT", + "body": "Nice work!" + }) + assert isinstance(result, list) + assert "Pull request review created" in result[0].text + assert "Review posted" in result[0].text + +@pytest.mark.asyncio +async def test_handle_create_pull_request_review_api_error(): + mock_response = MagicMock() + mock_response.status = 422 + mock_response.reason = "Unprocessable Entity" + mock_response.read.return_value = b'{"message": "Validation Failed"}' + with patch('http.client.HTTPSConnection') as mock_conn: + mock_conn.return_value.getresponse.return_value = mock_response + result = await handle_create_pull_request_review({ + "owner": "test", + "repo": "repo", + "pull_number": 1, + "event": "COMMENT", + "body": "invalid" + }) + assert isinstance(result, list) + assert "Error creating pull request review" in result[0].text + assert "422" in result[0].text or "Unprocessable" in result[0].text + +@pytest.mark.asyncio +async def test_handle_create_pull_request_review_exception(): + with patch('http.client.HTTPSConnection') as mock_conn: + mock_conn.side_effect = Exception("oops") + result = await handle_create_pull_request_review({ + "owner": "test", + "repo": "repo", + "pull_number": 1, + "event": "COMMENT", + }) + assert isinstance(result, list) + assert "Exception occurred" in result[0].text + +@pytest.mark.asyncio +async def test_handle_get_pull_request_files_missing_params(): + """Test that missing parameters return an error message""" + result = await handle_get_pull_request_files({}) + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "Missing required parameters" in result[0].text + + +@pytest.mark.asyncio +async def test_handle_get_pull_request_files_success(): + """Test successful pull request files retrieval""" + mock_response = MagicMock() + mock_response.status = 200 + mock_response.read.return_value = b'''[ + { + "filename": "src/example.py", + "status": "modified", + "additions": 10, + "deletions": 2, + "changes": 12, + "blob_url": "https://github.com/owner/repo/blob/sha/src/example.py", + "patch": "@@ -10,2 +10,10 @@ class Example:\\n- def old_method(self):\\n- pass\\n+ def new_method(self):\\n+ return 'new implementation'" + }, + { + "filename": "README.md", + "status": "added", + "additions": 15, + "deletions": 0, + "changes": 15, + "blob_url": "https://github.com/owner/repo/blob/sha/README.md" + }, + { + "filename": "tests/old_test.py", + "status": "removed", + "additions": 0, + "deletions": 25, + "changes": 25, + "blob_url": "https://github.com/owner/repo/blob/sha/tests/old_test.py" + } + ]''' + + with patch('http.client.HTTPSConnection') as mock_conn: + mock_conn.return_value.getresponse.return_value = mock_response + result = await handle_get_pull_request_files({ + "owner": "test", + "repo": "repo", + "pull_number": 1 + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "Files Changed in Pull Request #1" in result[0].text + assert "src/example.py" in result[0].text + assert "README.md" in result[0].text + assert "tests/old_test.py" in result[0].text + assert "modified" in result[0].text + assert "added" in result[0].text + assert "removed" in result[0].text + assert "Total Files Changed: 3" in result[0].text + assert "Total Additions: +25" in result[0].text + assert "Total Deletions: -27" in result[0].text + + +@pytest.mark.asyncio +async def test_handle_get_pull_request_files_empty(): + """Test handling of pull request with no files""" + mock_response = MagicMock() + mock_response.status = 200 + mock_response.read.return_value = b'[]' + + with patch('http.client.HTTPSConnection') as mock_conn: + mock_conn.return_value.getresponse.return_value = mock_response + result = await handle_get_pull_request_files({ + "owner": "test", + "repo": "repo", + "pull_number": 1 + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "No files found in pull request #1" in result[0].text + + +@pytest.mark.asyncio +async def test_handle_get_pull_request_files_not_found(): + """Test handling of non-existent pull request""" + mock_response = MagicMock() + mock_response.status = 404 + + with patch('http.client.HTTPSConnection') as mock_conn: + mock_conn.return_value.getresponse.return_value = mock_response + result = await handle_get_pull_request_files({ + "owner": "test", + "repo": "repo", + "pull_number": 999 + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "not found" in result[0].text.lower() + + +@pytest.mark.asyncio +async def test_handle_get_pull_request_files_error(): + """Test handling of API error""" + mock_response = MagicMock() + mock_response.status = 500 + mock_response.reason = "Internal Server Error" + + with patch('http.client.HTTPSConnection') as mock_conn: + mock_conn.return_value.getresponse.return_value = mock_response + result = await handle_get_pull_request_files({ + "owner": "test", + "repo": "repo", + "pull_number": 1 + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "Error fetching pull request files" in result[0].text + assert "500" in result[0].text + + +@pytest.mark.asyncio +async def test_handle_get_pull_request_files_exception(): + """Test handling of unexpected exceptions""" + with patch('http.client.HTTPSConnection') as mock_conn: + mock_conn.side_effect = Exception("Connection failed") + result = await handle_get_pull_request_files({ + "owner": "test", + "repo": "repo", + "pull_number": 1 + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "Error fetching pull request files" in result[0].text + +@pytest.mark.asyncio +async def test_handle_list_pull_requests_missing_params(): + """Test that missing parameters return an error message""" + result = await handle_list_pull_requests({}) + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "Missing required parameters" in result[0].text + + +@pytest.mark.asyncio +async def test_handle_list_pull_requests_success(): + """Test successful pull requests listing""" + mock_response = MagicMock() + mock_response.status = 200 + mock_response.read.return_value = b'''[ + { + "number": 1, + "title": "First PR", + "state": "open", + "user": {"login": "testuser"}, + "created_at": "2024-01-01T00:00:00Z", + "html_url": "https://github.com/test/repo/pulls/1", + "head": {"label": "testuser:feature-branch"}, + "base": {"label": "test:main"} + }, + { + "number": 2, + "title": "Second PR", + "state": "open", + "user": {"login": "otheruser"}, + "created_at": "2024-01-02T00:00:00Z", + "html_url": "https://github.com/test/repo/pulls/2", + "head": {"label": "otheruser:bugfix-branch"}, + "base": {"label": "test:main"} + } + ]''' + + with patch('http.client.HTTPSConnection') as mock_conn: + mock_conn.return_value.getresponse.return_value = mock_response + result = await handle_list_pull_requests({ + "owner": "test", + "repo": "repo", + "state": "open" + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "Pull Requests in test/repo (open)" in result[0].text + assert "First PR" in result[0].text + assert "Second PR" in result[0].text + assert "Total pull requests found: 2" in result[0].text + assert "testuser:feature-branch → test:main" in result[0].text + assert "otheruser:bugfix-branch → test:main" in result[0].text + assert "testuser" in result[0].text + assert "otheruser" in result[0].text + + +@pytest.mark.asyncio +async def test_handle_list_pull_requests_empty(): + """Test handling of repo with no pull requests""" + mock_response = MagicMock() + mock_response.status = 200 + mock_response.read.return_value = b'[]' + + with patch('http.client.HTTPSConnection') as mock_conn: + mock_conn.return_value.getresponse.return_value = mock_response + result = await handle_list_pull_requests({ + "owner": "test", + "repo": "repo", + "state": "open" + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "No open pull requests found in repository test/repo" in result[0].text + + +@pytest.mark.asyncio +async def test_handle_list_pull_requests_not_found(): + """Test handling of non-existent repository""" + mock_response = MagicMock() + mock_response.status = 404 + + with patch('http.client.HTTPSConnection') as mock_conn: + mock_conn.return_value.getresponse.return_value = mock_response + result = await handle_list_pull_requests({ + "owner": "test", + "repo": "nonexistent", + "state": "open" + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "not found" in result[0].text.lower() + + +@pytest.mark.asyncio +async def test_handle_list_pull_requests_error(): + """Test handling of API error""" + mock_response = MagicMock() + mock_response.status = 500 + mock_response.reason = "Internal Server Error" + + with patch('http.client.HTTPSConnection') as mock_conn: + mock_conn.return_value.getresponse.return_value = mock_response + result = await handle_list_pull_requests({ + "owner": "test", + "repo": "repo", + "state": "open" + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "Error fetching pull requests" in result[0].text + assert "500" in result[0].text + + +@pytest.mark.asyncio +async def test_handle_list_pull_requests_exception(): + """Test handling of unexpected exceptions""" + with patch('http.client.HTTPSConnection') as mock_conn: + mock_conn.side_effect = Exception("Connection failed") + result = await handle_list_pull_requests({ + "owner": "test", + "repo": "repo", + "state": "open" + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "Error fetching pull requests" in result[0].text diff --git a/src/aidd/tools/test_github_tools_integration.py b/src/aidd/tools/test_github_tools_integration.py new file mode 100644 index 0000000..344a0d6 --- /dev/null +++ b/src/aidd/tools/test_github_tools_integration.py @@ -0,0 +1,170 @@ +import pytest +import os +from typing import Dict, Any +from mcp.types import TextContent +from .github_tools import handle_create_pull_request_review, handle_get_pull_request_files, handle_list_pull_requests + + +@pytest.mark.asyncio +@pytest.mark.skipif("GITHUB_TOKEN" not in os.environ or not os.environ.get('GITHUB_TEST_REPO_OWNER') or not os.environ.get('GITHUB_TEST_REPO'), reason="Needs GITHUB_TOKEN, GITHUB_TEST_REPO_OWNER, and GITHUB_TEST_REPO env variables.") +async def test_handle_create_pull_request_review_real_api(): + """Integration test for creating a review (safe COMMENT event).""" + owner = os.environ["GITHUB_TEST_REPO_OWNER"] + repo = os.environ["GITHUB_TEST_REPO"] + pr_num = int(os.environ.get("GITHUB_TEST_PR_NUMBER", "1")) # Default to PR 1 if not set + result = await handle_create_pull_request_review({ + "owner": owner, + "repo": repo, + "pull_number": pr_num, + "event": "COMMENT", + "body": "*Integration test comment*" + }) + assert isinstance(result, list) + assert any("review" in r.text.lower() for r in result) + +@pytest.mark.asyncio +async def test_handle_get_pull_request_files_real_api(): + """Test the get_pull_request_files tool against the real GitHub API""" + # Test with a well-known public repository and pull request + result = await handle_get_pull_request_files({ + "owner": "python", + "repo": "cpython", + "pull_number": 1 + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].type == "text" + assert "Files Changed in Pull Request #1" in result[0].text + + # Check for summary section containing expected stats + assert "Total Files Changed:" in result[0].text + assert "Total Additions:" in result[0].text + assert "Total Deletions:" in result[0].text + + +@pytest.mark.asyncio +async def test_handle_get_pull_request_files_not_found_real_api(): + """Test handling of non-existent pull request against the real GitHub API""" + result = await handle_get_pull_request_files({ + "owner": "python", + "repo": "cpython", + "pull_number": 999999999 # Very high number that shouldn't exist + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].type == "text" + assert "not found" in result[0].text.lower() + + +@pytest.mark.asyncio +async def test_handle_get_pull_request_files_private_repo(): + """Test handling of private repository access""" + result = await handle_get_pull_request_files({ + "owner": "github", + "repo": "github", # This is a private repository + "pull_number": 1 + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].type == "text" + assert "error" in result[0].text.lower() or "not found" in result[0].text.lower() + + +@pytest.mark.asyncio +async def test_handle_list_pull_requests_real_api(): + """Test the list_pull_requests tool against the real GitHub API""" + # Test with a well-known public repository + result = await handle_list_pull_requests({ + "owner": "python", + "repo": "cpython", + "state": "open" + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].type == "text" + assert "Pull Requests in python/cpython (open)" in result[0].text + # Check for table headers + assert "| Number | Title | State | Creator | Head → Base | Created At |" in result[0].text + # Check for summary + assert "Total pull requests found:" in result[0].text + + +@pytest.mark.asyncio +async def test_handle_list_pull_requests_closed_real_api(): + """Test the list_pull_requests tool with closed pull requests against the real GitHub API""" + result = await handle_list_pull_requests({ + "owner": "python", + "repo": "cpython", + "state": "closed", + "per_page": 10 + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].type == "text" + assert "Pull Requests in python/cpython (closed)" in result[0].text + # Check for table headers + assert "| Number | Title | State | Creator | Head → Base | Created At |" in result[0].text + # Check for summary + assert "Total pull requests found:" in result[0].text + + +@pytest.mark.asyncio +async def test_handle_list_pull_requests_filtered_real_api(): + """Test the list_pull_requests tool with filters against the real GitHub API""" + result = await handle_list_pull_requests({ + "owner": "python", + "repo": "cpython", + "state": "open", + "base": "main", + "sort": "updated", + "direction": "desc", + "per_page": 5 + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].type == "text" + assert "Pull Requests in python/cpython (open)" in result[0].text + + +@pytest.mark.asyncio +async def test_handle_list_pull_requests_not_found_real_api(): + """Test handling of non-existent repository against the real GitHub API""" + result = await handle_list_pull_requests({ + "owner": "python", + "repo": "nonexistent_repo_name_abc123", # This shouldn't exist + "state": "open" + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].type == "text" + assert "not found" in result[0].text.lower() + + +@pytest.mark.asyncio +async def test_handle_list_pull_requests_private_repo(): + """Test handling of private repository access""" + result = await handle_list_pull_requests({ + "owner": "github", + "repo": "github", # This is a private repository + "state": "open" + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].type == "text" + assert "error" in result[0].text.lower() or "not found" in result[0].text.lower()