canopy-cli 3.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. canopy/__init__.py +2 -0
  2. canopy/actions/__init__.py +32 -0
  3. canopy/actions/aliases.py +421 -0
  4. canopy/actions/augments.py +55 -0
  5. canopy/actions/bootstrap.py +249 -0
  6. canopy/actions/bot_resolutions.py +123 -0
  7. canopy/actions/bot_status.py +133 -0
  8. canopy/actions/commit.py +511 -0
  9. canopy/actions/conflicts.py +314 -0
  10. canopy/actions/doctor.py +1459 -0
  11. canopy/actions/draft_replies.py +185 -0
  12. canopy/actions/drift.py +241 -0
  13. canopy/actions/errors.py +115 -0
  14. canopy/actions/evacuate.py +192 -0
  15. canopy/actions/feature_state.py +607 -0
  16. canopy/actions/historian.py +612 -0
  17. canopy/actions/ide_workspace.py +49 -0
  18. canopy/actions/last_visit.py +83 -0
  19. canopy/actions/migrate_slots.py +313 -0
  20. canopy/actions/preflight_state.py +97 -0
  21. canopy/actions/push.py +199 -0
  22. canopy/actions/reads.py +304 -0
  23. canopy/actions/resume.py +582 -0
  24. canopy/actions/review_filter.py +135 -0
  25. canopy/actions/ship.py +399 -0
  26. canopy/actions/slot_details.py +208 -0
  27. canopy/actions/slot_load.py +383 -0
  28. canopy/actions/slots.py +221 -0
  29. canopy/actions/stash.py +230 -0
  30. canopy/actions/switch.py +775 -0
  31. canopy/actions/switch_preflight.py +192 -0
  32. canopy/actions/thread_actions.py +88 -0
  33. canopy/actions/thread_resolutions.py +101 -0
  34. canopy/actions/triage.py +286 -0
  35. canopy/agent/__init__.py +5 -0
  36. canopy/agent/runner.py +129 -0
  37. canopy/agent_setup/__init__.py +264 -0
  38. canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
  39. canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
  40. canopy/cli/__init__.py +0 -0
  41. canopy/cli/main.py +4152 -0
  42. canopy/cli/render.py +98 -0
  43. canopy/cli/ui.py +150 -0
  44. canopy/features/__init__.py +2 -0
  45. canopy/features/coordinator.py +1256 -0
  46. canopy/git/__init__.py +0 -0
  47. canopy/git/hooks.py +173 -0
  48. canopy/git/multi.py +435 -0
  49. canopy/git/repo.py +859 -0
  50. canopy/git/templates/post-checkout.py +67 -0
  51. canopy/graph/__init__.py +0 -0
  52. canopy/integrations/__init__.py +0 -0
  53. canopy/integrations/github.py +983 -0
  54. canopy/integrations/linear.py +307 -0
  55. canopy/integrations/precommit.py +239 -0
  56. canopy/mcp/__init__.py +0 -0
  57. canopy/mcp/client.py +329 -0
  58. canopy/mcp/server.py +1797 -0
  59. canopy/providers/__init__.py +105 -0
  60. canopy/providers/github_issues.py +289 -0
  61. canopy/providers/linear.py +341 -0
  62. canopy/providers/types.py +149 -0
  63. canopy/workspace/__init__.py +4 -0
  64. canopy/workspace/config.py +378 -0
  65. canopy/workspace/context.py +224 -0
  66. canopy/workspace/discovery.py +197 -0
  67. canopy/workspace/workspace.py +173 -0
  68. canopy_cli-3.1.0.dist-info/METADATA +282 -0
  69. canopy_cli-3.1.0.dist-info/RECORD +71 -0
  70. canopy_cli-3.1.0.dist-info/WHEEL +4 -0
  71. canopy_cli-3.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,135 @@
