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,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)
@@ -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)
@@ -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"