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,607 @@
|
|
|
1
|
+
"""feature_state(feature) — single source of truth for the dashboard CTAs.
|
|
2
|
+
|
|
3
|
+
Composes drift detection (P1) + dirty/branch state (workspace) + ahead/behind
|
|
4
|
+
(git) + temporal-filtered review comments (P4) + recorded preflight result
|
|
5
|
+
(``preflight_state``) + GitHub PR data (gh CLI fallback or MCP) into one
|
|
6
|
+
of these states:
|
|
7
|
+
|
|
8
|
+
drifted -- branches not on the feature; first thing to fix
|
|
9
|
+
needs_work -- review feedback exists (CHANGES_REQUESTED or
|
|
10
|
+
actionable threads from any reviewer)
|
|
11
|
+
in_progress -- aligned, dirty tree, no fresh preflight
|
|
12
|
+
ready_to_commit -- aligned, dirty tree, preflight passed for current HEAD
|
|
13
|
+
ready_to_push -- aligned, clean, ahead of remote
|
|
14
|
+
awaiting_review -- aligned, clean, pushed, PRs open, no actionable threads
|
|
15
|
+
approved -- all PRs approved
|
|
16
|
+
no_prs -- aligned, clean, no PRs anywhere
|
|
17
|
+
|
|
18
|
+
The state result also carries a ``next_actions`` list — the dashboard
|
|
19
|
+
renders the first one as the primary CTA, the rest as secondary. Same
|
|
20
|
+
data the agent uses to decide what to do next, so the human and the
|
|
21
|
+
agent stay in lockstep.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ..git import repo as git
|
|
29
|
+
from ..integrations import github as gh
|
|
30
|
+
from ..workspace.workspace import Workspace
|
|
31
|
+
from .aliases import (
|
|
32
|
+
repos_for_feature, resolve_feature, _resolve_owner_slug,
|
|
33
|
+
)
|
|
34
|
+
from .augments import bot_authors
|
|
35
|
+
from .bot_resolutions import resolutions_for_feature
|
|
36
|
+
from .preflight_state import is_fresh
|
|
37
|
+
from .review_filter import classify_threads
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def feature_state(workspace: Workspace, feature: str) -> dict[str, Any]:
|
|
41
|
+
"""Compute the feature's current state + suggested next actions.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
workspace: loaded workspace.
|
|
45
|
+
feature: feature alias (resolved through the standard alias layer).
|
|
46
|
+
|
|
47
|
+
Returns ``{feature, state, summary, next_actions, warnings}`` —
|
|
48
|
+
summary fields aggregate per-repo state so the dashboard can render
|
|
49
|
+
a feature card without re-querying.
|
|
50
|
+
"""
|
|
51
|
+
feature_name = resolve_feature(workspace, feature)
|
|
52
|
+
workspace.refresh()
|
|
53
|
+
|
|
54
|
+
repo_branches = repos_for_feature(workspace, feature_name)
|
|
55
|
+
if not repo_branches:
|
|
56
|
+
return _shell_result(feature_name, "no_prs",
|
|
57
|
+
note="no repos resolved for feature")
|
|
58
|
+
|
|
59
|
+
# A worktree-backed feature physically lives at its worktree path,
|
|
60
|
+
# regardless of which feature is "active" right now. Resolve per-repo
|
|
61
|
+
# paths up front so drift + per-repo facts both check the right tree.
|
|
62
|
+
repo_paths, has_worktrees = resolve_repo_paths(
|
|
63
|
+
workspace, feature_name, repo_branches,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Drift check from LIVE git state (not heads.json, which may be empty
|
|
67
|
+
# if the post-checkout hook hasn't run). The hook + heads.json power
|
|
68
|
+
# canopy drift's fast path; feature_state prefers correctness.
|
|
69
|
+
drift_info = _live_drift(workspace, repo_branches, repo_paths)
|
|
70
|
+
if drift_info["drifted_repos"] or drift_info["missing_repos"]:
|
|
71
|
+
return _drifted_result(feature_name, drift_info, has_worktrees=has_worktrees)
|
|
72
|
+
|
|
73
|
+
# Aligned. Gather per-repo facts.
|
|
74
|
+
per_repo = _per_repo_facts(workspace, feature_name, repo_branches, repo_paths)
|
|
75
|
+
summary = _summarize(per_repo)
|
|
76
|
+
preflight_fresh, preflight_entry = is_fresh(
|
|
77
|
+
workspace, feature_name, repo_branches,
|
|
78
|
+
)
|
|
79
|
+
summary["preflight"] = _preflight_summary(preflight_entry, preflight_fresh)
|
|
80
|
+
|
|
81
|
+
state, next_actions, warnings = _decide_state(
|
|
82
|
+
feature_name, per_repo, summary, preflight_fresh, preflight_entry,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
"feature": feature_name,
|
|
87
|
+
"state": state,
|
|
88
|
+
"summary": summary,
|
|
89
|
+
"next_actions": next_actions,
|
|
90
|
+
"warnings": warnings,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def resolve_repo_paths(
|
|
95
|
+
workspace: Workspace, feature_name: str, repo_branches: dict[str, str],
|
|
96
|
+
) -> tuple[dict[str, Path], bool]:
|
|
97
|
+
"""Per-repo path resolution for state derivation.
|
|
98
|
+
|
|
99
|
+
Worktree-backed features always resolve to the worktree path, regardless
|
|
100
|
+
of activation status — a worktree IS the feature's home, the active flag
|
|
101
|
+
only governs implicit cwd in canopy_run/IDE openers.
|
|
102
|
+
|
|
103
|
+
Returns (paths_by_repo, has_any_worktrees). The flag drives downstream
|
|
104
|
+
UX choices (e.g. drifted-state next-action: switch vs realign).
|
|
105
|
+
"""
|
|
106
|
+
from ..features.coordinator import FeatureCoordinator
|
|
107
|
+
coord = FeatureCoordinator(workspace)
|
|
108
|
+
try:
|
|
109
|
+
lane = coord.status(feature_name)
|
|
110
|
+
except Exception:
|
|
111
|
+
lane = None
|
|
112
|
+
|
|
113
|
+
paths: dict[str, Path] = {}
|
|
114
|
+
has_worktrees = False
|
|
115
|
+
for repo_name in repo_branches:
|
|
116
|
+
try:
|
|
117
|
+
state = workspace.get_repo(repo_name)
|
|
118
|
+
except KeyError:
|
|
119
|
+
continue
|
|
120
|
+
wt_path: Path | None = None
|
|
121
|
+
if lane is not None:
|
|
122
|
+
wt_str = (lane.repo_states.get(repo_name) or {}).get("worktree_path")
|
|
123
|
+
if wt_str:
|
|
124
|
+
candidate = Path(wt_str).resolve()
|
|
125
|
+
# ``worktree_for_branch`` returns the main repo path when the
|
|
126
|
+
# branch is checked out there, so candidate == state.abs_path
|
|
127
|
+
# means "no linked worktree — feature lives in the main tree."
|
|
128
|
+
if candidate.exists() and candidate != state.abs_path.resolve():
|
|
129
|
+
wt_path = candidate
|
|
130
|
+
has_worktrees = True
|
|
131
|
+
paths[repo_name] = wt_path if wt_path is not None else state.abs_path
|
|
132
|
+
return paths, has_worktrees
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _per_repo_facts(
|
|
136
|
+
workspace: Workspace, feature_name: str, repo_branches: dict[str, str],
|
|
137
|
+
repo_paths: dict[str, Path],
|
|
138
|
+
) -> dict[str, dict]:
|
|
139
|
+
"""Gather facts per repo: dirty, ahead/behind, PR, comments.
|
|
140
|
+
|
|
141
|
+
``repo_paths`` resolves to the worktree path for worktree-backed
|
|
142
|
+
features, the main repo path otherwise. Without it, dirty/ahead/branch
|
|
143
|
+
checks would target main even when the feature lives in a worktree.
|
|
144
|
+
"""
|
|
145
|
+
out: dict[str, dict] = {}
|
|
146
|
+
for repo_name, branch in repo_branches.items():
|
|
147
|
+
try:
|
|
148
|
+
state = workspace.get_repo(repo_name)
|
|
149
|
+
except KeyError:
|
|
150
|
+
continue
|
|
151
|
+
repo_path = repo_paths.get(repo_name, state.abs_path)
|
|
152
|
+
|
|
153
|
+
facts: dict[str, Any] = {
|
|
154
|
+
"branch": branch,
|
|
155
|
+
"exists_locally": git.branch_exists(repo_path, branch),
|
|
156
|
+
}
|
|
157
|
+
if not facts["exists_locally"]:
|
|
158
|
+
out[repo_name] = facts
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
facts["is_dirty"] = git.is_dirty(repo_path)
|
|
163
|
+
facts["dirty_count"] = git.dirty_file_count(repo_path)
|
|
164
|
+
except git.GitError:
|
|
165
|
+
facts["is_dirty"] = False
|
|
166
|
+
facts["dirty_count"] = 0
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
facts["head_sha"] = git.sha_of(repo_path, branch)
|
|
170
|
+
except git.GitError:
|
|
171
|
+
facts["head_sha"] = ""
|
|
172
|
+
|
|
173
|
+
remote_ref = f"origin/{branch}"
|
|
174
|
+
facts["has_upstream"] = bool(git.sha_of(repo_path, remote_ref))
|
|
175
|
+
if facts["has_upstream"]:
|
|
176
|
+
try:
|
|
177
|
+
ahead, behind = git.divergence(repo_path, branch, remote_ref)
|
|
178
|
+
facts["ahead"] = ahead
|
|
179
|
+
facts["behind"] = behind
|
|
180
|
+
except Exception:
|
|
181
|
+
facts["ahead"] = 0
|
|
182
|
+
facts["behind"] = 0
|
|
183
|
+
else:
|
|
184
|
+
facts["ahead"] = 0
|
|
185
|
+
facts["behind"] = 0
|
|
186
|
+
|
|
187
|
+
# PR + comment data.
|
|
188
|
+
try:
|
|
189
|
+
owner, slug = _resolve_owner_slug(workspace, repo_name)
|
|
190
|
+
except Exception:
|
|
191
|
+
owner, slug = "", ""
|
|
192
|
+
facts["owner"] = owner
|
|
193
|
+
facts["repo_slug"] = slug
|
|
194
|
+
facts["pr"] = None
|
|
195
|
+
facts["actionable_count"] = 0
|
|
196
|
+
facts["actionable_human_count"] = 0
|
|
197
|
+
facts["actionable_bot_count"] = 0
|
|
198
|
+
facts["actionable_bot_threads"] = []
|
|
199
|
+
facts["likely_resolved_count"] = 0
|
|
200
|
+
facts["review_decision"] = ""
|
|
201
|
+
if owner and slug:
|
|
202
|
+
try:
|
|
203
|
+
pr = gh.find_pull_request(
|
|
204
|
+
workspace.config.root, owner, slug, branch,
|
|
205
|
+
)
|
|
206
|
+
except gh.GitHubNotConfiguredError:
|
|
207
|
+
pr = None
|
|
208
|
+
if pr:
|
|
209
|
+
facts["pr"] = pr
|
|
210
|
+
facts["review_decision"] = pr.get("review_decision", "")
|
|
211
|
+
# M10: CI check rollup. Best-effort — failures here
|
|
212
|
+
# default to ``no_checks`` rather than blocking the
|
|
213
|
+
# whole feature_state read.
|
|
214
|
+
try:
|
|
215
|
+
ci_status, _raw = gh.get_pr_checks(
|
|
216
|
+
workspace.config.root, owner, slug, pr["number"],
|
|
217
|
+
)
|
|
218
|
+
facts["ci_status"] = ci_status
|
|
219
|
+
except Exception:
|
|
220
|
+
facts["ci_status"] = {"status": "no_checks"}
|
|
221
|
+
try:
|
|
222
|
+
comments, _ = gh.get_review_comments(
|
|
223
|
+
workspace.config.root, owner, slug, pr["number"],
|
|
224
|
+
)
|
|
225
|
+
classification = classify_threads(comments, repo_path, branch)
|
|
226
|
+
actionable = classification["actionable_threads"]
|
|
227
|
+
facts["likely_resolved_count"] = len(
|
|
228
|
+
classification["likely_resolved_threads"],
|
|
229
|
+
)
|
|
230
|
+
bot_subs = bot_authors(workspace.config)
|
|
231
|
+
resolved_ids = set(
|
|
232
|
+
resolutions_for_feature(
|
|
233
|
+
workspace.config.root, feature_name,
|
|
234
|
+
).keys()
|
|
235
|
+
)
|
|
236
|
+
bot_threads = [
|
|
237
|
+
t for t in actionable
|
|
238
|
+
if _is_bot_comment(t, bot_subs)
|
|
239
|
+
and str(t.get("id", "")) not in resolved_ids
|
|
240
|
+
]
|
|
241
|
+
human_threads = [
|
|
242
|
+
t for t in actionable
|
|
243
|
+
if not _is_bot_comment(t, bot_subs)
|
|
244
|
+
]
|
|
245
|
+
facts["actionable_human_count"] = len(human_threads)
|
|
246
|
+
facts["actionable_bot_count"] = len(bot_threads)
|
|
247
|
+
facts["actionable_bot_threads"] = bot_threads
|
|
248
|
+
facts["actionable_count"] = (
|
|
249
|
+
facts["actionable_human_count"] + facts["actionable_bot_count"]
|
|
250
|
+
)
|
|
251
|
+
except Exception:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
out[repo_name] = facts
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _is_bot_comment(comment: dict, bot_substrings: list[str]) -> bool:
|
|
259
|
+
"""Determine if a normalized review comment came from a bot.
|
|
260
|
+
|
|
261
|
+
With ``review_bots`` configured (M2 augment), require both
|
|
262
|
+
``author_type == "Bot"`` AND a substring match against the configured
|
|
263
|
+
list. Without it, fall back to the GitHub-provided ``author_type``
|
|
264
|
+
alone — so unconfigured workspaces still benefit from basic bot
|
|
265
|
+
detection.
|
|
266
|
+
"""
|
|
267
|
+
author_type = (comment.get("author_type") or "").lower()
|
|
268
|
+
is_typed_bot = author_type == "bot"
|
|
269
|
+
if not bot_substrings:
|
|
270
|
+
return is_typed_bot
|
|
271
|
+
author = (comment.get("author") or "").lower()
|
|
272
|
+
return is_typed_bot and any(sub in author for sub in bot_substrings)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _summarize(per_repo: dict[str, dict]) -> dict[str, Any]:
|
|
276
|
+
dirty_repos = [r for r, f in per_repo.items() if f.get("is_dirty")]
|
|
277
|
+
ahead_repos = {
|
|
278
|
+
r: f.get("ahead", 0) for r, f in per_repo.items() if f.get("ahead", 0) > 0
|
|
279
|
+
}
|
|
280
|
+
actionable_total = sum(f.get("actionable_count", 0) for f in per_repo.values())
|
|
281
|
+
actionable_human_total = sum(
|
|
282
|
+
f.get("actionable_human_count", 0) for f in per_repo.values()
|
|
283
|
+
)
|
|
284
|
+
actionable_bot_total = sum(
|
|
285
|
+
f.get("actionable_bot_count", 0) for f in per_repo.values()
|
|
286
|
+
)
|
|
287
|
+
likely_resolved_total = sum(
|
|
288
|
+
f.get("likely_resolved_count", 0) for f in per_repo.values()
|
|
289
|
+
)
|
|
290
|
+
decisions = {
|
|
291
|
+
r: f.get("review_decision", "") for r, f in per_repo.items() if f.get("pr")
|
|
292
|
+
}
|
|
293
|
+
pr_count = sum(1 for f in per_repo.values() if f.get("pr"))
|
|
294
|
+
ci_per_repo = {
|
|
295
|
+
r: f["ci_status"] for r, f in per_repo.items()
|
|
296
|
+
if f.get("pr") and f.get("ci_status")
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
"dirty_repos": dirty_repos,
|
|
300
|
+
"ahead_repos": ahead_repos,
|
|
301
|
+
"actionable_count": actionable_total,
|
|
302
|
+
"actionable_human_count": actionable_human_total,
|
|
303
|
+
"actionable_bot_count": actionable_bot_total,
|
|
304
|
+
"likely_resolved_count": likely_resolved_total,
|
|
305
|
+
"review_decisions": decisions,
|
|
306
|
+
"pr_count": pr_count,
|
|
307
|
+
"repos": {r: {k: v for k, v in f.items() if k not in ("pr", "actionable_bot_threads")}
|
|
308
|
+
for r, f in per_repo.items()},
|
|
309
|
+
"prs": {r: f["pr"] for r, f in per_repo.items() if f.get("pr")},
|
|
310
|
+
# M10: per-repo CI rollup + a feature-level aggregate. The
|
|
311
|
+
# aggregate picks the worst across repos so a feature whose api
|
|
312
|
+
# is passing but ui is failing reports as "failing."
|
|
313
|
+
"ci_per_repo": ci_per_repo,
|
|
314
|
+
"ci_aggregate": _aggregate_ci(ci_per_repo),
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _aggregate_ci(ci_per_repo: dict[str, dict]) -> str:
|
|
319
|
+
"""Worst-state-wins reduction across repos (M10)."""
|
|
320
|
+
if not ci_per_repo:
|
|
321
|
+
return "no_checks"
|
|
322
|
+
statuses = {(c.get("status") or "no_checks") for c in ci_per_repo.values()}
|
|
323
|
+
for severe in ("failing", "pending", "passing"):
|
|
324
|
+
if severe in statuses:
|
|
325
|
+
return severe
|
|
326
|
+
return "no_checks"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _preflight_summary(entry, fresh: bool) -> dict[str, Any]:
|
|
330
|
+
if not entry:
|
|
331
|
+
return {"ran": False, "fresh": False}
|
|
332
|
+
return {
|
|
333
|
+
"ran": True,
|
|
334
|
+
"fresh": fresh,
|
|
335
|
+
"passed": entry.get("passed", False),
|
|
336
|
+
"ran_at": entry.get("ran_at", ""),
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _decide_state(
|
|
341
|
+
feature_name: str,
|
|
342
|
+
per_repo: dict[str, dict],
|
|
343
|
+
summary: dict[str, Any],
|
|
344
|
+
preflight_fresh: bool,
|
|
345
|
+
preflight_entry,
|
|
346
|
+
) -> tuple[str, list[dict], list[dict]]:
|
|
347
|
+
decisions = summary["review_decisions"]
|
|
348
|
+
actionable = summary["actionable_count"]
|
|
349
|
+
actionable_human = summary.get("actionable_human_count", actionable)
|
|
350
|
+
actionable_bot = summary.get("actionable_bot_count", 0)
|
|
351
|
+
dirty = bool(summary["dirty_repos"])
|
|
352
|
+
ahead = bool(summary["ahead_repos"])
|
|
353
|
+
pr_count = summary["pr_count"]
|
|
354
|
+
warnings: list[dict] = []
|
|
355
|
+
next_actions: list[dict] = []
|
|
356
|
+
|
|
357
|
+
if dirty:
|
|
358
|
+
if preflight_fresh and preflight_entry and preflight_entry.get("passed"):
|
|
359
|
+
state = "ready_to_commit"
|
|
360
|
+
next_actions = [
|
|
361
|
+
{"action": "commit", "args": {"feature": feature_name},
|
|
362
|
+
"primary": True, "label": "Commit",
|
|
363
|
+
"preview": f"{len(summary['dirty_repos'])} repo(s) staged"},
|
|
364
|
+
{"action": "preflight", "args": {"feature": feature_name},
|
|
365
|
+
"primary": False, "label": "Re-run preflight"},
|
|
366
|
+
]
|
|
367
|
+
else:
|
|
368
|
+
state = "in_progress"
|
|
369
|
+
if preflight_entry and not preflight_fresh:
|
|
370
|
+
warnings.append({
|
|
371
|
+
"code": "preflight_stale",
|
|
372
|
+
"what": "preflight result is stale (HEAD has moved since last run)",
|
|
373
|
+
})
|
|
374
|
+
next_actions = [
|
|
375
|
+
{"action": "preflight", "args": {"feature": feature_name},
|
|
376
|
+
"primary": True, "label": "Run preflight"},
|
|
377
|
+
{"action": "stash", "args": {"feature": feature_name},
|
|
378
|
+
"primary": False, "label": "Stash changes"},
|
|
379
|
+
]
|
|
380
|
+
return state, next_actions, warnings
|
|
381
|
+
|
|
382
|
+
# Clean working tree from here on.
|
|
383
|
+
if ahead:
|
|
384
|
+
# If branch isn't pushed yet (no upstream OR ahead > 0),
|
|
385
|
+
# the next action is push.
|
|
386
|
+
next_actions = [
|
|
387
|
+
{"action": "push", "args": {"feature": feature_name},
|
|
388
|
+
"primary": True, "label": "Push",
|
|
389
|
+
"preview": ", ".join(f"{r}: +{n}" for r, n in summary['ahead_repos'].items())},
|
|
390
|
+
]
|
|
391
|
+
# If PRs already exist + we have actionable comments, also surface
|
|
392
|
+
# 'address review comments' as secondary.
|
|
393
|
+
if actionable > 0:
|
|
394
|
+
next_actions.append({
|
|
395
|
+
"action": "address_review_comments",
|
|
396
|
+
"args": {"feature": feature_name},
|
|
397
|
+
"primary": False,
|
|
398
|
+
"label": "Address review comments",
|
|
399
|
+
})
|
|
400
|
+
return "ready_to_push", next_actions, warnings
|
|
401
|
+
|
|
402
|
+
# Aligned, clean, caught up to remote (or nothing to push).
|
|
403
|
+
# Human signals (CHANGES_REQUESTED reviews, or actionable human threads)
|
|
404
|
+
# block on `needs_work`; bot threads alone route to `awaiting_bot_resolution`.
|
|
405
|
+
if actionable_human > 0 or _any_changes_requested(decisions):
|
|
406
|
+
next_actions = [
|
|
407
|
+
{"action": "address_review_comments",
|
|
408
|
+
"args": {"feature": feature_name},
|
|
409
|
+
"primary": True, "label": "Address review comments",
|
|
410
|
+
"preview": f"{actionable_human} human thread(s), {actionable_bot} bot thread(s)"
|
|
411
|
+
if actionable_bot else f"{actionable_human} human thread(s)"},
|
|
412
|
+
{"action": "comments", "args": {"feature": feature_name},
|
|
413
|
+
"primary": False, "label": "View comments"},
|
|
414
|
+
]
|
|
415
|
+
return "needs_work", next_actions, warnings
|
|
416
|
+
|
|
417
|
+
if pr_count == 0:
|
|
418
|
+
# Aligned, clean, but no PRs — likely needs PR creation
|
|
419
|
+
next_actions = [
|
|
420
|
+
{"action": "pr_create", "args": {"feature": feature_name},
|
|
421
|
+
"primary": True, "label": "Open PR(s)"},
|
|
422
|
+
]
|
|
423
|
+
return "no_prs", next_actions, warnings
|
|
424
|
+
|
|
425
|
+
ci_aggregate = summary.get("ci_aggregate", "no_checks")
|
|
426
|
+
ci_per_repo = summary.get("ci_per_repo") or {}
|
|
427
|
+
non_empty = {d for d in decisions.values() if d}
|
|
428
|
+
if non_empty and non_empty <= {"APPROVED"}:
|
|
429
|
+
# M10 CI matrix: approved + CI is the merge gate.
|
|
430
|
+
if ci_aggregate == "failing":
|
|
431
|
+
failing_names = sorted(
|
|
432
|
+
name for repo, ci in ci_per_repo.items()
|
|
433
|
+
for name in (ci.get("required_failing") or [])
|
|
434
|
+
)
|
|
435
|
+
next_actions = [
|
|
436
|
+
{"action": "investigate_ci",
|
|
437
|
+
"args": {"feature": feature_name},
|
|
438
|
+
"primary": True, "label": "Investigate failing CI",
|
|
439
|
+
"preview": ", ".join(failing_names) or "see Checks tab"},
|
|
440
|
+
{"action": "comments", "args": {"feature": feature_name},
|
|
441
|
+
"primary": False, "label": "View comments"},
|
|
442
|
+
]
|
|
443
|
+
# Failing CI overrides the "approved" badge — same intent as
|
|
444
|
+
# a CHANGES_REQUESTED review.
|
|
445
|
+
return "needs_work", next_actions, warnings
|
|
446
|
+
if ci_aggregate == "pending":
|
|
447
|
+
pending_names = sorted(
|
|
448
|
+
name for repo, ci in ci_per_repo.items()
|
|
449
|
+
for name in (ci.get("required_pending") or [])
|
|
450
|
+
)
|
|
451
|
+
next_actions = [
|
|
452
|
+
{"action": "wait_for_ci",
|
|
453
|
+
"args": {"feature": feature_name},
|
|
454
|
+
"primary": True, "label": "Waiting on CI",
|
|
455
|
+
"preview": ", ".join(pending_names) or "checks running"},
|
|
456
|
+
{"action": "refresh", "args": {"feature": feature_name},
|
|
457
|
+
"primary": False, "label": "Refresh"},
|
|
458
|
+
]
|
|
459
|
+
return "awaiting_ci", next_actions, warnings
|
|
460
|
+
|
|
461
|
+
next_actions = [
|
|
462
|
+
{"action": "merge", "args": {"feature": feature_name},
|
|
463
|
+
"primary": True, "label": "Merge",
|
|
464
|
+
"preview": "all PRs approved (manual or via UI)"},
|
|
465
|
+
]
|
|
466
|
+
# Bots may still have unresolved nits — surface as a non-gating
|
|
467
|
+
# secondary CTA. State stays `approved` (human approval is the merge
|
|
468
|
+
# gate; bot nits are a side-channel).
|
|
469
|
+
if actionable_bot > 0:
|
|
470
|
+
next_actions.append({
|
|
471
|
+
"action": "address_bot_comments",
|
|
472
|
+
"args": {"feature": feature_name},
|
|
473
|
+
"primary": False, "label": "Address bot comments",
|
|
474
|
+
"preview": f"{actionable_bot} unresolved bot thread(s)",
|
|
475
|
+
})
|
|
476
|
+
return "approved", next_actions, warnings
|
|
477
|
+
|
|
478
|
+
# No human action pending, PR open, not yet approved. Bot nits get their
|
|
479
|
+
# own state so the agent + dashboard can distinguish "still review-pending"
|
|
480
|
+
# from "human is silent but bots flagged things."
|
|
481
|
+
if actionable_bot > 0:
|
|
482
|
+
next_actions = [
|
|
483
|
+
{"action": "address_bot_comments",
|
|
484
|
+
"args": {"feature": feature_name},
|
|
485
|
+
"primary": True, "label": "Address bot comments",
|
|
486
|
+
"preview": f"{actionable_bot} bot thread(s)"},
|
|
487
|
+
{"action": "comments", "args": {"feature": feature_name},
|
|
488
|
+
"primary": False, "label": "View comments"},
|
|
489
|
+
]
|
|
490
|
+
return "awaiting_bot_resolution", next_actions, warnings
|
|
491
|
+
|
|
492
|
+
next_actions = [
|
|
493
|
+
{"action": "refresh", "args": {"feature": feature_name},
|
|
494
|
+
"primary": True, "label": "Refresh",
|
|
495
|
+
"preview": "waiting on review"},
|
|
496
|
+
]
|
|
497
|
+
return "awaiting_review", next_actions, warnings
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _any_changes_requested(decisions: dict[str, str]) -> bool:
|
|
501
|
+
return "CHANGES_REQUESTED" in decisions.values()
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _live_drift(
|
|
505
|
+
workspace: Workspace, repo_branches: dict[str, str],
|
|
506
|
+
repo_paths: dict[str, Path],
|
|
507
|
+
) -> dict[str, Any]:
|
|
508
|
+
"""Check actual git state vs expected per repo against the resolved path.
|
|
509
|
+
|
|
510
|
+
For worktree-backed features the resolved path is the worktree, so the
|
|
511
|
+
branch check is against the worktree's HEAD. For main-tree features it's
|
|
512
|
+
the main repo. Either way, the branch check is targeted correctly.
|
|
513
|
+
|
|
514
|
+
Returns ``{drifted_repos, missing_repos, expected, actual}``.
|
|
515
|
+
"""
|
|
516
|
+
drifted: list[str] = []
|
|
517
|
+
missing: list[str] = []
|
|
518
|
+
expected: dict[str, str] = {}
|
|
519
|
+
actual: dict[str, str | None] = {}
|
|
520
|
+
for repo_name, expected_branch in repo_branches.items():
|
|
521
|
+
expected[repo_name] = expected_branch
|
|
522
|
+
try:
|
|
523
|
+
state = workspace.get_repo(repo_name)
|
|
524
|
+
except KeyError:
|
|
525
|
+
missing.append(repo_name)
|
|
526
|
+
actual[repo_name] = None
|
|
527
|
+
continue
|
|
528
|
+
check_path = repo_paths.get(repo_name, state.abs_path)
|
|
529
|
+
if not git.branch_exists(check_path, expected_branch):
|
|
530
|
+
missing.append(repo_name)
|
|
531
|
+
actual[repo_name] = None
|
|
532
|
+
continue
|
|
533
|
+
try:
|
|
534
|
+
current = git.current_branch(check_path)
|
|
535
|
+
except git.GitError:
|
|
536
|
+
current = None
|
|
537
|
+
actual[repo_name] = current
|
|
538
|
+
if current != expected_branch:
|
|
539
|
+
drifted.append(repo_name)
|
|
540
|
+
return {
|
|
541
|
+
"drifted_repos": drifted,
|
|
542
|
+
"missing_repos": missing,
|
|
543
|
+
"expected": expected,
|
|
544
|
+
"actual": actual,
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _drifted_result(
|
|
549
|
+
feature_name: str, drift_info: dict, *, has_worktrees: bool = False,
|
|
550
|
+
) -> dict[str, Any]:
|
|
551
|
+
drifted = drift_info["drifted_repos"]
|
|
552
|
+
missing = drift_info["missing_repos"]
|
|
553
|
+
|
|
554
|
+
# F-12: post-Wave 2.9, the canonical-slot model handles both worktree
|
|
555
|
+
# and main-tree recovery via ``switch``. The deprecated ``realign``
|
|
556
|
+
# action is no longer surfaced as the primary CTA for either case;
|
|
557
|
+
# ``switch`` re-establishes the feature context regardless of where
|
|
558
|
+
# the feature lives. ``done`` stays as a secondary CTA on the
|
|
559
|
+
# worktree path so users can intentionally drop a broken worktree.
|
|
560
|
+
if has_worktrees:
|
|
561
|
+
next_actions = [
|
|
562
|
+
{"action": "switch", "args": {"feature": feature_name},
|
|
563
|
+
"primary": True, "label": "Switch",
|
|
564
|
+
"preview": (
|
|
565
|
+
"worktree is on the wrong branch — switch to re-establish"
|
|
566
|
+
" the feature context"
|
|
567
|
+
)},
|
|
568
|
+
{"action": "done", "args": {"feature": feature_name},
|
|
569
|
+
"primary": False, "label": "Clean up worktree",
|
|
570
|
+
"preview": "remove the worktree if you no longer need it"},
|
|
571
|
+
]
|
|
572
|
+
else:
|
|
573
|
+
next_actions = [
|
|
574
|
+
{"action": "switch", "args": {"feature": feature_name},
|
|
575
|
+
"primary": True, "label": "Switch",
|
|
576
|
+
"preview": (
|
|
577
|
+
f"checkout expected branch in "
|
|
578
|
+
f"{', '.join(drifted + missing)}"
|
|
579
|
+
)},
|
|
580
|
+
]
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
"feature": feature_name,
|
|
584
|
+
"state": "drifted",
|
|
585
|
+
"summary": {
|
|
586
|
+
"alignment": {
|
|
587
|
+
"aligned": False,
|
|
588
|
+
"expected": drift_info["expected"],
|
|
589
|
+
"actual": drift_info["actual"],
|
|
590
|
+
"drifted_repos": drifted,
|
|
591
|
+
"missing_repos": missing,
|
|
592
|
+
"has_worktrees": has_worktrees,
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
"next_actions": next_actions,
|
|
596
|
+
"warnings": [],
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _shell_result(feature_name: str, state: str, *, note: str = "") -> dict[str, Any]:
|
|
601
|
+
return {
|
|
602
|
+
"feature": feature_name,
|
|
603
|
+
"state": state,
|
|
604
|
+
"summary": {"note": note} if note else {},
|
|
605
|
+
"next_actions": [],
|
|
606
|
+
"warnings": [],
|
|
607
|
+
}
|