canopy-cli 3.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.
- canopy/__init__.py +2 -0
- canopy/actions/__init__.py +32 -0
- canopy/actions/aliases.py +421 -0
- canopy/actions/augments.py +55 -0
- canopy/actions/bootstrap.py +249 -0
- canopy/actions/bot_resolutions.py +123 -0
- canopy/actions/bot_status.py +133 -0
- canopy/actions/commit.py +511 -0
- canopy/actions/conflicts.py +314 -0
- canopy/actions/doctor.py +1459 -0
- canopy/actions/draft_replies.py +185 -0
- canopy/actions/drift.py +241 -0
- canopy/actions/errors.py +115 -0
- canopy/actions/evacuate.py +192 -0
- canopy/actions/feature_state.py +607 -0
- canopy/actions/historian.py +612 -0
- canopy/actions/ide_workspace.py +49 -0
- canopy/actions/last_visit.py +83 -0
- canopy/actions/migrate_slots.py +313 -0
- canopy/actions/preflight_state.py +97 -0
- canopy/actions/push.py +199 -0
- canopy/actions/reads.py +304 -0
- canopy/actions/resume.py +582 -0
- canopy/actions/review_filter.py +135 -0
- canopy/actions/ship.py +399 -0
- canopy/actions/slot_details.py +208 -0
- canopy/actions/slot_load.py +383 -0
- canopy/actions/slots.py +221 -0
- canopy/actions/stash.py +230 -0
- canopy/actions/switch.py +775 -0
- canopy/actions/switch_preflight.py +192 -0
- canopy/actions/thread_actions.py +88 -0
- canopy/actions/thread_resolutions.py +101 -0
- canopy/actions/triage.py +286 -0
- canopy/agent/__init__.py +5 -0
- canopy/agent/runner.py +129 -0
- canopy/agent_setup/__init__.py +264 -0
- canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
- canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
- canopy/cli/__init__.py +0 -0
- canopy/cli/main.py +4152 -0
- canopy/cli/render.py +98 -0
- canopy/cli/ui.py +150 -0
- canopy/features/__init__.py +2 -0
- canopy/features/coordinator.py +1256 -0
- canopy/git/__init__.py +0 -0
- canopy/git/hooks.py +173 -0
- canopy/git/multi.py +435 -0
- canopy/git/repo.py +859 -0
- canopy/git/templates/post-checkout.py +67 -0
- canopy/graph/__init__.py +0 -0
- canopy/integrations/__init__.py +0 -0
- canopy/integrations/github.py +983 -0
- canopy/integrations/linear.py +307 -0
- canopy/integrations/precommit.py +239 -0
- canopy/mcp/__init__.py +0 -0
- canopy/mcp/client.py +329 -0
- canopy/mcp/server.py +1797 -0
- canopy/providers/__init__.py +105 -0
- canopy/providers/github_issues.py +289 -0
- canopy/providers/linear.py +341 -0
- canopy/providers/types.py +149 -0
- canopy/workspace/__init__.py +4 -0
- canopy/workspace/config.py +378 -0
- canopy/workspace/context.py +224 -0
- canopy/workspace/discovery.py +197 -0
- canopy/workspace/workspace.py +173 -0
- canopy_cli-3.1.0.dist-info/METADATA +282 -0
- canopy_cli-3.1.0.dist-info/RECORD +71 -0
- canopy_cli-3.1.0.dist-info/WHEEL +4 -0
- canopy_cli-3.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,983 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GitHub integration via MCP, with gh CLI fallback.
|
|
3
|
+
|
|
4
|
+
Fetches PR data and review comments from a GitHub MCP server configured
|
|
5
|
+
in .canopy/mcps.json when available, falling back to the user's local
|
|
6
|
+
``gh`` CLI when MCP isn't configured. Same return shapes either way so
|
|
7
|
+
upstream callers don't branch.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import platform
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ..mcp.client import (
|
|
20
|
+
get_mcp_config,
|
|
21
|
+
is_mcp_configured,
|
|
22
|
+
call_tool,
|
|
23
|
+
McpClientError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GitHubNotConfiguredError(Exception):
|
|
28
|
+
"""Neither GitHub MCP nor authenticated gh CLI is available.
|
|
29
|
+
|
|
30
|
+
Carries the same structured payload that ``github_unavailable_blocker()``
|
|
31
|
+
returns so upstream callers (e.g. triage) can convert to a BlockerError
|
|
32
|
+
with proper ``fix_actions`` without re-deriving install hints.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str = "", *, payload: dict | None = None):
|
|
36
|
+
super().__init__(message or (payload or {}).get("what", "GitHub not configured"))
|
|
37
|
+
self.payload = payload or {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PullRequestNotFoundError(Exception):
|
|
41
|
+
"""No pull request found for the given branch."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_github_config(workspace_root: Path) -> dict:
|
|
45
|
+
"""Get GitHub MCP config, raising if not configured."""
|
|
46
|
+
config = get_mcp_config(workspace_root, "github")
|
|
47
|
+
if config is None:
|
|
48
|
+
raise GitHubNotConfiguredError(
|
|
49
|
+
"GitHub MCP not configured.\n"
|
|
50
|
+
"Add a 'github' entry to .canopy/mcps.json:\n"
|
|
51
|
+
" {\n"
|
|
52
|
+
' "github": {\n'
|
|
53
|
+
' "command": "npx",\n'
|
|
54
|
+
' "args": ["-y", "@modelcontextprotocol/server-github"],\n'
|
|
55
|
+
' "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_..."}\n'
|
|
56
|
+
" }\n"
|
|
57
|
+
" }"
|
|
58
|
+
)
|
|
59
|
+
return config
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_github_configured(workspace_root: Path) -> bool:
|
|
63
|
+
"""Check if GitHub access is available — MCP first, gh CLI as fallback."""
|
|
64
|
+
return is_mcp_configured(workspace_root, "github") or have_gh_cli()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def have_gh_cli() -> bool:
|
|
68
|
+
"""True if the gh CLI is installed and authenticated."""
|
|
69
|
+
if shutil.which("gh") is None:
|
|
70
|
+
return False
|
|
71
|
+
result = subprocess.run(
|
|
72
|
+
["gh", "auth", "status"], capture_output=True, text=True, check=False,
|
|
73
|
+
)
|
|
74
|
+
return result.returncode == 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def gh_install_hint() -> str:
|
|
78
|
+
"""Platform-aware install instructions for gh CLI."""
|
|
79
|
+
system = platform.system()
|
|
80
|
+
if system == "Darwin":
|
|
81
|
+
return "brew install gh && gh auth login"
|
|
82
|
+
if system == "Linux":
|
|
83
|
+
return (
|
|
84
|
+
"Install gh from https://github.com/cli/cli#installation "
|
|
85
|
+
"(`apt install gh` on Debian/Ubuntu, `dnf install gh` on Fedora), "
|
|
86
|
+
"then run `gh auth login`."
|
|
87
|
+
)
|
|
88
|
+
if system == "Windows":
|
|
89
|
+
return "winget install --id GitHub.cli && gh auth login"
|
|
90
|
+
return "See https://github.com/cli/cli#installation, then run `gh auth login`."
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def gh_status_hint() -> str:
|
|
94
|
+
"""Hint when gh is installed but not authenticated."""
|
|
95
|
+
return "Run `gh auth login` to authenticate."
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def github_unavailable_blocker() -> dict:
|
|
99
|
+
"""Build a structured no-github-access dict that callers can wrap into BlockerError.
|
|
100
|
+
|
|
101
|
+
Returns ``{code, what, fix_actions}`` matching the action contract. Use
|
|
102
|
+
when neither the GitHub MCP server is configured nor the gh CLI is
|
|
103
|
+
available. Tells the user how to fix it on their platform.
|
|
104
|
+
"""
|
|
105
|
+
have_gh_binary = shutil.which("gh") is not None
|
|
106
|
+
actions = []
|
|
107
|
+
if have_gh_binary:
|
|
108
|
+
# gh installed but not authed
|
|
109
|
+
actions.append({
|
|
110
|
+
"action": "gh auth login",
|
|
111
|
+
"args": {},
|
|
112
|
+
"safe": True,
|
|
113
|
+
"preview": gh_status_hint(),
|
|
114
|
+
})
|
|
115
|
+
else:
|
|
116
|
+
actions.append({
|
|
117
|
+
"action": "install gh CLI",
|
|
118
|
+
"args": {},
|
|
119
|
+
"safe": True,
|
|
120
|
+
"preview": gh_install_hint(),
|
|
121
|
+
})
|
|
122
|
+
actions.append({
|
|
123
|
+
"action": "configure github MCP",
|
|
124
|
+
"args": {},
|
|
125
|
+
"safe": True,
|
|
126
|
+
"preview": (
|
|
127
|
+
"Add a 'github' entry to .canopy/mcps.json with command/args/env "
|
|
128
|
+
"for an MCP server (e.g. @modelcontextprotocol/server-github)"
|
|
129
|
+
),
|
|
130
|
+
})
|
|
131
|
+
return {
|
|
132
|
+
"code": "github_not_configured",
|
|
133
|
+
"what": (
|
|
134
|
+
"GitHub access not configured. Either install + auth gh CLI, "
|
|
135
|
+
"or configure the github MCP server in .canopy/mcps.json."
|
|
136
|
+
),
|
|
137
|
+
"fix_actions": actions,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _gh(args: list[str], timeout: float = 15.0) -> str:
|
|
142
|
+
"""Run gh and return stdout. Raises GitHubNotConfiguredError on failure."""
|
|
143
|
+
try:
|
|
144
|
+
proc = subprocess.run(
|
|
145
|
+
["gh"] + args, capture_output=True, text=True,
|
|
146
|
+
timeout=timeout, check=False,
|
|
147
|
+
)
|
|
148
|
+
except FileNotFoundError as e:
|
|
149
|
+
raise GitHubNotConfiguredError(f"gh CLI not on PATH: {e}")
|
|
150
|
+
except subprocess.TimeoutExpired as e:
|
|
151
|
+
raise GitHubNotConfiguredError(f"gh CLI timed out: {' '.join(args)}")
|
|
152
|
+
if proc.returncode != 0:
|
|
153
|
+
raise GitHubNotConfiguredError(
|
|
154
|
+
f"gh {' '.join(args)} failed: {proc.stderr.strip() or proc.stdout.strip()}"
|
|
155
|
+
)
|
|
156
|
+
return proc.stdout
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _parse_mcp_result(result: Any) -> Any:
|
|
160
|
+
"""Extract data from an MCP tool call result.
|
|
161
|
+
|
|
162
|
+
MCP results come as a CallToolResult with .content blocks.
|
|
163
|
+
GitHub MCP tools typically return a single text block with JSON.
|
|
164
|
+
"""
|
|
165
|
+
if result is None:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
for block in result.content:
|
|
169
|
+
if hasattr(block, "text") and block.text:
|
|
170
|
+
text = block.text.strip()
|
|
171
|
+
if text.startswith("{") or text.startswith("["):
|
|
172
|
+
try:
|
|
173
|
+
return json.loads(text)
|
|
174
|
+
except json.JSONDecodeError:
|
|
175
|
+
pass
|
|
176
|
+
return {"raw": text}
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _extract_owner_repo(remote_url: str) -> tuple[str, str] | None:
|
|
181
|
+
"""Extract owner/repo from a git remote URL.
|
|
182
|
+
|
|
183
|
+
Handles:
|
|
184
|
+
git@github.com:owner/repo.git
|
|
185
|
+
https://github.com/owner/repo.git
|
|
186
|
+
https://github.com/owner/repo
|
|
187
|
+
"""
|
|
188
|
+
# SSH format
|
|
189
|
+
m = re.match(r"git@github\.com:([^/]+)/([^/.]+?)(?:\.git)?$", remote_url)
|
|
190
|
+
if m:
|
|
191
|
+
return m.group(1), m.group(2)
|
|
192
|
+
|
|
193
|
+
# HTTPS format
|
|
194
|
+
m = re.match(r"https?://github\.com/([^/]+)/([^/.]+?)(?:\.git)?$", remote_url)
|
|
195
|
+
if m:
|
|
196
|
+
return m.group(1), m.group(2)
|
|
197
|
+
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def find_pull_request(
|
|
202
|
+
workspace_root: Path,
|
|
203
|
+
owner: str,
|
|
204
|
+
repo: str,
|
|
205
|
+
branch: str,
|
|
206
|
+
) -> dict | None:
|
|
207
|
+
"""Find an open PR for a branch in a repo. Returns None if not found.
|
|
208
|
+
|
|
209
|
+
Tries the configured GitHub MCP server first, then falls back to
|
|
210
|
+
``gh pr list``. Same dict shape either way: at minimum ``number,
|
|
211
|
+
title, url, state, head_branch``.
|
|
212
|
+
"""
|
|
213
|
+
if is_mcp_configured(workspace_root, "github"):
|
|
214
|
+
config = _get_github_config(workspace_root)
|
|
215
|
+
tool_attempts = [
|
|
216
|
+
("list_pull_requests", {
|
|
217
|
+
"owner": owner, "repo": repo,
|
|
218
|
+
"head": f"{owner}:{branch}", "state": "open",
|
|
219
|
+
}),
|
|
220
|
+
("search_pull_requests", {
|
|
221
|
+
"owner": owner, "repo": repo,
|
|
222
|
+
"head": branch, "state": "open",
|
|
223
|
+
}),
|
|
224
|
+
("list_pull_requests", {
|
|
225
|
+
"owner": owner, "repo": repo, "state": "open",
|
|
226
|
+
}),
|
|
227
|
+
]
|
|
228
|
+
for tool_name, args in tool_attempts:
|
|
229
|
+
try:
|
|
230
|
+
result = call_tool(config, tool_name, args, timeout=15.0, server_name="github")
|
|
231
|
+
parsed = _parse_mcp_result(result)
|
|
232
|
+
if parsed is None:
|
|
233
|
+
continue
|
|
234
|
+
prs = _extract_prs(parsed, branch)
|
|
235
|
+
if prs:
|
|
236
|
+
return _normalize_pr(prs[0])
|
|
237
|
+
except McpClientError:
|
|
238
|
+
continue
|
|
239
|
+
# MCP configured but didn't find anything — don't fall back; treat as
|
|
240
|
+
# authoritative "no PR" to avoid double-querying.
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
if have_gh_cli():
|
|
244
|
+
try:
|
|
245
|
+
output = _gh([
|
|
246
|
+
"pr", "list",
|
|
247
|
+
"--repo", f"{owner}/{repo}",
|
|
248
|
+
"--head", branch, "--state", "open",
|
|
249
|
+
"--json", "number,title,url,state,headRefName,body",
|
|
250
|
+
"--limit", "5",
|
|
251
|
+
])
|
|
252
|
+
data = json.loads(output) if output.strip() else []
|
|
253
|
+
if data:
|
|
254
|
+
pr = data[0]
|
|
255
|
+
return _normalize_pr({
|
|
256
|
+
"number": pr.get("number"),
|
|
257
|
+
"title": pr.get("title"),
|
|
258
|
+
"html_url": pr.get("url"),
|
|
259
|
+
"state": (pr.get("state") or "open").lower(),
|
|
260
|
+
"head": {"ref": pr.get("headRefName")},
|
|
261
|
+
"body": pr.get("body") or "",
|
|
262
|
+
})
|
|
263
|
+
except (GitHubNotConfiguredError, json.JSONDecodeError):
|
|
264
|
+
pass
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
payload = github_unavailable_blocker()
|
|
268
|
+
raise GitHubNotConfiguredError(payload=payload)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def get_pull_request_by_number(
|
|
272
|
+
workspace_root: Path,
|
|
273
|
+
owner: str,
|
|
274
|
+
repo: str,
|
|
275
|
+
pr_number: int,
|
|
276
|
+
) -> dict | None:
|
|
277
|
+
"""Fetch a specific PR by number. Returns None if not found.
|
|
278
|
+
|
|
279
|
+
MCP first; gh fallback. Same return shape as ``find_pull_request``,
|
|
280
|
+
plus ``base_branch``, ``mergeable``, ``draft``, ``review_decision``.
|
|
281
|
+
"""
|
|
282
|
+
if is_mcp_configured(workspace_root, "github"):
|
|
283
|
+
config = _get_github_config(workspace_root)
|
|
284
|
+
for tool_name, args in [
|
|
285
|
+
("get_pull_request", {"owner": owner, "repo": repo, "pull_number": pr_number}),
|
|
286
|
+
("pull_request_get", {"owner": owner, "repo": repo, "pull_number": pr_number}),
|
|
287
|
+
]:
|
|
288
|
+
try:
|
|
289
|
+
result = call_tool(config, tool_name, args, timeout=15.0, server_name="github")
|
|
290
|
+
parsed = _parse_mcp_result(result)
|
|
291
|
+
if parsed:
|
|
292
|
+
return _normalize_pr(parsed)
|
|
293
|
+
except McpClientError:
|
|
294
|
+
continue
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
if have_gh_cli():
|
|
298
|
+
try:
|
|
299
|
+
output = _gh([
|
|
300
|
+
"pr", "view", str(pr_number),
|
|
301
|
+
"--repo", f"{owner}/{repo}",
|
|
302
|
+
"--json", "number,title,url,state,headRefName,baseRefName,body,reviewDecision,mergeable,isDraft",
|
|
303
|
+
])
|
|
304
|
+
pr = json.loads(output) if output.strip() else None
|
|
305
|
+
if pr:
|
|
306
|
+
return _normalize_pr({
|
|
307
|
+
"number": pr.get("number"),
|
|
308
|
+
"title": pr.get("title"),
|
|
309
|
+
"html_url": pr.get("url"),
|
|
310
|
+
"state": (pr.get("state") or "open").lower(),
|
|
311
|
+
"head": {"ref": pr.get("headRefName")},
|
|
312
|
+
"base": {"ref": pr.get("baseRefName")},
|
|
313
|
+
"body": pr.get("body") or "",
|
|
314
|
+
"review_decision": pr.get("reviewDecision") or "",
|
|
315
|
+
"mergeable": pr.get("mergeable") or "",
|
|
316
|
+
"draft": bool(pr.get("isDraft")),
|
|
317
|
+
})
|
|
318
|
+
except (GitHubNotConfiguredError, json.JSONDecodeError):
|
|
319
|
+
pass
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
payload = github_unavailable_blocker()
|
|
323
|
+
raise GitHubNotConfiguredError(payload=payload)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def list_open_prs(
|
|
327
|
+
workspace_root: Path,
|
|
328
|
+
owner: str,
|
|
329
|
+
repo: str,
|
|
330
|
+
author: str | None = None,
|
|
331
|
+
limit: int = 50,
|
|
332
|
+
) -> list[dict]:
|
|
333
|
+
"""List open PRs in a repo, optionally filtered by author.
|
|
334
|
+
|
|
335
|
+
MCP first; gh fallback. Each entry: ``{number, title, url, state,
|
|
336
|
+
head_branch, review_decision, body}``.
|
|
337
|
+
"""
|
|
338
|
+
if is_mcp_configured(workspace_root, "github"):
|
|
339
|
+
config = _get_github_config(workspace_root)
|
|
340
|
+
args = {"owner": owner, "repo": repo, "state": "open"}
|
|
341
|
+
if author:
|
|
342
|
+
args["author"] = author
|
|
343
|
+
for tool_name in ("list_pull_requests", "search_pull_requests"):
|
|
344
|
+
try:
|
|
345
|
+
result = call_tool(config, tool_name, args, timeout=15.0, server_name="github")
|
|
346
|
+
parsed = _parse_mcp_result(result)
|
|
347
|
+
if parsed is None:
|
|
348
|
+
continue
|
|
349
|
+
prs = parsed if isinstance(parsed, list) else (
|
|
350
|
+
parsed.get("pull_requests") or parsed.get("items")
|
|
351
|
+
or parsed.get("data") or []
|
|
352
|
+
)
|
|
353
|
+
if isinstance(prs, list):
|
|
354
|
+
return [_normalize_pr(p) for p in prs[:limit]]
|
|
355
|
+
except McpClientError:
|
|
356
|
+
continue
|
|
357
|
+
return []
|
|
358
|
+
|
|
359
|
+
if have_gh_cli():
|
|
360
|
+
try:
|
|
361
|
+
cli_args = [
|
|
362
|
+
"pr", "list", "--repo", f"{owner}/{repo}",
|
|
363
|
+
"--state", "open", "--limit", str(limit),
|
|
364
|
+
"--json", "number,title,url,state,headRefName,body,reviewDecision",
|
|
365
|
+
]
|
|
366
|
+
if author:
|
|
367
|
+
cli_args.extend(["--author", author])
|
|
368
|
+
output = _gh(cli_args)
|
|
369
|
+
data = json.loads(output) if output.strip() else []
|
|
370
|
+
return [
|
|
371
|
+
_normalize_pr({
|
|
372
|
+
"number": pr.get("number"),
|
|
373
|
+
"title": pr.get("title"),
|
|
374
|
+
"html_url": pr.get("url"),
|
|
375
|
+
"state": (pr.get("state") or "open").lower(),
|
|
376
|
+
"head": {"ref": pr.get("headRefName")},
|
|
377
|
+
"body": pr.get("body") or "",
|
|
378
|
+
"review_decision": pr.get("reviewDecision") or "",
|
|
379
|
+
})
|
|
380
|
+
for pr in data
|
|
381
|
+
]
|
|
382
|
+
except (GitHubNotConfiguredError, json.JSONDecodeError):
|
|
383
|
+
return []
|
|
384
|
+
|
|
385
|
+
payload = github_unavailable_blocker()
|
|
386
|
+
raise GitHubNotConfiguredError(payload=payload)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def create_pr(
|
|
390
|
+
workspace_root: Path,
|
|
391
|
+
owner: str,
|
|
392
|
+
repo: str,
|
|
393
|
+
*,
|
|
394
|
+
branch: str,
|
|
395
|
+
base: str,
|
|
396
|
+
title: str,
|
|
397
|
+
body: str,
|
|
398
|
+
draft: bool = False,
|
|
399
|
+
reviewers: list[str] | None = None,
|
|
400
|
+
) -> dict:
|
|
401
|
+
"""Open a pull request. Returns ``{number, url, state}`` on success.
|
|
402
|
+
|
|
403
|
+
Tries the configured GitHub MCP server first, falls back to ``gh pr
|
|
404
|
+
create``. Raises ``GitHubNotConfiguredError`` if neither is available.
|
|
405
|
+
"""
|
|
406
|
+
if is_mcp_configured(workspace_root, "github"):
|
|
407
|
+
config = _get_github_config(workspace_root)
|
|
408
|
+
args = {
|
|
409
|
+
"owner": owner, "repo": repo,
|
|
410
|
+
"head": branch, "base": base,
|
|
411
|
+
"title": title, "body": body, "draft": draft,
|
|
412
|
+
}
|
|
413
|
+
for tool_name in ("create_pull_request", "pull_request_create"):
|
|
414
|
+
try:
|
|
415
|
+
result = call_tool(config, tool_name, args, timeout=30.0, server_name="github")
|
|
416
|
+
parsed = _parse_mcp_result(result)
|
|
417
|
+
if parsed:
|
|
418
|
+
pr = _normalize_pr(parsed)
|
|
419
|
+
if reviewers:
|
|
420
|
+
_request_reviewers_via_gh(owner, repo, pr["number"], reviewers)
|
|
421
|
+
return pr
|
|
422
|
+
except McpClientError:
|
|
423
|
+
continue
|
|
424
|
+
# MCP configured but failed — fall through to gh below for resilience.
|
|
425
|
+
|
|
426
|
+
if have_gh_cli():
|
|
427
|
+
cli_args = [
|
|
428
|
+
"pr", "create",
|
|
429
|
+
"--repo", f"{owner}/{repo}",
|
|
430
|
+
"--head", branch, "--base", base,
|
|
431
|
+
"--title", title, "--body", body,
|
|
432
|
+
]
|
|
433
|
+
if draft:
|
|
434
|
+
cli_args.append("--draft")
|
|
435
|
+
if reviewers:
|
|
436
|
+
cli_args += ["--reviewer", ",".join(reviewers)]
|
|
437
|
+
# gh pr create prints the PR URL on stdout (last non-empty line).
|
|
438
|
+
output = _gh(cli_args, timeout=30.0)
|
|
439
|
+
url = next((line for line in reversed(output.splitlines()) if line.strip()), "")
|
|
440
|
+
match = re.search(r"/pull/(\d+)", url)
|
|
441
|
+
pr_number = int(match.group(1)) if match else 0
|
|
442
|
+
if pr_number:
|
|
443
|
+
pr = get_pull_request_by_number(workspace_root, owner, repo, pr_number)
|
|
444
|
+
if pr:
|
|
445
|
+
return pr
|
|
446
|
+
return {"number": pr_number, "url": url.strip(), "state": "open"}
|
|
447
|
+
|
|
448
|
+
raise GitHubNotConfiguredError(payload=github_unavailable_blocker())
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def update_pr_body(
|
|
452
|
+
workspace_root: Path,
|
|
453
|
+
owner: str,
|
|
454
|
+
repo: str,
|
|
455
|
+
pr_number: int,
|
|
456
|
+
body: str,
|
|
457
|
+
) -> None:
|
|
458
|
+
"""Update an existing PR's body. Raises if both backends fail."""
|
|
459
|
+
if is_mcp_configured(workspace_root, "github"):
|
|
460
|
+
config = _get_github_config(workspace_root)
|
|
461
|
+
for tool_name in ("update_pull_request", "pull_request_update"):
|
|
462
|
+
try:
|
|
463
|
+
call_tool(
|
|
464
|
+
config, tool_name,
|
|
465
|
+
{"owner": owner, "repo": repo,
|
|
466
|
+
"pull_number": pr_number, "body": body},
|
|
467
|
+
timeout=15.0, server_name="github",
|
|
468
|
+
)
|
|
469
|
+
return
|
|
470
|
+
except McpClientError:
|
|
471
|
+
continue
|
|
472
|
+
|
|
473
|
+
if have_gh_cli():
|
|
474
|
+
_gh([
|
|
475
|
+
"pr", "edit", str(pr_number),
|
|
476
|
+
"--repo", f"{owner}/{repo}",
|
|
477
|
+
"--body", body,
|
|
478
|
+
], timeout=15.0)
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
raise GitHubNotConfiguredError(payload=github_unavailable_blocker())
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _request_reviewers_via_gh(
|
|
485
|
+
owner: str, repo: str, pr_number: int, reviewers: list[str],
|
|
486
|
+
) -> None:
|
|
487
|
+
"""Best-effort reviewer request via gh; silent on failure since the PR
|
|
488
|
+
is already open by the time we get here."""
|
|
489
|
+
if not have_gh_cli() or not reviewers:
|
|
490
|
+
return
|
|
491
|
+
try:
|
|
492
|
+
_gh([
|
|
493
|
+
"pr", "edit", str(pr_number),
|
|
494
|
+
"--repo", f"{owner}/{repo}",
|
|
495
|
+
"--add-reviewer", ",".join(reviewers),
|
|
496
|
+
], timeout=15.0)
|
|
497
|
+
except GitHubNotConfiguredError:
|
|
498
|
+
pass
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def get_pr_checks(
|
|
502
|
+
workspace_root: Path,
|
|
503
|
+
owner: str,
|
|
504
|
+
repo: str,
|
|
505
|
+
pr_number: int,
|
|
506
|
+
) -> tuple[dict, list[dict]]:
|
|
507
|
+
"""Fetch CI check runs for a PR (M10).
|
|
508
|
+
|
|
509
|
+
Returns ``(rolled_up_status, raw_check_list)``.
|
|
510
|
+
|
|
511
|
+
The roll-up shape mirrors the plan's spec::
|
|
512
|
+
|
|
513
|
+
{status: "passing"|"failing"|"pending"|"no_checks",
|
|
514
|
+
passed: int, failing: int, pending: int, skipped: int,
|
|
515
|
+
required_failing: [<name>...], required_pending: [<name>...],
|
|
516
|
+
details_url: str}
|
|
517
|
+
|
|
518
|
+
v1 sources from ``gh pr checks --json``. The MCP path is reserved for
|
|
519
|
+
when a github-mcp tool actually exposes check-run shape (the standard
|
|
520
|
+
server doesn't yet). Failure (gh missing, PR not found) returns the
|
|
521
|
+
sentinel ``{status: "no_checks"}`` rather than raising — CI is a
|
|
522
|
+
nice-to-have signal; we don't want it to brick ``feature_state``.
|
|
523
|
+
"""
|
|
524
|
+
if not have_gh_cli():
|
|
525
|
+
return {"status": "no_checks"}, []
|
|
526
|
+
try:
|
|
527
|
+
output = _gh([
|
|
528
|
+
"pr", "checks", str(pr_number),
|
|
529
|
+
"--repo", f"{owner}/{repo}",
|
|
530
|
+
"--json", "name,state,bucket,description,workflow,link,startedAt,completedAt",
|
|
531
|
+
], timeout=20.0)
|
|
532
|
+
except GitHubNotConfiguredError:
|
|
533
|
+
return {"status": "no_checks"}, []
|
|
534
|
+
try:
|
|
535
|
+
raw = json.loads(output) if output.strip() else []
|
|
536
|
+
except json.JSONDecodeError:
|
|
537
|
+
return {"status": "no_checks"}, []
|
|
538
|
+
if not isinstance(raw, list) or not raw:
|
|
539
|
+
return {"status": "no_checks"}, []
|
|
540
|
+
|
|
541
|
+
rollup = _rollup_checks(raw, owner=owner, repo=repo, pr_number=pr_number)
|
|
542
|
+
return rollup, raw
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _rollup_checks(
|
|
546
|
+
raw: list[dict], *, owner: str, repo: str, pr_number: int,
|
|
547
|
+
) -> dict:
|
|
548
|
+
"""Reduce a list of check runs to the rolled-up status shape.
|
|
549
|
+
|
|
550
|
+
Bucket → state mapping (gh's ``bucket`` field is normalized):
|
|
551
|
+
pass → counted as passed
|
|
552
|
+
fail → counted as failing
|
|
553
|
+
pending → counted as pending
|
|
554
|
+
cancel → counted as failing (a cancelled required check blocks)
|
|
555
|
+
skipping→ counted as skipped (informational)
|
|
556
|
+
"""
|
|
557
|
+
passed = failing = pending = skipped = 0
|
|
558
|
+
failing_names: list[str] = []
|
|
559
|
+
pending_names: list[str] = []
|
|
560
|
+
for c in raw:
|
|
561
|
+
bucket = (c.get("bucket") or "").lower()
|
|
562
|
+
name = c.get("name") or ""
|
|
563
|
+
if bucket in ("pass", "success"):
|
|
564
|
+
passed += 1
|
|
565
|
+
elif bucket in ("fail", "failure", "cancel", "cancelled"):
|
|
566
|
+
failing += 1
|
|
567
|
+
if name:
|
|
568
|
+
failing_names.append(name)
|
|
569
|
+
elif bucket in ("pending", "queued", "running", "in_progress"):
|
|
570
|
+
pending += 1
|
|
571
|
+
if name:
|
|
572
|
+
pending_names.append(name)
|
|
573
|
+
elif bucket in ("skipping", "skipped", "neutral"):
|
|
574
|
+
skipped += 1
|
|
575
|
+
else:
|
|
576
|
+
# Unknown bucket — count as pending so we wait rather than
|
|
577
|
+
# claim passing.
|
|
578
|
+
pending += 1
|
|
579
|
+
if name:
|
|
580
|
+
pending_names.append(name)
|
|
581
|
+
|
|
582
|
+
if failing > 0:
|
|
583
|
+
status = "failing"
|
|
584
|
+
elif pending > 0:
|
|
585
|
+
status = "pending"
|
|
586
|
+
elif passed > 0:
|
|
587
|
+
status = "passing"
|
|
588
|
+
else:
|
|
589
|
+
status = "no_checks"
|
|
590
|
+
|
|
591
|
+
return {
|
|
592
|
+
"status": status,
|
|
593
|
+
"passed": passed,
|
|
594
|
+
"failing": failing,
|
|
595
|
+
"pending": pending,
|
|
596
|
+
"skipped": skipped,
|
|
597
|
+
# v1 doesn't query branch protection — every check is treated as
|
|
598
|
+
# "required" for state-machine purposes. Conservative: false
|
|
599
|
+
# negatives (informational red checks tagged as required) are
|
|
600
|
+
# less harmful than false positives (required check missed).
|
|
601
|
+
"required_failing": failing_names,
|
|
602
|
+
"required_pending": pending_names,
|
|
603
|
+
"details_url": f"https://github.com/{owner}/{repo}/pull/{pr_number}/checks",
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _extract_prs(data: Any, branch: str) -> list[dict]:
|
|
608
|
+
"""Extract PR list from various MCP response shapes, filtering by branch."""
|
|
609
|
+
if isinstance(data, list):
|
|
610
|
+
prs = data
|
|
611
|
+
elif isinstance(data, dict):
|
|
612
|
+
# Some MCPs wrap in {pull_requests: [...]} or {items: [...]}
|
|
613
|
+
prs = (
|
|
614
|
+
data.get("pull_requests")
|
|
615
|
+
or data.get("items")
|
|
616
|
+
or data.get("data")
|
|
617
|
+
or []
|
|
618
|
+
)
|
|
619
|
+
if not isinstance(prs, list):
|
|
620
|
+
prs = [data]
|
|
621
|
+
else:
|
|
622
|
+
return []
|
|
623
|
+
|
|
624
|
+
# Filter to PRs matching the branch
|
|
625
|
+
matched = []
|
|
626
|
+
for pr in prs:
|
|
627
|
+
head = pr.get("head", {})
|
|
628
|
+
head_ref = head.get("ref", "") if isinstance(head, dict) else ""
|
|
629
|
+
if head_ref == branch or pr.get("head_branch") == branch:
|
|
630
|
+
matched.append(pr)
|
|
631
|
+
|
|
632
|
+
# If the initial query already filtered by head, all results match
|
|
633
|
+
if not matched and prs:
|
|
634
|
+
# The API might have already filtered — check if any PR exists
|
|
635
|
+
# that looks right
|
|
636
|
+
for pr in prs:
|
|
637
|
+
head = pr.get("head", {})
|
|
638
|
+
head_ref = head.get("ref", "") if isinstance(head, dict) else ""
|
|
639
|
+
if branch in (head_ref or pr.get("head_branch", "")):
|
|
640
|
+
matched.append(pr)
|
|
641
|
+
|
|
642
|
+
return matched
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _normalize_pr(data: dict) -> dict:
|
|
646
|
+
"""Normalize PR data into a consistent shape across MCP / gh."""
|
|
647
|
+
head = data.get("head") or {}
|
|
648
|
+
head_branch = head.get("ref", "") if isinstance(head, dict) else ""
|
|
649
|
+
if not head_branch:
|
|
650
|
+
head_branch = data.get("head_branch") or ""
|
|
651
|
+
base = data.get("base") or {}
|
|
652
|
+
base_branch = base.get("ref", "") if isinstance(base, dict) else ""
|
|
653
|
+
if not base_branch:
|
|
654
|
+
base_branch = data.get("base_branch") or ""
|
|
655
|
+
return {
|
|
656
|
+
"number": data.get("number") or data.get("id"),
|
|
657
|
+
"title": data.get("title") or "",
|
|
658
|
+
"url": data.get("html_url") or data.get("url") or "",
|
|
659
|
+
"state": data.get("state") or "open",
|
|
660
|
+
"head_branch": head_branch,
|
|
661
|
+
"base_branch": base_branch,
|
|
662
|
+
"body": data.get("body") or "",
|
|
663
|
+
"review_decision": data.get("review_decision") or data.get("reviewDecision") or "",
|
|
664
|
+
"mergeable": data.get("mergeable") or "",
|
|
665
|
+
"draft": bool(data.get("draft") or data.get("isDraft")),
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _graphql_via_mcp(workspace_root: Path, query: str, vars: dict) -> dict:
|
|
670
|
+
"""Run a GraphQL call via the configured GitHub MCP server.
|
|
671
|
+
|
|
672
|
+
Most GitHub MCP servers don't expose raw GraphQL, so this is best-effort.
|
|
673
|
+
Raises McpClientError (or any exception) on failure so callers fall through.
|
|
674
|
+
"""
|
|
675
|
+
config = _get_github_config(workspace_root)
|
|
676
|
+
result = call_tool(
|
|
677
|
+
config, "graphql",
|
|
678
|
+
{"query": query, "variables": vars},
|
|
679
|
+
timeout=15.0, server_name="github",
|
|
680
|
+
)
|
|
681
|
+
parsed = _parse_mcp_result(result)
|
|
682
|
+
if parsed is None:
|
|
683
|
+
raise McpClientError("graphql tool returned no data")
|
|
684
|
+
return parsed
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _graphql_via_gh_cli(query: str, vars: dict) -> dict:
|
|
688
|
+
"""Run a GraphQL call via ``gh api graphql``.
|
|
689
|
+
|
|
690
|
+
Variables are passed as ``-F name=value`` (typed integers stay integers).
|
|
691
|
+
Returns the parsed JSON response body.
|
|
692
|
+
"""
|
|
693
|
+
args = ["gh", "api", "graphql", "-f", f"query={query}"]
|
|
694
|
+
for k, v in vars.items():
|
|
695
|
+
if isinstance(v, bool):
|
|
696
|
+
args.extend(["-F", f"{k}={'true' if v else 'false'}"])
|
|
697
|
+
elif isinstance(v, int):
|
|
698
|
+
args.extend(["-F", f"{k}={v}"])
|
|
699
|
+
else:
|
|
700
|
+
args.extend(["-f", f"{k}={v}"])
|
|
701
|
+
proc = subprocess.run(args, capture_output=True, text=True)
|
|
702
|
+
if proc.returncode != 0:
|
|
703
|
+
raise GitHubNotConfiguredError(
|
|
704
|
+
f"gh api graphql failed: {proc.stderr.strip()}"
|
|
705
|
+
)
|
|
706
|
+
return json.loads(proc.stdout)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _graphql(workspace_root: Path, query: str, **vars) -> dict:
|
|
710
|
+
"""Run a GraphQL call via MCP if configured, otherwise via gh CLI.
|
|
711
|
+
|
|
712
|
+
Returns the parsed JSON response body.
|
|
713
|
+
MCP failure falls through silently to gh CLI.
|
|
714
|
+
"""
|
|
715
|
+
if is_mcp_configured(workspace_root, "github"):
|
|
716
|
+
try:
|
|
717
|
+
return _graphql_via_mcp(workspace_root, query, vars)
|
|
718
|
+
except Exception:
|
|
719
|
+
pass # fall through to gh CLI
|
|
720
|
+
return _graphql_via_gh_cli(query, vars)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def list_review_threads(
|
|
724
|
+
workspace_root: Path,
|
|
725
|
+
owner: str,
|
|
726
|
+
repo: str,
|
|
727
|
+
pr_number: int,
|
|
728
|
+
) -> list[dict]:
|
|
729
|
+
"""Return every review thread on the PR with node IDs and resolution state.
|
|
730
|
+
|
|
731
|
+
GraphQL because REST /pulls/<n>/comments doesn't surface thread IDs.
|
|
732
|
+
|
|
733
|
+
Returns:
|
|
734
|
+
[{thread_id, is_resolved, resolved_at, comments: [{
|
|
735
|
+
comment_id, path, line, body, author, created_at, url
|
|
736
|
+
}]}, ...]
|
|
737
|
+
"""
|
|
738
|
+
query = """
|
|
739
|
+
query($owner: String!, $repo: String!, $number: Int!) {
|
|
740
|
+
repository(owner: $owner, name: $repo) {
|
|
741
|
+
pullRequest(number: $number) {
|
|
742
|
+
reviewThreads(first: 100) {
|
|
743
|
+
nodes {
|
|
744
|
+
id
|
|
745
|
+
isResolved
|
|
746
|
+
resolvedAt
|
|
747
|
+
comments(first: 20) {
|
|
748
|
+
nodes {
|
|
749
|
+
databaseId path line body createdAt url
|
|
750
|
+
author { login __typename }
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
"""
|
|
759
|
+
data = _graphql(workspace_root, query, owner=owner, repo=repo, number=pr_number)
|
|
760
|
+
nodes = (
|
|
761
|
+
data.get("data", {})
|
|
762
|
+
.get("repository", {})
|
|
763
|
+
.get("pullRequest", {})
|
|
764
|
+
.get("reviewThreads", {})
|
|
765
|
+
.get("nodes") or []
|
|
766
|
+
)
|
|
767
|
+
out: list[dict] = []
|
|
768
|
+
for n in nodes:
|
|
769
|
+
comments = [
|
|
770
|
+
{
|
|
771
|
+
"comment_id": c.get("databaseId"),
|
|
772
|
+
"path": c.get("path"),
|
|
773
|
+
"line": c.get("line"),
|
|
774
|
+
"body": c.get("body"),
|
|
775
|
+
"created_at": c.get("createdAt"),
|
|
776
|
+
"url": c.get("url"),
|
|
777
|
+
"author": (c.get("author") or {}).get("login", ""),
|
|
778
|
+
"author_type": (c.get("author") or {}).get("__typename", ""),
|
|
779
|
+
}
|
|
780
|
+
for c in (n.get("comments") or {}).get("nodes", [])
|
|
781
|
+
]
|
|
782
|
+
out.append({
|
|
783
|
+
"thread_id": n["id"],
|
|
784
|
+
"is_resolved": bool(n.get("isResolved")),
|
|
785
|
+
"resolved_at": n.get("resolvedAt"),
|
|
786
|
+
"comments": comments,
|
|
787
|
+
})
|
|
788
|
+
return out
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def resolve_thread(workspace_root: Path, thread_id: str) -> dict:
|
|
792
|
+
"""Resolve a GitHub PR review thread by its node ID."""
|
|
793
|
+
query = (
|
|
794
|
+
"mutation($id: ID!) { resolveReviewThread(input: {threadId: $id})"
|
|
795
|
+
" { thread { id isResolved } } }"
|
|
796
|
+
)
|
|
797
|
+
data = _graphql(workspace_root, query, id=thread_id)
|
|
798
|
+
thread = data.get("data", {}).get("resolveReviewThread", {}).get("thread") or {}
|
|
799
|
+
return {"thread_id": thread.get("id"), "is_resolved": bool(thread.get("isResolved"))}
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def unresolve_thread(workspace_root: Path, thread_id: str) -> dict:
|
|
803
|
+
"""Unresolve a GitHub PR review thread by its node ID."""
|
|
804
|
+
query = (
|
|
805
|
+
"mutation($id: ID!) { unresolveReviewThread(input: {threadId: $id})"
|
|
806
|
+
" { thread { id isResolved } } }"
|
|
807
|
+
)
|
|
808
|
+
data = _graphql(workspace_root, query, id=thread_id)
|
|
809
|
+
thread = data.get("data", {}).get("unresolveReviewThread", {}).get("thread") or {}
|
|
810
|
+
return {"thread_id": thread.get("id"), "is_resolved": bool(thread.get("isResolved"))}
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def reply_to_thread(workspace_root: Path, thread_id: str, body: str) -> dict:
|
|
814
|
+
"""Post a reply to a GitHub PR review thread."""
|
|
815
|
+
query = (
|
|
816
|
+
"mutation($id: ID!, $body: String!) {"
|
|
817
|
+
" addPullRequestReviewThreadReply("
|
|
818
|
+
" input: {pullRequestReviewThreadId: $id, body: $body}) {"
|
|
819
|
+
" comment { id url } } }"
|
|
820
|
+
)
|
|
821
|
+
data = _graphql(workspace_root, query, id=thread_id, body=body)
|
|
822
|
+
comment = (
|
|
823
|
+
data.get("data", {})
|
|
824
|
+
.get("addPullRequestReviewThreadReply", {})
|
|
825
|
+
.get("comment") or {}
|
|
826
|
+
)
|
|
827
|
+
return {"comment_id": comment.get("id"), "url": comment.get("url", "")}
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _build_comments_from_threads(threads: list[dict]) -> tuple[list[dict], int]:
|
|
831
|
+
"""Build a normalized comment list from list_review_threads output.
|
|
832
|
+
|
|
833
|
+
Threads where is_resolved is True contribute to resolved_count and their
|
|
834
|
+
comments are excluded (matching the existing _normalize_comments behavior).
|
|
835
|
+
Each comment dict carries thread_id plus the standard normalized fields.
|
|
836
|
+
"""
|
|
837
|
+
comments: list[dict] = []
|
|
838
|
+
resolved_count = 0
|
|
839
|
+
for t in threads:
|
|
840
|
+
if t["is_resolved"]:
|
|
841
|
+
resolved_count += 1
|
|
842
|
+
continue
|
|
843
|
+
for c in t["comments"]:
|
|
844
|
+
comments.append({
|
|
845
|
+
"id": c["comment_id"],
|
|
846
|
+
"path": c["path"] or "",
|
|
847
|
+
"line": c["line"] or 0,
|
|
848
|
+
"body": c["body"] or "",
|
|
849
|
+
"author": c["author"],
|
|
850
|
+
"author_type": c.get("author_type", ""),
|
|
851
|
+
"state": "",
|
|
852
|
+
"created_at": c["created_at"] or "",
|
|
853
|
+
"url": c["url"] or "",
|
|
854
|
+
"in_reply_to_id": None,
|
|
855
|
+
"commit_id": "",
|
|
856
|
+
"thread_id": t["thread_id"],
|
|
857
|
+
})
|
|
858
|
+
return comments, resolved_count
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def get_review_comments(
|
|
862
|
+
workspace_root: Path,
|
|
863
|
+
owner: str,
|
|
864
|
+
repo: str,
|
|
865
|
+
pr_number: int,
|
|
866
|
+
) -> tuple[list[dict], int]:
|
|
867
|
+
"""Fetch review comments for a PR. GraphQL first; MCP/gh CLI fallback.
|
|
868
|
+
|
|
869
|
+
Returns ``(comments, resolved_count)``: comments are normalized with
|
|
870
|
+
fields ``path, line, body, author, author_type, state, created_at,
|
|
871
|
+
url, in_reply_to_id, commit_id, thread_id``. ``resolved_count`` is the
|
|
872
|
+
number of threads excluded because GitHub flagged them resolved.
|
|
873
|
+
|
|
874
|
+
When GraphQL succeeds it is the sole source of truth (one round-trip,
|
|
875
|
+
thread_id on every comment). When GraphQL fails, falls back to the MCP
|
|
876
|
+
three-tool ladder and gh CLI REST path; those comments get thread_id="".
|
|
877
|
+
|
|
878
|
+
Bot threads are kept (the temporal classifier downstream handles
|
|
879
|
+
staleness). If neither path is available, returns ``([], 0)``.
|
|
880
|
+
"""
|
|
881
|
+
# Try GraphQL first — gets threads + comments + thread_id in one shot.
|
|
882
|
+
if is_github_configured(workspace_root):
|
|
883
|
+
try:
|
|
884
|
+
threads = list_review_threads(workspace_root, owner, repo, pr_number)
|
|
885
|
+
return _build_comments_from_threads(threads)
|
|
886
|
+
except Exception:
|
|
887
|
+
pass # fall through to legacy path — never crash on GraphQL failure
|
|
888
|
+
|
|
889
|
+
# Legacy fallback: MCP three-tool ladder then gh CLI REST.
|
|
890
|
+
# Decorate each comment with thread_id="" so callers always have the key.
|
|
891
|
+
if is_mcp_configured(workspace_root, "github"):
|
|
892
|
+
config = _get_github_config(workspace_root)
|
|
893
|
+
for tool_name, args in [
|
|
894
|
+
("get_pull_request_comments", {"owner": owner, "repo": repo, "pull_number": pr_number}),
|
|
895
|
+
("list_review_comments", {"owner": owner, "repo": repo, "pull_number": pr_number}),
|
|
896
|
+
("get_pull_request_reviews", {"owner": owner, "repo": repo, "pull_number": pr_number}),
|
|
897
|
+
]:
|
|
898
|
+
try:
|
|
899
|
+
result = call_tool(config, tool_name, args, timeout=15.0, server_name="github")
|
|
900
|
+
parsed = _parse_mcp_result(result)
|
|
901
|
+
if parsed is not None:
|
|
902
|
+
comments, resolved_count = _normalize_comments(parsed)
|
|
903
|
+
for c in comments:
|
|
904
|
+
c.setdefault("thread_id", "")
|
|
905
|
+
return comments, resolved_count
|
|
906
|
+
except McpClientError:
|
|
907
|
+
continue
|
|
908
|
+
return [], 0
|
|
909
|
+
|
|
910
|
+
if have_gh_cli():
|
|
911
|
+
try:
|
|
912
|
+
output = _gh([
|
|
913
|
+
"api", f"repos/{owner}/{repo}/pulls/{pr_number}/comments",
|
|
914
|
+
"--paginate",
|
|
915
|
+
])
|
|
916
|
+
data = json.loads(output) if output.strip() else []
|
|
917
|
+
comments, resolved_count = _normalize_comments(data)
|
|
918
|
+
for c in comments:
|
|
919
|
+
c.setdefault("thread_id", "")
|
|
920
|
+
return comments, resolved_count
|
|
921
|
+
except (GitHubNotConfiguredError, json.JSONDecodeError):
|
|
922
|
+
return [], 0
|
|
923
|
+
|
|
924
|
+
return [], 0
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _normalize_comments(data: Any) -> tuple[list[dict], int]:
|
|
928
|
+
"""Normalize review comments from various MCP response shapes.
|
|
929
|
+
|
|
930
|
+
Drops threads explicitly marked resolved (isResolved/state==RESOLVED).
|
|
931
|
+
Bot comments are NOT filtered: a claude[bot] thread may carry the
|
|
932
|
+
only actionable feedback. The temporal classifier downstream handles
|
|
933
|
+
staleness regardless of author.
|
|
934
|
+
|
|
935
|
+
Returns ``(comments, resolved_count)`` so callers can report how many
|
|
936
|
+
were excluded.
|
|
937
|
+
"""
|
|
938
|
+
if isinstance(data, list):
|
|
939
|
+
comments = data
|
|
940
|
+
elif isinstance(data, dict):
|
|
941
|
+
comments = (
|
|
942
|
+
data.get("comments")
|
|
943
|
+
or data.get("data")
|
|
944
|
+
or data.get("items")
|
|
945
|
+
or []
|
|
946
|
+
)
|
|
947
|
+
if not isinstance(comments, list):
|
|
948
|
+
comments = [data]
|
|
949
|
+
else:
|
|
950
|
+
return [], 0
|
|
951
|
+
|
|
952
|
+
normalized = []
|
|
953
|
+
resolved_count = 0
|
|
954
|
+
for c in comments:
|
|
955
|
+
if c.get("resolved", False) or c.get("state") == "RESOLVED":
|
|
956
|
+
resolved_count += 1
|
|
957
|
+
continue
|
|
958
|
+
|
|
959
|
+
author = c.get("user", {})
|
|
960
|
+
if isinstance(author, dict):
|
|
961
|
+
author_login = author.get("login", "")
|
|
962
|
+
author_type = author.get("type", "")
|
|
963
|
+
else:
|
|
964
|
+
author_login = str(author) if author else ""
|
|
965
|
+
author_type = ""
|
|
966
|
+
|
|
967
|
+
normalized.append({
|
|
968
|
+
"id": c.get("id"), # M3: stable id for `commit --address`
|
|
969
|
+
"path": c.get("path") or c.get("file") or "",
|
|
970
|
+
"line": c.get("line") or c.get("original_line") or c.get("position") or 0,
|
|
971
|
+
"body": c.get("body") or "",
|
|
972
|
+
"author": author_login or c.get("author", ""),
|
|
973
|
+
"author_type": author_type,
|
|
974
|
+
"state": c.get("state") or "",
|
|
975
|
+
"created_at": c.get("created_at") or c.get("createdAt") or "",
|
|
976
|
+
"url": c.get("html_url") or c.get("url") or "",
|
|
977
|
+
"in_reply_to_id": c.get("in_reply_to_id"),
|
|
978
|
+
# M9: commit at which the comment was anchored — drives the
|
|
979
|
+
# "addressed since this sha" walk in draft_replies.
|
|
980
|
+
"commit_id": c.get("commit_id") or c.get("original_commit_id") or "",
|
|
981
|
+
})
|
|
982
|
+
|
|
983
|
+
return normalized, resolved_count
|