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.
Files changed (71) hide show
  1. canopy/__init__.py +2 -0
  2. canopy/actions/__init__.py +32 -0
  3. canopy/actions/aliases.py +421 -0
  4. canopy/actions/augments.py +55 -0
  5. canopy/actions/bootstrap.py +249 -0
  6. canopy/actions/bot_resolutions.py +123 -0
  7. canopy/actions/bot_status.py +133 -0
  8. canopy/actions/commit.py +511 -0
  9. canopy/actions/conflicts.py +314 -0
  10. canopy/actions/doctor.py +1459 -0
  11. canopy/actions/draft_replies.py +185 -0
  12. canopy/actions/drift.py +241 -0
  13. canopy/actions/errors.py +115 -0
  14. canopy/actions/evacuate.py +192 -0
  15. canopy/actions/feature_state.py +607 -0
  16. canopy/actions/historian.py +612 -0
  17. canopy/actions/ide_workspace.py +49 -0
  18. canopy/actions/last_visit.py +83 -0
  19. canopy/actions/migrate_slots.py +313 -0
  20. canopy/actions/preflight_state.py +97 -0
  21. canopy/actions/push.py +199 -0
  22. canopy/actions/reads.py +304 -0
  23. canopy/actions/resume.py +582 -0
  24. canopy/actions/review_filter.py +135 -0
  25. canopy/actions/ship.py +399 -0
  26. canopy/actions/slot_details.py +208 -0
  27. canopy/actions/slot_load.py +383 -0
  28. canopy/actions/slots.py +221 -0
  29. canopy/actions/stash.py +230 -0
  30. canopy/actions/switch.py +775 -0
  31. canopy/actions/switch_preflight.py +192 -0
  32. canopy/actions/thread_actions.py +88 -0
  33. canopy/actions/thread_resolutions.py +101 -0
  34. canopy/actions/triage.py +286 -0
  35. canopy/agent/__init__.py +5 -0
  36. canopy/agent/runner.py +129 -0
  37. canopy/agent_setup/__init__.py +264 -0
  38. canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
  39. canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
  40. canopy/cli/__init__.py +0 -0
  41. canopy/cli/main.py +4152 -0
  42. canopy/cli/render.py +98 -0
  43. canopy/cli/ui.py +150 -0
  44. canopy/features/__init__.py +2 -0
  45. canopy/features/coordinator.py +1256 -0
  46. canopy/git/__init__.py +0 -0
  47. canopy/git/hooks.py +173 -0
  48. canopy/git/multi.py +435 -0
  49. canopy/git/repo.py +859 -0
  50. canopy/git/templates/post-checkout.py +67 -0
  51. canopy/graph/__init__.py +0 -0
  52. canopy/integrations/__init__.py +0 -0
  53. canopy/integrations/github.py +983 -0
  54. canopy/integrations/linear.py +307 -0
  55. canopy/integrations/precommit.py +239 -0
  56. canopy/mcp/__init__.py +0 -0
  57. canopy/mcp/client.py +329 -0
  58. canopy/mcp/server.py +1797 -0
  59. canopy/providers/__init__.py +105 -0
  60. canopy/providers/github_issues.py +289 -0
  61. canopy/providers/linear.py +341 -0
  62. canopy/providers/types.py +149 -0
  63. canopy/workspace/__init__.py +4 -0
  64. canopy/workspace/config.py +378 -0
  65. canopy/workspace/context.py +224 -0
  66. canopy/workspace/discovery.py +197 -0
  67. canopy/workspace/workspace.py +173 -0
  68. canopy_cli-3.1.0.dist-info/METADATA +282 -0
  69. canopy_cli-3.1.0.dist-info/RECORD +71 -0
  70. canopy_cli-3.1.0.dist-info/WHEEL +4 -0
  71. 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