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/commit.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""commit — feature-scoped multi-repo commit.
|
|
2
|
+
|
|
3
|
+
Stages tracked changes (or explicit ``paths``) and commits across every
|
|
4
|
+
repo in a feature lane with a single message. The canonical feature is
|
|
5
|
+
inferred from ``slots.json`` when no ``--feature`` is given;
|
|
6
|
+
explicit names override.
|
|
7
|
+
|
|
8
|
+
Pre-flight: every in-scope repo must currently be on its expected branch
|
|
9
|
+
(``lane.branches[repo]`` or, by default, the feature name). If any repo
|
|
10
|
+
has drifted to a different branch, commit refuses before any side effects
|
|
11
|
+
and surfaces a ``BlockerError(code='wrong_branch')`` whose ``details``
|
|
12
|
+
carry the per-repo expected/actual map.
|
|
13
|
+
|
|
14
|
+
Per-repo recipe::
|
|
15
|
+
|
|
16
|
+
1. stage --paths if given, else `git add -u` (all tracked changes)
|
|
17
|
+
2. if nothing staged → status: "nothing"
|
|
18
|
+
3. else `git commit -m <message>` (hooks honored unless no_hooks)
|
|
19
|
+
4. on hook failure → status: "hooks_failed" + hook_output tail
|
|
20
|
+
5. on success → status: "ok" + sha + files_changed
|
|
21
|
+
|
|
22
|
+
Per-repo failure does NOT cancel other repos. Result aggregates the
|
|
23
|
+
per-repo outcome dict so the caller can act on partial success.
|
|
24
|
+
|
|
25
|
+
The ``--address <comment-id>`` flag (M3) auto-formats the commit message
|
|
26
|
+
with a bot comment's title + URL and records a resolution entry in
|
|
27
|
+
``.canopy/state/bot_resolutions.json`` when the matching repo commits
|
|
28
|
+
successfully.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from ..git import repo as git
|
|
37
|
+
from ..workspace.workspace import Workspace
|
|
38
|
+
from . import slots as slots_mod
|
|
39
|
+
from .aliases import repos_for_feature, resolve_feature
|
|
40
|
+
from .bot_resolutions import record_resolution
|
|
41
|
+
from .errors import BlockerError, FixAction
|
|
42
|
+
from .feature_state import _per_repo_facts, resolve_repo_paths
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resolve_feature_name(
|
|
46
|
+
workspace: Workspace, feature: str | None,
|
|
47
|
+
) -> str:
|
|
48
|
+
"""Pick the feature: explicit alias → resolved name, else canonical."""
|
|
49
|
+
if feature:
|
|
50
|
+
return resolve_feature(workspace, feature)
|
|
51
|
+
state = slots_mod.read_state(workspace)
|
|
52
|
+
if state is None or state.canonical is None:
|
|
53
|
+
raise BlockerError(
|
|
54
|
+
code="no_canonical_feature",
|
|
55
|
+
what="no active feature; pass --feature or run `canopy switch <name>` first",
|
|
56
|
+
fix_actions=[
|
|
57
|
+
FixAction(action="switch", args={}, safe=False,
|
|
58
|
+
preview="canopy switch <feature> sets the canonical slot"),
|
|
59
|
+
],
|
|
60
|
+
)
|
|
61
|
+
return state.canonical.feature
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _verify_branches(
|
|
65
|
+
repo_paths: dict[str, Path],
|
|
66
|
+
repo_branches: dict[str, str],
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Raise BlockerError if any repo's current branch != expected.
|
|
69
|
+
|
|
70
|
+
Ran before any per-repo work. ``details.per_repo`` carries the
|
|
71
|
+
full expected/actual map so the agent can decide how to recover.
|
|
72
|
+
"""
|
|
73
|
+
mismatches: dict[str, dict[str, str]] = {}
|
|
74
|
+
for repo_name, expected in repo_branches.items():
|
|
75
|
+
path = repo_paths.get(repo_name)
|
|
76
|
+
if path is None:
|
|
77
|
+
continue
|
|
78
|
+
actual = git.current_branch(path)
|
|
79
|
+
if actual != expected:
|
|
80
|
+
mismatches[repo_name] = {"expected": expected, "actual": actual}
|
|
81
|
+
|
|
82
|
+
if not mismatches:
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
fixes = [
|
|
86
|
+
FixAction(action="switch", args={}, safe=False,
|
|
87
|
+
preview="canopy switch <feature> aligns all repos"),
|
|
88
|
+
]
|
|
89
|
+
raise BlockerError(
|
|
90
|
+
code="wrong_branch",
|
|
91
|
+
what=(
|
|
92
|
+
f"{len(mismatches)} repo(s) are on a different branch than the feature expects"
|
|
93
|
+
),
|
|
94
|
+
details={"per_repo": mismatches},
|
|
95
|
+
fix_actions=fixes,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _commit_one(
|
|
100
|
+
repo_path: Path,
|
|
101
|
+
message: str,
|
|
102
|
+
*,
|
|
103
|
+
paths: list[str] | None,
|
|
104
|
+
no_hooks: bool,
|
|
105
|
+
amend: bool,
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
"""Commit one repo. Returns a per-repo result dict."""
|
|
108
|
+
if amend:
|
|
109
|
+
# `--amend` works regardless of staging state; we still stage paths
|
|
110
|
+
# if requested, but skip the empty-stage early-return below.
|
|
111
|
+
if paths:
|
|
112
|
+
try:
|
|
113
|
+
git.stage_files(repo_path, paths)
|
|
114
|
+
except git.GitError as e:
|
|
115
|
+
return {"status": "failed", "reason": str(e)}
|
|
116
|
+
else:
|
|
117
|
+
try:
|
|
118
|
+
git.stage_all_tracked(repo_path)
|
|
119
|
+
except git.GitError as e:
|
|
120
|
+
return {"status": "failed", "reason": str(e)}
|
|
121
|
+
try:
|
|
122
|
+
result = git.commit(
|
|
123
|
+
repo_path, message, amend=True, no_hooks=no_hooks,
|
|
124
|
+
)
|
|
125
|
+
except git.GitError as e:
|
|
126
|
+
return _classify_commit_error(e)
|
|
127
|
+
return {
|
|
128
|
+
"status": "ok",
|
|
129
|
+
"sha": result["sha"],
|
|
130
|
+
"files_changed": result["files_changed"],
|
|
131
|
+
"amended": True,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# Non-amend path: stage, then short-circuit if nothing's staged.
|
|
135
|
+
try:
|
|
136
|
+
if paths:
|
|
137
|
+
git.stage_files(repo_path, paths)
|
|
138
|
+
else:
|
|
139
|
+
git.stage_all_tracked(repo_path)
|
|
140
|
+
except git.GitError as e:
|
|
141
|
+
return {"status": "failed", "reason": str(e)}
|
|
142
|
+
|
|
143
|
+
if git.staged_file_count(repo_path) == 0:
|
|
144
|
+
return {"status": "nothing", "reason": "no changes to commit"}
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
result = git.commit(repo_path, message, no_hooks=no_hooks)
|
|
148
|
+
except git.GitError as e:
|
|
149
|
+
return _classify_commit_error(e)
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
"status": "ok",
|
|
153
|
+
"sha": result["sha"],
|
|
154
|
+
"files_changed": result["files_changed"],
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _classify_commit_error(err: git.GitError) -> dict[str, Any]:
|
|
159
|
+
"""Distinguish hook-failures from other commit failures.
|
|
160
|
+
|
|
161
|
+
Pre-commit / commit-msg hooks fail with stderr that mentions the hook
|
|
162
|
+
name; other failures (gpg, locked index, etc.) get reported as-is.
|
|
163
|
+
"""
|
|
164
|
+
msg = str(err)
|
|
165
|
+
lower = msg.lower()
|
|
166
|
+
if "pre-commit" in lower or "commit-msg" in lower or "hook" in lower:
|
|
167
|
+
tail = "\n".join(msg.splitlines()[-10:])
|
|
168
|
+
return {"status": "hooks_failed", "hook_output": tail}
|
|
169
|
+
return {"status": "failed", "reason": msg}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def commit(
|
|
173
|
+
workspace: Workspace,
|
|
174
|
+
message: str,
|
|
175
|
+
*,
|
|
176
|
+
feature: str | None = None,
|
|
177
|
+
repos: list[str] | None = None,
|
|
178
|
+
paths: list[str] | None = None,
|
|
179
|
+
no_hooks: bool = False,
|
|
180
|
+
amend: bool = False,
|
|
181
|
+
address: str | None = None,
|
|
182
|
+
resolve_thread: bool | None = None,
|
|
183
|
+
) -> dict[str, Any]:
|
|
184
|
+
"""Commit across every repo in a feature lane.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
workspace: the workspace.
|
|
188
|
+
message: commit message (required unless ``amend`` or ``address``
|
|
189
|
+
supplies one). Empty messages should be rejected at the CLI
|
|
190
|
+
parse layer before reaching here.
|
|
191
|
+
feature: feature alias. If None, falls back to the canonical
|
|
192
|
+
feature in ``slots.json``.
|
|
193
|
+
repos: optional filter — only commit in these repos within
|
|
194
|
+
the feature scope. Repos NOT in the feature lane are
|
|
195
|
+
silently skipped (single source of truth: the feature).
|
|
196
|
+
paths: optional file path filter; relative to each repo root.
|
|
197
|
+
If given, ``git add <paths>`` instead of ``git add -u``.
|
|
198
|
+
no_hooks: skip pre-commit / commit-msg hooks (``--no-verify``).
|
|
199
|
+
amend: amend HEAD instead of creating a new commit.
|
|
200
|
+
address: a bot review comment ID or its GitHub URL (M3). When
|
|
201
|
+
set, the commit message is auto-suffixed with the comment
|
|
202
|
+
title + URL, and on success the resolution is recorded in
|
|
203
|
+
``.canopy/state/bot_resolutions.json``. Comment must belong
|
|
204
|
+
to one of the feature's actionable bot threads; a non-bot
|
|
205
|
+
comment raises ``BlockerError(code='not_a_bot_comment')``.
|
|
206
|
+
resolve_thread: when ``address`` is set, controls whether the
|
|
207
|
+
corresponding GitHub review thread is resolved after a
|
|
208
|
+
successful commit. ``True`` forces resolve; ``False`` forces
|
|
209
|
+
skip; ``None`` (default) defers to the workspace augment
|
|
210
|
+
``auto_resolve_threads_on_address`` (which defaults to
|
|
211
|
+
``False`` when absent). Thread resolution is a best-effort
|
|
212
|
+
step — failures are captured in ``result["thread_resolved"]``
|
|
213
|
+
as ``{"skipped": "<reason>"}`` rather than raising.
|
|
214
|
+
|
|
215
|
+
Returns ``{feature, results: {<repo>: {...}}, addressed?}``. The
|
|
216
|
+
per-repo dict has shape ``{status, sha?, files_changed?, reason?,
|
|
217
|
+
hook_output?, amended?}`` where ``status`` is one of
|
|
218
|
+
``ok | nothing | hooks_failed | failed``. When ``--address`` is given
|
|
219
|
+
and a resolution was recorded, ``addressed`` carries
|
|
220
|
+
``{comment_id, repo, sha, title, url}``. When thread resolution was
|
|
221
|
+
attempted, ``addressed`` also carries ``thread_resolved`` (a dict
|
|
222
|
+
with the GH result or a ``{"skipped": "<reason>"}`` entry).
|
|
223
|
+
"""
|
|
224
|
+
feature_name = _resolve_feature_name(workspace, feature)
|
|
225
|
+
repo_branches = repos_for_feature(workspace, feature_name)
|
|
226
|
+
if not repo_branches:
|
|
227
|
+
raise BlockerError(
|
|
228
|
+
code="empty_feature",
|
|
229
|
+
what=f"feature '{feature_name}' has no associated repos",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Optional repo filter — restrict to subset, but never expand beyond
|
|
233
|
+
# the feature scope.
|
|
234
|
+
if repos:
|
|
235
|
+
repo_branches = {
|
|
236
|
+
r: b for r, b in repo_branches.items() if r in set(repos)
|
|
237
|
+
}
|
|
238
|
+
if not repo_branches:
|
|
239
|
+
raise BlockerError(
|
|
240
|
+
code="repos_filter_empty",
|
|
241
|
+
what=f"none of {sorted(repos)} are in feature '{feature_name}'",
|
|
242
|
+
details={"feature_repos": sorted(repos_for_feature(workspace, feature_name).keys())},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
repo_paths, _has_wt = resolve_repo_paths(workspace, feature_name, repo_branches)
|
|
246
|
+
|
|
247
|
+
# ── --address: locate the bot comment and rewrite the message ───────
|
|
248
|
+
addressed_info: dict[str, Any] | None = None
|
|
249
|
+
if address is not None:
|
|
250
|
+
comment_id = _parse_comment_id(address)
|
|
251
|
+
bot_comment, owning_repo, _owner, _repo_slug, _pr_number = (
|
|
252
|
+
_find_actionable_bot_comment(
|
|
253
|
+
workspace, feature_name, repo_branches, repo_paths, comment_id,
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
if bot_comment is None:
|
|
257
|
+
raise BlockerError(
|
|
258
|
+
code="not_a_bot_comment",
|
|
259
|
+
what=(
|
|
260
|
+
f"comment {comment_id} is not in any actionable bot thread for "
|
|
261
|
+
f"feature '{feature_name}'"
|
|
262
|
+
),
|
|
263
|
+
details={
|
|
264
|
+
"feature": feature_name,
|
|
265
|
+
"comment_id": comment_id,
|
|
266
|
+
"hint": (
|
|
267
|
+
"verify the id with `canopy bot-status --feature "
|
|
268
|
+
f"{feature_name}` and pass either the numeric id or "
|
|
269
|
+
"the comment URL"
|
|
270
|
+
),
|
|
271
|
+
},
|
|
272
|
+
)
|
|
273
|
+
title = _comment_title(bot_comment.get("body", ""))
|
|
274
|
+
url = bot_comment.get("url", "")
|
|
275
|
+
message = _format_address_message(message or "", title, url)
|
|
276
|
+
addressed_info = {
|
|
277
|
+
"comment_id": comment_id,
|
|
278
|
+
"repo": owning_repo,
|
|
279
|
+
"title": title,
|
|
280
|
+
"url": url,
|
|
281
|
+
"_owner": _owner,
|
|
282
|
+
"_repo_slug": _repo_slug,
|
|
283
|
+
"_pr_number": _pr_number,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if not message and not amend:
|
|
287
|
+
# CLI argparse should catch this, but guard for direct callers.
|
|
288
|
+
raise BlockerError(
|
|
289
|
+
code="empty_message",
|
|
290
|
+
what="commit message is required",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if amend:
|
|
294
|
+
# Amend skips the wrong_branch pre-check — amending a commit on a
|
|
295
|
+
# different branch is sometimes intentional (rebase aftermath).
|
|
296
|
+
# Other failures still surface per-repo.
|
|
297
|
+
pass
|
|
298
|
+
else:
|
|
299
|
+
_verify_branches(repo_paths, repo_branches)
|
|
300
|
+
|
|
301
|
+
results: dict[str, dict[str, Any]] = {}
|
|
302
|
+
for repo_name, repo_path in repo_paths.items():
|
|
303
|
+
results[repo_name] = _commit_one(
|
|
304
|
+
repo_path,
|
|
305
|
+
message,
|
|
306
|
+
paths=paths,
|
|
307
|
+
no_hooks=no_hooks,
|
|
308
|
+
amend=amend,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
out: dict[str, Any] = {"feature": feature_name, "results": results}
|
|
312
|
+
|
|
313
|
+
# Record the resolution iff the owning repo committed successfully.
|
|
314
|
+
if addressed_info is not None:
|
|
315
|
+
owning = addressed_info["repo"]
|
|
316
|
+
owning_result = results.get(owning, {})
|
|
317
|
+
if owning_result.get("status") == "ok":
|
|
318
|
+
sha = owning_result["sha"]
|
|
319
|
+
record_resolution(
|
|
320
|
+
workspace.config.root,
|
|
321
|
+
comment_id=addressed_info["comment_id"],
|
|
322
|
+
feature=feature_name,
|
|
323
|
+
repo=owning,
|
|
324
|
+
commit_sha=sha,
|
|
325
|
+
comment_title=addressed_info["title"],
|
|
326
|
+
comment_url=addressed_info["url"],
|
|
327
|
+
)
|
|
328
|
+
# Mirror the resolution into historian (M4) so the per-feature
|
|
329
|
+
# memory file's Resolutions log stays current. Non-fatal if
|
|
330
|
+
# the historian write fails — the canonical state is still in
|
|
331
|
+
# bot_resolutions.json.
|
|
332
|
+
try:
|
|
333
|
+
from . import historian
|
|
334
|
+
historian.record_comment_resolved(
|
|
335
|
+
workspace.config.root, feature_name,
|
|
336
|
+
comment_id=addressed_info["comment_id"],
|
|
337
|
+
commit_sha=sha,
|
|
338
|
+
gist=addressed_info["title"],
|
|
339
|
+
url=addressed_info["url"],
|
|
340
|
+
)
|
|
341
|
+
except Exception:
|
|
342
|
+
pass
|
|
343
|
+
addressed_info["sha"] = sha
|
|
344
|
+
addressed_info["recorded"] = True
|
|
345
|
+
|
|
346
|
+
# ── Optional: resolve the GH review thread (T4) ─────────────
|
|
347
|
+
# Determine effective resolve flag: explicit flag > augment default.
|
|
348
|
+
# Note: this fires on local commit success. The thread will be
|
|
349
|
+
# resolved before the commit reaches GitHub — push your branch to
|
|
350
|
+
# make the linkage live on the remote.
|
|
351
|
+
from ..integrations import github as gh
|
|
352
|
+
from .thread_actions import resolve_thread as _resolve_thread_action
|
|
353
|
+
|
|
354
|
+
augment_default = bool(
|
|
355
|
+
(workspace.config.augments or {}).get(
|
|
356
|
+
"auto_resolve_threads_on_address", False,
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
effective_resolve = (
|
|
360
|
+
resolve_thread if resolve_thread is not None else augment_default
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
if effective_resolve:
|
|
364
|
+
owner = addressed_info.get("_owner") or ""
|
|
365
|
+
repo_slug = addressed_info.get("_repo_slug") or ""
|
|
366
|
+
pr_number = addressed_info.get("_pr_number")
|
|
367
|
+
comment_id_val = addressed_info["comment_id"]
|
|
368
|
+
|
|
369
|
+
if not (owner and repo_slug and pr_number):
|
|
370
|
+
addressed_info["thread_resolved"] = {
|
|
371
|
+
"skipped": "pr_not_found",
|
|
372
|
+
"comment_id": comment_id_val,
|
|
373
|
+
}
|
|
374
|
+
else:
|
|
375
|
+
try:
|
|
376
|
+
threads = gh.list_review_threads(
|
|
377
|
+
workspace.config.root, owner, repo_slug, pr_number,
|
|
378
|
+
)
|
|
379
|
+
except Exception as exc:
|
|
380
|
+
addressed_info["thread_resolved"] = {
|
|
381
|
+
"skipped": "gh_unreachable",
|
|
382
|
+
"error": str(exc),
|
|
383
|
+
"comment_id": comment_id_val,
|
|
384
|
+
}
|
|
385
|
+
else:
|
|
386
|
+
# comment_id may be str or int; normalise to int for comparison.
|
|
387
|
+
try:
|
|
388
|
+
cid_int = int(comment_id_val)
|
|
389
|
+
except (ValueError, TypeError):
|
|
390
|
+
cid_int = None
|
|
391
|
+
|
|
392
|
+
target_tid = next(
|
|
393
|
+
(
|
|
394
|
+
t["thread_id"]
|
|
395
|
+
for t in threads
|
|
396
|
+
if any(
|
|
397
|
+
c.get("comment_id") == cid_int
|
|
398
|
+
for c in t.get("comments", [])
|
|
399
|
+
)
|
|
400
|
+
),
|
|
401
|
+
None,
|
|
402
|
+
)
|
|
403
|
+
if target_tid is None:
|
|
404
|
+
addressed_info["thread_resolved"] = {
|
|
405
|
+
"skipped": "thread_not_found",
|
|
406
|
+
"comment_id": comment_id_val,
|
|
407
|
+
}
|
|
408
|
+
else:
|
|
409
|
+
try:
|
|
410
|
+
from .errors import ActionError
|
|
411
|
+
addressed_info["thread_resolved"] = _resolve_thread_action(
|
|
412
|
+
workspace,
|
|
413
|
+
target_tid,
|
|
414
|
+
feature=feature_name,
|
|
415
|
+
via_command="commit_address",
|
|
416
|
+
via_commit_sha=sha,
|
|
417
|
+
)
|
|
418
|
+
except ActionError as exc:
|
|
419
|
+
addressed_info["thread_resolved"] = {
|
|
420
|
+
"skipped": "resolve_failed",
|
|
421
|
+
"error": exc.to_dict(),
|
|
422
|
+
"comment_id": comment_id_val,
|
|
423
|
+
}
|
|
424
|
+
else:
|
|
425
|
+
addressed_info["recorded"] = False
|
|
426
|
+
addressed_info["reason"] = (
|
|
427
|
+
f"owning repo '{owning}' commit status: {owning_result.get('status', 'unknown')}"
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
# Strip internal PR-coordinate keys before returning.
|
|
431
|
+
addressed_info.pop("_owner", None)
|
|
432
|
+
addressed_info.pop("_repo_slug", None)
|
|
433
|
+
addressed_info.pop("_pr_number", None)
|
|
434
|
+
out["addressed"] = addressed_info
|
|
435
|
+
|
|
436
|
+
return out
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# ── --address helpers ────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
_TRAILING_DIGITS = re.compile(r"(\d+)\s*$")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _parse_comment_id(address: str) -> str:
|
|
446
|
+
"""Accept a numeric id, a ``#123`` form, or a GitHub URL.
|
|
447
|
+
|
|
448
|
+
GitHub URLs end with ``#discussion_r<N>``, ``#issuecomment-<N>``, or
|
|
449
|
+
similar — we extract the trailing digit run as the canonical id.
|
|
450
|
+
"""
|
|
451
|
+
raw = address.strip()
|
|
452
|
+
match = _TRAILING_DIGITS.search(raw)
|
|
453
|
+
if not match:
|
|
454
|
+
raise BlockerError(
|
|
455
|
+
code="invalid_comment_id",
|
|
456
|
+
what=f"could not parse a comment id from '{address}'",
|
|
457
|
+
details={"hint": "pass a numeric id (e.g. 123456) or the GitHub comment URL"},
|
|
458
|
+
)
|
|
459
|
+
return match.group(1)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _find_actionable_bot_comment(
|
|
463
|
+
workspace: Workspace,
|
|
464
|
+
feature_name: str,
|
|
465
|
+
repo_branches: dict[str, str],
|
|
466
|
+
repo_paths: dict[str, Path],
|
|
467
|
+
comment_id: str,
|
|
468
|
+
) -> tuple[dict | None, str | None, str | None, str | None, int | None]:
|
|
469
|
+
"""Walk per-repo bot threads for a matching comment id.
|
|
470
|
+
|
|
471
|
+
Returns ``(comment_dict, owning_repo, owner, repo_slug, pr_number)`` or
|
|
472
|
+
``(None, None, None, None, None)`` when no actionable bot thread carries
|
|
473
|
+
the requested id. The extra fields come from the same ``_per_repo_facts``
|
|
474
|
+
call so no second network round-trip is needed.
|
|
475
|
+
"""
|
|
476
|
+
facts = _per_repo_facts(workspace, feature_name, repo_branches, repo_paths)
|
|
477
|
+
for repo_name, repo_facts in facts.items():
|
|
478
|
+
for thread in repo_facts.get("actionable_bot_threads", []):
|
|
479
|
+
if str(thread.get("id", "")) == comment_id:
|
|
480
|
+
pr = repo_facts.get("pr") or {}
|
|
481
|
+
return (
|
|
482
|
+
thread,
|
|
483
|
+
repo_name,
|
|
484
|
+
repo_facts.get("owner"),
|
|
485
|
+
repo_facts.get("repo_slug"),
|
|
486
|
+
pr.get("number"),
|
|
487
|
+
)
|
|
488
|
+
return None, None, None, None, None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _comment_title(body: str, max_len: int = 80) -> str:
|
|
492
|
+
"""First non-empty line of the comment, trimmed to ``max_len``."""
|
|
493
|
+
for line in (body or "").splitlines():
|
|
494
|
+
line = line.strip()
|
|
495
|
+
if not line:
|
|
496
|
+
continue
|
|
497
|
+
if len(line) <= max_len:
|
|
498
|
+
return line
|
|
499
|
+
return line[:max_len].rstrip() + "…"
|
|
500
|
+
return ""
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _format_address_message(user_message: str, title: str, url: str) -> str:
|
|
504
|
+
"""Append the standard ``Addresses bot comment`` trailer."""
|
|
505
|
+
suffix_parts = [f'Addresses bot comment: "{title}"' if title else "Addresses bot comment"]
|
|
506
|
+
if url:
|
|
507
|
+
suffix_parts[-1] += f" ({url})"
|
|
508
|
+
suffix = "\n\n".join(suffix_parts)
|
|
509
|
+
if user_message.strip():
|
|
510
|
+
return f"{user_message.rstrip()}\n\n{suffix}"
|
|
511
|
+
return suffix
|