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.
Files changed (71) hide show
  1. canopy/__init__.py +2 -0
  2. canopy/actions/__init__.py +32 -0
  3. canopy/actions/aliases.py +421 -0
  4. canopy/actions/augments.py +55 -0
  5. canopy/actions/bootstrap.py +249 -0
  6. canopy/actions/bot_resolutions.py +123 -0
  7. canopy/actions/bot_status.py +133 -0
  8. canopy/actions/commit.py +511 -0
  9. canopy/actions/conflicts.py +314 -0
  10. canopy/actions/doctor.py +1459 -0
  11. canopy/actions/draft_replies.py +185 -0
  12. canopy/actions/drift.py +241 -0
  13. canopy/actions/errors.py +115 -0
  14. canopy/actions/evacuate.py +192 -0
  15. canopy/actions/feature_state.py +607 -0
  16. canopy/actions/historian.py +612 -0
  17. canopy/actions/ide_workspace.py +49 -0
  18. canopy/actions/last_visit.py +83 -0
  19. canopy/actions/migrate_slots.py +313 -0
  20. canopy/actions/preflight_state.py +97 -0
  21. canopy/actions/push.py +199 -0
  22. canopy/actions/reads.py +304 -0
  23. canopy/actions/resume.py +582 -0
  24. canopy/actions/review_filter.py +135 -0
  25. canopy/actions/ship.py +399 -0
  26. canopy/actions/slot_details.py +208 -0
  27. canopy/actions/slot_load.py +383 -0
  28. canopy/actions/slots.py +221 -0
  29. canopy/actions/stash.py +230 -0
  30. canopy/actions/switch.py +775 -0
  31. canopy/actions/switch_preflight.py +192 -0
  32. canopy/actions/thread_actions.py +88 -0
  33. canopy/actions/thread_resolutions.py +101 -0
  34. canopy/actions/triage.py +286 -0
  35. canopy/agent/__init__.py +5 -0
  36. canopy/agent/runner.py +129 -0
  37. canopy/agent_setup/__init__.py +264 -0
  38. canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
  39. canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
  40. canopy/cli/__init__.py +0 -0
  41. canopy/cli/main.py +4152 -0
  42. canopy/cli/render.py +98 -0
  43. canopy/cli/ui.py +150 -0
  44. canopy/features/__init__.py +2 -0
  45. canopy/features/coordinator.py +1256 -0
  46. canopy/git/__init__.py +0 -0
  47. canopy/git/hooks.py +173 -0
  48. canopy/git/multi.py +435 -0
  49. canopy/git/repo.py +859 -0
  50. canopy/git/templates/post-checkout.py +67 -0
  51. canopy/graph/__init__.py +0 -0
  52. canopy/integrations/__init__.py +0 -0
  53. canopy/integrations/github.py +983 -0
  54. canopy/integrations/linear.py +307 -0
  55. canopy/integrations/precommit.py +239 -0
  56. canopy/mcp/__init__.py +0 -0
  57. canopy/mcp/client.py +329 -0
  58. canopy/mcp/server.py +1797 -0
  59. canopy/providers/__init__.py +105 -0
  60. canopy/providers/github_issues.py +289 -0
  61. canopy/providers/linear.py +341 -0
  62. canopy/providers/types.py +149 -0
  63. canopy/workspace/__init__.py +4 -0
  64. canopy/workspace/config.py +378 -0
  65. canopy/workspace/context.py +224 -0
  66. canopy/workspace/discovery.py +197 -0
  67. canopy/workspace/workspace.py +173 -0
  68. canopy_cli-3.1.0.dist-info/METADATA +282 -0
  69. canopy_cli-3.1.0.dist-info/RECORD +71 -0
  70. canopy_cli-3.1.0.dist-info/WHEEL +4 -0
  71. canopy_cli-3.1.0.dist-info/entry_points.txt +3 -0
