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,105 @@
1
+ """Issue-provider injection (M5).
2
+
3
+ Workspaces opt into a provider via the ``[issue_provider]`` block in
4
+ ``canopy.toml``. The action layer obtains the configured provider via
5
+ :func:`get_issue_provider` — it never imports a provider module directly.
6
+
7
+ See ``docs/architecture/providers.md`` for the design.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+
14
+ from .types import Issue, IssueProvider, IssueProviderError, IssueNotFoundError, ProviderNotConfigured
15
+
16
+ if TYPE_CHECKING:
17
+ from ..workspace.workspace import Workspace
18
+
19
+ __all__ = [
20
+ "Issue",
21
+ "IssueProvider",
22
+ "IssueProviderError",
23
+ "IssueNotFoundError",
24
+ "ProviderNotConfigured",
25
+ "get_issue_provider",
26
+ "register_provider",
27
+ "available_providers",
28
+ ]
29
+
30
+
31
+ # Lazy-imported registry. Backends are imported on first use to avoid
32
+ # pulling in MCP / gh CLI machinery for every action.
33
+ _REGISTRY: dict[str, str] = {
34
+ "linear": "canopy.providers.linear.LinearProvider",
35
+ "github_issues": "canopy.providers.github_issues.GitHubIssuesProvider",
36
+ }
37
+
38
+ # Per-workspace cache. Keyed on the workspace root path so multi-workspace
39
+ # MCP sessions don't share instances.
40
+ _INSTANCES: dict[Path, IssueProvider] = {}
41
+
42
+
43
+ def register_provider(name: str, dotted_path: str) -> None:
44
+ """Register a third-party provider class. Out of band from canopy.toml.
45
+
46
+ Reserved for future entry-point discovery; v1 only ships bundled
47
+ providers but exposing this avoids a refactor when entry points land.
48
+ """
49
+ _REGISTRY[name] = dotted_path
50
+ # Drop any cached instance for that name.
51
+ for path, instance in list(_INSTANCES.items()):
52
+ if type(instance).__name__ == dotted_path.rsplit(".", 1)[-1]:
53
+ _INSTANCES.pop(path, None)
54
+
55
+
56
+ def available_providers() -> list[str]:
57
+ """Sorted list of registered provider names."""
58
+ return sorted(_REGISTRY.keys())
59
+
60
+
61
+ def get_issue_provider(workspace: "Workspace") -> IssueProvider:
62
+ """Return the configured issue provider for the workspace.
63
+
64
+ Cached per-workspace; constructed lazily on first access.
65
+
66
+ Raises:
67
+ ProviderNotConfigured: when ``[issue_provider]`` references an
68
+ unknown name. Carries the list of available providers so the
69
+ agent can suggest the right one.
70
+ """
71
+ root = workspace.config.root
72
+ if root in _INSTANCES:
73
+ return _INSTANCES[root]
74
+
75
+ config = workspace.config.issue_provider
76
+ name = config.name
77
+ dotted = _REGISTRY.get(name)
78
+ if dotted is None:
79
+ raise ProviderNotConfigured(
80
+ f"Unknown issue provider '{name}'. "
81
+ f"Available: {', '.join(available_providers())}",
82
+ )
83
+
84
+ cls = _import(dotted)
85
+ # Backends accept their config dict as a positional argument plus
86
+ # ``workspace_root`` as a kwarg. Backends that don't need the root
87
+ # (e.g. a hypothetical purely-credential-driven provider) can ignore
88
+ # it via **kwargs.
89
+ instance = cls(config.options, workspace_root=root)
90
+ _INSTANCES[root] = instance
91
+ return instance
92
+
93
+
94
+ def _import(dotted_path: str):
95
+ """Resolve a 'module.Class' string to the class object."""
96
+ module_path, _, attr = dotted_path.rpartition(".")
97
+ import importlib
98
+
99
+ module = importlib.import_module(module_path)
100
+ return getattr(module, attr)
101
+
102
+
103
+ def _clear_cache() -> None:
104
+ """Clear the per-workspace instance cache. Test-only."""
105
+ _INSTANCES.clear()
@@ -0,0 +1,289 @@
1
+ """GitHub Issues backend for the issue-provider contract.
2
+
3
+ New in M5. Uses the existing ``gh`` CLI helper from
4
+ ``integrations/github.py`` (no MCP server required); falls back to
5
+ ``BlockerError(code='github_not_configured')`` semantics if ``gh`` isn't
6
+ available.
7
+
8
+ Workspace config under ``[issue_provider.github_issues]`` accepts:
9
+
10
+ - ``repo``: required. ``"owner/repo"`` of the GitHub repository hosting
11
+ the issues for this workspace.
12
+ - ``labels_filter``: optional list of label names. When set, restricts
13
+ ``list_my_issues`` to issues bearing at least one of these labels.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from .types import (
23
+ Issue,
24
+ IssueNotFoundError,
25
+ IssueProviderError,
26
+ ProviderNotConfigured,
27
+ )
28
+ from ..integrations.github import GitHubNotConfiguredError, _gh
29
+
30
+
31
+ # GitHub state names → canonical canopy states. GH only has open / closed,
32
+ # but issues with the ``state_reason`` of "completed" vs "not_planned"
33
+ # get split into done / cancelled.
34
+ _GH_STATE_MAP = {
35
+ "open": "in_progress",
36
+ "closed": "done",
37
+ }
38
+
39
+ # Labels we recognize for priority hinting. Order matters — first match
40
+ # wins. Both "p0/p1/..." short forms and "priority/urgent" GH-Issues
41
+ # convention are supported.
42
+ _PRIORITY_LABEL_MAP = {
43
+ "priority/urgent": 1,
44
+ "priority/critical": 1,
45
+ "priority/high": 2,
46
+ "priority/medium": 3,
47
+ "priority/low": 4,
48
+ "p0": 1,
49
+ "p1": 2,
50
+ "p2": 3,
51
+ "p3": 4,
52
+ }
53
+
54
+
55
+ class GitHubIssuesProvider:
56
+ """GitHub Issues issue provider.
57
+
58
+ Backed by the ``gh`` CLI (the same helper canopy uses for PR data).
59
+ Requires a configured ``repo`` in canopy.toml; aliases get parsed as
60
+ issue numbers (``#142``, ``142``, or ``owner/repo#142``).
61
+ """
62
+
63
+ def __init__(self, options: dict[str, Any] | None = None, *, workspace_root: Path | None = None):
64
+ self._options = options or {}
65
+ self._workspace_root = workspace_root # currently unused; reserved for future config files
66
+ repo = self._options.get("repo")
67
+ if not repo:
68
+ raise ProviderNotConfigured(
69
+ "GitHubIssuesProvider requires 'repo' in [issue_provider.github_issues]. "
70
+ "Example: repo = \"owner/repo\"",
71
+ )
72
+ self.repo: str = repo
73
+ self.labels_filter: list[str] = list(self._options.get("labels_filter") or [])
74
+
75
+ # ── Protocol methods ────────────────────────────────────────────────
76
+
77
+ def get_issue(self, alias: str) -> Issue:
78
+ """Fetch a GitHub issue by alias.
79
+
80
+ Aliases:
81
+ - ``"#142"`` — issue 142 in the configured repo
82
+ - ``"142"`` — same
83
+ - ``"owner/repo#142"`` — explicit repo override (must match self.repo for v1; cross-repo lookup is a future)
84
+ """
85
+ target_repo, issue_num = self._parse_alias(alias)
86
+ try:
87
+ raw = _gh_json(["api", f"repos/{target_repo}/issues/{issue_num}"])
88
+ except GitHubNotConfiguredError as e:
89
+ raise ProviderNotConfigured(str(e)) from e
90
+
91
+ if not isinstance(raw, dict) or "number" not in raw:
92
+ raise IssueNotFoundError(f"GitHub issue '{alias}' not found in {target_repo}")
93
+ # GH returns PRs from /issues/<n> too; filter them out — canopy's
94
+ # PR handling lives elsewhere.
95
+ if raw.get("pull_request"):
96
+ raise IssueNotFoundError(
97
+ f"'{alias}' is a pull request, not an issue. Use canopy review for PRs.",
98
+ )
99
+ return _to_issue(raw, default_repo=target_repo)
100
+
101
+ def list_my_issues(self, limit: int = 50) -> list[Issue]:
102
+ """Return open GitHub issues assigned to the current user, scoped
103
+ to the configured repo. Honors ``labels_filter`` when set.
104
+
105
+ Uses ``gh issue list`` (not ``gh search issues``) — the search form
106
+ treats positional args as search *text*, so passing qualifiers like
107
+ ``repo:...`` and ``is:open`` quotes them as a single text token and
108
+ the API returns "Invalid search query." See test-findings F-10.
109
+ """
110
+ args = [
111
+ "issue", "list",
112
+ "--repo", self.repo,
113
+ "--state", "open",
114
+ "--assignee", "@me",
115
+ "--limit", str(limit),
116
+ "--json", "number,title,state,body,url,assignees,labels",
117
+ ]
118
+ if self.labels_filter:
119
+ for label in self.labels_filter:
120
+ args.extend(["--label", label])
121
+
122
+ try:
123
+ raw_list = _gh_json(args)
124
+ except GitHubNotConfiguredError as e:
125
+ raise ProviderNotConfigured(str(e)) from e
126
+
127
+ if not isinstance(raw_list, list):
128
+ return []
129
+ return [_to_issue(r, default_repo=self.repo) for r in raw_list if isinstance(r, dict)]
130
+
131
+ def format_branch_name(
132
+ self,
133
+ issue_id: str,
134
+ title: str | None = None,
135
+ custom_name: str | None = None,
136
+ ) -> str:
137
+ """``"gh-<n>-<slug>"`` or ``"gh-<n>"`` if title missing."""
138
+ if custom_name:
139
+ return custom_name
140
+ try:
141
+ _, n = self._parse_alias(issue_id)
142
+ except IssueProviderError:
143
+ # Not a parseable issue alias — fall back to lowercased id.
144
+ n_str = issue_id.lstrip("#").lower()
145
+ if not title:
146
+ return f"gh-{n_str}"
147
+ return f"gh-{n_str}-{_slugify(title)}"
148
+ if not title:
149
+ return f"gh-{n}"
150
+ return f"gh-{n}-{_slugify(title)}"
151
+
152
+ def update_issue_state(self, alias: str, new_state: str) -> None:
153
+ """Lifecycle automation reserved for a future plan."""
154
+ raise NotImplementedError(
155
+ "GitHubIssuesProvider.update_issue_state is not implemented in v1.",
156
+ )
157
+
158
+ def parse_alias(self, alias: str) -> str | None:
159
+ """Recognize GitHub-shaped aliases. See ``_GH_ALIAS`` shapes.
160
+
161
+ Returns the canonical alias string (which ``get_issue`` can
162
+ consume) when recognized, ``None`` otherwise.
163
+ """
164
+ s = alias.strip()
165
+ # Full issue URL — return the issue id (provider knows its repo).
166
+ url_match = re.match(
167
+ r"^https?://github\.com/([\w\-.]+)/([\w\-.]+)/issues/(\d+)/?$", s,
168
+ )
169
+ if url_match:
170
+ owner, repo, num = url_match.group(1), url_match.group(2), url_match.group(3)
171
+ return f"{owner}/{repo}#{num}"
172
+ # owner/repo#N
173
+ if re.match(r"^[\w\-.]+/[\w\-.]+#\d+$", s):
174
+ return s
175
+ # #N or bare N
176
+ if re.match(r"^#?\d+$", s):
177
+ return s.lstrip("#")
178
+ return None
179
+
180
+ # ── Internal ────────────────────────────────────────────────────────
181
+
182
+ def _parse_alias(self, alias: str) -> tuple[str, int]:
183
+ """Parse an alias into (repo, issue_number).
184
+
185
+ Accepts:
186
+ - ``"#142"`` → (self.repo, 142)
187
+ - ``"142"`` → (self.repo, 142)
188
+ - ``"owner/repo#142"`` → ("owner/repo", 142)
189
+ """
190
+ # owner/repo#142
191
+ m = re.match(r"^([\w\-.]+/[\w\-.]+)#(\d+)$", alias)
192
+ if m:
193
+ return m.group(1), int(m.group(2))
194
+ # #142 or 142
195
+ m = re.match(r"^#?(\d+)$", alias)
196
+ if m:
197
+ return self.repo, int(m.group(1))
198
+ raise IssueNotFoundError(
199
+ f"Can't parse GitHub issue alias '{alias}'. Use '#142', '142', or 'owner/repo#142'.",
200
+ )
201
+
202
+
203
+ # ── Module-level helpers ────────────────────────────────────────────────
204
+
205
+
206
+ def _gh_json(args: list[str]) -> Any:
207
+ """``_gh`` wrapper that JSON-decodes stdout. Empty stdout → ``None``.
208
+
209
+ Lifted to module level so tests can monkeypatch it without touching
210
+ the underlying subprocess machinery in ``integrations/github.py``.
211
+ """
212
+ out = _gh(args)
213
+ if not out.strip():
214
+ return None
215
+ try:
216
+ return json.loads(out)
217
+ except json.JSONDecodeError as e:
218
+ raise IssueProviderError(f"gh returned non-JSON output: {e}") from e
219
+
220
+
221
+ def _to_issue(raw: dict, *, default_repo: str) -> Issue:
222
+ """Map a GitHub API issue payload to a canonical ``Issue``.
223
+
224
+ Used by both ``get_issue`` (which fetches a single issue) and
225
+ ``list_my_issues`` (which gets a list from the search endpoint).
226
+ Search results have a slightly different shape (top-level fields are
227
+ similar but ``html_url`` may be missing; ``url`` is the API URL).
228
+ """
229
+ state_raw = raw.get("state") or ""
230
+ state_reason = raw.get("state_reason") or ""
231
+ canonical_state = _GH_STATE_MAP.get(state_raw.lower(), "todo")
232
+ if canonical_state == "done" and state_reason == "not_planned":
233
+ canonical_state = "cancelled"
234
+
235
+ labels_raw = raw.get("labels") or []
236
+ labels: tuple[str, ...] = tuple(
237
+ l.get("name") if isinstance(l, dict) else str(l)
238
+ for l in labels_raw
239
+ if (isinstance(l, dict) and l.get("name")) or isinstance(l, str)
240
+ )
241
+
242
+ assignees = raw.get("assignees") or []
243
+ assignee = (
244
+ assignees[0].get("login")
245
+ if assignees and isinstance(assignees[0], dict)
246
+ else (raw.get("assignee", {}) or {}).get("login")
247
+ )
248
+
249
+ # Search results put repo info in raw["repository"]["nameWithOwner"];
250
+ # single-issue responses derive from URL.
251
+ repository = raw.get("repository") or {}
252
+ if isinstance(repository, dict):
253
+ repo_name = repository.get("nameWithOwner") or repository.get("full_name") or default_repo
254
+ else:
255
+ repo_name = default_repo
256
+
257
+ number = raw.get("number")
258
+ return Issue(
259
+ id=str(number),
260
+ identifier=f"#{number}",
261
+ title=raw.get("title") or "",
262
+ description=raw.get("body"),
263
+ state=canonical_state,
264
+ url=raw.get("html_url") or raw.get("url") or _make_html_url(repo_name, number),
265
+ assignee=assignee,
266
+ labels=labels,
267
+ priority=_priority_from_labels(labels),
268
+ raw=raw,
269
+ )
270
+
271
+
272
+ def _make_html_url(repo_name: str, number: int | None) -> str:
273
+ if not number:
274
+ return ""
275
+ return f"https://github.com/{repo_name}/issues/{number}"
276
+
277
+
278
+ def _priority_from_labels(labels: tuple[str, ...]) -> int | None:
279
+ for l in labels:
280
+ p = _PRIORITY_LABEL_MAP.get(l.lower())
281
+ if p is not None:
282
+ return p
283
+ return None
284
+
285
+
286
+ def _slugify(s: str) -> str:
287
+ s = re.sub(r"[^a-zA-Z0-9\s-]", "", s)
288
+ s = re.sub(r"\s+", "-", s.strip()).lower()
289
+ return s[:50].rstrip("-")