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
canopy/actions/resume.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"""feature_resume — switch-aware compound action.
|
|
2
|
+
|
|
3
|
+
Single command takes an alias and gets the user back in business:
|
|
4
|
+
1. Resolve alias to feature name.
|
|
5
|
+
2. If feature isn't canonical, switch to it (which will bump last_visit once T13 lands).
|
|
6
|
+
3. Compute "since prior anchor" sections (commits, threads, drafts...).
|
|
7
|
+
4. Compute current_state (feature_state, CI, bot rollup, branch position).
|
|
8
|
+
5. Build intent_hints from the deltas + current state.
|
|
9
|
+
6. If no switch happened, bump last_visit at the end.
|
|
10
|
+
7. Return the complete brief.
|
|
11
|
+
|
|
12
|
+
Refreshes from GitHub/Linear on every call. No caching at this layer.
|
|
13
|
+
|
|
14
|
+
Single-bump invariant: last_visit moves exactly once per feature_resume call.
|
|
15
|
+
- switch ran → switch bumps (T13). Resume does NOT bump again.
|
|
16
|
+
- no switch → resume bumps at the end, after delta is computed.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from ..workspace.workspace import Workspace
|
|
24
|
+
from . import last_visit as lv
|
|
25
|
+
from . import slots as slots_mod
|
|
26
|
+
from .aliases import resolve_feature
|
|
27
|
+
from .switch import switch
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_UNSET = object() # sentinel to distinguish "not passed" from "explicitly None"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resume_summary(
|
|
34
|
+
workspace: Workspace, feature: str, prior_iso: str | None = _UNSET, # type: ignore[assignment]
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
"""Counts-only view embedded in switch return.
|
|
37
|
+
|
|
38
|
+
No GH fetch on failure — falls back to local state and sets degraded: True.
|
|
39
|
+
|
|
40
|
+
``prior_iso`` should be the last_visit anchor captured BEFORE any
|
|
41
|
+
mark_visited call so the diff window is meaningful.
|
|
42
|
+
|
|
43
|
+
- Pass the prior anchor explicitly (including ``None`` for first visit) when
|
|
44
|
+
you've already captured it (e.g. switch.py).
|
|
45
|
+
- Omit it (leave as default) to let resume_summary read the current
|
|
46
|
+
last_visit itself. Only do this when mark_visited has NOT yet run.
|
|
47
|
+
"""
|
|
48
|
+
from . import historian
|
|
49
|
+
|
|
50
|
+
# Resolve anchor: explicit wins; fallback reads live state.
|
|
51
|
+
if prior_iso is _UNSET:
|
|
52
|
+
visit = lv.get_last_visit(workspace, feature)
|
|
53
|
+
anchor: str | None = visit["last_visit"] if visit else None
|
|
54
|
+
else:
|
|
55
|
+
anchor = prior_iso # type: ignore[assignment]
|
|
56
|
+
|
|
57
|
+
memory_present = bool(
|
|
58
|
+
historian.format_for_agent(workspace.config.root, feature)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if anchor is None:
|
|
62
|
+
return {
|
|
63
|
+
"last_visit": None,
|
|
64
|
+
"first_visit": True,
|
|
65
|
+
"new_commit_count": 0,
|
|
66
|
+
"new_thread_count": 0,
|
|
67
|
+
"github_resolved_count": 0,
|
|
68
|
+
"ci_changed": False,
|
|
69
|
+
"draft_replies_pending": 0,
|
|
70
|
+
"memory_present": memory_present,
|
|
71
|
+
"degraded": False,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
new_commit_count = sum(
|
|
75
|
+
len(c) for c in _commits_since(workspace, feature, anchor).values()
|
|
76
|
+
)
|
|
77
|
+
degraded = False
|
|
78
|
+
new_thread_count = 0
|
|
79
|
+
github_resolved_count = 0
|
|
80
|
+
try:
|
|
81
|
+
td = _threads_delta(workspace, feature, anchor)
|
|
82
|
+
new_thread_count = len(td["new"])
|
|
83
|
+
github_resolved_count = len(td["resolved_gh"])
|
|
84
|
+
except Exception:
|
|
85
|
+
degraded = True
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
"last_visit": anchor,
|
|
89
|
+
"first_visit": False,
|
|
90
|
+
"new_commit_count": new_commit_count,
|
|
91
|
+
"new_thread_count": new_thread_count,
|
|
92
|
+
"github_resolved_count": github_resolved_count,
|
|
93
|
+
"ci_changed": False, # v1: no persisted snapshot to diff
|
|
94
|
+
"draft_replies_pending": 0, # v1: skip for switch tail
|
|
95
|
+
"memory_present": memory_present,
|
|
96
|
+
"degraded": degraded,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def feature_resume(workspace: Workspace, alias: str) -> dict[str, Any]:
|
|
101
|
+
"""Resolve alias, switch-if-needed, build and return the resume brief."""
|
|
102
|
+
feature = resolve_feature(workspace, alias)
|
|
103
|
+
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
104
|
+
|
|
105
|
+
# 1. Capture prior anchor BEFORE any switch can move it.
|
|
106
|
+
prior_visit = lv.get_last_visit(workspace, feature)
|
|
107
|
+
prior_iso: str | None = prior_visit["last_visit"] if prior_visit else None
|
|
108
|
+
|
|
109
|
+
# 2. Switch-if-needed. Read slot state to decide.
|
|
110
|
+
switch_summary: dict | None = None
|
|
111
|
+
state = slots_mod.read_state(workspace)
|
|
112
|
+
is_canonical = (
|
|
113
|
+
state is not None
|
|
114
|
+
and state.canonical is not None
|
|
115
|
+
and state.canonical.feature == feature
|
|
116
|
+
)
|
|
117
|
+
if not is_canonical:
|
|
118
|
+
switch_summary = switch(workspace, feature)
|
|
119
|
+
# T13 will make switch bump last_visit internally. Until then, the
|
|
120
|
+
# single-bump invariant is: resume does NOT bump when switch ran.
|
|
121
|
+
|
|
122
|
+
# 3. Empty containers — T7–T12 expand _populate_since / _populate_current.
|
|
123
|
+
since: dict[str, Any] = {
|
|
124
|
+
"commits": {},
|
|
125
|
+
"threads_new": [],
|
|
126
|
+
"threads_resolved_on_github": [],
|
|
127
|
+
"threads_resolved_by_canopy": [],
|
|
128
|
+
"ci_status_delta": {},
|
|
129
|
+
"draft_replies_pending": 0,
|
|
130
|
+
"historian_excerpt": "",
|
|
131
|
+
}
|
|
132
|
+
current: dict[str, Any] = {
|
|
133
|
+
"feature_state": None,
|
|
134
|
+
"open_thread_count": 0,
|
|
135
|
+
"ci_summary_per_repo": {},
|
|
136
|
+
"bot_unresolved_total": 0,
|
|
137
|
+
"draft_replies_summary": {"addressed_total": 0, "unaddressed_total": 0},
|
|
138
|
+
"branch_position_per_repo": {},
|
|
139
|
+
"linear_issue": None,
|
|
140
|
+
"linear_url": None,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
# 4. Populate sections (prior_iso may be None on first visit).
|
|
144
|
+
if prior_iso is not None:
|
|
145
|
+
since = _populate_since(workspace, feature, prior_iso, since)
|
|
146
|
+
current = _populate_current(workspace, feature, current)
|
|
147
|
+
|
|
148
|
+
# 5. Build intent hints from populated shapes.
|
|
149
|
+
intent_hints = _intent_hints(since, current, prior_iso is None)
|
|
150
|
+
|
|
151
|
+
# 6. Single-bump: only bump when switch didn't already run.
|
|
152
|
+
if switch_summary is None:
|
|
153
|
+
lv.mark_visited(workspace, feature)
|
|
154
|
+
|
|
155
|
+
# 7. Window duration (None on first visit).
|
|
156
|
+
window_hours = _hours_between(prior_iso, now_iso) if prior_iso is not None else None
|
|
157
|
+
|
|
158
|
+
# Strip transport-only internal key before returning.
|
|
159
|
+
current.pop("__feature_name__", None)
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
"version": 1,
|
|
163
|
+
"feature": feature,
|
|
164
|
+
"now": now_iso,
|
|
165
|
+
"last_visit": prior_iso, # the PRIOR anchor, not freshly bumped
|
|
166
|
+
"first_visit": prior_iso is None,
|
|
167
|
+
"window_hours": window_hours,
|
|
168
|
+
"switch_performed": switch_summary is not None,
|
|
169
|
+
"switch_summary": switch_summary,
|
|
170
|
+
"intent_hints": intent_hints,
|
|
171
|
+
"since_last_visit": since,
|
|
172
|
+
"current_state": current,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ── Section populators (stubs) ────────────────────────────────────────────────
|
|
177
|
+
# T7–T12 fill these in. T6 leaves the shapes as-is.
|
|
178
|
+
|
|
179
|
+
def _populate_since(
|
|
180
|
+
workspace: Workspace,
|
|
181
|
+
feature: str,
|
|
182
|
+
last_visit_iso: str,
|
|
183
|
+
since: dict[str, Any],
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
"""T7-T12 fill this. T7 populates commits. T8 populates thread deltas."""
|
|
186
|
+
since["commits"] = _commits_since(workspace, feature, last_visit_iso)
|
|
187
|
+
threads = _threads_delta(workspace, feature, last_visit_iso)
|
|
188
|
+
since["threads_new"] = threads["new"]
|
|
189
|
+
since["threads_resolved_on_github"] = threads["resolved_gh"]
|
|
190
|
+
since["threads_resolved_by_canopy"] = _resolutions_by_canopy_since(
|
|
191
|
+
workspace, feature, last_visit_iso,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# T11: draft_replies_pending — count of addressed-but-not-yet-posted drafts.
|
|
195
|
+
# Only populate when there's a prior anchor (not first visit).
|
|
196
|
+
from . import draft_replies as dr
|
|
197
|
+
try:
|
|
198
|
+
drafts = dr.draft_replies(workspace, feature)
|
|
199
|
+
since["draft_replies_pending"] = sum(
|
|
200
|
+
len(r.get("addressed") or [])
|
|
201
|
+
for r in (drafts.get("repos") or {}).values()
|
|
202
|
+
)
|
|
203
|
+
except Exception:
|
|
204
|
+
pass # leaves the default 0
|
|
205
|
+
|
|
206
|
+
# T12: historian_excerpt — sessions/events/decisions since last_visit.
|
|
207
|
+
from . import historian
|
|
208
|
+
try:
|
|
209
|
+
since["historian_excerpt"] = historian.format_for_agent_since(
|
|
210
|
+
workspace.config.root, feature, last_visit_iso,
|
|
211
|
+
)
|
|
212
|
+
except Exception:
|
|
213
|
+
pass # leaves the default ""
|
|
214
|
+
|
|
215
|
+
return since
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _commits_since(workspace: Workspace, feature: str, since_iso: str) -> dict[str, list]:
|
|
219
|
+
"""Populate per-repo commits authored after since_iso on the feature branch.
|
|
220
|
+
|
|
221
|
+
Returns {repo_name: [commit dicts]} where each commit has
|
|
222
|
+
{sha, short_sha, at, author, subject}. Per-repo errors (missing branch,
|
|
223
|
+
git failures) silently default to empty list; exceptions don't crash the brief.
|
|
224
|
+
"""
|
|
225
|
+
from ..git import repo as git
|
|
226
|
+
from .aliases import repos_for_feature
|
|
227
|
+
|
|
228
|
+
out: dict[str, list] = {}
|
|
229
|
+
repos_map = repos_for_feature(workspace, feature)
|
|
230
|
+
|
|
231
|
+
for repo_name, branch in repos_map.items():
|
|
232
|
+
try:
|
|
233
|
+
state = workspace.get_repo(repo_name)
|
|
234
|
+
out[repo_name] = git.log_since(state.abs_path, branch, since_iso)
|
|
235
|
+
except Exception:
|
|
236
|
+
# Missing repo in workspace, or git error — default to empty list.
|
|
237
|
+
out[repo_name] = []
|
|
238
|
+
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _pr_coords_per_repo(
|
|
243
|
+
workspace: Workspace, feature: str,
|
|
244
|
+
) -> dict[str, dict | None]:
|
|
245
|
+
"""Return {repo_name: {"owner": str, "repo_slug": str, "pr_number": int} | None}.
|
|
246
|
+
|
|
247
|
+
Uses the same pattern as FeatureCoordinator.review_status: iterates repos
|
|
248
|
+
in the feature lane, resolves remote URL → owner/slug, finds the open PR.
|
|
249
|
+
On any per-repo error (no remote, unparseable URL, no PR) returns None for
|
|
250
|
+
that repo. Propagates only hard exceptions (feature not found, etc.).
|
|
251
|
+
"""
|
|
252
|
+
from ..git import repo as git
|
|
253
|
+
from ..integrations.github import _extract_owner_repo, find_pull_request
|
|
254
|
+
from .aliases import repos_for_feature
|
|
255
|
+
|
|
256
|
+
repos_map = repos_for_feature(workspace, feature)
|
|
257
|
+
out: dict[str, dict | None] = {}
|
|
258
|
+
|
|
259
|
+
for repo_name, branch in repos_map.items():
|
|
260
|
+
try:
|
|
261
|
+
state = workspace.get_repo(repo_name)
|
|
262
|
+
remote = git.remote_url(state.abs_path)
|
|
263
|
+
if not remote:
|
|
264
|
+
out[repo_name] = None
|
|
265
|
+
continue
|
|
266
|
+
parsed = _extract_owner_repo(remote)
|
|
267
|
+
if not parsed:
|
|
268
|
+
out[repo_name] = None
|
|
269
|
+
continue
|
|
270
|
+
owner, repo_slug = parsed
|
|
271
|
+
pr = find_pull_request(workspace.config.root, owner, repo_slug, branch)
|
|
272
|
+
if pr is None:
|
|
273
|
+
out[repo_name] = None
|
|
274
|
+
else:
|
|
275
|
+
out[repo_name] = {
|
|
276
|
+
"owner": owner,
|
|
277
|
+
"repo_slug": repo_slug,
|
|
278
|
+
"pr_number": pr["number"],
|
|
279
|
+
}
|
|
280
|
+
except Exception:
|
|
281
|
+
out[repo_name] = None
|
|
282
|
+
|
|
283
|
+
return out
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _threads_delta(
|
|
287
|
+
workspace: Workspace, feature: str, since_iso: str,
|
|
288
|
+
) -> dict[str, list]:
|
|
289
|
+
"""Return {"new": [...], "resolved_gh": [...]}.
|
|
290
|
+
|
|
291
|
+
Calls list_review_threads per-repo+PR. On ANY exception (no PR yet,
|
|
292
|
+
GH unreachable, etc.), returns {"new": [], "resolved_gh": []} and
|
|
293
|
+
swallows. Never crashes the brief.
|
|
294
|
+
"""
|
|
295
|
+
from ..integrations import github as gh
|
|
296
|
+
from . import thread_resolutions as tr
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
pr_coords = _pr_coords_per_repo(workspace, feature)
|
|
300
|
+
except Exception:
|
|
301
|
+
return {"new": [], "resolved_gh": []}
|
|
302
|
+
|
|
303
|
+
canopy_log = tr.load(workspace.config.root)
|
|
304
|
+
new_threads: list[dict] = []
|
|
305
|
+
resolved_gh: list[dict] = []
|
|
306
|
+
|
|
307
|
+
for repo_name, coords in pr_coords.items():
|
|
308
|
+
if not coords:
|
|
309
|
+
continue
|
|
310
|
+
owner = coords["owner"]
|
|
311
|
+
repo_slug = coords["repo_slug"]
|
|
312
|
+
pr_number = coords["pr_number"]
|
|
313
|
+
try:
|
|
314
|
+
threads = gh.list_review_threads(
|
|
315
|
+
workspace.config.root, owner, repo_slug, pr_number,
|
|
316
|
+
)
|
|
317
|
+
except Exception:
|
|
318
|
+
continue
|
|
319
|
+
for t in threads:
|
|
320
|
+
first = (t.get("comments") or [None])[0]
|
|
321
|
+
created_at = (first or {}).get("created_at", "")
|
|
322
|
+
if (not t["is_resolved"]) and created_at > since_iso:
|
|
323
|
+
new_threads.append({
|
|
324
|
+
"thread_id": t["thread_id"],
|
|
325
|
+
"comment_id": (first or {}).get("comment_id"),
|
|
326
|
+
"author": (first or {}).get("author", ""),
|
|
327
|
+
"path": (first or {}).get("path", ""),
|
|
328
|
+
"line": (first or {}).get("line", 0),
|
|
329
|
+
"body_excerpt": ((first or {}).get("body") or "")[:200],
|
|
330
|
+
"created_at": created_at,
|
|
331
|
+
"url": (first or {}).get("url", ""),
|
|
332
|
+
"repo": repo_name,
|
|
333
|
+
"pr_number": pr_number,
|
|
334
|
+
})
|
|
335
|
+
elif t["is_resolved"] and (t.get("resolved_at") or "") > since_iso:
|
|
336
|
+
resolved_gh.append({
|
|
337
|
+
"thread_id": t["thread_id"],
|
|
338
|
+
"resolved_at": t["resolved_at"],
|
|
339
|
+
"by_canopy": t["thread_id"] in canopy_log,
|
|
340
|
+
"repo": repo_name,
|
|
341
|
+
"pr_number": pr_number,
|
|
342
|
+
"summary_excerpt": ((first or {}).get("body") or "")[:200],
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
return {"new": new_threads, "resolved_gh": resolved_gh}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _resolutions_by_canopy_since(
|
|
349
|
+
workspace: Workspace, feature: str, since_iso: str,
|
|
350
|
+
) -> list[dict]:
|
|
351
|
+
"""Bot_resolutions entries for this feature with addressed_at > since_iso."""
|
|
352
|
+
from . import bot_resolutions as br
|
|
353
|
+
|
|
354
|
+
out: list[dict] = []
|
|
355
|
+
try:
|
|
356
|
+
entries = br.resolutions_for_feature(workspace.config.root, feature)
|
|
357
|
+
except Exception:
|
|
358
|
+
return []
|
|
359
|
+
for cid, e in entries.items():
|
|
360
|
+
if e.get("addressed_at", "") > since_iso:
|
|
361
|
+
out.append({"comment_id": cid, **e})
|
|
362
|
+
return out
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _populate_current(
|
|
366
|
+
workspace: Workspace,
|
|
367
|
+
feature: str,
|
|
368
|
+
current: dict[str, Any],
|
|
369
|
+
) -> dict[str, Any]:
|
|
370
|
+
"""T9: feature_state, ci_summary_per_repo, branch_position_per_repo.
|
|
371
|
+
|
|
372
|
+
T10-T11 fill the remaining sections. Errors in any sub-section are
|
|
373
|
+
swallowed so the brief always returns with reasonable defaults.
|
|
374
|
+
"""
|
|
375
|
+
current["__feature_name__"] = feature
|
|
376
|
+
|
|
377
|
+
from . import feature_state as fs
|
|
378
|
+
from . import bot_status as bs
|
|
379
|
+
from ..git import repo as git
|
|
380
|
+
from .aliases import repos_for_feature
|
|
381
|
+
|
|
382
|
+
# feature_state + ci_summary_per_repo ─────────────────────────────────
|
|
383
|
+
try:
|
|
384
|
+
st = fs.feature_state(workspace, feature)
|
|
385
|
+
except Exception:
|
|
386
|
+
st = {}
|
|
387
|
+
|
|
388
|
+
current["feature_state"] = st.get("state", "unknown")
|
|
389
|
+
|
|
390
|
+
# CI lives in summary["ci_per_repo"] → {repo: {"status": ...}}
|
|
391
|
+
ci_per_repo = (st.get("summary") or {}).get("ci_per_repo") or {}
|
|
392
|
+
current["ci_summary_per_repo"] = {
|
|
393
|
+
r: (info.get("status") or "no_checks")
|
|
394
|
+
for r, info in ci_per_repo.items()
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
# bot_unresolved_total ────────────────────────────────────────────────
|
|
398
|
+
try:
|
|
399
|
+
roll = bs.bot_comments_status(workspace, feature)
|
|
400
|
+
except Exception:
|
|
401
|
+
roll = {"repos": {}}
|
|
402
|
+
current["bot_unresolved_total"] = sum(
|
|
403
|
+
r.get("unresolved", 0) for r in (roll.get("repos") or {}).values()
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# draft_replies_summary ───────────────────────────────────────────────
|
|
407
|
+
# T11: Populate from draft_replies; swallow errors, use defaults.
|
|
408
|
+
from . import draft_replies as dr
|
|
409
|
+
try:
|
|
410
|
+
drafts = dr.draft_replies(workspace, feature)
|
|
411
|
+
current["draft_replies_summary"] = {
|
|
412
|
+
"addressed_total": drafts.get("addressed_total", 0),
|
|
413
|
+
"unaddressed_total": drafts.get("unaddressed_total", 0),
|
|
414
|
+
}
|
|
415
|
+
except Exception:
|
|
416
|
+
pass # leaves the T6 default {addressed_total: 0, unaddressed_total: 0}
|
|
417
|
+
|
|
418
|
+
# branch_position_per_repo ────────────────────────────────────────────
|
|
419
|
+
pos: dict[str, dict] = {}
|
|
420
|
+
for repo_name, branch in repos_for_feature(workspace, feature).items():
|
|
421
|
+
try:
|
|
422
|
+
repo_state = workspace.get_repo(repo_name)
|
|
423
|
+
default = repo_state.config.default_branch
|
|
424
|
+
ahead, behind = git.divergence(repo_state.abs_path, branch, default)
|
|
425
|
+
last_sync_at = git.commit_iso_date(
|
|
426
|
+
repo_state.abs_path, f"{branch}...{default}",
|
|
427
|
+
)
|
|
428
|
+
pos[repo_name] = {
|
|
429
|
+
"branch": branch,
|
|
430
|
+
"default_branch": default,
|
|
431
|
+
"ahead": ahead,
|
|
432
|
+
"behind": behind,
|
|
433
|
+
"last_sync_at": last_sync_at or "",
|
|
434
|
+
}
|
|
435
|
+
except Exception:
|
|
436
|
+
continue
|
|
437
|
+
current["branch_position_per_repo"] = pos
|
|
438
|
+
|
|
439
|
+
# linear_issue / linear_url — lifted from FeatureLane ─────────────────
|
|
440
|
+
try:
|
|
441
|
+
from ..features.coordinator import FeatureCoordinator
|
|
442
|
+
coord = FeatureCoordinator(workspace)
|
|
443
|
+
lane = coord.status(feature)
|
|
444
|
+
current["linear_issue"] = getattr(lane, "linear_issue", None) or None
|
|
445
|
+
current["linear_url"] = getattr(lane, "linear_url", None) or None
|
|
446
|
+
except Exception:
|
|
447
|
+
pass # leaves the defaults (None) from initialization
|
|
448
|
+
|
|
449
|
+
# open_thread_count — rolled up from list_review_threads per PR ───────
|
|
450
|
+
current["open_thread_count"] = _open_thread_count(workspace, feature)
|
|
451
|
+
|
|
452
|
+
return current
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ── Helpers for current_state ─────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
def _open_thread_count(workspace: Workspace, feature: str) -> int:
|
|
458
|
+
"""Total unresolved review threads across all repos+PRs for the feature.
|
|
459
|
+
|
|
460
|
+
Calls list_review_threads per-repo+PR using the same coords that
|
|
461
|
+
_threads_delta uses. On any exception, returns 0 and swallows.
|
|
462
|
+
|
|
463
|
+
# TODO: cache list_review_threads per resume call to avoid 2x round-trips
|
|
464
|
+
# when _threads_delta already ran in _populate_since (milestone-3 item).
|
|
465
|
+
"""
|
|
466
|
+
from ..integrations import github as gh
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
pr_coords = _pr_coords_per_repo(workspace, feature)
|
|
470
|
+
except Exception:
|
|
471
|
+
return 0
|
|
472
|
+
|
|
473
|
+
total = 0
|
|
474
|
+
for repo_name, coords in pr_coords.items():
|
|
475
|
+
if not coords:
|
|
476
|
+
continue
|
|
477
|
+
try:
|
|
478
|
+
threads = gh.list_review_threads(
|
|
479
|
+
workspace.config.root,
|
|
480
|
+
coords["owner"],
|
|
481
|
+
coords["repo_slug"],
|
|
482
|
+
coords["pr_number"],
|
|
483
|
+
)
|
|
484
|
+
except Exception:
|
|
485
|
+
continue
|
|
486
|
+
total += sum(1 for t in threads if not t.get("is_resolved", False))
|
|
487
|
+
return total
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# ── Intent hints ──────────────────────────────────────────────────────────────
|
|
491
|
+
|
|
492
|
+
def _intent_hints(
|
|
493
|
+
since: dict[str, Any],
|
|
494
|
+
current: dict[str, Any],
|
|
495
|
+
first_visit: bool,
|
|
496
|
+
) -> list[dict]:
|
|
497
|
+
"""Build prioritized next-action suggestions from the brief data.
|
|
498
|
+
|
|
499
|
+
Hints are derived, not stored — recomputed on every call. Ordering by
|
|
500
|
+
``priority`` field; the agent typically reads top 3.
|
|
501
|
+
"""
|
|
502
|
+
hints: list[dict] = []
|
|
503
|
+
|
|
504
|
+
# Address new comments (highest priority — reviewer activity is most
|
|
505
|
+
# actionable thing after returning).
|
|
506
|
+
new_threads = since.get("threads_new") or []
|
|
507
|
+
if new_threads:
|
|
508
|
+
hints.append({
|
|
509
|
+
"kind": "address_comments",
|
|
510
|
+
"summary": f"{len(new_threads)} new PR comment(s) since last visit",
|
|
511
|
+
"suggested_tool": "review_comments",
|
|
512
|
+
"suggested_args": {"alias": current.get("__feature_name__")},
|
|
513
|
+
"priority": 1,
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
# Align with default branch (the user's "align with dev" intent path).
|
|
517
|
+
behind_per_repo = {
|
|
518
|
+
r: info.get("behind", 0)
|
|
519
|
+
for r, info in (current.get("branch_position_per_repo") or {}).items()
|
|
520
|
+
if info.get("behind", 0) > 0
|
|
521
|
+
}
|
|
522
|
+
if behind_per_repo:
|
|
523
|
+
worst = max(behind_per_repo.items(), key=lambda kv: kv[1])
|
|
524
|
+
hints.append({
|
|
525
|
+
"kind": "align_with_default",
|
|
526
|
+
"summary": (
|
|
527
|
+
f"behind default by {worst[1]} commits in {worst[0]}"
|
|
528
|
+
+ (
|
|
529
|
+
f" (+ {len(behind_per_repo) - 1} other repos)"
|
|
530
|
+
if len(behind_per_repo) > 1
|
|
531
|
+
else ""
|
|
532
|
+
)
|
|
533
|
+
),
|
|
534
|
+
"suggested_tool": "log",
|
|
535
|
+
"suggested_args": {"repo": worst[0]},
|
|
536
|
+
"priority": 2,
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
# Post drafted replies.
|
|
540
|
+
drafts = current.get("draft_replies_summary") or {}
|
|
541
|
+
if drafts.get("addressed_total", 0) > 0:
|
|
542
|
+
hints.append({
|
|
543
|
+
"kind": "post_drafts",
|
|
544
|
+
"summary": f"{drafts['addressed_total']} draft replies ready",
|
|
545
|
+
"suggested_tool": "draft_replies",
|
|
546
|
+
"suggested_args": {"alias": current.get("__feature_name__")},
|
|
547
|
+
"priority": 3,
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
# CI failing.
|
|
551
|
+
ci = current.get("ci_summary_per_repo") or {}
|
|
552
|
+
failing = [r for r, status in ci.items() if status == "failing"]
|
|
553
|
+
if failing:
|
|
554
|
+
hints.append({
|
|
555
|
+
"kind": "investigate_ci",
|
|
556
|
+
"summary": f"CI failing in {', '.join(failing)}",
|
|
557
|
+
"suggested_tool": "pr_checks",
|
|
558
|
+
"suggested_args": {"alias": current.get("__feature_name__")},
|
|
559
|
+
"priority": 1, # ties with comments — both are blockers
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
# First-visit special case: hint to read the linear issue.
|
|
563
|
+
if first_visit and current.get("linear_issue"):
|
|
564
|
+
hints.append({
|
|
565
|
+
"kind": "read_issue",
|
|
566
|
+
"summary": f"first visit — read {current['linear_issue']}",
|
|
567
|
+
"suggested_tool": "linear_get_issue",
|
|
568
|
+
"suggested_args": {"alias": current.get("linear_issue")},
|
|
569
|
+
"priority": 1,
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
hints.sort(key=lambda h: h["priority"])
|
|
573
|
+
return hints
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
577
|
+
|
|
578
|
+
def _hours_between(start_iso: str, end_iso: str) -> float:
|
|
579
|
+
"""Return elapsed hours between two ISO-Z timestamps."""
|
|
580
|
+
def _parse(s: str) -> datetime:
|
|
581
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
582
|
+
return (_parse(end_iso) - _parse(start_iso)).total_seconds() / 3600.0
|