canopy/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Canopy — workspace-first development orchestrator."""
2
+ __version__ = "3.1.0"
@@ -0,0 +1,32 @@
1
+ """Actions: completion-driven recipes that compose tools.
2
+
3
+ Every action accepts semantic context (feature, repo) and runs preconditions
4
+ → steps → completion verification. Failures are returned as structured
5
+ BlockerError instances that consumers (CLI, MCP, extension) render or react
6
+ to in a uniform way.
7
+ """
8
+ from .drift import (
9
+ DriftReport,
10
+ FeatureDrift,
11
+ RepoAlignment,
12
+ assert_aligned,
13
+ detect_drift,
14
+ )
15
+ from .errors import (
16
+ ActionError,
17
+ BlockerError,
18
+ FailedError,
19
+ FixAction,
20
+ )
21
+
22
+ __all__ = [
23
+ "ActionError",
24
+ "BlockerError",
25
+ "DriftReport",
26
+ "FailedError",
27
+ "FeatureDrift",
28
+ "FixAction",
29
+ "RepoAlignment",
30
+ "assert_aligned",
31
+ "detect_drift",
32
+ ]
@@ -0,0 +1,421 @@
1
+ """Alias resolution for read primitives.
2
+
3
+ The agent (and humans) pass a single alias like ``TEAM-101`` to any read
4
+ tool and canopy figures out what to fetch. Each tool also accepts its
5
+ native specific form for direct lookups when the caller already has a
6
+ concrete reference.
7
+
8
+ Supported alias forms:
9
+ - Feature alias: feature name (e.g. ``auth-flow``) or Linear issue ID
10
+ (e.g. ``TEAM-101``). Resolves via ``FeatureCoordinator._resolve_name``
11
+ + ``features.json`` ``linear_issue`` field.
12
+ - PR specific: ``<repo>#<pr_number>`` (e.g. ``api#142``) or a GitHub PR
13
+ URL.
14
+ - Branch specific: ``<repo>:<branch>`` (e.g. ``api:auth-flow``).
15
+ - **Slot id:** ``worktree-N`` resolves to the feature currently in that
16
+ slot. ``BlockerError(empty_slot)`` when the slot is empty;
17
+ ``BlockerError(unknown_slot)`` when N is out of range.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from dataclasses import dataclass
23
+
24
+ from ..workspace.workspace import Workspace
25
+ from .errors import BlockerError, FixAction
26
+
27
+
28
+ _LINEAR_ID = re.compile(r"^[A-Z]+-\d+$", re.IGNORECASE)
29
+ _PR_SPECIFIC = re.compile(r"^([A-Za-z0-9_.-]+)#(\d+)$")
30
+ _BRANCH_SPECIFIC = re.compile(r"^([A-Za-z0-9_.-]+):(.+)$")
31
+ _PR_URL = re.compile(r"^https?://github\.com/([^/]+)/([^/]+)/pull/(\d+)")
32
+ _SLOT_ID = re.compile(r"^worktree-(\d+)$")
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class PRTarget:
37
+ repo: str # canopy repo name
38
+ owner: str # github owner
39
+ repo_slug: str # github repo
40
+ pr_number: int
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class BranchTarget:
45
+ repo: str # canopy repo name
46
+ branch: str
47
+
48
+
49
+ def resolve_feature(workspace: Workspace, alias: str) -> str:
50
+ """Resolve a feature alias to a canonical feature name.
51
+
52
+ Resolution order:
53
+ 0. Slot id (``worktree-N``) — resolves to the feature occupying that slot.
54
+ 1. Explicit lane in ``features.json``.
55
+ 2. ``features.json`` lane via ``branches`` mapping (per-repo branch overrides).
56
+ 3. Implicit multi-repo feature (``workspace.active_features()`` —
57
+ branch present in 2+ repos).
58
+ 4. Single-repo implicit feature (branch present in any registered repo).
59
+
60
+ Step 4 lets single-repo features resolve without an explicit
61
+ features.json entry. Without it, queries like ``canopy comments
62
+ auth-flow-api-only`` fail when only one repo carries the branch.
63
+ """
64
+ # Step 0: slot-id alias form — must come before _resolve_name, which
65
+ # treats unknown strings as implicit feature names.
66
+ m = _SLOT_ID.match(alias)
67
+ if m:
68
+ from . import slots as slots_mod
69
+ cap = workspace.config.slots
70
+ n = int(m.group(1))
71
+ if n < 1 or n > cap:
72
+ raise BlockerError(
73
+ code="unknown_slot",
74
+ what=f"slot '{alias}' is out of range (cap={cap})",
75
+ details={"slot": alias, "cap": cap},
76
+ )
77
+ state = slots_mod.read_state(workspace)
78
+ if state is None or alias not in state.slots:
79
+ raise BlockerError(
80
+ code="empty_slot",
81
+ what=f"slot '{alias}' is empty",
82
+ details={"slot": alias, "cap": cap},
83
+ )
84
+ return state.slots[alias].feature
85
+
86
+ from ..features.coordinator import FeatureCoordinator
87
+ from ..git import repo as git
88
+ coord = FeatureCoordinator(workspace)
89
+ try:
90
+ resolved = coord._resolve_name(alias)
91
+ except ValueError as e:
92
+ raise BlockerError(
93
+ code="ambiguous_alias",
94
+ what=str(e),
95
+ details={"alias": alias},
96
+ )
97
+
98
+ features = coord._load_features()
99
+ if resolved in features:
100
+ return resolved
101
+
102
+ # Step 2: alias may be a per-repo branch in some lane's branches map.
103
+ for fname, fdata in features.items():
104
+ branches_map = fdata.get("branches") or {}
105
+ if resolved in branches_map.values():
106
+ return fname
107
+
108
+ workspace.refresh()
109
+ if resolved in workspace.active_features():
110
+ return resolved
111
+
112
+ # Step 4: single-repo branch fallback.
113
+ for state in workspace.repos:
114
+ try:
115
+ if git.branch_exists(state.abs_path, resolved):
116
+ return resolved
117
+ except Exception:
118
+ pass
119
+
120
+ raise BlockerError(
121
+ code="unknown_alias",
122
+ what=f"no feature lane matches alias '{alias}'",
123
+ expected={
124
+ "explicit_features": sorted(features.keys()),
125
+ "implicit_features": sorted(workspace.active_features()),
126
+ },
127
+ details={"alias": alias, "resolved_to": resolved},
128
+ fix_actions=[
129
+ FixAction(action="list", args={}, safe=True,
130
+ preview="canopy list shows all feature lanes"),
131
+ ],
132
+ )
133
+
134
+
135
+ def repos_for_feature(
136
+ workspace: Workspace, feature_name: str,
137
+ ) -> dict[str, str]:
138
+ """Return ``{repo_name: expected_branch_name}`` for the feature.
139
+
140
+ Resolution:
141
+ - If ``feature_name`` is in ``features.json``: return all declared
142
+ ``repos`` with their expected branch (per-repo ``branches`` map
143
+ override, else feature name). Missing branches are NOT filtered
144
+ — callers (e.g. realign) need to know about declared repos
145
+ whose branch is gone, to report ``branch_not_found``.
146
+ - Otherwise (implicit feature): scan workspace repos and include
147
+ each where a branch named ``feature_name`` exists.
148
+ """
149
+ from ..features.coordinator import FeatureCoordinator
150
+ from ..git import repo as git
151
+
152
+ coord = FeatureCoordinator(workspace)
153
+ features = coord._load_features()
154
+
155
+ if feature_name in features:
156
+ fdata = features[feature_name]
157
+ branches_map = fdata.get("branches") or {}
158
+ return {
159
+ repo_name: branches_map.get(repo_name, feature_name)
160
+ for repo_name in (fdata.get("repos") or [])
161
+ }
162
+
163
+ # Implicit: scan repos for the branch.
164
+ out: dict[str, str] = {}
165
+ for state in workspace.repos:
166
+ try:
167
+ if git.branch_exists(state.abs_path, feature_name):
168
+ out[state.config.name] = feature_name
169
+ except Exception:
170
+ pass
171
+ return out
172
+
173
+
174
+ def resolve_issue_id(workspace: Workspace, alias: str) -> str:
175
+ """Resolve an alias to the active issue provider's canonical id (M5+).
176
+
177
+ Resolution order:
178
+ 1. **Provider parse** — ask the configured provider whether it
179
+ recognises the alias shape (e.g. ``SIN-412`` for Linear,
180
+ ``5`` / ``#5`` / ``owner/repo#5`` / GitHub URL for GitHub Issues).
181
+ If yes, use the provider-canonicalised form.
182
+ 2. **Feature-lane lookup** — treat the alias as a feature name
183
+ (e.g. ``auth-flow``) and read ``linear_issue`` from
184
+ features.json. (The field is still named ``linear_issue`` for
185
+ back-compat; treat it as "linked issue id".)
186
+ 3. **Fail loud** — ``BlockerError(code='no_linked_issue')`` with
187
+ helpful fix actions.
188
+
189
+ Replaces the pre-M5 ``resolve_linear_id`` (kept as a deprecated
190
+ alias below), which only knew about Linear-shaped IDs and broke the
191
+ CLI for any other provider — see test-findings F-7.
192
+ """
193
+ from ..providers import get_issue_provider, ProviderNotConfigured
194
+
195
+ # Step 1 — provider parse.
196
+ try:
197
+ provider = get_issue_provider(workspace)
198
+ except ProviderNotConfigured:
199
+ provider = None
200
+ if provider is not None:
201
+ try:
202
+ parsed = provider.parse_alias(alias)
203
+ except AttributeError:
204
+ # Older provider that hasn't implemented parse_alias yet —
205
+ # fall through to feature-lane lookup.
206
+ parsed = None
207
+ if parsed:
208
+ return parsed
209
+
210
+ # Step 2 — feature-lane lookup.
211
+ try:
212
+ feature_name = resolve_feature(workspace, alias)
213
+ except BlockerError:
214
+ # Not a recognised provider shape AND not a feature name.
215
+ # Re-raise with provider-aware fix-actions.
216
+ provider_name = (
217
+ workspace.config.issue_provider.name
218
+ if hasattr(workspace.config, "issue_provider")
219
+ else "issue provider"
220
+ )
221
+ raise BlockerError(
222
+ code="unknown_alias",
223
+ what=f"alias '{alias}' isn't a {provider_name} id, an issue URL, or a feature name",
224
+ details={"alias": alias, "provider": provider_name},
225
+ fix_actions=[
226
+ FixAction(
227
+ action="list", args={}, safe=True,
228
+ preview="canopy list shows all feature lanes",
229
+ ),
230
+ ],
231
+ )
232
+
233
+ from ..features.coordinator import FeatureCoordinator
234
+ features = FeatureCoordinator(workspace)._load_features()
235
+ feature = features.get(feature_name) or {}
236
+ linear_id = feature.get("linear_issue")
237
+ if not linear_id:
238
+ raise BlockerError(
239
+ code="no_linked_issue",
240
+ what=f"feature '{feature_name}' has no linked issue",
241
+ details={"alias": alias, "feature": feature_name},
242
+ fix_actions=[
243
+ FixAction(
244
+ action="feature_link_linear",
245
+ args={"feature": feature_name, "issue": "<ID>"},
246
+ safe=True,
247
+ preview="link an issue id to this feature lane",
248
+ ),
249
+ ],
250
+ )
251
+ return linear_id
252
+
253
+
254
+ # Deprecated alias kept for back-compat with existing imports
255
+ # (linear_get_issue, the legacy MCP tool, tests). New code calls
256
+ # ``resolve_issue_id``.
257
+ def resolve_linear_id(workspace: Workspace, alias: str) -> str:
258
+ """**Deprecated.** Renamed to ``resolve_issue_id`` (M5+).
259
+
260
+ Functionally equivalent — provider-aware when the workspace has an
261
+ ``[issue_provider]`` configured. The old name leaks Linear-ness;
262
+ new call sites should use ``resolve_issue_id``. The legacy error
263
+ code ``no_linear_id`` is also reissued as ``no_linked_issue``.
264
+ """
265
+ try:
266
+ return resolve_issue_id(workspace, alias)
267
+ except BlockerError as err:
268
+ # Surface the legacy code so existing assertions on
269
+ # ``no_linear_id`` keep working until callers migrate.
270
+ if err.code == "no_linked_issue":
271
+ raise BlockerError(
272
+ code="no_linear_id",
273
+ what=err.what,
274
+ details=err.details,
275
+ fix_actions=err.fix_actions,
276
+ ) from None
277
+ raise
278
+
279
+
280
+ def resolve_pr_targets(workspace: Workspace, alias: str) -> list[PRTarget]:
281
+ """Resolve an alias to one or more PR targets.
282
+
283
+ Accepts:
284
+ - PR URL (specific PR)
285
+ - ``<repo>#<n>`` (specific PR)
286
+ - Feature alias (all PRs in the lane, across repos — uses per-repo
287
+ branches map when set)
288
+ """
289
+ m = _PR_URL.match(alias)
290
+ if m:
291
+ owner, repo_slug, pr = m.group(1), m.group(2), int(m.group(3))
292
+ canopy_repo = _find_canopy_repo_by_slug(workspace, owner, repo_slug)
293
+ return [PRTarget(canopy_repo, owner, repo_slug, pr)]
294
+
295
+ m = _PR_SPECIFIC.match(alias)
296
+ if m:
297
+ canopy_repo, pr = m.group(1), int(m.group(2))
298
+ if canopy_repo not in {r.config.name for r in workspace.repos}:
299
+ raise BlockerError(
300
+ code="unknown_repo",
301
+ what=f"no repo '{canopy_repo}' in workspace",
302
+ expected={"available_repos": sorted(r.config.name for r in workspace.repos)},
303
+ details={"alias": alias},
304
+ )
305
+ owner, repo_slug = _resolve_owner_slug(workspace, canopy_repo)
306
+ return [PRTarget(canopy_repo, owner, repo_slug, pr)]
307
+
308
+ feature_name = resolve_feature(workspace, alias)
309
+ repo_branches = repos_for_feature(workspace, feature_name)
310
+
311
+ # Imported here (not at module top) to avoid a circular import: github
312
+ # imports from canopy.actions.errors which imports from this package.
313
+ from ..integrations import github as _gh
314
+
315
+ targets: list[PRTarget] = []
316
+ for canopy_repo, branch in repo_branches.items():
317
+ try:
318
+ owner, repo_slug = _resolve_owner_slug(workspace, canopy_repo)
319
+ except BlockerError:
320
+ continue
321
+ pr = _gh.find_pull_request(workspace.config.root, owner, repo_slug, branch)
322
+ if pr is None:
323
+ continue
324
+ targets.append(PRTarget(
325
+ repo=canopy_repo, owner=owner, repo_slug=repo_slug,
326
+ pr_number=pr["number"],
327
+ ))
328
+
329
+ if not targets:
330
+ raise BlockerError(
331
+ code="no_prs_for_feature",
332
+ what=f"feature '{feature_name}' has no open PRs in any repo",
333
+ details={"alias": alias, "feature": feature_name,
334
+ "repos_checked": list(repo_branches)},
335
+ fix_actions=[
336
+ FixAction(action="pr_create", args={"feature": feature_name},
337
+ safe=False, preview="open PRs for this feature"),
338
+ ],
339
+ )
340
+ return targets
341
+
342
+
343
+ def resolve_branch_targets(
344
+ workspace: Workspace, alias: str, repo: str | None = None,
345
+ ) -> list[BranchTarget]:
346
+ """Resolve an alias to one or more branch targets.
347
+
348
+ Accepts:
349
+ - ``<repo>:<branch>`` (specific branch in specific repo)
350
+ - Feature alias (per-repo branches from the lane's ``branches`` map,
351
+ falling back to the feature name)
352
+
353
+ If ``repo`` is provided alongside a feature alias, filters to that repo.
354
+ """
355
+ m = _BRANCH_SPECIFIC.match(alias)
356
+ if m:
357
+ canopy_repo, branch = m.group(1), m.group(2)
358
+ repo_names = {r.config.name for r in workspace.repos}
359
+ if canopy_repo not in repo_names:
360
+ raise BlockerError(
361
+ code="unknown_repo",
362
+ what=f"no repo '{canopy_repo}' in workspace",
363
+ expected={"available_repos": sorted(repo_names)},
364
+ details={"alias": alias},
365
+ )
366
+ if repo and canopy_repo != repo:
367
+ raise BlockerError(
368
+ code="alias_repo_mismatch",
369
+ what=f"alias specifies '{canopy_repo}' but repo='{repo}' was passed",
370
+ details={"alias": alias, "repo": repo},
371
+ )
372
+ return [BranchTarget(canopy_repo, branch)]
373
+
374
+ feature_name = resolve_feature(workspace, alias)
375
+ repo_branches = repos_for_feature(workspace, feature_name)
376
+
377
+ if repo:
378
+ if repo not in repo_branches:
379
+ raise BlockerError(
380
+ code="repo_not_in_feature",
381
+ what=f"repo '{repo}' is not part of feature '{feature_name}'",
382
+ expected={"feature_repos": list(repo_branches)},
383
+ details={"alias": alias, "repo": repo, "feature": feature_name},
384
+ )
385
+ return [BranchTarget(repo, repo_branches[repo])]
386
+
387
+ return [BranchTarget(r, b) for r, b in repo_branches.items()]
388
+
389
+
390
+ def _find_canopy_repo_by_slug(workspace: Workspace, owner: str, slug: str) -> str:
391
+ from ..git import repo as git
392
+ target_lc = f"{owner}/{slug}".lower()
393
+ target_lc_no_dotgit = target_lc.removesuffix(".git")
394
+ for state in workspace.repos:
395
+ try:
396
+ url = git.remote_url(state.abs_path).lower().removesuffix(".git")
397
+ except Exception:
398
+ continue
399
+ if target_lc in url or target_lc_no_dotgit in url:
400
+ return state.config.name
401
+ raise BlockerError(
402
+ code="unknown_github_repo",
403
+ what=f"no canopy repo matches github {owner}/{slug}",
404
+ expected={"available_repos": sorted(r.config.name for r in workspace.repos)},
405
+ details={"owner": owner, "slug": slug},
406
+ )
407
+
408
+
409
+ def _resolve_owner_slug(workspace: Workspace, canopy_repo: str) -> tuple[str, str]:
410
+ from ..git import repo as git
411
+ from ..integrations.github import _extract_owner_repo
412
+ state = workspace.get_repo(canopy_repo)
413
+ url = git.remote_url(state.abs_path)
414
+ parsed = _extract_owner_repo(url)
415
+ if not parsed:
416
+ raise BlockerError(
417
+ code="unparseable_remote",
418
+ what=f"can't extract owner/repo from {canopy_repo} remote: {url}",
419
+ details={"canopy_repo": canopy_repo, "remote_url": url},
420
+ )
421
+ return parsed
@@ -0,0 +1,55 @@
1
+ """Per-workspace augment resolver (M2).
2
+
3
+ Augments are user-customizable behavioral overrides stored in canopy.toml.
4
+ Two-tier: workspace-level defaults under ``[augments]``, per-repo overrides
5
+ under ``[[repos]] augments = {...}``. Per-repo wins on key collision.
6
+
7
+ Consumed by:
8
+ - ``integrations/precommit.py`` — ``preflight_cmd`` overrides auto-detection.
9
+ - ``actions/feature_state.py`` — ``review_bots`` filters bot-vs-human
10
+ comment classification (M3 bot-tracking).
11
+ - ``actions/commit.py`` — ``auto_resolve_threads_on_address`` (T4): when
12
+ ``true``, ``commit --address <id>`` automatically resolves the corresponding
13
+ GitHub review thread after a successful push. Overridden by CLI flags
14
+ ``--resolve-thread`` / ``--no-resolve-thread``.
15
+ - (planned) future ``canopy test`` command — ``test_cmd`` per-repo.
16
+
17
+ The resolver is intentionally lenient: missing keys return ``None`` / empty
18
+ collections rather than raising. Validation that catches typos lives in
19
+ ``canopy doctor`` (deferred).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ from ..workspace.config import WorkspaceConfig
26
+
27
+
28
+ def repo_augments(workspace: WorkspaceConfig, repo_name: str) -> dict[str, Any]:
29
+ """Merge workspace ``[augments]`` defaults with the per-repo override.
30
+
31
+ Per-repo wins on key collision. If the repo isn't in the workspace, the
32
+ workspace-level defaults are returned unchanged — useful when callers have
33
+ a path but haven't resolved which RepoConfig it belongs to.
34
+ """
35
+ workspace_defaults = workspace.augments or {}
36
+ repo = next((r for r in workspace.repos if r.name == repo_name), None)
37
+ overrides = (repo.augments if repo else None) or {}
38
+ return {**workspace_defaults, **overrides}
39
+
40
+
41
+ def bot_authors(workspace: WorkspaceConfig) -> list[str]:
42
+ """Return the configured bot-author substrings, lowercased.
43
+
44
+ Reads ``augments.review_bots`` from workspace defaults. Per-repo overrides
45
+ are deliberately ignored — bot authorship is a workspace-level concern
46
+ (the same CodeRabbit account comments across all repos in a workspace).
47
+
48
+ Returns an empty list when unset, in which case callers should fall back
49
+ to whatever default bot detection they had before (typically the
50
+ ``author_type == "Bot"`` substring check on the GitHub PR-comment payload).
51
+ """
52
+ raw = (workspace.augments or {}).get("review_bots", [])
53
+ if not isinstance(raw, list):
54
+ return []
55
+ return [str(s).lower() for s in raw if s]