mcp-win-stdio-git 0.2.3__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_win_stdio/git/__init__.py +5 -0
- mcp_win_stdio/git/__main__.py +8 -0
- mcp_win_stdio/git/cli.py +134 -0
- mcp_win_stdio/git/guide.py +150 -0
- mcp_win_stdio/git/server.py +1899 -0
- mcp_win_stdio_git-0.2.3.dist-info/METADATA +118 -0
- mcp_win_stdio_git-0.2.3.dist-info/RECORD +9 -0
- mcp_win_stdio_git-0.2.3.dist-info/WHEEL +4 -0
- mcp_win_stdio_git-0.2.3.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,1899 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Unified Git & GitHub MCP Server for mcp-win-stdio.
|
|
4
|
+
Combines local Git repository management with remote GitHub CLI (gh) operations,
|
|
5
|
+
providing seamless repository switching, hybrid synergy workflows, token-safe
|
|
6
|
+
truncation, Windows CRLF filtering, and tiered fallback guidance.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any, Dict, List, Optional, Union
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from mcp.server.mcpserver import MCPServer as FastMCP
|
|
20
|
+
except (ImportError, ModuleNotFoundError):
|
|
21
|
+
from mcp.server.fastmcp import FastMCP
|
|
22
|
+
|
|
23
|
+
from mcp_win_stdio.core.config import INDALA_DIR, ensure_workspace_dirs
|
|
24
|
+
|
|
25
|
+
mcp = FastMCP("git-mcp")
|
|
26
|
+
|
|
27
|
+
# Persistent repository bookmarks
|
|
28
|
+
GIT_CONFIG_DIR = INDALA_DIR
|
|
29
|
+
REPOS_CONFIG_FILE = GIT_CONFIG_DIR / "git_repos.json"
|
|
30
|
+
|
|
31
|
+
_ACTIVE_REPO: Optional[Path] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ==============================================================================
|
|
35
|
+
# Dependency Checkers & Tiered Guidance
|
|
36
|
+
# ==============================================================================
|
|
37
|
+
|
|
38
|
+
def is_git_installed() -> bool:
|
|
39
|
+
"""Check if 'git' is accessible on PATH."""
|
|
40
|
+
return shutil.which("git") is not None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_gh_installed() -> bool:
|
|
44
|
+
"""Check if 'gh' (GitHub CLI) is accessible on PATH."""
|
|
45
|
+
return shutil.which("gh") is not None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_git_missing_guidance() -> Dict[str, Any]:
|
|
49
|
+
"""Actionable guidance when Git CLI is not found on Windows."""
|
|
50
|
+
return {
|
|
51
|
+
"status": "tool_unavailable",
|
|
52
|
+
"error": "Git CLI ('git') is not installed or not found on PATH.",
|
|
53
|
+
"install_guidance": {
|
|
54
|
+
"windows_winget": "winget install --id Git.Git -e",
|
|
55
|
+
"official_site": "https://git-scm.com/download/win",
|
|
56
|
+
"next_steps": [
|
|
57
|
+
"Open PowerShell or Windows Terminal.",
|
|
58
|
+
"Run: winget install --id Git.Git -e",
|
|
59
|
+
"Restart your terminal, Claude Desktop, or IDE.",
|
|
60
|
+
"Set author identity: git config --global user.name \"Your Name\" && git config --global user.email \"you@example.com\""
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_gh_missing_guidance() -> Dict[str, Any]:
|
|
67
|
+
"""Actionable guidance when GitHub CLI is not found on Windows."""
|
|
68
|
+
return {
|
|
69
|
+
"status": "tool_unavailable",
|
|
70
|
+
"error": "GitHub CLI ('gh') is not installed or not found on PATH.",
|
|
71
|
+
"install_guidance": {
|
|
72
|
+
"windows_winget": "winget install --id GitHub.cli -e",
|
|
73
|
+
"official_site": "https://cli.github.com",
|
|
74
|
+
"next_steps": [
|
|
75
|
+
"Open PowerShell or Windows Terminal.",
|
|
76
|
+
"Run: winget install --id GitHub.cli -e",
|
|
77
|
+
"Restart your terminal, Claude Desktop, or IDE.",
|
|
78
|
+
"Authenticate with GitHub: run 'gh auth login' and follow the browser prompt."
|
|
79
|
+
]
|
|
80
|
+
},
|
|
81
|
+
"note": "All local Git tools remain 100% operational without GitHub CLI."
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_gh_auth_missing_guidance() -> Dict[str, Any]:
|
|
86
|
+
"""Actionable guidance when GitHub CLI is installed but not authenticated."""
|
|
87
|
+
return {
|
|
88
|
+
"status": "auth_required",
|
|
89
|
+
"error": "GitHub CLI ('gh') is installed, but not logged in to GitHub.",
|
|
90
|
+
"login_guidance": {
|
|
91
|
+
"command": "gh auth login",
|
|
92
|
+
"steps": [
|
|
93
|
+
"Open PowerShell or your terminal.",
|
|
94
|
+
"Run: gh auth login",
|
|
95
|
+
"Select 'GitHub.com', choose 'HTTPS', and log in via your web browser."
|
|
96
|
+
]
|
|
97
|
+
},
|
|
98
|
+
"note": "All local Git tools remain 100% operational without GitHub authentication."
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ==============================================================================
|
|
103
|
+
# Subprocess Runners with Windows Safety & CRLF Sanitization
|
|
104
|
+
# ==============================================================================
|
|
105
|
+
|
|
106
|
+
def _filter_git_output(text: str) -> str:
|
|
107
|
+
"""Filter out noisy Windows CRLF replacement warnings."""
|
|
108
|
+
if not text:
|
|
109
|
+
return ""
|
|
110
|
+
lines = text.splitlines()
|
|
111
|
+
filtered = [
|
|
112
|
+
ln for ln in lines
|
|
113
|
+
if "LF will be replaced by CRLF" not in ln
|
|
114
|
+
and "CRLF will be replaced by LF" not in ln
|
|
115
|
+
and "warning: in the working copy of" not in ln
|
|
116
|
+
]
|
|
117
|
+
return "\n".join(filtered).strip()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _truncate_output(text: str, max_lines: int = 250, max_chars: int = 15000) -> Dict[str, Any]:
|
|
121
|
+
"""Protect LLM context window with token and line capping."""
|
|
122
|
+
lines = text.splitlines()
|
|
123
|
+
total_lines = len(lines)
|
|
124
|
+
truncated = False
|
|
125
|
+
|
|
126
|
+
if total_lines > max_lines:
|
|
127
|
+
lines = lines[:max_lines]
|
|
128
|
+
truncated = True
|
|
129
|
+
|
|
130
|
+
result_text = "\n".join(lines)
|
|
131
|
+
if len(result_text) > max_chars:
|
|
132
|
+
result_text = result_text[:max_chars]
|
|
133
|
+
truncated = True
|
|
134
|
+
|
|
135
|
+
payload: Dict[str, Any] = {
|
|
136
|
+
"content": result_text,
|
|
137
|
+
"total_lines": total_lines,
|
|
138
|
+
"returned_lines": len(lines),
|
|
139
|
+
"truncated": truncated
|
|
140
|
+
}
|
|
141
|
+
if truncated:
|
|
142
|
+
payload["notice"] = f"[Output capped: showing {len(lines)} of {total_lines} lines to protect context]"
|
|
143
|
+
return payload
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def run_git_command(args: List[str], cwd: Path, timeout: int = 30) -> Dict[str, Any]:
|
|
147
|
+
"""
|
|
148
|
+
Execute git command with non-interactive flags and timeout protection.
|
|
149
|
+
Prevents hangs on credentials, password prompts, or interactive editors.
|
|
150
|
+
"""
|
|
151
|
+
if not is_git_installed():
|
|
152
|
+
return get_git_missing_guidance()
|
|
153
|
+
|
|
154
|
+
env = os.environ.copy()
|
|
155
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
156
|
+
env["GIT_EDITOR"] = "true"
|
|
157
|
+
env["LC_ALL"] = "C.UTF-8"
|
|
158
|
+
|
|
159
|
+
cmd = ["git"] + args
|
|
160
|
+
try:
|
|
161
|
+
proc = subprocess.run(
|
|
162
|
+
cmd,
|
|
163
|
+
cwd=str(cwd),
|
|
164
|
+
env=env,
|
|
165
|
+
capture_output=True,
|
|
166
|
+
text=True,
|
|
167
|
+
encoding="utf-8",
|
|
168
|
+
errors="replace",
|
|
169
|
+
timeout=timeout
|
|
170
|
+
)
|
|
171
|
+
stdout = _filter_git_output(proc.stdout)
|
|
172
|
+
stderr = _filter_git_output(proc.stderr)
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
"success": proc.returncode == 0,
|
|
176
|
+
"returncode": proc.returncode,
|
|
177
|
+
"stdout": stdout,
|
|
178
|
+
"stderr": stderr
|
|
179
|
+
}
|
|
180
|
+
except subprocess.TimeoutExpired:
|
|
181
|
+
return {
|
|
182
|
+
"success": False,
|
|
183
|
+
"error": f"Git command timed out after {timeout} seconds.",
|
|
184
|
+
"command": " ".join(cmd)
|
|
185
|
+
}
|
|
186
|
+
except Exception as e:
|
|
187
|
+
return {
|
|
188
|
+
"success": False,
|
|
189
|
+
"error": f"Failed to execute git command: {str(e)}",
|
|
190
|
+
"command": " ".join(cmd)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def run_gh_command(args: List[str], cwd: Path, timeout: int = 30) -> Dict[str, Any]:
|
|
195
|
+
"""
|
|
196
|
+
Execute gh command with non-interactive flags and prompt suppression.
|
|
197
|
+
"""
|
|
198
|
+
if not is_gh_installed():
|
|
199
|
+
return get_gh_missing_guidance()
|
|
200
|
+
|
|
201
|
+
env = os.environ.copy()
|
|
202
|
+
env["GH_NO_UPDATE_NOTIFIER"] = "1"
|
|
203
|
+
env["GH_PROMPT_DISABLED"] = "1"
|
|
204
|
+
env["LC_ALL"] = "C.UTF-8"
|
|
205
|
+
|
|
206
|
+
cmd = ["gh"] + args
|
|
207
|
+
try:
|
|
208
|
+
proc = subprocess.run(
|
|
209
|
+
cmd,
|
|
210
|
+
cwd=str(cwd),
|
|
211
|
+
env=env,
|
|
212
|
+
capture_output=True,
|
|
213
|
+
text=True,
|
|
214
|
+
encoding="utf-8",
|
|
215
|
+
errors="replace",
|
|
216
|
+
timeout=timeout
|
|
217
|
+
)
|
|
218
|
+
stdout = proc.stdout.strip()
|
|
219
|
+
stderr = proc.stderr.strip()
|
|
220
|
+
|
|
221
|
+
# Check for authentication errors
|
|
222
|
+
if proc.returncode != 0 and any(kw in stderr.lower() for kw in ("not logged in", "authentication required", "gh auth login")):
|
|
223
|
+
return get_gh_auth_missing_guidance()
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
"success": proc.returncode == 0,
|
|
227
|
+
"returncode": proc.returncode,
|
|
228
|
+
"stdout": stdout,
|
|
229
|
+
"stderr": stderr
|
|
230
|
+
}
|
|
231
|
+
except subprocess.TimeoutExpired:
|
|
232
|
+
return {
|
|
233
|
+
"success": False,
|
|
234
|
+
"error": f"GitHub CLI command timed out after {timeout} seconds.",
|
|
235
|
+
"command": " ".join(cmd)
|
|
236
|
+
}
|
|
237
|
+
except Exception as e:
|
|
238
|
+
return {
|
|
239
|
+
"success": False,
|
|
240
|
+
"error": f"Failed to execute gh command: {str(e)}",
|
|
241
|
+
"command": " ".join(cmd)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ==============================================================================
|
|
246
|
+
# Repository Context & Remote Auto-Detection
|
|
247
|
+
# ==============================================================================
|
|
248
|
+
|
|
249
|
+
def _load_repo_bookmarks() -> Dict[str, Any]:
|
|
250
|
+
ensure_workspace_dirs()
|
|
251
|
+
if REPOS_CONFIG_FILE.exists():
|
|
252
|
+
try:
|
|
253
|
+
with open(REPOS_CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
254
|
+
return json.load(f)
|
|
255
|
+
except Exception:
|
|
256
|
+
return {"active": None, "repos": {}}
|
|
257
|
+
return {"active": None, "repos": {}}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _save_repo_bookmarks(data: Dict[str, Any]) -> None:
|
|
261
|
+
ensure_workspace_dirs()
|
|
262
|
+
with open(REPOS_CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
263
|
+
json.dump(data, f, indent=2)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def find_git_root(start_path: Optional[str] = None) -> Optional[Path]:
|
|
267
|
+
"""Find closest git repository root climbing up from start_path or current dir."""
|
|
268
|
+
curr = Path(start_path).resolve() if start_path else Path.cwd().resolve()
|
|
269
|
+
for p in [curr] + list(curr.parents):
|
|
270
|
+
git_dir = p / ".git"
|
|
271
|
+
if git_dir.exists():
|
|
272
|
+
return p
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def resolve_repo_path(repo_path: Optional[str] = None) -> Path:
|
|
277
|
+
"""Resolve active repository path using priority: explicit param -> active repo -> cwd search."""
|
|
278
|
+
global _ACTIVE_REPO
|
|
279
|
+
|
|
280
|
+
if repo_path:
|
|
281
|
+
p = Path(repo_path).resolve()
|
|
282
|
+
if (p / ".git").exists() or find_git_root(str(p)):
|
|
283
|
+
return find_git_root(str(p)) or p
|
|
284
|
+
return p
|
|
285
|
+
|
|
286
|
+
if _ACTIVE_REPO and (_ACTIVE_REPO / ".git").exists():
|
|
287
|
+
return _ACTIVE_REPO
|
|
288
|
+
|
|
289
|
+
# Check bookmarks config
|
|
290
|
+
bookmarks = _load_repo_bookmarks()
|
|
291
|
+
active_bookmark = bookmarks.get("active")
|
|
292
|
+
if active_bookmark and Path(active_bookmark).exists():
|
|
293
|
+
_ACTIVE_REPO = Path(active_bookmark).resolve()
|
|
294
|
+
return _ACTIVE_REPO
|
|
295
|
+
|
|
296
|
+
# Environment variable fallback
|
|
297
|
+
env_repo = os.environ.get("GIT_REPO_DIR")
|
|
298
|
+
if env_repo and Path(env_repo).exists():
|
|
299
|
+
_ACTIVE_REPO = Path(env_repo).resolve()
|
|
300
|
+
return _ACTIVE_REPO
|
|
301
|
+
|
|
302
|
+
# Upward climb from cwd
|
|
303
|
+
cwd_root = find_git_root()
|
|
304
|
+
if cwd_root:
|
|
305
|
+
_ACTIVE_REPO = cwd_root
|
|
306
|
+
return cwd_root
|
|
307
|
+
|
|
308
|
+
return Path.cwd().resolve()
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def get_remote_github_info(cwd: Path) -> Optional[Dict[str, str]]:
|
|
312
|
+
"""Extract GitHub owner and repository name from git remote origin URL."""
|
|
313
|
+
res = run_git_command(["remote", "get-url", "origin"], cwd=cwd)
|
|
314
|
+
if not res.get("success"):
|
|
315
|
+
return None
|
|
316
|
+
url = res.get("stdout", "").strip()
|
|
317
|
+
if not url:
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
# Matches https://github.com/owner/repo(.git) or git@github.com:owner/repo(.git)
|
|
321
|
+
match = re.search(r"github\.com[/:](?P<owner>[\w\-]+)/(?P<repo>[\w\-\.]+?)(?:\.git)?$", url, re.IGNORECASE)
|
|
322
|
+
if match:
|
|
323
|
+
return {
|
|
324
|
+
"owner": match.group("owner"),
|
|
325
|
+
"repo": match.group("repo"),
|
|
326
|
+
"url": url
|
|
327
|
+
}
|
|
328
|
+
return {"url": url, "owner": "", "repo": ""}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ==============================================================================
|
|
332
|
+
# 1. Repository Context & Bookmarks
|
|
333
|
+
# ==============================================================================
|
|
334
|
+
|
|
335
|
+
@mcp.tool()
|
|
336
|
+
def use_repo(repo_path: Optional[str] = None) -> Dict[str, Any]:
|
|
337
|
+
"""
|
|
338
|
+
Switch or display the active Git repository workspace.
|
|
339
|
+
If repo_path is not specified, auto-detects from the current directory.
|
|
340
|
+
"""
|
|
341
|
+
global _ACTIVE_REPO
|
|
342
|
+
if not is_git_installed():
|
|
343
|
+
return get_git_missing_guidance()
|
|
344
|
+
|
|
345
|
+
target = find_git_root(repo_path)
|
|
346
|
+
if not target or not (target / ".git").exists():
|
|
347
|
+
return {
|
|
348
|
+
"success": False,
|
|
349
|
+
"error": f"Directory '{repo_path or Path.cwd()}' is not a valid Git repository (no .git found)."
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
_ACTIVE_REPO = target
|
|
353
|
+
bookmarks = _load_repo_bookmarks()
|
|
354
|
+
bookmarks["active"] = str(target)
|
|
355
|
+
if "repos" not in bookmarks:
|
|
356
|
+
bookmarks["repos"] = {}
|
|
357
|
+
bookmarks["repos"][target.name] = str(target)
|
|
358
|
+
_save_repo_bookmarks(bookmarks)
|
|
359
|
+
|
|
360
|
+
remote_info = get_remote_github_info(target)
|
|
361
|
+
return {
|
|
362
|
+
"success": True,
|
|
363
|
+
"active_repo": str(target),
|
|
364
|
+
"name": target.name,
|
|
365
|
+
"remote_github": remote_info,
|
|
366
|
+
"message": f"Active repository set to: {target}"
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@mcp.tool()
|
|
371
|
+
def list_repos() -> Dict[str, Any]:
|
|
372
|
+
"""
|
|
373
|
+
List all registered repository bookmarks, active repository, and git status.
|
|
374
|
+
"""
|
|
375
|
+
bookmarks = _load_repo_bookmarks()
|
|
376
|
+
active_path = str(_ACTIVE_REPO) if _ACTIVE_REPO else bookmarks.get("active")
|
|
377
|
+
|
|
378
|
+
repos_list = []
|
|
379
|
+
for alias, p_str in bookmarks.get("repos", {}).items():
|
|
380
|
+
exists = Path(p_str).exists()
|
|
381
|
+
is_git = (Path(p_str) / ".git").exists() if exists else False
|
|
382
|
+
repos_list.append({
|
|
383
|
+
"alias": alias,
|
|
384
|
+
"path": p_str,
|
|
385
|
+
"exists": exists,
|
|
386
|
+
"is_git": is_git,
|
|
387
|
+
"is_active": (p_str == active_path)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
"active_repo": active_path,
|
|
392
|
+
"bookmarks_count": len(repos_list),
|
|
393
|
+
"repositories": repos_list
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@mcp.tool()
|
|
398
|
+
def add_repo(repo_path: str, alias: Optional[str] = None) -> Dict[str, Any]:
|
|
399
|
+
"""
|
|
400
|
+
Bookmark a repository path for quick switching across multi-project workspaces.
|
|
401
|
+
"""
|
|
402
|
+
p = find_git_root(repo_path)
|
|
403
|
+
if not p or not (p / ".git").exists():
|
|
404
|
+
return {
|
|
405
|
+
"success": False,
|
|
406
|
+
"error": f"Path '{repo_path}' is not a valid Git repository."
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
key = alias or p.name
|
|
410
|
+
bookmarks = _load_repo_bookmarks()
|
|
411
|
+
if "repos" not in bookmarks:
|
|
412
|
+
bookmarks["repos"] = {}
|
|
413
|
+
bookmarks["repos"][key] = str(p)
|
|
414
|
+
_save_repo_bookmarks(bookmarks)
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
"success": True,
|
|
418
|
+
"alias": key,
|
|
419
|
+
"path": str(p),
|
|
420
|
+
"message": f"Repository '{key}' registered successfully."
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ==============================================================================
|
|
425
|
+
# 2. Hybrid Synergy Tools (Git + GH Combined)
|
|
426
|
+
# ==============================================================================
|
|
427
|
+
|
|
428
|
+
@mcp.tool()
|
|
429
|
+
def repo_overview(repo_path: Optional[str] = None) -> Dict[str, Any]:
|
|
430
|
+
"""
|
|
431
|
+
Unified 360-degree repository status: local branch, dirty status, ahead/behind
|
|
432
|
+
tracking, open Pull Request for active branch, and latest CI/CD workflow runs.
|
|
433
|
+
Gracefully degrades if GitHub CLI ('gh') is not installed or not logged in.
|
|
434
|
+
"""
|
|
435
|
+
if not is_git_installed():
|
|
436
|
+
return get_git_missing_guidance()
|
|
437
|
+
|
|
438
|
+
cwd = resolve_repo_path(repo_path)
|
|
439
|
+
if not (cwd / ".git").exists():
|
|
440
|
+
return {"success": False, "error": f"Not a git repository: {cwd}"}
|
|
441
|
+
|
|
442
|
+
# 1. Local Git Branch & Status
|
|
443
|
+
b_res = run_git_command(["branch", "--show-current"], cwd=cwd)
|
|
444
|
+
curr_branch = b_res.get("stdout", "").strip() or "HEAD (detached)"
|
|
445
|
+
|
|
446
|
+
s_res = run_git_command(["status", "--porcelain", "-b"], cwd=cwd)
|
|
447
|
+
status_lines = s_res.get("stdout", "").splitlines()
|
|
448
|
+
branch_header = status_lines[0] if status_lines else ""
|
|
449
|
+
file_changes = status_lines[1:] if len(status_lines) > 1 else []
|
|
450
|
+
|
|
451
|
+
# Tracking ahead/behind
|
|
452
|
+
ahead, behind = 0, 0
|
|
453
|
+
if "ahead" in branch_header:
|
|
454
|
+
m = re.search(r"ahead (\d+)", branch_header)
|
|
455
|
+
if m:
|
|
456
|
+
ahead = int(m.group(1))
|
|
457
|
+
if "behind" in branch_header:
|
|
458
|
+
m = re.search(r"behind (\d+)", branch_header)
|
|
459
|
+
if m:
|
|
460
|
+
behind = int(m.group(1))
|
|
461
|
+
|
|
462
|
+
# Conflict check
|
|
463
|
+
is_merging = (cwd / ".git" / "MERGE_HEAD").exists()
|
|
464
|
+
is_rebasing = (cwd / ".git" / "rebase-apply").exists() or (cwd / ".git" / "rebase-merge").exists()
|
|
465
|
+
is_cherry_picking = (cwd / ".git" / "CHERRY_PICK_HEAD").exists()
|
|
466
|
+
|
|
467
|
+
overview: Dict[str, Any] = {
|
|
468
|
+
"repository_path": str(cwd),
|
|
469
|
+
"branch": curr_branch,
|
|
470
|
+
"tracking": branch_header.lstrip("## "),
|
|
471
|
+
"ahead": ahead,
|
|
472
|
+
"behind": behind,
|
|
473
|
+
"uncommitted_files_count": len(file_changes),
|
|
474
|
+
"is_dirty": len(file_changes) > 0,
|
|
475
|
+
"in_progress_state": {
|
|
476
|
+
"merging": is_merging,
|
|
477
|
+
"rebasing": is_rebasing,
|
|
478
|
+
"cherry_picking": is_cherry_picking
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
# 2. Remote GitHub Status (Graceful degradation)
|
|
483
|
+
remote_info = get_remote_github_info(cwd)
|
|
484
|
+
overview["remote"] = remote_info
|
|
485
|
+
|
|
486
|
+
if not is_gh_installed():
|
|
487
|
+
overview["github"] = {
|
|
488
|
+
"available": False,
|
|
489
|
+
"status": "gh_missing",
|
|
490
|
+
"message": "GitHub CLI ('gh') is not installed. Run 'winget install --id GitHub.cli -e' to view PRs and CI runs."
|
|
491
|
+
}
|
|
492
|
+
return overview
|
|
493
|
+
|
|
494
|
+
# Query active PR for this branch
|
|
495
|
+
pr_res = run_gh_command(["pr", "view", curr_branch, "--json", "number,title,state,url,mergeable,reviewDecision"], cwd=cwd)
|
|
496
|
+
active_pr = None
|
|
497
|
+
if pr_res.get("success"):
|
|
498
|
+
try:
|
|
499
|
+
active_pr = json.loads(pr_res.get("stdout", "{}"))
|
|
500
|
+
except Exception:
|
|
501
|
+
pass
|
|
502
|
+
|
|
503
|
+
# Query latest GitHub Actions CI run
|
|
504
|
+
run_res = run_gh_command(["run", "list", "--limit", "1", "--json", "databaseId,status,conclusion,name,headBranch,url"], cwd=cwd)
|
|
505
|
+
latest_run = None
|
|
506
|
+
if run_res.get("success"):
|
|
507
|
+
try:
|
|
508
|
+
runs = json.loads(run_res.get("stdout", "[]"))
|
|
509
|
+
if runs:
|
|
510
|
+
latest_run = runs[0]
|
|
511
|
+
except Exception:
|
|
512
|
+
pass
|
|
513
|
+
|
|
514
|
+
overview["github"] = {
|
|
515
|
+
"available": True,
|
|
516
|
+
"active_pull_request": active_pr,
|
|
517
|
+
"latest_ci_run": latest_run
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return overview
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
@mcp.tool()
|
|
524
|
+
def pr_quickstart(
|
|
525
|
+
title: str,
|
|
526
|
+
body: Optional[str] = None,
|
|
527
|
+
branch: Optional[str] = None,
|
|
528
|
+
files: Optional[List[str]] = None,
|
|
529
|
+
draft: bool = False,
|
|
530
|
+
repo_path: Optional[str] = None
|
|
531
|
+
) -> Dict[str, Any]:
|
|
532
|
+
"""
|
|
533
|
+
Seamless Hybrid Workflow:
|
|
534
|
+
1. Stages specified files (or all changes if files is omitted)
|
|
535
|
+
2. Commits with the specified title/body
|
|
536
|
+
3. Pushes branch to origin with upstream tracking
|
|
537
|
+
4. Creates a GitHub Pull Request via 'gh pr create'
|
|
538
|
+
"""
|
|
539
|
+
if not is_git_installed():
|
|
540
|
+
return get_git_missing_guidance()
|
|
541
|
+
if not is_gh_installed():
|
|
542
|
+
return get_gh_missing_guidance()
|
|
543
|
+
|
|
544
|
+
cwd = resolve_repo_path(repo_path)
|
|
545
|
+
if not (cwd / ".git").exists():
|
|
546
|
+
return {"success": False, "error": f"Not a git repository: {cwd}"}
|
|
547
|
+
|
|
548
|
+
# Step 1: Switch or verify branch
|
|
549
|
+
if branch:
|
|
550
|
+
run_git_command(["checkout", "-B", branch], cwd=cwd)
|
|
551
|
+
b_res = run_git_command(["branch", "--show-current"], cwd=cwd)
|
|
552
|
+
curr_branch = b_res.get("stdout", "").strip()
|
|
553
|
+
|
|
554
|
+
if curr_branch in ("main", "master") and not branch:
|
|
555
|
+
return {
|
|
556
|
+
"success": False,
|
|
557
|
+
"error": f"Cannot create PR directly from default branch '{curr_branch}'. Please specify a feature branch name."
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
# Step 2: Stage and Commit
|
|
561
|
+
if files:
|
|
562
|
+
run_git_command(["add"] + files, cwd=cwd)
|
|
563
|
+
else:
|
|
564
|
+
run_git_command(["add", "-A"], cwd=cwd)
|
|
565
|
+
|
|
566
|
+
# Check if anything to commit
|
|
567
|
+
st = run_git_command(["status", "--porcelain"], cwd=cwd)
|
|
568
|
+
if st.get("stdout", "").strip():
|
|
569
|
+
c_args = ["commit", "-m", title]
|
|
570
|
+
if body:
|
|
571
|
+
c_args += ["-m", body]
|
|
572
|
+
c_res = run_git_command(c_args, cwd=cwd)
|
|
573
|
+
if not c_res.get("success"):
|
|
574
|
+
return {"success": False, "step": "commit", "error": c_res.get("stderr") or c_res.get("stdout")}
|
|
575
|
+
|
|
576
|
+
# Step 3: Push to remote
|
|
577
|
+
p_res = run_git_command(["push", "-u", "origin", curr_branch], cwd=cwd)
|
|
578
|
+
if not p_res.get("success"):
|
|
579
|
+
return {"success": False, "step": "push", "error": p_res.get("stderr") or p_res.get("stdout")}
|
|
580
|
+
|
|
581
|
+
# Step 4: Create PR with GitHub CLI
|
|
582
|
+
gh_args = ["pr", "create", "--title", title, "--body", body or title]
|
|
583
|
+
if draft:
|
|
584
|
+
gh_args.append("--draft")
|
|
585
|
+
|
|
586
|
+
gh_res = run_gh_command(gh_args, cwd=cwd)
|
|
587
|
+
if not gh_res.get("success"):
|
|
588
|
+
return {
|
|
589
|
+
"success": False,
|
|
590
|
+
"step": "gh_pr_create",
|
|
591
|
+
"branch": curr_branch,
|
|
592
|
+
"message": "Pushed to remote successfully, but PR creation failed.",
|
|
593
|
+
"error": gh_res.get("stderr") or gh_res.get("stdout")
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
"success": True,
|
|
598
|
+
"branch": curr_branch,
|
|
599
|
+
"pr_url": gh_res.get("stdout", "").strip(),
|
|
600
|
+
"message": f"Successfully committed, pushed, and opened PR on {curr_branch}."
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
@mcp.tool()
|
|
605
|
+
def issue_start_work(
|
|
606
|
+
issue_number: int,
|
|
607
|
+
branch_prefix: str = "feature",
|
|
608
|
+
repo_path: Optional[str] = None
|
|
609
|
+
) -> Dict[str, Any]:
|
|
610
|
+
"""
|
|
611
|
+
Issue-Driven Workflow:
|
|
612
|
+
Fetches GitHub Issue details via 'gh issue view', extracts title, creates a clean
|
|
613
|
+
local branch name (e.g. 'feature/42-fix-login-error'), and checks it out.
|
|
614
|
+
"""
|
|
615
|
+
if not is_git_installed():
|
|
616
|
+
return get_git_missing_guidance()
|
|
617
|
+
if not is_gh_installed():
|
|
618
|
+
return get_gh_missing_guidance()
|
|
619
|
+
|
|
620
|
+
cwd = resolve_repo_path(repo_path)
|
|
621
|
+
# Fetch issue
|
|
622
|
+
i_res = run_gh_command(["issue", "view", str(issue_number), "--json", "number,title,labels,body,url"], cwd=cwd)
|
|
623
|
+
if not i_res.get("success"):
|
|
624
|
+
return {"success": False, "error": f"Failed to fetch issue #{issue_number}: {i_res.get('stderr')}"}
|
|
625
|
+
|
|
626
|
+
try:
|
|
627
|
+
issue = json.loads(i_res.get("stdout", "{}"))
|
|
628
|
+
except Exception as e:
|
|
629
|
+
return {"success": False, "error": f"Invalid JSON from gh issue view: {str(e)}"}
|
|
630
|
+
|
|
631
|
+
title = issue.get("title", f"issue-{issue_number}")
|
|
632
|
+
clean_title = re.sub(r"[^\w\s-]", "", title.lower())
|
|
633
|
+
slug = re.sub(r"[\s_-]+", "-", clean_title).strip("-")[:40]
|
|
634
|
+
branch_name = f"{branch_prefix}/{issue_number}-{slug}"
|
|
635
|
+
|
|
636
|
+
b_res = run_git_command(["checkout", "-b", branch_name], cwd=cwd)
|
|
637
|
+
if not b_res.get("success"):
|
|
638
|
+
return {"success": False, "error": f"Failed to create branch '{branch_name}': {b_res.get('stderr')}"}
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
"success": True,
|
|
642
|
+
"issue": issue,
|
|
643
|
+
"created_branch": branch_name,
|
|
644
|
+
"message": f"Checked out local branch '{branch_name}' for issue #{issue_number}."
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
# ==============================================================================
|
|
649
|
+
# 3. Local Git Operations (14 Tools)
|
|
650
|
+
# ==============================================================================
|
|
651
|
+
|
|
652
|
+
@mcp.tool()
|
|
653
|
+
def git_status(repo_path: Optional[str] = None) -> Dict[str, Any]:
|
|
654
|
+
"""
|
|
655
|
+
Structured git status showing staged, unstaged, untracked files, and in-progress states.
|
|
656
|
+
"""
|
|
657
|
+
if not is_git_installed():
|
|
658
|
+
return get_git_missing_guidance()
|
|
659
|
+
|
|
660
|
+
cwd = resolve_repo_path(repo_path)
|
|
661
|
+
res = run_git_command(["status", "--porcelain=v1", "-b"], cwd=cwd)
|
|
662
|
+
if not res.get("success"):
|
|
663
|
+
return {"success": False, "error": res.get("stderr") or res.get("stdout")}
|
|
664
|
+
|
|
665
|
+
lines = res.get("stdout", "").splitlines()
|
|
666
|
+
branch_line = lines[0] if lines else ""
|
|
667
|
+
staged, unstaged, untracked = [], [], []
|
|
668
|
+
|
|
669
|
+
for line in lines[1:]:
|
|
670
|
+
if len(line) < 3:
|
|
671
|
+
continue
|
|
672
|
+
index_code = line[0]
|
|
673
|
+
worktree_code = line[1]
|
|
674
|
+
file_path = line[3:].strip()
|
|
675
|
+
|
|
676
|
+
if index_code == "?" and worktree_code == "?":
|
|
677
|
+
untracked.append(file_path)
|
|
678
|
+
else:
|
|
679
|
+
if index_code not in (" ", "?"):
|
|
680
|
+
staged.append({"path": file_path, "status": index_code})
|
|
681
|
+
if worktree_code not in (" ", "?"):
|
|
682
|
+
unstaged.append({"path": file_path, "status": worktree_code})
|
|
683
|
+
|
|
684
|
+
return {
|
|
685
|
+
"success": True,
|
|
686
|
+
"branch": branch_line.lstrip("## "),
|
|
687
|
+
"clean": len(staged) == 0 and len(unstaged) == 0 and len(untracked) == 0,
|
|
688
|
+
"staged": staged,
|
|
689
|
+
"unstaged": unstaged,
|
|
690
|
+
"untracked": untracked,
|
|
691
|
+
"counts": {
|
|
692
|
+
"staged": len(staged),
|
|
693
|
+
"unstaged": len(unstaged),
|
|
694
|
+
"untracked": len(untracked)
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
@mcp.tool()
|
|
700
|
+
def git_diff(
|
|
701
|
+
staged: bool = False,
|
|
702
|
+
target: Optional[str] = None,
|
|
703
|
+
path: Optional[str] = None,
|
|
704
|
+
max_lines: int = 250,
|
|
705
|
+
repo_path: Optional[str] = None
|
|
706
|
+
) -> Dict[str, Any]:
|
|
707
|
+
"""
|
|
708
|
+
Token-safe git diff (unstaged changes, staged index diff, or between branches/commits).
|
|
709
|
+
"""
|
|
710
|
+
if not is_git_installed():
|
|
711
|
+
return get_git_missing_guidance()
|
|
712
|
+
|
|
713
|
+
cwd = resolve_repo_path(repo_path)
|
|
714
|
+
args = ["diff"]
|
|
715
|
+
if staged:
|
|
716
|
+
args.append("--staged")
|
|
717
|
+
if target:
|
|
718
|
+
args.append(target)
|
|
719
|
+
if path:
|
|
720
|
+
args += ["--", path]
|
|
721
|
+
|
|
722
|
+
res = run_git_command(args, cwd=cwd)
|
|
723
|
+
if not res.get("success"):
|
|
724
|
+
return {"success": False, "error": res.get("stderr") or res.get("stdout")}
|
|
725
|
+
|
|
726
|
+
raw_diff = res.get("stdout", "")
|
|
727
|
+
if not raw_diff:
|
|
728
|
+
return {"success": True, "diff": "", "message": "No diff found (working tree clean)."}
|
|
729
|
+
|
|
730
|
+
capped = _truncate_output(raw_diff, max_lines=max_lines)
|
|
731
|
+
return {
|
|
732
|
+
"success": True,
|
|
733
|
+
"staged": staged,
|
|
734
|
+
"target": target,
|
|
735
|
+
"diff": capped["content"],
|
|
736
|
+
"lines": capped["returned_lines"],
|
|
737
|
+
"truncated": capped["truncated"],
|
|
738
|
+
"notice": capped.get("notice")
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
@mcp.tool()
|
|
743
|
+
def git_log(
|
|
744
|
+
max_count: int = 20,
|
|
745
|
+
branch: Optional[str] = None,
|
|
746
|
+
path: Optional[str] = None,
|
|
747
|
+
repo_path: Optional[str] = None
|
|
748
|
+
) -> Dict[str, Any]:
|
|
749
|
+
"""
|
|
750
|
+
Structured commit history with hash, author, relative date, and subject.
|
|
751
|
+
Capped to prevent context window blowout.
|
|
752
|
+
"""
|
|
753
|
+
if not is_git_installed():
|
|
754
|
+
return get_git_missing_guidance()
|
|
755
|
+
|
|
756
|
+
cwd = resolve_repo_path(repo_path)
|
|
757
|
+
limit = min(max_count, 100)
|
|
758
|
+
fmt = "%H%x1f%an%x1f%ae%x1f%cr%x1f%s"
|
|
759
|
+
args = ["log", f"-n{limit}", f"--format={fmt}"]
|
|
760
|
+
if branch:
|
|
761
|
+
args.append(branch)
|
|
762
|
+
if path:
|
|
763
|
+
args += ["--", path]
|
|
764
|
+
|
|
765
|
+
res = run_git_command(args, cwd=cwd)
|
|
766
|
+
if not res.get("success"):
|
|
767
|
+
return {"success": False, "error": res.get("stderr") or res.get("stdout")}
|
|
768
|
+
|
|
769
|
+
commits = []
|
|
770
|
+
for line in res.get("stdout", "").splitlines():
|
|
771
|
+
parts = line.split("\x1f")
|
|
772
|
+
if len(parts) >= 5:
|
|
773
|
+
commits.append({
|
|
774
|
+
"commit": parts[0],
|
|
775
|
+
"short_hash": parts[0][:7],
|
|
776
|
+
"author": parts[1],
|
|
777
|
+
"email": parts[2],
|
|
778
|
+
"date": parts[3],
|
|
779
|
+
"subject": parts[4]
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
return {
|
|
783
|
+
"success": True,
|
|
784
|
+
"count": len(commits),
|
|
785
|
+
"commits": commits
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
@mcp.tool()
|
|
790
|
+
def git_commit(
|
|
791
|
+
message: str,
|
|
792
|
+
files: Optional[List[str]] = None,
|
|
793
|
+
all_modified: bool = False,
|
|
794
|
+
repo_path: Optional[str] = None
|
|
795
|
+
) -> Dict[str, Any]:
|
|
796
|
+
"""
|
|
797
|
+
Stage files and create a Git commit with a clean descriptive message.
|
|
798
|
+
"""
|
|
799
|
+
if not is_git_installed():
|
|
800
|
+
return get_git_missing_guidance()
|
|
801
|
+
|
|
802
|
+
cwd = resolve_repo_path(repo_path)
|
|
803
|
+
|
|
804
|
+
# Check git author identity
|
|
805
|
+
name_res = run_git_command(["config", "user.name"], cwd=cwd)
|
|
806
|
+
email_res = run_git_command(["config", "user.email"], cwd=cwd)
|
|
807
|
+
if not name_res.get("stdout") or not email_res.get("stdout"):
|
|
808
|
+
return {
|
|
809
|
+
"success": False,
|
|
810
|
+
"error": "Git author identity is not configured.",
|
|
811
|
+
"guidance": "Run: git config --global user.name \"Your Name\" && git config --global user.email \"you@example.com\""
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
# Staging
|
|
815
|
+
if files:
|
|
816
|
+
a_res = run_git_command(["add"] + files, cwd=cwd)
|
|
817
|
+
if not a_res.get("success"):
|
|
818
|
+
return {"success": False, "step": "stage", "error": a_res.get("stderr")}
|
|
819
|
+
elif all_modified:
|
|
820
|
+
run_git_command(["add", "-u"], cwd=cwd)
|
|
821
|
+
|
|
822
|
+
# Commit
|
|
823
|
+
res = run_git_command(["commit", "-m", message], cwd=cwd)
|
|
824
|
+
if not res.get("success"):
|
|
825
|
+
return {"success": False, "step": "commit", "error": res.get("stderr") or res.get("stdout")}
|
|
826
|
+
|
|
827
|
+
return {
|
|
828
|
+
"success": True,
|
|
829
|
+
"output": res.get("stdout"),
|
|
830
|
+
"message": "Commit created successfully."
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
@mcp.tool()
|
|
835
|
+
def git_branch(
|
|
836
|
+
action: str = "list",
|
|
837
|
+
branch_name: Optional[str] = None,
|
|
838
|
+
start_point: Optional[str] = None,
|
|
839
|
+
delete: bool = False,
|
|
840
|
+
force: bool = False,
|
|
841
|
+
repo_path: Optional[str] = None
|
|
842
|
+
) -> Dict[str, Any]:
|
|
843
|
+
"""
|
|
844
|
+
Branch operations: list branches, create, switch, or safely delete branches.
|
|
845
|
+
action: 'list', 'create', 'switch', 'delete'
|
|
846
|
+
"""
|
|
847
|
+
if not is_git_installed():
|
|
848
|
+
return get_git_missing_guidance()
|
|
849
|
+
|
|
850
|
+
cwd = resolve_repo_path(repo_path)
|
|
851
|
+
act = action.lower()
|
|
852
|
+
|
|
853
|
+
if act == "list":
|
|
854
|
+
res = run_git_command(["branch", "-a", "--format=%(refname:short)|%(upstream:short)|%(HEAD)"], cwd=cwd)
|
|
855
|
+
branches = []
|
|
856
|
+
for line in res.get("stdout", "").splitlines():
|
|
857
|
+
parts = line.split("|")
|
|
858
|
+
if len(parts) >= 3:
|
|
859
|
+
branches.append({
|
|
860
|
+
"name": parts[0],
|
|
861
|
+
"upstream": parts[1] or None,
|
|
862
|
+
"is_current": parts[2] == "*"
|
|
863
|
+
})
|
|
864
|
+
return {"success": True, "branches": branches}
|
|
865
|
+
|
|
866
|
+
if act in ("create", "new"):
|
|
867
|
+
if not branch_name:
|
|
868
|
+
return {"success": False, "error": "branch_name is required for creating a branch."}
|
|
869
|
+
args = ["checkout", "-b", branch_name]
|
|
870
|
+
if start_point:
|
|
871
|
+
args.append(start_point)
|
|
872
|
+
res = run_git_command(args, cwd=cwd)
|
|
873
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
874
|
+
|
|
875
|
+
if act in ("switch", "checkout"):
|
|
876
|
+
if not branch_name:
|
|
877
|
+
return {"success": False, "error": "branch_name is required for switching branch."}
|
|
878
|
+
res = run_git_command(["checkout", branch_name], cwd=cwd)
|
|
879
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
880
|
+
|
|
881
|
+
if act == "delete" or delete:
|
|
882
|
+
if not branch_name:
|
|
883
|
+
return {"success": False, "error": "branch_name is required for deleting a branch."}
|
|
884
|
+
flag = "-D" if force else "-d"
|
|
885
|
+
res = run_git_command(["branch", flag, branch_name], cwd=cwd)
|
|
886
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
887
|
+
|
|
888
|
+
return {"success": False, "error": f"Unknown branch action: '{action}'. Use list, create, switch, or delete."}
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
@mcp.tool()
|
|
892
|
+
def git_stash(
|
|
893
|
+
action: str = "list",
|
|
894
|
+
message: Optional[str] = None,
|
|
895
|
+
index: int = 0,
|
|
896
|
+
repo_path: Optional[str] = None
|
|
897
|
+
) -> Dict[str, Any]:
|
|
898
|
+
"""
|
|
899
|
+
Manage the Git stash: 'save', 'pop', 'list', 'drop', 'clear'.
|
|
900
|
+
"""
|
|
901
|
+
if not is_git_installed():
|
|
902
|
+
return get_git_missing_guidance()
|
|
903
|
+
|
|
904
|
+
cwd = resolve_repo_path(repo_path)
|
|
905
|
+
act = action.lower()
|
|
906
|
+
|
|
907
|
+
if act == "list":
|
|
908
|
+
res = run_git_command(["stash", "list"], cwd=cwd)
|
|
909
|
+
stashes = res.get("stdout", "").splitlines()
|
|
910
|
+
return {"success": True, "count": len(stashes), "stashes": stashes}
|
|
911
|
+
|
|
912
|
+
if act in ("save", "push"):
|
|
913
|
+
args = ["stash", "push"]
|
|
914
|
+
if message:
|
|
915
|
+
args += ["-m", message]
|
|
916
|
+
res = run_git_command(args, cwd=cwd)
|
|
917
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
918
|
+
|
|
919
|
+
if act == "pop":
|
|
920
|
+
res = run_git_command(["stash", "pop", f"stash@{{{index}}}"], cwd=cwd)
|
|
921
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
922
|
+
|
|
923
|
+
if act == "drop":
|
|
924
|
+
res = run_git_command(["stash", "drop", f"stash@{{{index}}}"], cwd=cwd)
|
|
925
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
926
|
+
|
|
927
|
+
return {"success": False, "error": f"Unknown stash action '{action}'. Use list, save, pop, or drop."}
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
@mcp.tool()
|
|
931
|
+
def git_sync(
|
|
932
|
+
action: str = "pull",
|
|
933
|
+
remote: str = "origin",
|
|
934
|
+
branch: Optional[str] = None,
|
|
935
|
+
set_upstream: bool = False,
|
|
936
|
+
repo_path: Optional[str] = None
|
|
937
|
+
) -> Dict[str, Any]:
|
|
938
|
+
"""
|
|
939
|
+
Synchronize with remote: 'fetch', 'pull', or 'push'.
|
|
940
|
+
"""
|
|
941
|
+
if not is_git_installed():
|
|
942
|
+
return get_git_missing_guidance()
|
|
943
|
+
|
|
944
|
+
cwd = resolve_repo_path(repo_path)
|
|
945
|
+
act = action.lower()
|
|
946
|
+
|
|
947
|
+
if act == "fetch":
|
|
948
|
+
res = run_git_command(["fetch", remote], cwd=cwd)
|
|
949
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
950
|
+
|
|
951
|
+
if act == "pull":
|
|
952
|
+
args = ["pull", remote]
|
|
953
|
+
if branch:
|
|
954
|
+
args.append(branch)
|
|
955
|
+
res = run_git_command(args, cwd=cwd)
|
|
956
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
957
|
+
|
|
958
|
+
if act == "push":
|
|
959
|
+
args = ["push"]
|
|
960
|
+
if set_upstream:
|
|
961
|
+
args.append("-u")
|
|
962
|
+
args.append(remote)
|
|
963
|
+
if branch:
|
|
964
|
+
args.append(branch)
|
|
965
|
+
res = run_git_command(args, cwd=cwd)
|
|
966
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
967
|
+
|
|
968
|
+
return {"success": False, "error": f"Unknown sync action: '{action}'. Use fetch, pull, or push."}
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
@mcp.tool()
|
|
972
|
+
def git_blame(
|
|
973
|
+
file_path: str,
|
|
974
|
+
start_line: Optional[int] = None,
|
|
975
|
+
end_line: Optional[int] = None,
|
|
976
|
+
repo_path: Optional[str] = None
|
|
977
|
+
) -> Dict[str, Any]:
|
|
978
|
+
"""
|
|
979
|
+
View file blame line-by-line with author, date, and commit hash.
|
|
980
|
+
Supports line range filtering (start_line, end_line) to conserve tokens.
|
|
981
|
+
"""
|
|
982
|
+
if not is_git_installed():
|
|
983
|
+
return get_git_missing_guidance()
|
|
984
|
+
|
|
985
|
+
cwd = resolve_repo_path(repo_path)
|
|
986
|
+
args = ["blame", "--show-email", "-s"]
|
|
987
|
+
if start_line is not None and end_line is not None:
|
|
988
|
+
args += [f"-L{start_line},{end_line}"]
|
|
989
|
+
elif start_line is not None:
|
|
990
|
+
args += [f"-L{start_line},+50"]
|
|
991
|
+
args += ["--", file_path]
|
|
992
|
+
|
|
993
|
+
res = run_git_command(args, cwd=cwd)
|
|
994
|
+
if not res.get("success"):
|
|
995
|
+
return {"success": False, "error": res.get("stderr") or res.get("stdout")}
|
|
996
|
+
|
|
997
|
+
capped = _truncate_output(res.get("stdout", ""), max_lines=200)
|
|
998
|
+
return {
|
|
999
|
+
"success": True,
|
|
1000
|
+
"file": file_path,
|
|
1001
|
+
"blame": capped["content"],
|
|
1002
|
+
"lines": capped["returned_lines"],
|
|
1003
|
+
"truncated": capped["truncated"]
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
@mcp.tool()
|
|
1008
|
+
def git_reset(
|
|
1009
|
+
mode: str = "restore",
|
|
1010
|
+
target: Optional[str] = None,
|
|
1011
|
+
files: Optional[List[str]] = None,
|
|
1012
|
+
confirm_destructive: bool = False,
|
|
1013
|
+
repo_path: Optional[str] = None
|
|
1014
|
+
) -> Dict[str, Any]:
|
|
1015
|
+
"""
|
|
1016
|
+
Safe file restore or commit reset:
|
|
1017
|
+
- mode='restore': Safely discards unstaged changes in specific files (git restore <files>).
|
|
1018
|
+
- mode='soft': Reset commit history while keeping working directory and index intact.
|
|
1019
|
+
- mode='hard': Destructive reset. Requires confirm_destructive=True.
|
|
1020
|
+
"""
|
|
1021
|
+
if not is_git_installed():
|
|
1022
|
+
return get_git_missing_guidance()
|
|
1023
|
+
|
|
1024
|
+
cwd = resolve_repo_path(repo_path)
|
|
1025
|
+
m = mode.lower()
|
|
1026
|
+
|
|
1027
|
+
if m == "restore":
|
|
1028
|
+
if not files:
|
|
1029
|
+
return {"success": False, "error": "Specify files to restore, or use confirm_destructive=True to restore all."}
|
|
1030
|
+
res = run_git_command(["restore"] + files, cwd=cwd)
|
|
1031
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr") or "Files restored."}
|
|
1032
|
+
|
|
1033
|
+
if m == "soft":
|
|
1034
|
+
tgt = target or "HEAD~1"
|
|
1035
|
+
res = run_git_command(["reset", "--soft", tgt], cwd=cwd)
|
|
1036
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
1037
|
+
|
|
1038
|
+
if m == "hard":
|
|
1039
|
+
if not confirm_destructive:
|
|
1040
|
+
return {
|
|
1041
|
+
"success": False,
|
|
1042
|
+
"error": "Destructive operation blocked. 'git reset --hard' wipes uncommitted changes. Pass confirm_destructive=True to proceed."
|
|
1043
|
+
}
|
|
1044
|
+
tgt = target or "HEAD"
|
|
1045
|
+
res = run_git_command(["reset", "--hard", tgt], cwd=cwd)
|
|
1046
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
1047
|
+
|
|
1048
|
+
return {"success": False, "error": f"Unknown reset mode '{mode}'. Use restore, soft, or hard."}
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
@mcp.tool()
|
|
1052
|
+
def git_tag(
|
|
1053
|
+
action: str = "list",
|
|
1054
|
+
tag_name: Optional[str] = None,
|
|
1055
|
+
message: Optional[str] = None,
|
|
1056
|
+
delete: bool = False,
|
|
1057
|
+
repo_path: Optional[str] = None
|
|
1058
|
+
) -> Dict[str, Any]:
|
|
1059
|
+
"""
|
|
1060
|
+
List, create, or delete Git tags.
|
|
1061
|
+
"""
|
|
1062
|
+
if not is_git_installed():
|
|
1063
|
+
return get_git_missing_guidance()
|
|
1064
|
+
|
|
1065
|
+
cwd = resolve_repo_path(repo_path)
|
|
1066
|
+
act = action.lower()
|
|
1067
|
+
|
|
1068
|
+
if act == "list":
|
|
1069
|
+
res = run_git_command(["tag", "-l", "--sort=-creatordate"], cwd=cwd)
|
|
1070
|
+
tags = res.get("stdout", "").splitlines()[:50]
|
|
1071
|
+
return {"success": True, "tags": tags, "count": len(tags)}
|
|
1072
|
+
|
|
1073
|
+
if act in ("create", "add"):
|
|
1074
|
+
if not tag_name:
|
|
1075
|
+
return {"success": False, "error": "tag_name is required to create a tag."}
|
|
1076
|
+
args = ["tag", tag_name]
|
|
1077
|
+
if message:
|
|
1078
|
+
args += ["-a", "-m", message]
|
|
1079
|
+
res = run_git_command(args, cwd=cwd)
|
|
1080
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
1081
|
+
|
|
1082
|
+
if act == "delete" or delete:
|
|
1083
|
+
if not tag_name:
|
|
1084
|
+
return {"success": False, "error": "tag_name is required to delete a tag."}
|
|
1085
|
+
res = run_git_command(["tag", "-d", tag_name], cwd=cwd)
|
|
1086
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
1087
|
+
|
|
1088
|
+
return {"success": False, "error": f"Unknown tag action: '{action}'. Use list, create, or delete."}
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
@mcp.tool()
|
|
1092
|
+
def git_grep(
|
|
1093
|
+
pattern: str,
|
|
1094
|
+
path_spec: Optional[str] = None,
|
|
1095
|
+
ignore_case: bool = True,
|
|
1096
|
+
max_results: int = 50,
|
|
1097
|
+
repo_path: Optional[str] = None
|
|
1098
|
+
) -> Dict[str, Any]:
|
|
1099
|
+
"""
|
|
1100
|
+
Fast regex pattern search across tracked repository files at commit/branch level.
|
|
1101
|
+
"""
|
|
1102
|
+
if not is_git_installed():
|
|
1103
|
+
return get_git_missing_guidance()
|
|
1104
|
+
|
|
1105
|
+
cwd = resolve_repo_path(repo_path)
|
|
1106
|
+
args = ["grep", "-n", "-E"]
|
|
1107
|
+
if ignore_case:
|
|
1108
|
+
args.append("-i")
|
|
1109
|
+
args.append(pattern)
|
|
1110
|
+
if path_spec:
|
|
1111
|
+
args += ["--", path_spec]
|
|
1112
|
+
|
|
1113
|
+
res = run_git_command(args, cwd=cwd)
|
|
1114
|
+
lines = res.get("stdout", "").splitlines()
|
|
1115
|
+
capped = lines[:max_results]
|
|
1116
|
+
|
|
1117
|
+
matches = []
|
|
1118
|
+
for ln in capped:
|
|
1119
|
+
parts = ln.split(":", 2)
|
|
1120
|
+
if len(parts) >= 3:
|
|
1121
|
+
matches.append({"file": parts[0], "line": parts[1], "match": parts[2]})
|
|
1122
|
+
else:
|
|
1123
|
+
matches.append({"raw": ln})
|
|
1124
|
+
|
|
1125
|
+
return {
|
|
1126
|
+
"success": True,
|
|
1127
|
+
"pattern": pattern,
|
|
1128
|
+
"total_found": len(lines),
|
|
1129
|
+
"returned": len(matches),
|
|
1130
|
+
"matches": matches,
|
|
1131
|
+
"truncated": len(lines) > max_results
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
@mcp.tool()
|
|
1136
|
+
def git_worktree(
|
|
1137
|
+
action: str = "list",
|
|
1138
|
+
path: Optional[str] = None,
|
|
1139
|
+
branch: Optional[str] = None,
|
|
1140
|
+
repo_path: Optional[str] = None
|
|
1141
|
+
) -> Dict[str, Any]:
|
|
1142
|
+
"""
|
|
1143
|
+
Manage Git worktrees: 'list', 'add', 'remove', 'prune'.
|
|
1144
|
+
Allows AI agents to work in isolated branches without disturbing user's open editor.
|
|
1145
|
+
"""
|
|
1146
|
+
if not is_git_installed():
|
|
1147
|
+
return get_git_missing_guidance()
|
|
1148
|
+
|
|
1149
|
+
cwd = resolve_repo_path(repo_path)
|
|
1150
|
+
act = action.lower()
|
|
1151
|
+
|
|
1152
|
+
if act == "list":
|
|
1153
|
+
res = run_git_command(["worktree", "list"], cwd=cwd)
|
|
1154
|
+
lines = res.get("stdout", "").splitlines()
|
|
1155
|
+
worktrees = []
|
|
1156
|
+
for ln in lines:
|
|
1157
|
+
parts = ln.split()
|
|
1158
|
+
if len(parts) >= 2:
|
|
1159
|
+
worktrees.append({
|
|
1160
|
+
"path": parts[0],
|
|
1161
|
+
"commit": parts[1],
|
|
1162
|
+
"branch": parts[2].strip("[]") if len(parts) > 2 else "detached"
|
|
1163
|
+
})
|
|
1164
|
+
return {"success": True, "worktrees": worktrees}
|
|
1165
|
+
|
|
1166
|
+
if act == "add":
|
|
1167
|
+
if not path:
|
|
1168
|
+
return {"success": False, "error": "Worktree directory path is required."}
|
|
1169
|
+
args = ["worktree", "add", path]
|
|
1170
|
+
if branch:
|
|
1171
|
+
args.append(branch)
|
|
1172
|
+
res = run_git_command(args, cwd=cwd)
|
|
1173
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
1174
|
+
|
|
1175
|
+
if act == "remove":
|
|
1176
|
+
if not path:
|
|
1177
|
+
return {"success": False, "error": "Worktree path is required to remove."}
|
|
1178
|
+
res = run_git_command(["worktree", "remove", path], cwd=cwd)
|
|
1179
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
1180
|
+
|
|
1181
|
+
if act == "prune":
|
|
1182
|
+
res = run_git_command(["worktree", "prune"], cwd=cwd)
|
|
1183
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
1184
|
+
|
|
1185
|
+
return {"success": False, "error": f"Unknown worktree action: '{action}'. Use list, add, remove, or prune."}
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
@mcp.tool()
|
|
1189
|
+
def git_config(
|
|
1190
|
+
action: str = "get",
|
|
1191
|
+
key: Optional[str] = None,
|
|
1192
|
+
value: Optional[str] = None,
|
|
1193
|
+
scope: str = "global",
|
|
1194
|
+
repo_path: Optional[str] = None
|
|
1195
|
+
) -> Dict[str, Any]:
|
|
1196
|
+
"""
|
|
1197
|
+
Inspect or configure Git identity & settings (e.g. user.name, user.email, core.autocrlf).
|
|
1198
|
+
scope: 'global' or 'local'
|
|
1199
|
+
"""
|
|
1200
|
+
if not is_git_installed():
|
|
1201
|
+
return get_git_missing_guidance()
|
|
1202
|
+
|
|
1203
|
+
cwd = resolve_repo_path(repo_path)
|
|
1204
|
+
act = action.lower()
|
|
1205
|
+
scope_flag = f"--{scope}"
|
|
1206
|
+
|
|
1207
|
+
if act == "get":
|
|
1208
|
+
if not key:
|
|
1209
|
+
res = run_git_command(["config", "--list", scope_flag], cwd=cwd)
|
|
1210
|
+
cfg = dict(line.split("=", 1) for line in res.get("stdout", "").splitlines() if "=" in line)
|
|
1211
|
+
return {"success": True, "scope": scope, "config": cfg}
|
|
1212
|
+
res = run_git_command(["config", scope_flag, key], cwd=cwd)
|
|
1213
|
+
return {"success": res.get("success"), "key": key, "value": res.get("stdout", "").strip()}
|
|
1214
|
+
|
|
1215
|
+
if act == "set":
|
|
1216
|
+
if not key or value is None:
|
|
1217
|
+
return {"success": False, "error": "Both key and value are required to set git config."}
|
|
1218
|
+
res = run_git_command(["config", scope_flag, key, value], cwd=cwd)
|
|
1219
|
+
return {"success": res.get("success"), "key": key, "value": value, "scope": scope}
|
|
1220
|
+
|
|
1221
|
+
return {"success": False, "error": f"Unknown config action: '{action}'. Use get or set."}
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
@mcp.tool()
|
|
1225
|
+
def git_conflict_resolve(
|
|
1226
|
+
action: str = "status",
|
|
1227
|
+
mode: Optional[str] = None,
|
|
1228
|
+
repo_path: Optional[str] = None
|
|
1229
|
+
) -> Dict[str, Any]:
|
|
1230
|
+
"""
|
|
1231
|
+
Detect and manage in-progress merge/rebase/cherry-pick conflicts.
|
|
1232
|
+
action:
|
|
1233
|
+
- 'status': List all conflicted unmerged files
|
|
1234
|
+
- 'abort': Abort current merge or rebase (e.g. git merge --abort)
|
|
1235
|
+
- 'continue': Continue after resolving conflicts (git rebase --continue / git merge --continue)
|
|
1236
|
+
"""
|
|
1237
|
+
if not is_git_installed():
|
|
1238
|
+
return get_git_missing_guidance()
|
|
1239
|
+
|
|
1240
|
+
cwd = resolve_repo_path(repo_path)
|
|
1241
|
+
is_merging = (cwd / ".git" / "MERGE_HEAD").exists()
|
|
1242
|
+
is_rebasing = (cwd / ".git" / "rebase-apply").exists() or (cwd / ".git" / "rebase-merge").exists()
|
|
1243
|
+
is_cherry_picking = (cwd / ".git" / "CHERRY_PICK_HEAD").exists()
|
|
1244
|
+
|
|
1245
|
+
act = action.lower()
|
|
1246
|
+
if act == "status":
|
|
1247
|
+
res = run_git_command(["diff", "--name-only", "--diff-filter=U"], cwd=cwd)
|
|
1248
|
+
conflicted = res.get("stdout", "").splitlines()
|
|
1249
|
+
return {
|
|
1250
|
+
"success": True,
|
|
1251
|
+
"in_conflict": len(conflicted) > 0 or is_merging or is_rebasing or is_cherry_picking,
|
|
1252
|
+
"merging": is_merging,
|
|
1253
|
+
"rebasing": is_rebasing,
|
|
1254
|
+
"cherry_picking": is_cherry_picking,
|
|
1255
|
+
"conflicted_files": conflicted
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
if act == "abort":
|
|
1259
|
+
cmd_type = "merge" if is_merging else ("rebase" if is_rebasing else "cherry-pick")
|
|
1260
|
+
res = run_git_command([cmd_type, "--abort"], cwd=cwd)
|
|
1261
|
+
return {"success": res.get("success"), "aborted": cmd_type, "output": res.get("stdout") or res.get("stderr")}
|
|
1262
|
+
|
|
1263
|
+
if act == "continue":
|
|
1264
|
+
cmd_type = "rebase" if is_rebasing else "merge"
|
|
1265
|
+
res = run_git_command([cmd_type, "--continue"], cwd=cwd)
|
|
1266
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
1267
|
+
|
|
1268
|
+
return {"success": False, "error": f"Unknown action: '{action}'. Use status, abort, or continue."}
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
# ==============================================================================
|
|
1272
|
+
# 4. Remote GitHub Operations via 'gh' CLI (10 Tools)
|
|
1273
|
+
# ==============================================================================
|
|
1274
|
+
|
|
1275
|
+
@mcp.tool()
|
|
1276
|
+
def gh_auth_status() -> Dict[str, Any]:
|
|
1277
|
+
"""
|
|
1278
|
+
Check GitHub CLI authentication status, active account, and protocol scopes.
|
|
1279
|
+
"""
|
|
1280
|
+
if not is_gh_installed():
|
|
1281
|
+
return get_gh_missing_guidance()
|
|
1282
|
+
|
|
1283
|
+
res = run_gh_command(["auth", "status"], cwd=resolve_repo_path())
|
|
1284
|
+
# gh auth status outputs to stderr by design
|
|
1285
|
+
out = res.get("stderr") or res.get("stdout")
|
|
1286
|
+
return {
|
|
1287
|
+
"success": res.get("success"),
|
|
1288
|
+
"raw_status": out,
|
|
1289
|
+
"is_logged_in": res.get("success") or "Logged in to" in out
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
@mcp.tool()
|
|
1294
|
+
def gh_switch_account(username: str) -> Dict[str, Any]:
|
|
1295
|
+
"""
|
|
1296
|
+
Switch active GitHub CLI account if multiple accounts are configured.
|
|
1297
|
+
"""
|
|
1298
|
+
if not is_gh_installed():
|
|
1299
|
+
return get_gh_missing_guidance()
|
|
1300
|
+
|
|
1301
|
+
res = run_gh_command(["auth", "switch", "--user", username], cwd=resolve_repo_path())
|
|
1302
|
+
return {
|
|
1303
|
+
"success": res.get("success"),
|
|
1304
|
+
"output": res.get("stdout") or res.get("stderr")
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
@mcp.tool()
|
|
1309
|
+
def gh_pr_list(
|
|
1310
|
+
state: str = "open",
|
|
1311
|
+
limit: int = 20,
|
|
1312
|
+
repo_path: Optional[str] = None
|
|
1313
|
+
) -> Dict[str, Any]:
|
|
1314
|
+
"""
|
|
1315
|
+
List pull requests for the repository (state: 'open', 'closed', 'merged', 'all').
|
|
1316
|
+
"""
|
|
1317
|
+
if not is_gh_installed():
|
|
1318
|
+
return get_gh_missing_guidance()
|
|
1319
|
+
|
|
1320
|
+
cwd = resolve_repo_path(repo_path)
|
|
1321
|
+
lim = min(limit, 50)
|
|
1322
|
+
res = run_gh_command([
|
|
1323
|
+
"pr", "list",
|
|
1324
|
+
"--state", state,
|
|
1325
|
+
"--limit", str(lim),
|
|
1326
|
+
"--json", "number,title,state,headRefName,baseRefName,author,isDraft,url,updatedAt"
|
|
1327
|
+
], cwd=cwd)
|
|
1328
|
+
|
|
1329
|
+
if not res.get("success"):
|
|
1330
|
+
return {"success": False, "error": res.get("stderr") or res.get("stdout")}
|
|
1331
|
+
|
|
1332
|
+
try:
|
|
1333
|
+
prs = json.loads(res.get("stdout", "[]"))
|
|
1334
|
+
return {"success": True, "count": len(prs), "pull_requests": prs}
|
|
1335
|
+
except Exception as e:
|
|
1336
|
+
return {"success": False, "error": f"Failed to parse PR list JSON: {str(e)}"}
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
@mcp.tool()
|
|
1340
|
+
def gh_pr_view(
|
|
1341
|
+
pr_number: int,
|
|
1342
|
+
repo_path: Optional[str] = None
|
|
1343
|
+
) -> Dict[str, Any]:
|
|
1344
|
+
"""
|
|
1345
|
+
View complete details of a pull request (title, body, author, reviews, checks, mergeable status).
|
|
1346
|
+
"""
|
|
1347
|
+
if not is_gh_installed():
|
|
1348
|
+
return get_gh_missing_guidance()
|
|
1349
|
+
|
|
1350
|
+
cwd = resolve_repo_path(repo_path)
|
|
1351
|
+
res = run_gh_command([
|
|
1352
|
+
"pr", "view", str(pr_number),
|
|
1353
|
+
"--json", "number,title,body,state,author,headRefName,baseRefName,mergeable,reviewDecision,statusCheckRollup,url"
|
|
1354
|
+
], cwd=cwd)
|
|
1355
|
+
|
|
1356
|
+
if not res.get("success"):
|
|
1357
|
+
return {"success": False, "error": res.get("stderr") or res.get("stdout")}
|
|
1358
|
+
|
|
1359
|
+
try:
|
|
1360
|
+
pr_data = json.loads(res.get("stdout", "{}"))
|
|
1361
|
+
return {"success": True, "pull_request": pr_data}
|
|
1362
|
+
except Exception as e:
|
|
1363
|
+
return {"success": False, "error": f"Failed to parse PR JSON: {str(e)}"}
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
@mcp.tool()
|
|
1367
|
+
def gh_pr_diff(
|
|
1368
|
+
pr_number: int,
|
|
1369
|
+
max_lines: int = 250,
|
|
1370
|
+
repo_path: Optional[str] = None
|
|
1371
|
+
) -> Dict[str, Any]:
|
|
1372
|
+
"""
|
|
1373
|
+
View token-capped diff of a remote GitHub Pull Request.
|
|
1374
|
+
"""
|
|
1375
|
+
if not is_gh_installed():
|
|
1376
|
+
return get_gh_missing_guidance()
|
|
1377
|
+
|
|
1378
|
+
cwd = resolve_repo_path(repo_path)
|
|
1379
|
+
res = run_gh_command(["pr", "diff", str(pr_number)], cwd=cwd)
|
|
1380
|
+
if not res.get("success"):
|
|
1381
|
+
return {"success": False, "error": res.get("stderr") or res.get("stdout")}
|
|
1382
|
+
|
|
1383
|
+
capped = _truncate_output(res.get("stdout", ""), max_lines=max_lines)
|
|
1384
|
+
return {
|
|
1385
|
+
"success": True,
|
|
1386
|
+
"pr_number": pr_number,
|
|
1387
|
+
"diff": capped["content"],
|
|
1388
|
+
"lines": capped["returned_lines"],
|
|
1389
|
+
"truncated": capped["truncated"]
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
@mcp.tool()
|
|
1394
|
+
def gh_pr_checkout(
|
|
1395
|
+
pr_number: int,
|
|
1396
|
+
repo_path: Optional[str] = None
|
|
1397
|
+
) -> Dict[str, Any]:
|
|
1398
|
+
"""
|
|
1399
|
+
Check out a GitHub Pull Request branch locally in the Git repository.
|
|
1400
|
+
"""
|
|
1401
|
+
if not is_gh_installed():
|
|
1402
|
+
return get_gh_missing_guidance()
|
|
1403
|
+
|
|
1404
|
+
cwd = resolve_repo_path(repo_path)
|
|
1405
|
+
res = run_gh_command(["pr", "checkout", str(pr_number)], cwd=cwd)
|
|
1406
|
+
return {
|
|
1407
|
+
"success": res.get("success"),
|
|
1408
|
+
"output": res.get("stdout") or res.get("stderr")
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
@mcp.tool()
|
|
1413
|
+
def gh_pr_action(
|
|
1414
|
+
pr_number: int,
|
|
1415
|
+
action: str,
|
|
1416
|
+
comment: Optional[str] = None,
|
|
1417
|
+
repo_path: Optional[str] = None
|
|
1418
|
+
) -> Dict[str, Any]:
|
|
1419
|
+
"""
|
|
1420
|
+
Perform PR lifecycle actions: 'approve', 'merge', 'close', 'reopen'.
|
|
1421
|
+
For 'merge', merges using default squash/merge settings.
|
|
1422
|
+
"""
|
|
1423
|
+
if not is_gh_installed():
|
|
1424
|
+
return get_gh_missing_guidance()
|
|
1425
|
+
|
|
1426
|
+
cwd = resolve_repo_path(repo_path)
|
|
1427
|
+
act = action.lower()
|
|
1428
|
+
|
|
1429
|
+
if act == "approve":
|
|
1430
|
+
args = ["pr", "review", str(pr_number), "--approve"]
|
|
1431
|
+
if comment:
|
|
1432
|
+
args += ["--body", comment]
|
|
1433
|
+
res = run_gh_command(args, cwd=cwd)
|
|
1434
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
1435
|
+
|
|
1436
|
+
if act == "merge":
|
|
1437
|
+
res = run_gh_command(["pr", "merge", str(pr_number), "--auto", "--merge"], cwd=cwd)
|
|
1438
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
1439
|
+
|
|
1440
|
+
if act == "close":
|
|
1441
|
+
args = ["pr", "close", str(pr_number)]
|
|
1442
|
+
if comment:
|
|
1443
|
+
args += ["--comment", comment]
|
|
1444
|
+
res = run_gh_command(args, cwd=cwd)
|
|
1445
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
1446
|
+
|
|
1447
|
+
if act == "reopen":
|
|
1448
|
+
res = run_gh_command(["pr", "reopen", str(pr_number)], cwd=cwd)
|
|
1449
|
+
return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
|
|
1450
|
+
|
|
1451
|
+
return {"success": False, "error": f"Unknown PR action '{action}'. Use approve, merge, close, or reopen."}
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
@mcp.tool()
|
|
1455
|
+
def gh_issue_list(
|
|
1456
|
+
state: str = "open",
|
|
1457
|
+
limit: int = 20,
|
|
1458
|
+
assignee: Optional[str] = None,
|
|
1459
|
+
labels: Optional[List[str]] = None,
|
|
1460
|
+
repo_path: Optional[str] = None
|
|
1461
|
+
) -> Dict[str, Any]:
|
|
1462
|
+
"""
|
|
1463
|
+
Search and list GitHub issues with filters (assignee, label, state).
|
|
1464
|
+
"""
|
|
1465
|
+
if not is_gh_installed():
|
|
1466
|
+
return get_gh_missing_guidance()
|
|
1467
|
+
|
|
1468
|
+
cwd = resolve_repo_path(repo_path)
|
|
1469
|
+
args = [
|
|
1470
|
+
"issue", "list",
|
|
1471
|
+
"--state", state,
|
|
1472
|
+
"--limit", str(min(limit, 50)),
|
|
1473
|
+
"--json", "number,title,state,author,labels,assignees,url,updatedAt"
|
|
1474
|
+
]
|
|
1475
|
+
if assignee:
|
|
1476
|
+
args += ["--assignee", assignee]
|
|
1477
|
+
if labels:
|
|
1478
|
+
for lbl in labels:
|
|
1479
|
+
args += ["--label", lbl]
|
|
1480
|
+
|
|
1481
|
+
res = run_gh_command(args, cwd=cwd)
|
|
1482
|
+
if not res.get("success"):
|
|
1483
|
+
return {"success": False, "error": res.get("stderr") or res.get("stdout")}
|
|
1484
|
+
|
|
1485
|
+
try:
|
|
1486
|
+
issues = json.loads(res.get("stdout", "[]"))
|
|
1487
|
+
return {"success": True, "count": len(issues), "issues": issues}
|
|
1488
|
+
except Exception as e:
|
|
1489
|
+
return {"success": False, "error": f"Failed to parse issues JSON: {str(e)}"}
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
@mcp.tool()
|
|
1493
|
+
def gh_issue_view(
|
|
1494
|
+
issue_number: int,
|
|
1495
|
+
repo_path: Optional[str] = None
|
|
1496
|
+
) -> Dict[str, Any]:
|
|
1497
|
+
"""
|
|
1498
|
+
View complete GitHub issue details including body, labels, comments, and assignees.
|
|
1499
|
+
"""
|
|
1500
|
+
if not is_gh_installed():
|
|
1501
|
+
return get_gh_missing_guidance()
|
|
1502
|
+
|
|
1503
|
+
cwd = resolve_repo_path(repo_path)
|
|
1504
|
+
res = run_gh_command([
|
|
1505
|
+
"issue", "view", str(issue_number),
|
|
1506
|
+
"--json", "number,title,body,state,author,labels,assignees,comments,url"
|
|
1507
|
+
], cwd=cwd)
|
|
1508
|
+
|
|
1509
|
+
if not res.get("success"):
|
|
1510
|
+
return {"success": False, "error": res.get("stderr") or res.get("stdout")}
|
|
1511
|
+
|
|
1512
|
+
try:
|
|
1513
|
+
data = json.loads(res.get("stdout", "{}"))
|
|
1514
|
+
return {"success": True, "issue": data}
|
|
1515
|
+
except Exception as e:
|
|
1516
|
+
return {"success": False, "error": f"Failed to parse issue JSON: {str(e)}"}
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
@mcp.tool()
|
|
1520
|
+
def gh_issue_create(
|
|
1521
|
+
title: str,
|
|
1522
|
+
body: str,
|
|
1523
|
+
labels: Optional[List[str]] = None,
|
|
1524
|
+
assignees: Optional[List[str]] = None,
|
|
1525
|
+
repo_path: Optional[str] = None
|
|
1526
|
+
) -> Dict[str, Any]:
|
|
1527
|
+
"""
|
|
1528
|
+
Create a new GitHub issue with title, description, labels, and assignees.
|
|
1529
|
+
"""
|
|
1530
|
+
if not is_gh_installed():
|
|
1531
|
+
return get_gh_missing_guidance()
|
|
1532
|
+
|
|
1533
|
+
cwd = resolve_repo_path(repo_path)
|
|
1534
|
+
args = ["issue", "create", "--title", title, "--body", body]
|
|
1535
|
+
if labels:
|
|
1536
|
+
for lbl in labels:
|
|
1537
|
+
args += ["--label", lbl]
|
|
1538
|
+
if assignees:
|
|
1539
|
+
for a in assignees:
|
|
1540
|
+
args += ["--assignee", a]
|
|
1541
|
+
|
|
1542
|
+
res = run_gh_command(args, cwd=cwd)
|
|
1543
|
+
if not res.get("success"):
|
|
1544
|
+
return {"success": False, "error": res.get("stderr") or res.get("stdout")}
|
|
1545
|
+
|
|
1546
|
+
return {
|
|
1547
|
+
"success": True,
|
|
1548
|
+
"issue_url": res.get("stdout", "").strip(),
|
|
1549
|
+
"message": "Issue created successfully."
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
@mcp.tool()
|
|
1554
|
+
def gh_issue_comment(
|
|
1555
|
+
issue_number: int,
|
|
1556
|
+
comment: str,
|
|
1557
|
+
repo_path: Optional[str] = None
|
|
1558
|
+
) -> Dict[str, Any]:
|
|
1559
|
+
"""
|
|
1560
|
+
Post a comment on a GitHub issue or pull request.
|
|
1561
|
+
"""
|
|
1562
|
+
if not is_gh_installed():
|
|
1563
|
+
return get_gh_missing_guidance()
|
|
1564
|
+
|
|
1565
|
+
cwd = resolve_repo_path(repo_path)
|
|
1566
|
+
res = run_gh_command(["issue", "comment", str(issue_number), "--body", comment], cwd=cwd)
|
|
1567
|
+
return {
|
|
1568
|
+
"success": res.get("success"),
|
|
1569
|
+
"output": res.get("stdout") or res.get("stderr")
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
@mcp.tool()
|
|
1574
|
+
def gh_run_status(
|
|
1575
|
+
limit: int = 10,
|
|
1576
|
+
repo_path: Optional[str] = None
|
|
1577
|
+
) -> Dict[str, Any]:
|
|
1578
|
+
"""
|
|
1579
|
+
Inspect GitHub Actions CI/CD workflows and recent run outcomes.
|
|
1580
|
+
"""
|
|
1581
|
+
if not is_gh_installed():
|
|
1582
|
+
return get_gh_missing_guidance()
|
|
1583
|
+
|
|
1584
|
+
cwd = resolve_repo_path(repo_path)
|
|
1585
|
+
res = run_gh_command([
|
|
1586
|
+
"run", "list",
|
|
1587
|
+
"--limit", str(min(limit, 20)),
|
|
1588
|
+
"--json", "databaseId,name,status,conclusion,event,headBranch,url,createdAt"
|
|
1589
|
+
], cwd=cwd)
|
|
1590
|
+
|
|
1591
|
+
if not res.get("success"):
|
|
1592
|
+
return {"success": False, "error": res.get("stderr") or res.get("stdout")}
|
|
1593
|
+
|
|
1594
|
+
try:
|
|
1595
|
+
runs = json.loads(res.get("stdout", "[]"))
|
|
1596
|
+
return {"success": True, "count": len(runs), "runs": runs}
|
|
1597
|
+
except Exception as e:
|
|
1598
|
+
return {"success": False, "error": f"Failed to parse runs JSON: {str(e)}"}
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
@mcp.tool()
|
|
1602
|
+
def gh_gist_create(
|
|
1603
|
+
description: str,
|
|
1604
|
+
files: Dict[str, str],
|
|
1605
|
+
public: bool = False
|
|
1606
|
+
) -> Dict[str, Any]:
|
|
1607
|
+
"""
|
|
1608
|
+
Create a GitHub Gist with multiple files to share snippets, patches, or logs.
|
|
1609
|
+
"""
|
|
1610
|
+
if not is_gh_installed():
|
|
1611
|
+
return get_gh_missing_guidance()
|
|
1612
|
+
|
|
1613
|
+
import tempfile
|
|
1614
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
1615
|
+
tmp_path = Path(tmp_dir)
|
|
1616
|
+
file_args = []
|
|
1617
|
+
for fname, content in files.items():
|
|
1618
|
+
f_dest = tmp_path / fname
|
|
1619
|
+
f_dest.write_text(content, encoding="utf-8")
|
|
1620
|
+
file_args.append(str(f_dest))
|
|
1621
|
+
|
|
1622
|
+
args = ["gist", "create", "--desc", description] + file_args
|
|
1623
|
+
if public:
|
|
1624
|
+
args.append("--public")
|
|
1625
|
+
|
|
1626
|
+
res = run_gh_command(args, cwd=Path.cwd())
|
|
1627
|
+
if not res.get("success"):
|
|
1628
|
+
return {"success": False, "error": res.get("stderr") or res.get("stdout")}
|
|
1629
|
+
|
|
1630
|
+
return {
|
|
1631
|
+
"success": True,
|
|
1632
|
+
"gist_url": res.get("stdout", "").strip(),
|
|
1633
|
+
"message": "Gist created successfully."
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
@mcp.tool()
|
|
1638
|
+
def gh_search(
|
|
1639
|
+
kind: str = "issues",
|
|
1640
|
+
query: str = "",
|
|
1641
|
+
limit: int = 15,
|
|
1642
|
+
repo_path: Optional[str] = None
|
|
1643
|
+
) -> Dict[str, Any]:
|
|
1644
|
+
"""
|
|
1645
|
+
Search GitHub globally or within the current repository (kind: 'issues', 'prs', 'code', 'repos').
|
|
1646
|
+
"""
|
|
1647
|
+
if not is_gh_installed():
|
|
1648
|
+
return get_gh_missing_guidance()
|
|
1649
|
+
|
|
1650
|
+
cwd = resolve_repo_path(repo_path)
|
|
1651
|
+
k = kind.lower()
|
|
1652
|
+
if k not in ("issues", "prs", "code", "repos"):
|
|
1653
|
+
return {"success": False, "error": f"Invalid search kind '{kind}'. Use issues, prs, code, or repos."}
|
|
1654
|
+
|
|
1655
|
+
args = ["search", k, query, "--limit", str(min(limit, 30))]
|
|
1656
|
+
res = run_gh_command(args, cwd=cwd)
|
|
1657
|
+
return {
|
|
1658
|
+
"success": res.get("success"),
|
|
1659
|
+
"results": res.get("stdout") or res.get("stderr")
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
@mcp.tool()
|
|
1664
|
+
def git_cherry_pick(
|
|
1665
|
+
commit_hash: str,
|
|
1666
|
+
no_commit: bool = False,
|
|
1667
|
+
repo_path: Optional[str] = None
|
|
1668
|
+
) -> Dict[str, Any]:
|
|
1669
|
+
"""
|
|
1670
|
+
[Advanced Tool] Apply the changes introduced by an existing commit to the current branch.
|
|
1671
|
+
Detects cherry-pick conflicts and provides clean resolution guidance.
|
|
1672
|
+
|
|
1673
|
+
Args:
|
|
1674
|
+
commit_hash: The SHA-1 hash or reference of the commit to cherry-pick.
|
|
1675
|
+
no_commit: Apply changes to working tree and index without creating a commit (default False).
|
|
1676
|
+
repo_path: Optional path to repository workspace.
|
|
1677
|
+
"""
|
|
1678
|
+
if not is_git_installed():
|
|
1679
|
+
return get_git_missing_guidance()
|
|
1680
|
+
|
|
1681
|
+
cwd = resolve_repo_path(repo_path)
|
|
1682
|
+
args = ["cherry-pick"]
|
|
1683
|
+
if no_commit:
|
|
1684
|
+
args.append("-n")
|
|
1685
|
+
args.append(commit_hash)
|
|
1686
|
+
|
|
1687
|
+
res = run_git_command(args, cwd=cwd)
|
|
1688
|
+
if not res.get("success"):
|
|
1689
|
+
err = res.get("stderr") or res.get("stdout") or ""
|
|
1690
|
+
is_conflict = "conflict" in err.lower() or (cwd / ".git" / "CHERRY_PICK_HEAD").exists()
|
|
1691
|
+
return {
|
|
1692
|
+
"success": False,
|
|
1693
|
+
"commit_hash": commit_hash,
|
|
1694
|
+
"in_conflict": is_conflict,
|
|
1695
|
+
"error": err,
|
|
1696
|
+
"resolution": "Resolve conflicted files and run 'git_conflict_resolve(action=\"continue\")' or abort with 'git_conflict_resolve(action=\"abort\")'." if is_conflict else None
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
return {
|
|
1700
|
+
"success": True,
|
|
1701
|
+
"commit_hash": commit_hash,
|
|
1702
|
+
"output": res.get("stdout") or "Commit cherry-picked successfully.",
|
|
1703
|
+
"message": f"Successfully cherry-picked {commit_hash} onto current branch."
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
@mcp.tool()
|
|
1708
|
+
def git_clean_untracked(
|
|
1709
|
+
dry_run: bool = True,
|
|
1710
|
+
remove_directories: bool = True,
|
|
1711
|
+
repo_path: Optional[str] = None
|
|
1712
|
+
) -> Dict[str, Any]:
|
|
1713
|
+
"""
|
|
1714
|
+
[Advanced Tool] Clean untracked files and build artifacts safely.
|
|
1715
|
+
Defaults to dry_run=True to prevent accidental loss of uncommitted work.
|
|
1716
|
+
|
|
1717
|
+
Args:
|
|
1718
|
+
dry_run: If True (default), only lists files that WOULD be removed without deleting anything.
|
|
1719
|
+
remove_directories: Also remove untracked directories (-d flag).
|
|
1720
|
+
repo_path: Optional path to repository workspace.
|
|
1721
|
+
"""
|
|
1722
|
+
if not is_git_installed():
|
|
1723
|
+
return get_git_missing_guidance()
|
|
1724
|
+
|
|
1725
|
+
cwd = resolve_repo_path(repo_path)
|
|
1726
|
+
args = ["clean"]
|
|
1727
|
+
if dry_run:
|
|
1728
|
+
args.append("-n")
|
|
1729
|
+
else:
|
|
1730
|
+
args.append("-f")
|
|
1731
|
+
|
|
1732
|
+
if remove_directories:
|
|
1733
|
+
args.append("-d")
|
|
1734
|
+
|
|
1735
|
+
res = run_git_command(args, cwd=cwd)
|
|
1736
|
+
lines = res.get("stdout", "").splitlines()
|
|
1737
|
+
capped = lines[:100]
|
|
1738
|
+
|
|
1739
|
+
return {
|
|
1740
|
+
"success": res.get("success"),
|
|
1741
|
+
"dry_run": dry_run,
|
|
1742
|
+
"files_count": len(lines),
|
|
1743
|
+
"files": capped,
|
|
1744
|
+
"truncated": len(lines) > 100,
|
|
1745
|
+
"notice": "Dry run mode: no files were deleted. Pass dry_run=False to delete these files." if dry_run else f"Cleaned {len(lines)} untracked item(s)."
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
@mcp.tool()
|
|
1750
|
+
def gh_release_list(
|
|
1751
|
+
limit: int = 10,
|
|
1752
|
+
repo_path: Optional[str] = None
|
|
1753
|
+
) -> Dict[str, Any]:
|
|
1754
|
+
"""
|
|
1755
|
+
[Advanced Tool] List GitHub releases, tags, published dates, and release notes for the repository.
|
|
1756
|
+
|
|
1757
|
+
Args:
|
|
1758
|
+
limit: Maximum number of releases to return (default 10, max 30).
|
|
1759
|
+
repo_path: Optional repository workspace path.
|
|
1760
|
+
"""
|
|
1761
|
+
if not is_gh_installed():
|
|
1762
|
+
return get_gh_missing_guidance()
|
|
1763
|
+
|
|
1764
|
+
cwd = resolve_repo_path(repo_path)
|
|
1765
|
+
lim = min(max(1, limit), 30)
|
|
1766
|
+
res = run_gh_command([
|
|
1767
|
+
"release", "list",
|
|
1768
|
+
"--limit", str(lim),
|
|
1769
|
+
"--json", "tagName,name,isDraft,isPrerelease,publishedAt,url"
|
|
1770
|
+
], cwd=cwd)
|
|
1771
|
+
|
|
1772
|
+
if not res.get("success"):
|
|
1773
|
+
return {"success": False, "error": res.get("stderr") or res.get("stdout")}
|
|
1774
|
+
|
|
1775
|
+
try:
|
|
1776
|
+
releases = json.loads(res.get("stdout", "[]"))
|
|
1777
|
+
return {
|
|
1778
|
+
"success": True,
|
|
1779
|
+
"releases_count": len(releases),
|
|
1780
|
+
"releases": releases
|
|
1781
|
+
}
|
|
1782
|
+
except Exception as e:
|
|
1783
|
+
return {"success": False, "error": f"Failed to parse release JSON: {str(e)}"}
|
|
1784
|
+
|
|
1785
|
+
|
|
1786
|
+
@mcp.tool()
|
|
1787
|
+
def gh_pr_review_comments(
|
|
1788
|
+
pr_number: int,
|
|
1789
|
+
limit: int = 30,
|
|
1790
|
+
repo_path: Optional[str] = None
|
|
1791
|
+
) -> Dict[str, Any]:
|
|
1792
|
+
"""
|
|
1793
|
+
[Advanced Tool] Retrieve inline code review comments and discussion threads on a Pull Request.
|
|
1794
|
+
|
|
1795
|
+
Args:
|
|
1796
|
+
pr_number: Pull request number.
|
|
1797
|
+
limit: Maximum number of review comments to return (default 30, max 60).
|
|
1798
|
+
repo_path: Optional repository workspace path.
|
|
1799
|
+
"""
|
|
1800
|
+
if not is_gh_installed():
|
|
1801
|
+
return get_gh_missing_guidance()
|
|
1802
|
+
|
|
1803
|
+
cwd = resolve_repo_path(repo_path)
|
|
1804
|
+
lim = min(max(1, limit), 60)
|
|
1805
|
+
|
|
1806
|
+
# Query PR review comments via gh api
|
|
1807
|
+
remote_info = get_remote_github_info(cwd)
|
|
1808
|
+
if not remote_info or not remote_info.get("owner") or not remote_info.get("repo"):
|
|
1809
|
+
return {"success": False, "error": "Could not determine GitHub owner/repo from git remote origin."}
|
|
1810
|
+
|
|
1811
|
+
owner = remote_info["owner"]
|
|
1812
|
+
repo = remote_info["repo"]
|
|
1813
|
+
endpoint = f"repos/{owner}/{repo}/pulls/{pr_number}/comments?per_page={lim}"
|
|
1814
|
+
|
|
1815
|
+
res = run_gh_command(["api", endpoint], cwd=cwd)
|
|
1816
|
+
if not res.get("success"):
|
|
1817
|
+
return {"success": False, "error": res.get("stderr") or res.get("stdout")}
|
|
1818
|
+
|
|
1819
|
+
try:
|
|
1820
|
+
raw_comments = json.loads(res.get("stdout", "[]"))
|
|
1821
|
+
cleaned_comments = []
|
|
1822
|
+
for c in raw_comments:
|
|
1823
|
+
cleaned_comments.append({
|
|
1824
|
+
"id": c.get("id"),
|
|
1825
|
+
"path": c.get("path"),
|
|
1826
|
+
"line": c.get("line") or c.get("original_line"),
|
|
1827
|
+
"author": c.get("user", {}).get("login"),
|
|
1828
|
+
"body": (c.get("body") or "")[:500],
|
|
1829
|
+
"created_at": c.get("created_at"),
|
|
1830
|
+
"url": c.get("html_url")
|
|
1831
|
+
})
|
|
1832
|
+
|
|
1833
|
+
return {
|
|
1834
|
+
"success": True,
|
|
1835
|
+
"pr_number": pr_number,
|
|
1836
|
+
"comments_count": len(cleaned_comments),
|
|
1837
|
+
"comments": cleaned_comments
|
|
1838
|
+
}
|
|
1839
|
+
except Exception as e:
|
|
1840
|
+
return {"success": False, "error": f"Failed to parse review comments: {str(e)}"}
|
|
1841
|
+
|
|
1842
|
+
|
|
1843
|
+
@mcp.tool()
|
|
1844
|
+
def gh_api(
|
|
1845
|
+
endpoint: str,
|
|
1846
|
+
method: str = "GET",
|
|
1847
|
+
fields: Optional[Dict[str, Any]] = None,
|
|
1848
|
+
max_chars: int = 15000,
|
|
1849
|
+
repo_path: Optional[str] = None
|
|
1850
|
+
) -> Dict[str, Any]:
|
|
1851
|
+
"""
|
|
1852
|
+
Direct GitHub REST/GraphQL API caller via 'gh api' with automatic context window protection.
|
|
1853
|
+
|
|
1854
|
+
Args:
|
|
1855
|
+
endpoint: GitHub API path (e.g. 'user', 'repos/:owner/:repo/commits').
|
|
1856
|
+
method: HTTP method (default 'GET').
|
|
1857
|
+
fields: Optional dictionary of query/body parameters.
|
|
1858
|
+
max_chars: Maximum character limit on response payload to preserve context (default 15000).
|
|
1859
|
+
repo_path: Optional repository workspace path.
|
|
1860
|
+
"""
|
|
1861
|
+
if not is_gh_installed():
|
|
1862
|
+
return get_gh_missing_guidance()
|
|
1863
|
+
|
|
1864
|
+
cwd = resolve_repo_path(repo_path)
|
|
1865
|
+
args = ["api", endpoint, "-X", method.upper()]
|
|
1866
|
+
if fields:
|
|
1867
|
+
for k, v in fields.items():
|
|
1868
|
+
args += ["-F", f"{k}={v}"]
|
|
1869
|
+
|
|
1870
|
+
res = run_gh_command(args, cwd=cwd)
|
|
1871
|
+
if not res.get("success"):
|
|
1872
|
+
return {"success": False, "error": res.get("stderr") or res.get("stdout")}
|
|
1873
|
+
|
|
1874
|
+
raw_out = res.get("stdout", "")
|
|
1875
|
+
capped = _truncate_output(raw_out, max_lines=250, max_chars=max_chars)
|
|
1876
|
+
|
|
1877
|
+
try:
|
|
1878
|
+
data = json.loads(capped["content"]) if not capped["truncated"] else json.loads(raw_out[:max_chars])
|
|
1879
|
+
return {
|
|
1880
|
+
"success": True,
|
|
1881
|
+
"truncated": capped["truncated"],
|
|
1882
|
+
"response": data,
|
|
1883
|
+
"notice": capped.get("notice")
|
|
1884
|
+
}
|
|
1885
|
+
except Exception:
|
|
1886
|
+
return {
|
|
1887
|
+
"success": True,
|
|
1888
|
+
"truncated": capped["truncated"],
|
|
1889
|
+
"raw_response": capped["content"],
|
|
1890
|
+
"notice": capped.get("notice")
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
|
|
1894
|
+
# ==============================================================================
|
|
1895
|
+
# Standalone execution
|
|
1896
|
+
# ==============================================================================
|
|
1897
|
+
|
|
1898
|
+
if __name__ == "__main__":
|
|
1899
|
+
mcp.run()
|