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,185 @@
|
|
|
1
|
+
"""``canopy draft_replies`` — auto-draft "Done in <sha>" replies (M9).
|
|
2
|
+
|
|
3
|
+
For each unresolved PR review comment in the feature's repos, walk the
|
|
4
|
+
file's commit history since the comment was anchored. If anything
|
|
5
|
+
changed, the comment is "addressed" — render a template-based draft
|
|
6
|
+
reply the user can review and post.
|
|
7
|
+
|
|
8
|
+
This is template-based on purpose. The user reviews the draft before
|
|
9
|
+
posting; an LLM-generated reply isn't worth the cost/latency for text
|
|
10
|
+
they'll edit anyway. Future ``draft_replies --llm`` is reserved.
|
|
11
|
+
|
|
12
|
+
Read-only — generates text, never posts. Posting is a separate (future)
|
|
13
|
+
``post_replies`` action.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from ..git import repo as git
|
|
21
|
+
from ..integrations import github as gh
|
|
22
|
+
from ..workspace.workspace import Workspace
|
|
23
|
+
from .aliases import resolve_pr_targets
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def draft_replies(
|
|
27
|
+
workspace: Workspace,
|
|
28
|
+
alias: str,
|
|
29
|
+
*,
|
|
30
|
+
include_likely_resolved: bool = False,
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
"""Per-PR draft-reply set for ``alias``.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
workspace: loaded workspace.
|
|
36
|
+
alias: feature name, ``<repo>#<n>``, or PR URL — same surface as
|
|
37
|
+
the existing ``review_comments`` read.
|
|
38
|
+
include_likely_resolved: also draft for the temporal classifier's
|
|
39
|
+
``likely_resolved`` set (weaker signal — comment looks
|
|
40
|
+
resolved but no commit directly touched the line). Off by
|
|
41
|
+
default; surface as `confidence: low` when on.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
``{alias, repos: {<repo>: {pr_number, pr_url, addressed: [<draft>],
|
|
45
|
+
unaddressed: [<comment>]}}, addressed_total, unaddressed_total}``.
|
|
46
|
+
"""
|
|
47
|
+
from .review_filter import classify_threads
|
|
48
|
+
|
|
49
|
+
targets = resolve_pr_targets(workspace, alias)
|
|
50
|
+
repos: dict[str, dict] = {}
|
|
51
|
+
addressed_total = 0
|
|
52
|
+
unaddressed_total = 0
|
|
53
|
+
|
|
54
|
+
for t in targets:
|
|
55
|
+
comments, _ = gh.get_review_comments(
|
|
56
|
+
workspace.config.root, t.owner, t.repo_slug, t.pr_number,
|
|
57
|
+
)
|
|
58
|
+
state = workspace.get_repo(t.repo)
|
|
59
|
+
pr = gh.get_pull_request_by_number(
|
|
60
|
+
workspace.config.root, t.owner, t.repo_slug, t.pr_number,
|
|
61
|
+
)
|
|
62
|
+
branch = (pr or {}).get("head_branch") or state.current_branch
|
|
63
|
+
classification = classify_threads(comments, state.abs_path, branch)
|
|
64
|
+
|
|
65
|
+
candidate_threads = list(classification.get("actionable_threads") or [])
|
|
66
|
+
if include_likely_resolved:
|
|
67
|
+
candidate_threads += list(classification.get("likely_resolved_threads") or [])
|
|
68
|
+
|
|
69
|
+
addressed: list[dict] = []
|
|
70
|
+
unaddressed: list[dict] = []
|
|
71
|
+
for thread in candidate_threads:
|
|
72
|
+
commit_id = thread.get("commit_id")
|
|
73
|
+
path = thread.get("path") or ""
|
|
74
|
+
if not commit_id or not path:
|
|
75
|
+
# Old comment without anchor info — surface as low-confidence
|
|
76
|
+
# unaddressed. Don't pretend we know.
|
|
77
|
+
unaddressed.append({**thread, "reason": "missing_anchor"})
|
|
78
|
+
continue
|
|
79
|
+
history = git.log_for_path(state.abs_path, commit_id, path)
|
|
80
|
+
classified = classify_comment(thread, history)
|
|
81
|
+
if classified["status"] == "addressed":
|
|
82
|
+
draft_text = render_reply(thread, classified["addressing_commits"],
|
|
83
|
+
classified["confidence"])
|
|
84
|
+
addressed.append({
|
|
85
|
+
"comment_id": thread.get("id"),
|
|
86
|
+
"comment_url": thread.get("url"),
|
|
87
|
+
"original_comment": {
|
|
88
|
+
"author": thread.get("author"),
|
|
89
|
+
"path": path,
|
|
90
|
+
"line": thread.get("line"),
|
|
91
|
+
"body": thread.get("body"),
|
|
92
|
+
},
|
|
93
|
+
"addressing_commits": classified["addressing_commits"],
|
|
94
|
+
"draft_reply": draft_text,
|
|
95
|
+
"confidence": classified["confidence"],
|
|
96
|
+
})
|
|
97
|
+
else:
|
|
98
|
+
unaddressed.append({**thread, "reason": classified.get("reason", "no_commits")})
|
|
99
|
+
|
|
100
|
+
addressed_total += len(addressed)
|
|
101
|
+
unaddressed_total += len(unaddressed)
|
|
102
|
+
repos[t.repo] = {
|
|
103
|
+
"pr_number": t.pr_number,
|
|
104
|
+
"pr_url": (pr or {}).get("url", ""),
|
|
105
|
+
"addressed": addressed,
|
|
106
|
+
"unaddressed": unaddressed,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
"alias": alias,
|
|
111
|
+
"addressed_total": addressed_total,
|
|
112
|
+
"unaddressed_total": unaddressed_total,
|
|
113
|
+
"repos": repos,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── Pure helpers (testable in isolation) ────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def classify_comment(comment: dict, history: list[dict]) -> dict[str, Any]:
|
|
120
|
+
"""Decide ``addressed`` vs ``unaddressed`` + a confidence tier.
|
|
121
|
+
|
|
122
|
+
Confidence rules:
|
|
123
|
+
- ``high`` single addressing commit, subject mentions a keyword
|
|
124
|
+
from the comment body
|
|
125
|
+
- ``medium`` single addressing commit, no keyword overlap
|
|
126
|
+
- ``low`` multiple addressing commits, OR likely-resolved
|
|
127
|
+
classifier promotion (caller signals via comment
|
|
128
|
+
metadata)
|
|
129
|
+
"""
|
|
130
|
+
if not history:
|
|
131
|
+
return {"status": "unaddressed", "addressing_commits": [],
|
|
132
|
+
"confidence": "low", "reason": "no_commits"}
|
|
133
|
+
|
|
134
|
+
if len(history) == 1:
|
|
135
|
+
keyword_match = _has_keyword_overlap(comment.get("body") or "",
|
|
136
|
+
history[0].get("subject") or "")
|
|
137
|
+
confidence = "high" if keyword_match else "medium"
|
|
138
|
+
else:
|
|
139
|
+
confidence = "low"
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
"status": "addressed",
|
|
143
|
+
"addressing_commits": history,
|
|
144
|
+
"confidence": confidence,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def render_reply(comment: dict, commits: list[dict], confidence: str) -> str:
|
|
149
|
+
"""Generate a template-based draft reply.
|
|
150
|
+
|
|
151
|
+
Three branches per the plan:
|
|
152
|
+
1. Specific subject match → "Done — <subject>. (<sha>)"
|
|
153
|
+
2. Single commit fallback → "Addressed in <sha>: <subject>."
|
|
154
|
+
3. Multiple commits → "Addressed across <N> commits — <shas>."
|
|
155
|
+
"""
|
|
156
|
+
if not commits:
|
|
157
|
+
return ""
|
|
158
|
+
if len(commits) > 1:
|
|
159
|
+
shas = ", ".join(c["sha"][:8] for c in commits)
|
|
160
|
+
return f"Addressed across {len(commits)} commits — {shas}."
|
|
161
|
+
|
|
162
|
+
commit = commits[0]
|
|
163
|
+
subject = (commit.get("subject") or "").strip()
|
|
164
|
+
short = (commit.get("sha") or "")[:8]
|
|
165
|
+
if confidence == "high":
|
|
166
|
+
# Keyword match → drop the redundant lead-in.
|
|
167
|
+
return f"Done — {subject}. ({short})"
|
|
168
|
+
return f"Addressed in {short}: {subject}."
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── Internal ────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
_TOKEN_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]+")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _has_keyword_overlap(body: str, subject: str) -> bool:
|
|
177
|
+
"""True iff body and subject share an identifier-shaped token (≥ 4 chars).
|
|
178
|
+
|
|
179
|
+
Cheap proxy for "the commit subject mentions what the comment was
|
|
180
|
+
about." Avoids stop-words by sticking to identifier shape and a
|
|
181
|
+
minimum length threshold.
|
|
182
|
+
"""
|
|
183
|
+
body_tokens = {t.lower() for t in _TOKEN_RE.findall(body) if len(t) >= 4}
|
|
184
|
+
subj_tokens = {t.lower() for t in _TOKEN_RE.findall(subject) if len(t) >= 4}
|
|
185
|
+
return bool(body_tokens & subj_tokens)
|
canopy/actions/drift.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Drift detection: compare recorded heads vs feature lane expectations.
|
|
2
|
+
|
|
3
|
+
Reads ground truth from ``.canopy/state/heads.json`` (written by the
|
|
4
|
+
post-checkout hook) and compares it to ``FeatureLane.repos`` from
|
|
5
|
+
``.canopy/features.json``. Returns a structured report; ``assert_aligned``
|
|
6
|
+
raises a ``BlockerError`` so any action that has alignment as a precondition
|
|
7
|
+
can use the same primitive.
|
|
8
|
+
|
|
9
|
+
v1 assumes ``expected_branch == feature_name`` per repo. Per-repo branch
|
|
10
|
+
overrides (e.g., ``auth-flow`` in api vs ``auth-flow-v2`` in ui) will be
|
|
11
|
+
added when the feature lane schema gains per-repo branch mapping. For
|
|
12
|
+
now, exact match against feature name.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ..git.hooks import read_heads_state
|
|
22
|
+
from ..workspace.workspace import Workspace
|
|
23
|
+
from .errors import BlockerError, FixAction
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class RepoAlignment:
|
|
28
|
+
repo: str
|
|
29
|
+
expected: str
|
|
30
|
+
actual: str | None
|
|
31
|
+
aligned: bool
|
|
32
|
+
state_recorded_at: str | None = None
|
|
33
|
+
state_age_seconds: float | None = None
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
return {
|
|
37
|
+
"repo": self.repo,
|
|
38
|
+
"expected": self.expected,
|
|
39
|
+
"actual": self.actual,
|
|
40
|
+
"aligned": self.aligned,
|
|
41
|
+
"state_recorded_at": self.state_recorded_at,
|
|
42
|
+
"state_age_seconds": self.state_age_seconds,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class FeatureDrift:
|
|
48
|
+
feature: str
|
|
49
|
+
aligned: bool
|
|
50
|
+
repos: list[RepoAlignment] = field(default_factory=list)
|
|
51
|
+
drifted_repos: list[str] = field(default_factory=list)
|
|
52
|
+
untracked_repos: list[str] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
return {
|
|
56
|
+
"feature": self.feature,
|
|
57
|
+
"aligned": self.aligned,
|
|
58
|
+
"repos": [r.to_dict() for r in self.repos],
|
|
59
|
+
"drifted_repos": list(self.drifted_repos),
|
|
60
|
+
"untracked_repos": list(self.untracked_repos),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class DriftReport:
|
|
66
|
+
workspace_root: str
|
|
67
|
+
overall_aligned: bool
|
|
68
|
+
features: list[FeatureDrift] = field(default_factory=list)
|
|
69
|
+
note: str | None = None
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict[str, Any]:
|
|
72
|
+
out: dict[str, Any] = {
|
|
73
|
+
"workspace_root": self.workspace_root,
|
|
74
|
+
"overall_aligned": self.overall_aligned,
|
|
75
|
+
"features": [f.to_dict() for f in self.features],
|
|
76
|
+
}
|
|
77
|
+
if self.note:
|
|
78
|
+
out["note"] = self.note
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def detect_drift(workspace: Workspace, feature_name: str | None = None) -> DriftReport:
|
|
83
|
+
"""Compute drift across one or all active features.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
workspace: loaded ``Workspace``.
|
|
87
|
+
feature_name: if set, scope to one feature (resolved through coordinator
|
|
88
|
+
alias logic). If None, report all active features.
|
|
89
|
+
|
|
90
|
+
The expected branch for each repo is the feature name. Repos in
|
|
91
|
+
``feature.repos`` but missing from heads.json are reported as
|
|
92
|
+
``untracked_repos`` — usually because the post-checkout hook hasn't
|
|
93
|
+
fired in that repo since install (e.g., a fresh workspace where ui
|
|
94
|
+
hasn't been switched yet).
|
|
95
|
+
"""
|
|
96
|
+
from ..features.coordinator import FeatureCoordinator
|
|
97
|
+
|
|
98
|
+
coordinator = FeatureCoordinator(workspace)
|
|
99
|
+
heads = read_heads_state(workspace.config.root)
|
|
100
|
+
|
|
101
|
+
# list_active() returns only active lanes from features.json + implicit
|
|
102
|
+
# branches present across multiple repos.
|
|
103
|
+
active_lanes = coordinator.list_active()
|
|
104
|
+
|
|
105
|
+
if feature_name is not None:
|
|
106
|
+
resolved = coordinator._resolve_name(feature_name)
|
|
107
|
+
active_lanes = [l for l in active_lanes if l.name == resolved]
|
|
108
|
+
if not active_lanes:
|
|
109
|
+
return DriftReport(
|
|
110
|
+
workspace_root=str(workspace.config.root),
|
|
111
|
+
overall_aligned=False,
|
|
112
|
+
note=f"feature '{feature_name}' is not an active feature lane",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if not active_lanes:
|
|
116
|
+
return DriftReport(
|
|
117
|
+
workspace_root=str(workspace.config.root),
|
|
118
|
+
overall_aligned=True,
|
|
119
|
+
note="no active features",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
feature_drifts: list[FeatureDrift] = []
|
|
123
|
+
overall = True
|
|
124
|
+
for lane in active_lanes:
|
|
125
|
+
fd = _compute_feature_drift(lane, heads)
|
|
126
|
+
feature_drifts.append(fd)
|
|
127
|
+
if not fd.aligned:
|
|
128
|
+
overall = False
|
|
129
|
+
|
|
130
|
+
return DriftReport(
|
|
131
|
+
workspace_root=str(workspace.config.root),
|
|
132
|
+
overall_aligned=overall,
|
|
133
|
+
features=feature_drifts,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def assert_aligned(workspace: Workspace, feature_name: str) -> None:
|
|
138
|
+
"""Raise ``BlockerError(code="drift_detected")`` if the feature has drift.
|
|
139
|
+
|
|
140
|
+
Used by mutating actions (commit, push, ship) as a precondition. The
|
|
141
|
+
error's ``fix_actions`` always includes a ``realign`` suggestion for
|
|
142
|
+
the feature.
|
|
143
|
+
"""
|
|
144
|
+
report = detect_drift(workspace, feature_name=feature_name)
|
|
145
|
+
if report.note and "not an active" in report.note:
|
|
146
|
+
raise BlockerError(
|
|
147
|
+
code="unknown_feature",
|
|
148
|
+
what=report.note,
|
|
149
|
+
details={"feature": feature_name},
|
|
150
|
+
)
|
|
151
|
+
drifted = [f for f in report.features if not f.aligned]
|
|
152
|
+
if not drifted:
|
|
153
|
+
return
|
|
154
|
+
fd = drifted[0]
|
|
155
|
+
expected = {r.repo: r.expected for r in fd.repos}
|
|
156
|
+
actual = {r.repo: r.actual for r in fd.repos}
|
|
157
|
+
raise BlockerError(
|
|
158
|
+
code="drift_detected",
|
|
159
|
+
what=f"branches don't match feature lane '{fd.feature}'",
|
|
160
|
+
expected={"feature": fd.feature, "branches": expected},
|
|
161
|
+
actual={"branches": actual},
|
|
162
|
+
fix_actions=[
|
|
163
|
+
FixAction(
|
|
164
|
+
action="realign",
|
|
165
|
+
args={"feature": fd.feature},
|
|
166
|
+
safe=_realign_is_safe(fd),
|
|
167
|
+
preview=_realign_preview(fd),
|
|
168
|
+
),
|
|
169
|
+
],
|
|
170
|
+
details={
|
|
171
|
+
"drifted_repos": fd.drifted_repos,
|
|
172
|
+
"untracked_repos": fd.untracked_repos,
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _compute_feature_drift(lane, heads: dict) -> FeatureDrift:
|
|
178
|
+
repos: list[RepoAlignment] = []
|
|
179
|
+
drifted: list[str] = []
|
|
180
|
+
untracked: list[str] = []
|
|
181
|
+
|
|
182
|
+
for repo_name in lane.repos:
|
|
183
|
+
# Use lane.branch_for to honor per-repo branch overrides
|
|
184
|
+
# (handles cases like auth-flow vs auth-flow-v2 across repos).
|
|
185
|
+
expected = lane.branch_for(repo_name)
|
|
186
|
+
head = heads.get(repo_name)
|
|
187
|
+
if head is None:
|
|
188
|
+
ra = RepoAlignment(
|
|
189
|
+
repo=repo_name, expected=expected,
|
|
190
|
+
actual=None, aligned=False,
|
|
191
|
+
)
|
|
192
|
+
untracked.append(repo_name)
|
|
193
|
+
else:
|
|
194
|
+
actual = head.get("branch")
|
|
195
|
+
aligned = actual == expected
|
|
196
|
+
ts = head.get("ts")
|
|
197
|
+
age = _age_seconds(ts) if ts else None
|
|
198
|
+
ra = RepoAlignment(
|
|
199
|
+
repo=repo_name, expected=expected,
|
|
200
|
+
actual=actual, aligned=aligned,
|
|
201
|
+
state_recorded_at=ts, state_age_seconds=age,
|
|
202
|
+
)
|
|
203
|
+
if not aligned:
|
|
204
|
+
drifted.append(repo_name)
|
|
205
|
+
repos.append(ra)
|
|
206
|
+
|
|
207
|
+
return FeatureDrift(
|
|
208
|
+
feature=lane.name,
|
|
209
|
+
aligned=not drifted and not untracked,
|
|
210
|
+
repos=repos,
|
|
211
|
+
drifted_repos=drifted,
|
|
212
|
+
untracked_repos=untracked,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _age_seconds(iso_ts: str) -> float | None:
|
|
217
|
+
try:
|
|
218
|
+
# Hook writes ISO with trailing 'Z'.
|
|
219
|
+
ts = datetime.strptime(iso_ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
220
|
+
except ValueError:
|
|
221
|
+
return None
|
|
222
|
+
return (datetime.now(timezone.utc) - ts).total_seconds()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _realign_is_safe(fd: FeatureDrift) -> bool:
|
|
226
|
+
"""Untracked repos are safe to realign (we don't know their state, but
|
|
227
|
+
realign will check for dirty trees itself before mutating). Drifted
|
|
228
|
+
repos are also safe — realign refuses to act on dirty trees."""
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _realign_preview(fd: FeatureDrift) -> str:
|
|
233
|
+
parts = []
|
|
234
|
+
for r in fd.repos:
|
|
235
|
+
if r.aligned:
|
|
236
|
+
continue
|
|
237
|
+
if r.actual is None:
|
|
238
|
+
parts.append(f"{r.repo} (no recorded state; will checkout {r.expected})")
|
|
239
|
+
else:
|
|
240
|
+
parts.append(f"{r.repo}: {r.actual} → {r.expected}")
|
|
241
|
+
return "; ".join(parts)
|
canopy/actions/errors.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Structured, actionable errors for canopy actions.
|
|
2
|
+
|
|
3
|
+
The design contract: every action error carries enough machine-readable
|
|
4
|
+
context that the consumer (a human reading CLI output, or an agent reading
|
|
5
|
+
MCP JSON) can act on it without parsing prose. Same shape across surfaces.
|
|
6
|
+
|
|
7
|
+
A BlockerError is a precondition failure: the action refused to start.
|
|
8
|
+
A FailedError is a mid-flight failure: the action started but couldn't
|
|
9
|
+
complete. Both serialize identically; consumers tell them apart by ``status``.
|
|
10
|
+
|
|
11
|
+
Error contract::
|
|
12
|
+
|
|
13
|
+
{
|
|
14
|
+
"status": "blocked" | "failed",
|
|
15
|
+
"code": "drift_detected" | "preflight_failed" | ...,
|
|
16
|
+
"what": "human-readable summary",
|
|
17
|
+
"expected": {...}, # action-specific
|
|
18
|
+
"actual": {...}, # action-specific
|
|
19
|
+
"fix_actions": [FixAction...], # ordered, most-recommended first
|
|
20
|
+
"details": {...}, # extra context
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
CLI renders this via ``canopy.cli.render.render_blocker``. MCP returns the
|
|
24
|
+
dict from ``to_dict`` directly to the agent.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class FixAction:
|
|
34
|
+
"""A suggested next step the user or agent can take to unblock the action.
|
|
35
|
+
|
|
36
|
+
``safe=True`` means an agent can run this without further user
|
|
37
|
+
confirmation (e.g., a clean realign). ``safe=False`` means the fix
|
|
38
|
+
might lose work or affect remote state, and a human should approve
|
|
39
|
+
first (e.g., ``--force`` deletes, force-push).
|
|
40
|
+
"""
|
|
41
|
+
action: str # canopy action name, e.g., "realign"
|
|
42
|
+
args: dict[str, Any] = field(default_factory=dict)
|
|
43
|
+
safe: bool = True
|
|
44
|
+
preview: str | None = None # one-line description of what'd happen
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"action": self.action,
|
|
49
|
+
"args": dict(self.args),
|
|
50
|
+
"safe": self.safe,
|
|
51
|
+
"preview": self.preview,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ActionError(Exception):
|
|
56
|
+
"""Base class for canopy action errors.
|
|
57
|
+
|
|
58
|
+
Subclasses set ``STATUS`` (``"blocked"`` or ``"failed"``) and provide
|
|
59
|
+
a ``code`` plus the structured fields. Raise; the calling layer catches
|
|
60
|
+
and either re-raises (Python consumers), serializes (MCP), or renders
|
|
61
|
+
(CLI).
|
|
62
|
+
"""
|
|
63
|
+
STATUS: str = "failed"
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
code: str,
|
|
68
|
+
what: str,
|
|
69
|
+
*,
|
|
70
|
+
expected: Any = None,
|
|
71
|
+
actual: Any = None,
|
|
72
|
+
fix_actions: list[FixAction] | None = None,
|
|
73
|
+
details: dict[str, Any] | None = None,
|
|
74
|
+
):
|
|
75
|
+
super().__init__(f"{self.STATUS}: {code}: {what}")
|
|
76
|
+
self.code = code
|
|
77
|
+
self.what = what
|
|
78
|
+
self.expected = expected
|
|
79
|
+
self.actual = actual
|
|
80
|
+
self.fix_actions: list[FixAction] = list(fix_actions or [])
|
|
81
|
+
self.details: dict[str, Any] = dict(details or {})
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict[str, Any]:
|
|
84
|
+
out: dict[str, Any] = {
|
|
85
|
+
"status": self.STATUS,
|
|
86
|
+
"code": self.code,
|
|
87
|
+
"what": self.what,
|
|
88
|
+
"fix_actions": [f.to_dict() for f in self.fix_actions],
|
|
89
|
+
}
|
|
90
|
+
if self.expected is not None:
|
|
91
|
+
out["expected"] = self.expected
|
|
92
|
+
if self.actual is not None:
|
|
93
|
+
out["actual"] = self.actual
|
|
94
|
+
if self.details:
|
|
95
|
+
out["details"] = self.details
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class BlockerError(ActionError):
|
|
100
|
+
"""Action refused to start because a precondition failed.
|
|
101
|
+
|
|
102
|
+
Raised before any side effects. The action's state hasn't changed.
|
|
103
|
+
Callers should rely on ``fix_actions`` to recover.
|
|
104
|
+
"""
|
|
105
|
+
STATUS = "blocked"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class FailedError(ActionError):
|
|
109
|
+
"""Action started but couldn't complete cleanly.
|
|
110
|
+
|
|
111
|
+
May have partial side effects (some repos updated, others not). The
|
|
112
|
+
``details`` field SHOULD include per-repo status so the caller can
|
|
113
|
+
reason about what's left to do.
|
|
114
|
+
"""
|
|
115
|
+
STATUS = "failed"
|