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/init.py
ADDED
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render a non-mutating Code Mower init plan for a setup profile."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import shlex
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Mapping
|
|
15
|
+
|
|
16
|
+
if __package__ in {None, ""}:
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
18
|
+
|
|
19
|
+
if __package__ in {None, "", "tools"}:
|
|
20
|
+
from tools import code_mower_secrets
|
|
21
|
+
from tools.code_mower_config import (
|
|
22
|
+
ConfigError,
|
|
23
|
+
RenderedPlan,
|
|
24
|
+
_format_issues,
|
|
25
|
+
_labels_for,
|
|
26
|
+
load_config,
|
|
27
|
+
required_secret_entries_for_lane,
|
|
28
|
+
validate_config,
|
|
29
|
+
)
|
|
30
|
+
else: # pragma: no cover - exercised after package extraction.
|
|
31
|
+
from . import secrets as code_mower_secrets
|
|
32
|
+
from .config import (
|
|
33
|
+
ConfigError,
|
|
34
|
+
RenderedPlan,
|
|
35
|
+
_format_issues,
|
|
36
|
+
_labels_for,
|
|
37
|
+
load_config,
|
|
38
|
+
required_secret_entries_for_lane,
|
|
39
|
+
validate_config,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
WORKFLOW_TARGETS_BY_DRIVER = {
|
|
44
|
+
"api_model": (
|
|
45
|
+
".github/workflows/{lane_stem}.yml",
|
|
46
|
+
".github/workflows/{lane_stem}-labeler.yml",
|
|
47
|
+
),
|
|
48
|
+
"hosted_bridge": (
|
|
49
|
+
".github/workflows/{lane_stem}-bridge.yml",
|
|
50
|
+
".github/workflows/{lane_stem}-labeler.yml",
|
|
51
|
+
),
|
|
52
|
+
"local_cli": (".github/workflows/{lane_stem}-labeler.yml",),
|
|
53
|
+
"manual": (),
|
|
54
|
+
"saas_event": (".github/workflows/{lane_stem}-labeler.yml",),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
DEFAULT_APPLY_OUTPUT_DIR = ".code-mower.generated"
|
|
58
|
+
APPLY_MANIFEST_FILENAME = "code-mower-init-plan.json"
|
|
59
|
+
APPLY_SUMMARY_FILES = ("labels.txt", "required-secrets.txt", "smoke-tests.sh")
|
|
60
|
+
REFERENCE_PYTHON = ".code-mower-venv/bin/python"
|
|
61
|
+
GEMINI_AUTH_FILE_ENV = "GEMINI_API_KEY_FILE"
|
|
62
|
+
GEMINI_AUTH_ENV_NAMES = ("GEMINI_API_KEY", "GOOGLE_API_KEY")
|
|
63
|
+
STARTER_DATA_FILES = (
|
|
64
|
+
(
|
|
65
|
+
"calibration-corpus.json",
|
|
66
|
+
"tools/calibration_corpus.example.json",
|
|
67
|
+
"templates/calibration-corpus.example.json",
|
|
68
|
+
"starter-calibration-corpus",
|
|
69
|
+
),
|
|
70
|
+
(
|
|
71
|
+
"context-packs.json",
|
|
72
|
+
"tools/context_packs.example.json",
|
|
73
|
+
"templates/context-packs.example.json",
|
|
74
|
+
"starter-context-packs",
|
|
75
|
+
),
|
|
76
|
+
(
|
|
77
|
+
"reviewer-spend.json",
|
|
78
|
+
"tools/reviewer_spend.example.json",
|
|
79
|
+
"templates/reviewer-spend.example.json",
|
|
80
|
+
"starter-reviewer-spend",
|
|
81
|
+
),
|
|
82
|
+
(
|
|
83
|
+
"reviewer-value-report.example.md",
|
|
84
|
+
"tools/reviewer_value_report.example.md",
|
|
85
|
+
"templates/reviewer-value-report.example.md",
|
|
86
|
+
"starter-reviewer-value-report",
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
PRODUCT_SUPPORT_FILES = (
|
|
91
|
+
(
|
|
92
|
+
"tools/code_mower",
|
|
93
|
+
"templates/product-support/code_mower",
|
|
94
|
+
"product-support-wrapper",
|
|
95
|
+
"0755",
|
|
96
|
+
),
|
|
97
|
+
(
|
|
98
|
+
"tools/code_mower_standalone_shadow.sh",
|
|
99
|
+
"templates/product-support/code_mower_standalone_shadow.sh",
|
|
100
|
+
"product-support-wrapper",
|
|
101
|
+
"0755",
|
|
102
|
+
),
|
|
103
|
+
(
|
|
104
|
+
"tools/code_mower_standalone_pin.env",
|
|
105
|
+
"templates/product-support/code_mower_standalone_pin.env",
|
|
106
|
+
"product-support-config",
|
|
107
|
+
"0644",
|
|
108
|
+
),
|
|
109
|
+
(
|
|
110
|
+
"tools/run_codex_audit_pr.sh",
|
|
111
|
+
"templates/product-support/run_codex_audit_pr.sh",
|
|
112
|
+
"product-support-wrapper",
|
|
113
|
+
"0755",
|
|
114
|
+
),
|
|
115
|
+
(
|
|
116
|
+
"tools/run_claude_audit_pr.sh",
|
|
117
|
+
"templates/product-support/run_claude_audit_pr.sh",
|
|
118
|
+
"product-support-wrapper",
|
|
119
|
+
"0755",
|
|
120
|
+
),
|
|
121
|
+
(
|
|
122
|
+
"tools/safe_gh_comment.py",
|
|
123
|
+
"templates/product-support/safe_gh_comment.py",
|
|
124
|
+
"product-support-helper",
|
|
125
|
+
"0755",
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class InitProfile:
|
|
132
|
+
profile_id: str
|
|
133
|
+
description: str
|
|
134
|
+
lanes: tuple[str, ...]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _profile(config: Mapping[str, Any], profile_id: str) -> InitProfile:
|
|
138
|
+
profiles = config.get("profiles")
|
|
139
|
+
if not isinstance(profiles, Mapping) or profile_id not in profiles:
|
|
140
|
+
available = ", ".join(sorted(profiles)) if isinstance(profiles, Mapping) else "none"
|
|
141
|
+
raise ConfigError(f"unknown profile {profile_id!r}; available profiles: {available}")
|
|
142
|
+
profile = profiles[profile_id]
|
|
143
|
+
if not isinstance(profile, Mapping):
|
|
144
|
+
raise ConfigError(f"profile {profile_id!r} must be a mapping")
|
|
145
|
+
lanes = profile.get("lanes", [])
|
|
146
|
+
if not isinstance(lanes, list):
|
|
147
|
+
raise ConfigError(f"profile {profile_id!r} lanes must be a list")
|
|
148
|
+
return InitProfile(
|
|
149
|
+
profile_id=profile_id,
|
|
150
|
+
description=str(profile.get("description", "")),
|
|
151
|
+
lanes=tuple(str(lane) for lane in lanes),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _workflow_targets(lane_id: str, lane: Mapping[str, Any]) -> tuple[str, ...]:
|
|
156
|
+
templates = WORKFLOW_TARGETS_BY_DRIVER.get(str(lane.get("driver")), ())
|
|
157
|
+
normalized = lane_id.replace("_", "-")
|
|
158
|
+
lane_type = str(lane.get("type"))
|
|
159
|
+
suffix = "-review" if lane_type == "review" else "-audit"
|
|
160
|
+
lane_stem = normalized if normalized.endswith(suffix) else f"{normalized}{suffix}"
|
|
161
|
+
return tuple(template.format(lane_stem=lane_stem) for template in templates)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _trailer_lane_name(lane_id: str, lane: Mapping[str, Any]) -> str:
|
|
165
|
+
return str(lane.get("trailer_lane") or lane.get("lane_config") or lane_id)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _lane_module_name(lane_id: str) -> str:
|
|
169
|
+
return lane_id.replace("-", "_")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _running_as_package() -> bool:
|
|
173
|
+
return bool(__package__ and __package__ != "tools")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _default_package_command() -> str:
|
|
177
|
+
command = sys.argv[0] or "code-mower"
|
|
178
|
+
name = Path(command).name
|
|
179
|
+
if name.endswith(".py"):
|
|
180
|
+
return f"{shlex.quote(sys.executable)} -m code_mower.cli"
|
|
181
|
+
python_suffix = name.removeprefix("python")
|
|
182
|
+
is_python_launcher = name == "python" or (
|
|
183
|
+
name.startswith("python")
|
|
184
|
+
and bool(python_suffix)
|
|
185
|
+
and python_suffix.replace(".", "").isdigit()
|
|
186
|
+
)
|
|
187
|
+
if is_python_launcher or name in {
|
|
188
|
+
"",
|
|
189
|
+
"-c",
|
|
190
|
+
"__main__.py",
|
|
191
|
+
"pytest",
|
|
192
|
+
"py.test",
|
|
193
|
+
}:
|
|
194
|
+
command = "code-mower"
|
|
195
|
+
return shlex.quote(command)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _resolve_config_path(config_arg: str) -> Path:
|
|
199
|
+
path = Path(config_arg)
|
|
200
|
+
if path.is_file() or config_arg != "code-mower.example.yml":
|
|
201
|
+
return path
|
|
202
|
+
|
|
203
|
+
script_path = Path(__file__).resolve()
|
|
204
|
+
candidates = []
|
|
205
|
+
if _running_as_package():
|
|
206
|
+
candidates.append(script_path.parent / "templates" / "code-mower.example.yml")
|
|
207
|
+
else:
|
|
208
|
+
candidates.append(script_path.parents[1] / "code-mower.example.yml")
|
|
209
|
+
|
|
210
|
+
for candidate in candidates:
|
|
211
|
+
if candidate.is_file():
|
|
212
|
+
return candidate
|
|
213
|
+
return path
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _lane_smoke_tests(
|
|
217
|
+
lane_id: str,
|
|
218
|
+
lane: Mapping[str, Any],
|
|
219
|
+
*,
|
|
220
|
+
package_mode: bool,
|
|
221
|
+
) -> tuple[str, ...]:
|
|
222
|
+
driver = lane.get("driver")
|
|
223
|
+
if package_mode and driver in {"local_cli", "hosted_bridge", "api_model", "manual", "saas_event"}:
|
|
224
|
+
return ()
|
|
225
|
+
if driver in {"local_cli", "hosted_bridge", "api_model"}:
|
|
226
|
+
trailer_lane = json.dumps(_trailer_lane_name(lane_id, lane))
|
|
227
|
+
code = f"from tools.lane_configs import load_lane_config; load_lane_config({trailer_lane})"
|
|
228
|
+
return (
|
|
229
|
+
f"{REFERENCE_PYTHON} -c {shlex.quote(code)}",
|
|
230
|
+
)
|
|
231
|
+
if driver == "saas_event":
|
|
232
|
+
adapter = json.dumps(str(lane.get("adapter")))
|
|
233
|
+
code = f"from tools.adapters import load_adapter; load_adapter({adapter})"
|
|
234
|
+
return (
|
|
235
|
+
f"{REFERENCE_PYTHON} -c {shlex.quote(code)}",
|
|
236
|
+
)
|
|
237
|
+
if driver == "manual":
|
|
238
|
+
return ("bash -n tools/post_review.sh",)
|
|
239
|
+
return ()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _lane_warnings(
|
|
243
|
+
lane_id: str,
|
|
244
|
+
lane: Mapping[str, Any],
|
|
245
|
+
*,
|
|
246
|
+
package_mode: bool,
|
|
247
|
+
) -> tuple[str, ...]:
|
|
248
|
+
warnings: list[str] = []
|
|
249
|
+
if lane.get("spend_policy") == "paid":
|
|
250
|
+
warnings.append(f"{lane_id}: paid lane; init dry-run will not trigger a review")
|
|
251
|
+
if lane.get("enabled_by_default") is False:
|
|
252
|
+
warnings.append(f"{lane_id}: opt-in lane selected by profile")
|
|
253
|
+
if lane.get("trigger_policy") == "manual":
|
|
254
|
+
warnings.append(f"{lane_id}: manual trigger policy; installer must not auto-dispatch")
|
|
255
|
+
if package_mode:
|
|
256
|
+
driver = lane.get("driver")
|
|
257
|
+
if driver in {"local_cli", "hosted_bridge", "api_model"}:
|
|
258
|
+
warnings.append(
|
|
259
|
+
f"{lane_id}: lane-config smoke deferred until package-relative lane configs are extracted"
|
|
260
|
+
)
|
|
261
|
+
elif driver == "saas_event":
|
|
262
|
+
warnings.append(
|
|
263
|
+
f"{lane_id}: adapter smoke deferred until package-relative adapters are extracted"
|
|
264
|
+
)
|
|
265
|
+
elif driver == "manual":
|
|
266
|
+
warnings.append(
|
|
267
|
+
f"{lane_id}: manual review script smoke deferred until repo-local review scripts are installed"
|
|
268
|
+
)
|
|
269
|
+
return tuple(warnings)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _repo_root() -> Path:
|
|
273
|
+
if __package__ and __package__ != "tools":
|
|
274
|
+
return Path.cwd()
|
|
275
|
+
return Path(__file__).resolve().parents[1]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def default_auth_config_dir(home_dir: Path | None = None) -> Path:
|
|
279
|
+
return (home_dir or Path.home()) / ".config" / "code-mower"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def default_gemini_auth_path(home_dir: Path | None = None) -> Path:
|
|
283
|
+
return default_auth_config_dir(home_dir) / "gemini.env"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _shell_export_line(name: str, value: str) -> str:
|
|
287
|
+
return f"export {name}={shlex.quote(value)}"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _parse_gemini_secret_source(text: str) -> str:
|
|
291
|
+
return code_mower_secrets.parse_secret_file_text(
|
|
292
|
+
text,
|
|
293
|
+
supported_env_names=set(GEMINI_AUTH_ENV_NAMES),
|
|
294
|
+
).value
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def write_gemini_auth_file(
|
|
298
|
+
secret_value: str,
|
|
299
|
+
path: Path | None = None,
|
|
300
|
+
*,
|
|
301
|
+
force: bool = False,
|
|
302
|
+
) -> dict[str, Any]:
|
|
303
|
+
value = _parse_gemini_secret_source(secret_value)
|
|
304
|
+
if not value:
|
|
305
|
+
raise ConfigError("Gemini key source was empty or not a supported Gemini key assignment")
|
|
306
|
+
destination = (path or default_gemini_auth_path()).expanduser()
|
|
307
|
+
if destination.is_symlink():
|
|
308
|
+
raise ConfigError(f"{destination} is a symlink; refusing to write secrets through it")
|
|
309
|
+
if destination.exists() and not destination.is_file():
|
|
310
|
+
raise ConfigError(f"{destination} is not a regular file; refusing to write secrets")
|
|
311
|
+
if destination.exists() and not force:
|
|
312
|
+
raise ConfigError(f"{destination} already exists; pass --force to overwrite it")
|
|
313
|
+
parent_existed = destination.parent.exists()
|
|
314
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
if path is None or not parent_existed:
|
|
316
|
+
destination.parent.chmod(0o700)
|
|
317
|
+
if destination.exists():
|
|
318
|
+
destination.chmod(0o600)
|
|
319
|
+
flags = os.O_WRONLY | os.O_CREAT
|
|
320
|
+
flags |= os.O_TRUNC if force else os.O_EXCL
|
|
321
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
322
|
+
flags |= os.O_NOFOLLOW
|
|
323
|
+
try:
|
|
324
|
+
fd = os.open(destination, flags, 0o600)
|
|
325
|
+
except FileExistsError as exc:
|
|
326
|
+
raise ConfigError(f"{destination} already exists; pass --force to overwrite it") from exc
|
|
327
|
+
try:
|
|
328
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
329
|
+
fd = -1
|
|
330
|
+
handle.write(value + "\n")
|
|
331
|
+
finally:
|
|
332
|
+
if fd != -1:
|
|
333
|
+
os.close(fd)
|
|
334
|
+
destination.chmod(0o600)
|
|
335
|
+
return {
|
|
336
|
+
"mode": "auth",
|
|
337
|
+
"provider": "gemini",
|
|
338
|
+
"path": str(destination),
|
|
339
|
+
"file_env": GEMINI_AUTH_FILE_ENV,
|
|
340
|
+
"shell_export": _shell_export_line(GEMINI_AUTH_FILE_ENV, str(destination)),
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _render_gemini_auth_instructions(path: Path | None = None) -> str:
|
|
345
|
+
destination = (path or default_gemini_auth_path()).expanduser()
|
|
346
|
+
return "\n".join(
|
|
347
|
+
[
|
|
348
|
+
"Code Mower Gemini auth setup",
|
|
349
|
+
f"Credential file: {destination}",
|
|
350
|
+
"",
|
|
351
|
+
"Write the key without putting it in shell history:",
|
|
352
|
+
f" printf '%s\\n' \"$GEMINI_API_KEY\" | code-mower init auth gemini --from-stdin --path {shlex.quote(str(destination))}",
|
|
353
|
+
"",
|
|
354
|
+
"Then make the file discoverable:",
|
|
355
|
+
f" {_shell_export_line(GEMINI_AUTH_FILE_ENV, str(destination))}",
|
|
356
|
+
"",
|
|
357
|
+
]
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _auth_main(argv: list[str]) -> int:
|
|
362
|
+
parser = argparse.ArgumentParser(prog="code-mower init auth")
|
|
363
|
+
subparsers = parser.add_subparsers(dest="provider", required=True)
|
|
364
|
+
gemini = subparsers.add_parser("gemini")
|
|
365
|
+
gemini.add_argument(
|
|
366
|
+
"--from-stdin",
|
|
367
|
+
action="store_true",
|
|
368
|
+
help="read the Gemini key or GEMINI_API_KEY assignment from stdin",
|
|
369
|
+
)
|
|
370
|
+
gemini.add_argument(
|
|
371
|
+
"--from-env",
|
|
372
|
+
nargs="?",
|
|
373
|
+
const="GEMINI_API_KEY",
|
|
374
|
+
help="read the key from an environment variable, defaulting to GEMINI_API_KEY",
|
|
375
|
+
)
|
|
376
|
+
gemini.add_argument(
|
|
377
|
+
"--path",
|
|
378
|
+
type=Path,
|
|
379
|
+
default=None,
|
|
380
|
+
help="credential file path, defaulting to ~/.config/code-mower/gemini.env",
|
|
381
|
+
)
|
|
382
|
+
gemini.add_argument("--force", action="store_true", help="overwrite an existing file")
|
|
383
|
+
gemini.add_argument("--print-shell", action="store_true", help="print shell export setup")
|
|
384
|
+
gemini.add_argument("--json", action="store_true")
|
|
385
|
+
args = parser.parse_args(argv)
|
|
386
|
+
|
|
387
|
+
if args.provider != "gemini": # pragma: no cover - argparse guards this.
|
|
388
|
+
raise AssertionError(f"unhandled auth provider: {args.provider}")
|
|
389
|
+
if args.from_stdin and args.from_env:
|
|
390
|
+
print("error: choose either --from-stdin or --from-env", file=sys.stderr)
|
|
391
|
+
return 1
|
|
392
|
+
if not args.from_stdin and not args.from_env:
|
|
393
|
+
text = _render_gemini_auth_instructions(args.path)
|
|
394
|
+
if args.json:
|
|
395
|
+
print(
|
|
396
|
+
json.dumps(
|
|
397
|
+
{
|
|
398
|
+
"mode": "auth",
|
|
399
|
+
"provider": "gemini",
|
|
400
|
+
"path": str((args.path or default_gemini_auth_path()).expanduser()),
|
|
401
|
+
"file_env": GEMINI_AUTH_FILE_ENV,
|
|
402
|
+
"created": False,
|
|
403
|
+
},
|
|
404
|
+
indent=2,
|
|
405
|
+
sort_keys=True,
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
else:
|
|
409
|
+
print(text, end="")
|
|
410
|
+
return 0
|
|
411
|
+
|
|
412
|
+
if args.from_stdin:
|
|
413
|
+
source_text = sys.stdin.read()
|
|
414
|
+
else:
|
|
415
|
+
env_name = str(args.from_env)
|
|
416
|
+
if env_name not in GEMINI_AUTH_ENV_NAMES:
|
|
417
|
+
print(
|
|
418
|
+
"error: --from-env must be GEMINI_API_KEY or GOOGLE_API_KEY",
|
|
419
|
+
file=sys.stderr,
|
|
420
|
+
)
|
|
421
|
+
return 1
|
|
422
|
+
source_text = os.environ.get(env_name, "")
|
|
423
|
+
if not source_text:
|
|
424
|
+
print(f"error: {env_name} is not set", file=sys.stderr)
|
|
425
|
+
return 1
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
result = write_gemini_auth_file(source_text, args.path, force=args.force)
|
|
429
|
+
except ConfigError as exc:
|
|
430
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
431
|
+
return 1
|
|
432
|
+
|
|
433
|
+
if args.json:
|
|
434
|
+
print(json.dumps({**result, "created": True}, indent=2, sort_keys=True))
|
|
435
|
+
else:
|
|
436
|
+
print(f"Code Mower Gemini auth file written: {result['path']}")
|
|
437
|
+
if args.print_shell:
|
|
438
|
+
print(result["shell_export"])
|
|
439
|
+
else:
|
|
440
|
+
print(f"Set {GEMINI_AUTH_FILE_ENV} for future shells:")
|
|
441
|
+
print(f" {result['shell_export']}")
|
|
442
|
+
return 0
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _safe_output_path(output_dir: Path, relative_path: str) -> Path:
|
|
446
|
+
path = Path(relative_path)
|
|
447
|
+
if path.is_absolute() or ".." in path.parts or not path.parts:
|
|
448
|
+
raise ConfigError(f"unsafe generated path: {relative_path}")
|
|
449
|
+
destination = output_dir.joinpath(path)
|
|
450
|
+
try:
|
|
451
|
+
destination.resolve().relative_to(output_dir.resolve())
|
|
452
|
+
except ValueError as exc:
|
|
453
|
+
raise ConfigError(f"generated path escapes output directory: {relative_path}") from exc
|
|
454
|
+
return destination
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _placeholder_text(path: str, source: str) -> str:
|
|
458
|
+
return (
|
|
459
|
+
f"# Code Mower generated placeholder for {path}\n"
|
|
460
|
+
f"# Source template: {source}\n"
|
|
461
|
+
"# This reference apply mode writes placeholders only when the source\n"
|
|
462
|
+
"# file is not present in the checkout. The standalone package will\n"
|
|
463
|
+
"# render this file from bundled templates.\n"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _render_stale_workflow_template(text: str, *, lane: str) -> str:
|
|
468
|
+
"""Render the shared stale-label workflow without requiring Jinja at runtime."""
|
|
469
|
+
|
|
470
|
+
return (
|
|
471
|
+
text.replace("{% raw %}", "")
|
|
472
|
+
.replace("{% endraw %}", "")
|
|
473
|
+
.replace('default: "devin"', f'default: "{lane}"')
|
|
474
|
+
.replace("github.event.inputs.lane || 'devin'", f"github.event.inputs.lane || '{lane}'")
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _copy_source_candidates(source_root: Path, entry: Mapping[str, Any], path: str) -> tuple[Path, ...]:
|
|
479
|
+
candidates: list[Path] = []
|
|
480
|
+
package_copy_from = entry.get("package_copy_from")
|
|
481
|
+
if entry.get("package_copy_first") and package_copy_from:
|
|
482
|
+
candidates.append(Path(__file__).resolve().parent / str(package_copy_from))
|
|
483
|
+
copy_from = str(entry.get("copy_from", path))
|
|
484
|
+
candidates.append(source_root / copy_from)
|
|
485
|
+
if package_copy_from and not entry.get("package_copy_first"):
|
|
486
|
+
candidates.append(Path(__file__).resolve().parent / str(package_copy_from))
|
|
487
|
+
return tuple(candidates)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _previous_apply_paths(output_dir: Path) -> list[Path]:
|
|
491
|
+
manifest_path = output_dir / APPLY_MANIFEST_FILENAME
|
|
492
|
+
if not manifest_path.exists():
|
|
493
|
+
return []
|
|
494
|
+
try:
|
|
495
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
496
|
+
except (OSError, json.JSONDecodeError):
|
|
497
|
+
return []
|
|
498
|
+
|
|
499
|
+
paths = [manifest_path, *(output_dir / filename for filename in APPLY_SUMMARY_FILES)]
|
|
500
|
+
generated_files = manifest.get("generated_files", [])
|
|
501
|
+
if isinstance(generated_files, list):
|
|
502
|
+
for entry in generated_files:
|
|
503
|
+
if not isinstance(entry, dict):
|
|
504
|
+
continue
|
|
505
|
+
try:
|
|
506
|
+
paths.append(_safe_output_path(output_dir, str(entry.get("path", ""))))
|
|
507
|
+
except ConfigError:
|
|
508
|
+
continue
|
|
509
|
+
return paths
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _prune_previous_apply(output_dir: Path) -> None:
|
|
513
|
+
previous_paths = _previous_apply_paths(output_dir)
|
|
514
|
+
for path in sorted(previous_paths, key=lambda item: len(item.parts), reverse=True):
|
|
515
|
+
if path.is_file() or path.is_symlink():
|
|
516
|
+
path.unlink()
|
|
517
|
+
|
|
518
|
+
generated_parents: set[Path] = set()
|
|
519
|
+
for path in previous_paths:
|
|
520
|
+
parent = path.parent
|
|
521
|
+
while parent != output_dir and output_dir in parent.parents:
|
|
522
|
+
generated_parents.add(parent)
|
|
523
|
+
parent = parent.parent
|
|
524
|
+
|
|
525
|
+
for directory in sorted(generated_parents, key=lambda item: len(item.parts), reverse=True):
|
|
526
|
+
try:
|
|
527
|
+
directory.rmdir()
|
|
528
|
+
except OSError:
|
|
529
|
+
pass
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def apply_init_plan(
|
|
533
|
+
plan: RenderedPlan,
|
|
534
|
+
output_dir: Path,
|
|
535
|
+
*,
|
|
536
|
+
source_root: Path | None = None,
|
|
537
|
+
) -> dict[str, Any]:
|
|
538
|
+
source_root = (source_root or _repo_root()).resolve()
|
|
539
|
+
resolved_output_dir = output_dir.resolve()
|
|
540
|
+
if resolved_output_dir == source_root:
|
|
541
|
+
raise ConfigError(
|
|
542
|
+
"refusing to write generated output into the source root; "
|
|
543
|
+
"choose a dedicated --output-dir such as .code-mower.generated"
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
generated_destinations: list[tuple[dict[str, Any], str, Path]] = []
|
|
547
|
+
for entry in plan.data["generated_files"]:
|
|
548
|
+
path = str(entry["path"])
|
|
549
|
+
generated_destinations.append((entry, path, _safe_output_path(output_dir, path)))
|
|
550
|
+
|
|
551
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
552
|
+
_prune_previous_apply(output_dir)
|
|
553
|
+
written_files: list[str] = []
|
|
554
|
+
placeholder_files: list[str] = []
|
|
555
|
+
|
|
556
|
+
manifest_path = output_dir / APPLY_MANIFEST_FILENAME
|
|
557
|
+
manifest_path.write_text(json.dumps(plan.data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
558
|
+
written_files.append(str(manifest_path))
|
|
559
|
+
|
|
560
|
+
summary_files = {
|
|
561
|
+
"labels.txt": "\n".join(plan.data["labels"]) + "\n",
|
|
562
|
+
"required-secrets.txt": "\n".join(plan.data["required_secrets"]) + "\n",
|
|
563
|
+
"smoke-tests.sh": "#!/usr/bin/env bash\nset -euo pipefail\n\n"
|
|
564
|
+
+ "\n".join(plan.data["smoke_tests"])
|
|
565
|
+
+ "\n",
|
|
566
|
+
}
|
|
567
|
+
for filename, text in summary_files.items():
|
|
568
|
+
destination = output_dir / filename
|
|
569
|
+
destination.write_text(text, encoding="utf-8")
|
|
570
|
+
if filename.endswith(".sh"):
|
|
571
|
+
destination.chmod(0o755)
|
|
572
|
+
written_files.append(str(destination))
|
|
573
|
+
|
|
574
|
+
for entry, path, destination in generated_destinations:
|
|
575
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
576
|
+
source = next(
|
|
577
|
+
(candidate for candidate in _copy_source_candidates(source_root, entry, path) if candidate.is_file()),
|
|
578
|
+
None,
|
|
579
|
+
)
|
|
580
|
+
if source is not None:
|
|
581
|
+
if entry.get("source") == "shared-stale-label-template":
|
|
582
|
+
destination.write_text(
|
|
583
|
+
_render_stale_workflow_template(
|
|
584
|
+
source.read_text(encoding="utf-8"),
|
|
585
|
+
lane=str(entry.get("stale_lane") or "devin"),
|
|
586
|
+
),
|
|
587
|
+
encoding="utf-8",
|
|
588
|
+
)
|
|
589
|
+
else:
|
|
590
|
+
shutil.copyfile(source, destination)
|
|
591
|
+
else:
|
|
592
|
+
destination.write_text(_placeholder_text(path, str(entry["source"])), encoding="utf-8")
|
|
593
|
+
placeholder_files.append(str(destination))
|
|
594
|
+
if entry.get("mode") == "0755":
|
|
595
|
+
destination.chmod(0o755)
|
|
596
|
+
written_files.append(str(destination))
|
|
597
|
+
|
|
598
|
+
return {
|
|
599
|
+
"mode": "apply",
|
|
600
|
+
"output_dir": str(output_dir),
|
|
601
|
+
"written_files": written_files,
|
|
602
|
+
"placeholder_files": placeholder_files,
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def render_init_plan(
|
|
607
|
+
config: Mapping[str, Any],
|
|
608
|
+
profile_id: str = "recommended",
|
|
609
|
+
config_path: str = "code-mower.example.yml",
|
|
610
|
+
*,
|
|
611
|
+
package_mode: bool | None = None,
|
|
612
|
+
package_command: str | None = None,
|
|
613
|
+
) -> RenderedPlan:
|
|
614
|
+
issues = validate_config(config)
|
|
615
|
+
if issues:
|
|
616
|
+
raise ConfigError(f"invalid Code Mower config:\n{_format_issues(issues)}")
|
|
617
|
+
|
|
618
|
+
profile = _profile(config, profile_id)
|
|
619
|
+
lanes: Mapping[str, Mapping[str, Any]] = config["lanes"]
|
|
620
|
+
selected_lanes = {lane_id: lanes[lane_id] for lane_id in profile.lanes}
|
|
621
|
+
|
|
622
|
+
labels: list[str] = []
|
|
623
|
+
workflows: list[dict[str, str]] = []
|
|
624
|
+
generated_files: list[dict[str, str]] = []
|
|
625
|
+
workflow_targets: set[str] = set()
|
|
626
|
+
generated_paths: set[str] = set()
|
|
627
|
+
required_secrets: set[str] = set()
|
|
628
|
+
quoted_config_path = shlex.quote(config_path)
|
|
629
|
+
quoted_profile_id = shlex.quote(profile.profile_id)
|
|
630
|
+
if package_mode is None:
|
|
631
|
+
package_mode = _running_as_package()
|
|
632
|
+
smoke_command_prefix = (
|
|
633
|
+
shlex.quote(package_command)
|
|
634
|
+
if package_mode and package_command
|
|
635
|
+
else _default_package_command()
|
|
636
|
+
if package_mode
|
|
637
|
+
else f"{REFERENCE_PYTHON} tools/code_mower"
|
|
638
|
+
)
|
|
639
|
+
smoke_tests: list[str] = [
|
|
640
|
+
(
|
|
641
|
+
f"{smoke_command_prefix} config validate {quoted_config_path}"
|
|
642
|
+
if package_mode
|
|
643
|
+
else f"{smoke_command_prefix}_config.py {quoted_config_path} --validate-only"
|
|
644
|
+
),
|
|
645
|
+
(
|
|
646
|
+
f"{smoke_command_prefix} init {quoted_config_path} --profile {quoted_profile_id} --dry-run --json"
|
|
647
|
+
if package_mode
|
|
648
|
+
else f"{smoke_command_prefix}_init.py {quoted_config_path} --profile {quoted_profile_id} --dry-run --json"
|
|
649
|
+
),
|
|
650
|
+
]
|
|
651
|
+
warnings: list[str] = []
|
|
652
|
+
merge_authority_lanes: list[str] = []
|
|
653
|
+
informational_lanes: list[str] = []
|
|
654
|
+
|
|
655
|
+
for lane_id, lane in selected_lanes.items():
|
|
656
|
+
lane_labels = _labels_for(lane)
|
|
657
|
+
labels.extend(str(lane_labels[key]) for key in ("needs", "done", "blocked"))
|
|
658
|
+
if lane.get("merge_authority"):
|
|
659
|
+
merge_authority_lanes.append(lane_id)
|
|
660
|
+
if lane.get("informational"):
|
|
661
|
+
informational_lanes.append(lane_id)
|
|
662
|
+
required_secrets.update(required_secret_entries_for_lane(lane))
|
|
663
|
+
for target in _workflow_targets(lane_id, lane):
|
|
664
|
+
if target in workflow_targets:
|
|
665
|
+
warnings.append(f"{lane_id}: workflow target {target} collides with another lane")
|
|
666
|
+
continue
|
|
667
|
+
workflow_targets.add(target)
|
|
668
|
+
workflows.append(
|
|
669
|
+
{
|
|
670
|
+
"lane": lane_id,
|
|
671
|
+
"driver": str(lane["driver"]),
|
|
672
|
+
"target": target,
|
|
673
|
+
}
|
|
674
|
+
)
|
|
675
|
+
if target not in generated_paths:
|
|
676
|
+
generated_paths.add(target)
|
|
677
|
+
generated_files.append({"path": target, "source": "workflow-template"})
|
|
678
|
+
if lane.get("driver") in {"local_cli", "hosted_bridge", "api_model"}:
|
|
679
|
+
trailer_lane = _trailer_lane_name(lane_id, lane)
|
|
680
|
+
trailer_module = _lane_module_name(trailer_lane)
|
|
681
|
+
path = f"tools/lane_configs/{trailer_module}.py"
|
|
682
|
+
if path in generated_paths:
|
|
683
|
+
warnings.append(f"{lane_id}: generated file {path} collides with another lane")
|
|
684
|
+
else:
|
|
685
|
+
generated_paths.add(path)
|
|
686
|
+
generated_files.append(
|
|
687
|
+
{
|
|
688
|
+
"path": path,
|
|
689
|
+
"source": "lane-config-template",
|
|
690
|
+
}
|
|
691
|
+
)
|
|
692
|
+
smoke_tests.extend(_lane_smoke_tests(lane_id, lane, package_mode=package_mode))
|
|
693
|
+
warnings.extend(_lane_warnings(lane_id, lane, package_mode=package_mode))
|
|
694
|
+
|
|
695
|
+
cleanup_path = ".github/workflows/audit-label-cleanup.yml"
|
|
696
|
+
if cleanup_path not in generated_paths:
|
|
697
|
+
required_secrets.add("AUDIT_LABEL_CLEANUP_TOKEN")
|
|
698
|
+
generated_paths.add(cleanup_path)
|
|
699
|
+
generated_files.append(
|
|
700
|
+
{
|
|
701
|
+
"path": cleanup_path,
|
|
702
|
+
"source": "shared-cleanup-template",
|
|
703
|
+
}
|
|
704
|
+
)
|
|
705
|
+
for lane_id, lane in selected_lanes.items():
|
|
706
|
+
hygiene = lane.get("review_hygiene")
|
|
707
|
+
if not isinstance(hygiene, Mapping) or not hygiene:
|
|
708
|
+
continue
|
|
709
|
+
stale_path = str(hygiene["workflow"])
|
|
710
|
+
if stale_path not in generated_paths:
|
|
711
|
+
generated_paths.add(stale_path)
|
|
712
|
+
generated_files.append(
|
|
713
|
+
{
|
|
714
|
+
"path": stale_path,
|
|
715
|
+
"source": "shared-stale-label-template",
|
|
716
|
+
"copy_from": "templates/workflows/review-clear-stale.yml.j2",
|
|
717
|
+
"package_copy_from": "templates/workflows/review-clear-stale.yml.j2",
|
|
718
|
+
"stale_lane": _trailer_lane_name(lane_id, lane),
|
|
719
|
+
}
|
|
720
|
+
)
|
|
721
|
+
for target, copy_from, package_copy_from, source_name in STARTER_DATA_FILES:
|
|
722
|
+
if target in generated_paths:
|
|
723
|
+
warnings.append(f"starter data target {target} collides with another generated file")
|
|
724
|
+
continue
|
|
725
|
+
generated_paths.add(target)
|
|
726
|
+
generated_files.append(
|
|
727
|
+
{
|
|
728
|
+
"path": target,
|
|
729
|
+
"source": source_name,
|
|
730
|
+
"copy_from": copy_from,
|
|
731
|
+
"package_copy_from": package_copy_from,
|
|
732
|
+
}
|
|
733
|
+
)
|
|
734
|
+
for target, package_copy_from, source_name, mode in PRODUCT_SUPPORT_FILES:
|
|
735
|
+
if target in generated_paths:
|
|
736
|
+
warnings.append(f"product support target {target} collides with another generated file")
|
|
737
|
+
continue
|
|
738
|
+
generated_paths.add(target)
|
|
739
|
+
generated_files.append(
|
|
740
|
+
{
|
|
741
|
+
"path": target,
|
|
742
|
+
"source": source_name,
|
|
743
|
+
"package_copy_from": package_copy_from,
|
|
744
|
+
"package_copy_first": True,
|
|
745
|
+
"mode": mode,
|
|
746
|
+
}
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
if not merge_authority_lanes:
|
|
750
|
+
warnings.append(
|
|
751
|
+
f"{profile.profile_id}: profile has no merge-authority lanes; keep informational only"
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
data = {
|
|
755
|
+
"mode": "dry-run",
|
|
756
|
+
"profile": {
|
|
757
|
+
"id": profile.profile_id,
|
|
758
|
+
"description": profile.description,
|
|
759
|
+
"lanes": list(profile.lanes),
|
|
760
|
+
},
|
|
761
|
+
"labels": sorted(set(labels)),
|
|
762
|
+
"workflows": workflows,
|
|
763
|
+
"generated_files": generated_files,
|
|
764
|
+
"required_secrets": sorted(required_secrets),
|
|
765
|
+
"merge_authority_lanes": merge_authority_lanes,
|
|
766
|
+
"informational_lanes": informational_lanes,
|
|
767
|
+
"smoke_tests": smoke_tests,
|
|
768
|
+
"warnings": warnings,
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
lines = [
|
|
772
|
+
"Code Mower init dry-run",
|
|
773
|
+
f"Profile: {profile.profile_id}",
|
|
774
|
+
f"Description: {profile.description}",
|
|
775
|
+
"",
|
|
776
|
+
"Selected lanes:",
|
|
777
|
+
]
|
|
778
|
+
for lane_id, lane in selected_lanes.items():
|
|
779
|
+
if lane.get("merge_authority"):
|
|
780
|
+
role = "merge-authority"
|
|
781
|
+
elif lane.get("informational"):
|
|
782
|
+
role = "informational"
|
|
783
|
+
else:
|
|
784
|
+
role = "standard"
|
|
785
|
+
lines.append(f"- {lane_id}: {lane['driver']} / {lane['provider']} ({role})")
|
|
786
|
+
|
|
787
|
+
lines.extend(["", "Labels to ensure:"])
|
|
788
|
+
lines.extend(f"- {label}" for label in data["labels"])
|
|
789
|
+
|
|
790
|
+
lines.extend(["", "Workflow files to render:"])
|
|
791
|
+
if workflows:
|
|
792
|
+
lines.extend(f"- {workflow['target']} ({workflow['lane']})" for workflow in workflows)
|
|
793
|
+
else:
|
|
794
|
+
lines.append("- none")
|
|
795
|
+
|
|
796
|
+
lines.extend(["", "Generated file manifest:"])
|
|
797
|
+
lines.extend(
|
|
798
|
+
f"- {entry['path']} [{entry['source']}]" for entry in data["generated_files"]
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
lines.extend(["", "Required secrets/PAT fallbacks:"])
|
|
802
|
+
if data["required_secrets"]:
|
|
803
|
+
lines.extend(f"- {secret}" for secret in data["required_secrets"])
|
|
804
|
+
else:
|
|
805
|
+
lines.append("- none beyond GITHUB_TOKEN")
|
|
806
|
+
|
|
807
|
+
lines.extend(["", "Smoke tests after render:"])
|
|
808
|
+
lines.extend(f"- {test}" for test in smoke_tests)
|
|
809
|
+
|
|
810
|
+
if warnings:
|
|
811
|
+
lines.extend(["", "Warnings:"])
|
|
812
|
+
lines.extend(f"- {warning}" for warning in warnings)
|
|
813
|
+
|
|
814
|
+
return RenderedPlan(text="\n".join(lines) + "\n", data=data)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def main(argv: list[str] | None = None) -> int:
|
|
818
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
819
|
+
if argv[:1] == ["auth"]:
|
|
820
|
+
return _auth_main(argv[1:])
|
|
821
|
+
|
|
822
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
823
|
+
parser.add_argument("config", nargs="?", default="code-mower.example.yml")
|
|
824
|
+
parser.add_argument("--profile", default="recommended")
|
|
825
|
+
parser.add_argument(
|
|
826
|
+
"--easy",
|
|
827
|
+
action="store_true",
|
|
828
|
+
help=(
|
|
829
|
+
"safe first-run alias for --profile recommended --dry-run; combine "
|
|
830
|
+
"with --apply to write generated output instead"
|
|
831
|
+
),
|
|
832
|
+
)
|
|
833
|
+
parser.add_argument("--dry-run", action="store_true", help="render the init plan")
|
|
834
|
+
parser.add_argument("--apply", action="store_true", help="write generated files to --output-dir")
|
|
835
|
+
parser.add_argument(
|
|
836
|
+
"--output-dir",
|
|
837
|
+
default=DEFAULT_APPLY_OUTPUT_DIR,
|
|
838
|
+
help="safe output directory for --apply mode",
|
|
839
|
+
)
|
|
840
|
+
parser.add_argument("--json", action="store_true", help="emit dry-run plan as JSON")
|
|
841
|
+
args = parser.parse_args(argv)
|
|
842
|
+
|
|
843
|
+
if args.easy:
|
|
844
|
+
args.profile = "recommended"
|
|
845
|
+
if not args.dry_run and not args.apply:
|
|
846
|
+
args.dry_run = True
|
|
847
|
+
if args.apply and args.dry_run:
|
|
848
|
+
print("error: choose either --dry-run or --apply", file=sys.stderr)
|
|
849
|
+
return 1
|
|
850
|
+
if not args.dry_run and not args.apply:
|
|
851
|
+
print("error: choose --dry-run or --apply", file=sys.stderr)
|
|
852
|
+
return 1
|
|
853
|
+
|
|
854
|
+
try:
|
|
855
|
+
config_source = _resolve_config_path(args.config)
|
|
856
|
+
rendered_config_path = (
|
|
857
|
+
str(config_source) if config_source != Path(args.config) else args.config
|
|
858
|
+
)
|
|
859
|
+
plan = render_init_plan(
|
|
860
|
+
load_config(config_source),
|
|
861
|
+
profile_id=args.profile,
|
|
862
|
+
config_path=rendered_config_path,
|
|
863
|
+
)
|
|
864
|
+
apply_result = (
|
|
865
|
+
apply_init_plan(plan, Path(args.output_dir))
|
|
866
|
+
if args.apply
|
|
867
|
+
else None
|
|
868
|
+
)
|
|
869
|
+
except ConfigError as exc:
|
|
870
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
871
|
+
return 1
|
|
872
|
+
|
|
873
|
+
if args.json:
|
|
874
|
+
print(json.dumps(apply_result or plan.data, indent=2, sort_keys=True))
|
|
875
|
+
elif apply_result:
|
|
876
|
+
print(f"Code Mower init apply wrote {len(apply_result['written_files'])} files")
|
|
877
|
+
print(f"Output: {apply_result['output_dir']}")
|
|
878
|
+
if apply_result["placeholder_files"]:
|
|
879
|
+
print("Placeholders:")
|
|
880
|
+
for path in apply_result["placeholder_files"]:
|
|
881
|
+
print(f"- {path}")
|
|
882
|
+
else:
|
|
883
|
+
print(plan.text, end="")
|
|
884
|
+
return 0
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
if __name__ == "__main__":
|
|
888
|
+
raise SystemExit(main())
|