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,1256 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Feature lane lifecycle management.
|
|
3
|
+
|
|
4
|
+
A feature lane is a coordination primitive that spans multiple repos.
|
|
5
|
+
It maps to real Git branches — one per participating repo — with
|
|
6
|
+
metadata tracked in .canopy/features.json.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field, asdict
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ..workspace.workspace import Workspace
|
|
16
|
+
from ..git import repo as git
|
|
17
|
+
from ..git.multi import create_branch_all, cross_repo_diff, find_type_overlaps
|
|
18
|
+
from ..providers import get_issue_provider
|
|
19
|
+
from ..actions import slots as slots_mod
|
|
20
|
+
|
|
21
|
+
# Default directory for worktrees, relative to workspace root. In Wave 3.0
|
|
22
|
+
# this contains generic numbered slot dirs (worktree-1, worktree-2, ...)
|
|
23
|
+
# whose feature occupancy is tracked in .canopy/state/slots.json.
|
|
24
|
+
_WORKTREE_DIR = ".canopy/worktrees"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WorktreeLimitError(Exception):
|
|
28
|
+
"""Worktree limit would be exceeded."""
|
|
29
|
+
def __init__(self, message: str, current: int = 0, limit: int = 0, stale: list[dict] | None = None):
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.current = current
|
|
32
|
+
self.limit = limit
|
|
33
|
+
self.stale = stale or []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class FeatureLane:
|
|
38
|
+
"""Metadata and live state for a feature lane."""
|
|
39
|
+
name: str
|
|
40
|
+
repos: list[str] # participating repo names
|
|
41
|
+
created_at: str = "" # ISO timestamp
|
|
42
|
+
status: str = "active" # active | merged | abandoned
|
|
43
|
+
|
|
44
|
+
# Optional integration links
|
|
45
|
+
linear_issue: str = "" # e.g. "ENG-123"
|
|
46
|
+
linear_title: str = "" # e.g. "Add payment processing"
|
|
47
|
+
linear_url: str = "" # e.g. "https://linear.app/..."
|
|
48
|
+
|
|
49
|
+
# Optional per-repo branch override. When unset, ``branch_for(repo)``
|
|
50
|
+
# returns the feature name (the historical default). When set,
|
|
51
|
+
# consumers should always go through ``branch_for`` to get the right
|
|
52
|
+
# branch name per repo. Used for cases like ``auth-flow`` (api) vs
|
|
53
|
+
# ``auth-flow-v2`` (ui) where the same feature has different branch
|
|
54
|
+
# names per repo.
|
|
55
|
+
branches: dict[str, str] = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
# Populated at query time (not persisted)
|
|
58
|
+
repo_states: dict[str, dict] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
def branch_for(self, repo: str) -> str:
|
|
61
|
+
"""Return the expected branch name for ``repo`` in this lane.
|
|
62
|
+
|
|
63
|
+
Falls back to the feature name if no per-repo override exists.
|
|
64
|
+
"""
|
|
65
|
+
return self.branches.get(repo) or self.name
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict:
|
|
68
|
+
d = {
|
|
69
|
+
"name": self.name,
|
|
70
|
+
"repos": self.repos,
|
|
71
|
+
"created_at": self.created_at,
|
|
72
|
+
"status": self.status,
|
|
73
|
+
"repo_states": self.repo_states,
|
|
74
|
+
}
|
|
75
|
+
if self.linear_issue:
|
|
76
|
+
d["linear_issue"] = self.linear_issue
|
|
77
|
+
d["linear_title"] = self.linear_title
|
|
78
|
+
d["linear_url"] = self.linear_url
|
|
79
|
+
if self.branches:
|
|
80
|
+
d["branches"] = dict(self.branches)
|
|
81
|
+
return d
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class FeatureCoordinator:
|
|
85
|
+
"""Manages feature lane lifecycle across a workspace."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, workspace: Workspace):
|
|
88
|
+
self.workspace = workspace
|
|
89
|
+
self._store_path = workspace.config.root / ".canopy" / "features.json"
|
|
90
|
+
|
|
91
|
+
def _resolve_name(self, name: str) -> str:
|
|
92
|
+
"""Resolve a short alias to a full feature name.
|
|
93
|
+
|
|
94
|
+
Supports:
|
|
95
|
+
- Exact match (returned as-is)
|
|
96
|
+
- Linear issue prefix (e.g. "ENG-412" → "ENG-412-add-oauth2-login")
|
|
97
|
+
- Unique prefix match (e.g. "ENG-412" matches if only one feature starts with it)
|
|
98
|
+
|
|
99
|
+
Raises ValueError if the alias is ambiguous (matches multiple features).
|
|
100
|
+
Returns the original name if no match is found (allows implicit features).
|
|
101
|
+
"""
|
|
102
|
+
features = self._load_features()
|
|
103
|
+
|
|
104
|
+
# Exact match — fast path
|
|
105
|
+
if name in features:
|
|
106
|
+
return name
|
|
107
|
+
|
|
108
|
+
# Prefix match: check if name is a prefix of exactly one feature
|
|
109
|
+
matches = [f for f in features if f.startswith(name)]
|
|
110
|
+
|
|
111
|
+
# Also check linear_issue field for issue-ID-only lookups
|
|
112
|
+
if not matches:
|
|
113
|
+
matches = [
|
|
114
|
+
f for f, data in features.items()
|
|
115
|
+
if data.get("linear_issue", "").upper() == name.upper()
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
if len(matches) == 1:
|
|
119
|
+
return matches[0]
|
|
120
|
+
elif len(matches) > 1:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"Ambiguous alias '{name}' matches: {', '.join(sorted(matches))}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# No match in features.json — return as-is for implicit feature detection
|
|
126
|
+
return name
|
|
127
|
+
|
|
128
|
+
def create(
|
|
129
|
+
self,
|
|
130
|
+
name: str,
|
|
131
|
+
repos: list[str] | None = None,
|
|
132
|
+
use_worktrees: bool = False,
|
|
133
|
+
worktree_base: Path | None = None,
|
|
134
|
+
linear_issue: str = "",
|
|
135
|
+
linear_title: str = "",
|
|
136
|
+
linear_url: str = "",
|
|
137
|
+
) -> FeatureLane:
|
|
138
|
+
"""Create a new feature lane.
|
|
139
|
+
|
|
140
|
+
Creates matching branches in all (or specified) repos and
|
|
141
|
+
records the feature in .canopy/features.json.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
name: Feature/branch name.
|
|
145
|
+
repos: Subset of repos (default: all).
|
|
146
|
+
use_worktrees: If True, create linked worktrees instead of
|
|
147
|
+
just branches. Each repo gets a worktree at
|
|
148
|
+
<worktree_base>/<feature>/<repo_name>.
|
|
149
|
+
worktree_base: Base directory for worktrees. Defaults to
|
|
150
|
+
<workspace_root>/.canopy/worktrees.
|
|
151
|
+
"""
|
|
152
|
+
target_repos = repos or [r.config.name for r in self.workspace.repos]
|
|
153
|
+
|
|
154
|
+
# Validate repos exist
|
|
155
|
+
known = {r.config.name for r in self.workspace.repos}
|
|
156
|
+
unknown = set(target_repos) - known
|
|
157
|
+
if unknown:
|
|
158
|
+
raise ValueError(f"Unknown repos: {', '.join(sorted(unknown))}")
|
|
159
|
+
|
|
160
|
+
worktree_paths: dict[str, str] = {}
|
|
161
|
+
allocated_slot: str | None = None
|
|
162
|
+
|
|
163
|
+
if use_worktrees:
|
|
164
|
+
# Wave 3.0: allocate a slot from .canopy/state/slots.json.
|
|
165
|
+
# The config's ``slots`` field is the warm-slot cap (canonical
|
|
166
|
+
# is separate). If all slots are full, raise WorktreeLimitError
|
|
167
|
+
# so the CLI / MCP can surface a fix action.
|
|
168
|
+
limit = self.workspace.config.slots
|
|
169
|
+
slot_state = slots_mod.read_state(self.workspace) or slots_mod.SlotState(
|
|
170
|
+
slot_count=limit,
|
|
171
|
+
)
|
|
172
|
+
# Honor the canopy.toml cap even if state was persisted with a
|
|
173
|
+
# different slot_count earlier.
|
|
174
|
+
slot_state.slot_count = limit
|
|
175
|
+
|
|
176
|
+
allocated_slot = slots_mod.allocate_slot(slot_state)
|
|
177
|
+
if allocated_slot is None:
|
|
178
|
+
stale = self._find_stale_worktrees()
|
|
179
|
+
current = len(slot_state.slots)
|
|
180
|
+
raise WorktreeLimitError(
|
|
181
|
+
f"Worktree limit reached ({current}/{limit}). "
|
|
182
|
+
f"Clean up with `canopy done <feature>` or raise the "
|
|
183
|
+
f"limit with `canopy config slots {limit + 1}`.",
|
|
184
|
+
current=current,
|
|
185
|
+
limit=limit,
|
|
186
|
+
stale=stale,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
base = worktree_base or (self.workspace.config.root / _WORKTREE_DIR)
|
|
190
|
+
feature_dir = base / allocated_slot
|
|
191
|
+
feature_dir.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
|
|
193
|
+
results: dict[str, bool | str] = {}
|
|
194
|
+
for repo_name in target_repos:
|
|
195
|
+
state = self.workspace.get_repo(repo_name)
|
|
196
|
+
wt_dest = feature_dir / repo_name
|
|
197
|
+
try:
|
|
198
|
+
git.worktree_add(
|
|
199
|
+
state.abs_path, wt_dest, name, create_branch=True,
|
|
200
|
+
)
|
|
201
|
+
results[repo_name] = True
|
|
202
|
+
worktree_paths[repo_name] = str(wt_dest)
|
|
203
|
+
except git.GitError as e:
|
|
204
|
+
results[repo_name] = str(e)
|
|
205
|
+
|
|
206
|
+
failed = {r: msg for r, msg in results.items() if msg is not True}
|
|
207
|
+
if len(failed) == len(target_repos):
|
|
208
|
+
raise RuntimeError(
|
|
209
|
+
f"Failed to create worktrees in all repos: {failed}"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Persist the slot occupancy + last_touched on success.
|
|
213
|
+
now = slots_mod.now_iso()
|
|
214
|
+
slot_state.slots[allocated_slot] = slots_mod.SlotEntry(
|
|
215
|
+
feature=name, occupied_at=now,
|
|
216
|
+
)
|
|
217
|
+
slot_state.last_touched[name] = now
|
|
218
|
+
slots_mod.write_state(self.workspace, slot_state)
|
|
219
|
+
else:
|
|
220
|
+
# Just create branches
|
|
221
|
+
results = create_branch_all(self.workspace, name, target_repos)
|
|
222
|
+
failed = {r: msg for r, msg in results.items() if msg is not True}
|
|
223
|
+
if len(failed) == len(target_repos):
|
|
224
|
+
raise RuntimeError(
|
|
225
|
+
f"Failed to create branch in all repos: {failed}"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Record the feature
|
|
229
|
+
lane = FeatureLane(
|
|
230
|
+
name=name,
|
|
231
|
+
repos=target_repos,
|
|
232
|
+
created_at=datetime.now(timezone.utc).isoformat(),
|
|
233
|
+
status="active",
|
|
234
|
+
linear_issue=linear_issue,
|
|
235
|
+
linear_title=linear_title,
|
|
236
|
+
linear_url=linear_url,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
features = self._load_features()
|
|
240
|
+
feature_data: dict = {
|
|
241
|
+
"repos": lane.repos,
|
|
242
|
+
"created_at": lane.created_at,
|
|
243
|
+
"status": lane.status,
|
|
244
|
+
}
|
|
245
|
+
if worktree_paths:
|
|
246
|
+
feature_data["worktree_paths"] = worktree_paths
|
|
247
|
+
feature_data["use_worktrees"] = True
|
|
248
|
+
if allocated_slot:
|
|
249
|
+
feature_data["slot_id"] = allocated_slot
|
|
250
|
+
if linear_issue:
|
|
251
|
+
feature_data["linear_issue"] = linear_issue
|
|
252
|
+
feature_data["linear_title"] = linear_title
|
|
253
|
+
feature_data["linear_url"] = linear_url
|
|
254
|
+
features[name] = feature_data
|
|
255
|
+
self._save_features(features)
|
|
256
|
+
|
|
257
|
+
return lane
|
|
258
|
+
|
|
259
|
+
def list_active(self) -> list[FeatureLane]:
|
|
260
|
+
"""List all active feature lanes with live state."""
|
|
261
|
+
features = self._load_features()
|
|
262
|
+
lanes = []
|
|
263
|
+
|
|
264
|
+
for name, data in features.items():
|
|
265
|
+
if data.get("status", "active") != "active":
|
|
266
|
+
continue
|
|
267
|
+
lane = FeatureLane(
|
|
268
|
+
name=name,
|
|
269
|
+
repos=data["repos"],
|
|
270
|
+
created_at=data.get("created_at", ""),
|
|
271
|
+
status=data.get("status", "active"),
|
|
272
|
+
linear_issue=data.get("linear_issue", ""),
|
|
273
|
+
linear_title=data.get("linear_title", ""),
|
|
274
|
+
linear_url=data.get("linear_url", ""),
|
|
275
|
+
branches=dict(data.get("branches") or {}),
|
|
276
|
+
)
|
|
277
|
+
self._enrich_lane(lane)
|
|
278
|
+
lanes.append(lane)
|
|
279
|
+
|
|
280
|
+
# Also detect implicit features (branches in 2+ repos not in features.json)
|
|
281
|
+
explicit_names = set(features.keys())
|
|
282
|
+
for branch_name in self.workspace.active_features():
|
|
283
|
+
if branch_name not in explicit_names:
|
|
284
|
+
# Find which repos have this branch
|
|
285
|
+
repos_with = []
|
|
286
|
+
for state in self.workspace.repos:
|
|
287
|
+
try:
|
|
288
|
+
if git.branch_exists(state.abs_path, branch_name):
|
|
289
|
+
repos_with.append(state.config.name)
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
if len(repos_with) >= 2:
|
|
293
|
+
lane = FeatureLane(
|
|
294
|
+
name=branch_name,
|
|
295
|
+
repos=repos_with,
|
|
296
|
+
status="active",
|
|
297
|
+
)
|
|
298
|
+
self._enrich_lane(lane)
|
|
299
|
+
lanes.append(lane)
|
|
300
|
+
|
|
301
|
+
return lanes
|
|
302
|
+
|
|
303
|
+
def status(self, name: str) -> FeatureLane:
|
|
304
|
+
"""Get detailed status for a feature lane."""
|
|
305
|
+
name = self._resolve_name(name)
|
|
306
|
+
features = self._load_features()
|
|
307
|
+
if name in features:
|
|
308
|
+
data = features[name]
|
|
309
|
+
lane = FeatureLane(
|
|
310
|
+
name=name,
|
|
311
|
+
repos=data["repos"],
|
|
312
|
+
created_at=data.get("created_at", ""),
|
|
313
|
+
status=data.get("status", "active"),
|
|
314
|
+
linear_issue=data.get("linear_issue", ""),
|
|
315
|
+
linear_title=data.get("linear_title", ""),
|
|
316
|
+
linear_url=data.get("linear_url", ""),
|
|
317
|
+
)
|
|
318
|
+
else:
|
|
319
|
+
# Implicit feature
|
|
320
|
+
repos = []
|
|
321
|
+
for state in self.workspace.repos:
|
|
322
|
+
if git.branch_exists(state.abs_path, name):
|
|
323
|
+
repos.append(state.config.name)
|
|
324
|
+
if not repos:
|
|
325
|
+
raise ValueError(f"Feature '{name}' not found")
|
|
326
|
+
lane = FeatureLane(name=name, repos=repos, status="active")
|
|
327
|
+
|
|
328
|
+
self._enrich_lane(lane)
|
|
329
|
+
return lane
|
|
330
|
+
|
|
331
|
+
def link_linear_issue(self, feature: str, issue: str) -> FeatureLane:
|
|
332
|
+
"""Attach a Linear issue to an existing feature lane.
|
|
333
|
+
|
|
334
|
+
Fetches issue data via the Linear MCP server and writes linear_issue,
|
|
335
|
+
linear_title, linear_url onto the lane's record in features.json.
|
|
336
|
+
Overwrites any previously linked issue.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
feature: Feature lane name or alias.
|
|
340
|
+
issue: Linear issue identifier (e.g. "ENG-412").
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
The updated FeatureLane (with enriched repo_states).
|
|
344
|
+
|
|
345
|
+
Raises:
|
|
346
|
+
ValueError: Feature not found in features.json.
|
|
347
|
+
ProviderNotConfigured: Issue provider isn't set up.
|
|
348
|
+
IssueNotFoundError: Issue can't be resolved.
|
|
349
|
+
"""
|
|
350
|
+
# M5: route through the provider registry. Method name kept as
|
|
351
|
+
# link_linear_issue for backward compat (callers + the MCP tool
|
|
352
|
+
# name); the linked issue can be from any configured provider.
|
|
353
|
+
name = self._resolve_name(feature)
|
|
354
|
+
features = self._load_features()
|
|
355
|
+
if name not in features:
|
|
356
|
+
raise ValueError(
|
|
357
|
+
f"Feature '{name}' not found in features.json — "
|
|
358
|
+
f"link_linear_issue only works on explicitly created lanes."
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
provider = get_issue_provider(self.workspace)
|
|
362
|
+
issue_data = provider.get_issue(issue)
|
|
363
|
+
features[name]["linear_issue"] = issue_data.identifier or issue
|
|
364
|
+
features[name]["linear_title"] = issue_data.title or ""
|
|
365
|
+
features[name]["linear_url"] = issue_data.url or ""
|
|
366
|
+
self._save_features(features)
|
|
367
|
+
|
|
368
|
+
return self.status(name)
|
|
369
|
+
|
|
370
|
+
def diff(self, name: str) -> dict:
|
|
371
|
+
"""Get aggregate diff for a feature lane across repos."""
|
|
372
|
+
name = self._resolve_name(name)
|
|
373
|
+
diff_data = cross_repo_diff(self.workspace, name)
|
|
374
|
+
overlaps = find_type_overlaps(self.workspace, name)
|
|
375
|
+
|
|
376
|
+
# Summary
|
|
377
|
+
total_files = sum(d["files_changed"] for d in diff_data.values())
|
|
378
|
+
total_ins = sum(d["insertions"] for d in diff_data.values())
|
|
379
|
+
total_del = sum(d["deletions"] for d in diff_data.values())
|
|
380
|
+
participating = sum(1 for d in diff_data.values() if d.get("has_branch"))
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
"feature": name,
|
|
384
|
+
"repos": diff_data,
|
|
385
|
+
"summary": {
|
|
386
|
+
"participating_repos": participating,
|
|
387
|
+
"total_repos": len(diff_data),
|
|
388
|
+
"total_files_changed": total_files,
|
|
389
|
+
"total_insertions": total_ins,
|
|
390
|
+
"total_deletions": total_del,
|
|
391
|
+
},
|
|
392
|
+
"type_overlaps": overlaps,
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
def feature_changes(self, name: str) -> dict:
|
|
396
|
+
"""Get per-file change status (M/A/D/?) for each repo in a feature.
|
|
397
|
+
|
|
398
|
+
Includes uncommitted changes — uses the worktree path when one
|
|
399
|
+
exists so the listing matches what the user is editing.
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
{
|
|
403
|
+
"feature": str,
|
|
404
|
+
"repos": {
|
|
405
|
+
"<repo>": {
|
|
406
|
+
"has_branch": bool,
|
|
407
|
+
"path": str, # repo or worktree path used
|
|
408
|
+
"default_branch": str,
|
|
409
|
+
"changes": [{path, status}, ...],
|
|
410
|
+
"error": str | None,
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
"""
|
|
415
|
+
name = self._resolve_name(name)
|
|
416
|
+
lane = self.status(name)
|
|
417
|
+
result: dict[str, dict] = {}
|
|
418
|
+
|
|
419
|
+
for repo_name in lane.repos:
|
|
420
|
+
repo_state = lane.repo_states.get(repo_name, {})
|
|
421
|
+
try:
|
|
422
|
+
state = self.workspace.get_repo(repo_name)
|
|
423
|
+
except KeyError:
|
|
424
|
+
result[repo_name] = {"error": "repo not found"}
|
|
425
|
+
continue
|
|
426
|
+
|
|
427
|
+
base = repo_state.get("default_branch") or state.config.default_branch
|
|
428
|
+
wt_path = repo_state.get("worktree_path")
|
|
429
|
+
scan_path = Path(wt_path) if wt_path else state.abs_path
|
|
430
|
+
|
|
431
|
+
if not repo_state.get("has_branch"):
|
|
432
|
+
result[repo_name] = {
|
|
433
|
+
"has_branch": False,
|
|
434
|
+
"path": str(scan_path),
|
|
435
|
+
"default_branch": base,
|
|
436
|
+
"changes": [],
|
|
437
|
+
}
|
|
438
|
+
continue
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
changes = git.changed_files_with_status(scan_path, name, base)
|
|
442
|
+
result[repo_name] = {
|
|
443
|
+
"has_branch": True,
|
|
444
|
+
"path": str(scan_path),
|
|
445
|
+
"default_branch": base,
|
|
446
|
+
"changes": changes,
|
|
447
|
+
}
|
|
448
|
+
except git.GitError as e:
|
|
449
|
+
result[repo_name] = {
|
|
450
|
+
"has_branch": True,
|
|
451
|
+
"path": str(scan_path),
|
|
452
|
+
"default_branch": base,
|
|
453
|
+
"changes": [],
|
|
454
|
+
"error": str(e),
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return {"feature": name, "repos": result}
|
|
458
|
+
|
|
459
|
+
def merge_readiness(self, name: str) -> dict:
|
|
460
|
+
"""Check if a feature lane is ready to merge.
|
|
461
|
+
|
|
462
|
+
Checks:
|
|
463
|
+
- All repos are clean (no uncommitted changes)
|
|
464
|
+
- All branches are up to date with default
|
|
465
|
+
- No type overlaps detected
|
|
466
|
+
"""
|
|
467
|
+
name = self._resolve_name(name)
|
|
468
|
+
lane = self.status(name)
|
|
469
|
+
issues = []
|
|
470
|
+
|
|
471
|
+
for repo_name, state in lane.repo_states.items():
|
|
472
|
+
if state.get("dirty"):
|
|
473
|
+
issues.append(f"{repo_name}: has uncommitted changes")
|
|
474
|
+
if state.get("behind", 0) > 0:
|
|
475
|
+
issues.append(
|
|
476
|
+
f"{repo_name}: {state['behind']} commits behind "
|
|
477
|
+
f"{state.get('default_branch', 'default')}"
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
overlaps = find_type_overlaps(self.workspace, name)
|
|
481
|
+
if overlaps:
|
|
482
|
+
for o in overlaps:
|
|
483
|
+
issues.append(
|
|
484
|
+
f"Type overlap: '{o['file_pattern']}' modified in "
|
|
485
|
+
f"{', '.join(o['repos'])}"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
"feature": name,
|
|
490
|
+
"ready": len(issues) == 0,
|
|
491
|
+
"issues": issues,
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
def resolve_paths(self, name: str) -> dict[str, str]:
|
|
495
|
+
"""Get the working directory path for each repo in a feature lane.
|
|
496
|
+
|
|
497
|
+
For each repo, returns the best path to work in:
|
|
498
|
+
- If the feature occupies a warm slot → the slot's repo subdir
|
|
499
|
+
(``.canopy/worktrees/worktree-N/<repo>``)
|
|
500
|
+
- If the branch is checked out in a worktree (legacy/ad-hoc) →
|
|
501
|
+
that worktree path
|
|
502
|
+
- If the branch is the current branch in the repo → the repo path
|
|
503
|
+
- Otherwise → the repo path (caller may need to checkout first)
|
|
504
|
+
|
|
505
|
+
This is used by IDE launchers to know which directories to open.
|
|
506
|
+
"""
|
|
507
|
+
name = self._resolve_name(name)
|
|
508
|
+
lane = self.status(name)
|
|
509
|
+
paths: dict[str, str] = {}
|
|
510
|
+
|
|
511
|
+
# Wave 3.0: prefer the slot path when the feature is warm. This is
|
|
512
|
+
# the authoritative source for warm features.
|
|
513
|
+
slot_id = slots_mod.slot_for_feature(self.workspace, name)
|
|
514
|
+
|
|
515
|
+
for repo_name in lane.repos:
|
|
516
|
+
try:
|
|
517
|
+
state = self.workspace.get_repo(repo_name)
|
|
518
|
+
except KeyError:
|
|
519
|
+
continue
|
|
520
|
+
|
|
521
|
+
repo_state = lane.repo_states.get(repo_name, {})
|
|
522
|
+
|
|
523
|
+
# Priority 1: slot path (Wave 3.0 canonical-slot model)
|
|
524
|
+
if slot_id is not None:
|
|
525
|
+
slot_path = slots_mod.slot_worktree_path(
|
|
526
|
+
self.workspace, slot_id, repo_name,
|
|
527
|
+
)
|
|
528
|
+
if slot_path.exists():
|
|
529
|
+
paths[repo_name] = str(slot_path)
|
|
530
|
+
continue
|
|
531
|
+
# Priority 2: worktree path discovered by git (fallback)
|
|
532
|
+
if repo_state.get("worktree_path"):
|
|
533
|
+
paths[repo_name] = repo_state["worktree_path"]
|
|
534
|
+
# Priority 3: repo is on this branch
|
|
535
|
+
elif state.current_branch == name:
|
|
536
|
+
paths[repo_name] = str(state.abs_path)
|
|
537
|
+
# Priority 4: branch exists but not checked out — use repo path
|
|
538
|
+
elif repo_state.get("has_branch"):
|
|
539
|
+
paths[repo_name] = str(state.abs_path)
|
|
540
|
+
|
|
541
|
+
return paths
|
|
542
|
+
|
|
543
|
+
def _enrich_lane(self, lane: FeatureLane) -> None:
|
|
544
|
+
"""Populate repo_states with live Git data."""
|
|
545
|
+
for repo_name in lane.repos:
|
|
546
|
+
try:
|
|
547
|
+
state = self.workspace.get_repo(repo_name)
|
|
548
|
+
except KeyError:
|
|
549
|
+
lane.repo_states[repo_name] = {"error": "repo not found"}
|
|
550
|
+
continue
|
|
551
|
+
|
|
552
|
+
base = state.config.default_branch
|
|
553
|
+
has_branch = git.branch_exists(state.abs_path, lane.name)
|
|
554
|
+
|
|
555
|
+
if not has_branch:
|
|
556
|
+
lane.repo_states[repo_name] = {
|
|
557
|
+
"has_branch": False,
|
|
558
|
+
"ahead": 0,
|
|
559
|
+
"behind": 0,
|
|
560
|
+
"dirty": False,
|
|
561
|
+
"changed_files": [],
|
|
562
|
+
}
|
|
563
|
+
continue
|
|
564
|
+
|
|
565
|
+
try:
|
|
566
|
+
ahead, behind = git.divergence(
|
|
567
|
+
state.abs_path, lane.name, base
|
|
568
|
+
)
|
|
569
|
+
files = git.changed_files(state.abs_path, lane.name, base)
|
|
570
|
+
dirty = state.is_dirty if state.current_branch == lane.name else False
|
|
571
|
+
|
|
572
|
+
repo_state: dict = {
|
|
573
|
+
"has_branch": True,
|
|
574
|
+
"ahead": ahead,
|
|
575
|
+
"behind": behind,
|
|
576
|
+
"dirty": dirty,
|
|
577
|
+
"changed_files": files,
|
|
578
|
+
"changed_file_count": len(files),
|
|
579
|
+
"default_branch": base,
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
# Check if branch is checked out in a worktree
|
|
583
|
+
wt_path = git.worktree_for_branch(state.abs_path, lane.name)
|
|
584
|
+
if wt_path:
|
|
585
|
+
repo_state["worktree_path"] = wt_path
|
|
586
|
+
|
|
587
|
+
lane.repo_states[repo_name] = repo_state
|
|
588
|
+
except git.GitError as e:
|
|
589
|
+
lane.repo_states[repo_name] = {
|
|
590
|
+
"has_branch": True,
|
|
591
|
+
"error": str(e),
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
def worktrees_live(self) -> dict:
|
|
595
|
+
"""Live scan of all worktrees across the workspace.
|
|
596
|
+
|
|
597
|
+
Wave 3.0: returns slot-keyed view of warm features. Iterates the
|
|
598
|
+
``slots`` map from ``.canopy/state/slots.json`` (not feature-named
|
|
599
|
+
directories) and enriches each slot's repo subdirs with live git
|
|
600
|
+
state. Also includes git-level worktree info per main repo.
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
{
|
|
604
|
+
"slots": {
|
|
605
|
+
"worktree-1": {
|
|
606
|
+
"feature": "<feature>",
|
|
607
|
+
"repos": {
|
|
608
|
+
"<repo>": {
|
|
609
|
+
"path": str,
|
|
610
|
+
"branch": str,
|
|
611
|
+
"dirty": bool,
|
|
612
|
+
"dirty_count": int,
|
|
613
|
+
"dirty_files": [...],
|
|
614
|
+
"ahead": int,
|
|
615
|
+
"behind": int,
|
|
616
|
+
"default_branch": str,
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
},
|
|
621
|
+
"repos": {
|
|
622
|
+
"<repo>": {
|
|
623
|
+
"main_path": str,
|
|
624
|
+
"worktrees": [{"path": str, "branch": str, "sha": str}]
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
"""
|
|
629
|
+
# ── Part 1: walk the slots map from slots.json ────────────────
|
|
630
|
+
slots: dict = {}
|
|
631
|
+
slot_state = slots_mod.read_state(self.workspace)
|
|
632
|
+
if slot_state is not None:
|
|
633
|
+
for slot_id, entry in sorted(slot_state.slots.items()):
|
|
634
|
+
feat_name = entry.feature
|
|
635
|
+
slot_dir = (
|
|
636
|
+
self.workspace.config.root / _WORKTREE_DIR / slot_id
|
|
637
|
+
)
|
|
638
|
+
if not slot_dir.is_dir():
|
|
639
|
+
continue
|
|
640
|
+
repos_info: dict = {}
|
|
641
|
+
for repo_dir in sorted(slot_dir.iterdir()):
|
|
642
|
+
if not repo_dir.is_dir():
|
|
643
|
+
continue
|
|
644
|
+
repo_name = repo_dir.name
|
|
645
|
+
repo_entry: dict = {"path": str(repo_dir)}
|
|
646
|
+
try:
|
|
647
|
+
repo_entry["branch"] = git.current_branch(repo_dir)
|
|
648
|
+
porcelain = git.status_porcelain(repo_dir)
|
|
649
|
+
repo_entry["dirty"] = len(porcelain) > 0
|
|
650
|
+
repo_entry["dirty_count"] = len(porcelain)
|
|
651
|
+
repo_entry["dirty_files"] = [
|
|
652
|
+
f.get("path", "") for f in porcelain
|
|
653
|
+
]
|
|
654
|
+
default_branch = "main"
|
|
655
|
+
try:
|
|
656
|
+
state = self.workspace.get_repo(repo_name)
|
|
657
|
+
default_branch = state.config.default_branch
|
|
658
|
+
except KeyError:
|
|
659
|
+
pass
|
|
660
|
+
repo_entry["default_branch"] = default_branch
|
|
661
|
+
try:
|
|
662
|
+
ahead, behind = git.divergence(
|
|
663
|
+
repo_dir, repo_entry["branch"], default_branch,
|
|
664
|
+
)
|
|
665
|
+
repo_entry["ahead"] = ahead
|
|
666
|
+
repo_entry["behind"] = behind
|
|
667
|
+
except git.GitError:
|
|
668
|
+
repo_entry["ahead"] = 0
|
|
669
|
+
repo_entry["behind"] = 0
|
|
670
|
+
except git.GitError as e:
|
|
671
|
+
repo_entry["error"] = str(e)
|
|
672
|
+
repos_info[repo_name] = repo_entry
|
|
673
|
+
slots[slot_id] = {"feature": feat_name, "repos": repos_info}
|
|
674
|
+
|
|
675
|
+
# ── Part 2: git-level worktree info per main repo ────────────
|
|
676
|
+
repos_wt: dict = {}
|
|
677
|
+
for state in self.workspace.repos:
|
|
678
|
+
if not state.abs_path.exists():
|
|
679
|
+
continue
|
|
680
|
+
worktrees = git.worktree_list(state.abs_path)
|
|
681
|
+
repos_wt[state.config.name] = {
|
|
682
|
+
"main_path": str(state.abs_path),
|
|
683
|
+
"worktrees": worktrees,
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return {
|
|
687
|
+
"slots": slots,
|
|
688
|
+
"repos": repos_wt,
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
def done(self, name: str, force: bool = False) -> dict:
|
|
692
|
+
"""Clean up a feature lane: remove worktrees, delete branches, archive.
|
|
693
|
+
|
|
694
|
+
Steps:
|
|
695
|
+
1. Check if worktrees are dirty (fail unless --force)
|
|
696
|
+
2. Remove worktree directories
|
|
697
|
+
3. Delete local branches
|
|
698
|
+
4. Mark feature as 'done' in features.json
|
|
699
|
+
|
|
700
|
+
Args:
|
|
701
|
+
name: Feature lane name (or alias/Linear ID).
|
|
702
|
+
force: If True, remove even with dirty worktrees.
|
|
703
|
+
|
|
704
|
+
Returns:
|
|
705
|
+
{
|
|
706
|
+
"feature": str,
|
|
707
|
+
"worktrees_removed": {repo: path},
|
|
708
|
+
"branches_deleted": {repo: "ok" | error},
|
|
709
|
+
"archived": bool,
|
|
710
|
+
}
|
|
711
|
+
"""
|
|
712
|
+
name = self._resolve_name(name)
|
|
713
|
+
features = self._load_features()
|
|
714
|
+
feature_data = features.get(name, {})
|
|
715
|
+
repos = feature_data.get("repos", [])
|
|
716
|
+
|
|
717
|
+
# If not in features.json, try to find it as an implicit feature
|
|
718
|
+
if not repos:
|
|
719
|
+
for state in self.workspace.repos:
|
|
720
|
+
if git.branch_exists(state.abs_path, name):
|
|
721
|
+
repos.append(state.config.name)
|
|
722
|
+
if not repos:
|
|
723
|
+
raise ValueError(f"Feature '{name}' not found")
|
|
724
|
+
|
|
725
|
+
worktrees_removed: dict[str, str] = {}
|
|
726
|
+
branches_deleted: dict[str, str] = {}
|
|
727
|
+
|
|
728
|
+
# ── Step 1+2: Remove worktrees from the feature's slot ──
|
|
729
|
+
# Wave 3.0: look up the slot in .canopy/state/slots.json. The
|
|
730
|
+
# worktree dir is .canopy/worktrees/<slot_id>/, not /<feature>/.
|
|
731
|
+
slot_id = slots_mod.slot_for_feature(self.workspace, name)
|
|
732
|
+
wt_base: Path | None = None
|
|
733
|
+
if slot_id is not None:
|
|
734
|
+
wt_base = (
|
|
735
|
+
self.workspace.config.root / _WORKTREE_DIR / slot_id
|
|
736
|
+
)
|
|
737
|
+
if wt_base is not None and wt_base.is_dir():
|
|
738
|
+
for repo_dir in sorted(wt_base.iterdir()):
|
|
739
|
+
if not repo_dir.is_dir():
|
|
740
|
+
continue
|
|
741
|
+
repo_name = repo_dir.name
|
|
742
|
+
|
|
743
|
+
# Check dirty state
|
|
744
|
+
if not force:
|
|
745
|
+
try:
|
|
746
|
+
porcelain = git.status_porcelain(repo_dir)
|
|
747
|
+
if porcelain:
|
|
748
|
+
raise ValueError(
|
|
749
|
+
f"Worktree '{slot_id}/{repo_name}' has uncommitted changes. "
|
|
750
|
+
f"Use --force to remove anyway."
|
|
751
|
+
)
|
|
752
|
+
except git.GitError:
|
|
753
|
+
pass
|
|
754
|
+
|
|
755
|
+
# Find the main repo to remove worktree from
|
|
756
|
+
try:
|
|
757
|
+
state = self.workspace.get_repo(repo_name)
|
|
758
|
+
git.worktree_remove(state.abs_path, repo_dir, force=force)
|
|
759
|
+
worktrees_removed[repo_name] = str(repo_dir)
|
|
760
|
+
except (KeyError, git.GitError) as e:
|
|
761
|
+
# If git worktree remove fails, try to clean up manually
|
|
762
|
+
import shutil
|
|
763
|
+
try:
|
|
764
|
+
shutil.rmtree(repo_dir)
|
|
765
|
+
worktrees_removed[repo_name] = str(repo_dir)
|
|
766
|
+
except OSError:
|
|
767
|
+
worktrees_removed[repo_name] = f"error: {e}"
|
|
768
|
+
|
|
769
|
+
# Remove the slot directory if empty
|
|
770
|
+
try:
|
|
771
|
+
wt_base.rmdir()
|
|
772
|
+
except OSError:
|
|
773
|
+
pass
|
|
774
|
+
|
|
775
|
+
# ── Step 2b: Drop the slot entry from slots.json ──
|
|
776
|
+
if slot_id is not None:
|
|
777
|
+
slot_state = slots_mod.read_state(self.workspace)
|
|
778
|
+
if slot_state is not None:
|
|
779
|
+
slot_state.slots.pop(slot_id, None)
|
|
780
|
+
# If canonical pointed at this feature (wind-down), clear it.
|
|
781
|
+
if (
|
|
782
|
+
slot_state.canonical is not None
|
|
783
|
+
and slot_state.canonical.feature == name
|
|
784
|
+
):
|
|
785
|
+
slot_state.canonical = None
|
|
786
|
+
slot_state.last_touched.pop(name, None)
|
|
787
|
+
slots_mod.write_state(self.workspace, slot_state)
|
|
788
|
+
|
|
789
|
+
# ── Step 3: Delete local branches ──
|
|
790
|
+
for repo_name in repos:
|
|
791
|
+
try:
|
|
792
|
+
state = self.workspace.get_repo(repo_name)
|
|
793
|
+
except KeyError:
|
|
794
|
+
branches_deleted[repo_name] = "repo not found"
|
|
795
|
+
continue
|
|
796
|
+
|
|
797
|
+
if not git.branch_exists(state.abs_path, name):
|
|
798
|
+
branches_deleted[repo_name] = "no branch"
|
|
799
|
+
continue
|
|
800
|
+
|
|
801
|
+
# Don't delete if it's the current branch
|
|
802
|
+
current = git.current_branch(state.abs_path)
|
|
803
|
+
if current == name:
|
|
804
|
+
# Switch to default branch first
|
|
805
|
+
try:
|
|
806
|
+
git.checkout(state.abs_path, state.config.default_branch)
|
|
807
|
+
except git.GitError as e:
|
|
808
|
+
branches_deleted[repo_name] = f"could not switch away: {e}"
|
|
809
|
+
continue
|
|
810
|
+
|
|
811
|
+
try:
|
|
812
|
+
git.delete_branch(state.abs_path, name, force=force)
|
|
813
|
+
branches_deleted[repo_name] = "ok"
|
|
814
|
+
except git.GitError as e:
|
|
815
|
+
branches_deleted[repo_name] = str(e)
|
|
816
|
+
|
|
817
|
+
# ── Step 4: Archive in features.json ──
|
|
818
|
+
archived = False
|
|
819
|
+
if name in features:
|
|
820
|
+
features[name]["status"] = "done"
|
|
821
|
+
# Remove worktree paths since they no longer exist
|
|
822
|
+
features[name].pop("worktree_paths", None)
|
|
823
|
+
features[name].pop("use_worktrees", None)
|
|
824
|
+
features[name].pop("slot_id", None)
|
|
825
|
+
self._save_features(features)
|
|
826
|
+
archived = True
|
|
827
|
+
|
|
828
|
+
# ── Step 5: Drop canonical pointer if this feature is canonical ──
|
|
829
|
+
active_cleared = False
|
|
830
|
+
try:
|
|
831
|
+
state = slots_mod.read_state(self.workspace)
|
|
832
|
+
if state and state.canonical and state.canonical.feature == name:
|
|
833
|
+
state.canonical = None
|
|
834
|
+
slots_mod.write_state(self.workspace, state)
|
|
835
|
+
active_cleared = True
|
|
836
|
+
except Exception:
|
|
837
|
+
pass
|
|
838
|
+
|
|
839
|
+
return {
|
|
840
|
+
"feature": name,
|
|
841
|
+
"worktrees_removed": worktrees_removed,
|
|
842
|
+
"branches_deleted": branches_deleted,
|
|
843
|
+
"archived": archived,
|
|
844
|
+
"active_cleared": active_cleared,
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
def review_status(self, name: str) -> dict:
|
|
848
|
+
"""Check if PRs exist for a feature lane across repos.
|
|
849
|
+
|
|
850
|
+
For each repo, resolves the remote URL to owner/repo, then queries
|
|
851
|
+
GitHub MCP for an open PR matching the feature branch.
|
|
852
|
+
|
|
853
|
+
Returns:
|
|
854
|
+
{
|
|
855
|
+
"feature": str,
|
|
856
|
+
"has_prs": bool,
|
|
857
|
+
"repos": {
|
|
858
|
+
"<repo>": {
|
|
859
|
+
"branch": str,
|
|
860
|
+
"owner": str,
|
|
861
|
+
"repo_name": str,
|
|
862
|
+
"pr": {number, title, url, state, head_branch} | None,
|
|
863
|
+
"error": str (optional)
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
Raises:
|
|
869
|
+
ValueError: If the feature doesn't exist.
|
|
870
|
+
GitHubNotConfiguredError: If GitHub MCP is not configured.
|
|
871
|
+
"""
|
|
872
|
+
from ..integrations.github import (
|
|
873
|
+
is_github_configured,
|
|
874
|
+
find_pull_request,
|
|
875
|
+
_extract_owner_repo,
|
|
876
|
+
GitHubNotConfiguredError,
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
name = self._resolve_name(name)
|
|
880
|
+
|
|
881
|
+
if not is_github_configured(self.workspace.config.root):
|
|
882
|
+
raise GitHubNotConfiguredError(
|
|
883
|
+
"GitHub MCP not configured.\n"
|
|
884
|
+
"Add a 'github' entry to .canopy/mcps.json:\n"
|
|
885
|
+
" {\n"
|
|
886
|
+
' "github": {\n'
|
|
887
|
+
' "command": "npx",\n'
|
|
888
|
+
' "args": ["-y", "@modelcontextprotocol/server-github"],\n'
|
|
889
|
+
' "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_..."}\n'
|
|
890
|
+
" }\n"
|
|
891
|
+
" }"
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
lane = self.status(name)
|
|
895
|
+
results: dict[str, dict] = {}
|
|
896
|
+
has_any_pr = False
|
|
897
|
+
|
|
898
|
+
for repo_name in lane.repos:
|
|
899
|
+
try:
|
|
900
|
+
state = self.workspace.get_repo(repo_name)
|
|
901
|
+
except KeyError:
|
|
902
|
+
results[repo_name] = {"error": "repo not found"}
|
|
903
|
+
continue
|
|
904
|
+
|
|
905
|
+
remote = git.remote_url(state.abs_path)
|
|
906
|
+
if not remote:
|
|
907
|
+
results[repo_name] = {
|
|
908
|
+
"branch": name,
|
|
909
|
+
"error": "no remote URL configured",
|
|
910
|
+
}
|
|
911
|
+
continue
|
|
912
|
+
|
|
913
|
+
parsed = _extract_owner_repo(remote)
|
|
914
|
+
if not parsed:
|
|
915
|
+
results[repo_name] = {
|
|
916
|
+
"branch": name,
|
|
917
|
+
"error": f"could not parse GitHub owner/repo from: {remote}",
|
|
918
|
+
}
|
|
919
|
+
continue
|
|
920
|
+
|
|
921
|
+
owner, repo_slug = parsed
|
|
922
|
+
try:
|
|
923
|
+
pr = find_pull_request(
|
|
924
|
+
self.workspace.config.root, owner, repo_slug, name,
|
|
925
|
+
)
|
|
926
|
+
if pr:
|
|
927
|
+
has_any_pr = True
|
|
928
|
+
results[repo_name] = {
|
|
929
|
+
"branch": name,
|
|
930
|
+
"owner": owner,
|
|
931
|
+
"repo_name": repo_slug,
|
|
932
|
+
"pr": pr,
|
|
933
|
+
}
|
|
934
|
+
except Exception as e:
|
|
935
|
+
results[repo_name] = {
|
|
936
|
+
"branch": name,
|
|
937
|
+
"owner": owner,
|
|
938
|
+
"repo_name": repo_slug,
|
|
939
|
+
"pr": None,
|
|
940
|
+
"error": str(e),
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return {
|
|
944
|
+
"feature": name,
|
|
945
|
+
"has_prs": has_any_pr,
|
|
946
|
+
"repos": results,
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
def review_comments(self, name: str) -> dict:
|
|
950
|
+
"""Fetch PR review comments classified by temporal staleness.
|
|
951
|
+
|
|
952
|
+
Precondition: at least one repo in the lane must have a PR. If
|
|
953
|
+
none do, raises ``PullRequestNotFoundError``.
|
|
954
|
+
|
|
955
|
+
Per repo, threads are sorted into:
|
|
956
|
+
- ``actionable_threads``: full comment data; agent reads these
|
|
957
|
+
- ``likely_resolved_threads``: slim summary + addressing commit
|
|
958
|
+
- ``resolved_thread_count``: GitHub-flagged resolved (excluded)
|
|
959
|
+
|
|
960
|
+
See ``actions.review_filter.classify_threads`` for the algorithm
|
|
961
|
+
(validated against 4 real PRs in the research doc).
|
|
962
|
+
|
|
963
|
+
Returns:
|
|
964
|
+
{
|
|
965
|
+
"feature": str,
|
|
966
|
+
"actionable_count": int, # across all repos
|
|
967
|
+
"likely_resolved_count": int,
|
|
968
|
+
"resolved_thread_count": int,
|
|
969
|
+
"repos": {
|
|
970
|
+
"<repo>": {
|
|
971
|
+
"pr_number": int,
|
|
972
|
+
"pr_url": str,
|
|
973
|
+
"pr_title": str,
|
|
974
|
+
"latest_commit_at": str, # ISO 8601 of branch HEAD
|
|
975
|
+
"actionable_threads": [...],
|
|
976
|
+
"likely_resolved_threads": [...],
|
|
977
|
+
"resolved_thread_count": int,
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
Raises:
|
|
983
|
+
PullRequestNotFoundError: If no PR exists for any repo.
|
|
984
|
+
GitHubNotConfiguredError: If GitHub MCP is not configured.
|
|
985
|
+
"""
|
|
986
|
+
from ..integrations.github import (
|
|
987
|
+
get_review_comments,
|
|
988
|
+
PullRequestNotFoundError,
|
|
989
|
+
GitHubNotConfiguredError,
|
|
990
|
+
)
|
|
991
|
+
from ..actions.review_filter import classify_threads
|
|
992
|
+
|
|
993
|
+
name = self._resolve_name(name)
|
|
994
|
+
status = self.review_status(name)
|
|
995
|
+
if not status["has_prs"]:
|
|
996
|
+
raise PullRequestNotFoundError(
|
|
997
|
+
f"No open PRs found for feature '{name}' in any repo. "
|
|
998
|
+
"Push your branch and create a PR first."
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
results: dict[str, dict] = {}
|
|
1002
|
+
actionable_total = 0
|
|
1003
|
+
likely_resolved_total = 0
|
|
1004
|
+
resolved_total = 0
|
|
1005
|
+
|
|
1006
|
+
for repo_name, info in status["repos"].items():
|
|
1007
|
+
pr = info.get("pr")
|
|
1008
|
+
if not pr:
|
|
1009
|
+
continue
|
|
1010
|
+
|
|
1011
|
+
owner = info.get("owner", "")
|
|
1012
|
+
repo_slug = info.get("repo_name", "")
|
|
1013
|
+
pr_number = pr["number"]
|
|
1014
|
+
|
|
1015
|
+
try:
|
|
1016
|
+
comments, resolved_count = get_review_comments(
|
|
1017
|
+
self.workspace.config.root, owner, repo_slug, pr_number,
|
|
1018
|
+
)
|
|
1019
|
+
repo_state = self.workspace.get_repo(repo_name)
|
|
1020
|
+
branch = info.get("branch") or repo_state.current_branch
|
|
1021
|
+
classification = classify_threads(
|
|
1022
|
+
comments, repo_state.abs_path, branch,
|
|
1023
|
+
)
|
|
1024
|
+
# Promote the GitHub-resolved count from upstream filtering.
|
|
1025
|
+
classification["resolved_thread_count"] = resolved_count
|
|
1026
|
+
|
|
1027
|
+
actionable_total += len(classification["actionable_threads"])
|
|
1028
|
+
likely_resolved_total += len(classification["likely_resolved_threads"])
|
|
1029
|
+
resolved_total += resolved_count
|
|
1030
|
+
|
|
1031
|
+
results[repo_name] = {
|
|
1032
|
+
"pr_number": pr_number,
|
|
1033
|
+
"pr_url": pr.get("url", ""),
|
|
1034
|
+
"pr_title": pr.get("title", ""),
|
|
1035
|
+
**classification,
|
|
1036
|
+
}
|
|
1037
|
+
except Exception as e:
|
|
1038
|
+
results[repo_name] = {
|
|
1039
|
+
"pr_number": pr_number,
|
|
1040
|
+
"pr_url": pr.get("url", ""),
|
|
1041
|
+
"pr_title": pr.get("title", ""),
|
|
1042
|
+
"actionable_threads": [],
|
|
1043
|
+
"likely_resolved_threads": [],
|
|
1044
|
+
"resolved_thread_count": 0,
|
|
1045
|
+
"latest_commit_at": "",
|
|
1046
|
+
"error": str(e),
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
return {
|
|
1050
|
+
"feature": name,
|
|
1051
|
+
"actionable_count": actionable_total,
|
|
1052
|
+
"likely_resolved_count": likely_resolved_total,
|
|
1053
|
+
"resolved_thread_count": resolved_total,
|
|
1054
|
+
"repos": results,
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
def review_prep(self, name: str, message: str = "") -> dict:
|
|
1058
|
+
"""Run pre-commit hooks and stage changes for a feature lane.
|
|
1059
|
+
|
|
1060
|
+
This is the "get to commit-ready state" workflow:
|
|
1061
|
+
1. Resolve feature → repo paths (worktree or checked-out)
|
|
1062
|
+
2. Run pre-commit hooks in each repo
|
|
1063
|
+
3. Stage all changes (git add -A)
|
|
1064
|
+
4. Report results (does NOT commit — leaves that to the caller)
|
|
1065
|
+
|
|
1066
|
+
If message is provided, it's included in the result for the caller
|
|
1067
|
+
to use as a commit message.
|
|
1068
|
+
|
|
1069
|
+
Returns:
|
|
1070
|
+
{
|
|
1071
|
+
"feature": str,
|
|
1072
|
+
"message": str,
|
|
1073
|
+
"repos": {
|
|
1074
|
+
"<repo>": {
|
|
1075
|
+
"path": str,
|
|
1076
|
+
"precommit": {type, passed, output},
|
|
1077
|
+
"staged": bool,
|
|
1078
|
+
"dirty_count": int,
|
|
1079
|
+
"error": str (optional),
|
|
1080
|
+
}
|
|
1081
|
+
},
|
|
1082
|
+
"all_passed": bool,
|
|
1083
|
+
}
|
|
1084
|
+
"""
|
|
1085
|
+
from ..integrations.precommit import run_precommit
|
|
1086
|
+
from ..actions.augments import repo_augments
|
|
1087
|
+
|
|
1088
|
+
name = self._resolve_name(name)
|
|
1089
|
+
paths = self.resolve_paths(name)
|
|
1090
|
+
if not paths:
|
|
1091
|
+
raise ValueError(f"No working directories found for feature '{name}'")
|
|
1092
|
+
|
|
1093
|
+
results: dict[str, dict] = {}
|
|
1094
|
+
all_passed = True
|
|
1095
|
+
|
|
1096
|
+
for repo_name, path_str in paths.items():
|
|
1097
|
+
repo_path = Path(path_str)
|
|
1098
|
+
entry: dict = {"path": path_str}
|
|
1099
|
+
|
|
1100
|
+
# Run pre-commit hooks (honoring per-repo augments.preflight_cmd)
|
|
1101
|
+
try:
|
|
1102
|
+
augments = repo_augments(self.workspace.config, repo_name)
|
|
1103
|
+
pc_result = run_precommit(repo_path, augments=augments)
|
|
1104
|
+
entry["precommit"] = pc_result
|
|
1105
|
+
if not pc_result["passed"]:
|
|
1106
|
+
all_passed = False
|
|
1107
|
+
except Exception as e:
|
|
1108
|
+
entry["precommit"] = {
|
|
1109
|
+
"type": "error",
|
|
1110
|
+
"passed": False,
|
|
1111
|
+
"output": str(e),
|
|
1112
|
+
}
|
|
1113
|
+
all_passed = False
|
|
1114
|
+
|
|
1115
|
+
# Stage all changes
|
|
1116
|
+
try:
|
|
1117
|
+
porcelain = git.status_porcelain(repo_path)
|
|
1118
|
+
if porcelain:
|
|
1119
|
+
git._run(["add", "-A"], cwd=repo_path)
|
|
1120
|
+
entry["staged"] = True
|
|
1121
|
+
entry["dirty_count"] = len(porcelain)
|
|
1122
|
+
else:
|
|
1123
|
+
entry["staged"] = False
|
|
1124
|
+
entry["dirty_count"] = 0
|
|
1125
|
+
except git.GitError as e:
|
|
1126
|
+
entry["staged"] = False
|
|
1127
|
+
entry["dirty_count"] = 0
|
|
1128
|
+
entry["error"] = str(e)
|
|
1129
|
+
|
|
1130
|
+
results[repo_name] = entry
|
|
1131
|
+
|
|
1132
|
+
# Persist the result so feature_state can distinguish IN_PROGRESS
|
|
1133
|
+
# from READY_TO_COMMIT. Records HEAD sha per repo at the time the
|
|
1134
|
+
# preflight ran; freshness is decided by comparing those shas
|
|
1135
|
+
# against current HEADs.
|
|
1136
|
+
try:
|
|
1137
|
+
from ..actions.preflight_state import record_result
|
|
1138
|
+
head_sha_per_repo: dict[str, str] = {}
|
|
1139
|
+
for repo_name in paths.keys():
|
|
1140
|
+
try:
|
|
1141
|
+
repo_state = self.workspace.get_repo(repo_name)
|
|
1142
|
+
head_sha_per_repo[repo_name] = git.head_sha(repo_state.abs_path)
|
|
1143
|
+
except Exception:
|
|
1144
|
+
pass
|
|
1145
|
+
record_result(
|
|
1146
|
+
self.workspace.config.root, name,
|
|
1147
|
+
passed=all_passed,
|
|
1148
|
+
head_sha_per_repo=head_sha_per_repo,
|
|
1149
|
+
summary=("all checks passed" if all_passed
|
|
1150
|
+
else "one or more checks failed"),
|
|
1151
|
+
)
|
|
1152
|
+
except Exception:
|
|
1153
|
+
# State tracking is auxiliary; don't fail review_prep itself.
|
|
1154
|
+
pass
|
|
1155
|
+
|
|
1156
|
+
return {
|
|
1157
|
+
"feature": name,
|
|
1158
|
+
"message": message,
|
|
1159
|
+
"repos": results,
|
|
1160
|
+
"all_passed": all_passed,
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
def _count_active_worktrees(self) -> int:
|
|
1164
|
+
"""Count occupied slots from slots.json."""
|
|
1165
|
+
state = slots_mod.read_state(self.workspace)
|
|
1166
|
+
if state is None:
|
|
1167
|
+
return 0
|
|
1168
|
+
return len(state.slots)
|
|
1169
|
+
|
|
1170
|
+
def _find_stale_worktrees(self) -> list[dict]:
|
|
1171
|
+
"""Find slot-occupied features that are candidates for cleanup.
|
|
1172
|
+
|
|
1173
|
+
Wave 3.0: iterates the ``slots`` map in slots.json (not
|
|
1174
|
+
feature-named directories). A slot is 'stale' if its feature is:
|
|
1175
|
+
- Marked as done/merged/abandoned in features.json, OR
|
|
1176
|
+
- All its repos are clean (no dirty files) and the branch has
|
|
1177
|
+
been merged into default.
|
|
1178
|
+
|
|
1179
|
+
Returns a list of {name, slot_id, reason} dicts, most stale first.
|
|
1180
|
+
"""
|
|
1181
|
+
slot_state = slots_mod.read_state(self.workspace)
|
|
1182
|
+
if slot_state is None or not slot_state.slots:
|
|
1183
|
+
return []
|
|
1184
|
+
|
|
1185
|
+
features = self._load_features()
|
|
1186
|
+
stale = []
|
|
1187
|
+
|
|
1188
|
+
for slot_id, entry in sorted(slot_state.slots.items()):
|
|
1189
|
+
feat_name = entry.feature
|
|
1190
|
+
meta = features.get(feat_name, {})
|
|
1191
|
+
slot_dir = (
|
|
1192
|
+
self.workspace.config.root / _WORKTREE_DIR / slot_id
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
# Check if archived
|
|
1196
|
+
status = meta.get("status", "active")
|
|
1197
|
+
if status in ("done", "merged", "abandoned"):
|
|
1198
|
+
stale.append({
|
|
1199
|
+
"name": feat_name, "slot_id": slot_id,
|
|
1200
|
+
"reason": f"status: {status}",
|
|
1201
|
+
})
|
|
1202
|
+
continue
|
|
1203
|
+
|
|
1204
|
+
if not slot_dir.is_dir():
|
|
1205
|
+
continue
|
|
1206
|
+
|
|
1207
|
+
# Check if all repos are clean and merged
|
|
1208
|
+
all_clean = True
|
|
1209
|
+
all_merged = True
|
|
1210
|
+
for repo_dir in slot_dir.iterdir():
|
|
1211
|
+
if not repo_dir.is_dir():
|
|
1212
|
+
continue
|
|
1213
|
+
try:
|
|
1214
|
+
porcelain = git.status_porcelain(repo_dir)
|
|
1215
|
+
if porcelain:
|
|
1216
|
+
all_clean = False
|
|
1217
|
+
except git.GitError:
|
|
1218
|
+
pass
|
|
1219
|
+
|
|
1220
|
+
try:
|
|
1221
|
+
repo_name = repo_dir.name
|
|
1222
|
+
state = self.workspace.get_repo(repo_name)
|
|
1223
|
+
ahead, _ = git.divergence(
|
|
1224
|
+
repo_dir, feat_name, state.config.default_branch,
|
|
1225
|
+
)
|
|
1226
|
+
if ahead > 0:
|
|
1227
|
+
all_merged = False
|
|
1228
|
+
except (KeyError, git.GitError):
|
|
1229
|
+
pass
|
|
1230
|
+
|
|
1231
|
+
if all_clean and all_merged:
|
|
1232
|
+
stale.append({
|
|
1233
|
+
"name": feat_name, "slot_id": slot_id,
|
|
1234
|
+
"reason": "clean and merged",
|
|
1235
|
+
})
|
|
1236
|
+
elif all_clean:
|
|
1237
|
+
stale.append({
|
|
1238
|
+
"name": feat_name, "slot_id": slot_id,
|
|
1239
|
+
"reason": "clean (not yet merged)",
|
|
1240
|
+
})
|
|
1241
|
+
|
|
1242
|
+
return stale
|
|
1243
|
+
|
|
1244
|
+
def _load_features(self) -> dict:
|
|
1245
|
+
"""Load features.json, returning empty dict if not found."""
|
|
1246
|
+
if not self._store_path.exists():
|
|
1247
|
+
return {}
|
|
1248
|
+
try:
|
|
1249
|
+
return json.loads(self._store_path.read_text())
|
|
1250
|
+
except (json.JSONDecodeError, OSError):
|
|
1251
|
+
return {}
|
|
1252
|
+
|
|
1253
|
+
def _save_features(self, features: dict) -> None:
|
|
1254
|
+
"""Save features.json."""
|
|
1255
|
+
self._store_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1256
|
+
self._store_path.write_text(json.dumps(features, indent=2))
|