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/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Actions: completion-driven recipes that compose tools.
|
|
2
|
+
|
|
3
|
+
Every action accepts semantic context (feature, repo) and runs preconditions
|
|
4
|
+
→ steps → completion verification. Failures are returned as structured
|
|
5
|
+
BlockerError instances that consumers (CLI, MCP, extension) render or react
|
|
6
|
+
to in a uniform way.
|
|
7
|
+
"""
|
|
8
|
+
from .drift import (
|
|
9
|
+
DriftReport,
|
|
10
|
+
FeatureDrift,
|
|
11
|
+
RepoAlignment,
|
|
12
|
+
assert_aligned,
|
|
13
|
+
detect_drift,
|
|
14
|
+
)
|
|
15
|
+
from .errors import (
|
|
16
|
+
ActionError,
|
|
17
|
+
BlockerError,
|
|
18
|
+
FailedError,
|
|
19
|
+
FixAction,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ActionError",
|
|
24
|
+
"BlockerError",
|
|
25
|
+
"DriftReport",
|
|
26
|
+
"FailedError",
|
|
27
|
+
"FeatureDrift",
|
|
28
|
+
"FixAction",
|
|
29
|
+
"RepoAlignment",
|
|
30
|
+
"assert_aligned",
|
|
31
|
+
"detect_drift",
|
|
32
|
+
]
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""Alias resolution for read primitives.
|
|
2
|
+
|
|
3
|
+
The agent (and humans) pass a single alias like ``TEAM-101`` to any read
|
|
4
|
+
tool and canopy figures out what to fetch. Each tool also accepts its
|
|
5
|
+
native specific form for direct lookups when the caller already has a
|
|
6
|
+
concrete reference.
|
|
7
|
+
|
|
8
|
+
Supported alias forms:
|
|
9
|
+
- Feature alias: feature name (e.g. ``auth-flow``) or Linear issue ID
|
|
10
|
+
(e.g. ``TEAM-101``). Resolves via ``FeatureCoordinator._resolve_name``
|
|
11
|
+
+ ``features.json`` ``linear_issue`` field.
|
|
12
|
+
- PR specific: ``<repo>#<pr_number>`` (e.g. ``api#142``) or a GitHub PR
|
|
13
|
+
URL.
|
|
14
|
+
- Branch specific: ``<repo>:<branch>`` (e.g. ``api:auth-flow``).
|
|
15
|
+
- **Slot id:** ``worktree-N`` resolves to the feature currently in that
|
|
16
|
+
slot. ``BlockerError(empty_slot)`` when the slot is empty;
|
|
17
|
+
``BlockerError(unknown_slot)`` when N is out of range.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from ..workspace.workspace import Workspace
|
|
25
|
+
from .errors import BlockerError, FixAction
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_LINEAR_ID = re.compile(r"^[A-Z]+-\d+$", re.IGNORECASE)
|
|
29
|
+
_PR_SPECIFIC = re.compile(r"^([A-Za-z0-9_.-]+)#(\d+)$")
|
|
30
|
+
_BRANCH_SPECIFIC = re.compile(r"^([A-Za-z0-9_.-]+):(.+)$")
|
|
31
|
+
_PR_URL = re.compile(r"^https?://github\.com/([^/]+)/([^/]+)/pull/(\d+)")
|
|
32
|
+
_SLOT_ID = re.compile(r"^worktree-(\d+)$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class PRTarget:
|
|
37
|
+
repo: str # canopy repo name
|
|
38
|
+
owner: str # github owner
|
|
39
|
+
repo_slug: str # github repo
|
|
40
|
+
pr_number: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class BranchTarget:
|
|
45
|
+
repo: str # canopy repo name
|
|
46
|
+
branch: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_feature(workspace: Workspace, alias: str) -> str:
|
|
50
|
+
"""Resolve a feature alias to a canonical feature name.
|
|
51
|
+
|
|
52
|
+
Resolution order:
|
|
53
|
+
0. Slot id (``worktree-N``) — resolves to the feature occupying that slot.
|
|
54
|
+
1. Explicit lane in ``features.json``.
|
|
55
|
+
2. ``features.json`` lane via ``branches`` mapping (per-repo branch overrides).
|
|
56
|
+
3. Implicit multi-repo feature (``workspace.active_features()`` —
|
|
57
|
+
branch present in 2+ repos).
|
|
58
|
+
4. Single-repo implicit feature (branch present in any registered repo).
|
|
59
|
+
|
|
60
|
+
Step 4 lets single-repo features resolve without an explicit
|
|
61
|
+
features.json entry. Without it, queries like ``canopy comments
|
|
62
|
+
auth-flow-api-only`` fail when only one repo carries the branch.
|
|
63
|
+
"""
|
|
64
|
+
# Step 0: slot-id alias form — must come before _resolve_name, which
|
|
65
|
+
# treats unknown strings as implicit feature names.
|
|
66
|
+
m = _SLOT_ID.match(alias)
|
|
67
|
+
if m:
|
|
68
|
+
from . import slots as slots_mod
|
|
69
|
+
cap = workspace.config.slots
|
|
70
|
+
n = int(m.group(1))
|
|
71
|
+
if n < 1 or n > cap:
|
|
72
|
+
raise BlockerError(
|
|
73
|
+
code="unknown_slot",
|
|
74
|
+
what=f"slot '{alias}' is out of range (cap={cap})",
|
|
75
|
+
details={"slot": alias, "cap": cap},
|
|
76
|
+
)
|
|
77
|
+
state = slots_mod.read_state(workspace)
|
|
78
|
+
if state is None or alias not in state.slots:
|
|
79
|
+
raise BlockerError(
|
|
80
|
+
code="empty_slot",
|
|
81
|
+
what=f"slot '{alias}' is empty",
|
|
82
|
+
details={"slot": alias, "cap": cap},
|
|
83
|
+
)
|
|
84
|
+
return state.slots[alias].feature
|
|
85
|
+
|
|
86
|
+
from ..features.coordinator import FeatureCoordinator
|
|
87
|
+
from ..git import repo as git
|
|
88
|
+
coord = FeatureCoordinator(workspace)
|
|
89
|
+
try:
|
|
90
|
+
resolved = coord._resolve_name(alias)
|
|
91
|
+
except ValueError as e:
|
|
92
|
+
raise BlockerError(
|
|
93
|
+
code="ambiguous_alias",
|
|
94
|
+
what=str(e),
|
|
95
|
+
details={"alias": alias},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
features = coord._load_features()
|
|
99
|
+
if resolved in features:
|
|
100
|
+
return resolved
|
|
101
|
+
|
|
102
|
+
# Step 2: alias may be a per-repo branch in some lane's branches map.
|
|
103
|
+
for fname, fdata in features.items():
|
|
104
|
+
branches_map = fdata.get("branches") or {}
|
|
105
|
+
if resolved in branches_map.values():
|
|
106
|
+
return fname
|
|
107
|
+
|
|
108
|
+
workspace.refresh()
|
|
109
|
+
if resolved in workspace.active_features():
|
|
110
|
+
return resolved
|
|
111
|
+
|
|
112
|
+
# Step 4: single-repo branch fallback.
|
|
113
|
+
for state in workspace.repos:
|
|
114
|
+
try:
|
|
115
|
+
if git.branch_exists(state.abs_path, resolved):
|
|
116
|
+
return resolved
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
raise BlockerError(
|
|
121
|
+
code="unknown_alias",
|
|
122
|
+
what=f"no feature lane matches alias '{alias}'",
|
|
123
|
+
expected={
|
|
124
|
+
"explicit_features": sorted(features.keys()),
|
|
125
|
+
"implicit_features": sorted(workspace.active_features()),
|
|
126
|
+
},
|
|
127
|
+
details={"alias": alias, "resolved_to": resolved},
|
|
128
|
+
fix_actions=[
|
|
129
|
+
FixAction(action="list", args={}, safe=True,
|
|
130
|
+
preview="canopy list shows all feature lanes"),
|
|
131
|
+
],
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def repos_for_feature(
|
|
136
|
+
workspace: Workspace, feature_name: str,
|
|
137
|
+
) -> dict[str, str]:
|
|
138
|
+
"""Return ``{repo_name: expected_branch_name}`` for the feature.
|
|
139
|
+
|
|
140
|
+
Resolution:
|
|
141
|
+
- If ``feature_name`` is in ``features.json``: return all declared
|
|
142
|
+
``repos`` with their expected branch (per-repo ``branches`` map
|
|
143
|
+
override, else feature name). Missing branches are NOT filtered
|
|
144
|
+
— callers (e.g. realign) need to know about declared repos
|
|
145
|
+
whose branch is gone, to report ``branch_not_found``.
|
|
146
|
+
- Otherwise (implicit feature): scan workspace repos and include
|
|
147
|
+
each where a branch named ``feature_name`` exists.
|
|
148
|
+
"""
|
|
149
|
+
from ..features.coordinator import FeatureCoordinator
|
|
150
|
+
from ..git import repo as git
|
|
151
|
+
|
|
152
|
+
coord = FeatureCoordinator(workspace)
|
|
153
|
+
features = coord._load_features()
|
|
154
|
+
|
|
155
|
+
if feature_name in features:
|
|
156
|
+
fdata = features[feature_name]
|
|
157
|
+
branches_map = fdata.get("branches") or {}
|
|
158
|
+
return {
|
|
159
|
+
repo_name: branches_map.get(repo_name, feature_name)
|
|
160
|
+
for repo_name in (fdata.get("repos") or [])
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
# Implicit: scan repos for the branch.
|
|
164
|
+
out: dict[str, str] = {}
|
|
165
|
+
for state in workspace.repos:
|
|
166
|
+
try:
|
|
167
|
+
if git.branch_exists(state.abs_path, feature_name):
|
|
168
|
+
out[state.config.name] = feature_name
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def resolve_issue_id(workspace: Workspace, alias: str) -> str:
|
|
175
|
+
"""Resolve an alias to the active issue provider's canonical id (M5+).
|
|
176
|
+
|
|
177
|
+
Resolution order:
|
|
178
|
+
1. **Provider parse** — ask the configured provider whether it
|
|
179
|
+
recognises the alias shape (e.g. ``SIN-412`` for Linear,
|
|
180
|
+
``5`` / ``#5`` / ``owner/repo#5`` / GitHub URL for GitHub Issues).
|
|
181
|
+
If yes, use the provider-canonicalised form.
|
|
182
|
+
2. **Feature-lane lookup** — treat the alias as a feature name
|
|
183
|
+
(e.g. ``auth-flow``) and read ``linear_issue`` from
|
|
184
|
+
features.json. (The field is still named ``linear_issue`` for
|
|
185
|
+
back-compat; treat it as "linked issue id".)
|
|
186
|
+
3. **Fail loud** — ``BlockerError(code='no_linked_issue')`` with
|
|
187
|
+
helpful fix actions.
|
|
188
|
+
|
|
189
|
+
Replaces the pre-M5 ``resolve_linear_id`` (kept as a deprecated
|
|
190
|
+
alias below), which only knew about Linear-shaped IDs and broke the
|
|
191
|
+
CLI for any other provider — see test-findings F-7.
|
|
192
|
+
"""
|
|
193
|
+
from ..providers import get_issue_provider, ProviderNotConfigured
|
|
194
|
+
|
|
195
|
+
# Step 1 — provider parse.
|
|
196
|
+
try:
|
|
197
|
+
provider = get_issue_provider(workspace)
|
|
198
|
+
except ProviderNotConfigured:
|
|
199
|
+
provider = None
|
|
200
|
+
if provider is not None:
|
|
201
|
+
try:
|
|
202
|
+
parsed = provider.parse_alias(alias)
|
|
203
|
+
except AttributeError:
|
|
204
|
+
# Older provider that hasn't implemented parse_alias yet —
|
|
205
|
+
# fall through to feature-lane lookup.
|
|
206
|
+
parsed = None
|
|
207
|
+
if parsed:
|
|
208
|
+
return parsed
|
|
209
|
+
|
|
210
|
+
# Step 2 — feature-lane lookup.
|
|
211
|
+
try:
|
|
212
|
+
feature_name = resolve_feature(workspace, alias)
|
|
213
|
+
except BlockerError:
|
|
214
|
+
# Not a recognised provider shape AND not a feature name.
|
|
215
|
+
# Re-raise with provider-aware fix-actions.
|
|
216
|
+
provider_name = (
|
|
217
|
+
workspace.config.issue_provider.name
|
|
218
|
+
if hasattr(workspace.config, "issue_provider")
|
|
219
|
+
else "issue provider"
|
|
220
|
+
)
|
|
221
|
+
raise BlockerError(
|
|
222
|
+
code="unknown_alias",
|
|
223
|
+
what=f"alias '{alias}' isn't a {provider_name} id, an issue URL, or a feature name",
|
|
224
|
+
details={"alias": alias, "provider": provider_name},
|
|
225
|
+
fix_actions=[
|
|
226
|
+
FixAction(
|
|
227
|
+
action="list", args={}, safe=True,
|
|
228
|
+
preview="canopy list shows all feature lanes",
|
|
229
|
+
),
|
|
230
|
+
],
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
from ..features.coordinator import FeatureCoordinator
|
|
234
|
+
features = FeatureCoordinator(workspace)._load_features()
|
|
235
|
+
feature = features.get(feature_name) or {}
|
|
236
|
+
linear_id = feature.get("linear_issue")
|
|
237
|
+
if not linear_id:
|
|
238
|
+
raise BlockerError(
|
|
239
|
+
code="no_linked_issue",
|
|
240
|
+
what=f"feature '{feature_name}' has no linked issue",
|
|
241
|
+
details={"alias": alias, "feature": feature_name},
|
|
242
|
+
fix_actions=[
|
|
243
|
+
FixAction(
|
|
244
|
+
action="feature_link_linear",
|
|
245
|
+
args={"feature": feature_name, "issue": "<ID>"},
|
|
246
|
+
safe=True,
|
|
247
|
+
preview="link an issue id to this feature lane",
|
|
248
|
+
),
|
|
249
|
+
],
|
|
250
|
+
)
|
|
251
|
+
return linear_id
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# Deprecated alias kept for back-compat with existing imports
|
|
255
|
+
# (linear_get_issue, the legacy MCP tool, tests). New code calls
|
|
256
|
+
# ``resolve_issue_id``.
|
|
257
|
+
def resolve_linear_id(workspace: Workspace, alias: str) -> str:
|
|
258
|
+
"""**Deprecated.** Renamed to ``resolve_issue_id`` (M5+).
|
|
259
|
+
|
|
260
|
+
Functionally equivalent — provider-aware when the workspace has an
|
|
261
|
+
``[issue_provider]`` configured. The old name leaks Linear-ness;
|
|
262
|
+
new call sites should use ``resolve_issue_id``. The legacy error
|
|
263
|
+
code ``no_linear_id`` is also reissued as ``no_linked_issue``.
|
|
264
|
+
"""
|
|
265
|
+
try:
|
|
266
|
+
return resolve_issue_id(workspace, alias)
|
|
267
|
+
except BlockerError as err:
|
|
268
|
+
# Surface the legacy code so existing assertions on
|
|
269
|
+
# ``no_linear_id`` keep working until callers migrate.
|
|
270
|
+
if err.code == "no_linked_issue":
|
|
271
|
+
raise BlockerError(
|
|
272
|
+
code="no_linear_id",
|
|
273
|
+
what=err.what,
|
|
274
|
+
details=err.details,
|
|
275
|
+
fix_actions=err.fix_actions,
|
|
276
|
+
) from None
|
|
277
|
+
raise
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def resolve_pr_targets(workspace: Workspace, alias: str) -> list[PRTarget]:
|
|
281
|
+
"""Resolve an alias to one or more PR targets.
|
|
282
|
+
|
|
283
|
+
Accepts:
|
|
284
|
+
- PR URL (specific PR)
|
|
285
|
+
- ``<repo>#<n>`` (specific PR)
|
|
286
|
+
- Feature alias (all PRs in the lane, across repos — uses per-repo
|
|
287
|
+
branches map when set)
|
|
288
|
+
"""
|
|
289
|
+
m = _PR_URL.match(alias)
|
|
290
|
+
if m:
|
|
291
|
+
owner, repo_slug, pr = m.group(1), m.group(2), int(m.group(3))
|
|
292
|
+
canopy_repo = _find_canopy_repo_by_slug(workspace, owner, repo_slug)
|
|
293
|
+
return [PRTarget(canopy_repo, owner, repo_slug, pr)]
|
|
294
|
+
|
|
295
|
+
m = _PR_SPECIFIC.match(alias)
|
|
296
|
+
if m:
|
|
297
|
+
canopy_repo, pr = m.group(1), int(m.group(2))
|
|
298
|
+
if canopy_repo not in {r.config.name for r in workspace.repos}:
|
|
299
|
+
raise BlockerError(
|
|
300
|
+
code="unknown_repo",
|
|
301
|
+
what=f"no repo '{canopy_repo}' in workspace",
|
|
302
|
+
expected={"available_repos": sorted(r.config.name for r in workspace.repos)},
|
|
303
|
+
details={"alias": alias},
|
|
304
|
+
)
|
|
305
|
+
owner, repo_slug = _resolve_owner_slug(workspace, canopy_repo)
|
|
306
|
+
return [PRTarget(canopy_repo, owner, repo_slug, pr)]
|
|
307
|
+
|
|
308
|
+
feature_name = resolve_feature(workspace, alias)
|
|
309
|
+
repo_branches = repos_for_feature(workspace, feature_name)
|
|
310
|
+
|
|
311
|
+
# Imported here (not at module top) to avoid a circular import: github
|
|
312
|
+
# imports from canopy.actions.errors which imports from this package.
|
|
313
|
+
from ..integrations import github as _gh
|
|
314
|
+
|
|
315
|
+
targets: list[PRTarget] = []
|
|
316
|
+
for canopy_repo, branch in repo_branches.items():
|
|
317
|
+
try:
|
|
318
|
+
owner, repo_slug = _resolve_owner_slug(workspace, canopy_repo)
|
|
319
|
+
except BlockerError:
|
|
320
|
+
continue
|
|
321
|
+
pr = _gh.find_pull_request(workspace.config.root, owner, repo_slug, branch)
|
|
322
|
+
if pr is None:
|
|
323
|
+
continue
|
|
324
|
+
targets.append(PRTarget(
|
|
325
|
+
repo=canopy_repo, owner=owner, repo_slug=repo_slug,
|
|
326
|
+
pr_number=pr["number"],
|
|
327
|
+
))
|
|
328
|
+
|
|
329
|
+
if not targets:
|
|
330
|
+
raise BlockerError(
|
|
331
|
+
code="no_prs_for_feature",
|
|
332
|
+
what=f"feature '{feature_name}' has no open PRs in any repo",
|
|
333
|
+
details={"alias": alias, "feature": feature_name,
|
|
334
|
+
"repos_checked": list(repo_branches)},
|
|
335
|
+
fix_actions=[
|
|
336
|
+
FixAction(action="pr_create", args={"feature": feature_name},
|
|
337
|
+
safe=False, preview="open PRs for this feature"),
|
|
338
|
+
],
|
|
339
|
+
)
|
|
340
|
+
return targets
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def resolve_branch_targets(
|
|
344
|
+
workspace: Workspace, alias: str, repo: str | None = None,
|
|
345
|
+
) -> list[BranchTarget]:
|
|
346
|
+
"""Resolve an alias to one or more branch targets.
|
|
347
|
+
|
|
348
|
+
Accepts:
|
|
349
|
+
- ``<repo>:<branch>`` (specific branch in specific repo)
|
|
350
|
+
- Feature alias (per-repo branches from the lane's ``branches`` map,
|
|
351
|
+
falling back to the feature name)
|
|
352
|
+
|
|
353
|
+
If ``repo`` is provided alongside a feature alias, filters to that repo.
|
|
354
|
+
"""
|
|
355
|
+
m = _BRANCH_SPECIFIC.match(alias)
|
|
356
|
+
if m:
|
|
357
|
+
canopy_repo, branch = m.group(1), m.group(2)
|
|
358
|
+
repo_names = {r.config.name for r in workspace.repos}
|
|
359
|
+
if canopy_repo not in repo_names:
|
|
360
|
+
raise BlockerError(
|
|
361
|
+
code="unknown_repo",
|
|
362
|
+
what=f"no repo '{canopy_repo}' in workspace",
|
|
363
|
+
expected={"available_repos": sorted(repo_names)},
|
|
364
|
+
details={"alias": alias},
|
|
365
|
+
)
|
|
366
|
+
if repo and canopy_repo != repo:
|
|
367
|
+
raise BlockerError(
|
|
368
|
+
code="alias_repo_mismatch",
|
|
369
|
+
what=f"alias specifies '{canopy_repo}' but repo='{repo}' was passed",
|
|
370
|
+
details={"alias": alias, "repo": repo},
|
|
371
|
+
)
|
|
372
|
+
return [BranchTarget(canopy_repo, branch)]
|
|
373
|
+
|
|
374
|
+
feature_name = resolve_feature(workspace, alias)
|
|
375
|
+
repo_branches = repos_for_feature(workspace, feature_name)
|
|
376
|
+
|
|
377
|
+
if repo:
|
|
378
|
+
if repo not in repo_branches:
|
|
379
|
+
raise BlockerError(
|
|
380
|
+
code="repo_not_in_feature",
|
|
381
|
+
what=f"repo '{repo}' is not part of feature '{feature_name}'",
|
|
382
|
+
expected={"feature_repos": list(repo_branches)},
|
|
383
|
+
details={"alias": alias, "repo": repo, "feature": feature_name},
|
|
384
|
+
)
|
|
385
|
+
return [BranchTarget(repo, repo_branches[repo])]
|
|
386
|
+
|
|
387
|
+
return [BranchTarget(r, b) for r, b in repo_branches.items()]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _find_canopy_repo_by_slug(workspace: Workspace, owner: str, slug: str) -> str:
|
|
391
|
+
from ..git import repo as git
|
|
392
|
+
target_lc = f"{owner}/{slug}".lower()
|
|
393
|
+
target_lc_no_dotgit = target_lc.removesuffix(".git")
|
|
394
|
+
for state in workspace.repos:
|
|
395
|
+
try:
|
|
396
|
+
url = git.remote_url(state.abs_path).lower().removesuffix(".git")
|
|
397
|
+
except Exception:
|
|
398
|
+
continue
|
|
399
|
+
if target_lc in url or target_lc_no_dotgit in url:
|
|
400
|
+
return state.config.name
|
|
401
|
+
raise BlockerError(
|
|
402
|
+
code="unknown_github_repo",
|
|
403
|
+
what=f"no canopy repo matches github {owner}/{slug}",
|
|
404
|
+
expected={"available_repos": sorted(r.config.name for r in workspace.repos)},
|
|
405
|
+
details={"owner": owner, "slug": slug},
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _resolve_owner_slug(workspace: Workspace, canopy_repo: str) -> tuple[str, str]:
|
|
410
|
+
from ..git import repo as git
|
|
411
|
+
from ..integrations.github import _extract_owner_repo
|
|
412
|
+
state = workspace.get_repo(canopy_repo)
|
|
413
|
+
url = git.remote_url(state.abs_path)
|
|
414
|
+
parsed = _extract_owner_repo(url)
|
|
415
|
+
if not parsed:
|
|
416
|
+
raise BlockerError(
|
|
417
|
+
code="unparseable_remote",
|
|
418
|
+
what=f"can't extract owner/repo from {canopy_repo} remote: {url}",
|
|
419
|
+
details={"canopy_repo": canopy_repo, "remote_url": url},
|
|
420
|
+
)
|
|
421
|
+
return parsed
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Per-workspace augment resolver (M2).
|
|
2
|
+
|
|
3
|
+
Augments are user-customizable behavioral overrides stored in canopy.toml.
|
|
4
|
+
Two-tier: workspace-level defaults under ``[augments]``, per-repo overrides
|
|
5
|
+
under ``[[repos]] augments = {...}``. Per-repo wins on key collision.
|
|
6
|
+
|
|
7
|
+
Consumed by:
|
|
8
|
+
- ``integrations/precommit.py`` — ``preflight_cmd`` overrides auto-detection.
|
|
9
|
+
- ``actions/feature_state.py`` — ``review_bots`` filters bot-vs-human
|
|
10
|
+
comment classification (M3 bot-tracking).
|
|
11
|
+
- ``actions/commit.py`` — ``auto_resolve_threads_on_address`` (T4): when
|
|
12
|
+
``true``, ``commit --address <id>`` automatically resolves the corresponding
|
|
13
|
+
GitHub review thread after a successful push. Overridden by CLI flags
|
|
14
|
+
``--resolve-thread`` / ``--no-resolve-thread``.
|
|
15
|
+
- (planned) future ``canopy test`` command — ``test_cmd`` per-repo.
|
|
16
|
+
|
|
17
|
+
The resolver is intentionally lenient: missing keys return ``None`` / empty
|
|
18
|
+
collections rather than raising. Validation that catches typos lives in
|
|
19
|
+
``canopy doctor`` (deferred).
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from ..workspace.config import WorkspaceConfig
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def repo_augments(workspace: WorkspaceConfig, repo_name: str) -> dict[str, Any]:
|
|
29
|
+
"""Merge workspace ``[augments]`` defaults with the per-repo override.
|
|
30
|
+
|
|
31
|
+
Per-repo wins on key collision. If the repo isn't in the workspace, the
|
|
32
|
+
workspace-level defaults are returned unchanged — useful when callers have
|
|
33
|
+
a path but haven't resolved which RepoConfig it belongs to.
|
|
34
|
+
"""
|
|
35
|
+
workspace_defaults = workspace.augments or {}
|
|
36
|
+
repo = next((r for r in workspace.repos if r.name == repo_name), None)
|
|
37
|
+
overrides = (repo.augments if repo else None) or {}
|
|
38
|
+
return {**workspace_defaults, **overrides}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def bot_authors(workspace: WorkspaceConfig) -> list[str]:
|
|
42
|
+
"""Return the configured bot-author substrings, lowercased.
|
|
43
|
+
|
|
44
|
+
Reads ``augments.review_bots`` from workspace defaults. Per-repo overrides
|
|
45
|
+
are deliberately ignored — bot authorship is a workspace-level concern
|
|
46
|
+
(the same CodeRabbit account comments across all repos in a workspace).
|
|
47
|
+
|
|
48
|
+
Returns an empty list when unset, in which case callers should fall back
|
|
49
|
+
to whatever default bot detection they had before (typically the
|
|
50
|
+
``author_type == "Bot"`` substring check on the GitHub PR-comment payload).
|
|
51
|
+
"""
|
|
52
|
+
raw = (workspace.augments or {}).get("review_bots", [])
|
|
53
|
+
if not isinstance(raw, list):
|
|
54
|
+
return []
|
|
55
|
+
return [str(s).lower() for s in raw if s]
|