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.
- mcp_github_crunchtools/__init__.py +58 -0
- mcp_github_crunchtools/__main__.py +6 -0
- mcp_github_crunchtools/client.py +271 -0
- mcp_github_crunchtools/config.py +140 -0
- mcp_github_crunchtools/errors.py +65 -0
- mcp_github_crunchtools/models.py +215 -0
- mcp_github_crunchtools/server.py +496 -0
- mcp_github_crunchtools/tools/__init__.py +48 -0
- mcp_github_crunchtools/tools/actions.py +187 -0
- mcp_github_crunchtools/tools/files.py +124 -0
- mcp_github_crunchtools/tools/issues.py +224 -0
- mcp_github_crunchtools/tools/pull_requests.py +287 -0
- mcp_github_crunchtools/tools/search.py +81 -0
- mcp_github_crunchtools-1.0.1.dist-info/METADATA +263 -0
- mcp_github_crunchtools-1.0.1.dist-info/RECORD +18 -0
- mcp_github_crunchtools-1.0.1.dist-info/WHEEL +4 -0
- mcp_github_crunchtools-1.0.1.dist-info/entry_points.txt +2 -0
- mcp_github_crunchtools-1.0.1.dist-info/licenses/LICENSE +661 -0
|
@@ -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
|
+
)
|