From 8c39b3a475d27f78bd8ce653ec6381f4380ed320 Mon Sep 17 00:00:00 2001 From: Duong Quang Hung Date: Mon, 28 Apr 2025 14:35:07 +0700 Subject: [PATCH 01/13] feat: Add github get issue tool --- src/aidd/server.py | 3 + src/aidd/tools/__init__.py | 5 + src/aidd/tools/github_tools.py | 106 ++++++++++++++++++ src/aidd/tools/test_github_tools.py | 101 +++++++++++++++++ .../tools/test_github_tools_integration.py | 49 ++++++++ 5 files changed, 264 insertions(+) create mode 100644 src/aidd/tools/github_tools.py create mode 100644 src/aidd/tools/test_github_tools.py create mode 100644 src/aidd/tools/test_github_tools_integration.py diff --git a/src/aidd/server.py b/src/aidd/server.py index caeed5f..5051234 100644 --- a/src/aidd/server.py +++ b/src/aidd/server.py @@ -9,6 +9,7 @@ server = Server("skydeckai-code") + @server.list_tools() async def handle_list_tools() -> list[types.Tool]: """ @@ -17,6 +18,7 @@ async def handle_list_tools() -> list[types.Tool]: """ return [types.Tool(**tool) for tool in TOOL_DEFINITIONS] + @server.call_tool() async def handle_call_tool( name: str, arguments: dict | None @@ -33,6 +35,7 @@ async def handle_call_tool( return await handler(arguments) + async def main(): # Run the server using stdin/stdout streams async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): diff --git a/src/aidd/tools/__init__.py b/src/aidd/tools/__init__.py index 95a2e64..55fbe5f 100644 --- a/src/aidd/tools/__init__.py +++ b/src/aidd/tools/__init__.py @@ -1,3 +1,4 @@ +from .github_tools import get_issue_tool, handle_get_issue from .code_analysis import handle_codebase_mapper, codebase_mapper_tool from .code_execution import ( execute_code_tool, @@ -82,6 +83,8 @@ web_search_tool(), # System tools get_system_info_tool(), + # Github tools + get_issue_tool(), ] # Export all handlers @@ -116,4 +119,6 @@ # Web handlers "web_fetch": handle_web_fetch, "web_search": handle_web_search, + # Github handlers + "get_issue": handle_get_issue, } diff --git a/src/aidd/tools/github_tools.py b/src/aidd/tools/github_tools.py new file mode 100644 index 0000000..4680ddd --- /dev/null +++ b/src/aidd/tools/github_tools.py @@ -0,0 +1,106 @@ +import http.client +import json +import os +from typing import Any, Dict, List, Optional +from mcp.types import TextContent + +# Tool definition + + +def get_issue_tool() -> Dict[str, Any]: + return { + "name": "get_issue", + "description": "Gets the contents of an issue within a repository", + "inputSchema": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "issue_number": { + "type": "number", + "description": "Issue number" + } + }, + "required": ["owner", "repo", "issue_number"] + }, + } + +# Tool handler + + +async def handle_get_issue(args: Dict[str, Any]) -> TextContent: + owner = args.get("owner") + repo = args.get("repo") + issue_number = args.get("issue_number") + + if not all([owner, repo, issue_number]): + return TextContent(type="text", text="Error: Missing required parameters. Required parameters are owner, repo, and issue_number.") + + # GitHub API URL + path = f"/repos/{owner}/{repo}/issues/{issue_number}" + 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"Issue #{issue_number} not found in repository {owner}/{repo}") + + if response.status != 200: + return TextContent(type="text", text=f"Error fetching issue: {response.status} {response.reason}") + + # Read and parse response + issue_data = json.loads(response.read()) + + # Format the issue data for display + issue_info = ( + f"# Issue #{issue_data.get('number')}: {issue_data.get('title')}\n\n" + f"**State:** {issue_data.get('state')}\n" + f"**Created by:** {issue_data.get('user', {}).get('login')}\n" + f"**Created at:** {issue_data.get('created_at')}\n" + f"**Updated at:** {issue_data.get('updated_at')}\n" + f"**URL:** {issue_data.get('html_url')}\n\n" + ) + + # Add labels if they exist + if labels := issue_data.get('labels', []): + label_names = [label.get('name') for label in labels] + issue_info += f"**Labels:** {', '.join(label_names)}\n\n" + + # Add assignees if they exist + if assignees := issue_data.get('assignees', []): + assignee_names = [assignee.get('login') for assignee in assignees] + issue_info += f"**Assignees:** {', '.join(assignee_names)}\n\n" + + # Add body if it exists + if body := issue_data.get('body'): + issue_info += f"## Description\n\n{body}\n\n" + + return TextContent(type="text", text=issue_info) + + except Exception as e: + return TextContent(type="text", text=f"Error fetching issue: {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..eed82c9 --- /dev/null +++ b/src/aidd/tools/test_github_tools.py @@ -0,0 +1,101 @@ +import pytest +from unittest.mock import patch, MagicMock +from typing import Dict, Any +import mcp.types as types +from .github_tools import handle_get_issue + + +@pytest.mark.asyncio +async def test_handle_get_issue_missing_params(): + """Test that missing parameters return an error message""" + result = await handle_get_issue({}) + assert result.type == "text" + assert "Missing required parameters" in result.text + + +@pytest.mark.asyncio +async def test_handle_get_issue_success(): + """Test successful issue retrieval""" + mock_response = MagicMock() + mock_response.status = 200 + mock_response.read.return_value = b'''{ + "number": 1, + "title": "Test Issue", + "state": "open", + "user": {"login": "testuser"}, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + "html_url": "https://github.com/test/repo/issues/1", + "body": "Test issue body", + "labels": [{"name": "bug"}], + "assignees": [{"login": "assignee1"}] + }''' + + with patch('http.client.HTTPSConnection') as mock_conn: + mock_conn.return_value.getresponse.return_value = mock_response + result = await handle_get_issue({ + "owner": "test", + "repo": "repo", + "issue_number": 1 + }) + + assert result.type == "text" + assert "Test Issue" in result.text + assert "testuser" in result.text + assert "bug" in result.text + assert "assignee1" in result.text + assert "Test issue body" in result.text + + +@pytest.mark.asyncio +async def test_handle_get_issue_not_found(): + """Test handling of non-existent issue""" + 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_issue({ + "owner": "test", + "repo": "repo", + "issue_number": 999 + }) + + assert result.type == "text" + assert "not found" in result.text.lower() + + +@pytest.mark.asyncio +async def test_handle_get_issue_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_issue({ + "owner": "test", + "repo": "repo", + "issue_number": 1 + }) + + assert result.type == "text" + assert "Error fetching issue" in result.text + assert "500" in result.text + + +@pytest.mark.asyncio +async def test_handle_get_issue_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_issue({ + "owner": "test", + "repo": "repo", + "issue_number": 1 + }) + + assert result.type == "text" + assert "Error fetching issue" in result.text + assert "Connection failed" in result.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..ea1ebd8 --- /dev/null +++ b/src/aidd/tools/test_github_tools_integration.py @@ -0,0 +1,49 @@ +import pytest +import os +from typing import Dict, Any +from mcp.types import TextContent +from .github_tools import handle_get_issue + + +@pytest.mark.asyncio +async def test_handle_get_issue_real_api(): + """Test the get_issue tool against the real GitHub API""" + # Test with a well-known public repository and issue + result = await handle_get_issue({ + "owner": "python", + "repo": "cpython", + "issue_number": 1 + }) + + assert isinstance(result, TextContent) + assert result.type == "text" + assert "python" in result.text.lower() + assert "cpython" in result.text.lower() + + +@pytest.mark.asyncio +async def test_handle_get_issue_not_found_real_api(): + """Test handling of non-existent issue against the real GitHub API""" + result = await handle_get_issue({ + "owner": "python", + "repo": "cpython", + "issue_number": 999999999 # Very high number that shouldn't exist + }) + + assert isinstance(result, TextContent) + assert result.type == "text" + assert "not found" in result.text.lower() + + +@pytest.mark.asyncio +async def test_handle_get_issue_private_repo(): + """Test handling of private repository access""" + result = await handle_get_issue({ + "owner": "github", + "repo": "github", # This is a private repository + "issue_number": 1 + }) + + assert isinstance(result, TextContent) + assert result.type == "text" + assert "error" in result.text.lower() or "not found" in result.text.lower() From 058b0180ab86c61c872895d189b61a1bddbb4396 Mon Sep 17 00:00:00 2001 From: Duong Quang Hung Date: Mon, 28 Apr 2025 15:20:51 +0700 Subject: [PATCH 02/13] bug-fix: Return List[TextContent] for handle_get_issue because mcp server expects list --- src/aidd/tools/github_tools.py | 14 +++---- src/aidd/tools/test_github_tools.py | 41 +++++++++++-------- .../tools/test_github_tools_integration.py | 27 +++++++----- 3 files changed, 49 insertions(+), 33 deletions(-) diff --git a/src/aidd/tools/github_tools.py b/src/aidd/tools/github_tools.py index 4680ddd..21c5365 100644 --- a/src/aidd/tools/github_tools.py +++ b/src/aidd/tools/github_tools.py @@ -1,7 +1,7 @@ import http.client import json import os -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List from mcp.types import TextContent # Tool definition @@ -34,13 +34,13 @@ def get_issue_tool() -> Dict[str, Any]: # Tool handler -async def handle_get_issue(args: Dict[str, Any]) -> TextContent: +async def handle_get_issue(args: Dict[str, Any]) -> List[TextContent]: owner = args.get("owner") repo = args.get("repo") issue_number = args.get("issue_number") if not all([owner, repo, issue_number]): - return TextContent(type="text", text="Error: Missing required parameters. Required parameters are owner, repo, and issue_number.") + return [TextContent(type="text", text="Error: Missing required parameters. Required parameters are owner, repo, and issue_number.")] # GitHub API URL path = f"/repos/{owner}/{repo}/issues/{issue_number}" @@ -65,10 +65,10 @@ async def handle_get_issue(args: Dict[str, Any]) -> TextContent: response = conn.getresponse() if response.status == 404: - return TextContent(type="text", text=f"Issue #{issue_number} not found in repository {owner}/{repo}") + return [TextContent(type="text", text=f"Issue #{issue_number} not found in repository {owner}/{repo}")] if response.status != 200: - return TextContent(type="text", text=f"Error fetching issue: {response.status} {response.reason}") + return [TextContent(type="text", text=f"Error fetching issue: {response.status} {response.reason}")] # Read and parse response issue_data = json.loads(response.read()) @@ -97,10 +97,10 @@ async def handle_get_issue(args: Dict[str, Any]) -> TextContent: if body := issue_data.get('body'): issue_info += f"## Description\n\n{body}\n\n" - return TextContent(type="text", text=issue_info) + return [TextContent(type="text", text=issue_info)] except Exception as e: - return TextContent(type="text", text=f"Error fetching issue: {str(e)}") + return [TextContent(type="text", text=f"Error fetching issue: {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 index eed82c9..9a50d46 100644 --- a/src/aidd/tools/test_github_tools.py +++ b/src/aidd/tools/test_github_tools.py @@ -9,8 +9,10 @@ async def test_handle_get_issue_missing_params(): """Test that missing parameters return an error message""" result = await handle_get_issue({}) - assert result.type == "text" - assert "Missing required parameters" in result.text + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "Missing required parameters" in result[0].text @pytest.mark.asyncio @@ -39,12 +41,14 @@ async def test_handle_get_issue_success(): "issue_number": 1 }) - assert result.type == "text" - assert "Test Issue" in result.text - assert "testuser" in result.text - assert "bug" in result.text - assert "assignee1" in result.text - assert "Test issue body" in result.text + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "Test Issue" in result[0].text + assert "testuser" in result[0].text + assert "bug" in result[0].text + assert "assignee1" in result[0].text + assert "Test issue body" in result[0].text @pytest.mark.asyncio @@ -61,8 +65,10 @@ async def test_handle_get_issue_not_found(): "issue_number": 999 }) - assert result.type == "text" - assert "not found" in result.text.lower() + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "not found" in result[0].text.lower() @pytest.mark.asyncio @@ -80,9 +86,11 @@ async def test_handle_get_issue_error(): "issue_number": 1 }) - assert result.type == "text" - assert "Error fetching issue" in result.text - assert "500" in result.text + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "Error fetching issue" in result[0].text + assert "500" in result[0].text @pytest.mark.asyncio @@ -96,6 +104,7 @@ async def test_handle_get_issue_exception(): "issue_number": 1 }) - assert result.type == "text" - assert "Error fetching issue" in result.text - assert "Connection failed" in result.text + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "Error fetching issue" in result[0].text diff --git a/src/aidd/tools/test_github_tools_integration.py b/src/aidd/tools/test_github_tools_integration.py index ea1ebd8..8656580 100644 --- a/src/aidd/tools/test_github_tools_integration.py +++ b/src/aidd/tools/test_github_tools_integration.py @@ -15,10 +15,12 @@ async def test_handle_get_issue_real_api(): "issue_number": 1 }) - assert isinstance(result, TextContent) - assert result.type == "text" - assert "python" in result.text.lower() - assert "cpython" in result.text.lower() + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].type == "text" + assert "python" in result[0].text.lower() + assert "cpython" in result[0].text.lower() @pytest.mark.asyncio @@ -30,9 +32,11 @@ async def test_handle_get_issue_not_found_real_api(): "issue_number": 999999999 # Very high number that shouldn't exist }) - assert isinstance(result, TextContent) - assert result.type == "text" - assert "not found" in result.text.lower() + 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 @@ -44,6 +48,9 @@ async def test_handle_get_issue_private_repo(): "issue_number": 1 }) - assert isinstance(result, TextContent) - assert result.type == "text" - assert "error" in result.text.lower() or "not found" in result.text.lower() + 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() From e36940474bdd713ce5b317a9d144fd6aa16856dc Mon Sep 17 00:00:00 2001 From: Duong Quang Hung Date: Mon, 28 Apr 2025 15:45:39 +0700 Subject: [PATCH 03/13] feat: Increase get_issue_tool description clarity --- src/aidd/tools/github_tools.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/aidd/tools/github_tools.py b/src/aidd/tools/github_tools.py index 21c5365..e78752e 100644 --- a/src/aidd/tools/github_tools.py +++ b/src/aidd/tools/github_tools.py @@ -10,7 +10,15 @@ def get_issue_tool() -> Dict[str, Any]: return { "name": "get_issue", - "description": "Gets the contents of an issue within a repository", + "description": "Retrieves detailed information about a GitHub issue from a repository. " + "WHEN TO USE: When you need to examine issue details, track progress, understand requirements, " + "or gather context about a specific problem or feature request. Ideal for reviewing issue descriptions, " + "checking status, identifying assignees, or understanding the full context of a development task. " + "WHEN NOT TO USE: When you need to create new issues, update existing ones, or interact with pull requests. " + "For those operations, use the appropriate GitHub API endpoints or tools. " + "RETURNS: A formatted markdown response containing the issue's title, number, state, creator, creation date, " + "last update date, URL, labels, assignees, and full description. The response is structured for easy reading " + "and includes all relevant metadata to understand the issue's context and current status.", "inputSchema": { "type": "object", "properties": { From aaec650510a108212863b235d4b495d421549670 Mon Sep 17 00:00:00 2001 From: Duong Quang Hung Date: Mon, 28 Apr 2025 15:48:47 +0700 Subject: [PATCH 04/13] chore: Remove acidental whitespace changes in server.py --- src/aidd/server.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/aidd/server.py b/src/aidd/server.py index 5051234..caeed5f 100644 --- a/src/aidd/server.py +++ b/src/aidd/server.py @@ -9,7 +9,6 @@ server = Server("skydeckai-code") - @server.list_tools() async def handle_list_tools() -> list[types.Tool]: """ @@ -18,7 +17,6 @@ async def handle_list_tools() -> list[types.Tool]: """ return [types.Tool(**tool) for tool in TOOL_DEFINITIONS] - @server.call_tool() async def handle_call_tool( name: str, arguments: dict | None @@ -35,7 +33,6 @@ async def handle_call_tool( return await handler(arguments) - async def main(): # Run the server using stdin/stdout streams async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): From 2f10164d9495fc7fb831b16b1b153a09e2cc3725 Mon Sep 17 00:00:00 2001 From: Duong Quang Hung Date: Mon, 28 Apr 2025 16:20:53 +0700 Subject: [PATCH 05/13] feat: Request GITHUB_TOKEN in handle_get_issue 404 error message --- src/aidd/tools/github_tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aidd/tools/github_tools.py b/src/aidd/tools/github_tools.py index e78752e..1c17399 100644 --- a/src/aidd/tools/github_tools.py +++ b/src/aidd/tools/github_tools.py @@ -71,9 +71,9 @@ async def handle_get_issue(args: Dict[str, Any]) -> List[TextContent]: # Make request conn.request("GET", path, headers=headers) response = conn.getresponse() - if response.status == 404: - return [TextContent(type="text", text=f"Issue #{issue_number} not found in repository {owner}/{repo}")] + return [TextContent(type="text", text=f"Issue #{issue_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 issue: {response.status} {response.reason}")] From f62cdf963b439eb9b8f8cd44f1372fb3ca0082f4 Mon Sep 17 00:00:00 2001 From: Duong Quang Hung Date: Tue, 29 Apr 2025 14:28:48 +0700 Subject: [PATCH 06/13] feat: Add create_pull_request_review tool --- src/aidd/tools/__init__.py | 4 +- src/aidd/tools/github_tools.py | 67 ++++++++++++++++++- src/aidd/tools/test_github_tools.py | 60 ++++++++++++++++- .../tools/test_github_tools_integration.py | 19 +++++- 4 files changed, 146 insertions(+), 4 deletions(-) diff --git a/src/aidd/tools/__init__.py b/src/aidd/tools/__init__.py index 55fbe5f..f14c7bb 100644 --- a/src/aidd/tools/__init__.py +++ b/src/aidd/tools/__init__.py @@ -1,4 +1,4 @@ -from .github_tools import get_issue_tool, handle_get_issue +from .github_tools import create_pull_request_review_tool, get_issue_tool, handle_create_pull_request_review, handle_get_issue from .code_analysis import handle_codebase_mapper, codebase_mapper_tool from .code_execution import ( execute_code_tool, @@ -85,6 +85,7 @@ get_system_info_tool(), # Github tools get_issue_tool(), + create_pull_request_review_tool(), ] # Export all handlers @@ -121,4 +122,5 @@ "web_search": handle_web_search, # Github handlers "get_issue": handle_get_issue, + "create_pull_request_review": handle_create_pull_request_review, } diff --git a/src/aidd/tools/github_tools.py b/src/aidd/tools/github_tools.py index 1c17399..01f5a2a 100644 --- a/src/aidd/tools/github_tools.py +++ b/src/aidd/tools/github_tools.py @@ -2,7 +2,7 @@ import json import os from typing import Any, Dict, List -from mcp.types import TextContent +from mcp.types import TextContent, ErrorData # Tool definition @@ -42,6 +42,71 @@ def get_issue_tool() -> Dict[str, Any]: # Tool handler +def create_pull_request_review_tool() -> Dict[str, Any]: + return { + "name": "create_pull_request_review", + "description": "Create a review for a pull request. Use this to approve, request changes, or comment on a PR. You can also submit code review comments.", + "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)", "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() + async def handle_get_issue(args: Dict[str, Any]) -> List[TextContent]: owner = args.get("owner") repo = args.get("repo") diff --git a/src/aidd/tools/test_github_tools.py b/src/aidd/tools/test_github_tools.py index 9a50d46..ab8f583 100644 --- a/src/aidd/tools/test_github_tools.py +++ b/src/aidd/tools/test_github_tools.py @@ -2,9 +2,67 @@ from unittest.mock import patch, MagicMock from typing import Dict, Any import mcp.types as types -from .github_tools import handle_get_issue +from .github_tools import handle_get_issue, handle_create_pull_request_review +@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_issue_missing_params(): """Test that missing parameters return an error message""" diff --git a/src/aidd/tools/test_github_tools_integration.py b/src/aidd/tools/test_github_tools_integration.py index 8656580..43983d6 100644 --- a/src/aidd/tools/test_github_tools_integration.py +++ b/src/aidd/tools/test_github_tools_integration.py @@ -2,9 +2,26 @@ import os from typing import Dict, Any from mcp.types import TextContent -from .github_tools import handle_get_issue +from .github_tools import handle_get_issue, handle_create_pull_request_review +@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_issue_real_api(): """Test the get_issue tool against the real GitHub API""" From 36431938c07f06958da4995f9605fae878b4f640 Mon Sep 17 00:00:00 2001 From: Duong Quang Hung Date: Fri, 2 May 2025 14:27:27 +0700 Subject: [PATCH 07/13] feat: Add get_pull_request_files tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implements a new GitHub tool to retrieve the list of files changed in a pull request - Provides helpful formatting with status, additions, deletions, and summaries - Includes comprehensive unit and integration tests - Follows the same pattern as existing GitHub tools 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/aidd/tools/__init__.py | 11 +- src/aidd/tools/github_tools.py | 120 ++++++++++++++ src/aidd/tools/test_github_tools.py | 148 +++++++++++++++++- .../tools/test_github_tools_integration.py | 56 ++++++- 4 files changed, 332 insertions(+), 3 deletions(-) diff --git a/src/aidd/tools/__init__.py b/src/aidd/tools/__init__.py index f14c7bb..d47e2a8 100644 --- a/src/aidd/tools/__init__.py +++ b/src/aidd/tools/__init__.py @@ -1,4 +1,11 @@ -from .github_tools import create_pull_request_review_tool, get_issue_tool, handle_create_pull_request_review, handle_get_issue +from .github_tools import ( + create_pull_request_review_tool, + get_issue_tool, + get_pull_request_files_tool, + handle_create_pull_request_review, + handle_get_issue, + handle_get_pull_request_files +) from .code_analysis import handle_codebase_mapper, codebase_mapper_tool from .code_execution import ( execute_code_tool, @@ -86,6 +93,7 @@ # Github tools get_issue_tool(), create_pull_request_review_tool(), + get_pull_request_files_tool(), ] # Export all handlers @@ -123,4 +131,5 @@ # Github handlers "get_issue": handle_get_issue, "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 index 01f5a2a..17febcd 100644 --- a/src/aidd/tools/github_tools.py +++ b/src/aidd/tools/github_tools.py @@ -177,3 +177,123 @@ async def handle_get_issue(args: Dict[str, Any]) -> List[TextContent]: finally: if conn is not None: 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. " + "WHEN TO USE: When you need to examine which files were modified, added, or deleted in a specific pull request. " + "This is useful for code review, understanding the scope of changes, or analyzing the impact of a pull request. " + "WHEN NOT TO USE: When you need to view the actual content changes within files, create or modify pull requests, " + "or when you're interested in other pull request metadata such as comments or reviews. " + "RETURNS: A formatted markdown response containing a list of all files changed in the pull request, including " + "the filename, status (added, modified, removed), and change statistics (additions and deletions).", + "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"): + if len(patch) < 500: # Only include patch if it's reasonably sized + files_info += f"```diff\n{patch}\n```\n" + else: + files_info += f" - [Patch too large to display - view on GitHub]\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() diff --git a/src/aidd/tools/test_github_tools.py b/src/aidd/tools/test_github_tools.py index ab8f583..77c733c 100644 --- a/src/aidd/tools/test_github_tools.py +++ b/src/aidd/tools/test_github_tools.py @@ -2,7 +2,7 @@ from unittest.mock import patch, MagicMock from typing import Dict, Any import mcp.types as types -from .github_tools import handle_get_issue, handle_create_pull_request_review +from .github_tools import handle_get_issue, handle_create_pull_request_review, handle_get_pull_request_files @pytest.mark.asyncio @@ -166,3 +166,149 @@ async def test_handle_get_issue_exception(): assert len(result) == 1 assert result[0].type == "text" assert "Error fetching issue" 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 diff --git a/src/aidd/tools/test_github_tools_integration.py b/src/aidd/tools/test_github_tools_integration.py index 43983d6..1fbc31d 100644 --- a/src/aidd/tools/test_github_tools_integration.py +++ b/src/aidd/tools/test_github_tools_integration.py @@ -2,7 +2,7 @@ import os from typing import Dict, Any from mcp.types import TextContent -from .github_tools import handle_get_issue, handle_create_pull_request_review +from .github_tools import handle_get_issue, handle_create_pull_request_review, handle_get_pull_request_files @pytest.mark.asyncio @@ -71,3 +71,57 @@ async def test_handle_get_issue_private_repo(): 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_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() From 6cafa83ba204c14f5d918644745f15e6118cdd7f Mon Sep 17 00:00:00 2001 From: Duong Quang Hung Date: Mon, 5 May 2025 09:29:17 +0700 Subject: [PATCH 08/13] Increase diff len for handle_get_pull_request_files --- src/aidd/tools/github_tools.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/aidd/tools/github_tools.py b/src/aidd/tools/github_tools.py index 17febcd..65399e4 100644 --- a/src/aidd/tools/github_tools.py +++ b/src/aidd/tools/github_tools.py @@ -107,6 +107,7 @@ async def handle_create_pull_request_review(args: Dict[str, Any]) -> List[TextCo if conn: conn.close() + async def handle_get_issue(args: Dict[str, Any]) -> List[TextContent]: owner = args.get("owner") repo = args.get("repo") @@ -254,36 +255,36 @@ async def handle_get_pull_request_files(args: Dict[str, Any]) -> List[TextConten 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"): - if len(patch) < 500: # Only include patch if it's reasonably sized - files_info += f"```diff\n{patch}\n```\n" + if len(patch) < 10000: # Only include patch if it's reasonably sized + files_info = f"```diff\n{patch}\n```\n" else: files_info += f" - [Patch too large to display - view on GitHub]\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" From f62bdf6f4b6bcb7b1d0ac36795a47a8010d55fb8 Mon Sep 17 00:00:00 2001 From: Duong Quang Hung Date: Mon, 5 May 2025 10:27:30 +0700 Subject: [PATCH 09/13] Add list_issues_tool --- src/aidd/tools/__init__.py | 6 +- src/aidd/tools/github_tools.py | 170 +++++++++++++++++- src/aidd/tools/test_github_tools.py | 150 +++++++++++++++- .../tools/test_github_tools_integration.py | 99 +++++++++- 4 files changed, 420 insertions(+), 5 deletions(-) diff --git a/src/aidd/tools/__init__.py b/src/aidd/tools/__init__.py index d47e2a8..7bae5fc 100644 --- a/src/aidd/tools/__init__.py +++ b/src/aidd/tools/__init__.py @@ -2,9 +2,11 @@ create_pull_request_review_tool, get_issue_tool, get_pull_request_files_tool, + list_issues_tool, handle_create_pull_request_review, handle_get_issue, - handle_get_pull_request_files + handle_get_pull_request_files, + handle_list_issues ) from .code_analysis import handle_codebase_mapper, codebase_mapper_tool from .code_execution import ( @@ -92,6 +94,7 @@ get_system_info_tool(), # Github tools get_issue_tool(), + list_issues_tool(), create_pull_request_review_tool(), get_pull_request_files_tool(), ] @@ -130,6 +133,7 @@ "web_search": handle_web_search, # Github handlers "get_issue": handle_get_issue, + "list_issues": handle_list_issues, "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 index 65399e4..42ee095 100644 --- a/src/aidd/tools/github_tools.py +++ b/src/aidd/tools/github_tools.py @@ -1,11 +1,78 @@ import http.client import json import os -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from mcp.types import TextContent, ErrorData # Tool definition +def list_issues_tool() -> Dict[str, Any]: + return { + "name": "list_issues", + "description": "Lists issues in a GitHub repository. " + "WHEN TO USE: When you need to explore or search for issues in a repository, check what's currently open, " + "assigned to specific users, or filter by various criteria. Useful for project management, bug tracking, " + "or understanding what work is in progress or needs attention. " + "WHEN NOT TO USE: When you need detailed information about a single specific issue - use get_issue instead. " + "Also not suitable for creating or modifying issues. " + "RETURNS: A formatted markdown response containing a list of issues matching the specified criteria, " + "including issue numbers, titles, states, creators, labels, and creation dates.", + "inputSchema": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "state": { + "type": "string", + "description": "Filter by issue state: open, closed, or all", + "enum": ["open", "closed", "all"], + "default": "open" + }, + "labels": { + "type": "string", + "description": "Filter by comma-separated list of labels", + "nullable": True + }, + "assignee": { + "type": "string", + "description": "Filter by assignee username", + "nullable": True + }, + "creator": { + "type": "string", + "description": "Filter by creator username", + "nullable": True + }, + "sort": { + "type": "string", + "description": "How to sort the results", + "enum": ["created", "updated", "comments"], + "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 get_issue_tool() -> Dict[str, Any]: return { @@ -274,7 +341,7 @@ async def handle_get_pull_request_files(args: Dict[str, Any]) -> List[TextConten # Add patch information if available and not too large if patch := file.get("patch"): if len(patch) < 10000: # Only include patch if it's reasonably sized - files_info = f"```diff\n{patch}\n```\n" + files_info += f"```diff\n{patch}\n```\n" else: files_info += f" - [Patch too large to display - view on GitHub]\n" @@ -298,3 +365,102 @@ async def handle_get_pull_request_files(args: Dict[str, Any]) -> List[TextConten finally: if conn is not None: conn.close() + + +async def handle_list_issues(args: Dict[str, Any]) -> List[TextContent]: + owner = args.get("owner") + repo = args.get("repo") + state = args.get("state", "open") + labels = args.get("labels") + assignee = args.get("assignee") + creator = args.get("creator") + 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 labels: + query_params += f"&labels={labels}" + if assignee: + query_params += f"&assignee={assignee}" + if creator: + query_params += f"&creator={creator}" + + # GitHub API URL + path = f"/repos/{owner}/{repo}/issues?{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 issues: {response.status} {response.reason}")] + + # Read and parse response + issues_data = json.loads(response.read()) + + # Handle empty results + if not issues_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} issues found in repository {owner}/{repo}.")] + + # Format the issues data for display + issues_info = f"# Issues in {owner}/{repo} ({state})\n\n" + + # Create a table header + issues_info += "| Number | Title | State | Creator | Labels | Created At |\n" + issues_info += "|--------|-------|-------|---------|--------|------------|\n" + + for issue in issues_data: + # Skip pull requests (which the API also returns) + if issue.get("pull_request"): + continue + + number = issue.get("number", "N/A") + title = issue.get("title", "No title") + issue_state = issue.get("state", "unknown") + creator = issue.get("user", {}).get("login", "unknown") + created_at = issue.get("created_at", "unknown") + + # Format labels + label_names = [label.get("name", "") for label in issue.get("labels", [])] + labels_str = ", ".join(label_names) if label_names else "none" + + # Add row to table + issues_info += f"| [{number}]({issue.get('html_url', '')}) | {title} | {issue_state} | {creator} | {labels_str} | {created_at} |\n" + + # Add summary + issues_count = len([i for i in issues_data if not i.get("pull_request")]) + issues_info += f"\n\n**Total issues found: {issues_count}**\n" + issues_info += f"View all issues: https://github.com/{owner}/{repo}/issues\n" + + return [TextContent(type="text", text=issues_info)] + + except Exception as e: + return [TextContent(type="text", text=f"Error fetching issues: {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 index 77c733c..ed7913e 100644 --- a/src/aidd/tools/test_github_tools.py +++ b/src/aidd/tools/test_github_tools.py @@ -2,7 +2,7 @@ from unittest.mock import patch, MagicMock from typing import Dict, Any import mcp.types as types -from .github_tools import handle_get_issue, handle_create_pull_request_review, handle_get_pull_request_files +from .github_tools import handle_get_issue, handle_create_pull_request_review, handle_get_pull_request_files, handle_list_issues @pytest.mark.asyncio @@ -312,3 +312,151 @@ async def test_handle_get_pull_request_files_exception(): 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_issues_missing_params(): + """Test that missing parameters return an error message""" + result = await handle_list_issues({}) + 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_issues_success(): + """Test successful issues listing""" + mock_response = MagicMock() + mock_response.status = 200 + mock_response.read.return_value = b'''[ + { + "number": 1, + "title": "First Issue", + "state": "open", + "user": {"login": "testuser"}, + "created_at": "2024-01-01T00:00:00Z", + "html_url": "https://github.com/test/repo/issues/1", + "labels": [{"name": "bug"}, {"name": "high-priority"}] + }, + { + "number": 2, + "title": "Second Issue", + "state": "open", + "user": {"login": "otheruser"}, + "created_at": "2024-01-02T00:00:00Z", + "html_url": "https://github.com/test/repo/issues/2", + "labels": [] + }, + { + "number": 3, + "title": "Pull Request", + "state": "open", + "user": {"login": "contributor"}, + "created_at": "2024-01-03T00:00:00Z", + "html_url": "https://github.com/test/repo/pull/3", + "pull_request": {"url": "https://api.github.com/repos/test/repo/pulls/3"} + } + ]''' + + with patch('http.client.HTTPSConnection') as mock_conn: + mock_conn.return_value.getresponse.return_value = mock_response + result = await handle_list_issues({ + "owner": "test", + "repo": "repo", + "state": "open" + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "Issues in test/repo (open)" in result[0].text + assert "First Issue" in result[0].text + assert "Second Issue" in result[0].text + assert "Total issues found: 2" in result[0].text # Should exclude PR + assert "bug, high-priority" in result[0].text + assert "testuser" in result[0].text + assert "otheruser" in result[0].text + # PR should be excluded + assert "Pull Request" not in result[0].text + assert "contributor" not in result[0].text + + +@pytest.mark.asyncio +async def test_handle_list_issues_empty(): + """Test handling of repo with no issues""" + 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_issues({ + "owner": "test", + "repo": "repo", + "state": "open" + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "No open issues found in repository test/repo" in result[0].text + + +@pytest.mark.asyncio +async def test_handle_list_issues_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_issues({ + "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_issues_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_issues({ + "owner": "test", + "repo": "repo", + "state": "open" + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "Error fetching issues" in result[0].text + assert "500" in result[0].text + + +@pytest.mark.asyncio +async def test_handle_list_issues_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_issues({ + "owner": "test", + "repo": "repo", + "state": "open" + }) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert "Error fetching issues" in result[0].text diff --git a/src/aidd/tools/test_github_tools_integration.py b/src/aidd/tools/test_github_tools_integration.py index 1fbc31d..022dc77 100644 --- a/src/aidd/tools/test_github_tools_integration.py +++ b/src/aidd/tools/test_github_tools_integration.py @@ -2,7 +2,7 @@ import os from typing import Dict, Any from mcp.types import TextContent -from .github_tools import handle_get_issue, handle_create_pull_request_review, handle_get_pull_request_files +from .github_tools import handle_get_issue, handle_create_pull_request_review, handle_get_pull_request_files, handle_list_issues @pytest.mark.asyncio @@ -125,3 +125,100 @@ async def test_handle_get_pull_request_files_private_repo(): 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_issues_real_api(): + """Test the list_issues tool against the real GitHub API""" + # Test with a well-known public repository + result = await handle_list_issues({ + "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 "Issues in python/cpython (open)" in result[0].text + # Check for table headers + assert "| Number | Title | State | Creator | Labels | Created At |" in result[0].text + # Check for summary + assert "Total issues found:" in result[0].text + + +@pytest.mark.asyncio +async def test_handle_list_issues_closed_real_api(): + """Test the list_issues tool with closed issues against the real GitHub API""" + result = await handle_list_issues({ + "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 "Issues in python/cpython (closed)" in result[0].text + # Check for table headers + assert "| Number | Title | State | Creator | Labels | Created At |" in result[0].text + # Check for summary + assert "Total issues found:" in result[0].text + + +@pytest.mark.asyncio +async def test_handle_list_issues_filtered_real_api(): + """Test the list_issues tool with filters against the real GitHub API""" + result = await handle_list_issues({ + "owner": "python", + "repo": "cpython", + "state": "open", + "labels": "documentation", + "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 "Issues in python/cpython (open)" in result[0].text + + # We can't assert that "documentation" is in every issue since the API might change, + # but we can check if the response is well-formed + + +@pytest.mark.asyncio +async def test_handle_list_issues_not_found_real_api(): + """Test handling of non-existent repository against the real GitHub API""" + result = await handle_list_issues({ + "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_issues_private_repo(): + """Test handling of private repository access""" + result = await handle_list_issues({ + "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() From 6293cc6444fd5484c102d9676a41c086758c6906 Mon Sep 17 00:00:00 2001 From: Duong Quang Hung Date: Mon, 5 May 2025 10:43:36 +0700 Subject: [PATCH 10/13] feat: Add list_pull_requests tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement list_pull_requests_tool for fetching PRs from GitHub repositories - Add handler function to process API requests and format results - Create unit and integration tests for the new tool - Add tool to module exports 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/aidd/tools/__init__.py | 6 +- src/aidd/tools/github_tools.py | 154 ++++++++++++++++++ src/aidd/tools/test_github_tools.py | 141 +++++++++++++++- .../tools/test_github_tools_integration.py | 96 ++++++++++- 4 files changed, 394 insertions(+), 3 deletions(-) diff --git a/src/aidd/tools/__init__.py b/src/aidd/tools/__init__.py index 7bae5fc..02202d9 100644 --- a/src/aidd/tools/__init__.py +++ b/src/aidd/tools/__init__.py @@ -3,10 +3,12 @@ get_issue_tool, get_pull_request_files_tool, list_issues_tool, + list_pull_requests_tool, handle_create_pull_request_review, handle_get_issue, handle_get_pull_request_files, - handle_list_issues + handle_list_issues, + handle_list_pull_requests ) from .code_analysis import handle_codebase_mapper, codebase_mapper_tool from .code_execution import ( @@ -95,6 +97,7 @@ # Github tools get_issue_tool(), list_issues_tool(), + list_pull_requests_tool(), create_pull_request_review_tool(), get_pull_request_files_tool(), ] @@ -134,6 +137,7 @@ # Github handlers "get_issue": handle_get_issue, "list_issues": handle_list_issues, + "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 index 42ee095..1a54cd9 100644 --- a/src/aidd/tools/github_tools.py +++ b/src/aidd/tools/github_tools.py @@ -6,6 +6,68 @@ # Tool definition +def list_pull_requests_tool() -> Dict[str, Any]: + return { + "name": "list_pull_requests", + "description": "Lists pull requests in a GitHub repository. " + "WHEN TO USE: When you need to explore or search for pull requests in a repository, check what's currently open, " + "or filter by various criteria. Useful for project management, code review, " + "or understanding what work is in progress or needs review. " + "WHEN NOT TO USE: When you need detailed information about a single specific pull request. " + "Also not suitable for creating or modifying pull requests. " + "RETURNS: A formatted markdown response containing a list of pull requests matching the specified criteria, " + "including PR numbers, titles, states, creators, branches, and creation dates.", + "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 list_issues_tool() -> Dict[str, Any]: return { "name": "list_issues", @@ -367,6 +429,98 @@ async def handle_get_pull_request_files(args: Dict[str, Any]) -> List[TextConten 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() + + async def handle_list_issues(args: Dict[str, Any]) -> List[TextContent]: owner = args.get("owner") repo = args.get("repo") diff --git a/src/aidd/tools/test_github_tools.py b/src/aidd/tools/test_github_tools.py index ed7913e..81c50b4 100644 --- a/src/aidd/tools/test_github_tools.py +++ b/src/aidd/tools/test_github_tools.py @@ -2,7 +2,7 @@ from unittest.mock import patch, MagicMock from typing import Dict, Any import mcp.types as types -from .github_tools import handle_get_issue, handle_create_pull_request_review, handle_get_pull_request_files, handle_list_issues +from .github_tools import handle_get_issue, handle_create_pull_request_review, handle_get_pull_request_files, handle_list_issues, handle_list_pull_requests @pytest.mark.asyncio @@ -460,3 +460,142 @@ async def test_handle_list_issues_exception(): assert len(result) == 1 assert result[0].type == "text" assert "Error fetching issues" 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 index 022dc77..c82988a 100644 --- a/src/aidd/tools/test_github_tools_integration.py +++ b/src/aidd/tools/test_github_tools_integration.py @@ -2,7 +2,7 @@ import os from typing import Dict, Any from mcp.types import TextContent -from .github_tools import handle_get_issue, handle_create_pull_request_review, handle_get_pull_request_files, handle_list_issues +from .github_tools import handle_get_issue, handle_create_pull_request_review, handle_get_pull_request_files, handle_list_issues, handle_list_pull_requests @pytest.mark.asyncio @@ -222,3 +222,97 @@ async def test_handle_list_issues_private_repo(): 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() From a01f57cd49bbfc715ce31d2a95db3a5a91d34228 Mon Sep 17 00:00:00 2001 From: Duong Quang Hung Date: Mon, 5 May 2025 17:04:35 +0700 Subject: [PATCH 11/13] Add more detail descriptions for create_pull_request_review_tool --- src/aidd/tools/github_tools.py | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/aidd/tools/github_tools.py b/src/aidd/tools/github_tools.py index 1a54cd9..be04db2 100644 --- a/src/aidd/tools/github_tools.py +++ b/src/aidd/tools/github_tools.py @@ -6,6 +6,7 @@ # Tool definition + def list_pull_requests_tool() -> Dict[str, Any]: return { "name": "list_pull_requests", @@ -68,6 +69,7 @@ def list_pull_requests_tool() -> Dict[str, Any]: }, } + def list_issues_tool() -> Dict[str, Any]: return { "name": "list_issues", @@ -174,7 +176,9 @@ def get_issue_tool() -> Dict[str, Any]: def create_pull_request_review_tool() -> Dict[str, Any]: return { "name": "create_pull_request_review", - "description": "Create a review for a pull request. Use this to approve, request changes, or comment on a PR. You can also submit code review comments.", + "description": "Create a review for a pull request. Use this to approve, request changes, or comment on a PR. You can also submit code review comments. " + "WHEN TO USE: When you need to review a pull request, you can use this tool to approve, request changes, or comment on a PR." + "RETURNS: A formatted markdown response containing the review's status, comments, and any additional information.", "inputSchema": { "type": "object", "properties": { @@ -183,7 +187,7 @@ def create_pull_request_review_tool() -> Dict[str, Any]: "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)", "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"] @@ -483,12 +487,13 @@ async def handle_list_pull_requests(args: Dict[str, Any]) -> List[TextContent]: # Handle empty results if not pr_data: - status_label = "open" if state == "open" else ("closed" if state == "closed" else "matching your criteria") + 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" @@ -499,12 +504,12 @@ async def handle_list_pull_requests(args: Dict[str, Any]) -> List[TextContent]: 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" @@ -578,12 +583,13 @@ async def handle_list_issues(args: Dict[str, Any]) -> List[TextContent]: # Handle empty results if not issues_data: - status_label = "open" if state == "open" else ("closed" if state == "closed" else "matching your criteria") + status_label = "open" if state == "open" else ( + "closed" if state == "closed" else "matching your criteria") return [TextContent(type="text", text=f"No {status_label} issues found in repository {owner}/{repo}.")] # Format the issues data for display issues_info = f"# Issues in {owner}/{repo} ({state})\n\n" - + # Create a table header issues_info += "| Number | Title | State | Creator | Labels | Created At |\n" issues_info += "|--------|-------|-------|---------|--------|------------|\n" @@ -592,22 +598,24 @@ async def handle_list_issues(args: Dict[str, Any]) -> List[TextContent]: # Skip pull requests (which the API also returns) if issue.get("pull_request"): continue - + number = issue.get("number", "N/A") title = issue.get("title", "No title") issue_state = issue.get("state", "unknown") creator = issue.get("user", {}).get("login", "unknown") created_at = issue.get("created_at", "unknown") - + # Format labels - label_names = [label.get("name", "") for label in issue.get("labels", [])] + label_names = [label.get("name", "") + for label in issue.get("labels", [])] labels_str = ", ".join(label_names) if label_names else "none" - + # Add row to table issues_info += f"| [{number}]({issue.get('html_url', '')}) | {title} | {issue_state} | {creator} | {labels_str} | {created_at} |\n" # Add summary - issues_count = len([i for i in issues_data if not i.get("pull_request")]) + issues_count = len( + [i for i in issues_data if not i.get("pull_request")]) issues_info += f"\n\n**Total issues found: {issues_count}**\n" issues_info += f"View all issues: https://github.com/{owner}/{repo}/issues\n" From b0fe8d30fe8fe1295917cabdd1fbcbeaf0191fe7 Mon Sep 17 00:00:00 2001 From: Duong Quang Hung Date: Tue, 6 May 2025 11:05:45 +0700 Subject: [PATCH 12/13] Simplify github tool assuming models are well trained on github --- src/aidd/tools/__init__.py | 8 - src/aidd/tools/github_tools.py | 314 +----------------- src/aidd/tools/test_github_tools.py | 256 +------------- .../tools/test_github_tools_integration.py | 150 +-------- 4 files changed, 8 insertions(+), 720 deletions(-) diff --git a/src/aidd/tools/__init__.py b/src/aidd/tools/__init__.py index 02202d9..9be59b6 100644 --- a/src/aidd/tools/__init__.py +++ b/src/aidd/tools/__init__.py @@ -1,13 +1,9 @@ from .github_tools import ( create_pull_request_review_tool, - get_issue_tool, get_pull_request_files_tool, - list_issues_tool, list_pull_requests_tool, handle_create_pull_request_review, - handle_get_issue, handle_get_pull_request_files, - handle_list_issues, handle_list_pull_requests ) from .code_analysis import handle_codebase_mapper, codebase_mapper_tool @@ -95,8 +91,6 @@ # System tools get_system_info_tool(), # Github tools - get_issue_tool(), - list_issues_tool(), list_pull_requests_tool(), create_pull_request_review_tool(), get_pull_request_files_tool(), @@ -135,8 +129,6 @@ "web_fetch": handle_web_fetch, "web_search": handle_web_search, # Github handlers - "get_issue": handle_get_issue, - "list_issues": handle_list_issues, "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 index be04db2..ac57906 100644 --- a/src/aidd/tools/github_tools.py +++ b/src/aidd/tools/github_tools.py @@ -1,23 +1,14 @@ import http.client import json import os -from typing import Any, Dict, List, Optional -from mcp.types import TextContent, ErrorData - -# Tool definition +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": "Lists pull requests in a GitHub repository. " - "WHEN TO USE: When you need to explore or search for pull requests in a repository, check what's currently open, " - "or filter by various criteria. Useful for project management, code review, " - "or understanding what work is in progress or needs review. " - "WHEN NOT TO USE: When you need detailed information about a single specific pull request. " - "Also not suitable for creating or modifying pull requests. " - "RETURNS: A formatted markdown response containing a list of pull requests matching the specified criteria, " - "including PR numbers, titles, states, creators, branches, and creation dates.", + "description": "List and filter repository pull requests", "inputSchema": { "type": "object", "properties": { @@ -69,116 +60,10 @@ def list_pull_requests_tool() -> Dict[str, Any]: }, } - -def list_issues_tool() -> Dict[str, Any]: - return { - "name": "list_issues", - "description": "Lists issues in a GitHub repository. " - "WHEN TO USE: When you need to explore or search for issues in a repository, check what's currently open, " - "assigned to specific users, or filter by various criteria. Useful for project management, bug tracking, " - "or understanding what work is in progress or needs attention. " - "WHEN NOT TO USE: When you need detailed information about a single specific issue - use get_issue instead. " - "Also not suitable for creating or modifying issues. " - "RETURNS: A formatted markdown response containing a list of issues matching the specified criteria, " - "including issue numbers, titles, states, creators, labels, and creation dates.", - "inputSchema": { - "type": "object", - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "state": { - "type": "string", - "description": "Filter by issue state: open, closed, or all", - "enum": ["open", "closed", "all"], - "default": "open" - }, - "labels": { - "type": "string", - "description": "Filter by comma-separated list of labels", - "nullable": True - }, - "assignee": { - "type": "string", - "description": "Filter by assignee username", - "nullable": True - }, - "creator": { - "type": "string", - "description": "Filter by creator username", - "nullable": True - }, - "sort": { - "type": "string", - "description": "How to sort the results", - "enum": ["created", "updated", "comments"], - "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 get_issue_tool() -> Dict[str, Any]: - return { - "name": "get_issue", - "description": "Retrieves detailed information about a GitHub issue from a repository. " - "WHEN TO USE: When you need to examine issue details, track progress, understand requirements, " - "or gather context about a specific problem or feature request. Ideal for reviewing issue descriptions, " - "checking status, identifying assignees, or understanding the full context of a development task. " - "WHEN NOT TO USE: When you need to create new issues, update existing ones, or interact with pull requests. " - "For those operations, use the appropriate GitHub API endpoints or tools. " - "RETURNS: A formatted markdown response containing the issue's title, number, state, creator, creation date, " - "last update date, URL, labels, assignees, and full description. The response is structured for easy reading " - "and includes all relevant metadata to understand the issue's context and current status.", - "inputSchema": { - "type": "object", - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "issue_number": { - "type": "number", - "description": "Issue number" - } - }, - "required": ["owner", "repo", "issue_number"] - }, - } - -# Tool handler - - def create_pull_request_review_tool() -> Dict[str, Any]: return { "name": "create_pull_request_review", - "description": "Create a review for a pull request. Use this to approve, request changes, or comment on a PR. You can also submit code review comments. " - "WHEN TO USE: When you need to review a pull request, you can use this tool to approve, request changes, or comment on a PR." - "RETURNS: A formatted markdown response containing the review's status, comments, and any additional information.", + "description": "Create a review for a pull request.", "inputSchema": { "type": "object", "properties": { @@ -194,7 +79,6 @@ def create_pull_request_review_tool() -> Dict[str, Any]: }, } - # Handler for creating a pull request review async def handle_create_pull_request_review(args: Dict[str, Any]) -> List[TextContent]: owner = args.get("owner") @@ -240,89 +124,10 @@ async def handle_create_pull_request_review(args: Dict[str, Any]) -> List[TextCo if conn: conn.close() - -async def handle_get_issue(args: Dict[str, Any]) -> List[TextContent]: - owner = args.get("owner") - repo = args.get("repo") - issue_number = args.get("issue_number") - - if not all([owner, repo, issue_number]): - return [TextContent(type="text", text="Error: Missing required parameters. Required parameters are owner, repo, and issue_number.")] - - # GitHub API URL - path = f"/repos/{owner}/{repo}/issues/{issue_number}" - 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"Issue #{issue_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 issue: {response.status} {response.reason}")] - - # Read and parse response - issue_data = json.loads(response.read()) - - # Format the issue data for display - issue_info = ( - f"# Issue #{issue_data.get('number')}: {issue_data.get('title')}\n\n" - f"**State:** {issue_data.get('state')}\n" - f"**Created by:** {issue_data.get('user', {}).get('login')}\n" - f"**Created at:** {issue_data.get('created_at')}\n" - f"**Updated at:** {issue_data.get('updated_at')}\n" - f"**URL:** {issue_data.get('html_url')}\n\n" - ) - - # Add labels if they exist - if labels := issue_data.get('labels', []): - label_names = [label.get('name') for label in labels] - issue_info += f"**Labels:** {', '.join(label_names)}\n\n" - - # Add assignees if they exist - if assignees := issue_data.get('assignees', []): - assignee_names = [assignee.get('login') for assignee in assignees] - issue_info += f"**Assignees:** {', '.join(assignee_names)}\n\n" - - # Add body if it exists - if body := issue_data.get('body'): - issue_info += f"## Description\n\n{body}\n\n" - - return [TextContent(type="text", text=issue_info)] - - except Exception as e: - return [TextContent(type="text", text=f"Error fetching issue: {str(e)}")] - finally: - if conn is not None: - 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. " - "WHEN TO USE: When you need to examine which files were modified, added, or deleted in a specific pull request. " - "This is useful for code review, understanding the scope of changes, or analyzing the impact of a pull request. " - "WHEN NOT TO USE: When you need to view the actual content changes within files, create or modify pull requests, " - "or when you're interested in other pull request metadata such as comments or reviews. " - "RETURNS: A formatted markdown response containing a list of all files changed in the pull request, including " - "the filename, status (added, modified, removed), and change statistics (additions and deletions).", + "description": "Retrieves the list of files changed in a GitHub pull request.", "inputSchema": { "type": "object", "properties": { @@ -343,7 +148,6 @@ def get_pull_request_files_tool() -> Dict[str, Any]: }, } - async def handle_get_pull_request_files(args: Dict[str, Any]) -> List[TextContent]: owner = args.get("owner") repo = args.get("repo") @@ -406,10 +210,7 @@ async def handle_get_pull_request_files(args: Dict[str, Any]) -> List[TextConten # Add patch information if available and not too large if patch := file.get("patch"): - if len(patch) < 10000: # Only include patch if it's reasonably sized - files_info += f"```diff\n{patch}\n```\n" - else: - files_info += f" - [Patch too large to display - view on GitHub]\n" + files_info += f"```diff\n{patch}\n```\n" files_info += "\n" @@ -432,7 +233,6 @@ async def handle_get_pull_request_files(args: Dict[str, Any]) -> List[TextConten 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") @@ -524,105 +324,3 @@ async def handle_list_pull_requests(args: Dict[str, Any]) -> List[TextContent]: finally: if conn is not None: conn.close() - - -async def handle_list_issues(args: Dict[str, Any]) -> List[TextContent]: - owner = args.get("owner") - repo = args.get("repo") - state = args.get("state", "open") - labels = args.get("labels") - assignee = args.get("assignee") - creator = args.get("creator") - 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 labels: - query_params += f"&labels={labels}" - if assignee: - query_params += f"&assignee={assignee}" - if creator: - query_params += f"&creator={creator}" - - # GitHub API URL - path = f"/repos/{owner}/{repo}/issues?{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 issues: {response.status} {response.reason}")] - - # Read and parse response - issues_data = json.loads(response.read()) - - # Handle empty results - if not issues_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} issues found in repository {owner}/{repo}.")] - - # Format the issues data for display - issues_info = f"# Issues in {owner}/{repo} ({state})\n\n" - - # Create a table header - issues_info += "| Number | Title | State | Creator | Labels | Created At |\n" - issues_info += "|--------|-------|-------|---------|--------|------------|\n" - - for issue in issues_data: - # Skip pull requests (which the API also returns) - if issue.get("pull_request"): - continue - - number = issue.get("number", "N/A") - title = issue.get("title", "No title") - issue_state = issue.get("state", "unknown") - creator = issue.get("user", {}).get("login", "unknown") - created_at = issue.get("created_at", "unknown") - - # Format labels - label_names = [label.get("name", "") - for label in issue.get("labels", [])] - labels_str = ", ".join(label_names) if label_names else "none" - - # Add row to table - issues_info += f"| [{number}]({issue.get('html_url', '')}) | {title} | {issue_state} | {creator} | {labels_str} | {created_at} |\n" - - # Add summary - issues_count = len( - [i for i in issues_data if not i.get("pull_request")]) - issues_info += f"\n\n**Total issues found: {issues_count}**\n" - issues_info += f"View all issues: https://github.com/{owner}/{repo}/issues\n" - - return [TextContent(type="text", text=issues_info)] - - except Exception as e: - return [TextContent(type="text", text=f"Error fetching issues: {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 index 81c50b4..9d1d971 100644 --- a/src/aidd/tools/test_github_tools.py +++ b/src/aidd/tools/test_github_tools.py @@ -2,7 +2,7 @@ from unittest.mock import patch, MagicMock from typing import Dict, Any import mcp.types as types -from .github_tools import handle_get_issue, handle_create_pull_request_review, handle_get_pull_request_files, handle_list_issues, handle_list_pull_requests +from .github_tools import handle_create_pull_request_review, handle_get_pull_request_files, handle_list_pull_requests @pytest.mark.asyncio @@ -63,111 +63,6 @@ async def test_handle_create_pull_request_review_exception(): assert isinstance(result, list) assert "Exception occurred" in result[0].text -@pytest.mark.asyncio -async def test_handle_get_issue_missing_params(): - """Test that missing parameters return an error message""" - result = await handle_get_issue({}) - 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_issue_success(): - """Test successful issue retrieval""" - mock_response = MagicMock() - mock_response.status = 200 - mock_response.read.return_value = b'''{ - "number": 1, - "title": "Test Issue", - "state": "open", - "user": {"login": "testuser"}, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z", - "html_url": "https://github.com/test/repo/issues/1", - "body": "Test issue body", - "labels": [{"name": "bug"}], - "assignees": [{"login": "assignee1"}] - }''' - - with patch('http.client.HTTPSConnection') as mock_conn: - mock_conn.return_value.getresponse.return_value = mock_response - result = await handle_get_issue({ - "owner": "test", - "repo": "repo", - "issue_number": 1 - }) - - assert isinstance(result, list) - assert len(result) == 1 - assert result[0].type == "text" - assert "Test Issue" in result[0].text - assert "testuser" in result[0].text - assert "bug" in result[0].text - assert "assignee1" in result[0].text - assert "Test issue body" in result[0].text - - -@pytest.mark.asyncio -async def test_handle_get_issue_not_found(): - """Test handling of non-existent issue""" - 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_issue({ - "owner": "test", - "repo": "repo", - "issue_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_issue_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_issue({ - "owner": "test", - "repo": "repo", - "issue_number": 1 - }) - - assert isinstance(result, list) - assert len(result) == 1 - assert result[0].type == "text" - assert "Error fetching issue" in result[0].text - assert "500" in result[0].text - - -@pytest.mark.asyncio -async def test_handle_get_issue_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_issue({ - "owner": "test", - "repo": "repo", - "issue_number": 1 - }) - - assert isinstance(result, list) - assert len(result) == 1 - assert result[0].type == "text" - assert "Error fetching issue" 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""" @@ -313,155 +208,6 @@ async def test_handle_get_pull_request_files_exception(): assert result[0].type == "text" assert "Error fetching pull request files" in result[0].text - -@pytest.mark.asyncio -async def test_handle_list_issues_missing_params(): - """Test that missing parameters return an error message""" - result = await handle_list_issues({}) - 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_issues_success(): - """Test successful issues listing""" - mock_response = MagicMock() - mock_response.status = 200 - mock_response.read.return_value = b'''[ - { - "number": 1, - "title": "First Issue", - "state": "open", - "user": {"login": "testuser"}, - "created_at": "2024-01-01T00:00:00Z", - "html_url": "https://github.com/test/repo/issues/1", - "labels": [{"name": "bug"}, {"name": "high-priority"}] - }, - { - "number": 2, - "title": "Second Issue", - "state": "open", - "user": {"login": "otheruser"}, - "created_at": "2024-01-02T00:00:00Z", - "html_url": "https://github.com/test/repo/issues/2", - "labels": [] - }, - { - "number": 3, - "title": "Pull Request", - "state": "open", - "user": {"login": "contributor"}, - "created_at": "2024-01-03T00:00:00Z", - "html_url": "https://github.com/test/repo/pull/3", - "pull_request": {"url": "https://api.github.com/repos/test/repo/pulls/3"} - } - ]''' - - with patch('http.client.HTTPSConnection') as mock_conn: - mock_conn.return_value.getresponse.return_value = mock_response - result = await handle_list_issues({ - "owner": "test", - "repo": "repo", - "state": "open" - }) - - assert isinstance(result, list) - assert len(result) == 1 - assert result[0].type == "text" - assert "Issues in test/repo (open)" in result[0].text - assert "First Issue" in result[0].text - assert "Second Issue" in result[0].text - assert "Total issues found: 2" in result[0].text # Should exclude PR - assert "bug, high-priority" in result[0].text - assert "testuser" in result[0].text - assert "otheruser" in result[0].text - # PR should be excluded - assert "Pull Request" not in result[0].text - assert "contributor" not in result[0].text - - -@pytest.mark.asyncio -async def test_handle_list_issues_empty(): - """Test handling of repo with no issues""" - 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_issues({ - "owner": "test", - "repo": "repo", - "state": "open" - }) - - assert isinstance(result, list) - assert len(result) == 1 - assert result[0].type == "text" - assert "No open issues found in repository test/repo" in result[0].text - - -@pytest.mark.asyncio -async def test_handle_list_issues_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_issues({ - "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_issues_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_issues({ - "owner": "test", - "repo": "repo", - "state": "open" - }) - - assert isinstance(result, list) - assert len(result) == 1 - assert result[0].type == "text" - assert "Error fetching issues" in result[0].text - assert "500" in result[0].text - - -@pytest.mark.asyncio -async def test_handle_list_issues_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_issues({ - "owner": "test", - "repo": "repo", - "state": "open" - }) - - assert isinstance(result, list) - assert len(result) == 1 - assert result[0].type == "text" - assert "Error fetching issues" in result[0].text - - @pytest.mark.asyncio async def test_handle_list_pull_requests_missing_params(): """Test that missing parameters return an error message""" diff --git a/src/aidd/tools/test_github_tools_integration.py b/src/aidd/tools/test_github_tools_integration.py index c82988a..344a0d6 100644 --- a/src/aidd/tools/test_github_tools_integration.py +++ b/src/aidd/tools/test_github_tools_integration.py @@ -2,7 +2,7 @@ import os from typing import Dict, Any from mcp.types import TextContent -from .github_tools import handle_get_issue, handle_create_pull_request_review, handle_get_pull_request_files, handle_list_issues, handle_list_pull_requests +from .github_tools import handle_create_pull_request_review, handle_get_pull_request_files, handle_list_pull_requests @pytest.mark.asyncio @@ -22,57 +22,6 @@ async def test_handle_create_pull_request_review_real_api(): assert isinstance(result, list) assert any("review" in r.text.lower() for r in result) -@pytest.mark.asyncio -async def test_handle_get_issue_real_api(): - """Test the get_issue tool against the real GitHub API""" - # Test with a well-known public repository and issue - result = await handle_get_issue({ - "owner": "python", - "repo": "cpython", - "issue_number": 1 - }) - - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].type == "text" - assert "python" in result[0].text.lower() - assert "cpython" in result[0].text.lower() - - -@pytest.mark.asyncio -async def test_handle_get_issue_not_found_real_api(): - """Test handling of non-existent issue against the real GitHub API""" - result = await handle_get_issue({ - "owner": "python", - "repo": "cpython", - "issue_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_issue_private_repo(): - """Test handling of private repository access""" - result = await handle_get_issue({ - "owner": "github", - "repo": "github", # This is a private repository - "issue_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_get_pull_request_files_real_api(): """Test the get_pull_request_files tool against the real GitHub API""" @@ -127,103 +76,6 @@ async def test_handle_get_pull_request_files_private_repo(): assert "error" in result[0].text.lower() or "not found" in result[0].text.lower() -@pytest.mark.asyncio -async def test_handle_list_issues_real_api(): - """Test the list_issues tool against the real GitHub API""" - # Test with a well-known public repository - result = await handle_list_issues({ - "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 "Issues in python/cpython (open)" in result[0].text - # Check for table headers - assert "| Number | Title | State | Creator | Labels | Created At |" in result[0].text - # Check for summary - assert "Total issues found:" in result[0].text - - -@pytest.mark.asyncio -async def test_handle_list_issues_closed_real_api(): - """Test the list_issues tool with closed issues against the real GitHub API""" - result = await handle_list_issues({ - "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 "Issues in python/cpython (closed)" in result[0].text - # Check for table headers - assert "| Number | Title | State | Creator | Labels | Created At |" in result[0].text - # Check for summary - assert "Total issues found:" in result[0].text - - -@pytest.mark.asyncio -async def test_handle_list_issues_filtered_real_api(): - """Test the list_issues tool with filters against the real GitHub API""" - result = await handle_list_issues({ - "owner": "python", - "repo": "cpython", - "state": "open", - "labels": "documentation", - "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 "Issues in python/cpython (open)" in result[0].text - - # We can't assert that "documentation" is in every issue since the API might change, - # but we can check if the response is well-formed - - -@pytest.mark.asyncio -async def test_handle_list_issues_not_found_real_api(): - """Test handling of non-existent repository against the real GitHub API""" - result = await handle_list_issues({ - "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_issues_private_repo(): - """Test handling of private repository access""" - result = await handle_list_issues({ - "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() - - @pytest.mark.asyncio async def test_handle_list_pull_requests_real_api(): """Test the list_pull_requests tool against the real GitHub API""" From a3ee04f4f50a1c19dc2e5147cecca37688603b6a Mon Sep 17 00:00:00 2001 From: Duong Quang Hung Date: Tue, 6 May 2025 14:23:43 +0700 Subject: [PATCH 13/13] Update README.md --- README.md | 201 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 169 insertions(+), 32 deletions(-) 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