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,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
|