1
+ """Temporal classification of PR review threads.
2
+
3
+ Compares each review comment's ``created_at`` to the branch's latest
4
+ commit timestamp and to commits that touched the comment's file. Splits
5
+ threads into actionable / likely_resolved / resolved so the agent's
6
+ context budget goes to comprehension, not to figuring out which feedback
7
+ is current.
8
+
9
+ Uses timestamp + path matching only — no NLP. Bot threads are NOT
10
+ filtered by author here: a ``claude[bot]`` thread may carry the only
11
+ actionable feedback, and the temporal heuristic handles staleness
12
+ regardless.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from ..git import repo as git
21
+
22
+
23
+ def classify_threads(
24
+ comments: list[dict],
25
+ repo_path: Path,
26
+ branch: str,
27
+ ) -> dict[str, Any]:
28
+ """Bucket review comments into actionable / likely_resolved.
29
+
30
+ Algorithm (per the research doc):
31
+ if comment.created_at > branch.latest_commit_at:
32
+ ACTIONABLE — posted after latest commit, not addressed yet
33
+ elif any commit on branch after the comment touched the comment's path:
34
+ LIKELY_RESOLVED — file was modified after the comment
35
+ else:
36
+ ACTIONABLE — old comment, file untouched since
37
+
38
+ Args:
39
+ comments: normalized comments from ``integrations.github.get_review_comments``.
40
+ Each dict must have ``path``, ``created_at`` (ISO 8601), plus
41
+ anything else the consumer wants. Comments with ``state == 'RESOLVED'``
42
+ are excluded upstream by ``_normalize_comments``.
43
+ repo_path: local repo path for git history lookups.
44
+ branch: branch name to use as the comparison ref.
45
+
46
+ Returns:
47
+ ``{actionable_threads, likely_resolved_threads, resolved_thread_count,
48
+ latest_commit_at}``.
49
+
50
+ ``actionable_threads`` carry the full comment dict, plus
51
+ ``classification_reason`` describing why they're flagged.
52
+ ``likely_resolved_threads`` carry a slim summary: ``path``, ``author``,
53
+ ``created_at``, plus ``addressed_by_commit`` (sha) and ``reason``.
54
+ """
55
+ latest_commit_at = git.commit_iso_date(repo_path, branch)
56
+ latest_dt = _parse_iso(latest_commit_at)
57
+
58
+ actionable: list[dict] = []
59
+ likely_resolved: list[dict] = []
60
+
61
+ for c in comments:
62
+ created_at = c.get("created_at", "")
63
+ created_dt = _parse_iso(created_at)
64
+ path = c.get("path", "")
65
+
66
+ if created_dt is None or latest_dt is None:
67
+ # Missing timestamps — keep as actionable to be safe.
68
+ actionable.append({**c, "classification_reason": "missing_timestamp"})
69
+ continue
70
+
71
+ if created_dt > latest_dt:
72
+ actionable.append({
73
+ **c,
74
+ "classification_reason": "posted_after_latest_commit",
75
+ })
76
+ continue
77
+
78
+ if not path:
79
+ # Comment with no file path → can't temporally check; assume actionable.
80
+ actionable.append({**c, "classification_reason": "no_path_to_check"})
81
+ continue
82
+
83
+ post_comment_commits = git.commits_touching_path(
84
+ repo_path, branch, path, since=created_at,
85
+ )
86
+ if post_comment_commits:
87
+ most_recent = post_comment_commits[0]
88
+ likely_resolved.append({
89
+ "path": path,
90
+ "author": c.get("author", ""),
91
+ "created_at": created_at,
92
+ "body_excerpt": _excerpt(c.get("body", "")),
93
+ "url": c.get("url", ""),
94
+ "addressed_by_commit": most_recent["sha"],
95
+ "addressed_by_short_sha": most_recent["short_sha"],
96
+ "addressed_at": most_recent["committed_at"],
97
+ "reason": (
98
+ f"commit {most_recent['short_sha']} touched this file "
99
+ f"after the comment"
100
+ ),
101
+ })
102
+ else:
103
+ actionable.append({
104
+ **c,
105
+ "classification_reason": "no_post_comment_commit_touched_file",
106
+ })
107
+
108
+ return {
109
+ "actionable_threads": actionable,
110
+ "likely_resolved_threads": likely_resolved,
111
+ # ``resolved_thread_count`` reflects threads excluded upstream by
112
+ # GitHub's isResolved field (not visible to us at this layer). The
113
+ # caller may set it; default 0.
114
+ "resolved_thread_count": 0,
115
+ "latest_commit_at": latest_commit_at,
116
+ }
117
+
118
+
119
+ def _parse_iso(s: str) -> datetime | None:
120
+ """Parse an ISO 8601 timestamp; return None on failure."""
121
+ if not s:
122
+ return None
123
+ # Accept both ``...Z`` and ``...+HH:MM`` forms.
124
+ s = s.replace("Z", "+00:00")
125
+ try:
126
+ return datetime.fromisoformat(s)
127
+ except ValueError:
128
+ return None
129
+
130
+
131
+ def _excerpt(body: str, max_len: int = 120) -> str:
132
+ body = " ".join(body.split()) # collapse whitespace
133
+ if len(body) <= max_len:
134
+ return body
135
+ return body[: max_len - 1] + "…"
canopy/actions/ship.py ADDED
@@ -0,0 +1,399 @@
1
+ """``canopy ship`` — capstone of the per-feature workflow (M8 / Wave 2.4).
2
+
3
+ Take a feature from "code is committed" to "PR is open and reviewers
4
+ can look." Per-repo recipe: ensure-pushed → ensure-PR-exists. After all
5
+ PRs are open, a second pass updates each PR body with the *now-known*
6
+ sibling PR numbers so reviewers landing on the API PR see the UI PR
7
+ linked (and vice versa).
8
+
9
+ **Idempotent.** Re-running ``ship`` after more commits + push reports
10
+ ``up_to_date`` per repo (PRs auto-track the branch). Re-running after
11
+ manually closing a PR reports ``closed`` and refuses to silently
12
+ recreate.
13
+
14
+ **Atomic.** No silent destruction. If the PR's head SHA doesn't match
15
+ what we just pushed (force-push divergence), we report ``diverged``
16
+ and skip the body update — the user investigates.
17
+
18
+ Read the existing push primitive first; this orchestrator only opens/
19
+ updates PRs and re-uses ``actions/push.push`` for the publish step.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ from ..git import repo as git
26
+ from ..integrations import github as gh
27
+ from ..workspace.workspace import Workspace
28
+ from . import slots as slots_mod
29
+ from .aliases import _resolve_owner_slug, repos_for_feature, resolve_feature
30
+ from .errors import BlockerError, FixAction
31
+ from .feature_state import resolve_repo_paths
32
+ from .push import push as push_impl
33
+
34
+
35
+ def ship(
36
+ workspace: Workspace,
37
+ *,
38
+ feature: str | None = None,
39
+ repos: list[str] | None = None,
40
+ draft: bool = False,
41
+ reviewers: list[str] | None = None,
42
+ dry_run: bool = False,
43
+ base: str | None = None,
44
+ ) -> dict[str, Any]:
45
+ """Open or update one PR per repo in the canonical (or named) feature.
46
+
47
+ Args:
48
+ workspace: loaded workspace.
49
+ feature: feature alias. Defaults to the canonical slot.
50
+ repos: optional filter — only ship these repos within the feature
51
+ scope.
52
+ draft: open PRs as drafts (initial open only; doesn't auto-undraft
53
+ on subsequent ships).
54
+ reviewers: GitHub usernames / team slugs to request review from.
55
+ dry_run: enumerate what would happen without firing pushes or
56
+ opening PRs.
57
+ base: override the base branch for every repo. Default: each
58
+ repo's ``default_branch`` from canopy.toml (matches Phil's
59
+ per-repo target_branch when set there).
60
+
61
+ Returns ``{feature, results: {<repo>: {status, pr_number?, url?,
62
+ reason?, warning?}}, cross_repo_links_updated: bool}``.
63
+ """
64
+ feature_name = _resolve_feature_name(workspace, feature)
65
+ repo_branches = repos_for_feature(workspace, feature_name)
66
+ if not repo_branches:
67
+ raise BlockerError(
68
+ code="empty_feature",
69
+ what=f"feature '{feature_name}' has no associated repos",
70
+ )
71
+
72
+ if repos:
73
+ wanted = set(repos)
74
+ repo_branches = {r: b for r, b in repo_branches.items() if r in wanted}
75
+ if not repo_branches:
76
+ raise BlockerError(
77
+ code="repos_filter_empty",
78
+ what=f"none of {sorted(repos)} are in feature '{feature_name}'",
79
+ )
80
+
81
+ repo_paths, _ = resolve_repo_paths(workspace, feature_name, repo_branches)
82
+
83
+ # First pass: per-repo ensure-pushed → ensure-PR-exists.
84
+ results: dict[str, dict[str, Any]] = {}
85
+ for repo_name, branch in repo_branches.items():
86
+ repo_path = repo_paths.get(repo_name)
87
+ if repo_path is None:
88
+ results[repo_name] = {"status": "failed", "reason": "repo path unresolved"}
89
+ continue
90
+ results[repo_name] = _ship_one(
91
+ workspace, feature_name, repo_name, branch, repo_path,
92
+ draft=draft, reviewers=reviewers, dry_run=dry_run, base_override=base,
93
+ )
94
+
95
+ cross_links_updated = False
96
+ if not dry_run:
97
+ cross_links_updated = _refresh_cross_repo_links(
98
+ workspace, feature_name, results,
99
+ )
100
+
101
+ return {
102
+ "feature": feature_name,
103
+ "results": results,
104
+ "cross_repo_links_updated": cross_links_updated,
105
+ }
106
+
107
+
108
+ # ── per-repo ────────────────────────────────────────────────────────────
109
+
110
+ def _ship_one(
111
+ workspace: Workspace,
112
+ feature_name: str,
113
+ repo_name: str,
114
+ branch: str,
115
+ repo_path,
116
+ *,
117
+ draft: bool,
118
+ reviewers: list[str] | None,
119
+ dry_run: bool,
120
+ base_override: str | None,
121
+ ) -> dict[str, Any]:
122
+ """Run ship for one repo. Returns the per-repo result dict."""
123
+ state = workspace.get_repo(repo_name)
124
+ base = base_override or state.config.default_branch
125
+
126
+ # 0. Check that the branch exists locally + has commits ahead of base.
127
+ if not git.branch_exists(repo_path, branch):
128
+ return {"status": "skipped", "reason": "no branch on disk"}
129
+ ahead = _ahead_count(repo_path, branch, base)
130
+ if ahead == 0:
131
+ return {"status": "skipped", "reason": "no commits ahead of base"}
132
+
133
+ if dry_run:
134
+ return _dry_run_one(workspace, repo_name, branch, base, ahead)
135
+
136
+ # 1. Make sure the branch is pushed. push_impl handles set-upstream,
137
+ # up-to-date short-circuit, and rejected/failed classification.
138
+ push_result = push_impl(
139
+ workspace, feature=feature_name, repos=[repo_name], set_upstream=True,
140
+ )
141
+ pushed = push_result["results"].get(repo_name, {})
142
+ if pushed.get("status") in ("rejected", "failed"):
143
+ return {
144
+ "status": "failed",
145
+ "reason": f"push failed: {pushed.get('reason', pushed['status'])}",
146
+ }
147
+
148
+ # 2. Look up an existing PR for this branch.
149
+ try:
150
+ owner, repo_slug = _resolve_owner_slug(workspace, repo_name)
151
+ except BlockerError as err:
152
+ return {"status": "failed", "reason": f"owner/slug unresolved: {err.what}"}
153
+ try:
154
+ existing = gh.find_pull_request(workspace.config.root, owner, repo_slug, branch)
155
+ except gh.GitHubNotConfiguredError as err:
156
+ return {
157
+ "status": "failed",
158
+ "reason": f"github not configured: {err.payload.get('what', '')}",
159
+ }
160
+
161
+ if existing:
162
+ return _classify_existing_pr(repo_path, branch, existing)
163
+
164
+ # 3. No PR — open one.
165
+ title = _format_title(workspace, feature_name)
166
+ body = _format_body_initial(workspace, feature_name, repo_name)
167
+ try:
168
+ created = gh.create_pr(
169
+ workspace.config.root, owner, repo_slug,
170
+ branch=branch, base=base, title=title, body=body,
171
+ draft=draft, reviewers=reviewers,
172
+ )
173
+ except gh.GitHubNotConfiguredError as err:
174
+ return {"status": "failed", "reason": f"create failed: {err.payload.get('what', '')}"}
175
+ return {
176
+ "status": "opened",
177
+ "pr_number": created.get("number"),
178
+ "url": created.get("url"),
179
+ "draft": draft,
180
+ }
181
+
182
+
183
+ def _classify_existing_pr(repo_path, branch: str, pr: dict) -> dict[str, Any]:
184
+ """Classify an existing PR vs the local branch state."""
185
+ pr_state = (pr.get("state") or "").lower()
186
+ if pr_state in ("closed", "merged"):
187
+ return {
188
+ "status": "closed",
189
+ "pr_number": pr.get("number"),
190
+ "url": pr.get("url"),
191
+ "reason": f"PR is {pr_state}; manual reopen needed",
192
+ }
193
+
194
+ pr_head = pr.get("head_sha") or pr.get("head", {}).get("sha") or ""
195
+ local_head = git.head_sha(repo_path)
196
+ if pr_head and local_head and pr_head != local_head:
197
+ return {
198
+ "status": "diverged",
199
+ "pr_number": pr.get("number"),
200
+ "url": pr.get("url"),
201
+ "warning": "PR head sha doesn't match local; force-push divergence — manual review recommended",
202
+ }
203
+
204
+ return {
205
+ "status": "up_to_date",
206
+ "pr_number": pr.get("number"),
207
+ "url": pr.get("url"),
208
+ }
209
+
210
+
211
+ def _dry_run_one(
212
+ workspace: Workspace, repo_name: str, branch: str, base: str, ahead: int,
213
+ ) -> dict[str, Any]:
214
+ """Cheap read-only enumeration of what ship would do for this repo."""
215
+ try:
216
+ owner, repo_slug = _resolve_owner_slug(workspace, repo_name)
217
+ existing = gh.find_pull_request(workspace.config.root, owner, repo_slug, branch)
218
+ except (BlockerError, gh.GitHubNotConfiguredError):
219
+ existing = None
220
+ if existing:
221
+ return {
222
+ "status": "would_update_or_skip",
223
+ "pr_number": existing.get("number"),
224
+ "url": existing.get("url"),
225
+ "ahead": ahead,
226
+ "base": base,
227
+ "dry_run": True,
228
+ }
229
+ return {
230
+ "status": "would_open",
231
+ "ahead": ahead,
232
+ "base": base,
233
+ "dry_run": True,
234
+ }
235
+
236
+
237
+ # ── cross-repo body refresh ─────────────────────────────────────────────
238
+
239
+ def _refresh_cross_repo_links(
240
+ workspace: Workspace, feature_name: str, results: dict[str, dict],
241
+ ) -> bool:
242
+ """After all PRs are open/up-to-date, update each body with sibling PR
243
+ numbers. Returns True iff at least one body was updated."""
244
+ pr_pairs: list[tuple[str, int, str]] = []
245
+ for repo, result in results.items():
246
+ pr_number = result.get("pr_number")
247
+ url = result.get("url") or ""
248
+ if pr_number and result.get("status") in ("opened", "up_to_date"):
249
+ pr_pairs.append((repo, int(pr_number), url))
250
+ if len(pr_pairs) < 2:
251
+ # Single-repo feature — body's "1 of 1" line is already accurate
252
+ # from the initial open. Nothing cross-repo to add.
253
+ return False
254
+
255
+ updated_any = False
256
+ for repo_name, pr_number, _url in pr_pairs:
257
+ try:
258
+ owner, repo_slug = _resolve_owner_slug(workspace, repo_name)
259
+ except BlockerError:
260
+ continue
261
+ new_body = _format_body_with_siblings(
262
+ workspace, feature_name, repo_name, pr_pairs,
263
+ )
264
+ try:
265
+ gh.update_pr_body(
266
+ workspace.config.root, owner, repo_slug, pr_number, new_body,
267
+ )
268
+ updated_any = True
269
+ except gh.GitHubNotConfiguredError:
270
+ break
271
+ return updated_any
272
+
273
+
274
+ # ── formatters ──────────────────────────────────────────────────────────
275
+
276
+ def _format_title(workspace: Workspace, feature_name: str) -> str:
277
+ """`<LINEAR-ID> <feature title or feature name>` per spec."""
278
+ feature_meta = _read_feature_entry(workspace, feature_name)
279
+ linear_id = (feature_meta or {}).get("linear_issue") or ""
280
+ title = (feature_meta or {}).get("linear_title") or feature_name
281
+ if linear_id:
282
+ return f"{linear_id} {title}".strip()
283
+ return feature_name
284
+
285
+
286
+ def _format_body_initial(
287
+ workspace: Workspace, feature_name: str, repo_name: str,
288
+ ) -> str:
289
+ """Body emitted on first open — no sibling PR numbers yet."""
290
+ feature_meta = _read_feature_entry(workspace, feature_name) or {}
291
+ linear_url = feature_meta.get("linear_url") or ""
292
+ linear_id = feature_meta.get("linear_issue") or ""
293
+ repos = feature_meta.get("repos") or [repo_name]
294
+
295
+ lines: list[str] = []
296
+ if linear_url:
297
+ lines.append(f"[Linear: {linear_id}]({linear_url})")
298
+ lines.append("")
299
+ lines.append(
300
+ f"This PR is part of the canopy feature `{feature_name}` "
301
+ f"({_position(repo_name, repos)} of {len(repos)} repos):"
302
+ )
303
+ lines.append("")
304
+ for r in repos:
305
+ if r == repo_name:
306
+ lines.append(f"- {r}: this PR")
307
+ else:
308
+ lines.append(f"- {r}: (sibling PR pending; ship will link on second pass)")
309
+ lines.append("")
310
+ lines.append("---")
311
+ lines.append("")
312
+ lines.append("🌳 Opened by [canopy](https://github.com/ashmitb95/canopy)")
313
+ return "\n".join(lines)
314
+
315
+
316
+ def _format_body_with_siblings(
317
+ workspace: Workspace,
318
+ feature_name: str,
319
+ repo_name: str,
320
+ pr_pairs: list[tuple[str, int, str]],
321
+ ) -> str:
322
+ """Body emitted on the cross-repo refresh pass — sibling PRs known."""
323
+ feature_meta = _read_feature_entry(workspace, feature_name) or {}
324
+ linear_url = feature_meta.get("linear_url") or ""
325
+ linear_id = feature_meta.get("linear_issue") or ""
326
+ by_repo = {r: (n, u) for r, n, u in pr_pairs}
327
+
328
+ lines: list[str] = []
329
+ if linear_url:
330
+ lines.append(f"[Linear: {linear_id}]({linear_url})")
331
+ lines.append("")
332
+ lines.append(
333
+ f"This PR is part of the canopy feature `{feature_name}` "
334
+ f"({_position(repo_name, [r for r, _, _ in pr_pairs])} of {len(pr_pairs)} repos):"
335
+ )
336
+ lines.append("")
337
+ for r, n, u in pr_pairs:
338
+ if r == repo_name:
339
+ lines.append(f"- {r}: this PR (#{n})")
340
+ else:
341
+ lines.append(f"- {r}: [#{n}]({u})")
342
+ lines.append("")
343
+ lines.append("---")
344
+ lines.append("")
345
+ lines.append("🌳 Opened by [canopy](https://github.com/ashmitb95/canopy)")
346
+ return "\n".join(lines)
347
+
348
+
349
+ # ── helpers ─────────────────────────────────────────────────────────────
350
+
351
+ def _resolve_feature_name(workspace: Workspace, feature: str | None) -> str:
352
+ if feature:
353
+ return resolve_feature(workspace, feature)
354
+ state = slots_mod.read_state(workspace)
355
+ if state is None or state.canonical is None:
356
+ raise BlockerError(
357
+ code="no_canonical_feature",
358
+ what="no active feature; pass --feature or run `canopy switch <name>` first",
359
+ fix_actions=[
360
+ FixAction(action="switch", args={}, safe=False,
361
+ preview="canopy switch <feature> sets the canonical slot"),
362
+ ],
363
+ )
364
+ return state.canonical.feature
365
+
366
+
367
+ def _read_feature_entry(workspace: Workspace, feature_name: str) -> dict | None:
368
+ """Load the features.json entry for ``feature_name``, or None."""
369
+ import json
370
+ path = workspace.config.root / ".canopy" / "features.json"
371
+ if not path.exists():
372
+ return None
373
+ try:
374
+ data = json.loads(path.read_text("utf-8"))
375
+ except (OSError, ValueError):
376
+ return None
377
+ return data.get(feature_name)
378
+
379
+
380
+ def _ahead_count(repo_path, branch: str, base: str) -> int:
381
+ """Count commits unique to ``branch`` vs ``base``. 0 means nothing to ship."""
382
+ try:
383
+ out = git._run_ok(
384
+ ["rev-list", "--count", f"{base}..{branch}"], cwd=repo_path,
385
+ )
386
+ except git.GitError:
387
+ return 0
388
+ try:
389
+ return int(out.strip())
390
+ except (TypeError, ValueError):
391
+ return 0
392
+
393
+
394
+ def _position(needle: str, haystack: list[str]) -> int:
395
+ """1-based index of ``needle`` in ``haystack``, or len+1 if missing."""
396
+ try:
397
+ return haystack.index(needle) + 1
398
+ except ValueError:
399
+ return len(haystack) + 1