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
canopy/actions/reads.py
ADDED
|
@@ -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
|