mcp-github-crunchtools 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ """GitHub MCP tools.
2
+
3
+ This package contains all the MCP tool implementations for GitHub operations.
4
+ """
5
+
6
+ from .actions import (
7
+ list_workflow_runs,
8
+ rerun_failed_jobs,
9
+ rerun_workflow_run,
10
+ trigger_workflow,
11
+ )
12
+ from .files import get_file_content, list_repo_tree
13
+ from .issues import (
14
+ create_issue,
15
+ create_issue_comment,
16
+ get_issue,
17
+ list_issues,
18
+ update_issue,
19
+ )
20
+ from .pull_requests import (
21
+ get_pull_request,
22
+ get_pull_request_checks,
23
+ get_pull_request_diff,
24
+ list_pull_requests,
25
+ update_pull_request,
26
+ )
27
+ from .search import search_code, search_issues
28
+
29
+ __all__ = [
30
+ "create_issue",
31
+ "create_issue_comment",
32
+ "get_file_content",
33
+ "get_issue",
34
+ "get_pull_request",
35
+ "get_pull_request_checks",
36
+ "get_pull_request_diff",
37
+ "list_issues",
38
+ "list_pull_requests",
39
+ "list_repo_tree",
40
+ "list_workflow_runs",
41
+ "rerun_failed_jobs",
42
+ "rerun_workflow_run",
43
+ "search_code",
44
+ "search_issues",
45
+ "trigger_workflow",
46
+ "update_issue",
47
+ "update_pull_request",
48
+ ]
@@ -0,0 +1,187 @@
1
+ """GitHub Actions tools.
2
+
3
+ Tools for listing workflow runs, triggering fresh runs, and re-running CI
4
+ on GitHub Actions.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from ..client import get_client
10
+ from ..models import (
11
+ clamp_per_page,
12
+ resolve_owner,
13
+ validate_name,
14
+ validate_positive_int,
15
+ validate_ref,
16
+ validate_workflow_inputs,
17
+ )
18
+
19
+
20
+ async def list_workflow_runs(
21
+ owner: str | None,
22
+ repo: str,
23
+ branch: str | None = None,
24
+ status: str | None = None,
25
+ per_page: int = 20,
26
+ page: int = 1,
27
+ ) -> dict[str, Any]:
28
+ """List GitHub Actions workflow runs for a repository.
29
+
30
+ Args:
31
+ owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
32
+ repo: Repository name
33
+ branch: Filter by head branch name
34
+ status: Filter by status or conclusion (e.g., "completed",
35
+ "in_progress", "queued", "failure", "success")
36
+ per_page: Results per page, max 100 (default: 20)
37
+ page: Page number (default: 1)
38
+
39
+ Returns:
40
+ Trimmed list of workflow runs with pagination info
41
+ """
42
+ owner = resolve_owner(owner)
43
+ repo = validate_name(repo, "repo")
44
+
45
+ params: dict[str, Any] = {
46
+ "per_page": clamp_per_page(per_page),
47
+ "page": validate_positive_int(page, "page"),
48
+ }
49
+ if branch:
50
+ params["branch"] = branch
51
+ if status:
52
+ params["status"] = status
53
+
54
+ client = get_client()
55
+ result = await client.get(
56
+ f"/repos/{owner}/{repo}/actions/runs", params=params
57
+ )
58
+
59
+ runs = result.get("workflow_runs", [])
60
+ items = [
61
+ {
62
+ "id": run.get("id"),
63
+ "name": run.get("name"),
64
+ "head_branch": run.get("head_branch"),
65
+ "event": run.get("event"),
66
+ "status": run.get("status"),
67
+ "conclusion": run.get("conclusion"),
68
+ "html_url": run.get("html_url"),
69
+ "created_at": run.get("created_at"),
70
+ }
71
+ for run in runs
72
+ ]
73
+
74
+ return {
75
+ "total_count": result.get("total_count", len(items)),
76
+ "items": items,
77
+ }
78
+
79
+
80
+ async def trigger_workflow(
81
+ owner: str | None,
82
+ repo: str,
83
+ workflow_id: str,
84
+ ref: str | None = None,
85
+ inputs: dict[str, Any] | None = None,
86
+ ) -> dict[str, Any]:
87
+ """Trigger a fresh GitHub Actions run via the workflow_dispatch event.
88
+
89
+ Unlike ``rerun_workflow_run`` (which re-runs an *existing* run and is
90
+ rejected by GitHub with a 403 for runs created more than 30 days ago),
91
+ this dispatches a brand-new run, so it works no matter how long ago the
92
+ workflow last ran. The target workflow's YAML must declare an
93
+ ``on: workflow_dispatch`` trigger.
94
+
95
+ Args:
96
+ owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
97
+ repo: Repository name
98
+ workflow_id: Workflow file name (e.g. "build.yml") or its numeric ID
99
+ ref: Git ref (branch or tag) to run on. Defaults to the
100
+ repository's default branch when omitted.
101
+ inputs: Optional workflow_dispatch inputs as name/value pairs
102
+
103
+ Returns:
104
+ A confirmation dict:
105
+ {"status": "dispatch_requested", "workflow": ..., "ref": ...}
106
+ """
107
+ owner = resolve_owner(owner)
108
+ repo = validate_name(repo, "repo")
109
+ workflow_id = validate_name(workflow_id, "workflow_id")
110
+
111
+ client = get_client()
112
+
113
+ if ref is None or not ref.strip():
114
+ repo_info = await client.get(f"/repos/{owner}/{repo}")
115
+ ref = repo_info.get("default_branch", "main")
116
+ ref = validate_ref(ref)
117
+
118
+ body: dict[str, Any] = {"ref": ref}
119
+ if inputs:
120
+ body["inputs"] = validate_workflow_inputs(inputs)
121
+
122
+ await client.post(
123
+ f"/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches",
124
+ json_data=body,
125
+ )
126
+ return {"status": "dispatch_requested", "workflow": workflow_id, "ref": ref}
127
+
128
+
129
+ async def rerun_workflow_run(
130
+ owner: str | None,
131
+ repo: str,
132
+ run_id: int,
133
+ ) -> dict[str, Any]:
134
+ """Re-run all jobs in a GitHub Actions workflow run.
135
+
136
+ GitHub rejects re-runs of runs created more than 30 days ago with a 403
137
+ ("Unable to retry this workflow run because it was created over a month
138
+ ago"). That is NOT a permission problem: it surfaces as a GitHubApiError
139
+ carrying GitHub's message, not a PermissionDeniedError (which is reserved
140
+ for 401). To force a fresh build regardless of age, use trigger_workflow.
141
+
142
+ Args:
143
+ owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
144
+ repo: Repository name
145
+ run_id: Workflow run ID
146
+
147
+ Returns:
148
+ A confirmation dict: {"status": "rerun_requested", "run_id": run_id}
149
+ """
150
+ owner = resolve_owner(owner)
151
+ repo = validate_name(repo, "repo")
152
+ run_id = validate_positive_int(run_id, "run_id")
153
+
154
+ client = get_client()
155
+ await client.post(f"/repos/{owner}/{repo}/actions/runs/{run_id}/rerun")
156
+ return {"status": "rerun_requested", "run_id": run_id}
157
+
158
+
159
+ async def rerun_failed_jobs(
160
+ owner: str | None,
161
+ repo: str,
162
+ run_id: int,
163
+ ) -> dict[str, Any]:
164
+ """Re-run only the failed jobs in a GitHub Actions workflow run.
165
+
166
+ As with rerun_workflow_run, GitHub rejects re-runs of runs older than 30
167
+ days with a 403 that surfaces as a GitHubApiError (GitHub's message
168
+ preserved), not a PermissionDeniedError. Use trigger_workflow to force a
169
+ fresh build regardless of age.
170
+
171
+ Args:
172
+ owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
173
+ repo: Repository name
174
+ run_id: Workflow run ID
175
+
176
+ Returns:
177
+ A confirmation dict: {"status": "rerun_requested", "run_id": run_id}
178
+ """
179
+ owner = resolve_owner(owner)
180
+ repo = validate_name(repo, "repo")
181
+ run_id = validate_positive_int(run_id, "run_id")
182
+
183
+ client = get_client()
184
+ await client.post(
185
+ f"/repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs"
186
+ )
187
+ return {"status": "rerun_requested", "run_id": run_id}
@@ -0,0 +1,124 @@
1
+ """Repository file tools.
2
+
3
+ Tools for reading file content and listing the git tree of a repository.
4
+ """
5
+
6
+ import base64
7
+ import binascii
8
+ from typing import Any
9
+ from urllib.parse import quote
10
+
11
+ from ..client import get_client
12
+ from ..errors import ValidationError
13
+ from ..models import (
14
+ MAX_PATH_LENGTH,
15
+ resolve_owner,
16
+ validate_name,
17
+ )
18
+
19
+ MAX_DECODE_SIZE = 1024 * 1024
20
+
21
+
22
+ async def get_file_content(
23
+ owner: str | None,
24
+ repo: str,
25
+ path: str,
26
+ ref: str | None = None,
27
+ ) -> dict[str, Any]:
28
+ """Get the decoded text content of a file in a repository.
29
+
30
+ Args:
31
+ owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
32
+ repo: Repository name
33
+ path: Path to the file within the repository
34
+ ref: Branch, tag, or commit SHA (default: the repository's default branch)
35
+
36
+ Returns:
37
+ File metadata plus the decoded text under "text", or a notice if the
38
+ content is binary or too large to decode
39
+ """
40
+ owner = resolve_owner(owner)
41
+ repo = validate_name(repo, "repo")
42
+
43
+ if not path or not path.strip():
44
+ raise ValidationError("path must not be empty")
45
+ if len(path) > MAX_PATH_LENGTH:
46
+ raise ValidationError("path is too long")
47
+ if ".." in path:
48
+ raise ValidationError("path must not contain '..'")
49
+
50
+ encoded_path = quote(path.strip(), safe="/")
51
+
52
+ client = get_client()
53
+ params = {"ref": ref} if ref else None
54
+ result = await client.get(
55
+ f"/repos/{owner}/{repo}/contents/{encoded_path}", params=params
56
+ )
57
+
58
+ if isinstance(result.get("content"), list):
59
+ raise ValidationError("path refers to a directory, not a file")
60
+
61
+ encoding = result.get("encoding")
62
+ raw_content = result.get("content", "")
63
+ size = result.get("size", 0)
64
+
65
+ summary: dict[str, Any] = {
66
+ "name": result.get("name"),
67
+ "path": result.get("path"),
68
+ "sha": result.get("sha"),
69
+ "size": size,
70
+ "encoding": encoding,
71
+ "html_url": result.get("html_url"),
72
+ "download_url": result.get("download_url"),
73
+ }
74
+
75
+ if encoding != "base64" or not raw_content:
76
+ summary["text"] = None
77
+ summary["notice"] = "Content not available as base64 (binary or empty)."
78
+ return summary
79
+
80
+ if size and size > MAX_DECODE_SIZE:
81
+ summary["text"] = None
82
+ summary["notice"] = "File too large to decode; use download_url."
83
+ return summary
84
+
85
+ try:
86
+ decoded = base64.b64decode(raw_content)
87
+ summary["text"] = decoded.decode("utf-8")
88
+ except (binascii.Error, UnicodeDecodeError):
89
+ summary["text"] = None
90
+ summary["notice"] = "Content is binary; use download_url."
91
+
92
+ return summary
93
+
94
+
95
+ async def list_repo_tree(
96
+ owner: str | None,
97
+ repo: str,
98
+ tree_sha: str = "HEAD",
99
+ recursive: bool = False,
100
+ ) -> dict[str, Any]:
101
+ """List the git tree (files and directories) of a repository.
102
+
103
+ Args:
104
+ owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
105
+ repo: Repository name
106
+ tree_sha: Tree SHA, branch name, or "HEAD" (default: HEAD)
107
+ recursive: Recurse into subtrees (default: false)
108
+
109
+ Returns:
110
+ Tree listing with entries and a truncation flag
111
+ """
112
+ owner = resolve_owner(owner)
113
+ repo = validate_name(repo, "repo")
114
+
115
+ if not tree_sha or not tree_sha.strip():
116
+ raise ValidationError("tree_sha must not be empty")
117
+
118
+ encoded_sha = quote(tree_sha.strip(), safe="")
119
+
120
+ client = get_client()
121
+ params = {"recursive": "1"} if recursive else None
122
+ return await client.get(
123
+ f"/repos/{owner}/{repo}/git/trees/{encoded_sha}", params=params
124
+ )
@@ -0,0 +1,224 @@
1
+ """Issue management tools.
2
+
3
+ Tools for listing, fetching, and commenting on GitHub issues.
4
+ """
5
+
6
+ from typing import Any
7
+
8
+ from ..client import get_client
9
+ from ..errors import ValidationError
10
+ from ..models import (
11
+ ISSUE_STATES,
12
+ CreateIssueCommentInput,
13
+ CreateIssueInput,
14
+ clamp_per_page,
15
+ resolve_owner,
16
+ validate_name,
17
+ validate_positive_int,
18
+ )
19
+
20
+
21
+ async def list_issues(
22
+ owner: str | None,
23
+ repo: str,
24
+ state: str = "open",
25
+ labels: str | None = None,
26
+ per_page: int = 30,
27
+ page: int = 1,
28
+ ) -> dict[str, Any]:
29
+ """List issues for a repository.
30
+
31
+ Pull requests are filtered out (the GitHub issues endpoint includes PRs).
32
+
33
+ Args:
34
+ owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
35
+ repo: Repository name
36
+ state: Filter by state (open, closed, all)
37
+ labels: Comma-separated label names
38
+ per_page: Results per page (max 100)
39
+ page: Page number
40
+
41
+ Returns:
42
+ List of issues with pagination info
43
+ """
44
+ owner = resolve_owner(owner)
45
+ repo = validate_name(repo, "repo")
46
+ if state not in ISSUE_STATES:
47
+ allowed = ", ".join(sorted(ISSUE_STATES))
48
+ raise ValidationError(f"Invalid state. Allowed: {allowed}")
49
+
50
+ client = get_client()
51
+
52
+ params: dict[str, Any] = {
53
+ "state": state,
54
+ "per_page": clamp_per_page(per_page),
55
+ "page": validate_positive_int(page, "page"),
56
+ }
57
+ if labels:
58
+ params["labels"] = labels
59
+
60
+ result = await client.get(f"/repos/{owner}/{repo}/issues", params=params)
61
+
62
+ items = result.get("items", [])
63
+ result["items"] = [item for item in items if "pull_request" not in item]
64
+
65
+ return result
66
+
67
+
68
+ async def get_issue(
69
+ owner: str | None,
70
+ repo: str,
71
+ issue_number: int,
72
+ ) -> dict[str, Any]:
73
+ """Get a single issue by number.
74
+
75
+ Args:
76
+ owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
77
+ repo: Repository name
78
+ issue_number: Issue number (the value shown in the UI, e.g., #42)
79
+
80
+ Returns:
81
+ Issue details
82
+ """
83
+ owner = resolve_owner(owner)
84
+ repo = validate_name(repo, "repo")
85
+ issue_number = validate_positive_int(issue_number, "issue_number")
86
+
87
+ client = get_client()
88
+ return await client.get(f"/repos/{owner}/{repo}/issues/{issue_number}")
89
+
90
+
91
+ async def create_issue(
92
+ owner: str | None,
93
+ repo: str,
94
+ title: str,
95
+ body: str = "",
96
+ labels: list[str] | None = None,
97
+ ) -> dict[str, Any]:
98
+ """Create a new issue in a repository.
99
+
100
+ Args:
101
+ owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
102
+ repo: Repository name
103
+ title: Issue title (required, non-empty)
104
+ body: Issue body (Markdown)
105
+ labels: Optional list of label names to apply
106
+
107
+ Returns:
108
+ Created issue details (number, html_url, title)
109
+ """
110
+ owner = resolve_owner(owner)
111
+ repo = validate_name(repo, "repo")
112
+ if not title or not title.strip():
113
+ raise ValidationError("title must not be empty")
114
+ validated = CreateIssueInput(title=title.strip(), body=body)
115
+
116
+ json_data: dict[str, Any] = {
117
+ "title": validated.title,
118
+ "body": validated.body,
119
+ }
120
+ if labels:
121
+ json_data["labels"] = labels
122
+
123
+ client = get_client()
124
+ result = await client.post(
125
+ f"/repos/{owner}/{repo}/issues",
126
+ json_data=json_data,
127
+ )
128
+ return {
129
+ "number": result.get("number"),
130
+ "html_url": result.get("html_url"),
131
+ "title": result.get("title"),
132
+ }
133
+
134
+
135
+ async def update_issue(
136
+ owner: str | None,
137
+ repo: str,
138
+ issue_number: int,
139
+ state: str | None = None,
140
+ state_reason: str | None = None,
141
+ title: str | None = None,
142
+ body: str | None = None,
143
+ labels: list[str] | None = None,
144
+ ) -> dict[str, Any]:
145
+ """Update an existing issue — including closing or reopening it.
146
+
147
+ Args:
148
+ owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
149
+ repo: Repository name
150
+ issue_number: Issue number
151
+ state: "open" or "closed" (set "closed" to close the issue)
152
+ state_reason: When closing, one of "completed" or "not_planned";
153
+ when reopening, "reopened"
154
+ title: New title (optional)
155
+ body: New body (optional)
156
+ labels: Replacement list of label names (optional)
157
+
158
+ Returns:
159
+ Updated issue details (number, state, html_url, title)
160
+ """
161
+ owner = resolve_owner(owner)
162
+ repo = validate_name(repo, "repo")
163
+ issue_number = validate_positive_int(issue_number, "issue_number")
164
+
165
+ json_data: dict[str, Any] = {}
166
+ if state is not None:
167
+ if state not in ("open", "closed"):
168
+ raise ValidationError("state must be 'open' or 'closed'")
169
+ json_data["state"] = state
170
+ if state_reason is not None:
171
+ if state_reason not in ("completed", "not_planned", "reopened"):
172
+ raise ValidationError(
173
+ "state_reason must be 'completed', 'not_planned', or 'reopened'"
174
+ )
175
+ json_data["state_reason"] = state_reason
176
+ if title is not None:
177
+ json_data["title"] = title
178
+ if body is not None:
179
+ json_data["body"] = body
180
+ if labels is not None:
181
+ json_data["labels"] = labels
182
+ if not json_data:
183
+ raise ValidationError("no fields to update")
184
+
185
+ client = get_client()
186
+ result = await client.patch(
187
+ f"/repos/{owner}/{repo}/issues/{issue_number}",
188
+ json_data=json_data,
189
+ )
190
+ return {
191
+ "number": result.get("number"),
192
+ "state": result.get("state"),
193
+ "html_url": result.get("html_url"),
194
+ "title": result.get("title"),
195
+ }
196
+
197
+
198
+ async def create_issue_comment(
199
+ owner: str | None,
200
+ repo: str,
201
+ issue_number: int,
202
+ body: str,
203
+ ) -> dict[str, Any]:
204
+ """Create a comment on an issue or pull request.
205
+
206
+ Args:
207
+ owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
208
+ repo: Repository name
209
+ issue_number: Issue or pull request number
210
+ body: Comment body (Markdown)
211
+
212
+ Returns:
213
+ Created comment details
214
+ """
215
+ owner = resolve_owner(owner)
216
+ repo = validate_name(repo, "repo")
217
+ issue_number = validate_positive_int(issue_number, "issue_number")
218
+ validated = CreateIssueCommentInput(body=body)
219
+
220
+ client = get_client()
221
+ return await client.post(
222
+ f"/repos/{owner}/{repo}/issues/{issue_number}/comments",
223
+ json_data={"body": validated.body},
224
+ )