diff --git a/Containerfile b/Containerfile index c531549..5b9255e 100644 --- a/Containerfile +++ b/Containerfile @@ -17,7 +17,7 @@ FROM quay.io/hummingbird/python:latest # Labels for container metadata LABEL name="mcp-github-crunchtools" \ - version="0.2.0" \ + version="0.3.0" \ summary="Secure MCP server for GitHub issues, pull requests, files, and search" \ description="A security-focused MCP server for GitHub built on Red Hat UBI" \ maintainer="crunchtools.com" \ diff --git a/pyproject.toml b/pyproject.toml index 8a8da2e..cc4295c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-github-crunchtools" -version = "0.2.0" +version = "0.3.0" description = "Secure MCP server for GitHub issues, pull requests, files, and search" requires-python = ">=3.10" license = "AGPL-3.0-or-later" diff --git a/src/mcp_github_crunchtools/client.py b/src/mcp_github_crunchtools/client.py index b8ea7b1..f954c72 100644 --- a/src/mcp_github_crunchtools/client.py +++ b/src/mcp_github_crunchtools/client.py @@ -242,6 +242,14 @@ async def put( """Make a PUT request.""" return await self._request("PUT", path, json_data=json_data) + async def patch( + self, + path: str, + json_data: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Make a PATCH request.""" + return await self._request("PATCH", path, json_data=json_data) + async def delete(self, path: str) -> dict[str, Any]: """Make a DELETE request.""" return await self._request("DELETE", path) diff --git a/src/mcp_github_crunchtools/server.py b/src/mcp_github_crunchtools/server.py index 918b347..d36f684 100644 --- a/src/mcp_github_crunchtools/server.py +++ b/src/mcp_github_crunchtools/server.py @@ -24,6 +24,8 @@ rerun_workflow_run, search_code, search_issues, + update_issue, + update_pull_request, ) logger = logging.getLogger(__name__) @@ -138,6 +140,47 @@ async def create_issue_comment_tool( ) +@mcp.tool() +async def update_issue_tool( + repo: str, + issue_number: int, + state: str | None = None, + state_reason: str | None = None, + title: str | None = None, + body: str | None = None, + labels: list[str] | None = None, + owner: str | None = None, +) -> dict[str, Any]: + """Update a GitHub issue, including closing or reopening it. + + Set state="closed" to close an issue. Set state="open" with + state_reason="reopened" to reopen. + + Args: + repo: Repository name + issue_number: Issue number + state: "open" or "closed" + state_reason: "completed", "not_planned", or "reopened" + title: New title (optional) + body: New body (optional) + labels: Replacement list of label names (optional) + owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset) + + Returns: + Updated issue details (number, state, html_url, title) + """ + return await update_issue( + owner=owner, + repo=repo, + issue_number=issue_number, + state=state, + state_reason=state_reason, + title=title, + body=body, + labels=labels, + ) + + @mcp.tool() async def list_pull_requests_tool( repo: str, @@ -235,6 +278,40 @@ async def get_pull_request_checks_tool( ) +@mcp.tool() +async def update_pull_request_tool( + repo: str, + pull_number: int, + state: str | None = None, + title: str | None = None, + body: str | None = None, + owner: str | None = None, +) -> dict[str, Any]: + """Update a GitHub pull request, including closing or reopening it. + + This does NOT merge. Set state="closed" to close a PR without merging. + + Args: + repo: Repository name + pull_number: Pull request number + state: "open" or "closed" + title: New title (optional) + body: New body (optional) + owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset) + + Returns: + Updated PR details (number, state, html_url, title) + """ + return await update_pull_request( + owner=owner, + repo=repo, + pull_number=pull_number, + state=state, + title=title, + body=body, + ) + + @mcp.tool() async def get_file_content_tool( repo: str, diff --git a/src/mcp_github_crunchtools/tools/__init__.py b/src/mcp_github_crunchtools/tools/__init__.py index 4234757..22c451e 100644 --- a/src/mcp_github_crunchtools/tools/__init__.py +++ b/src/mcp_github_crunchtools/tools/__init__.py @@ -9,12 +9,19 @@ rerun_workflow_run, ) from .files import get_file_content, list_repo_tree -from .issues import create_issue, create_issue_comment, get_issue, list_issues +from .issues import ( + create_issue, + create_issue_comment, + get_issue, + list_issues, + update_issue, +) from .pull_requests import ( get_pull_request, get_pull_request_checks, get_pull_request_diff, list_pull_requests, + update_pull_request, ) from .search import search_code, search_issues @@ -23,10 +30,12 @@ "get_issue", "create_issue", "create_issue_comment", + "update_issue", "list_pull_requests", "get_pull_request", "get_pull_request_diff", "get_pull_request_checks", + "update_pull_request", "get_file_content", "list_repo_tree", "search_code", diff --git a/src/mcp_github_crunchtools/tools/issues.py b/src/mcp_github_crunchtools/tools/issues.py index 1b641e1..46c610a 100644 --- a/src/mcp_github_crunchtools/tools/issues.py +++ b/src/mcp_github_crunchtools/tools/issues.py @@ -132,6 +132,69 @@ async def create_issue( } +async def update_issue( + owner: str | None, + repo: str, + issue_number: int, + state: str | None = None, + state_reason: str | None = None, + title: str | None = None, + body: str | None = None, + labels: list[str] | None = None, +) -> dict[str, Any]: + """Update an existing issue — including closing or reopening it. + + Args: + owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset) + repo: Repository name + issue_number: Issue number + state: "open" or "closed" (set "closed" to close the issue) + state_reason: When closing, one of "completed" or "not_planned"; + when reopening, "reopened" + title: New title (optional) + body: New body (optional) + labels: Replacement list of label names (optional) + + Returns: + Updated issue details (number, state, html_url, title) + """ + owner = resolve_owner(owner) + repo = validate_name(repo, "repo") + issue_number = validate_positive_int(issue_number, "issue_number") + + json_data: dict[str, Any] = {} + if state is not None: + if state not in ("open", "closed"): + raise ValidationError("state must be 'open' or 'closed'") + json_data["state"] = state + if state_reason is not None: + if state_reason not in ("completed", "not_planned", "reopened"): + raise ValidationError( + "state_reason must be 'completed', 'not_planned', or 'reopened'" + ) + json_data["state_reason"] = state_reason + if title is not None: + json_data["title"] = title + if body is not None: + json_data["body"] = body + if labels is not None: + json_data["labels"] = labels + if not json_data: + raise ValidationError("no fields to update") + + client = get_client() + result = await client.patch( + f"/repos/{owner}/{repo}/issues/{issue_number}", + json_data=json_data, + ) + return { + "number": result.get("number"), + "state": result.get("state"), + "html_url": result.get("html_url"), + "title": result.get("title"), + } + + async def create_issue_comment( owner: str | None, repo: str, diff --git a/src/mcp_github_crunchtools/tools/pull_requests.py b/src/mcp_github_crunchtools/tools/pull_requests.py index f9c5199..314c4c5 100644 --- a/src/mcp_github_crunchtools/tools/pull_requests.py +++ b/src/mcp_github_crunchtools/tools/pull_requests.py @@ -233,3 +233,55 @@ async def get_pull_request_checks( "skipped": skipped, "passed": passed, } + + +async def update_pull_request( + owner: str | None, + repo: str, + pull_number: int, + state: str | None = None, + title: str | None = None, + body: str | None = None, +) -> dict[str, Any]: + """Update a pull request — including closing or reopening it. + + Note: this does NOT merge. Set state="closed" to close a PR without merging. + + Args: + owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset) + repo: Repository name + pull_number: Pull request number + state: "open" or "closed" (set "closed" to close the PR) + title: New title (optional) + body: New body (optional) + + Returns: + Updated PR details (number, state, html_url, title) + """ + owner = resolve_owner(owner) + repo = validate_name(repo, "repo") + pull_number = validate_positive_int(pull_number, "pull_number") + + json_data: dict[str, Any] = {} + if state is not None: + if state not in ("open", "closed"): + raise ValidationError("state must be 'open' or 'closed'") + json_data["state"] = state + if title is not None: + json_data["title"] = title + if body is not None: + json_data["body"] = body + if not json_data: + raise ValidationError("no fields to update") + + client = get_client() + result = await client.patch( + f"/repos/{owner}/{repo}/pulls/{pull_number}", + json_data=json_data, + ) + return { + "number": result.get("number"), + "state": result.get("state"), + "html_url": result.get("html_url"), + "title": result.get("title"), + } diff --git a/tests/test_tools.py b/tests/test_tools.py index 3251881..5a0ba2e 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -30,10 +30,10 @@ def test_imports(self) -> None: assert callable(func), f"{name} is not callable" def test_tool_count(self) -> None: - """Server should export exactly 15 tools.""" + """Server should export exactly 17 tools.""" from mcp_github_crunchtools.tools import __all__ - assert len(__all__) == 15 + assert len(__all__) == 17 class TestErrorSafety: @@ -314,6 +314,57 @@ async def test_create_issue(self) -> None: "title": "Bug found", } + async def test_update_issue_close(self) -> None: + from mcp_github_crunchtools.tools import update_issue + + resp = _mock_response( + json_data={ + "number": 4, + "state": "closed", + "html_url": "https://github.com/o/r/issues/4", + "title": "Fixed", + }, + ) + + with _patch_client(resp) as mock_client: + result = await update_issue( + owner="o", + repo="r", + issue_number=4, + state="closed", + state_reason="completed", + ) + call = mock_client.return_value.request.call_args + assert call.kwargs["method"] == "PATCH" + assert call.kwargs["json"]["state"] == "closed" + assert call.kwargs["json"]["state_reason"] == "completed" + + assert result["state"] == "closed" + assert result["number"] == 4 + + async def test_update_pull_request_close(self) -> None: + from mcp_github_crunchtools.tools import update_pull_request + + resp = _mock_response( + json_data={ + "number": 3, + "state": "closed", + "html_url": "https://github.com/o/r/pull/3", + "title": "WIP", + }, + ) + + with _patch_client(resp) as mock_client: + result = await update_pull_request( + owner="o", repo="r", pull_number=3, state="closed" + ) + call = mock_client.return_value.request.call_args + assert call.kwargs["method"] == "PATCH" + assert "/pulls/3" in call.kwargs["url"] + assert call.kwargs["json"]["state"] == "closed" + + assert result["state"] == "closed" + @pytest.mark.asyncio async def test_create_issue_empty_title(self) -> None: from mcp_github_crunchtools.errors import ValidationError