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,192 @@
|
|
|
1
|
+
"""Preflight for ``switch`` — predictable-failure detection without state mutation.
|
|
2
|
+
|
|
3
|
+
Catches the classes of failure that are knowable from the current
|
|
4
|
+
filesystem + git state alone:
|
|
5
|
+
|
|
6
|
+
- target branch missing in any repo (and not creatable from default)
|
|
7
|
+
- leftover warm-worktree directory from a previous failed run
|
|
8
|
+
- git index lock currently held in any participating repo
|
|
9
|
+
- cap reached + no fix path acceptable (active-rotation past warm cap)
|
|
10
|
+
|
|
11
|
+
Returns ``None`` when everything checks out; raises a structured
|
|
12
|
+
``BlockerError`` with all detected issues otherwise. Bundling per-repo
|
|
13
|
+
failures into one error means the user sees the full picture in one
|
|
14
|
+
shot instead of fixing one issue at a time.
|
|
15
|
+
|
|
16
|
+
Defense-in-depth: preflight catches ~80% of failures cheaply. The rest
|
|
17
|
+
(disk fills mid-op, network blip during fetch, IDE racing the checkout)
|
|
18
|
+
need the rollback walker — that's PR2.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from ..git import repo as git
|
|
26
|
+
from ..workspace.workspace import Workspace
|
|
27
|
+
from . import slots as slots_mod
|
|
28
|
+
from .errors import BlockerError, FixAction
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Default cap when slots is unset. The config parser already defaults
|
|
32
|
+
# `slots` to 2, so this is purely defensive.
|
|
33
|
+
DEFAULT_WARM_SLOT_CAP = 2
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def warm_slot_cap(workspace: Workspace) -> int:
|
|
37
|
+
"""Return the warm-slot cap honored by switch's canonical-slot logic."""
|
|
38
|
+
raw = workspace.config.slots
|
|
39
|
+
return raw if raw and raw > 0 else DEFAULT_WARM_SLOT_CAP
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def preflight(
|
|
43
|
+
workspace: Workspace,
|
|
44
|
+
feature_to_activate: str,
|
|
45
|
+
repo_branches: dict[str, str],
|
|
46
|
+
*,
|
|
47
|
+
release_current: bool = False,
|
|
48
|
+
no_evict: bool = False,
|
|
49
|
+
evict_to: str | None = None,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
"""Pre-validate a ``switch`` call. Raises ``BlockerError`` on failure.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
feature_to_activate: the feature being promoted to canonical (Y).
|
|
55
|
+
repo_branches: per-repo branch map for Y, from ``repos_for_feature``.
|
|
56
|
+
release_current: if True (wind-down mode), the cap-reached check
|
|
57
|
+
is skipped (X goes cold, no warm slot consumed).
|
|
58
|
+
no_evict: in active-rotation mode, refuse to evict an LRU warm
|
|
59
|
+
worktree when the cap is full instead of asking the user.
|
|
60
|
+
evict_to: if set, the user has pinned a destination slot for X.
|
|
61
|
+
Skip the cap-fire check — the explicit-slot path (in switch)
|
|
62
|
+
handles validation + eviction of any occupant.
|
|
63
|
+
|
|
64
|
+
Returns a small fact dict the caller can use to make decisions:
|
|
65
|
+
``{branches_to_create: [(repo, branch)], cap_will_fire: bool,
|
|
66
|
+
lru_eviction_candidate: <feature> | None,
|
|
67
|
+
previously_canonical: <feature> | None}``.
|
|
68
|
+
"""
|
|
69
|
+
# Per-repo branch + path checks
|
|
70
|
+
branches_to_create: list[tuple[str, str]] = []
|
|
71
|
+
issues: list[dict[str, Any]] = []
|
|
72
|
+
|
|
73
|
+
for repo_name, branch in repo_branches.items():
|
|
74
|
+
try:
|
|
75
|
+
state = workspace.get_repo(repo_name)
|
|
76
|
+
except KeyError:
|
|
77
|
+
issues.append({
|
|
78
|
+
"repo": repo_name,
|
|
79
|
+
"kind": "repo_not_in_workspace",
|
|
80
|
+
"what": f"repo '{repo_name}' not in canopy.toml",
|
|
81
|
+
})
|
|
82
|
+
continue
|
|
83
|
+
repo_path = state.abs_path
|
|
84
|
+
|
|
85
|
+
# Lock check — git refuses to operate while index.lock exists
|
|
86
|
+
if (repo_path / ".git" / "index.lock").exists():
|
|
87
|
+
issues.append({
|
|
88
|
+
"repo": repo_name,
|
|
89
|
+
"kind": "index_lock_held",
|
|
90
|
+
"what": (
|
|
91
|
+
f".git/index.lock present in {repo_name} — another git"
|
|
92
|
+
" process may be running"
|
|
93
|
+
),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
# Branch existence — we'll create from default if missing
|
|
97
|
+
if not git.branch_exists(repo_path, branch):
|
|
98
|
+
branches_to_create.append((repo_name, branch))
|
|
99
|
+
|
|
100
|
+
# Read the slot state (3.0 layout). previously_canonical is the
|
|
101
|
+
# canonical feature, if any, that differs from Y.
|
|
102
|
+
state = slots_mod.read_state(workspace)
|
|
103
|
+
previously_canonical: str | None = None
|
|
104
|
+
if state and state.canonical and state.canonical.feature != feature_to_activate:
|
|
105
|
+
previously_canonical = state.canonical.feature
|
|
106
|
+
|
|
107
|
+
already_warm: set[str] = (
|
|
108
|
+
{e.feature for e in state.slots.values()} if state else set()
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Cap-will-fire check (only active-rotation mode evacuates X to warm).
|
|
112
|
+
# When the user pinned a destination via ``--evict-to``, the
|
|
113
|
+
# explicit-slot path in switch handles validation + occupant
|
|
114
|
+
# eviction, so skip the cap-fire surface here.
|
|
115
|
+
cap_will_fire = False
|
|
116
|
+
lru_eviction_candidate: str | None = None
|
|
117
|
+
if previously_canonical and not release_current and evict_to is None:
|
|
118
|
+
cap = warm_slot_cap(workspace)
|
|
119
|
+
# Y is becoming canonical, so if Y was warm it leaves the warm set;
|
|
120
|
+
# X (previously_canonical) is joining the warm set.
|
|
121
|
+
post_switch_warm = (already_warm - {feature_to_activate}) | {previously_canonical}
|
|
122
|
+
if len(post_switch_warm) > cap:
|
|
123
|
+
cap_will_fire = True
|
|
124
|
+
lru_eviction_candidate = slots_mod.lru_evictee(
|
|
125
|
+
state, exclude={feature_to_activate},
|
|
126
|
+
)
|
|
127
|
+
if no_evict or lru_eviction_candidate is None:
|
|
128
|
+
issues.append({
|
|
129
|
+
"kind": "worktree_cap_reached",
|
|
130
|
+
"what": (
|
|
131
|
+
f"adding {previously_canonical} as warm would exceed"
|
|
132
|
+
f" warm_slot_cap={cap} (currently warm:"
|
|
133
|
+
f" {sorted(already_warm)})"
|
|
134
|
+
),
|
|
135
|
+
"current_warm": sorted(already_warm),
|
|
136
|
+
"cap": cap,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
if issues:
|
|
140
|
+
cap_issue = next((i for i in issues if i.get("kind") == "worktree_cap_reached"), None)
|
|
141
|
+
if cap_issue:
|
|
142
|
+
raise BlockerError(
|
|
143
|
+
code="worktree_cap_reached",
|
|
144
|
+
what=cap_issue["what"],
|
|
145
|
+
expected={"warm_slot_cap": cap_issue["cap"]},
|
|
146
|
+
actual={"warm_now": cap_issue["current_warm"]},
|
|
147
|
+
fix_actions=[
|
|
148
|
+
FixAction(
|
|
149
|
+
action="switch",
|
|
150
|
+
args={"feature": feature_to_activate, "release_current": True},
|
|
151
|
+
safe=False,
|
|
152
|
+
preview=(
|
|
153
|
+
f"wind-down mode: {previously_canonical} goes"
|
|
154
|
+
f" cold (with stash), no eviction needed"
|
|
155
|
+
),
|
|
156
|
+
),
|
|
157
|
+
FixAction(
|
|
158
|
+
action="switch",
|
|
159
|
+
args={
|
|
160
|
+
"feature": feature_to_activate,
|
|
161
|
+
"evict": lru_eviction_candidate,
|
|
162
|
+
} if lru_eviction_candidate else {"feature": feature_to_activate},
|
|
163
|
+
safe=False,
|
|
164
|
+
preview=(
|
|
165
|
+
f"evict LRU warm worktree"
|
|
166
|
+
f" '{lru_eviction_candidate}' to cold"
|
|
167
|
+
if lru_eviction_candidate
|
|
168
|
+
else "no LRU candidate found — set last_touched manually"
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
FixAction(
|
|
172
|
+
action="workspace_config",
|
|
173
|
+
args={"slots": cap_issue["cap"] + 1},
|
|
174
|
+
safe=True,
|
|
175
|
+
preview=f"raise warm_slot_cap to {cap_issue['cap'] + 1}",
|
|
176
|
+
),
|
|
177
|
+
],
|
|
178
|
+
details={"all_issues": issues},
|
|
179
|
+
)
|
|
180
|
+
# Non-cap blockers
|
|
181
|
+
raise BlockerError(
|
|
182
|
+
code="switch_preflight_failed",
|
|
183
|
+
what=f"{len(issues)} issue(s) detected before switch could proceed",
|
|
184
|
+
details={"issues": issues},
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
"branches_to_create": branches_to_create,
|
|
189
|
+
"cap_will_fire": cap_will_fire,
|
|
190
|
+
"lru_eviction_candidate": lru_eviction_candidate,
|
|
191
|
+
"previously_canonical": previously_canonical,
|
|
192
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Thread action wrappers — resolve, reply, unresolve a GitHub review thread.
|
|
2
|
+
|
|
3
|
+
Each wrapper calls the GitHub integration and records the event locally in
|
|
4
|
+
``.canopy/state/thread_resolutions.json`` so the resume brief can attribute
|
|
5
|
+
"resolved by canopy" vs "resolved on GitHub directly".
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ..workspace.workspace import Workspace
|
|
10
|
+
from .errors import ActionError, BlockerError
|
|
11
|
+
from . import thread_resolutions as tr
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _validate_thread_id(thread_id: str) -> None:
|
|
15
|
+
if not thread_id.startswith("PRRT_"):
|
|
16
|
+
raise BlockerError(
|
|
17
|
+
code="invalid_thread_id",
|
|
18
|
+
what=f"thread_id must start with 'PRRT_'; got {thread_id!r}",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_thread(
|
|
23
|
+
workspace: Workspace,
|
|
24
|
+
thread_id: str,
|
|
25
|
+
*,
|
|
26
|
+
feature: str,
|
|
27
|
+
via_command: str = "resolve",
|
|
28
|
+
via_commit_sha: str | None = None,
|
|
29
|
+
) -> dict:
|
|
30
|
+
"""Resolve a GitHub PR review thread and record it locally.
|
|
31
|
+
|
|
32
|
+
Steps:
|
|
33
|
+
1. Validate thread_id format.
|
|
34
|
+
2. Call the GitHub GraphQL mutation.
|
|
35
|
+
3. Log the resolution to ``.canopy/state/thread_resolutions.json``.
|
|
36
|
+
4. Return the combined result.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
BlockerError: if ``thread_id`` does not start with ``PRRT_``.
|
|
40
|
+
"""
|
|
41
|
+
from ..integrations import github as gh
|
|
42
|
+
|
|
43
|
+
_validate_thread_id(thread_id)
|
|
44
|
+
gh_result = gh.resolve_thread(workspace.config.root, thread_id)
|
|
45
|
+
log_entry = tr.record(
|
|
46
|
+
workspace.config.root,
|
|
47
|
+
thread_id=thread_id,
|
|
48
|
+
feature=feature,
|
|
49
|
+
via_command=via_command,
|
|
50
|
+
via_commit_sha=via_commit_sha,
|
|
51
|
+
)
|
|
52
|
+
return {**gh_result, "logged": log_entry}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def reply_to_thread(
|
|
56
|
+
workspace: Workspace,
|
|
57
|
+
thread_id: str,
|
|
58
|
+
body: str,
|
|
59
|
+
*,
|
|
60
|
+
feature: str,
|
|
61
|
+
resolve_after: bool = False,
|
|
62
|
+
) -> dict:
|
|
63
|
+
"""Post a reply to a GitHub PR review thread, optionally resolving it.
|
|
64
|
+
|
|
65
|
+
Steps:
|
|
66
|
+
1. Validate thread_id format.
|
|
67
|
+
2. Post the reply via the GitHub GraphQL mutation.
|
|
68
|
+
3. If ``resolve_after`` is True, resolve the thread and include the result.
|
|
69
|
+
|
|
70
|
+
Returns a dict with ``posted`` and optionally ``resolved`` keys.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
BlockerError: if ``thread_id`` does not start with ``PRRT_``.
|
|
74
|
+
"""
|
|
75
|
+
from ..integrations import github as gh
|
|
76
|
+
|
|
77
|
+
_validate_thread_id(thread_id)
|
|
78
|
+
posted = gh.reply_to_thread(workspace.config.root, thread_id, body)
|
|
79
|
+
result: dict = {"posted": posted}
|
|
80
|
+
if resolve_after:
|
|
81
|
+
try:
|
|
82
|
+
res = resolve_thread(
|
|
83
|
+
workspace, thread_id, feature=feature, via_command="reply_resolve",
|
|
84
|
+
)
|
|
85
|
+
result["resolved"] = res
|
|
86
|
+
except ActionError as e:
|
|
87
|
+
result["resolved"] = {"error": e.to_dict()}
|
|
88
|
+
return result
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Thread resolution log — persistent record of threads resolved via canopy.
|
|
2
|
+
|
|
3
|
+
State file: .canopy/state/thread_resolutions.json (atomic temp+rename writes).
|
|
4
|
+
|
|
5
|
+
Schema::
|
|
6
|
+
|
|
7
|
+
{
|
|
8
|
+
"PRRT_abc": {
|
|
9
|
+
"resolved_by_canopy_at": "2026-05-29T12:00:00Z",
|
|
10
|
+
"feature": "auth-flow",
|
|
11
|
+
"via_command": "resolve" | "commit_address" | "reply_resolve",
|
|
12
|
+
"via_commit_sha": "abc1234" | null
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
Used by the resume brief to attribute "resolved by canopy" vs "resolved on
|
|
17
|
+
GitHub directly" — only canopy-initiated resolutions appear here.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import tempfile
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _state_path(workspace_root: Path) -> Path:
|
|
29
|
+
return workspace_root / ".canopy" / "state" / "thread_resolutions.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _now_iso() -> str:
|
|
33
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load(workspace_root: Path) -> dict:
|
|
37
|
+
"""Return the full log dict. Missing file returns an empty dict."""
|
|
38
|
+
path = _state_path(workspace_root)
|
|
39
|
+
if not path.exists():
|
|
40
|
+
return {}
|
|
41
|
+
try:
|
|
42
|
+
data = json.loads(path.read_text())
|
|
43
|
+
if isinstance(data, dict):
|
|
44
|
+
return data
|
|
45
|
+
except (OSError, json.JSONDecodeError):
|
|
46
|
+
pass
|
|
47
|
+
return {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def record(
|
|
51
|
+
workspace_root: Path,
|
|
52
|
+
*,
|
|
53
|
+
thread_id: str,
|
|
54
|
+
feature: str,
|
|
55
|
+
via_command: str,
|
|
56
|
+
via_commit_sha: str | None = None,
|
|
57
|
+
) -> dict:
|
|
58
|
+
"""Append or overwrite a thread resolution entry. Returns the entry written.
|
|
59
|
+
|
|
60
|
+
Atomic write: mkstemp in same dir → write → os.replace.
|
|
61
|
+
"""
|
|
62
|
+
log = load(workspace_root)
|
|
63
|
+
entry = {
|
|
64
|
+
"resolved_by_canopy_at": _now_iso(),
|
|
65
|
+
"feature": feature,
|
|
66
|
+
"via_command": via_command,
|
|
67
|
+
"via_commit_sha": via_commit_sha,
|
|
68
|
+
}
|
|
69
|
+
log[thread_id] = entry
|
|
70
|
+
|
|
71
|
+
path = _state_path(workspace_root)
|
|
72
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".json.tmp")
|
|
75
|
+
try:
|
|
76
|
+
with os.fdopen(fd, "w") as fh:
|
|
77
|
+
json.dump(log, fh, indent=2)
|
|
78
|
+
os.replace(tmp_path, path)
|
|
79
|
+
except Exception:
|
|
80
|
+
try:
|
|
81
|
+
os.unlink(tmp_path)
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
raise
|
|
85
|
+
|
|
86
|
+
return entry
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def filter_since(workspace_root: Path, since_iso: str) -> dict:
|
|
90
|
+
"""Return only entries with ``resolved_by_canopy_at`` >= ``since_iso``.
|
|
91
|
+
|
|
92
|
+
Comparison is lexicographic on the ISO 8601 Z strings (safe because they
|
|
93
|
+
are always produced in the same zero-padded format by ``_now_iso``).
|
|
94
|
+
"""
|
|
95
|
+
log = load(workspace_root)
|
|
96
|
+
return {
|
|
97
|
+
tid: entry
|
|
98
|
+
for tid, entry in log.items()
|
|
99
|
+
if isinstance(entry, dict)
|
|
100
|
+
and entry.get("resolved_by_canopy_at", "") >= since_iso
|
|
101
|
+
}
|
canopy/actions/triage.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""triage(author) — the agent's daily entry point.
|
|
2
|
+
|
|
3
|
+
Enumerates open PRs across all configured repos, groups by feature lane
|
|
4
|
+
(explicit from features.json or implicit by shared branch name), runs
|
|
5
|
+
each group's review comments through the temporal classifier, and tags
|
|
6
|
+
each feature with a priority tier:
|
|
7
|
+
|
|
8
|
+
changes_requested > review_required_with_bot_comments
|
|
9
|
+
> review_required
|
|
10
|
+
> approved
|
|
11
|
+
|
|
12
|
+
Designed for the user's morning workflow: ``canopy triage`` returns a
|
|
13
|
+
single ordered list of "what needs my attention right now".
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ..integrations import github as gh
|
|
20
|
+
from ..workspace.workspace import Workspace
|
|
21
|
+
from . import slots as slots_mod
|
|
22
|
+
from .aliases import _resolve_owner_slug
|
|
23
|
+
from .errors import BlockerError
|
|
24
|
+
from .review_filter import classify_threads
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_PRIORITY_ORDER = {
|
|
28
|
+
"changes_requested": 0,
|
|
29
|
+
"review_required_with_bot_comments": 1,
|
|
30
|
+
"review_required": 2,
|
|
31
|
+
"approved": 3,
|
|
32
|
+
"unknown": 4,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def triage(
|
|
37
|
+
workspace: Workspace,
|
|
38
|
+
author: str = "@me",
|
|
39
|
+
repos: list[str] | None = None,
|
|
40
|
+
) -> dict[str, Any]:
|
|
41
|
+
"""Return prioritized list of features needing user attention.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
workspace: loaded workspace.
|
|
45
|
+
author: GitHub username/handle to filter PRs by; ``@me`` is
|
|
46
|
+
the gh CLI shorthand for the authenticated user.
|
|
47
|
+
repos: subset of canopy repos to scan (default: all).
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
``{author, canonical_feature, features: [{feature, linear_issue,
|
|
51
|
+
linear_url, linear_title, priority, is_canonical, physical_state,
|
|
52
|
+
repos: {<r>: {pr_number, pr_url, pr_title, branch, review_decision,
|
|
53
|
+
actionable_count, likely_resolved_count, has_actionable_bot_thread,
|
|
54
|
+
physical_state, path}}}]}`` ordered most-urgent first.
|
|
55
|
+
|
|
56
|
+
``physical_state`` per feature is ``canonical | warm | cold | none``
|
|
57
|
+
(none = no worktree, branch may not even be checked out anywhere).
|
|
58
|
+
Per-repo ``physical_state`` + ``path`` lets the agent decide
|
|
59
|
+
whether to switch first or just `canopy_run` against the recorded
|
|
60
|
+
path.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
BlockerError: if no GitHub transport is available, or if a
|
|
64
|
+
requested repo is unknown.
|
|
65
|
+
"""
|
|
66
|
+
target_repos = _select_repos(workspace, repos)
|
|
67
|
+
prs_by_repo = _fetch_open_prs(workspace, target_repos, author)
|
|
68
|
+
feature_groups = _group_by_feature(workspace, prs_by_repo)
|
|
69
|
+
state = slots_mod.read_state(workspace)
|
|
70
|
+
canonical_feature = state.canonical.feature if state and state.canonical else None
|
|
71
|
+
enriched = [_enrich(workspace, g, canonical_feature) for g in feature_groups]
|
|
72
|
+
enriched.sort(key=lambda f: _PRIORITY_ORDER.get(f["priority"], 99))
|
|
73
|
+
return {
|
|
74
|
+
"author": author,
|
|
75
|
+
"canonical_feature": canonical_feature,
|
|
76
|
+
"features": enriched,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _fetch_open_prs(
|
|
81
|
+
workspace: Workspace, target_repos: list[str], author: str,
|
|
82
|
+
) -> dict[str, list[dict]]:
|
|
83
|
+
out: dict[str, list[dict]] = {}
|
|
84
|
+
for repo_name in target_repos:
|
|
85
|
+
try:
|
|
86
|
+
owner, slug = _resolve_owner_slug(workspace, repo_name)
|
|
87
|
+
except BlockerError:
|
|
88
|
+
# Repo with no parseable github remote — skip silently
|
|
89
|
+
out[repo_name] = []
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
out[repo_name] = gh.list_open_prs(
|
|
93
|
+
workspace.config.root, owner, slug, author=author,
|
|
94
|
+
)
|
|
95
|
+
except gh.GitHubNotConfiguredError as e:
|
|
96
|
+
from .errors import FixAction
|
|
97
|
+
payload = e.payload or {}
|
|
98
|
+
fix_actions = [
|
|
99
|
+
FixAction(action=fa["action"], args=fa.get("args", {}),
|
|
100
|
+
safe=fa.get("safe", True), preview=fa.get("preview"))
|
|
101
|
+
for fa in payload.get("fix_actions", [])
|
|
102
|
+
]
|
|
103
|
+
raise BlockerError(
|
|
104
|
+
code=payload.get("code", "github_not_configured"),
|
|
105
|
+
what=payload.get("what", str(e)),
|
|
106
|
+
fix_actions=fix_actions,
|
|
107
|
+
details={"repo": repo_name},
|
|
108
|
+
)
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _group_by_feature(
|
|
113
|
+
workspace: Workspace, prs_by_repo: dict[str, list[dict]],
|
|
114
|
+
) -> list[dict]:
|
|
115
|
+
"""Group PRs into feature lanes.
|
|
116
|
+
|
|
117
|
+
Strategy:
|
|
118
|
+
1. Build (repo, branch) → pr index.
|
|
119
|
+
2. For each explicit feature in features.json, claim PRs whose
|
|
120
|
+
branch matches the lane's expected branch *for that repo*
|
|
121
|
+
(using the per-repo ``branches`` override map when set, else
|
|
122
|
+
feature name). This is what groups
|
|
123
|
+
``auth-flow`` (api) + ``auth-flow-v2`` (ui) into one
|
|
124
|
+
feature lane.
|
|
125
|
+
3. Remaining (repo, branch) pairs that weren't consumed become
|
|
126
|
+
implicit features: each branch becomes a feature, multi-repo
|
|
127
|
+
when the same branch appears in 2+ repos, single-repo otherwise.
|
|
128
|
+
"""
|
|
129
|
+
from ..features.coordinator import FeatureCoordinator
|
|
130
|
+
|
|
131
|
+
by_repo_branch: dict[tuple[str, str], dict] = {}
|
|
132
|
+
for repo_name, prs in prs_by_repo.items():
|
|
133
|
+
for pr in prs:
|
|
134
|
+
branch = pr.get("head_branch") or ""
|
|
135
|
+
if not branch:
|
|
136
|
+
continue
|
|
137
|
+
by_repo_branch[(repo_name, branch)] = pr
|
|
138
|
+
|
|
139
|
+
coord = FeatureCoordinator(workspace)
|
|
140
|
+
features = coord._load_features()
|
|
141
|
+
consumed: set[tuple[str, str]] = set()
|
|
142
|
+
groups: list[dict] = []
|
|
143
|
+
|
|
144
|
+
for feature_name, feature_data in features.items():
|
|
145
|
+
if feature_data.get("status") != "active":
|
|
146
|
+
continue
|
|
147
|
+
feature_repos = list(feature_data.get("repos") or [])
|
|
148
|
+
branches_map = feature_data.get("branches") or {}
|
|
149
|
+
|
|
150
|
+
repos_for_feature: dict[str, dict] = {}
|
|
151
|
+
for repo_name in feature_repos:
|
|
152
|
+
expected_branch = branches_map.get(repo_name, feature_name)
|
|
153
|
+
key = (repo_name, expected_branch)
|
|
154
|
+
if key in by_repo_branch and key not in consumed:
|
|
155
|
+
repos_for_feature[repo_name] = by_repo_branch[key]
|
|
156
|
+
consumed.add(key)
|
|
157
|
+
|
|
158
|
+
if not repos_for_feature:
|
|
159
|
+
continue
|
|
160
|
+
groups.append({
|
|
161
|
+
"feature": feature_name,
|
|
162
|
+
"linear_issue": feature_data.get("linear_issue", ""),
|
|
163
|
+
"linear_url": feature_data.get("linear_url", ""),
|
|
164
|
+
"linear_title": feature_data.get("linear_title", ""),
|
|
165
|
+
"repos": repos_for_feature,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
# Remaining (repo, branch) pairs become implicit feature groups.
|
|
169
|
+
# Same branch across repos → one group; otherwise per-branch group.
|
|
170
|
+
remaining_by_branch: dict[str, dict[str, dict]] = {}
|
|
171
|
+
for (repo_name, branch), pr in by_repo_branch.items():
|
|
172
|
+
if (repo_name, branch) in consumed:
|
|
173
|
+
continue
|
|
174
|
+
remaining_by_branch.setdefault(branch, {})[repo_name] = pr
|
|
175
|
+
|
|
176
|
+
for branch, repos in remaining_by_branch.items():
|
|
177
|
+
groups.append({
|
|
178
|
+
"feature": branch,
|
|
179
|
+
"linear_issue": "",
|
|
180
|
+
"linear_url": "",
|
|
181
|
+
"linear_title": "",
|
|
182
|
+
"repos": repos,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
return groups
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _enrich(
|
|
189
|
+
workspace: Workspace, group: dict, canonical_feature: str | None,
|
|
190
|
+
) -> dict:
|
|
191
|
+
feature_name = group["feature"]
|
|
192
|
+
is_canonical = canonical_feature == feature_name
|
|
193
|
+
per_repo: dict[str, dict] = {}
|
|
194
|
+
for canopy_repo, pr in group["repos"].items():
|
|
195
|
+
owner, slug = _resolve_owner_slug(workspace, canopy_repo)
|
|
196
|
+
comments, _resolved = gh.get_review_comments(
|
|
197
|
+
workspace.config.root, owner, slug, pr["number"],
|
|
198
|
+
)
|
|
199
|
+
state = workspace.get_repo(canopy_repo)
|
|
200
|
+
classification = classify_threads(
|
|
201
|
+
comments, state.abs_path, pr.get("head_branch") or "",
|
|
202
|
+
)
|
|
203
|
+
actionable = classification["actionable_threads"]
|
|
204
|
+
|
|
205
|
+
# Physical state per repo: where this feature lives right now.
|
|
206
|
+
slot_id = slots_mod.slot_for_feature(workspace, feature_name)
|
|
207
|
+
wt = (
|
|
208
|
+
slots_mod.slot_worktree_path(workspace, slot_id, canopy_repo)
|
|
209
|
+
if slot_id else None
|
|
210
|
+
)
|
|
211
|
+
if is_canonical:
|
|
212
|
+
phys = "canonical"
|
|
213
|
+
path = str(state.abs_path.resolve())
|
|
214
|
+
elif wt is not None and wt.exists() and (wt / ".git").exists():
|
|
215
|
+
phys = "warm"
|
|
216
|
+
path = str(wt.resolve())
|
|
217
|
+
else:
|
|
218
|
+
phys = "cold"
|
|
219
|
+
path = "" # no on-disk home yet; switch will create one
|
|
220
|
+
|
|
221
|
+
per_repo[canopy_repo] = {
|
|
222
|
+
"pr_number": pr["number"],
|
|
223
|
+
"pr_url": pr.get("url", ""),
|
|
224
|
+
"pr_title": pr.get("title", ""),
|
|
225
|
+
"branch": pr.get("head_branch", ""),
|
|
226
|
+
"review_decision": pr.get("review_decision", ""),
|
|
227
|
+
"actionable_count": len(actionable),
|
|
228
|
+
"likely_resolved_count": len(classification["likely_resolved_threads"]),
|
|
229
|
+
"has_actionable_bot_thread": any(
|
|
230
|
+
t.get("author_type") == "Bot" for t in actionable
|
|
231
|
+
),
|
|
232
|
+
"physical_state": phys,
|
|
233
|
+
"path": path,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
# Top-level physical_state is the highest-resolution per-repo state.
|
|
237
|
+
# canonical > warm > cold. (If repos disagree we report the warmest.)
|
|
238
|
+
states = {r["physical_state"] for r in per_repo.values()}
|
|
239
|
+
if "canonical" in states:
|
|
240
|
+
feat_phys = "canonical"
|
|
241
|
+
elif "warm" in states:
|
|
242
|
+
feat_phys = "warm" if states <= {"warm", "cold"} else "mixed"
|
|
243
|
+
else:
|
|
244
|
+
feat_phys = "cold"
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
"feature": feature_name,
|
|
248
|
+
"linear_issue": group["linear_issue"],
|
|
249
|
+
"linear_url": group["linear_url"],
|
|
250
|
+
"linear_title": group["linear_title"],
|
|
251
|
+
"priority": _compute_priority(per_repo),
|
|
252
|
+
"is_canonical": is_canonical,
|
|
253
|
+
"physical_state": feat_phys,
|
|
254
|
+
"repos": per_repo,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _compute_priority(per_repo: dict[str, dict]) -> str:
|
|
259
|
+
decisions = {info.get("review_decision", "") for info in per_repo.values()}
|
|
260
|
+
bot_actionable = any(
|
|
261
|
+
info.get("has_actionable_bot_thread") for info in per_repo.values()
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if "CHANGES_REQUESTED" in decisions:
|
|
265
|
+
return "changes_requested"
|
|
266
|
+
non_empty = {d for d in decisions if d}
|
|
267
|
+
if non_empty and non_empty <= {"APPROVED"}:
|
|
268
|
+
return "approved"
|
|
269
|
+
if bot_actionable:
|
|
270
|
+
return "review_required_with_bot_comments"
|
|
271
|
+
return "review_required"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _select_repos(workspace: Workspace, requested: list[str] | None) -> list[str]:
|
|
275
|
+
all_names = [r.config.name for r in workspace.repos]
|
|
276
|
+
if requested is None:
|
|
277
|
+
return all_names
|
|
278
|
+
unknown = [r for r in requested if r not in set(all_names)]
|
|
279
|
+
if unknown:
|
|
280
|
+
raise BlockerError(
|
|
281
|
+
code="unknown_repo",
|
|
282
|
+
what=f"unknown repos: {', '.join(unknown)}",
|
|
283
|
+
expected={"available_repos": sorted(all_names)},
|
|
284
|
+
details={"requested": list(requested)},
|
|
285
|
+
)
|
|
286
|
+
return list(requested)
|