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,304 @@
1
+ """Read primitives — alias-aware fetches against Linear and GitHub.
2
+
3
+ Each tool accepts the universal alias forms (feature name or Linear ID)
4
+ plus its native specific form. See ``actions/aliases.py`` for resolution
5
+ rules. All return JSON, never mutate.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from ..git import repo as git
12
+ from ..integrations import github as gh
13
+ from ..providers import (
14
+ IssueNotFoundError,
15
+ ProviderNotConfigured,
16
+ get_issue_provider,
17
+ )
18
+ from ..workspace.workspace import Workspace
19
+ from .aliases import (
20
+ BranchTarget, PRTarget,
21
+ resolve_branch_targets, resolve_issue_id, resolve_linear_id, resolve_pr_targets,
22
+ )
23
+ from .errors import BlockerError, FixAction
24
+
25
+
26
+ def issue_get(workspace: Workspace, alias: str) -> dict:
27
+ """Fetch an issue and return the canonical ``Issue.to_dict()`` shape.
28
+
29
+ Mirrors ``mcp__canopy__issue_get``. Canonical fields: ``id``,
30
+ ``identifier``, ``title``, ``description``, ``state`` (mapped to
31
+ ``todo`` / ``in_progress`` / ``done`` / ``cancelled``), ``url``,
32
+ ``assignee``, ``labels``, ``priority``, ``raw``.
33
+
34
+ Use this from new CLI / action code. ``linear_get_issue`` (below)
35
+ is the legacy wrapper that exposes the raw provider state for
36
+ pre-M5 callers — kept until they migrate.
37
+ """
38
+ issue_id = resolve_issue_id(workspace, alias)
39
+ provider = get_issue_provider(workspace)
40
+ try:
41
+ issue = provider.get_issue(issue_id)
42
+ except ProviderNotConfigured as e:
43
+ raise BlockerError(
44
+ code="issue_provider_not_configured",
45
+ what=f"Issue provider '{workspace.config.issue_provider.name}' is not configured",
46
+ details={"alias": alias, "issue_id": issue_id, "error": str(e)},
47
+ fix_actions=[
48
+ FixAction(
49
+ action="configure_provider",
50
+ args={"provider": workspace.config.issue_provider.name},
51
+ safe=True,
52
+ preview=f"configure {workspace.config.issue_provider.name} per docs/architecture/providers.md §4",
53
+ ),
54
+ ],
55
+ )
56
+ except IssueNotFoundError as e:
57
+ raise BlockerError(
58
+ code="issue_not_found",
59
+ what=f"Issue '{issue_id}' not found",
60
+ details={"alias": alias, "issue_id": issue_id, "error": str(e)},
61
+ )
62
+ out = issue.to_dict()
63
+ out["alias"] = alias # convenience — original alias the caller passed
64
+ return out
65
+
66
+
67
+ def linear_get_issue(workspace: Workspace, alias: str) -> dict:
68
+ """**Deprecated.** Legacy wrapper that exposes raw provider state.
69
+
70
+ Pre-M5 callers used this and asserted on ``state`` carrying the raw
71
+ string ("Todo", "open"). New code should call ``issue_get`` instead,
72
+ which returns the canonical mapped ``Issue.to_dict()`` shape.
73
+
74
+ Kept until: deprecated MCP tool ``linear_get_issue`` is retired.
75
+
76
+ Despite the historical name, after M5 this resolves through the
77
+ provider registry — the workspace's ``[issue_provider]`` block picks
78
+ Linear / GitHub Issues / a future backend. The output dict shape is
79
+ preserved for backward compatibility (existing callers).
80
+
81
+ Accepts:
82
+ - Provider-native ID (e.g. ``"SIN-7"`` for Linear, ``"#142"`` for GH)
83
+ - Feature alias whose lane has a linked issue
84
+
85
+ Raises ``BlockerError`` if the provider isn't configured or the
86
+ issue can't be fetched.
87
+ """
88
+ issue_id = resolve_linear_id(workspace, alias)
89
+ provider = get_issue_provider(workspace)
90
+ try:
91
+ issue = provider.get_issue(issue_id)
92
+ except ProviderNotConfigured as e:
93
+ raise BlockerError(
94
+ code="issue_provider_not_configured",
95
+ what=f"Issue provider '{workspace.config.issue_provider.name}' is not configured",
96
+ details={"alias": alias, "issue_id": issue_id, "error": str(e)},
97
+ fix_actions=[
98
+ FixAction(
99
+ action="configure_provider",
100
+ args={"provider": workspace.config.issue_provider.name},
101
+ safe=True,
102
+ preview=f"configure {workspace.config.issue_provider.name} per docs/architecture/providers.md §4",
103
+ ),
104
+ ],
105
+ )
106
+ except IssueNotFoundError as e:
107
+ raise BlockerError(
108
+ code="issue_not_found",
109
+ what=f"Issue '{issue_id}' not found",
110
+ details={"alias": alias, "issue_id": issue_id, "error": str(e)},
111
+ )
112
+
113
+ # Preserve the historical output shape. ``state`` carries the raw
114
+ # provider-native state name (Linear: "In Progress"; GH: "open") via
115
+ # ``Issue.raw`` so existing callers asserting on raw values keep
116
+ # working. Falls back to canonical when raw isn't a recognized shape.
117
+ raw = issue.raw or {}
118
+ raw_state = (
119
+ raw.get("state", {}).get("name")
120
+ if isinstance(raw.get("state"), dict)
121
+ else raw.get("state") or raw.get("status") or issue.state
122
+ )
123
+ return {
124
+ "alias": alias,
125
+ "issue_id": issue_id,
126
+ "title": issue.title,
127
+ "state": raw_state,
128
+ "url": issue.url,
129
+ "description": issue.description or "",
130
+ "raw": raw,
131
+ }
132
+
133
+
134
+ def github_get_pr(workspace: Workspace, alias: str) -> dict:
135
+ """Fetch PR data per repo for an alias.
136
+
137
+ Accepts:
138
+ - Feature alias → all PRs in the lane (multi-repo)
139
+ - ``<repo>#<pr_number>`` → specific PR
140
+ - GitHub PR URL → specific PR
141
+ """
142
+ targets = resolve_pr_targets(workspace, alias)
143
+ repos: dict[str, dict] = {}
144
+ for t in targets:
145
+ pr = gh.get_pull_request_by_number(
146
+ workspace.config.root, t.owner, t.repo_slug, t.pr_number,
147
+ )
148
+ if pr is None:
149
+ repos[t.repo] = {
150
+ "pr_number": t.pr_number,
151
+ "owner": t.owner,
152
+ "repo_slug": t.repo_slug,
153
+ "found": False,
154
+ }
155
+ else:
156
+ repos[t.repo] = {
157
+ "pr_number": t.pr_number,
158
+ "owner": t.owner,
159
+ "repo_slug": t.repo_slug,
160
+ "found": True,
161
+ **pr,
162
+ }
163
+ return {"alias": alias, "repos": repos}
164
+
165
+
166
+ def github_get_branch(
167
+ workspace: Workspace, alias: str, repo: str | None = None,
168
+ ) -> dict:
169
+ """Fetch branch info per repo for an alias.
170
+
171
+ Accepts:
172
+ - Feature alias → per-repo branches from the feature lane
173
+ - ``<repo>:<branch>`` → specific branch in specific repo
174
+
175
+ Returned per repo: ``{branch, exists_locally, head_sha, ahead, behind,
176
+ has_upstream, pr_number?}``.
177
+ """
178
+ targets = resolve_branch_targets(workspace, alias, repo=repo)
179
+ repos: dict[str, dict] = {}
180
+ for t in targets:
181
+ state = workspace.get_repo(t.repo)
182
+ info: dict[str, Any] = {
183
+ "branch": t.branch,
184
+ "exists_locally": git.branch_exists(state.abs_path, t.branch),
185
+ }
186
+ if info["exists_locally"]:
187
+ info["head_sha"] = git.sha_of(state.abs_path, t.branch)
188
+ remote_ref = f"origin/{t.branch}"
189
+ info["has_upstream"] = bool(git.sha_of(state.abs_path, remote_ref))
190
+ if info["has_upstream"]:
191
+ try:
192
+ ahead, behind = git.divergence(state.abs_path, t.branch, remote_ref)
193
+ except Exception:
194
+ ahead, behind = 0, 0
195
+ info["ahead"] = ahead
196
+ info["behind"] = behind
197
+ else:
198
+ info["ahead"] = 0
199
+ info["behind"] = 0
200
+ repos[t.repo] = info
201
+ return {"alias": alias, "repos": repos}
202
+
203
+
204
+ def github_get_pr_comments(workspace: Workspace, alias: str) -> dict:
205
+ """Fetch temporally classified PR review comments per repo for an alias.
206
+
207
+ Same shape as Wave 1's ``review_comments`` (per-repo
208
+ ``actionable_threads`` / ``likely_resolved_threads`` /
209
+ ``resolved_thread_count`` / ``latest_commit_at``), but accepts the
210
+ full alias surface — feature alias, ``<repo>#<n>``, or PR URL.
211
+
212
+ M4 hook: when ``alias`` resolves to a tracked feature, each comment
213
+ seen here is logged into the feature's historian memory (deduped
214
+ per-session by id), and the temporal classifier's ``likely_resolved``
215
+ set is logged once per session.
216
+ """
217
+ from .review_filter import classify_threads
218
+
219
+ targets = resolve_pr_targets(workspace, alias)
220
+ repos: dict[str, dict] = {}
221
+ actionable_total = 0
222
+ likely_resolved_total = 0
223
+ resolved_total = 0
224
+
225
+ for t in targets:
226
+ comments, resolved_count = gh.get_review_comments(
227
+ workspace.config.root, t.owner, t.repo_slug, t.pr_number,
228
+ )
229
+ state = workspace.get_repo(t.repo)
230
+ # Need the PR's head branch to anchor the temporal classifier.
231
+ pr = gh.get_pull_request_by_number(
232
+ workspace.config.root, t.owner, t.repo_slug, t.pr_number,
233
+ )
234
+ branch = (pr or {}).get("head_branch") or state.current_branch
235
+ classification = classify_threads(comments, state.abs_path, branch)
236
+ classification["resolved_thread_count"] = resolved_count
237
+
238
+ actionable_total += len(classification["actionable_threads"])
239
+ likely_resolved_total += len(classification["likely_resolved_threads"])
240
+ resolved_total += resolved_count
241
+
242
+ repos[t.repo] = {
243
+ "pr_number": t.pr_number,
244
+ "pr_url": (pr or {}).get("url", ""),
245
+ "pr_title": (pr or {}).get("title", ""),
246
+ **classification,
247
+ }
248
+
249
+ # M4: mirror into historian when this alias maps to a tracked feature.
250
+ _historian_record_comments_read(workspace, alias, repos)
251
+
252
+ return {
253
+ "alias": alias,
254
+ "actionable_count": actionable_total,
255
+ "likely_resolved_count": likely_resolved_total,
256
+ "resolved_thread_count": resolved_total,
257
+ "repos": repos,
258
+ }
259
+
260
+
261
+ def _historian_record_comments_read(
262
+ workspace: Workspace, alias: str, repos: dict[str, dict],
263
+ ) -> None:
264
+ """Best-effort historian capture for `review_comments` reads (M4).
265
+
266
+ Fails silently — the canonical comment data is the GitHub response;
267
+ historian is only a narrative layer. We only write when the alias
268
+ resolves cleanly to a feature in features.json.
269
+ """
270
+ try:
271
+ from .aliases import resolve_feature
272
+ from . import historian
273
+
274
+ feature_name = resolve_feature(workspace, alias)
275
+ except Exception:
276
+ return
277
+
278
+ for repo_data in repos.values():
279
+ for thread in repo_data.get("actionable_threads", []) or []:
280
+ cid = thread.get("id")
281
+ if cid is None:
282
+ continue
283
+ try:
284
+ historian.record_comment_read(
285
+ workspace.config.root, feature_name,
286
+ comment_id=cid,
287
+ author=thread.get("author", ""),
288
+ path=thread.get("path", ""),
289
+ line=thread.get("line", 0),
290
+ body_excerpt=(thread.get("body") or "").splitlines()[0][:120]
291
+ if thread.get("body") else "",
292
+ url=thread.get("url", ""),
293
+ )
294
+ except Exception:
295
+ continue
296
+ # Classifier-resolved batch (one entry per session per call).
297
+ likely = repo_data.get("likely_resolved_threads", []) or []
298
+ if likely:
299
+ try:
300
+ historian.record_classifier_resolved(
301
+ workspace.config.root, feature_name, threads=likely,
302
+ )
303
+ except Exception:
304
+ pass