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,249 @@
|
|
|
1
|
+
"""``canopy worktree bootstrap`` — env files, deps, IDE workspace (M6).
|
|
2
|
+
|
|
3
|
+
Three optional steps, gated per repo + per invocation:
|
|
4
|
+
|
|
5
|
+
1. **Env file copy** — per-repo ``env_files`` lists files (relative to
|
|
6
|
+
repo root) to copy from the main checkout into the worktree.
|
|
7
|
+
2. **Dependency install** — per-repo ``install_cmd`` runs once in the
|
|
8
|
+
worktree directory (e.g. ``uv sync`` / ``pnpm install``).
|
|
9
|
+
3. **IDE workspace file** — workspace-level ``ide = "vscode"`` writes
|
|
10
|
+
``.canopy/workspaces/<feature>.code-workspace`` listing every
|
|
11
|
+
worktree dir for the feature.
|
|
12
|
+
|
|
13
|
+
Each step is **off by default**. When the relevant config exists, the
|
|
14
|
+
caller must pass ``bootstrap=True`` (or set ``bootstrap_default = true``
|
|
15
|
+
in ``[workspace]``).
|
|
16
|
+
|
|
17
|
+
Failure of any step doesn't roll back the worktree — the worktree is
|
|
18
|
+
still valid. The caller can re-run ``canopy worktree bootstrap`` to
|
|
19
|
+
retry just the failed step.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Iterable
|
|
27
|
+
|
|
28
|
+
from ..workspace.workspace import Workspace
|
|
29
|
+
from .aliases import resolve_feature
|
|
30
|
+
from .errors import BlockerError
|
|
31
|
+
from .ide_workspace import render_code_workspace
|
|
32
|
+
|
|
33
|
+
ALLOWED_STEPS = ("env", "deps", "ide")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def bootstrap_feature(
|
|
37
|
+
workspace: Workspace,
|
|
38
|
+
feature: str,
|
|
39
|
+
*,
|
|
40
|
+
force: bool = False,
|
|
41
|
+
steps: Iterable[str] | None = None,
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
"""Run all three steps for every repo in a feature.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
workspace: loaded workspace.
|
|
47
|
+
feature: feature alias.
|
|
48
|
+
force: overwrite existing destination env files.
|
|
49
|
+
steps: subset of {"env", "deps", "ide"} to run; default = all.
|
|
50
|
+
|
|
51
|
+
Returns ``{feature, results: {<repo>: {env, deps}}, ide}``.
|
|
52
|
+
Per-step result shape::
|
|
53
|
+
|
|
54
|
+
env → {status: "ok"|"skipped"|"missing_source", files_copied: [...]}
|
|
55
|
+
deps → {status: "ok"|"failed"|"skipped", exit_code, duration_ms,
|
|
56
|
+
stderr_tail?}
|
|
57
|
+
ide → {status: "ok"|"skipped"|"no_ide_configured", path?}
|
|
58
|
+
"""
|
|
59
|
+
feature_name = resolve_feature(workspace, feature)
|
|
60
|
+
chosen_steps = _validate_steps(steps)
|
|
61
|
+
worktree_paths = _resolve_worktree_paths(workspace, feature_name)
|
|
62
|
+
|
|
63
|
+
if not worktree_paths:
|
|
64
|
+
raise BlockerError(
|
|
65
|
+
code="no_worktrees",
|
|
66
|
+
what=f"feature '{feature_name}' has no worktree paths recorded",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
results: dict[str, dict[str, Any]] = {}
|
|
70
|
+
for repo_name, worktree_path in worktree_paths.items():
|
|
71
|
+
results[repo_name] = bootstrap_repo(
|
|
72
|
+
workspace, feature_name, repo_name, worktree_path,
|
|
73
|
+
force=force, steps=chosen_steps,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
ide_result: dict[str, Any]
|
|
77
|
+
if "ide" in chosen_steps and workspace.config.ide and workspace.config.ide != "none":
|
|
78
|
+
ide_result = _write_ide_workspace(workspace, feature_name, worktree_paths)
|
|
79
|
+
else:
|
|
80
|
+
ide_result = {"status": "no_ide_configured"} if "ide" in chosen_steps else {"status": "skipped"}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"feature": feature_name,
|
|
84
|
+
"results": results,
|
|
85
|
+
"ide": ide_result,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def bootstrap_repo(
|
|
90
|
+
workspace: Workspace,
|
|
91
|
+
feature_name: str,
|
|
92
|
+
repo_name: str,
|
|
93
|
+
worktree_path: Path,
|
|
94
|
+
*,
|
|
95
|
+
force: bool = False,
|
|
96
|
+
steps: Iterable[str] = ALLOWED_STEPS,
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
"""Run env-copy + deps-install for a single repo's worktree."""
|
|
99
|
+
chosen = set(steps)
|
|
100
|
+
state = workspace.get_repo(repo_name)
|
|
101
|
+
main_path = state.abs_path
|
|
102
|
+
repo_config = state.config
|
|
103
|
+
|
|
104
|
+
env_result: dict[str, Any] = {"status": "skipped", "files_copied": []}
|
|
105
|
+
if "env" in chosen:
|
|
106
|
+
if repo_config.env_files:
|
|
107
|
+
env_result = _copy_env_files(
|
|
108
|
+
repo_config.env_files, main_path, worktree_path, force=force,
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
env_result = {"status": "skipped", "files_copied": [],
|
|
112
|
+
"reason": "no env_files configured"}
|
|
113
|
+
|
|
114
|
+
deps_result: dict[str, Any] = {"status": "skipped"}
|
|
115
|
+
if "deps" in chosen:
|
|
116
|
+
if repo_config.install_cmd:
|
|
117
|
+
deps_result = _run_install(repo_config.install_cmd, worktree_path)
|
|
118
|
+
else:
|
|
119
|
+
deps_result = {"status": "skipped", "reason": "no install_cmd configured"}
|
|
120
|
+
|
|
121
|
+
return {"env": env_result, "deps": deps_result}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── step 1: env-file copy ──────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
def _copy_env_files(
|
|
127
|
+
env_files: list[str],
|
|
128
|
+
src_dir: Path,
|
|
129
|
+
dst_dir: Path,
|
|
130
|
+
*,
|
|
131
|
+
force: bool,
|
|
132
|
+
) -> dict[str, Any]:
|
|
133
|
+
"""Copy each file relative to ``src_dir`` into ``dst_dir``.
|
|
134
|
+
|
|
135
|
+
Missing source files surface as ``missing_source`` per file but
|
|
136
|
+
don't block the others. Existing destinations are skipped unless
|
|
137
|
+
``force=True``.
|
|
138
|
+
"""
|
|
139
|
+
copied: list[str] = []
|
|
140
|
+
skipped: list[str] = []
|
|
141
|
+
missing: list[str] = []
|
|
142
|
+
for rel in env_files:
|
|
143
|
+
src = src_dir / rel
|
|
144
|
+
dst = dst_dir / rel
|
|
145
|
+
if not src.exists():
|
|
146
|
+
missing.append(rel)
|
|
147
|
+
continue
|
|
148
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
if dst.exists() and not force:
|
|
150
|
+
skipped.append(rel)
|
|
151
|
+
continue
|
|
152
|
+
shutil.copy2(src, dst)
|
|
153
|
+
copied.append(rel)
|
|
154
|
+
|
|
155
|
+
if missing and not copied and not skipped:
|
|
156
|
+
status = "missing_source"
|
|
157
|
+
elif copied or skipped:
|
|
158
|
+
status = "ok"
|
|
159
|
+
else:
|
|
160
|
+
status = "skipped"
|
|
161
|
+
|
|
162
|
+
out: dict[str, Any] = {
|
|
163
|
+
"status": status,
|
|
164
|
+
"files_copied": copied,
|
|
165
|
+
}
|
|
166
|
+
if skipped:
|
|
167
|
+
out["files_skipped"] = skipped
|
|
168
|
+
if missing:
|
|
169
|
+
out["files_missing"] = missing
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ── step 2: dep install ────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
def _run_install(install_cmd: str, worktree_path: Path) -> dict[str, Any]:
|
|
176
|
+
"""Run ``install_cmd`` in ``worktree_path`` and capture exit + duration."""
|
|
177
|
+
import time
|
|
178
|
+
start = time.monotonic()
|
|
179
|
+
proc = subprocess.run(
|
|
180
|
+
install_cmd, shell=True, cwd=worktree_path,
|
|
181
|
+
capture_output=True, text=True,
|
|
182
|
+
)
|
|
183
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
184
|
+
out: dict[str, Any] = {
|
|
185
|
+
"status": "ok" if proc.returncode == 0 else "failed",
|
|
186
|
+
"exit_code": proc.returncode,
|
|
187
|
+
"duration_ms": duration_ms,
|
|
188
|
+
}
|
|
189
|
+
if proc.returncode != 0:
|
|
190
|
+
# Tail the last few lines of stderr — full output would balloon
|
|
191
|
+
# the JSON return for the dashboard. Caller can rerun manually
|
|
192
|
+
# for full output if needed.
|
|
193
|
+
tail = "\n".join(proc.stderr.splitlines()[-10:])
|
|
194
|
+
out["stderr_tail"] = tail
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ── step 3: IDE workspace file ─────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
def _write_ide_workspace(
|
|
201
|
+
workspace: Workspace,
|
|
202
|
+
feature_name: str,
|
|
203
|
+
worktree_paths: dict[str, Path],
|
|
204
|
+
) -> dict[str, Any]:
|
|
205
|
+
"""Write ``.canopy/workspaces/<feature>.code-workspace`` atomically."""
|
|
206
|
+
if workspace.config.ide != "vscode":
|
|
207
|
+
return {"status": "skipped",
|
|
208
|
+
"reason": f"ide={workspace.config.ide!r} not supported (vscode only in v1)"}
|
|
209
|
+
ws_dir = workspace.config.root / ".canopy" / "workspaces"
|
|
210
|
+
ws_dir.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
out_path = ws_dir / f"{feature_name}.code-workspace"
|
|
212
|
+
body = render_code_workspace(workspace, feature_name, worktree_paths)
|
|
213
|
+
tmp = out_path.with_suffix(out_path.suffix + ".tmp")
|
|
214
|
+
tmp.write_text(body)
|
|
215
|
+
tmp.replace(out_path)
|
|
216
|
+
return {"status": "ok", "path": str(out_path)}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ── helpers ────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
def _validate_steps(steps: Iterable[str] | None) -> set[str]:
|
|
222
|
+
if steps is None:
|
|
223
|
+
return set(ALLOWED_STEPS)
|
|
224
|
+
chosen = set(steps)
|
|
225
|
+
bad = chosen - set(ALLOWED_STEPS)
|
|
226
|
+
if bad:
|
|
227
|
+
raise BlockerError(
|
|
228
|
+
code="unknown_bootstrap_step",
|
|
229
|
+
what=f"unknown step(s): {sorted(bad)}",
|
|
230
|
+
expected={"allowed_steps": list(ALLOWED_STEPS)},
|
|
231
|
+
)
|
|
232
|
+
return chosen
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _resolve_worktree_paths(
|
|
236
|
+
workspace: Workspace, feature_name: str,
|
|
237
|
+
) -> dict[str, Path]:
|
|
238
|
+
"""Pull recorded worktree paths from features.json."""
|
|
239
|
+
import json
|
|
240
|
+
path = workspace.config.root / ".canopy" / "features.json"
|
|
241
|
+
if not path.exists():
|
|
242
|
+
return {}
|
|
243
|
+
try:
|
|
244
|
+
data = json.loads(path.read_text("utf-8"))
|
|
245
|
+
except (OSError, ValueError):
|
|
246
|
+
return {}
|
|
247
|
+
entry = data.get(feature_name) or {}
|
|
248
|
+
raw = entry.get("worktree_paths") or {}
|
|
249
|
+
return {repo: Path(p) for repo, p in raw.items() if p}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Persistent log of bot review-comments addressed by canopy commits (M3).
|
|
2
|
+
|
|
3
|
+
State file: ``<workspace_root>/.canopy/state/bot_resolutions.json``
|
|
4
|
+
|
|
5
|
+
Schema (an append-only mapping; each key is a stringified GitHub comment ID)::
|
|
6
|
+
|
|
7
|
+
{
|
|
8
|
+
"123456": {
|
|
9
|
+
"feature": "sin-6-cache-stats",
|
|
10
|
+
"repo": "test-api",
|
|
11
|
+
"commit_sha": "abc123de",
|
|
12
|
+
"addressed_at": "2026-05-02T17:30:00Z",
|
|
13
|
+
"comment_title": "rename hit_rate to cache_hit_rate",
|
|
14
|
+
"comment_url": "https://github.com/owner/repo/pull/142#discussion_r123456"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Written by ``commit --address``; read by the ``bot_comments_status`` rollup
|
|
19
|
+
and by ``feature_state`` to subtract resolved bot comments from the
|
|
20
|
+
actionable count surfaced in the agent dashboard.
|
|
21
|
+
|
|
22
|
+
Writes are atomic (temp file + ``os.replace``) so concurrent record calls
|
|
23
|
+
across worktrees don't corrupt the file.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import tempfile
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_STATE_DIR = ".canopy/state"
|
|
36
|
+
_STATE_FILE = "bot_resolutions.json"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _state_path(workspace_root: Path) -> Path:
|
|
40
|
+
return workspace_root / _STATE_DIR / _STATE_FILE
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _now_iso() -> str:
|
|
44
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_resolutions(workspace_root: Path) -> dict[str, dict[str, Any]]:
|
|
48
|
+
"""Read the resolution log. Returns an empty dict when no file exists."""
|
|
49
|
+
path = _state_path(workspace_root)
|
|
50
|
+
if not path.exists():
|
|
51
|
+
return {}
|
|
52
|
+
try:
|
|
53
|
+
with open(path, "r") as f:
|
|
54
|
+
data = json.load(f)
|
|
55
|
+
except (json.JSONDecodeError, OSError):
|
|
56
|
+
return {}
|
|
57
|
+
return data if isinstance(data, dict) else {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_resolved(workspace_root: Path, comment_id: str | int) -> bool:
|
|
61
|
+
"""True iff this comment id is in the resolution log."""
|
|
62
|
+
return str(comment_id) in load_resolutions(workspace_root)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolutions_for_feature(
|
|
66
|
+
workspace_root: Path, feature: str,
|
|
67
|
+
) -> dict[str, dict[str, Any]]:
|
|
68
|
+
"""Filter the log to entries tagged with this feature name."""
|
|
69
|
+
return {
|
|
70
|
+
cid: entry
|
|
71
|
+
for cid, entry in load_resolutions(workspace_root).items()
|
|
72
|
+
if entry.get("feature") == feature
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def record_resolution(
|
|
77
|
+
workspace_root: Path,
|
|
78
|
+
*,
|
|
79
|
+
comment_id: str | int,
|
|
80
|
+
feature: str,
|
|
81
|
+
repo: str,
|
|
82
|
+
commit_sha: str,
|
|
83
|
+
comment_title: str,
|
|
84
|
+
comment_url: str = "",
|
|
85
|
+
addressed_at: str | None = None,
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
"""Append a resolution entry. Last-write-wins on duplicate comment_id.
|
|
88
|
+
|
|
89
|
+
Returns the entry written. Creates the state directory if missing.
|
|
90
|
+
"""
|
|
91
|
+
path = _state_path(workspace_root)
|
|
92
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
|
|
94
|
+
existing = load_resolutions(workspace_root)
|
|
95
|
+
entry = {
|
|
96
|
+
"feature": feature,
|
|
97
|
+
"repo": repo,
|
|
98
|
+
"commit_sha": commit_sha,
|
|
99
|
+
"addressed_at": addressed_at or _now_iso(),
|
|
100
|
+
"comment_title": comment_title,
|
|
101
|
+
"comment_url": comment_url,
|
|
102
|
+
}
|
|
103
|
+
existing[str(comment_id)] = entry
|
|
104
|
+
_atomic_write(path, existing)
|
|
105
|
+
return entry
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _atomic_write(path: Path, data: dict) -> None:
|
|
109
|
+
"""Write JSON atomically: tmp file in same dir, then os.replace."""
|
|
110
|
+
fd, tmp = tempfile.mkstemp(
|
|
111
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent),
|
|
112
|
+
)
|
|
113
|
+
try:
|
|
114
|
+
with os.fdopen(fd, "w") as f:
|
|
115
|
+
json.dump(data, f, indent=2, sort_keys=True)
|
|
116
|
+
f.write("\n")
|
|
117
|
+
os.replace(tmp, path)
|
|
118
|
+
except Exception:
|
|
119
|
+
try:
|
|
120
|
+
os.unlink(tmp)
|
|
121
|
+
except FileNotFoundError:
|
|
122
|
+
pass
|
|
123
|
+
raise
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Per-feature bot-comment rollup (M3).
|
|
2
|
+
|
|
3
|
+
Composes the live actionable bot threads (from ``feature_state._per_repo_facts``)
|
|
4
|
+
with the persistent resolution log (``bot_resolutions.json``) into a single
|
|
5
|
+
``{feature, repos: {<repo>: {pr_number, total, resolved, unresolved, threads}},
|
|
6
|
+
all_resolved}`` shape that the CLI / MCP / dashboard share.
|
|
7
|
+
|
|
8
|
+
A "bot comment" here means an actionable thread whose author was classified as
|
|
9
|
+
a bot per ``feature_state._is_bot_comment`` (GitHub-typed bot AND, when
|
|
10
|
+
``review_bots`` is set in canopy.toml augments, substring-matching the
|
|
11
|
+
configured list).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from . import slots as slots_mod
|
|
18
|
+
from .aliases import repos_for_feature, resolve_feature
|
|
19
|
+
from .bot_resolutions import resolutions_for_feature
|
|
20
|
+
from .errors import BlockerError, FixAction
|
|
21
|
+
from .feature_state import _per_repo_facts, resolve_repo_paths
|
|
22
|
+
from ..workspace.workspace import Workspace
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def bot_comments_status(
|
|
26
|
+
workspace: Workspace,
|
|
27
|
+
feature: str | None = None,
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
"""Build the rollup. Falls back to the canonical feature when ``feature`` is None."""
|
|
30
|
+
feature_name = _resolve_feature_name(workspace, feature)
|
|
31
|
+
repo_branches = repos_for_feature(workspace, feature_name)
|
|
32
|
+
if not repo_branches:
|
|
33
|
+
raise BlockerError(
|
|
34
|
+
code="empty_feature",
|
|
35
|
+
what=f"feature '{feature_name}' has no associated repos",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
repo_paths, _has_wt = resolve_repo_paths(workspace, feature_name, repo_branches)
|
|
39
|
+
facts = _per_repo_facts(workspace, feature_name, repo_branches, repo_paths)
|
|
40
|
+
resolutions = resolutions_for_feature(workspace.config.root, feature_name)
|
|
41
|
+
|
|
42
|
+
repos_out: dict[str, dict[str, Any]] = {}
|
|
43
|
+
all_resolved = True
|
|
44
|
+
any_bot_comment_seen = False
|
|
45
|
+
|
|
46
|
+
for repo_name, repo_facts in facts.items():
|
|
47
|
+
pr = repo_facts.get("pr") or {}
|
|
48
|
+
bot_threads = repo_facts.get("actionable_bot_threads", [])
|
|
49
|
+
# Resolution entries scoped to this repo (so we report a sensible
|
|
50
|
+
# `resolved` count per PR even when other repos have their own).
|
|
51
|
+
repo_resolutions = {
|
|
52
|
+
cid: entry
|
|
53
|
+
for cid, entry in resolutions.items()
|
|
54
|
+
if entry.get("repo") == repo_name
|
|
55
|
+
}
|
|
56
|
+
unresolved_threads = [
|
|
57
|
+
_thread_summary(t, resolved=False, resolution=None) for t in bot_threads
|
|
58
|
+
]
|
|
59
|
+
resolved_threads = [
|
|
60
|
+
_resolved_summary(cid, entry)
|
|
61
|
+
for cid, entry in sorted(repo_resolutions.items())
|
|
62
|
+
]
|
|
63
|
+
threads = resolved_threads + unresolved_threads
|
|
64
|
+
|
|
65
|
+
total = len(threads)
|
|
66
|
+
if total > 0:
|
|
67
|
+
any_bot_comment_seen = True
|
|
68
|
+
if unresolved_threads:
|
|
69
|
+
all_resolved = False
|
|
70
|
+
|
|
71
|
+
repos_out[repo_name] = {
|
|
72
|
+
"pr_number": pr.get("number"),
|
|
73
|
+
"pr_url": pr.get("url", ""),
|
|
74
|
+
"total": total,
|
|
75
|
+
"resolved": len(resolved_threads),
|
|
76
|
+
"unresolved": len(unresolved_threads),
|
|
77
|
+
"threads": threads,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
"feature": feature_name,
|
|
82
|
+
"repos": repos_out,
|
|
83
|
+
"all_resolved": all_resolved if any_bot_comment_seen else True,
|
|
84
|
+
"any_bot_comments": any_bot_comment_seen,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _resolve_feature_name(workspace: Workspace, feature: str | None) -> str:
|
|
89
|
+
if feature:
|
|
90
|
+
return resolve_feature(workspace, feature)
|
|
91
|
+
state = slots_mod.read_state(workspace)
|
|
92
|
+
if state is None or state.canonical is None:
|
|
93
|
+
raise BlockerError(
|
|
94
|
+
code="no_canonical_feature",
|
|
95
|
+
what="no active feature; pass --feature or run `canopy switch <name>` first",
|
|
96
|
+
fix_actions=[
|
|
97
|
+
FixAction(action="switch", args={}, safe=False,
|
|
98
|
+
preview="canopy switch <feature> sets the canonical slot"),
|
|
99
|
+
],
|
|
100
|
+
)
|
|
101
|
+
return state.canonical.feature
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _thread_summary(
|
|
105
|
+
thread: dict, *, resolved: bool, resolution: dict | None,
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
out = {
|
|
108
|
+
"id": thread.get("id"),
|
|
109
|
+
"author": thread.get("author", ""),
|
|
110
|
+
"path": thread.get("path", ""),
|
|
111
|
+
"line": thread.get("line", 0),
|
|
112
|
+
"url": thread.get("url", ""),
|
|
113
|
+
"body_preview": (thread.get("body") or "").splitlines()[0][:120] if thread.get("body") else "",
|
|
114
|
+
"resolved": resolved,
|
|
115
|
+
}
|
|
116
|
+
if resolution:
|
|
117
|
+
out["resolved_by_commit"] = resolution.get("commit_sha", "")
|
|
118
|
+
out["addressed_at"] = resolution.get("addressed_at", "")
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _resolved_summary(comment_id: str, entry: dict) -> dict[str, Any]:
|
|
123
|
+
return {
|
|
124
|
+
"id": comment_id,
|
|
125
|
+
"author": "",
|
|
126
|
+
"path": "",
|
|
127
|
+
"line": 0,
|
|
128
|
+
"url": entry.get("comment_url", ""),
|
|
129
|
+
"body_preview": entry.get("comment_title", ""),
|
|
130
|
+
"resolved": True,
|
|
131
|
+
"resolved_by_commit": entry.get("commit_sha", ""),
|
|
132
|
+
"addressed_at": entry.get("addressed_at", ""),
|
|
133
|
+
}
|