code-mower 0.5.0b5__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 (185) hide show
  1. code_mower/__init__.py +3 -0
  2. code_mower/adapters/__init__.py +39 -0
  3. code_mower/adapters/_base.py +148 -0
  4. code_mower/adapters/cursor_bugbot.py +97 -0
  5. code_mower/adapters/gitar.py +111 -0
  6. code_mower/adapters/greptile.py +191 -0
  7. code_mower/adapters/qodo.py +140 -0
  8. code_mower/antigravity_cli_audit_pr.py +241 -0
  9. code_mower/audit_handoff_log.py +345 -0
  10. code_mower/audit_labeler_lib.py +311 -0
  11. code_mower/audit_progress.py +203 -0
  12. code_mower/blind_review_artifacts.py +562 -0
  13. code_mower/blind_review_coordinator.py +276 -0
  14. code_mower/bootstrap.py +524 -0
  15. code_mower/builder_experiment.py +539 -0
  16. code_mower/calibration/__init__.py +181 -0
  17. code_mower/calibration/arms.py +267 -0
  18. code_mower/calibration/auto_discovery.py +348 -0
  19. code_mower/calibration/commands.py +192 -0
  20. code_mower/calibration/context_inputs.py +199 -0
  21. code_mower/calibration/corpus.py +91 -0
  22. code_mower/calibration/evidence.py +20 -0
  23. code_mower/calibration/evidence_report.py +360 -0
  24. code_mower/calibration/identity.py +27 -0
  25. code_mower/calibration/metrics.py +18 -0
  26. code_mower/calibration/overlap.py +103 -0
  27. code_mower/calibration/planning.py +309 -0
  28. code_mower/calibration/policy.py +207 -0
  29. code_mower/calibration/results.py +315 -0
  30. code_mower/calibration/run_results.py +147 -0
  31. code_mower/calibration/run_status.py +64 -0
  32. code_mower/calibration/runner.py +360 -0
  33. code_mower/calibration/truth.py +188 -0
  34. code_mower/calibration/value_report.py +142 -0
  35. code_mower/checks.py +402 -0
  36. code_mower/claude_audit_pr.py +1126 -0
  37. code_mower/claude_cli_bounce.py +303 -0
  38. code_mower/claude_cli_environment.py +73 -0
  39. code_mower/clear_stale.py +374 -0
  40. code_mower/cli.py +537 -0
  41. code_mower/cloud.py +674 -0
  42. code_mower/cloud_client/__init__.py +173 -0
  43. code_mower/cloud_client/bundle.py +155 -0
  44. code_mower/cloud_client/doctor.py +206 -0
  45. code_mower/cloud_client/dogfood.py +78 -0
  46. code_mower/cloud_client/endpoints.py +114 -0
  47. code_mower/cloud_client/errors.py +7 -0
  48. code_mower/cloud_client/events.py +278 -0
  49. code_mower/cloud_client/export.py +272 -0
  50. code_mower/cloud_client/git_metadata.py +46 -0
  51. code_mower/cloud_client/manifest.py +39 -0
  52. code_mower/cloud_client/operations.py +448 -0
  53. code_mower/cloud_client/reports.py +45 -0
  54. code_mower/cloud_client/setup.py +205 -0
  55. code_mower/cloud_client/upload.py +97 -0
  56. code_mower/code_mower_calibration.py +598 -0
  57. code_mower/code_mower_context_packs.py +591 -0
  58. code_mower/code_mower_merge.py +227 -0
  59. code_mower/code_mower_telemetry.py +561 -0
  60. code_mower/coderabbit_cli_audit_pr.py +526 -0
  61. code_mower/codex_audit_env_preflight.py +220 -0
  62. code_mower/codex_audit_pr.py +1738 -0
  63. code_mower/codex_audit_schema_smoke.py +160 -0
  64. code_mower/codex_audit_verdict.schema.json +44 -0
  65. code_mower/config.py +655 -0
  66. code_mower/doctor.py +161 -0
  67. code_mower/doctor_checks/__init__.py +104 -0
  68. code_mower/doctor_checks/cloud.py +129 -0
  69. code_mower/doctor_checks/common.py +215 -0
  70. code_mower/doctor_checks/github.py +128 -0
  71. code_mower/doctor_checks/github_actions.py +11 -0
  72. code_mower/doctor_checks/github_actions_cost.py +99 -0
  73. code_mower/doctor_checks/github_actions_cost_summary.py +111 -0
  74. code_mower/doctor_checks/github_actions_failure_annotations.py +27 -0
  75. code_mower/doctor_checks/github_actions_failure_models.py +47 -0
  76. code_mower/doctor_checks/github_actions_failure_scan.py +200 -0
  77. code_mower/doctor_checks/github_actions_failure_selection.py +63 -0
  78. code_mower/doctor_checks/github_actions_failures.py +103 -0
  79. code_mower/doctor_checks/github_actions_permissions.py +55 -0
  80. code_mower/doctor_checks/github_api.py +79 -0
  81. code_mower/doctor_checks/github_branch.py +56 -0
  82. code_mower/doctor_checks/github_config.py +25 -0
  83. code_mower/doctor_checks/github_provider.py +61 -0
  84. code_mower/doctor_checks/github_repo.py +120 -0
  85. code_mower/doctor_checks/groups.py +36 -0
  86. code_mower/doctor_checks/models.py +96 -0
  87. code_mower/doctor_checks/output.py +86 -0
  88. code_mower/doctor_checks/presets.py +64 -0
  89. code_mower/doctor_checks/privacy.py +20 -0
  90. code_mower/doctor_checks/provider_api_model.py +138 -0
  91. code_mower/doctor_checks/provider_api_model_openai.py +29 -0
  92. code_mower/doctor_checks/provider_api_model_profiles.py +137 -0
  93. code_mower/doctor_checks/provider_env.py +113 -0
  94. code_mower/doctor_checks/provider_env_required.py +56 -0
  95. code_mower/doctor_checks/provider_env_tokens.py +100 -0
  96. code_mower/doctor_checks/provider_local_cli.py +162 -0
  97. code_mower/doctor_checks/provider_local_cli_commands.py +47 -0
  98. code_mower/doctor_checks/provider_local_cli_probe_config.py +70 -0
  99. code_mower/doctor_checks/provider_probe.py +20 -0
  100. code_mower/doctor_checks/provider_probe_auth.py +52 -0
  101. code_mower/doctor_checks/provider_probe_evaluation.py +109 -0
  102. code_mower/doctor_checks/provider_probe_json.py +45 -0
  103. code_mower/doctor_checks/provider_probe_remediation.py +39 -0
  104. code_mower/doctor_checks/providers.py +159 -0
  105. code_mower/doctor_checks/registry.py +69 -0
  106. code_mower/doctor_checks/runner.py +188 -0
  107. code_mower/doctor_checks/runtime.py +89 -0
  108. code_mower/doctor_checks/runtime_github_auth.py +148 -0
  109. code_mower/gemini_cli_audit_pr.py +897 -0
  110. code_mower/hermes_cli_audit_pr.py +436 -0
  111. code_mower/init.py +888 -0
  112. code_mower/lane_configs/__init__.py +37 -0
  113. code_mower/lane_configs/aider.py +32 -0
  114. code_mower/lane_configs/antigravity_cli.py +35 -0
  115. code_mower/lane_configs/claude.py +35 -0
  116. code_mower/lane_configs/codex.py +32 -0
  117. code_mower/lane_configs/devin.py +33 -0
  118. code_mower/lane_configs/gemini_cli.py +35 -0
  119. code_mower/lane_configs/hermes_cli.py +35 -0
  120. code_mower/lane_configs/local_llm.py +31 -0
  121. code_mower/local_llm_audit_pr.py +1364 -0
  122. code_mower/local_llm_bakeoff.py +458 -0
  123. code_mower/local_llm_calibration.py +441 -0
  124. code_mower/local_llm_profiles.py +66 -0
  125. code_mower/migration.py +508 -0
  126. code_mower/migration_install.py +292 -0
  127. code_mower/migration_mirror.py +392 -0
  128. code_mower/migration_readiness.py +237 -0
  129. code_mower/migration_rehearsal.py +718 -0
  130. code_mower/next_steps.py +441 -0
  131. code_mower/package.py +673 -0
  132. code_mower/package_content.py +444 -0
  133. code_mower/package_manifest.py +452 -0
  134. code_mower/package_paths.py +53 -0
  135. code_mower/package_rendering.py +90 -0
  136. code_mower/package_static.py +585 -0
  137. code_mower/prompts.py +267 -0
  138. code_mower/provider_registry.py +469 -0
  139. code_mower/provider_runners/__init__.py +60 -0
  140. code_mower/provider_runners/comments.py +31 -0
  141. code_mower/provider_runners/git.py +46 -0
  142. code_mower/provider_runners/github_auth.py +61 -0
  143. code_mower/provider_runners/github_pr.py +120 -0
  144. code_mower/provider_runners/process.py +58 -0
  145. code_mower/provider_runners/repo_paths.py +23 -0
  146. code_mower/provider_runners/text_schema.py +41 -0
  147. code_mower/provider_runners/verdict_artifacts.py +103 -0
  148. code_mower/provider_runners/workspace.py +57 -0
  149. code_mower/release_readiness.py +549 -0
  150. code_mower/reviewer_metrics.py +389 -0
  151. code_mower/saas_reviewer_labeler.py +809 -0
  152. code_mower/secrets.py +89 -0
  153. code_mower/templates/builder-experiment.example.json +55 -0
  154. code_mower/templates/calibration-corpus.example.json +129 -0
  155. code_mower/templates/calibration-corpus.json +129 -0
  156. code_mower/templates/code-mower.example.yml +423 -0
  157. code_mower/templates/context-packs.example.json +150 -0
  158. code_mower/templates/lane_prompts/base-audit.md +22 -0
  159. code_mower/templates/lane_prompts/calibration-policy.md +21 -0
  160. code_mower/templates/lane_prompts/context-driven-quality.md +21 -0
  161. code_mower/templates/lane_prompts/docs-design.md +12 -0
  162. code_mower/templates/lane_prompts/generic-programming.md +21 -0
  163. code_mower/templates/lane_prompts/operability.md +22 -0
  164. code_mower/templates/lane_prompts/package-runtime.md +12 -0
  165. code_mower/templates/lane_prompts/security-threat-model.md +22 -0
  166. code_mower/templates/product-support/code_mower +216 -0
  167. code_mower/templates/product-support/code_mower_standalone_pin.env +7 -0
  168. code_mower/templates/product-support/code_mower_standalone_shadow.sh +151 -0
  169. code_mower/templates/product-support/run_claude_audit_pr.sh +32 -0
  170. code_mower/templates/product-support/run_codex_audit_pr.sh +32 -0
  171. code_mower/templates/product-support/safe_gh_comment.py +96 -0
  172. code_mower/templates/providers.yml +454 -0
  173. code_mower/templates/reviewer-spend.example.json +28 -0
  174. code_mower/templates/reviewer-value-report.example.md +20 -0
  175. code_mower/templates/workflows/private-standalone-shadow.yml.j2 +106 -0
  176. code_mower/templates/workflows/review-clear-stale.yml.j2 +83 -0
  177. code_mower/trailer_comment_labeler.py +207 -0
  178. code_mower/versioning.py +32 -0
  179. code_mower-0.5.0b5.dist-info/METADATA +302 -0
  180. code_mower-0.5.0b5.dist-info/RECORD +185 -0
  181. code_mower-0.5.0b5.dist-info/WHEEL +5 -0
  182. code_mower-0.5.0b5.dist-info/entry_points.txt +2 -0
  183. code_mower-0.5.0b5.dist-info/licenses/LICENSE +202 -0
  184. code_mower-0.5.0b5.dist-info/licenses/NOTICE +10 -0
  185. code_mower-0.5.0b5.dist-info/top_level.txt +1 -0
