diff --git a/Containerfile b/Containerfile index ae45436..c531549 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.1.0" \ + version="0.2.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 5219146..8a8da2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-github-crunchtools" -version = "0.1.0" +version = "0.2.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/models.py b/src/mcp_github_crunchtools/models.py index bc4cc44..14ae0bb 100644 --- a/src/mcp_github_crunchtools/models.py +++ b/src/mcp_github_crunchtools/models.py @@ -20,6 +20,8 @@ MAX_COMMENT_LENGTH = 65536 MAX_QUERY_LENGTH = 1000 MAX_PER_PAGE = 100 +MAX_TITLE_LENGTH = 256 +MAX_BODY_LENGTH = 65536 def resolve_owner(owner: str | None) -> str: @@ -111,3 +113,21 @@ class CreateIssueCommentInput(BaseModel): max_length=MAX_COMMENT_LENGTH, description="Comment body (Markdown)", ) + + +class CreateIssueInput(BaseModel): + """Validated input for creating an issue.""" + + model_config = ConfigDict(extra="forbid") + + title: str = Field( + ..., + min_length=1, + max_length=MAX_TITLE_LENGTH, + description="Issue title", + ) + body: str = Field( + default="", + max_length=MAX_BODY_LENGTH, + description="Issue body (Markdown)", + ) diff --git a/src/mcp_github_crunchtools/server.py b/src/mcp_github_crunchtools/server.py index 70c86f2..918b347 100644 --- a/src/mcp_github_crunchtools/server.py +++ b/src/mcp_github_crunchtools/server.py @@ -9,6 +9,7 @@ from fastmcp import FastMCP from .tools import ( + create_issue, create_issue_comment, get_file_content, get_issue, @@ -18,6 +19,9 @@ list_issues, list_pull_requests, list_repo_tree, + list_workflow_runs, + rerun_failed_jobs, + rerun_workflow_run, search_code, search_issues, ) @@ -26,7 +30,7 @@ mcp = FastMCP( name="mcp-github", - version="0.1.0", + version="0.2.0", instructions=( "Secure MCP server for GitHub repositories: issues, pull requests " "(diffs and CI checks), repository files, and code/issue search. " @@ -86,6 +90,31 @@ async def get_issue_tool( return await get_issue(owner=owner, repo=repo, issue_number=issue_number) +@mcp.tool() +async def create_issue_tool( + repo: str, + title: str, + body: str = "", + labels: list[str] | None = None, + owner: str | None = None, +) -> dict[str, Any]: + """Create a new issue in a GitHub repository. + + Args: + repo: Repository name + title: Issue title (required, non-empty) + body: Issue body (Markdown) + labels: Optional list of label names to apply + owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset) + + Returns: + Created issue details (number, html_url, title) + """ + return await create_issue( + owner=owner, repo=repo, title=title, body=body, labels=labels + ) + + @mcp.tool() async def create_issue_comment_tool( repo: str, @@ -184,10 +213,13 @@ async def get_pull_request_checks_tool( pull_number: int, owner: str | None = None, ) -> dict[str, Any]: - """Get a combined CI status summary for a GitHub pull request. + """Get a CI verdict for a PR that distinguishes skipped from failed. - Combines check-runs and the legacy commit status into one summary, - including the overall state and per-check conclusions. + Classifies every check-run and commit-status context into passed, + failing, pending, or skipped. SKIPPED checks are NOT failures. Use the + returned ``ready_to_merge`` boolean as the signal for whether the PR is + clear to merge (True only when nothing is failing or pending and the PR + is not known-unmergeable). Args: repo: Repository name @@ -195,7 +227,8 @@ async def get_pull_request_checks_tool( owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset) Returns: - Summary with head SHA, overall state, and per-check conclusions + A verdict with head SHA, mergeability, ready_to_merge, a summary + count, and per-bucket check lists """ return await get_pull_request_checks( owner=owner, repo=repo, pull_number=pull_number @@ -286,3 +319,74 @@ async def search_issues_tool( Search results with total count and matched issues/PRs """ return await search_issues(query=query, per_page=per_page, page=page) + + +@mcp.tool() +async def list_workflow_runs_tool( + repo: str, + owner: str | None = None, + branch: str | None = None, + status: str | None = None, + per_page: int = 20, + page: int = 1, +) -> dict[str, Any]: + """List GitHub Actions workflow runs for a repository. + + Args: + repo: Repository name + owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset) + branch: Filter by head branch name + status: Filter by status or conclusion (e.g., "completed", + "in_progress", "queued", "failure", "success") + per_page: Results per page, max 100 (default: 20) + page: Page number (default: 1) + + Returns: + Trimmed list of workflow runs with total count + """ + return await list_workflow_runs( + owner=owner, + repo=repo, + branch=branch, + status=status, + per_page=per_page, + page=page, + ) + + +@mcp.tool() +async def rerun_workflow_run_tool( + repo: str, + run_id: int, + owner: str | None = None, +) -> dict[str, Any]: + """Re-run all jobs in a GitHub Actions workflow run. + + Args: + repo: Repository name + run_id: Workflow run ID + owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset) + + Returns: + A confirmation dict: {"status": "rerun_requested", "run_id": run_id} + """ + return await rerun_workflow_run(owner=owner, repo=repo, run_id=run_id) + + +@mcp.tool() +async def rerun_failed_jobs_tool( + repo: str, + run_id: int, + owner: str | None = None, +) -> dict[str, Any]: + """Re-run only the failed jobs in a GitHub Actions workflow run. + + Args: + repo: Repository name + run_id: Workflow run ID + owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset) + + Returns: + A confirmation dict: {"status": "rerun_requested", "run_id": run_id} + """ + return await rerun_failed_jobs(owner=owner, repo=repo, run_id=run_id) diff --git a/src/mcp_github_crunchtools/tools/__init__.py b/src/mcp_github_crunchtools/tools/__init__.py index cdf0917..4234757 100644 --- a/src/mcp_github_crunchtools/tools/__init__.py +++ b/src/mcp_github_crunchtools/tools/__init__.py @@ -3,8 +3,13 @@ This package contains all the MCP tool implementations for GitHub operations. """ +from .actions import ( + list_workflow_runs, + rerun_failed_jobs, + rerun_workflow_run, +) from .files import get_file_content, list_repo_tree -from .issues import create_issue_comment, get_issue, list_issues +from .issues import create_issue, create_issue_comment, get_issue, list_issues from .pull_requests import ( get_pull_request, get_pull_request_checks, @@ -16,6 +21,7 @@ __all__ = [ "list_issues", "get_issue", + "create_issue", "create_issue_comment", "list_pull_requests", "get_pull_request", @@ -25,4 +31,7 @@ "list_repo_tree", "search_code", "search_issues", + "list_workflow_runs", + "rerun_workflow_run", + "rerun_failed_jobs", ] diff --git a/src/mcp_github_crunchtools/tools/actions.py b/src/mcp_github_crunchtools/tools/actions.py new file mode 100644 index 0000000..b873fc3 --- /dev/null +++ b/src/mcp_github_crunchtools/tools/actions.py @@ -0,0 +1,124 @@ +"""GitHub Actions tools. + +Tools for listing workflow runs and re-running CI on GitHub Actions. +""" + +from typing import Any + +from ..client import get_client +from ..models import ( + clamp_per_page, + resolve_owner, + validate_name, + validate_positive_int, +) + + +async def list_workflow_runs( + owner: str | None, + repo: str, + branch: str | None = None, + status: str | None = None, + per_page: int = 20, + page: int = 1, +) -> dict[str, Any]: + """List GitHub Actions workflow runs for a repository. + + Args: + owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset) + repo: Repository name + branch: Filter by head branch name + status: Filter by status or conclusion (e.g., "completed", + "in_progress", "queued", "failure", "success") + per_page: Results per page, max 100 (default: 20) + page: Page number (default: 1) + + Returns: + Trimmed list of workflow runs with pagination info + """ + owner = resolve_owner(owner) + repo = validate_name(repo, "repo") + + params: dict[str, Any] = { + "per_page": clamp_per_page(per_page), + "page": validate_positive_int(page, "page"), + } + if branch: + params["branch"] = branch + if status: + params["status"] = status + + client = get_client() + result = await client.get( + f"/repos/{owner}/{repo}/actions/runs", params=params + ) + + runs = result.get("workflow_runs", []) + items = [ + { + "id": run.get("id"), + "name": run.get("name"), + "head_branch": run.get("head_branch"), + "event": run.get("event"), + "status": run.get("status"), + "conclusion": run.get("conclusion"), + "html_url": run.get("html_url"), + "created_at": run.get("created_at"), + } + for run in runs + ] + + return { + "total_count": result.get("total_count", len(items)), + "items": items, + } + + +async def rerun_workflow_run( + owner: str | None, + repo: str, + run_id: int, +) -> dict[str, Any]: + """Re-run all jobs in a GitHub Actions workflow run. + + Args: + owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset) + repo: Repository name + run_id: Workflow run ID + + Returns: + A confirmation dict: {"status": "rerun_requested", "run_id": run_id} + """ + owner = resolve_owner(owner) + repo = validate_name(repo, "repo") + run_id = validate_positive_int(run_id, "run_id") + + client = get_client() + await client.post(f"/repos/{owner}/{repo}/actions/runs/{run_id}/rerun") + return {"status": "rerun_requested", "run_id": run_id} + + +async def rerun_failed_jobs( + owner: str | None, + repo: str, + run_id: int, +) -> dict[str, Any]: + """Re-run only the failed jobs in a GitHub Actions workflow run. + + Args: + owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset) + repo: Repository name + run_id: Workflow run ID + + Returns: + A confirmation dict: {"status": "rerun_requested", "run_id": run_id} + """ + owner = resolve_owner(owner) + repo = validate_name(repo, "repo") + run_id = validate_positive_int(run_id, "run_id") + + client = get_client() + await client.post( + f"/repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs" + ) + return {"status": "rerun_requested", "run_id": run_id} diff --git a/src/mcp_github_crunchtools/tools/issues.py b/src/mcp_github_crunchtools/tools/issues.py index 7832fa9..1b641e1 100644 --- a/src/mcp_github_crunchtools/tools/issues.py +++ b/src/mcp_github_crunchtools/tools/issues.py @@ -10,6 +10,7 @@ from ..models import ( ISSUE_STATES, CreateIssueCommentInput, + CreateIssueInput, clamp_per_page, resolve_owner, validate_name, @@ -87,6 +88,50 @@ async def get_issue( return await client.get(f"/repos/{owner}/{repo}/issues/{issue_number}") +async def create_issue( + owner: str | None, + repo: str, + title: str, + body: str = "", + labels: list[str] | None = None, +) -> dict[str, Any]: + """Create a new issue in a repository. + + Args: + owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset) + repo: Repository name + title: Issue title (required, non-empty) + body: Issue body (Markdown) + labels: Optional list of label names to apply + + Returns: + Created issue details (number, html_url, title) + """ + owner = resolve_owner(owner) + repo = validate_name(repo, "repo") + if not title or not title.strip(): + raise ValidationError("title must not be empty") + validated = CreateIssueInput(title=title.strip(), body=body) + + json_data: dict[str, Any] = { + "title": validated.title, + "body": validated.body, + } + if labels: + json_data["labels"] = labels + + client = get_client() + result = await client.post( + f"/repos/{owner}/{repo}/issues", + json_data=json_data, + ) + return { + "number": result.get("number"), + "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 0fa9c93..f9c5199 100644 --- a/src/mcp_github_crunchtools/tools/pull_requests.py +++ b/src/mcp_github_crunchtools/tools/pull_requests.py @@ -103,15 +103,79 @@ async def get_pull_request_diff( ) +def _classify_checks( + check_runs: dict[str, Any], combined_status: dict[str, Any] +) -> dict[str, list[dict[str, Any]]]: + """Sort check-runs and commit-status contexts into verdict buckets. + + Check-run conclusions failure/cancelled/timed_out/action_required are + failures; skipped/neutral are skips (NOT failures); success passes. An + incomplete run, or any other completed conclusion, is pending. Commit + statuses map failure/error -> failing, pending -> pending, success -> + passed. + """ + buckets: dict[str, list[dict[str, Any]]] = { + "failing": [], + "pending": [], + "skipped": [], + "passed": [], + } + + for run in check_runs.get("check_runs", []): + name = run.get("name") + status = run.get("status") + if status != "completed": + buckets["pending"].append({"name": name, "status": status}) + continue + match run.get("conclusion"): + case ( + "failure" | "cancelled" | "timed_out" | "action_required" + ) as conclusion: + url = run.get("html_url") or run.get("details_url") or "" + buckets["failing"].append( + {"name": name, "conclusion": conclusion, "url": url} + ) + case ("skipped" | "neutral") as conclusion: + buckets["skipped"].append({"name": name, "conclusion": conclusion}) + case "success": + buckets["passed"].append({"name": name}) + case _: + buckets["pending"].append({"name": name, "status": status}) + + for ctx in combined_status.get("statuses", []): + name = ctx.get("context") + match ctx.get("state"): + case ("failure" | "error") as state: + url = ctx.get("target_url") or "" + buckets["failing"].append( + {"name": name, "conclusion": state, "url": url} + ) + case "pending": + buckets["pending"].append({"name": name, "status": "pending"}) + case "success": + buckets["passed"].append({"name": name}) + + return buckets + + async def get_pull_request_checks( owner: str | None, repo: str, pull_number: int, ) -> dict[str, Any]: - """Get a combined CI status summary for a pull request. + """Get a CI verdict for a pull request that distinguishes skip from fail. + + Resolves the PR head SHA, then classifies every check-run (GitHub Checks + API) and legacy commit-status context into exactly one bucket: passed, + failing, pending, or skipped. - Resolves the PR head SHA, then aggregates check-runs (GitHub Checks API) - and the legacy combined commit status into a single summary. + IMPORTANT: SKIPPED checks (conclusion "skipped" or "neutral") are NOT + failures and do not block merging. The legacy combined commit status can + report an overall state of "failure" when checks are merely skipped, which + is why this tool classifies each check explicitly instead of trusting that + aggregate. Use the ``ready_to_merge`` boolean as the signal for whether the + PR is clear to merge: it is True only when there are no failing and no + pending checks and the PR is not known-unmergeable. Args: owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset) @@ -119,7 +183,8 @@ async def get_pull_request_checks( pull_number: Pull request number Returns: - Summary with the head SHA, overall state, and per-check conclusions + A verdict with the head SHA, mergeability, per-bucket check lists, a + summary count, and the ``ready_to_merge`` signal """ owner = resolve_owner(owner) repo = validate_name(repo, "repo") @@ -140,29 +205,31 @@ async def get_pull_request_checks( f"/repos/{owner}/{repo}/commits/{sha}/status" ) - runs = check_runs.get("check_runs", []) - checks = [ - { - "name": run.get("name"), - "status": run.get("status"), - "conclusion": run.get("conclusion"), - } - for run in runs - ] - - statuses = [ - { - "context": status.get("context"), - "state": status.get("state"), - "description": status.get("description"), - } - for status in combined_status.get("statuses", []) - ] + buckets = _classify_checks(check_runs, combined_status) + failing = buckets["failing"] + pending = buckets["pending"] + skipped = buckets["skipped"] + passed = buckets["passed"] + + mergeable = pr.get("mergeable") + ready_to_merge = ( + not failing and not pending and mergeable is not False + ) return { - "sha": sha, - "overall_state": combined_status.get("state"), - "total_check_runs": check_runs.get("total_count", len(runs)), - "check_runs": checks, - "statuses": statuses, + "pull_number": pull_number, + "head_sha": sha, + "mergeable": mergeable, + "mergeable_state": pr.get("mergeable_state", ""), + "ready_to_merge": ready_to_merge, + "summary": { + "passed": len(passed), + "failing": len(failing), + "pending": len(pending), + "skipped": len(skipped), + }, + "failing": failing, + "pending": pending, + "skipped": skipped, + "passed": passed, } diff --git a/tests/test_tools.py b/tests/test_tools.py index fec8f44..3251881 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 11 MVP tools.""" + """Server should export exactly 15 tools.""" from mcp_github_crunchtools.tools import __all__ - assert len(__all__) == 11 + assert len(__all__) == 15 class TestErrorSafety: @@ -282,6 +282,47 @@ async def test_create_issue_comment(self) -> None: assert result["body"] == "Thanks!" + @pytest.mark.asyncio + async def test_create_issue(self) -> None: + from mcp_github_crunchtools.tools import create_issue + + resp = _mock_response( + status_code=201, + json_data={ + "number": 7, + "html_url": "https://github.com/o/r/issues/7", + "title": "Bug found", + "body": "details", + }, + ) + + with _patch_client(resp) as mock_client: + result = await create_issue( + owner="o", + repo="r", + title="Bug found", + body="details", + labels=["bug"], + ) + call = mock_client.return_value.request.call_args + assert call.kwargs["json"]["title"] == "Bug found" + assert call.kwargs["json"]["labels"] == ["bug"] + + assert result == { + "number": 7, + "html_url": "https://github.com/o/r/issues/7", + "title": "Bug found", + } + + @pytest.mark.asyncio + async def test_create_issue_empty_title(self) -> None: + from mcp_github_crunchtools.errors import ValidationError + from mcp_github_crunchtools.tools import create_issue + + resp = _mock_response(json_data={}) + with _patch_client(resp), pytest.raises(ValidationError): + await create_issue(owner="o", repo="r", title=" ") + @pytest.mark.asyncio async def test_invalid_state(self) -> None: from mcp_github_crunchtools.errors import ValidationError @@ -337,25 +378,81 @@ async def test_get_pull_request_diff(self) -> None: assert "diff --git" in result["content"] @pytest.mark.asyncio - async def test_get_pull_request_checks(self) -> None: - """Checks should combine PR head, check-runs, and commit status.""" + async def test_checks_skipped_not_treated_as_failure(self) -> None: + """A passed + skipped check should yield ready_to_merge=True.""" + from mcp_github_crunchtools.tools import get_pull_request_checks + + pr_resp = _mock_response( + json_data={ + "number": 5, + "head": {"sha": "deadbeef"}, + "mergeable": True, + "mergeable_state": "clean", + }, + ) + runs_resp = _mock_response( + json_data={ + "total_count": 2, + "check_runs": [ + {"name": "build", "status": "completed", "conclusion": "success"}, + {"name": "lint", "status": "completed", "conclusion": "skipped"}, + ], + }, + ) + status_resp = _mock_response( + json_data={"state": "failure", "statuses": []}, + ) + + with _patch_client_sequence(pr_resp, runs_resp, status_resp): + result = await get_pull_request_checks( + owner="o", repo="r", pull_number=5 + ) + + assert result["head_sha"] == "deadbeef" + assert result["mergeable"] is True + assert result["mergeable_state"] == "clean" + assert result["ready_to_merge"] is True + assert result["summary"] == { + "passed": 1, + "failing": 0, + "pending": 0, + "skipped": 1, + } + assert result["passed"] == [{"name": "build"}] + assert result["skipped"][0]["name"] == "lint" + + @pytest.mark.asyncio + async def test_checks_failing_blocks_merge(self) -> None: + """A failing check should yield ready_to_merge=False.""" from mcp_github_crunchtools.tools import get_pull_request_checks - pr_resp = _mock_response(json_data={"number": 5, "head": {"sha": "deadbeef"}}) + pr_resp = _mock_response( + json_data={ + "number": 5, + "head": {"sha": "deadbeef"}, + "mergeable": True, + "mergeable_state": "blocked", + }, + ) runs_resp = _mock_response( json_data={ "total_count": 2, "check_runs": [ {"name": "build", "status": "completed", "conclusion": "success"}, - {"name": "test", "status": "completed", "conclusion": "failure"}, + { + "name": "test", + "status": "completed", + "conclusion": "failure", + "html_url": "https://gh/run/1", + }, ], }, ) status_resp = _mock_response( json_data={ - "state": "failure", + "state": "success", "statuses": [ - {"context": "ci/legacy", "state": "success", "description": "ok"}, + {"context": "ci/legacy", "state": "error", "target_url": "x"}, ], }, ) @@ -365,12 +462,78 @@ async def test_get_pull_request_checks(self) -> None: owner="o", repo="r", pull_number=5 ) - assert result["sha"] == "deadbeef" - assert result["overall_state"] == "failure" - assert result["total_check_runs"] == 2 - assert len(result["check_runs"]) == 2 - assert result["check_runs"][1]["conclusion"] == "failure" - assert result["statuses"][0]["context"] == "ci/legacy" + assert result["ready_to_merge"] is False + assert result["summary"]["failing"] == 2 + assert result["failing"][0]["name"] == "test" + assert result["failing"][0]["url"] == "https://gh/run/1" + assert result["failing"][1]["name"] == "ci/legacy" + + +class TestActionsTools: + """Tests for GitHub Actions tools.""" + + @pytest.mark.asyncio + async def test_list_workflow_runs_unwraps(self) -> None: + """workflow_runs should be unwrapped into a trimmed items list.""" + from mcp_github_crunchtools.tools import list_workflow_runs + + resp = _mock_response( + json_data={ + "total_count": 1, + "workflow_runs": [ + { + "id": 999, + "name": "CI", + "head_branch": "main", + "event": "push", + "status": "completed", + "conclusion": "failure", + "html_url": "https://gh/run/999", + "created_at": "2026-06-14T00:00:00Z", + "extra": "dropped", + }, + ], + }, + ) + + with _patch_client(resp): + result = await list_workflow_runs(owner="o", repo="r") + + assert result["total_count"] == 1 + assert len(result["items"]) == 1 + item = result["items"][0] + assert item["id"] == 999 + assert item["conclusion"] == "failure" + assert "extra" not in item + + @pytest.mark.asyncio + async def test_rerun_workflow_run_empty_body(self) -> None: + """A 201 with an empty body should yield a synthesized success dict.""" + from mcp_github_crunchtools.tools import rerun_workflow_run + + resp = _mock_response(status_code=201, text="", content_type="") + + with _patch_client(resp) as mock_client: + result = await rerun_workflow_run(owner="o", repo="r", run_id=42) + call = mock_client.return_value.request.call_args + assert call.kwargs["method"] == "POST" + assert call.kwargs["url"].endswith("/actions/runs/42/rerun") + + assert result == {"status": "rerun_requested", "run_id": 42} + + @pytest.mark.asyncio + async def test_rerun_failed_jobs_empty_body(self) -> None: + """rerun-failed-jobs should also handle an empty 201 body.""" + from mcp_github_crunchtools.tools import rerun_failed_jobs + + resp = _mock_response(status_code=201, text="", content_type="") + + with _patch_client(resp) as mock_client: + result = await rerun_failed_jobs(owner="o", repo="r", run_id=42) + call = mock_client.return_value.request.call_args + assert call.kwargs["url"].endswith("/actions/runs/42/rerun-failed-jobs") + + assert result == {"status": "rerun_requested", "run_id": 42} class TestFileTools: