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,341 @@
1
+ """Linear backend for the issue-provider contract.
2
+
3
+ Refactored from ``integrations/linear.py`` (M5). The original module
4
+ remains as a re-export shim for one release cycle so external callers
5
+ don't break.
6
+
7
+ Wraps the canonical Linear MCP tool conventions plus a fan-out of
8
+ fallback tool/args shapes for legacy MCP servers. See ``docs/architecture/
9
+ providers.md`` §7 for the rationale.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .types import (
18
+ Issue,
19
+ IssueNotFoundError,
20
+ IssueProviderError,
21
+ ProviderNotConfigured,
22
+ )
23
+ from ..mcp.client import (
24
+ McpClientError,
25
+ call_tool,
26
+ get_mcp_config,
27
+ is_mcp_configured,
28
+ )
29
+
30
+
31
+ _MCP_ERROR_PATTERN = re.compile(r"^(error\s*:|mcp error -?\d+\s*:)", re.IGNORECASE)
32
+ _OPEN_STATUS_TYPES = {"backlog", "unstarted", "started", "triage"}
33
+
34
+ # Linear's state names → canonical canopy states.
35
+ _LINEAR_STATE_MAP = {
36
+ "backlog": "todo",
37
+ "triage": "todo",
38
+ "todo": "todo",
39
+ "unstarted": "todo",
40
+ "started": "in_progress",
41
+ "in progress": "in_progress",
42
+ "in review": "in_progress",
43
+ "in_progress": "in_progress",
44
+ "completed": "done",
45
+ "done": "done",
46
+ "merged": "done",
47
+ "shipped": "done",
48
+ "cancelled": "cancelled",
49
+ "canceled": "cancelled",
50
+ "duplicate": "cancelled",
51
+ }
52
+
53
+
54
+ class LinearProvider:
55
+ """Linear MCP-backed issue provider.
56
+
57
+ The provider's ``options`` (passed by the registry) come from the
58
+ workspace's ``[issue_provider.linear]`` block. v1 honors:
59
+
60
+ - ``api_key_env``: name of the env var holding the Linear API key.
61
+ Default ``"LINEAR_API_KEY"``. The MCP server is what actually reads
62
+ this; canopy just passes config through.
63
+
64
+ The provider needs the workspace root to look up MCP config. The
65
+ registry passes a ``Workspace`` reference at construction so each
66
+ method has access without changing the protocol signatures.
67
+ """
68
+
69
+ def __init__(self, options: dict[str, Any] | None = None, *, workspace_root: Path | None = None):
70
+ self._options = options or {}
71
+ # workspace_root is set by the registry's factory before first use;
72
+ # tests can also pass it directly.
73
+ self._workspace_root: Path | None = workspace_root
74
+ self.api_key_env: str = self._options.get("api_key_env", "LINEAR_API_KEY")
75
+
76
+ # ── Internal helpers ────────────────────────────────────────────────
77
+
78
+ def _root(self) -> Path:
79
+ if self._workspace_root is None:
80
+ raise IssueProviderError(
81
+ "LinearProvider has no workspace_root; the registry must set it before use.",
82
+ )
83
+ return self._workspace_root
84
+
85
+ def _config(self) -> dict:
86
+ """Fetch the Linear MCP config or raise ``ProviderNotConfigured``."""
87
+ config = get_mcp_config(self._root(), "linear")
88
+ if config is None:
89
+ raise ProviderNotConfigured(
90
+ "Linear MCP not configured. "
91
+ "Add a 'linear' entry to .canopy/mcps.json with command + env "
92
+ "(LINEAR_API_KEY).",
93
+ )
94
+ return config
95
+
96
+ def is_configured(self) -> bool:
97
+ """Lightweight check: is the Linear MCP entry present?"""
98
+ return is_mcp_configured(self._root(), "linear")
99
+
100
+ # ── Protocol methods ────────────────────────────────────────────────
101
+
102
+ def get_issue(self, alias: str) -> Issue:
103
+ """Fetch a Linear issue by identifier (e.g. SIN-7).
104
+
105
+ Tries the canonical Linear MCP tool first, then a fan-out of
106
+ legacy server shapes. Raises ``IssueNotFoundError`` if every
107
+ attempt fails.
108
+ """
109
+ config = self._config()
110
+
111
+ tool_attempts = [
112
+ ("get_issue", {"id": alias}),
113
+ ("get_issue", {"issue_id": alias}),
114
+ ("linear_get_issue", {"issueId": alias}),
115
+ ("get_issue", {"issueId": alias}),
116
+ ("search_issues", {"query": alias}),
117
+ ("linear_search_issues", {"query": alias}),
118
+ ]
119
+
120
+ last_error: McpClientError | None = None
121
+ for tool_name, args in tool_attempts:
122
+ try:
123
+ result = call_tool(config, tool_name, args, timeout=15.0, server_name="linear")
124
+ parsed = _parse_issue_result(result)
125
+ if parsed:
126
+ return _to_issue(parsed, alias)
127
+ except McpClientError as e:
128
+ last_error = e
129
+ continue
130
+
131
+ raise IssueNotFoundError(
132
+ f"Could not fetch Linear issue '{alias}'. "
133
+ f"Last MCP error: {last_error}",
134
+ )
135
+
136
+ def list_my_issues(self, limit: int = 50) -> list[Issue]:
137
+ """Return the current user's open Linear issues.
138
+
139
+ Tries the canonical ``list_issues(assignee="me")`` first, then a
140
+ fan-out of legacy shapes. Filters server-side responses agent-side
141
+ to ``statusType in {backlog, unstarted, started, triage}`` since
142
+ the canonical Linear MCP doesn't accept a ``state`` filter on
143
+ ``list_issues``.
144
+
145
+ Raises ``IssueProviderError`` (with a per-attempt log) if every
146
+ tool/args combo fails. Soft-empty (``[]``) is reserved for the
147
+ configured-but-no-issues case.
148
+ """
149
+ config = self._config()
150
+
151
+ tool_attempts = [
152
+ ("list_issues", {"assignee": "me"}),
153
+ ("list_my_issues", {}),
154
+ ("linear_list_my_issues", {}),
155
+ ("get_my_issues", {}),
156
+ ("list_issues", {"assignee": "me", "state": "open"}),
157
+ ("linear_list_issues", {"assignee": "me", "state": "open"}),
158
+ ("search_issues", {"query": "assignee:me state:open"}),
159
+ ]
160
+ attempts_log: list[tuple[str, dict, str]] = []
161
+
162
+ for tool_name, args in tool_attempts:
163
+ try:
164
+ result = call_tool(
165
+ config, tool_name, args, timeout=15.0, server_name="linear",
166
+ )
167
+ except McpClientError as e:
168
+ attempts_log.append((tool_name, args, str(e)))
169
+ continue
170
+
171
+ parsed = _parse_issue_result(result)
172
+ if parsed is None:
173
+ attempts_log.append(
174
+ (tool_name, args, "no usable response (parse failed or inline MCP error)"),
175
+ )
176
+ continue
177
+
178
+ items = parsed
179
+ if isinstance(items, dict):
180
+ for key in ("issues", "results", "data", "items"):
181
+ if isinstance(items.get(key), list):
182
+ items = items[key]
183
+ break
184
+ if not isinstance(items, list):
185
+ attempts_log.append(
186
+ (tool_name, args, f"unexpected response shape: {type(items).__name__}"),
187
+ )
188
+ continue
189
+
190
+ normalized: list[Issue] = []
191
+ for entry in items[:limit]:
192
+ if not isinstance(entry, dict):
193
+ continue
194
+ status_type = entry.get("statusType")
195
+ if status_type and str(status_type).lower() not in _OPEN_STATUS_TYPES:
196
+ continue
197
+ normalized.append(
198
+ _to_issue(entry, entry.get("identifier", entry.get("id", ""))),
199
+ )
200
+ if normalized:
201
+ return normalized
202
+ attempts_log.append((tool_name, args, "no open issues in response"))
203
+
204
+ # All attempts failed — surface the per-attempt log so the caller
205
+ # can render it as a structured BlockerError.
206
+ summary = "\n ".join(
207
+ f"- {tool}({args}): {err}" for tool, args, err in attempts_log
208
+ )
209
+ raise IssueProviderError(
210
+ f"All Linear MCP attempts failed:\n {summary}",
211
+ )
212
+
213
+ def format_branch_name(
214
+ self,
215
+ issue_id: str,
216
+ title: str | None = None,
217
+ custom_name: str | None = None,
218
+ ) -> str:
219
+ """Format a branch name from a Linear issue.
220
+
221
+ - ``custom_name`` overrides everything (returned as-is).
222
+ - With ``title``: ``"<lowercased-issue-id>-<slug>"``.
223
+ - Without: ``"<lowercased-issue-id>"``.
224
+ """
225
+ if custom_name:
226
+ return custom_name
227
+ if not title:
228
+ return issue_id.lower()
229
+ slug = re.sub(r"[^a-zA-Z0-9\s-]", "", title)
230
+ slug = re.sub(r"\s+", "-", slug.strip()).lower()
231
+ slug = slug[:50].rstrip("-")
232
+ return f"{issue_id}-{slug}".lower()
233
+
234
+ def update_issue_state(self, alias: str, new_state: str) -> None:
235
+ """Lifecycle automation reserved for a future plan."""
236
+ raise NotImplementedError(
237
+ "LinearProvider.update_issue_state is not implemented in v1. "
238
+ "Track via a future plan if you need write-back.",
239
+ )
240
+
241
+ def parse_alias(self, alias: str) -> str | None:
242
+ """Recognize Linear-shaped IDs like ``ENG-412`` (``[A-Z]+-\\d+``).
243
+
244
+ Returns the alias unchanged when it matches; ``None`` otherwise
245
+ so the resolver falls back to feature-lane lookup.
246
+ """
247
+ if _LINEAR_ID_PATTERN.match(alias.strip()):
248
+ return alias.strip()
249
+ return None
250
+
251
+
252
+ _LINEAR_ID_PATTERN = re.compile(r"^[A-Z]+-\d+$", re.IGNORECASE)
253
+
254
+
255
+ # ── Module-level parsers (kept module-level so tests can mock easily) ──
256
+
257
+
258
+ def _parse_issue_result(result: Any) -> dict | list | None:
259
+ """Extract issue data from an MCP tool call result.
260
+
261
+ MCP results come as a CallToolResult with .content blocks. Linear MCP
262
+ typically returns a single text block with JSON. Inline MCP errors
263
+ (text blocks starting with ``Error:`` / ``MCP error -32602:``) are
264
+ treated as failure so the caller falls through to the next attempt
265
+ instead of normalizing into an empty issue.
266
+ """
267
+ if result is None:
268
+ return None
269
+
270
+ for block in result.content:
271
+ if hasattr(block, "text") and block.text:
272
+ text = block.text.strip()
273
+ if _looks_like_mcp_error(text):
274
+ return None
275
+ if text.startswith("{") or text.startswith("["):
276
+ import json
277
+ try:
278
+ return json.loads(text)
279
+ except json.JSONDecodeError:
280
+ pass
281
+ return {"raw": text}
282
+ return None
283
+
284
+
285
+ def _looks_like_mcp_error(text: str) -> bool:
286
+ """Heuristic: does this text content look like an inline MCP error?"""
287
+ if not text:
288
+ return False
289
+ head = text.strip()[:200]
290
+ return bool(_MCP_ERROR_PATTERN.match(head)) or "Input validation error" in head
291
+
292
+
293
+ def _to_issue(data: dict | list, original_id: str) -> Issue:
294
+ """Map a Linear API response dict into a canonical ``Issue``.
295
+
296
+ Handles search-result lists (takes the first match) and nested
297
+ ``{issues: [...]}`` envelopes. Raises ``IssueNotFoundError`` for
298
+ empty results.
299
+ """
300
+ if isinstance(data, list):
301
+ if not data:
302
+ raise IssueNotFoundError(f"No Linear results for '{original_id}'")
303
+ data = data[0]
304
+
305
+ if "issues" in data and isinstance(data["issues"], list):
306
+ if not data["issues"]:
307
+ raise IssueNotFoundError(f"No Linear results for '{original_id}'")
308
+ data = data["issues"][0]
309
+
310
+ raw_state = (
311
+ data.get("state", {}).get("name")
312
+ if isinstance(data.get("state"), dict)
313
+ else data.get("state") or data.get("status") or ""
314
+ )
315
+ canonical_state = _LINEAR_STATE_MAP.get(str(raw_state).lower(), "todo")
316
+
317
+ labels_node = (data.get("labels") or {}).get("nodes", []) if isinstance(data.get("labels"), dict) else (data.get("labels") or [])
318
+ labels: tuple[str, ...] = tuple(
319
+ l.get("name") if isinstance(l, dict) else str(l)
320
+ for l in labels_node
321
+ if (isinstance(l, dict) and l.get("name")) or isinstance(l, str)
322
+ )
323
+
324
+ assignee_node = data.get("assignee")
325
+ assignee = (
326
+ assignee_node.get("name") or assignee_node.get("displayName")
327
+ if isinstance(assignee_node, dict) else assignee_node
328
+ )
329
+
330
+ return Issue(
331
+ id=str(data.get("id") or data.get("identifier") or original_id),
332
+ identifier=data.get("identifier") or data.get("id") or original_id,
333
+ title=data.get("title") or data.get("name") or "",
334
+ description=data.get("description") or "",
335
+ state=canonical_state,
336
+ url=data.get("url") or "",
337
+ assignee=assignee,
338
+ labels=labels,
339
+ priority=data.get("priority"),
340
+ raw=data if isinstance(data, dict) else None,
341
+ )
@@ -0,0 +1,149 @@
1
+ """Canonical types for the issue-provider contract.
2
+
3
+ The :class:`Issue` dataclass is what every action that consumes issue data
4
+ operates on, regardless of which backend produced it. Providers map their
5
+ internal shapes into ``Issue`` instances.
6
+
7
+ See ``docs/architecture/providers.md`` §2 for the design.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Protocol, runtime_checkable
13
+
14
+
15
+ # Canonical state vocabulary. Every provider maps its internal state names
16
+ # into one of these. Agents pattern-match on these strings; downstream
17
+ # rendering can show the raw provider name from ``Issue.raw`` when needed.
18
+ CANONICAL_STATES = ("todo", "in_progress", "done", "cancelled")
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Issue:
23
+ """An issue from any tracker, normalized to the canopy canonical shape.
24
+
25
+ Every provider returns this. ``raw`` is an escape hatch holding the
26
+ original API response so adapter layers (e.g. ``actions/reads.py``)
27
+ can pass through provider-specific fields for backward compatibility
28
+ without expanding the canonical type.
29
+ """
30
+ id: str # provider-internal id (Linear UUID, GH issue number, JIRA key)
31
+ identifier: str # human-readable: "SIN-7", "#142", "PROJ-123"
32
+ title: str
33
+ description: str | None = None
34
+ state: str = "todo" # one of CANONICAL_STATES
35
+ url: str = ""
36
+ assignee: str | None = None
37
+ labels: tuple[str, ...] = ()
38
+ priority: int | None = None # 1=urgent, 2=high, 3=medium, 4=low
39
+ raw: dict[str, Any] | None = None # provider-specific original payload
40
+
41
+ def to_dict(self) -> dict[str, Any]:
42
+ """Render to a JSON-friendly dict. Tuples become lists."""
43
+ return {
44
+ "id": self.id,
45
+ "identifier": self.identifier,
46
+ "title": self.title,
47
+ "description": self.description,
48
+ "state": self.state,
49
+ "url": self.url,
50
+ "assignee": self.assignee,
51
+ "labels": list(self.labels),
52
+ "priority": self.priority,
53
+ "raw": self.raw,
54
+ }
55
+
56
+
57
+ @runtime_checkable
58
+ class IssueProvider(Protocol):
59
+ """Contract every issue provider must implement.
60
+
61
+ Concrete providers live under ``canopy.providers.<name>``. The
62
+ registry in ``canopy.providers.__init__`` dispatches based on the
63
+ workspace's ``[issue_provider] name = "..."`` config.
64
+ """
65
+
66
+ def get_issue(self, alias: str) -> Issue:
67
+ """Resolve a provider-native alias to an ``Issue``.
68
+
69
+ Alias formats are provider-specific:
70
+ - Linear: ``"SIN-7"``
71
+ - GitHub Issues: ``"#142"`` or ``"owner/repo#142"``
72
+ - JIRA: ``"PROJ-123"``
73
+
74
+ Raises:
75
+ IssueNotFoundError: alias didn't resolve to an existing issue.
76
+ ProviderNotConfigured: backend credentials missing or wrong.
77
+ IssueProviderError: any other backend / network failure.
78
+ """
79
+ ...
80
+
81
+ def list_my_issues(self, limit: int = 50) -> list[Issue]:
82
+ """Return the current user's open issues, ordered by recency or
83
+ priority (provider's choice). Empty list is valid.
84
+
85
+ Raises:
86
+ ProviderNotConfigured: backend credentials missing or wrong.
87
+ IssueProviderError: backend / network failure.
88
+ """
89
+ ...
90
+
91
+ def format_branch_name(
92
+ self,
93
+ issue_id: str,
94
+ title: str | None = None,
95
+ custom_name: str | None = None,
96
+ ) -> str:
97
+ """Provider-specific branch slug rules.
98
+
99
+ ``custom_name`` overrides any default slugging when the user wants
100
+ a non-derived branch name.
101
+ """
102
+ ...
103
+
104
+ def update_issue_state(self, alias: str, new_state: str) -> None:
105
+ """Optional. Lifecycle automation (e.g. flip to ``in_progress``
106
+ on ``canopy switch <issue>``). v1 implementations may raise
107
+ ``NotImplementedError`` — this slot exists so future plans can
108
+ wire it without changing the protocol.
109
+ """
110
+ ...
111
+
112
+ def parse_alias(self, alias: str) -> str | None:
113
+ """Recognize a provider-native alias and return its canonical form.
114
+
115
+ Returns ``None`` when the alias doesn't look like one this
116
+ provider can handle — the caller (``actions/aliases.py``) then
117
+ falls through to feature-lane lookup.
118
+
119
+ Examples:
120
+ - ``LinearProvider.parse_alias("SIN-412")`` → ``"SIN-412"``
121
+ - ``LinearProvider.parse_alias("5")`` → ``None`` (Linear IDs need a team prefix)
122
+ - ``GitHubIssuesProvider.parse_alias("5")`` → ``"5"``
123
+ - ``GitHubIssuesProvider.parse_alias("#5")`` → ``"5"``
124
+ - ``GitHubIssuesProvider.parse_alias("owner/repo#5")`` → ``"owner/repo#5"``
125
+ - ``GitHubIssuesProvider.parse_alias("https://github.com/o/r/issues/5")`` → ``"5"``
126
+
127
+ Implementations should be cheap (regex / string ops) — no
128
+ network. The resolver may call this on every CLI input. Existing
129
+ v1 providers can default to a regex check; future providers
130
+ plug in their own native shapes.
131
+ """
132
+ ...
133
+
134
+
135
+ # Provider exceptions. These are the only exceptions providers should
136
+ # raise; the action layer catches them and converts to ``BlockerError``
137
+ # for the CLI / MCP surfaces.
138
+
139
+ class IssueProviderError(Exception):
140
+ """Base for all provider-raised errors."""
141
+
142
+
143
+ class ProviderNotConfigured(IssueProviderError):
144
+ """Backend isn't configured for this workspace (missing creds, wrong
145
+ canopy.toml block, unknown provider name in registry)."""
146
+
147
+
148
+ class IssueNotFoundError(IssueProviderError):
149
+ """Alias didn't resolve to a real issue."""
@@ -0,0 +1,4 @@
1
+ """Workspace configuration and discovery."""
2
+ from .config import WorkspaceConfig, RepoConfig, load_config, validate_config
3
+ from .workspace import Workspace, RepoState
4
+ from .discovery import discover_repos, generate_toml