code_mower/config.py ADDED
@@ -0,0 +1,655 @@
1
+ #!/usr/bin/env python3
2
+ """Validate and render a dry-run plan for a Code Mower config.
3
+
4
+ This is intentionally dependency-free. It supports the documented
5
+ `code-mower.example.yml` subset so the reference repos can prove the
6
+ configuration contract before the OSS package chooses a full YAML dependency.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import re
14
+ import sys
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any, Iterable, Mapping
18
+
19
+
20
+ ALLOWED_LANE_TYPES = {"audit", "review"}
21
+ ALLOWED_DRIVERS = {"local_cli", "manual", "hosted_bridge", "saas_event", "api_model"}
22
+ ALLOWED_EVENTS = {"issue_comment", "pull_request_review", "check_run"}
23
+ ALLOWED_SPEND_POLICIES = {"none", "local", "included", "paid"}
24
+ SAFE_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
25
+ SAFE_WORKFLOW_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*\.ya?ml$")
26
+ GENERATED_WORKFLOW_BY_DRIVER = {
27
+ "api_model": "trailer-comment labeler + API/model runner",
28
+ "hosted_bridge": "hosted bridge + trailer-comment labeler",
29
+ "local_cli": "local CLI runner + trailer-comment labeler",
30
+ "manual": "manual review state machine",
31
+ "saas_event": "SaaS reviewer event labeler",
32
+ }
33
+
34
+
35
+ class ConfigError(ValueError):
36
+ """Raised when the config file cannot be parsed."""
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ConfigIssue:
41
+ path: str
42
+ message: str
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class RenderedPlan:
47
+ text: str
48
+ data: Mapping[str, Any]
49
+
50
+
51
+ def _strip_inline_comment(raw: str) -> str:
52
+ in_single = False
53
+ in_double = False
54
+ for index, char in enumerate(raw):
55
+ if char == "'" and not in_double:
56
+ in_single = not in_single
57
+ elif char == '"' and not in_single:
58
+ in_double = not in_double
59
+ elif char == "#" and not in_single and not in_double:
60
+ if index == 0 or raw[index - 1].isspace():
61
+ return raw[:index].rstrip()
62
+ return raw.rstrip()
63
+
64
+
65
+ def _strip_comments_and_blank_lines(text: str) -> list[tuple[int, str, int]]:
66
+ lines: list[tuple[int, str, int]] = []
67
+ for line_no, raw in enumerate(text.splitlines(), start=1):
68
+ raw = _strip_inline_comment(raw)
69
+ if not raw.strip() or raw.lstrip().startswith("#"):
70
+ continue
71
+ indent = len(raw) - len(raw.lstrip(" "))
72
+ if raw[:indent].replace(" ", ""):
73
+ raise ConfigError(f"line {line_no}: indentation must use spaces")
74
+ lines.append((indent, raw.strip(), line_no))
75
+ return lines
76
+
77
+
78
+ def _parse_scalar(value: str) -> Any:
79
+ if value in {"true", "True"}:
80
+ return True
81
+ if value in {"false", "False"}:
82
+ return False
83
+ if value in {"null", "None", "~"}:
84
+ return None
85
+ if value == "{}":
86
+ return {}
87
+ if value.startswith('"') and value.endswith('"'):
88
+ return value[1:-1]
89
+ if value.startswith("'") and value.endswith("'"):
90
+ return value[1:-1]
91
+ if value.startswith("[") and value.endswith("]"):
92
+ inside = value[1:-1].strip()
93
+ if not inside:
94
+ return []
95
+ return [_parse_scalar(part.strip()) for part in _split_inline_list(inside)]
96
+ return value
97
+
98
+
99
+ def _split_inline_list(value: str) -> list[str]:
100
+ parts: list[str] = []
101
+ current: list[str] = []
102
+ in_single = False
103
+ in_double = False
104
+ bracket_depth = 0
105
+ for char in value:
106
+ if char == "'" and not in_double:
107
+ in_single = not in_single
108
+ elif char == '"' and not in_single:
109
+ in_double = not in_double
110
+ elif not in_single and not in_double:
111
+ if char == "[":
112
+ bracket_depth += 1
113
+ elif char == "]" and bracket_depth:
114
+ bracket_depth -= 1
115
+ elif char == "," and bracket_depth == 0:
116
+ parts.append("".join(current).strip())
117
+ current = []
118
+ continue
119
+ current.append(char)
120
+ parts.append("".join(current).strip())
121
+ return parts
122
+
123
+
124
+ def _mapping_separator_index(text: str) -> int | None:
125
+ in_single = False
126
+ in_double = False
127
+ for index, char in enumerate(text):
128
+ if char == "'" and not in_double:
129
+ in_single = not in_single
130
+ elif char == '"' and not in_single:
131
+ in_double = not in_double
132
+ elif char == ":" and not in_single and not in_double:
133
+ if index == len(text) - 1 or text[index + 1].isspace():
134
+ return index
135
+ return None
136
+
137
+
138
+ def _split_key_value(text: str, line_no: int) -> tuple[str, str | None]:
139
+ separator = _mapping_separator_index(text)
140
+ if separator is None:
141
+ raise ConfigError(f"line {line_no}: expected key/value pair")
142
+ key = text[:separator]
143
+ value = text[separator + 1 :]
144
+ key = key.strip()
145
+ if not key:
146
+ raise ConfigError(f"line {line_no}: empty key")
147
+ value = value.strip()
148
+ return key, value or None
149
+
150
+
151
+ class _YamlSubsetParser:
152
+ def __init__(self, text: str) -> None:
153
+ self.lines = _strip_comments_and_blank_lines(text)
154
+ self.index = 0
155
+
156
+ def parse(self) -> Any:
157
+ if not self.lines:
158
+ return {}
159
+ value = self._parse_block(self.lines[0][0])
160
+ if self.index != len(self.lines):
161
+ _, _, line_no = self.lines[self.index]
162
+ raise ConfigError(f"line {line_no}: unexpected trailing content")
163
+ return value
164
+
165
+ def _parse_block(self, indent: int) -> Any:
166
+ if self.index >= len(self.lines):
167
+ return {}
168
+ current_indent, text, line_no = self.lines[self.index]
169
+ if current_indent != indent:
170
+ raise ConfigError(
171
+ f"line {line_no}: expected indent {indent}, got {current_indent}"
172
+ )
173
+ if text.startswith("- "):
174
+ return self._parse_list(indent)
175
+ return self._parse_mapping(indent)
176
+
177
+ def _parse_mapping(self, indent: int) -> dict[str, Any]:
178
+ result: dict[str, Any] = {}
179
+ while self.index < len(self.lines):
180
+ current_indent, text, line_no = self.lines[self.index]
181
+ if current_indent < indent:
182
+ break
183
+ if current_indent > indent:
184
+ raise ConfigError(f"line {line_no}: unexpected nested mapping")
185
+ if text.startswith("- "):
186
+ break
187
+
188
+ key, value = _split_key_value(text, line_no)
189
+ self.index += 1
190
+ if value is not None:
191
+ result[key] = _parse_scalar(value)
192
+ continue
193
+
194
+ if self.index >= len(self.lines):
195
+ result[key] = {}
196
+ continue
197
+ next_indent, _, next_line_no = self.lines[self.index]
198
+ if next_indent <= indent:
199
+ result[key] = {}
200
+ continue
201
+ if next_indent != indent + 2:
202
+ raise ConfigError(
203
+ f"line {next_line_no}: expected indent {indent + 2}, got {next_indent}"
204
+ )
205
+ result[key] = self._parse_block(next_indent)
206
+ return result
207
+
208
+ def _parse_list(self, indent: int) -> list[Any]:
209
+ items: list[Any] = []
210
+ while self.index < len(self.lines):
211
+ current_indent, text, line_no = self.lines[self.index]
212
+ if current_indent < indent:
213
+ break
214
+ if current_indent > indent:
215
+ raise ConfigError(f"line {line_no}: unexpected nested list item")
216
+ if not text.startswith("- "):
217
+ break
218
+
219
+ item_text = text[2:].strip()
220
+ self.index += 1
221
+ if not item_text:
222
+ if self.index >= len(self.lines):
223
+ items.append({})
224
+ continue
225
+ next_indent, _, next_line_no = self.lines[self.index]
226
+ if next_indent <= indent:
227
+ items.append({})
228
+ continue
229
+ if next_indent != indent + 2:
230
+ raise ConfigError(
231
+ f"line {next_line_no}: expected indent {indent + 2}, got {next_indent}"
232
+ )
233
+ items.append(self._parse_block(next_indent))
234
+ continue
235
+
236
+ if _mapping_separator_index(item_text) is not None:
237
+ key, value = _split_key_value(item_text, line_no)
238
+ item: dict[str, Any] = {}
239
+ if value is not None:
240
+ item[key] = _parse_scalar(value)
241
+ elif self.index < len(self.lines) and self.lines[self.index][0] > indent + 2:
242
+ item[key] = self._parse_block(self.lines[self.index][0])
243
+ else:
244
+ item[key] = {}
245
+ if self.index < len(self.lines):
246
+ next_indent, next_text, next_line_no = self.lines[self.index]
247
+ if next_indent == indent + 2 and not next_text.startswith("- "):
248
+ item.update(self._parse_mapping(next_indent))
249
+ elif next_indent > indent + 2:
250
+ raise ConfigError(
251
+ f"line {next_line_no}: expected indent {indent + 2}, got {next_indent}"
252
+ )
253
+ items.append(item)
254
+ continue
255
+
256
+ items.append(_parse_scalar(item_text))
257
+ return items
258
+
259
+
260
+ def load_config(path: Path) -> Mapping[str, Any]:
261
+ try:
262
+ text = path.read_text(encoding="utf-8")
263
+ except (OSError, UnicodeDecodeError) as exc:
264
+ raise ConfigError(f"unable to read {path}: {exc}") from exc
265
+ parsed = _YamlSubsetParser(text).parse()
266
+ if not isinstance(parsed, Mapping):
267
+ raise ConfigError("top-level config must be a mapping")
268
+ return parsed
269
+
270
+
271
+ def _as_mapping(value: Any, path: str, issues: list[ConfigIssue]) -> Mapping[str, Any]:
272
+ if isinstance(value, Mapping):
273
+ return value
274
+ issues.append(ConfigIssue(path, "must be a mapping"))
275
+ return {}
276
+
277
+
278
+ def _as_sequence(value: Any, path: str, issues: list[ConfigIssue]) -> tuple[Any, ...]:
279
+ if isinstance(value, list):
280
+ return tuple(value)
281
+ issues.append(ConfigIssue(path, "must be a list"))
282
+ return ()
283
+
284
+
285
+ def _require_string(value: Any, path: str, issues: list[ConfigIssue]) -> str | None:
286
+ if isinstance(value, str) and value:
287
+ return value
288
+ issues.append(ConfigIssue(path, "must be a non-empty string"))
289
+ return None
290
+
291
+
292
+ def _require_identifier(value: Any, path: str, issues: list[ConfigIssue]) -> str | None:
293
+ text = _require_string(value, path, issues)
294
+ if text is None:
295
+ return None
296
+ if not SAFE_IDENTIFIER_RE.fullmatch(text):
297
+ issues.append(
298
+ ConfigIssue(
299
+ path,
300
+ "must match [A-Za-z0-9][A-Za-z0-9_-]* for generated file safety",
301
+ )
302
+ )
303
+ return None
304
+ return text
305
+
306
+
307
+ def _require_workflow_path(value: Any, path: str, issues: list[ConfigIssue]) -> str | None:
308
+ text = _require_string(value, path, issues)
309
+ if text is None:
310
+ return None
311
+ parts = text.split("/")
312
+ valid = (
313
+ len(parts) == 3
314
+ and parts[0] == ".github"
315
+ and parts[1] == "workflows"
316
+ and "\\" not in text
317
+ and all(part not in {"", ".", ".."} for part in parts)
318
+ and SAFE_WORKFLOW_FILENAME_RE.fullmatch(parts[2]) is not None
319
+ )
320
+ if not valid:
321
+ issues.append(
322
+ ConfigIssue(
323
+ path,
324
+ "must be a safe direct .github/workflows/*.yml path",
325
+ )
326
+ )
327
+ return None
328
+ return text
329
+
330
+
331
+ def _token_env_any_groups(lane: Mapping[str, Any]) -> tuple[tuple[str, ...], ...]:
332
+ groups: list[tuple[str, ...]] = []
333
+ raw_groups = lane.get("token_env_any", []) or []
334
+ if not isinstance(raw_groups, list):
335
+ return ()
336
+ for group in raw_groups:
337
+ if not isinstance(group, list):
338
+ continue
339
+ names = tuple(str(name) for name in group if isinstance(name, str) and name)
340
+ if names:
341
+ groups.append(names)
342
+ return tuple(groups)
343
+
344
+
345
+ def required_secret_entries_for_lane(lane: Mapping[str, Any]) -> tuple[str, ...]:
346
+ secrets: list[str] = []
347
+ seen: set[str] = set()
348
+
349
+ def add(secret: str) -> None:
350
+ if not secret or secret == "GITHUB_TOKEN" or secret in seen:
351
+ return
352
+ seen.add(secret)
353
+ secrets.append(secret)
354
+
355
+ for token in lane.get("token_env", []) or []:
356
+ if isinstance(token, str):
357
+ add(token)
358
+ for group in _token_env_any_groups(lane):
359
+ alternatives = [token for token in group if token != "GITHUB_TOKEN"]
360
+ if alternatives:
361
+ add(" or ".join(alternatives))
362
+ review_hygiene = lane.get("review_hygiene")
363
+ if isinstance(review_hygiene, Mapping):
364
+ token = review_hygiene.get("token_env")
365
+ if isinstance(token, str):
366
+ add(token)
367
+ return tuple(secrets)
368
+
369
+
370
+ def validate_config(config: Mapping[str, Any]) -> list[ConfigIssue]:
371
+ issues: list[ConfigIssue] = []
372
+ if config.get("version") not in {1, "1"}:
373
+ issues.append(ConfigIssue("version", "must be 1"))
374
+
375
+ project = _as_mapping(config.get("project"), "project", issues)
376
+ _require_string(project.get("name"), "project.name", issues)
377
+ _require_string(project.get("state_dir"), "project.state_dir", issues)
378
+
379
+ seen_repos: set[str] = set()
380
+ for index, repo in enumerate(_as_sequence(config.get("repositories"), "repositories", issues)):
381
+ path = f"repositories[{index}]"
382
+ repo_map = _as_mapping(repo, path, issues)
383
+ slug = _require_string(repo_map.get("slug"), f"{path}.slug", issues)
384
+ _require_string(repo_map.get("default_branch"), f"{path}.default_branch", issues)
385
+ if slug and slug in seen_repos:
386
+ issues.append(ConfigIssue(f"{path}.slug", f"duplicate repository {slug}"))
387
+ if slug:
388
+ seen_repos.add(slug)
389
+
390
+ lanes = _as_mapping(config.get("lanes"), "lanes", issues)
391
+ all_labels: dict[str, str] = {}
392
+ for lane_id, lane in lanes.items():
393
+ path = f"lanes.{lane_id}"
394
+ _require_identifier(lane_id, path, issues)
395
+ lane_map = _as_mapping(lane, path, issues)
396
+ lane_type = lane_map.get("type")
397
+ driver = lane_map.get("driver")
398
+ if lane_type not in ALLOWED_LANE_TYPES:
399
+ issues.append(ConfigIssue(f"{path}.type", f"must be one of {sorted(ALLOWED_LANE_TYPES)}"))
400
+ if driver not in ALLOWED_DRIVERS:
401
+ issues.append(ConfigIssue(f"{path}.driver", f"must be one of {sorted(ALLOWED_DRIVERS)}"))
402
+ _require_string(lane_map.get("provider"), f"{path}.provider", issues)
403
+ if lane_map.get("trailer_lane") is not None:
404
+ _require_identifier(lane_map.get("trailer_lane"), f"{path}.trailer_lane", issues)
405
+ if lane_map.get("lane_config") is not None:
406
+ _require_identifier(lane_map.get("lane_config"), f"{path}.lane_config", issues)
407
+
408
+ labels = _as_mapping(lane_map.get("labels"), f"{path}.labels", issues)
409
+ for label_key in ("needs", "done", "blocked"):
410
+ label = _require_string(labels.get(label_key), f"{path}.labels.{label_key}", issues)
411
+ if not label:
412
+ continue
413
+ owner = all_labels.setdefault(label, f"{lane_id}.{label_key}")
414
+ if owner != f"{lane_id}.{label_key}":
415
+ issues.append(ConfigIssue(f"{path}.labels.{label_key}", f"duplicates {owner}"))
416
+
417
+ if driver == "saas_event":
418
+ _require_identifier(lane_map.get("adapter"), f"{path}.adapter", issues)
419
+ events = lane_map.get("events")
420
+ if not events:
421
+ issues.append(ConfigIssue(f"{path}.events", "saas_event lanes require events"))
422
+ else:
423
+ for event in _as_sequence(events, f"{path}.events", issues):
424
+ if event not in ALLOWED_EVENTS:
425
+ issues.append(ConfigIssue(f"{path}.events", f"unsupported event {event!r}"))
426
+
427
+ if lane_map.get("informational") and lane_map.get("merge_authority"):
428
+ issues.append(
429
+ ConfigIssue(path, "informational lanes must not also have merge_authority")
430
+ )
431
+
432
+ spend_policy = lane_map.get("spend_policy")
433
+ if spend_policy is not None and (
434
+ not isinstance(spend_policy, str)
435
+ or spend_policy not in ALLOWED_SPEND_POLICIES
436
+ ):
437
+ issues.append(
438
+ ConfigIssue(
439
+ f"{path}.spend_policy",
440
+ f"must be one of {sorted(ALLOWED_SPEND_POLICIES)}",
441
+ )
442
+ )
443
+
444
+ token_env = lane_map.get("token_env")
445
+ if token_env is not None:
446
+ for index, token_name in enumerate(
447
+ _as_sequence(token_env, f"{path}.token_env", issues)
448
+ ):
449
+ if not isinstance(token_name, str) or not token_name:
450
+ issues.append(
451
+ ConfigIssue(
452
+ f"{path}.token_env[{index}]",
453
+ "must be a non-empty string",
454
+ )
455
+ )
456
+
457
+ token_env_any = lane_map.get("token_env_any")
458
+ if token_env_any is not None:
459
+ for group_index, token_group in enumerate(
460
+ _as_sequence(token_env_any, f"{path}.token_env_any", issues)
461
+ ):
462
+ group_path = f"{path}.token_env_any[{group_index}]"
463
+ group_values = _as_sequence(token_group, group_path, issues)
464
+ if not group_values:
465
+ issues.append(ConfigIssue(group_path, "must contain at least one token name"))
466
+ continue
467
+ for token_index, token_name in enumerate(group_values):
468
+ if not isinstance(token_name, str) or not token_name:
469
+ issues.append(
470
+ ConfigIssue(
471
+ f"{group_path}[{token_index}]",
472
+ "must be a non-empty string",
473
+ )
474
+ )
475
+
476
+ review_hygiene = lane_map.get("review_hygiene")
477
+ if review_hygiene is not None:
478
+ hygiene_map = _as_mapping(review_hygiene, f"{path}.review_hygiene", issues)
479
+ if hygiene_map:
480
+ _require_workflow_path(
481
+ hygiene_map.get("workflow"),
482
+ f"{path}.review_hygiene.workflow",
483
+ issues,
484
+ )
485
+ token = hygiene_map.get("token_env")
486
+ if token is not None:
487
+ _require_string(token, f"{path}.review_hygiene.token_env", issues)
488
+
489
+ profiles = _as_mapping(config.get("profiles", {}), "profiles", issues)
490
+ for profile_id, profile in profiles.items():
491
+ path = f"profiles.{profile_id}"
492
+ profile_map = _as_mapping(profile, path, issues)
493
+ _require_string(profile_map.get("description"), f"{path}.description", issues)
494
+ for index, lane_id in enumerate(
495
+ _as_sequence(profile_map.get("lanes"), f"{path}.lanes", issues)
496
+ ):
497
+ if not isinstance(lane_id, str) or not lane_id:
498
+ issues.append(
499
+ ConfigIssue(f"{path}.lanes[{index}]", "must be a non-empty string")
500
+ )
501
+ continue
502
+ if lane_id not in lanes:
503
+ issues.append(ConfigIssue(f"{path}.lanes", f"unknown lane {lane_id!r}"))
504
+
505
+ return issues
506
+
507
+
508
+ def _lane_flags(lane: Mapping[str, Any]) -> str:
509
+ flags = []
510
+ if lane.get("merge_authority"):
511
+ flags.append("merge-authority")
512
+ if lane.get("informational"):
513
+ flags.append("informational")
514
+ if lane.get("enabled_by_default") is False:
515
+ flags.append("opt-in")
516
+ trigger_policy = lane.get("trigger_policy")
517
+ if trigger_policy and trigger_policy != "label":
518
+ flags.append(f"trigger={trigger_policy}")
519
+ return ", ".join(flags) if flags else "standard"
520
+
521
+
522
+ def _labels_for(lane: Mapping[str, Any]) -> Mapping[str, str]:
523
+ labels = lane.get("labels")
524
+ return labels if isinstance(labels, Mapping) else {}
525
+
526
+
527
+ def render_dry_run(config: Mapping[str, Any]) -> RenderedPlan:
528
+ issues = validate_config(config)
529
+ if issues:
530
+ issue_text = "; ".join(f"{issue.path}: {issue.message}" for issue in issues)
531
+ raise ConfigError(f"invalid Code Mower config: {issue_text}")
532
+
533
+ project = config["project"]
534
+ repositories = config["repositories"]
535
+ lanes: Mapping[str, Mapping[str, Any]] = config["lanes"]
536
+ profiles: Mapping[str, Mapping[str, Any]] = config.get("profiles", {})
537
+
538
+ labels: list[str] = []
539
+ workflows: list[dict[str, str]] = []
540
+ secrets: set[str] = set()
541
+ for lane_id, lane in lanes.items():
542
+ lane_labels = _labels_for(lane)
543
+ labels.extend(str(lane_labels[key]) for key in ("needs", "done", "blocked"))
544
+ secrets.update(required_secret_entries_for_lane(lane))
545
+ workflow_kind = GENERATED_WORKFLOW_BY_DRIVER.get(str(lane["driver"]), "custom")
546
+ workflows.append({"lane": str(lane_id), "kind": workflow_kind})
547
+
548
+ plan = {
549
+ "project": project["name"],
550
+ "state_dir": project["state_dir"],
551
+ "repositories": repositories,
552
+ "lanes": {
553
+ lane_id: {
554
+ "driver": lane["driver"],
555
+ "provider": lane["provider"],
556
+ "labels": _labels_for(lane),
557
+ "flags": _lane_flags(lane),
558
+ }
559
+ for lane_id, lane in lanes.items()
560
+ },
561
+ "labels": sorted(set(labels)),
562
+ "required_secrets": sorted(secrets),
563
+ "workflows": workflows,
564
+ "profiles": {
565
+ profile_id: {
566
+ "description": profile.get("description", ""),
567
+ "lanes": profile.get("lanes", []),
568
+ }
569
+ for profile_id, profile in profiles.items()
570
+ },
571
+ }
572
+
573
+ lines = [
574
+ "Code Mower dry-run plan",
575
+ f"Project: {project['name']}",
576
+ f"State dir: {project['state_dir']}",
577
+ "",
578
+ "Repositories:",
579
+ ]
580
+ for repo in repositories:
581
+ lines.append(
582
+ f"- {repo['slug']} (default={repo['default_branch']}, local_path_env={repo.get('local_path_env', 'n/a')})"
583
+ )
584
+
585
+ lines.extend(["", "Lanes:"])
586
+ for lane_id, lane in lanes.items():
587
+ lane_labels = _labels_for(lane)
588
+ lines.append(
589
+ f"- {lane_id}: {lane['type']}/{lane['driver']} provider={lane['provider']} [{_lane_flags(lane)}]"
590
+ )
591
+ lines.append(
592
+ f" labels: {lane_labels['needs']} -> {lane_labels['done']} / {lane_labels['blocked']}"
593
+ )
594
+ if lane.get("events"):
595
+ lines.append(f" events: {', '.join(lane['events'])}")
596
+ if lane.get("adapter"):
597
+ lines.append(f" adapter: {lane['adapter']}")
598
+
599
+ lines.extend(["", "Labels to ensure:"])
600
+ lines.extend(f"- {label}" for label in plan["labels"])
601
+
602
+ lines.extend(["", "Workflow/render targets:"])
603
+ for workflow in workflows:
604
+ lines.append(f"- {workflow['lane']}: {workflow['kind']}")
605
+
606
+ lines.extend(["", "Required secrets/PAT fallbacks:"])
607
+ if plan["required_secrets"]:
608
+ lines.extend(f"- {secret}" for secret in plan["required_secrets"])
609
+ else:
610
+ lines.append("- none beyond GITHUB_TOKEN")
611
+
612
+ if profiles:
613
+ lines.extend(["", "Setup profiles:"])
614
+ for profile_id, profile in profiles.items():
615
+ lane_list = ", ".join(profile.get("lanes", []))
616
+ lines.append(f"- {profile_id}: {lane_list}")
617
+ lines.append(f" {profile.get('description', '')}")
618
+
619
+ return RenderedPlan(text="\n".join(lines) + "\n", data=plan)
620
+
621
+
622
+ def _format_issues(issues: Iterable[ConfigIssue]) -> str:
623
+ return "\n".join(f"- {issue.path}: {issue.message}" for issue in issues)
624
+
625
+
626
+ def main(argv: list[str] | None = None) -> int:
627
+ parser = argparse.ArgumentParser(description=__doc__)
628
+ parser.add_argument("config", nargs="?", default="code-mower.example.yml")
629
+ parser.add_argument("--json", action="store_true", help="emit dry-run plan as JSON")
630
+ parser.add_argument("--validate-only", action="store_true", help="only validate the config")
631
+ args = parser.parse_args(argv)
632
+
633
+ try:
634
+ config = load_config(Path(args.config))
635
+ issues = validate_config(config)
636
+ if issues:
637
+ print(_format_issues(issues), file=sys.stderr)
638
+ return 1
639
+ if args.validate_only:
640
+ print("Code Mower config OK")
641
+ return 0
642
+ plan = render_dry_run(config)
643
+ except ConfigError as exc:
644
+ print(f"error: {exc}", file=sys.stderr)
645
+ return 1
646
+
647
+ if args.json:
648
+ print(json.dumps(plan.data, indent=2, sort_keys=True))
649
+ else:
650
+ print(plan.text, end="")
651
+ return 0
652
+
653
+
654
+ if __name__ == "__main__":
655
+ raise SystemExit(main())