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
|
@@ -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."""
|