github-mcp 1.1.0__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.
- github_mcp/__init__.py +3 -0
- github_mcp/app.py +3 -0
- github_mcp/client.py +252 -0
- github_mcp/formatting.py +144 -0
- github_mcp/server.py +17 -0
- github_mcp/tools/__init__.py +1 -0
- github_mcp/tools/issues.py +375 -0
- github_mcp/tools/pulls.py +429 -0
- github_mcp/tools/repos.py +686 -0
- github_mcp/tools/search.py +524 -0
- github_mcp-1.1.0.dist-info/METADATA +193 -0
- github_mcp-1.1.0.dist-info/RECORD +15 -0
- github_mcp-1.1.0.dist-info/WHEEL +4 -0
- github_mcp-1.1.0.dist-info/entry_points.txt +3 -0
- github_mcp-1.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
from github_mcp.app import mcp
|
|
8
|
+
from mcp.types import ToolAnnotations
|
|
9
|
+
from github_mcp.client import github_request, handle_github_error
|
|
10
|
+
from github_mcp.formatting import (
|
|
11
|
+
ResponseFormat,
|
|
12
|
+
fmt_json,
|
|
13
|
+
fmt_list_markdown,
|
|
14
|
+
fmt_timestamp,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# MARK: Shared pagination helper for search endpoints
|
|
18
|
+
|
|
19
|
+
_SEARCH_BASE = "/search"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _search(
|
|
23
|
+
endpoint: str,
|
|
24
|
+
q: str,
|
|
25
|
+
*,
|
|
26
|
+
sort: str | None = None,
|
|
27
|
+
order: str | None = None,
|
|
28
|
+
limit: int = 20,
|
|
29
|
+
page: int = 1,
|
|
30
|
+
) -> dict:
|
|
31
|
+
"""Run a GitHub search query and return the raw result dict."""
|
|
32
|
+
params: dict = {"q": q, "per_page": limit, "page": page}
|
|
33
|
+
if sort:
|
|
34
|
+
params["sort"] = sort
|
|
35
|
+
if order:
|
|
36
|
+
params["order"] = order
|
|
37
|
+
resp = await github_request("GET", f"{_SEARCH_BASE}/{endpoint}", params=params)
|
|
38
|
+
return resp.json()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# MARK: Input models
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SearchRepositoriesInput(BaseModel):
|
|
45
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
46
|
+
|
|
47
|
+
q: str = Field(
|
|
48
|
+
...,
|
|
49
|
+
description=(
|
|
50
|
+
"GitHub repository search query. Supports qualifiers like "
|
|
51
|
+
"'language:python stars:>100 topic:mcp'. See "
|
|
52
|
+
"https://docs.github.com/en/search-github/searching-on-github/searching-for-repositories"
|
|
53
|
+
),
|
|
54
|
+
min_length=1,
|
|
55
|
+
)
|
|
56
|
+
sort: Optional[str] = Field(
|
|
57
|
+
default=None,
|
|
58
|
+
description="Sort field: 'stars', 'forks', 'help-wanted-issues', 'updated'. Default: best match.",
|
|
59
|
+
)
|
|
60
|
+
order: Optional[str] = Field(
|
|
61
|
+
default="desc", description="Sort order: 'desc' (default) or 'asc'."
|
|
62
|
+
)
|
|
63
|
+
limit: int = Field(
|
|
64
|
+
default=20,
|
|
65
|
+
ge=1,
|
|
66
|
+
le=100,
|
|
67
|
+
description="Max results to return (1–100, default 20).",
|
|
68
|
+
)
|
|
69
|
+
page: int = Field(default=1, ge=1, description="Page number.")
|
|
70
|
+
response_format: ResponseFormat = Field(
|
|
71
|
+
default=ResponseFormat.MARKDOWN, description="Output format."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SearchCodeInput(BaseModel):
|
|
76
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
77
|
+
|
|
78
|
+
q: str = Field(
|
|
79
|
+
...,
|
|
80
|
+
description=(
|
|
81
|
+
"Code search query. Supports qualifiers like 'repo:owner/repo language:python "
|
|
82
|
+
"filename:test_*.py'. See https://docs.github.com/en/search-github/searching-on-github/searching-code"
|
|
83
|
+
),
|
|
84
|
+
min_length=1,
|
|
85
|
+
)
|
|
86
|
+
limit: int = Field(
|
|
87
|
+
default=20,
|
|
88
|
+
ge=1,
|
|
89
|
+
le=100,
|
|
90
|
+
description="Max results to return (1–100, default 20).",
|
|
91
|
+
)
|
|
92
|
+
page: int = Field(default=1, ge=1, description="Page number.")
|
|
93
|
+
response_format: ResponseFormat = Field(
|
|
94
|
+
default=ResponseFormat.MARKDOWN, description="Output format."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SearchIssuesInput(BaseModel):
|
|
99
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
100
|
+
|
|
101
|
+
q: str = Field(
|
|
102
|
+
...,
|
|
103
|
+
description=(
|
|
104
|
+
"Issue/PR search query. Supports qualifiers like 'is:issue is:open label:bug repo:owner/repo'. "
|
|
105
|
+
"See https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests"
|
|
106
|
+
),
|
|
107
|
+
min_length=1,
|
|
108
|
+
)
|
|
109
|
+
sort: Optional[str] = Field(
|
|
110
|
+
default=None,
|
|
111
|
+
description=(
|
|
112
|
+
"Sort by: 'comments', 'reactions', 'reactions-+1', 'reactions--1', "
|
|
113
|
+
"'reactions-smile', 'reactions-thinking_face', 'reactions-heart', "
|
|
114
|
+
"'reactions-tada', 'interactions', 'created', 'updated'. Default: best match."
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
order: Optional[str] = Field(
|
|
118
|
+
default="desc", description="Sort order: 'desc' (default) or 'asc'."
|
|
119
|
+
)
|
|
120
|
+
limit: int = Field(
|
|
121
|
+
default=20,
|
|
122
|
+
ge=1,
|
|
123
|
+
le=100,
|
|
124
|
+
description="Max results to return (1–100, default 20).",
|
|
125
|
+
)
|
|
126
|
+
page: int = Field(default=1, ge=1, description="Page number.")
|
|
127
|
+
response_format: ResponseFormat = Field(
|
|
128
|
+
default=ResponseFormat.MARKDOWN, description="Output format."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class SearchUsersInput(BaseModel):
|
|
133
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
134
|
+
|
|
135
|
+
q: str = Field(
|
|
136
|
+
...,
|
|
137
|
+
description=(
|
|
138
|
+
"User/org search query. Supports qualifiers like 'type:user location:Berlin language:python'. "
|
|
139
|
+
"See https://docs.github.com/en/search-github/searching-on-github/searching-users"
|
|
140
|
+
),
|
|
141
|
+
min_length=1,
|
|
142
|
+
)
|
|
143
|
+
sort: Optional[str] = Field(
|
|
144
|
+
default=None,
|
|
145
|
+
description="Sort by: 'followers', 'repositories', 'joined'. Default: best match.",
|
|
146
|
+
)
|
|
147
|
+
order: Optional[str] = Field(
|
|
148
|
+
default="desc", description="Sort order: 'desc' (default) or 'asc'."
|
|
149
|
+
)
|
|
150
|
+
limit: int = Field(
|
|
151
|
+
default=20, ge=1, le=100, description="Max results (1–100, default 20)."
|
|
152
|
+
)
|
|
153
|
+
page: int = Field(default=1, ge=1, description="Page number.")
|
|
154
|
+
response_format: ResponseFormat = Field(
|
|
155
|
+
default=ResponseFormat.MARKDOWN, description="Output format."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class SearchCommitsInput(BaseModel):
|
|
160
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
161
|
+
|
|
162
|
+
q: str = Field(
|
|
163
|
+
...,
|
|
164
|
+
description=(
|
|
165
|
+
"Commit search query. Supports qualifiers like 'repo:owner/repo author:login "
|
|
166
|
+
"committer-date:>2024-01-01'. "
|
|
167
|
+
"See https://docs.github.com/en/search-github/searching-on-github/searching-commits"
|
|
168
|
+
),
|
|
169
|
+
min_length=1,
|
|
170
|
+
)
|
|
171
|
+
sort: Optional[str] = Field(
|
|
172
|
+
default=None,
|
|
173
|
+
description="Sort by: 'author-date', 'committer-date'. Default: best match.",
|
|
174
|
+
)
|
|
175
|
+
order: Optional[str] = Field(
|
|
176
|
+
default="desc", description="Sort order: 'desc' (default) or 'asc'."
|
|
177
|
+
)
|
|
178
|
+
limit: int = Field(
|
|
179
|
+
default=20, ge=1, le=100, description="Max results (1–100, default 20)."
|
|
180
|
+
)
|
|
181
|
+
page: int = Field(default=1, ge=1, description="Page number.")
|
|
182
|
+
response_format: ResponseFormat = Field(
|
|
183
|
+
default=ResponseFormat.MARKDOWN, description="Output format."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# Tools
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@mcp.tool(
|
|
193
|
+
name="github_search_repositories",
|
|
194
|
+
annotations=ToolAnnotations(
|
|
195
|
+
title="Search GitHub Repositories",
|
|
196
|
+
readOnlyHint=True,
|
|
197
|
+
destructiveHint=False,
|
|
198
|
+
idempotentHint=True,
|
|
199
|
+
openWorldHint=True,
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
async def github_search_repositories(params: SearchRepositoriesInput) -> str:
|
|
203
|
+
"""Search GitHub repositories using GitHub's search syntax.
|
|
204
|
+
|
|
205
|
+
Supports qualifiers such as language, stars, topics, owner, etc.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
params (SearchRepositoriesInput): Validated input containing:
|
|
209
|
+
- q (str): Search query string.
|
|
210
|
+
- sort (Optional[str]): Sort field.
|
|
211
|
+
- order (Optional[str]): 'asc' or 'desc'.
|
|
212
|
+
- limit (int): Max results (1–100).
|
|
213
|
+
- page (int): Page number.
|
|
214
|
+
- response_format (ResponseFormat): 'markdown' or 'json'.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
str: Formatted results. Markdown shows repo name, description, stars,
|
|
218
|
+
language, and URL. JSON includes full_name, description, stars,
|
|
219
|
+
forks, language, topics, html_url, and total_count.
|
|
220
|
+
|
|
221
|
+
Error response: "Error <code>: <message>" string.
|
|
222
|
+
"""
|
|
223
|
+
try:
|
|
224
|
+
data = await _search(
|
|
225
|
+
"repositories",
|
|
226
|
+
params.q,
|
|
227
|
+
sort=params.sort,
|
|
228
|
+
order=params.order,
|
|
229
|
+
limit=params.limit,
|
|
230
|
+
page=params.page,
|
|
231
|
+
)
|
|
232
|
+
repos = data.get("items", [])
|
|
233
|
+
total = data.get("total_count", 0)
|
|
234
|
+
|
|
235
|
+
if params.response_format == ResponseFormat.JSON:
|
|
236
|
+
return fmt_json({"total_count": total, "items": repos})
|
|
237
|
+
|
|
238
|
+
lines = [
|
|
239
|
+
f"## Repository Search: `{params.q}`",
|
|
240
|
+
f"*{total:,} total matches — showing {len(repos)}*",
|
|
241
|
+
"",
|
|
242
|
+
]
|
|
243
|
+
lines.append(
|
|
244
|
+
fmt_list_markdown(
|
|
245
|
+
repos,
|
|
246
|
+
fields=[
|
|
247
|
+
"description",
|
|
248
|
+
"language",
|
|
249
|
+
"stargazers_count",
|
|
250
|
+
"forks_count",
|
|
251
|
+
"updated_at",
|
|
252
|
+
],
|
|
253
|
+
name_field="full_name",
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
return "\n".join(lines)
|
|
257
|
+
except Exception as e:
|
|
258
|
+
return handle_github_error(e)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@mcp.tool(
|
|
262
|
+
name="github_search_code",
|
|
263
|
+
annotations=ToolAnnotations(
|
|
264
|
+
title="Search GitHub Code",
|
|
265
|
+
readOnlyHint=True,
|
|
266
|
+
destructiveHint=False,
|
|
267
|
+
idempotentHint=True,
|
|
268
|
+
openWorldHint=True,
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
async def github_search_code(params: SearchCodeInput) -> str:
|
|
272
|
+
"""Search for code across GitHub repositories using GitHub's code search syntax.
|
|
273
|
+
|
|
274
|
+
Note: Code search requires authentication. Results are limited to the first
|
|
275
|
+
1 000 matches by GitHub's API.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
params (SearchCodeInput): Validated input containing:
|
|
279
|
+
- q (str): Code search query (e.g. 'FastMCP repo:user/repo language:python').
|
|
280
|
+
- limit (int): Max results (1–100).
|
|
281
|
+
- page (int): Page number.
|
|
282
|
+
- response_format (ResponseFormat): 'markdown' or 'json'.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
str: Formatted results. Markdown shows file path, repository, and URL.
|
|
286
|
+
JSON includes name, path, repository, sha, and html_url.
|
|
287
|
+
|
|
288
|
+
Error response: "Error <code>: <message>" string.
|
|
289
|
+
"""
|
|
290
|
+
try:
|
|
291
|
+
# Code search doesn't support sort parameter in a stable way
|
|
292
|
+
data = await _search("code", params.q, limit=params.limit, page=params.page)
|
|
293
|
+
items = data.get("items", [])
|
|
294
|
+
total = data.get("total_count", 0)
|
|
295
|
+
|
|
296
|
+
if params.response_format == ResponseFormat.JSON:
|
|
297
|
+
return fmt_json({"total_count": total, "items": items})
|
|
298
|
+
|
|
299
|
+
lines = [
|
|
300
|
+
f"## Code Search: `{params.q}`",
|
|
301
|
+
f"*{total:,} total matches — showing {len(items)}*",
|
|
302
|
+
"",
|
|
303
|
+
]
|
|
304
|
+
if not items:
|
|
305
|
+
lines.append("*No code matches found.*")
|
|
306
|
+
else:
|
|
307
|
+
for item in items:
|
|
308
|
+
path = item.get("path", "")
|
|
309
|
+
repo_name = item.get("repository", {}).get("full_name", "")
|
|
310
|
+
url = item.get("html_url", "")
|
|
311
|
+
lines.append(f"- [`{path}`]({url}) in **{repo_name}**")
|
|
312
|
+
return "\n".join(lines)
|
|
313
|
+
except Exception as e:
|
|
314
|
+
return handle_github_error(e)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@mcp.tool(
|
|
318
|
+
name="github_search_issues",
|
|
319
|
+
annotations=ToolAnnotations(
|
|
320
|
+
title="Search GitHub Issues and Pull Requests",
|
|
321
|
+
readOnlyHint=True,
|
|
322
|
+
destructiveHint=False,
|
|
323
|
+
idempotentHint=True,
|
|
324
|
+
openWorldHint=True,
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
async def github_search_issues(params: SearchIssuesInput) -> str:
|
|
328
|
+
"""Search issues and pull requests across GitHub using search qualifiers.
|
|
329
|
+
|
|
330
|
+
Use ``is:issue`` or ``is:pr`` to restrict to one type.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
params (SearchIssuesInput): Validated input containing:
|
|
334
|
+
- q (str): Search query (e.g. 'is:open is:issue label:bug repo:owner/repo').
|
|
335
|
+
- sort (Optional[str]): Sort field.
|
|
336
|
+
- order (Optional[str]): 'asc' or 'desc'.
|
|
337
|
+
- limit (int): Max results (1–100).
|
|
338
|
+
- page (int): Page number.
|
|
339
|
+
- response_format (ResponseFormat): 'markdown' or 'json'.
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
str: Formatted issue/PR list. Markdown shows number, title, state,
|
|
343
|
+
type (issue/PR), labels, and URL. JSON includes number, title, state,
|
|
344
|
+
labels, user, created_at, html_url, and total_count.
|
|
345
|
+
|
|
346
|
+
Error response: "Error <code>: <message>" string.
|
|
347
|
+
"""
|
|
348
|
+
try:
|
|
349
|
+
data = await _search(
|
|
350
|
+
"issues",
|
|
351
|
+
params.q,
|
|
352
|
+
sort=params.sort,
|
|
353
|
+
order=params.order,
|
|
354
|
+
limit=params.limit,
|
|
355
|
+
page=params.page,
|
|
356
|
+
)
|
|
357
|
+
items = data.get("items", [])
|
|
358
|
+
total = data.get("total_count", 0)
|
|
359
|
+
|
|
360
|
+
if params.response_format == ResponseFormat.JSON:
|
|
361
|
+
return fmt_json({"total_count": total, "items": items})
|
|
362
|
+
|
|
363
|
+
lines = [
|
|
364
|
+
f"## Issues/PR Search: `{params.q}`",
|
|
365
|
+
f"*{total:,} total matches — showing {len(items)}*",
|
|
366
|
+
"",
|
|
367
|
+
]
|
|
368
|
+
if not items:
|
|
369
|
+
lines.append("*No matches found.*")
|
|
370
|
+
else:
|
|
371
|
+
for item in items:
|
|
372
|
+
num = item.get("number", "?")
|
|
373
|
+
title = item.get("title", "")
|
|
374
|
+
url = item.get("html_url", "")
|
|
375
|
+
state = item.get("state", "")
|
|
376
|
+
kind = "PR" if "pull_request" in item else "Issue"
|
|
377
|
+
labels = ", ".join(lbl["name"] for lbl in item.get("labels", []))
|
|
378
|
+
label_str = f" [{labels}]" if labels else ""
|
|
379
|
+
repo_url = item.get("repository_url", "")
|
|
380
|
+
repo_name = repo_url.split("repos/")[-1] if "repos/" in repo_url else ""
|
|
381
|
+
lines.append(
|
|
382
|
+
f"- **#{num}** ({kind}) [{title}]({url}){label_str} — {state}"
|
|
383
|
+
)
|
|
384
|
+
if repo_name:
|
|
385
|
+
lines.append(f" - Repo: {repo_name}")
|
|
386
|
+
return "\n".join(lines)
|
|
387
|
+
except Exception as e:
|
|
388
|
+
return handle_github_error(e)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@mcp.tool(
|
|
392
|
+
name="github_search_users",
|
|
393
|
+
annotations=ToolAnnotations(
|
|
394
|
+
title="Search GitHub Users and Organisations",
|
|
395
|
+
readOnlyHint=True,
|
|
396
|
+
destructiveHint=False,
|
|
397
|
+
idempotentHint=True,
|
|
398
|
+
openWorldHint=True,
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
async def github_search_users(params: SearchUsersInput) -> str:
|
|
402
|
+
"""Search GitHub users and organisations.
|
|
403
|
+
|
|
404
|
+
Supports qualifiers such as location, language, followers, type, etc.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
params (SearchUsersInput): Validated input containing:
|
|
408
|
+
- q (str): User search query.
|
|
409
|
+
- sort (Optional[str]): Sort field.
|
|
410
|
+
- order (Optional[str]): 'asc' or 'desc'.
|
|
411
|
+
- limit (int): Max results (1–100).
|
|
412
|
+
- page (int): Page number.
|
|
413
|
+
- response_format (ResponseFormat): 'markdown' or 'json'.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
str: Formatted user list. Markdown shows login, type (user/org),
|
|
417
|
+
and profile URL. JSON includes login, type, avatar_url, html_url.
|
|
418
|
+
|
|
419
|
+
Error response: "Error <code>: <message>" string.
|
|
420
|
+
"""
|
|
421
|
+
try:
|
|
422
|
+
data = await _search(
|
|
423
|
+
"users",
|
|
424
|
+
params.q,
|
|
425
|
+
sort=params.sort,
|
|
426
|
+
order=params.order,
|
|
427
|
+
limit=params.limit,
|
|
428
|
+
page=params.page,
|
|
429
|
+
)
|
|
430
|
+
users = data.get("items", [])
|
|
431
|
+
total = data.get("total_count", 0)
|
|
432
|
+
|
|
433
|
+
if params.response_format == ResponseFormat.JSON:
|
|
434
|
+
return fmt_json({"total_count": total, "items": users})
|
|
435
|
+
|
|
436
|
+
lines = [
|
|
437
|
+
f"## User Search: `{params.q}`",
|
|
438
|
+
f"*{total:,} total matches — showing {len(users)}*",
|
|
439
|
+
"",
|
|
440
|
+
]
|
|
441
|
+
if not users:
|
|
442
|
+
lines.append("*No users found.*")
|
|
443
|
+
else:
|
|
444
|
+
for user in users:
|
|
445
|
+
login = user.get("login", "?")
|
|
446
|
+
url = user.get("html_url", "")
|
|
447
|
+
user_type = user.get("type", "User")
|
|
448
|
+
lines.append(f"- **[{login}]({url})** ({user_type})")
|
|
449
|
+
return "\n".join(lines)
|
|
450
|
+
except Exception as e:
|
|
451
|
+
return handle_github_error(e)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@mcp.tool(
|
|
455
|
+
name="github_search_commits",
|
|
456
|
+
annotations=ToolAnnotations(
|
|
457
|
+
title="Search GitHub Commits",
|
|
458
|
+
readOnlyHint=True,
|
|
459
|
+
destructiveHint=False,
|
|
460
|
+
idempotentHint=True,
|
|
461
|
+
openWorldHint=True,
|
|
462
|
+
),
|
|
463
|
+
)
|
|
464
|
+
async def github_search_commits(params: SearchCommitsInput) -> str:
|
|
465
|
+
"""Search commits across GitHub repositories.
|
|
466
|
+
|
|
467
|
+
Supports qualifiers like repo, author, committer, committer-date, merge, etc.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
params (SearchCommitsInput): Validated input containing:
|
|
471
|
+
- q (str): Commit search query.
|
|
472
|
+
- sort (Optional[str]): 'author-date' or 'committer-date'.
|
|
473
|
+
- order (Optional[str]): 'asc' or 'desc'.
|
|
474
|
+
- limit (int): Max results (1–100).
|
|
475
|
+
- page (int): Page number.
|
|
476
|
+
- response_format (ResponseFormat): 'markdown' or 'json'.
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
str: Formatted commit list. Markdown shows SHA (short), commit message,
|
|
480
|
+
author, date, and URL. JSON is raw API items with total_count.
|
|
481
|
+
|
|
482
|
+
Error response: "Error <code>: <message>" string.
|
|
483
|
+
"""
|
|
484
|
+
try:
|
|
485
|
+
resp = await github_request(
|
|
486
|
+
"GET",
|
|
487
|
+
f"{_SEARCH_BASE}/commits",
|
|
488
|
+
params={
|
|
489
|
+
"q": params.q,
|
|
490
|
+
"per_page": params.limit,
|
|
491
|
+
"page": params.page,
|
|
492
|
+
**({"sort": params.sort} if params.sort else {}),
|
|
493
|
+
**({"order": params.order} if params.order else {}),
|
|
494
|
+
},
|
|
495
|
+
accept="application/vnd.github+json",
|
|
496
|
+
)
|
|
497
|
+
data = resp.json()
|
|
498
|
+
items = data.get("items", [])
|
|
499
|
+
total = data.get("total_count", 0)
|
|
500
|
+
|
|
501
|
+
if params.response_format == ResponseFormat.JSON:
|
|
502
|
+
return fmt_json({"total_count": total, "items": items})
|
|
503
|
+
|
|
504
|
+
lines = [
|
|
505
|
+
f"## Commit Search: `{params.q}`",
|
|
506
|
+
f"*{total:,} total matches — showing {len(items)}*",
|
|
507
|
+
"",
|
|
508
|
+
]
|
|
509
|
+
if not items:
|
|
510
|
+
lines.append("*No commits found.*")
|
|
511
|
+
else:
|
|
512
|
+
for item in items:
|
|
513
|
+
sha = item.get("sha", "")[:7]
|
|
514
|
+
commit_data = item.get("commit", {})
|
|
515
|
+
msg = commit_data.get("message", "").split("\n")[0][:80]
|
|
516
|
+
author = commit_data.get("author", {}).get("name", "unknown")
|
|
517
|
+
date = fmt_timestamp(commit_data.get("author", {}).get("date"))
|
|
518
|
+
repo_name = item.get("repository", {}).get("full_name", "")
|
|
519
|
+
url = item.get("html_url", "")
|
|
520
|
+
lines.append(f"- [`{sha}`]({url}) {msg}")
|
|
521
|
+
lines.append(f" - Author: {author} — {date} in **{repo_name}**")
|
|
522
|
+
return "\n".join(lines)
|
|
523
|
+
except Exception as e:
|
|
524
|
+
return handle_github_error(e)
|