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,511 @@
1
+ """commit — feature-scoped multi-repo commit.
2
+
3
+ Stages tracked changes (or explicit ``paths``) and commits across every
4
+ repo in a feature lane with a single message. The canonical feature is
5
+ inferred from ``slots.json`` when no ``--feature`` is given;
6
+ explicit names override.
7
+
8
+ Pre-flight: every in-scope repo must currently be on its expected branch
9
+ (``lane.branches[repo]`` or, by default, the feature name). If any repo
10
+ has drifted to a different branch, commit refuses before any side effects
11
+ and surfaces a ``BlockerError(code='wrong_branch')`` whose ``details``
12
+ carry the per-repo expected/actual map.
13
+
14
+ Per-repo recipe::
15
+
16
+ 1. stage --paths if given, else `git add -u` (all tracked changes)
17
+ 2. if nothing staged → status: "nothing"
18
+ 3. else `git commit -m <message>` (hooks honored unless no_hooks)
19
+ 4. on hook failure → status: "hooks_failed" + hook_output tail
20
+ 5. on success → status: "ok" + sha + files_changed
21
+
22
+ Per-repo failure does NOT cancel other repos. Result aggregates the
23
+ per-repo outcome dict so the caller can act on partial success.
24
+
25
+ The ``--address <comment-id>`` flag (M3) auto-formats the commit message
26
+ with a bot comment's title + URL and records a resolution entry in
27
+ ``.canopy/state/bot_resolutions.json`` when the matching repo commits
28
+ successfully.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ from ..git import repo as git
37
+ from ..workspace.workspace import Workspace
38
+ from . import slots as slots_mod
39
+ from .aliases import repos_for_feature, resolve_feature
40
+ from .bot_resolutions import record_resolution
41
+ from .errors import BlockerError, FixAction
42
+ from .feature_state import _per_repo_facts, resolve_repo_paths
43
+
44
+
45
+ def _resolve_feature_name(
46
+ workspace: Workspace, feature: str | None,
47
+ ) -> str:
48
+ """Pick the feature: explicit alias → resolved name, else canonical."""
49
+ if feature:
50
+ return resolve_feature(workspace, feature)
51
+ state = slots_mod.read_state(workspace)
52
+ if state is None or state.canonical is None:
53
+ raise BlockerError(
54
+ code="no_canonical_feature",
55
+ what="no active feature; pass --feature or run `canopy switch <name>` first",
56
+ fix_actions=[
57
+ FixAction(action="switch", args={}, safe=False,
58
+ preview="canopy switch <feature> sets the canonical slot"),
59
+ ],
60
+ )
61
+ return state.canonical.feature
62
+
63
+
64
+ def _verify_branches(
65
+ repo_paths: dict[str, Path],
66
+ repo_branches: dict[str, str],
67
+ ) -> None:
68
+ """Raise BlockerError if any repo's current branch != expected.
69
+
70
+ Ran before any per-repo work. ``details.per_repo`` carries the
71
+ full expected/actual map so the agent can decide how to recover.
72
+ """
73
+ mismatches: dict[str, dict[str, str]] = {}
74
+ for repo_name, expected in repo_branches.items():
75
+ path = repo_paths.get(repo_name)
76
+ if path is None:
77
+ continue
78
+ actual = git.current_branch(path)
79
+ if actual != expected:
80
+ mismatches[repo_name] = {"expected": expected, "actual": actual}
81
+
82
+ if not mismatches:
83
+ return
84
+
85
+ fixes = [
86
+ FixAction(action="switch", args={}, safe=False,
87
+ preview="canopy switch <feature> aligns all repos"),
88
+ ]
89
+ raise BlockerError(
90
+ code="wrong_branch",
91
+ what=(
92
+ f"{len(mismatches)} repo(s) are on a different branch than the feature expects"
93
+ ),
94
+ details={"per_repo": mismatches},
95
+ fix_actions=fixes,
96
+ )
97
+
98
+
99
+ def _commit_one(
100
+ repo_path: Path,
101
+ message: str,
102
+ *,
103
+ paths: list[str] | None,
104
+ no_hooks: bool,
105
+ amend: bool,
106
+ ) -> dict[str, Any]:
107
+ """Commit one repo. Returns a per-repo result dict."""
108
+ if amend:
109
+ # `--amend` works regardless of staging state; we still stage paths
110
+ # if requested, but skip the empty-stage early-return below.
111
+ if paths:
112
+ try:
113
+ git.stage_files(repo_path, paths)
114
+ except git.GitError as e:
115
+ return {"status": "failed", "reason": str(e)}
116
+ else:
117
+ try:
118
+ git.stage_all_tracked(repo_path)
119
+ except git.GitError as e:
120
+ return {"status": "failed", "reason": str(e)}
121
+ try:
122
+ result = git.commit(
123
+ repo_path, message, amend=True, no_hooks=no_hooks,
124
+ )
125
+ except git.GitError as e:
126
+ return _classify_commit_error(e)
127
+ return {
128
+ "status": "ok",
129
+ "sha": result["sha"],
130
+ "files_changed": result["files_changed"],
131
+ "amended": True,
132
+ }
133
+
134
+ # Non-amend path: stage, then short-circuit if nothing's staged.
135
+ try:
136
+ if paths:
137
+ git.stage_files(repo_path, paths)
138
+ else:
139
+ git.stage_all_tracked(repo_path)
140
+ except git.GitError as e:
141
+ return {"status": "failed", "reason": str(e)}
142
+
143
+ if git.staged_file_count(repo_path) == 0:
144
+ return {"status": "nothing", "reason": "no changes to commit"}
145
+
146
+ try:
147
+ result = git.commit(repo_path, message, no_hooks=no_hooks)
148
+ except git.GitError as e:
149
+ return _classify_commit_error(e)
150
+
151
+ return {
152
+ "status": "ok",
153
+ "sha": result["sha"],
154
+ "files_changed": result["files_changed"],
155
+ }
156
+
157
+
158
+ def _classify_commit_error(err: git.GitError) -> dict[str, Any]:
159
+ """Distinguish hook-failures from other commit failures.
160
+
161
+ Pre-commit / commit-msg hooks fail with stderr that mentions the hook
162
+ name; other failures (gpg, locked index, etc.) get reported as-is.
163
+ """
164
+ msg = str(err)
165
+ lower = msg.lower()
166
+ if "pre-commit" in lower or "commit-msg" in lower or "hook" in lower:
167
+ tail = "\n".join(msg.splitlines()[-10:])
168
+ return {"status": "hooks_failed", "hook_output": tail}
169
+ return {"status": "failed", "reason": msg}
170
+
171
+
172
+ def commit(
173
+ workspace: Workspace,
174
+ message: str,
175
+ *,
176
+ feature: str | None = None,
177
+ repos: list[str] | None = None,
178
+ paths: list[str] | None = None,
179
+ no_hooks: bool = False,
180
+ amend: bool = False,
181
+ address: str | None = None,
182
+ resolve_thread: bool | None = None,
183
+ ) -> dict[str, Any]:
184
+ """Commit across every repo in a feature lane.
185
+
186
+ Args:
187
+ workspace: the workspace.
188
+ message: commit message (required unless ``amend`` or ``address``
189
+ supplies one). Empty messages should be rejected at the CLI
190
+ parse layer before reaching here.
191
+ feature: feature alias. If None, falls back to the canonical
192
+ feature in ``slots.json``.
193
+ repos: optional filter — only commit in these repos within
194
+ the feature scope. Repos NOT in the feature lane are
195
+ silently skipped (single source of truth: the feature).
196
+ paths: optional file path filter; relative to each repo root.
197
+ If given, ``git add <paths>`` instead of ``git add -u``.
198
+ no_hooks: skip pre-commit / commit-msg hooks (``--no-verify``).
199
+ amend: amend HEAD instead of creating a new commit.
200
+ address: a bot review comment ID or its GitHub URL (M3). When
201
+ set, the commit message is auto-suffixed with the comment
202
+ title + URL, and on success the resolution is recorded in
203
+ ``.canopy/state/bot_resolutions.json``. Comment must belong
204
+ to one of the feature's actionable bot threads; a non-bot
205
+ comment raises ``BlockerError(code='not_a_bot_comment')``.
206
+ resolve_thread: when ``address`` is set, controls whether the
207
+ corresponding GitHub review thread is resolved after a
208
+ successful commit. ``True`` forces resolve; ``False`` forces
209
+ skip; ``None`` (default) defers to the workspace augment
210
+ ``auto_resolve_threads_on_address`` (which defaults to
211
+ ``False`` when absent). Thread resolution is a best-effort
212
+ step — failures are captured in ``result["thread_resolved"]``
213
+ as ``{"skipped": "<reason>"}`` rather than raising.
214
+
215
+ Returns ``{feature, results: {<repo>: {...}}, addressed?}``. The
216
+ per-repo dict has shape ``{status, sha?, files_changed?, reason?,
217
+ hook_output?, amended?}`` where ``status`` is one of
218
+ ``ok | nothing | hooks_failed | failed``. When ``--address`` is given
219
+ and a resolution was recorded, ``addressed`` carries
220
+ ``{comment_id, repo, sha, title, url}``. When thread resolution was
221
+ attempted, ``addressed`` also carries ``thread_resolved`` (a dict
222
+ with the GH result or a ``{"skipped": "<reason>"}`` entry).
223
+ """
224
+ feature_name = _resolve_feature_name(workspace, feature)
225
+ repo_branches = repos_for_feature(workspace, feature_name)
226
+ if not repo_branches:
227
+ raise BlockerError(
228
+ code="empty_feature",
229
+ what=f"feature '{feature_name}' has no associated repos",
230
+ )
231
+
232
+ # Optional repo filter — restrict to subset, but never expand beyond
233
+ # the feature scope.
234
+ if repos:
235
+ repo_branches = {
236
+ r: b for r, b in repo_branches.items() if r in set(repos)
237
+ }
238
+ if not repo_branches:
239
+ raise BlockerError(
240
+ code="repos_filter_empty",
241
+ what=f"none of {sorted(repos)} are in feature '{feature_name}'",
242
+ details={"feature_repos": sorted(repos_for_feature(workspace, feature_name).keys())},
243
+ )
244
+
245
+ repo_paths, _has_wt = resolve_repo_paths(workspace, feature_name, repo_branches)
246
+
247
+ # ── --address: locate the bot comment and rewrite the message ───────
248
+ addressed_info: dict[str, Any] | None = None
249
+ if address is not None:
250
+ comment_id = _parse_comment_id(address)
251
+ bot_comment, owning_repo, _owner, _repo_slug, _pr_number = (
252
+ _find_actionable_bot_comment(
253
+ workspace, feature_name, repo_branches, repo_paths, comment_id,
254
+ )
255
+ )
256
+ if bot_comment is None:
257
+ raise BlockerError(
258
+ code="not_a_bot_comment",
259
+ what=(
260
+ f"comment {comment_id} is not in any actionable bot thread for "
261
+ f"feature '{feature_name}'"
262
+ ),
263
+ details={
264
+ "feature": feature_name,
265
+ "comment_id": comment_id,
266
+ "hint": (
267
+ "verify the id with `canopy bot-status --feature "
268
+ f"{feature_name}` and pass either the numeric id or "
269
+ "the comment URL"
270
+ ),
271
+ },
272
+ )
273
+ title = _comment_title(bot_comment.get("body", ""))
274
+ url = bot_comment.get("url", "")
275
+ message = _format_address_message(message or "", title, url)
276
+ addressed_info = {
277
+ "comment_id": comment_id,
278
+ "repo": owning_repo,
279
+ "title": title,
280
+ "url": url,
281
+ "_owner": _owner,
282
+ "_repo_slug": _repo_slug,
283
+ "_pr_number": _pr_number,
284
+ }
285
+
286
+ if not message and not amend:
287
+ # CLI argparse should catch this, but guard for direct callers.
288
+ raise BlockerError(
289
+ code="empty_message",
290
+ what="commit message is required",
291
+ )
292
+
293
+ if amend:
294
+ # Amend skips the wrong_branch pre-check — amending a commit on a
295
+ # different branch is sometimes intentional (rebase aftermath).
296
+ # Other failures still surface per-repo.
297
+ pass
298
+ else:
299
+ _verify_branches(repo_paths, repo_branches)
300
+
301
+ results: dict[str, dict[str, Any]] = {}
302
+ for repo_name, repo_path in repo_paths.items():
303
+ results[repo_name] = _commit_one(
304
+ repo_path,
305
+ message,
306
+ paths=paths,
307
+ no_hooks=no_hooks,
308
+ amend=amend,
309
+ )
310
+
311
+ out: dict[str, Any] = {"feature": feature_name, "results": results}
312
+
313
+ # Record the resolution iff the owning repo committed successfully.
314
+ if addressed_info is not None:
315
+ owning = addressed_info["repo"]
316
+ owning_result = results.get(owning, {})
317
+ if owning_result.get("status") == "ok":
318
+ sha = owning_result["sha"]
319
+ record_resolution(
320
+ workspace.config.root,
321
+ comment_id=addressed_info["comment_id"],
322
+ feature=feature_name,
323
+ repo=owning,
324
+ commit_sha=sha,
325
+ comment_title=addressed_info["title"],
326
+ comment_url=addressed_info["url"],
327
+ )
328
+ # Mirror the resolution into historian (M4) so the per-feature
329
+ # memory file's Resolutions log stays current. Non-fatal if
330
+ # the historian write fails — the canonical state is still in
331
+ # bot_resolutions.json.
332
+ try:
333
+ from . import historian
334
+ historian.record_comment_resolved(
335
+ workspace.config.root, feature_name,
336
+ comment_id=addressed_info["comment_id"],
337
+ commit_sha=sha,
338
+ gist=addressed_info["title"],
339
+ url=addressed_info["url"],
340
+ )
341
+ except Exception:
342
+ pass
343
+ addressed_info["sha"] = sha
344
+ addressed_info["recorded"] = True
345
+
346
+ # ── Optional: resolve the GH review thread (T4) ─────────────
347
+ # Determine effective resolve flag: explicit flag > augment default.
348
+ # Note: this fires on local commit success. The thread will be
349
+ # resolved before the commit reaches GitHub — push your branch to
350
+ # make the linkage live on the remote.
351
+ from ..integrations import github as gh
352
+ from .thread_actions import resolve_thread as _resolve_thread_action
353
+
354
+ augment_default = bool(
355
+ (workspace.config.augments or {}).get(
356
+ "auto_resolve_threads_on_address", False,
357
+ )
358
+ )
359
+ effective_resolve = (
360
+ resolve_thread if resolve_thread is not None else augment_default
361
+ )
362
+
363
+ if effective_resolve:
364
+ owner = addressed_info.get("_owner") or ""
365
+ repo_slug = addressed_info.get("_repo_slug") or ""
366
+ pr_number = addressed_info.get("_pr_number")
367
+ comment_id_val = addressed_info["comment_id"]
368
+
369
+ if not (owner and repo_slug and pr_number):
370
+ addressed_info["thread_resolved"] = {
371
+ "skipped": "pr_not_found",
372
+ "comment_id": comment_id_val,
373
+ }
374
+ else:
375
+ try:
376
+ threads = gh.list_review_threads(
377
+ workspace.config.root, owner, repo_slug, pr_number,
378
+ )
379
+ except Exception as exc:
380
+ addressed_info["thread_resolved"] = {
381
+ "skipped": "gh_unreachable",
382
+ "error": str(exc),
383
+ "comment_id": comment_id_val,
384
+ }
385
+ else:
386
+ # comment_id may be str or int; normalise to int for comparison.
387
+ try:
388
+ cid_int = int(comment_id_val)
389
+ except (ValueError, TypeError):
390
+ cid_int = None
391
+
392
+ target_tid = next(
393
+ (
394
+ t["thread_id"]
395
+ for t in threads
396
+ if any(
397
+ c.get("comment_id") == cid_int
398
+ for c in t.get("comments", [])
399
+ )
400
+ ),
401
+ None,
402
+ )
403
+ if target_tid is None:
404
+ addressed_info["thread_resolved"] = {
405
+ "skipped": "thread_not_found",
406
+ "comment_id": comment_id_val,
407
+ }
408
+ else:
409
+ try:
410
+ from .errors import ActionError
411
+ addressed_info["thread_resolved"] = _resolve_thread_action(
412
+ workspace,
413
+ target_tid,
414
+ feature=feature_name,
415
+ via_command="commit_address",
416
+ via_commit_sha=sha,
417
+ )
418
+ except ActionError as exc:
419
+ addressed_info["thread_resolved"] = {
420
+ "skipped": "resolve_failed",
421
+ "error": exc.to_dict(),
422
+ "comment_id": comment_id_val,
423
+ }
424
+ else:
425
+ addressed_info["recorded"] = False
426
+ addressed_info["reason"] = (
427
+ f"owning repo '{owning}' commit status: {owning_result.get('status', 'unknown')}"
428
+ )
429
+
430
+ # Strip internal PR-coordinate keys before returning.
431
+ addressed_info.pop("_owner", None)
432
+ addressed_info.pop("_repo_slug", None)
433
+ addressed_info.pop("_pr_number", None)
434
+ out["addressed"] = addressed_info
435
+
436
+ return out
437
+
438
+
439
+ # ── --address helpers ────────────────────────────────────────────────────
440
+
441
+
442
+ _TRAILING_DIGITS = re.compile(r"(\d+)\s*$")
443
+
444
+
445
+ def _parse_comment_id(address: str) -> str:
446
+ """Accept a numeric id, a ``#123`` form, or a GitHub URL.
447
+
448
+ GitHub URLs end with ``#discussion_r<N>``, ``#issuecomment-<N>``, or
449
+ similar — we extract the trailing digit run as the canonical id.
450
+ """
451
+ raw = address.strip()
452
+ match = _TRAILING_DIGITS.search(raw)
453
+ if not match:
454
+ raise BlockerError(
455
+ code="invalid_comment_id",
456
+ what=f"could not parse a comment id from '{address}'",
457
+ details={"hint": "pass a numeric id (e.g. 123456) or the GitHub comment URL"},
458
+ )
459
+ return match.group(1)
460
+
461
+
462
+ def _find_actionable_bot_comment(
463
+ workspace: Workspace,
464
+ feature_name: str,
465
+ repo_branches: dict[str, str],
466
+ repo_paths: dict[str, Path],
467
+ comment_id: str,
468
+ ) -> tuple[dict | None, str | None, str | None, str | None, int | None]:
469
+ """Walk per-repo bot threads for a matching comment id.
470
+
471
+ Returns ``(comment_dict, owning_repo, owner, repo_slug, pr_number)`` or
472
+ ``(None, None, None, None, None)`` when no actionable bot thread carries
473
+ the requested id. The extra fields come from the same ``_per_repo_facts``
474
+ call so no second network round-trip is needed.
475
+ """
476
+ facts = _per_repo_facts(workspace, feature_name, repo_branches, repo_paths)
477
+ for repo_name, repo_facts in facts.items():
478
+ for thread in repo_facts.get("actionable_bot_threads", []):
479
+ if str(thread.get("id", "")) == comment_id:
480
+ pr = repo_facts.get("pr") or {}
481
+ return (
482
+ thread,
483
+ repo_name,
484
+ repo_facts.get("owner"),
485
+ repo_facts.get("repo_slug"),
486
+ pr.get("number"),
487
+ )
488
+ return None, None, None, None, None
489
+
490
+
491
+ def _comment_title(body: str, max_len: int = 80) -> str:
492
+ """First non-empty line of the comment, trimmed to ``max_len``."""
493
+ for line in (body or "").splitlines():
494
+ line = line.strip()
495
+ if not line:
496
+ continue
497
+ if len(line) <= max_len:
498
+ return line
499
+ return line[:max_len].rstrip() + "…"
500
+ return ""
501
+
502
+
503
+ def _format_address_message(user_message: str, title: str, url: str) -> str:
504
+ """Append the standard ``Addresses bot comment`` trailer."""
505
+ suffix_parts = [f'Addresses bot comment: "{title}"' if title else "Addresses bot comment"]
506
+ if url:
507
+ suffix_parts[-1] += f" ({url})"
508
+ suffix = "\n\n".join(suffix_parts)
509
+ if user_message.strip():
510
+ return f"{user_message.rstrip()}\n\n{suffix}"
511
+ return suffix