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.
- code_mower/__init__.py +3 -0
- code_mower/adapters/__init__.py +39 -0
- code_mower/adapters/_base.py +148 -0
- code_mower/adapters/cursor_bugbot.py +97 -0
- code_mower/adapters/gitar.py +111 -0
- code_mower/adapters/greptile.py +191 -0
- code_mower/adapters/qodo.py +140 -0
- code_mower/antigravity_cli_audit_pr.py +241 -0
- code_mower/audit_handoff_log.py +345 -0
- code_mower/audit_labeler_lib.py +311 -0
- code_mower/audit_progress.py +203 -0
- code_mower/blind_review_artifacts.py +562 -0
- code_mower/blind_review_coordinator.py +276 -0
- code_mower/bootstrap.py +524 -0
- code_mower/builder_experiment.py +539 -0
- code_mower/calibration/__init__.py +181 -0
- code_mower/calibration/arms.py +267 -0
- code_mower/calibration/auto_discovery.py +348 -0
- code_mower/calibration/commands.py +192 -0
- code_mower/calibration/context_inputs.py +199 -0
- code_mower/calibration/corpus.py +91 -0
- code_mower/calibration/evidence.py +20 -0
- code_mower/calibration/evidence_report.py +360 -0
- code_mower/calibration/identity.py +27 -0
- code_mower/calibration/metrics.py +18 -0
- code_mower/calibration/overlap.py +103 -0
- code_mower/calibration/planning.py +309 -0
- code_mower/calibration/policy.py +207 -0
- code_mower/calibration/results.py +315 -0
- code_mower/calibration/run_results.py +147 -0
- code_mower/calibration/run_status.py +64 -0
- code_mower/calibration/runner.py +360 -0
- code_mower/calibration/truth.py +188 -0
- code_mower/calibration/value_report.py +142 -0
- code_mower/checks.py +402 -0
- code_mower/claude_audit_pr.py +1126 -0
- code_mower/claude_cli_bounce.py +303 -0
- code_mower/claude_cli_environment.py +73 -0
- code_mower/clear_stale.py +374 -0
- code_mower/cli.py +537 -0
- code_mower/cloud.py +674 -0
- code_mower/cloud_client/__init__.py +173 -0
- code_mower/cloud_client/bundle.py +155 -0
- code_mower/cloud_client/doctor.py +206 -0
- code_mower/cloud_client/dogfood.py +78 -0
- code_mower/cloud_client/endpoints.py +114 -0
- code_mower/cloud_client/errors.py +7 -0
- code_mower/cloud_client/events.py +278 -0
- code_mower/cloud_client/export.py +272 -0
- code_mower/cloud_client/git_metadata.py +46 -0
- code_mower/cloud_client/manifest.py +39 -0
- code_mower/cloud_client/operations.py +448 -0
- code_mower/cloud_client/reports.py +45 -0
- code_mower/cloud_client/setup.py +205 -0
- code_mower/cloud_client/upload.py +97 -0
- code_mower/code_mower_calibration.py +598 -0
- code_mower/code_mower_context_packs.py +591 -0
- code_mower/code_mower_merge.py +227 -0
- code_mower/code_mower_telemetry.py +561 -0
- code_mower/coderabbit_cli_audit_pr.py +526 -0
- code_mower/codex_audit_env_preflight.py +220 -0
- code_mower/codex_audit_pr.py +1738 -0
- code_mower/codex_audit_schema_smoke.py +160 -0
- code_mower/codex_audit_verdict.schema.json +44 -0
- code_mower/config.py +655 -0
- code_mower/doctor.py +161 -0
- code_mower/doctor_checks/__init__.py +104 -0
- code_mower/doctor_checks/cloud.py +129 -0
- code_mower/doctor_checks/common.py +215 -0
- code_mower/doctor_checks/github.py +128 -0
- code_mower/doctor_checks/github_actions.py +11 -0
- code_mower/doctor_checks/github_actions_cost.py +99 -0
- code_mower/doctor_checks/github_actions_cost_summary.py +111 -0
- code_mower/doctor_checks/github_actions_failure_annotations.py +27 -0
- code_mower/doctor_checks/github_actions_failure_models.py +47 -0
- code_mower/doctor_checks/github_actions_failure_scan.py +200 -0
- code_mower/doctor_checks/github_actions_failure_selection.py +63 -0
- code_mower/doctor_checks/github_actions_failures.py +103 -0
- code_mower/doctor_checks/github_actions_permissions.py +55 -0
- code_mower/doctor_checks/github_api.py +79 -0
- code_mower/doctor_checks/github_branch.py +56 -0
- code_mower/doctor_checks/github_config.py +25 -0
- code_mower/doctor_checks/github_provider.py +61 -0
- code_mower/doctor_checks/github_repo.py +120 -0
- code_mower/doctor_checks/groups.py +36 -0
- code_mower/doctor_checks/models.py +96 -0
- code_mower/doctor_checks/output.py +86 -0
- code_mower/doctor_checks/presets.py +64 -0
- code_mower/doctor_checks/privacy.py +20 -0
- code_mower/doctor_checks/provider_api_model.py +138 -0
- code_mower/doctor_checks/provider_api_model_openai.py +29 -0
- code_mower/doctor_checks/provider_api_model_profiles.py +137 -0
- code_mower/doctor_checks/provider_env.py +113 -0
- code_mower/doctor_checks/provider_env_required.py +56 -0
- code_mower/doctor_checks/provider_env_tokens.py +100 -0
- code_mower/doctor_checks/provider_local_cli.py +162 -0
- code_mower/doctor_checks/provider_local_cli_commands.py +47 -0
- code_mower/doctor_checks/provider_local_cli_probe_config.py +70 -0
- code_mower/doctor_checks/provider_probe.py +20 -0
- code_mower/doctor_checks/provider_probe_auth.py +52 -0
- code_mower/doctor_checks/provider_probe_evaluation.py +109 -0
- code_mower/doctor_checks/provider_probe_json.py +45 -0
- code_mower/doctor_checks/provider_probe_remediation.py +39 -0
- code_mower/doctor_checks/providers.py +159 -0
- code_mower/doctor_checks/registry.py +69 -0
- code_mower/doctor_checks/runner.py +188 -0
- code_mower/doctor_checks/runtime.py +89 -0
- code_mower/doctor_checks/runtime_github_auth.py +148 -0
- code_mower/gemini_cli_audit_pr.py +897 -0
- code_mower/hermes_cli_audit_pr.py +436 -0
- code_mower/init.py +888 -0
- code_mower/lane_configs/__init__.py +37 -0
- code_mower/lane_configs/aider.py +32 -0
- code_mower/lane_configs/antigravity_cli.py +35 -0
- code_mower/lane_configs/claude.py +35 -0
- code_mower/lane_configs/codex.py +32 -0
- code_mower/lane_configs/devin.py +33 -0
- code_mower/lane_configs/gemini_cli.py +35 -0
- code_mower/lane_configs/hermes_cli.py +35 -0
- code_mower/lane_configs/local_llm.py +31 -0
- code_mower/local_llm_audit_pr.py +1364 -0
- code_mower/local_llm_bakeoff.py +458 -0
- code_mower/local_llm_calibration.py +441 -0
- code_mower/local_llm_profiles.py +66 -0
- code_mower/migration.py +508 -0
- code_mower/migration_install.py +292 -0
- code_mower/migration_mirror.py +392 -0
- code_mower/migration_readiness.py +237 -0
- code_mower/migration_rehearsal.py +718 -0
- code_mower/next_steps.py +441 -0
- code_mower/package.py +673 -0
- code_mower/package_content.py +444 -0
- code_mower/package_manifest.py +452 -0
- code_mower/package_paths.py +53 -0
- code_mower/package_rendering.py +90 -0
- code_mower/package_static.py +585 -0
- code_mower/prompts.py +267 -0
- code_mower/provider_registry.py +469 -0
- code_mower/provider_runners/__init__.py +60 -0
- code_mower/provider_runners/comments.py +31 -0
- code_mower/provider_runners/git.py +46 -0
- code_mower/provider_runners/github_auth.py +61 -0
- code_mower/provider_runners/github_pr.py +120 -0
- code_mower/provider_runners/process.py +58 -0
- code_mower/provider_runners/repo_paths.py +23 -0
- code_mower/provider_runners/text_schema.py +41 -0
- code_mower/provider_runners/verdict_artifacts.py +103 -0
- code_mower/provider_runners/workspace.py +57 -0
- code_mower/release_readiness.py +549 -0
- code_mower/reviewer_metrics.py +389 -0
- code_mower/saas_reviewer_labeler.py +809 -0
- code_mower/secrets.py +89 -0
- code_mower/templates/builder-experiment.example.json +55 -0
- code_mower/templates/calibration-corpus.example.json +129 -0
- code_mower/templates/calibration-corpus.json +129 -0
- code_mower/templates/code-mower.example.yml +423 -0
- code_mower/templates/context-packs.example.json +150 -0
- code_mower/templates/lane_prompts/base-audit.md +22 -0
- code_mower/templates/lane_prompts/calibration-policy.md +21 -0
- code_mower/templates/lane_prompts/context-driven-quality.md +21 -0
- code_mower/templates/lane_prompts/docs-design.md +12 -0
- code_mower/templates/lane_prompts/generic-programming.md +21 -0
- code_mower/templates/lane_prompts/operability.md +22 -0
- code_mower/templates/lane_prompts/package-runtime.md +12 -0
- code_mower/templates/lane_prompts/security-threat-model.md +22 -0
- code_mower/templates/product-support/code_mower +216 -0
- code_mower/templates/product-support/code_mower_standalone_pin.env +7 -0
- code_mower/templates/product-support/code_mower_standalone_shadow.sh +151 -0
- code_mower/templates/product-support/run_claude_audit_pr.sh +32 -0
- code_mower/templates/product-support/run_codex_audit_pr.sh +32 -0
- code_mower/templates/product-support/safe_gh_comment.py +96 -0
- code_mower/templates/providers.yml +454 -0
- code_mower/templates/reviewer-spend.example.json +28 -0
- code_mower/templates/reviewer-value-report.example.md +20 -0
- code_mower/templates/workflows/private-standalone-shadow.yml.j2 +106 -0
- code_mower/templates/workflows/review-clear-stale.yml.j2 +83 -0
- code_mower/trailer_comment_labeler.py +207 -0
- code_mower/versioning.py +32 -0
- code_mower-0.5.0b5.dist-info/METADATA +302 -0
- code_mower-0.5.0b5.dist-info/RECORD +185 -0
- code_mower-0.5.0b5.dist-info/WHEEL +5 -0
- code_mower-0.5.0b5.dist-info/entry_points.txt +2 -0
- code_mower-0.5.0b5.dist-info/licenses/LICENSE +202 -0
- code_mower-0.5.0b5.dist-info/licenses/NOTICE +10 -0
- 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())
|