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
@@ -0,0 +1,458 @@
1
+ #!/usr/bin/env python3
2
+ """Run a local/private LLM bakeoff against one PR.
3
+
4
+ The bakeoff is informational only: it never posts GitHub comments and never
5
+ changes audit labels. It exists to compare local model behavior under the same
6
+ PR context and reviewer prompt.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import concurrent.futures
13
+ import json
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ import urllib.error
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ if __package__ in {None, ""}:
23
+ module_dir = Path(__file__).resolve().parent
24
+ sys.path.insert(0, str(module_dir.parent))
25
+ if module_dir.name == "code_mower": # pragma: no cover - extracted direct CLI.
26
+ from code_mower import local_llm_audit_pr, local_llm_profiles
27
+ else:
28
+ from tools import local_llm_audit_pr, local_llm_profiles
29
+ elif __package__ == "tools":
30
+ from tools import local_llm_audit_pr, local_llm_profiles
31
+ else: # pragma: no cover - exercised after package extraction.
32
+ from . import local_llm_audit_pr, local_llm_profiles
33
+
34
+
35
+ DEFAULT_BAKEOFF_PROFILES = (
36
+ "qwen3-coder-next-lmstudio",
37
+ "gemma4-ollama",
38
+ )
39
+
40
+
41
+ class BakeoffHeadChangedError(RuntimeError):
42
+ pass
43
+
44
+
45
+ def _split_profiles(value: str) -> list[str]:
46
+ return [item.strip() for item in value.split(",") if item.strip()]
47
+
48
+
49
+ def _artifact_name(profile_id: str, suffix: str) -> str:
50
+ safe = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "-" for ch in profile_id)
51
+ return f"{safe}.{suffix}"
52
+
53
+
54
+ def resolve_github_token() -> str:
55
+ token = os.environ.get("GITHUB_TOKEN")
56
+ if token:
57
+ return token
58
+ try:
59
+ completed = subprocess.run(
60
+ ["gh", "auth", "token"],
61
+ check=True,
62
+ capture_output=True,
63
+ text=True,
64
+ timeout=10,
65
+ )
66
+ except (
67
+ FileNotFoundError,
68
+ subprocess.CalledProcessError,
69
+ subprocess.TimeoutExpired,
70
+ ):
71
+ return ""
72
+ return completed.stdout.strip()
73
+
74
+
75
+ def summarize_audit_result(
76
+ profile: local_llm_profiles.LocalLlmProfile,
77
+ result: local_llm_audit_pr.AuditResult,
78
+ *,
79
+ duration_seconds: float,
80
+ max_files: int | None = None,
81
+ max_file_bytes: int | None = None,
82
+ http_timeout: int | None = None,
83
+ ) -> dict[str, Any]:
84
+ blocker_findings = [
85
+ {"path": finding.path, "blockers": finding.blockers}
86
+ for finding in result.file_findings
87
+ if finding.blockers
88
+ ]
89
+ concern_findings = [
90
+ {"path": finding.path, "concerns": finding.concerns}
91
+ for finding in result.file_findings
92
+ if finding.concerns and not finding.blockers
93
+ ]
94
+ parse_failure_count = sum(1 for finding in result.file_findings if finding.parse_failed)
95
+ return {
96
+ "profile_id": profile.profile_id,
97
+ "description": profile.description,
98
+ "api_base": profile.api_base,
99
+ "model": profile.model,
100
+ "context_window": profile.context_window,
101
+ "max_files": max_files if max_files is not None else profile.max_files,
102
+ "max_file_bytes": (
103
+ max_file_bytes if max_file_bytes is not None else profile.max_file_bytes
104
+ ),
105
+ "http_timeout": http_timeout if http_timeout is not None else profile.http_timeout,
106
+ "duration_seconds": round(duration_seconds, 3),
107
+ "repo": result.repo,
108
+ "pr_number": result.pr_number,
109
+ "head_sha_start": result.head_sha_start,
110
+ "head_sha_end": result.head_sha_end,
111
+ "verdict": result.verdict,
112
+ "files_reviewed": len(result.file_findings),
113
+ "blocker_file_count": len(blocker_findings),
114
+ "concern_file_count": len(concern_findings),
115
+ "parse_failure_count": parse_failure_count,
116
+ "json_repair_used_count": sum(1 for finding in result.file_findings if finding.json_repair_used),
117
+ "parse_attempts_total": sum(finding.parse_attempts for finding in result.file_findings),
118
+ "pr_level_blockers": list(result.pr_level_blockers),
119
+ "blocker_findings": blocker_findings,
120
+ "concern_findings": concern_findings,
121
+ "trailer": result.trailer,
122
+ }
123
+
124
+
125
+ def _run_profile_bakeoff(
126
+ *,
127
+ profile: local_llm_profiles.LocalLlmProfile,
128
+ repo: str,
129
+ pr_number: int,
130
+ github_token: str,
131
+ expected_head_sha: str,
132
+ max_files: int | None,
133
+ max_file_bytes: int | None,
134
+ http_timeout: int | None,
135
+ api_key: str | None,
136
+ json_repair_retries: int | None,
137
+ repo_path: Path | None,
138
+ base_ref: str,
139
+ allow_historical_head: bool,
140
+ ) -> tuple[str, local_llm_audit_pr.AuditResult, dict[str, Any]]:
141
+ started = time.monotonic()
142
+ config = local_llm_audit_pr.AuditConfig(
143
+ github_token=github_token,
144
+ api_base=profile.api_base,
145
+ model=profile.model,
146
+ api_key=api_key or os.environ.get("LOCAL_LLM_API_KEY") or profile.api_key,
147
+ http_timeout=http_timeout if http_timeout is not None else profile.http_timeout,
148
+ max_file_bytes=max_file_bytes if max_file_bytes is not None else profile.max_file_bytes,
149
+ max_files=max_files if max_files is not None else profile.max_files,
150
+ profile_id=profile.profile_id,
151
+ context_window=profile.context_window,
152
+ json_repair_retries=(
153
+ json_repair_retries
154
+ if json_repair_retries is not None
155
+ else 1
156
+ ),
157
+ repo_path=repo_path,
158
+ base_ref=base_ref,
159
+ allow_historical_head=allow_historical_head,
160
+ dry_run=True,
161
+ )
162
+ result = local_llm_audit_pr.audit_pr(config, repo, pr_number)
163
+ duration = time.monotonic() - started
164
+ if (
165
+ result.head_sha_start != expected_head_sha
166
+ or result.head_sha_end != expected_head_sha
167
+ ):
168
+ raise BakeoffHeadChangedError(
169
+ "PR head changed during bakeoff; "
170
+ f"expected {expected_head_sha}, "
171
+ f"got start={result.head_sha_start} end={result.head_sha_end}. "
172
+ "Discard this comparison and rerun on the current head."
173
+ )
174
+ summary = summarize_audit_result(
175
+ profile,
176
+ result,
177
+ duration_seconds=duration,
178
+ max_files=max_files,
179
+ max_file_bytes=max_file_bytes,
180
+ http_timeout=http_timeout,
181
+ )
182
+ return profile.profile_id, result, summary
183
+
184
+
185
+ def run_bakeoff(
186
+ *,
187
+ repo: str,
188
+ pr_number: int,
189
+ github_token: str,
190
+ profile_ids: list[str],
191
+ expected_head_sha: str | None = None,
192
+ max_files: int | None = None,
193
+ max_file_bytes: int | None = None,
194
+ http_timeout: int | None = None,
195
+ api_key: str | None = None,
196
+ json_repair_retries: int | None = None,
197
+ output_dir: Path | None = None,
198
+ jobs: int = 1,
199
+ repo_path: Path | None = None,
200
+ base_ref: str = local_llm_audit_pr.DEFAULT_BASE_REF,
201
+ allow_historical_head: bool = False,
202
+ ) -> dict[str, Any]:
203
+ if not profile_ids:
204
+ raise ValueError("no local LLM profiles selected; pass --profiles")
205
+ seen_profile_ids: set[str] = set()
206
+ for profile_id in profile_ids:
207
+ if profile_id in seen_profile_ids:
208
+ raise ValueError(f"duplicate local LLM profile id: {profile_id}")
209
+ seen_profile_ids.add(profile_id)
210
+ profiles = [local_llm_profiles.get_profile(profile_id) for profile_id in profile_ids]
211
+
212
+ runs: list[dict[str, Any]] = []
213
+ output_paths: dict[str, str] = {}
214
+ if output_dir is not None:
215
+ output_dir.mkdir(parents=True, exist_ok=True)
216
+
217
+ pr_meta = local_llm_audit_pr.fetch_pull_request(repo, pr_number, token=github_token)
218
+ pr_head_sha = str(pr_meta["head"]["sha"])
219
+ current_head_sha = (
220
+ local_llm_audit_pr._local_head_sha(repo_path.expanduser().resolve())
221
+ if repo_path is not None
222
+ else pr_head_sha
223
+ )
224
+ if (
225
+ repo_path is not None
226
+ and not allow_historical_head
227
+ and current_head_sha.lower() != pr_head_sha.lower()
228
+ ):
229
+ raise BakeoffHeadChangedError(
230
+ "local checkout is not at the current PR head; pass "
231
+ "--allow-historical-head for archived calibration runs. "
232
+ f"local={current_head_sha} current_pr={pr_head_sha}."
233
+ )
234
+ normalized_expected_head_sha = expected_head_sha.strip().lower() if expected_head_sha else ""
235
+ normalized_current_head_sha = str(current_head_sha).strip().lower()
236
+ if normalized_expected_head_sha and normalized_expected_head_sha != normalized_current_head_sha:
237
+ raise BakeoffHeadChangedError(
238
+ "PR head does not match calibration corpus; "
239
+ f"expected {expected_head_sha}, current={current_head_sha}. "
240
+ "Refresh the corpus or rerun against the pinned head before comparing results."
241
+ )
242
+ expected_head_sha = current_head_sha
243
+ jobs = max(1, min(jobs, len(profiles)))
244
+
245
+ completed: dict[str, tuple[local_llm_audit_pr.AuditResult, dict[str, Any]]] = {}
246
+ if jobs == 1:
247
+ for profile in profiles:
248
+ profile_id, result, summary = _run_profile_bakeoff(
249
+ profile=profile,
250
+ repo=repo,
251
+ pr_number=pr_number,
252
+ github_token=github_token,
253
+ expected_head_sha=expected_head_sha,
254
+ max_files=max_files,
255
+ max_file_bytes=max_file_bytes,
256
+ http_timeout=http_timeout,
257
+ api_key=api_key,
258
+ json_repair_retries=json_repair_retries,
259
+ repo_path=repo_path,
260
+ base_ref=base_ref,
261
+ allow_historical_head=allow_historical_head,
262
+ )
263
+ completed[profile_id] = (result, summary)
264
+ else:
265
+ executor = concurrent.futures.ThreadPoolExecutor(max_workers=jobs)
266
+ failure: Exception | None = None
267
+ try:
268
+ future_by_profile = {
269
+ executor.submit(
270
+ _run_profile_bakeoff,
271
+ profile=profile,
272
+ repo=repo,
273
+ pr_number=pr_number,
274
+ github_token=github_token,
275
+ expected_head_sha=expected_head_sha,
276
+ max_files=max_files,
277
+ max_file_bytes=max_file_bytes,
278
+ http_timeout=http_timeout,
279
+ api_key=api_key,
280
+ json_repair_retries=json_repair_retries,
281
+ repo_path=repo_path,
282
+ base_ref=base_ref,
283
+ allow_historical_head=allow_historical_head,
284
+ ): profile.profile_id
285
+ for profile in profiles
286
+ }
287
+ for future in concurrent.futures.as_completed(future_by_profile):
288
+ try:
289
+ profile_id, result, summary = future.result()
290
+ except Exception as exc:
291
+ for pending in future_by_profile:
292
+ pending.cancel()
293
+ failure = exc
294
+ break
295
+ completed[profile_id] = (result, summary)
296
+ finally:
297
+ executor.shutdown(wait=True, cancel_futures=failure is not None)
298
+ if failure is not None:
299
+ raise failure
300
+
301
+ for profile in profiles:
302
+ result, summary = completed[profile.profile_id]
303
+ runs.append(summary)
304
+ if output_dir is not None:
305
+ comment_path = output_dir / _artifact_name(profile.profile_id, "comment.md")
306
+ comment_path.write_text(result.comment_body, encoding="utf-8")
307
+ output_paths[f"{profile.profile_id}:comment"] = str(comment_path)
308
+
309
+ payload = {
310
+ "mode": "local-llm-bakeoff",
311
+ "repo": repo,
312
+ "pr_number": pr_number,
313
+ "head_sha": expected_head_sha,
314
+ "pr_head_sha": pr_head_sha,
315
+ "diff_source": "local_checkout" if repo_path is not None else "github_pr",
316
+ "base_ref": base_ref if repo_path is not None else None,
317
+ "profiles": profile_ids,
318
+ "jobs": jobs,
319
+ "runs": runs,
320
+ }
321
+ if output_dir is not None:
322
+ summary_path = output_dir / "summary.json"
323
+ payload["output_paths"] = output_paths
324
+ output_paths["summary"] = str(summary_path)
325
+ summary_path.write_text(
326
+ json.dumps(payload, indent=2, sort_keys=True) + "\n",
327
+ encoding="utf-8",
328
+ )
329
+ return payload
330
+
331
+
332
+ def render_bakeoff_text(payload: dict[str, Any]) -> str:
333
+ lines = [
334
+ f"Local LLM bakeoff for {payload['repo']}#{payload['pr_number']}",
335
+ f"jobs: {payload.get('jobs', 1)}",
336
+ "",
337
+ ]
338
+ for run in payload["runs"]:
339
+ lines.extend(
340
+ [
341
+ f"- {run['profile_id']} ({run['model']})",
342
+ f" verdict: {run['verdict']}",
343
+ f" files: {run['files_reviewed']}, blockers: {run['blocker_file_count']}, "
344
+ f"concerns: {run['concern_file_count']}",
345
+ f" parse failures: {run['parse_failure_count']}, "
346
+ f"json retry used: {run['json_repair_used_count']}",
347
+ f" runtime: {run['duration_seconds']}s",
348
+ ]
349
+ )
350
+ if payload.get("output_paths"):
351
+ lines.extend(["", "Artifacts:"])
352
+ for name, path in sorted(payload["output_paths"].items()):
353
+ lines.append(f"- {name}: {path}")
354
+ return "\n".join(lines) + "\n"
355
+
356
+
357
+ def main(argv: list[str] | None = None) -> int:
358
+ parser = argparse.ArgumentParser(description=__doc__)
359
+ parser.add_argument("--repo", required=True, help="owner/repo")
360
+ parser.add_argument("--pr", type=int, required=True, help="PR number")
361
+ parser.add_argument(
362
+ "--profiles",
363
+ default=",".join(DEFAULT_BAKEOFF_PROFILES),
364
+ help="Comma-separated local LLM profile ids.",
365
+ )
366
+ parser.add_argument("--max-files", type=int, default=None)
367
+ parser.add_argument("--max-file-bytes", type=int, default=None)
368
+ parser.add_argument("--http-timeout", type=int, default=None)
369
+ parser.add_argument("--api-key", default=None)
370
+ parser.add_argument(
371
+ "--expected-head-sha",
372
+ default=None,
373
+ help="Fail if the PR current head does not match this corpus-pinned SHA.",
374
+ )
375
+ parser.add_argument(
376
+ "--repo-path",
377
+ type=Path,
378
+ default=None,
379
+ help="optional local checkout to review for archived calibration heads",
380
+ )
381
+ parser.add_argument("--base-ref", default=local_llm_audit_pr.DEFAULT_BASE_REF)
382
+ parser.add_argument(
383
+ "--allow-historical-head",
384
+ action="store_true",
385
+ help="allow --repo-path HEAD to differ from the current GitHub PR head",
386
+ )
387
+ parser.add_argument("--json-repair-retries", type=int, default=None)
388
+ parser.add_argument("--output-dir", type=Path, default=None)
389
+ parser.add_argument(
390
+ "--jobs",
391
+ type=int,
392
+ default=None,
393
+ help="Number of profiles to review concurrently.",
394
+ )
395
+ parser.add_argument("--json", action="store_true")
396
+ args = parser.parse_args(argv)
397
+
398
+ token = resolve_github_token()
399
+ if not token:
400
+ print(
401
+ "error: set GITHUB_TOKEN or authenticate gh so `gh auth token` works",
402
+ file=sys.stderr,
403
+ )
404
+ return 1
405
+
406
+ try:
407
+ jobs = (
408
+ args.jobs
409
+ if args.jobs is not None
410
+ else int(os.environ.get("LOCAL_LLM_BAKEOFF_JOBS", "1"))
411
+ )
412
+ payload = run_bakeoff(
413
+ repo=args.repo,
414
+ pr_number=args.pr,
415
+ github_token=token,
416
+ profile_ids=_split_profiles(args.profiles),
417
+ expected_head_sha=args.expected_head_sha,
418
+ max_files=args.max_files,
419
+ max_file_bytes=args.max_file_bytes,
420
+ http_timeout=args.http_timeout,
421
+ api_key=args.api_key,
422
+ json_repair_retries=args.json_repair_retries,
423
+ output_dir=args.output_dir,
424
+ jobs=jobs,
425
+ repo_path=args.repo_path,
426
+ base_ref=args.base_ref,
427
+ allow_historical_head=args.allow_historical_head,
428
+ )
429
+ except (KeyError, ValueError) as exc:
430
+ print(f"error: {exc}", file=sys.stderr)
431
+ return 1
432
+ except BakeoffHeadChangedError as exc:
433
+ print(f"error: {exc}", file=sys.stderr)
434
+ return 2
435
+ except urllib.error.HTTPError as exc:
436
+ print(f"error: GitHub/API HTTP {exc.code} - {exc.reason}", file=sys.stderr)
437
+ return 1
438
+ except urllib.error.URLError as exc:
439
+ print(f"error: network - {exc}", file=sys.stderr)
440
+ return 1
441
+ except (
442
+ OSError,
443
+ RuntimeError,
444
+ subprocess.CalledProcessError,
445
+ subprocess.TimeoutExpired,
446
+ ) as exc:
447
+ print(f"error: {exc}", file=sys.stderr)
448
+ return 1
449
+
450
+ if args.json:
451
+ print(json.dumps(payload, indent=2, sort_keys=True))
452
+ else:
453
+ print(render_bakeoff_text(payload), end="")
454
+ return 0
455
+
456
+
457
+ if __name__ == "__main__":
458
+ raise SystemExit(main())