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,348 @@
1
+ """Draft calibration corpus discovery from GitHub PR metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as _dt
6
+ import json
7
+ import re
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Any, Mapping, Sequence
11
+
12
+ from .corpus import parse_int
13
+ from .identity import safe_slug
14
+ from .run_status import RUN_STATUS_BLOCKED, RUN_STATUS_PASS, RUN_STATUS_UNKNOWN
15
+
16
+ AUTO_DISCOVERY_SCHEMA = "code_mower.calibrationAutoDiscover.v1"
17
+ TRUTH_EXPECTATION_UNKNOWN = "unknown"
18
+ TRUTH_EXPECTATION_KNOWN_CLEAN = "known_clean"
19
+ TRUTH_EXPECTATION_KNOWN_BLOCKED = "known_blocked"
20
+
21
+ AUDIT_STATE_RE = re.compile(
22
+ r"<!--\s*(?P<provider>[A-Z0-9_]+)_AUDIT_STATE:\s*"
23
+ r"(?P<state>[a-z0-9_-]+)\s*-->",
24
+ re.IGNORECASE,
25
+ )
26
+ FINDINGS_COUNT_RE = re.compile(
27
+ r"Findings:\s*P0=(?P<p0>\d+),\s*P1=(?P<p1>\d+),\s*"
28
+ r"P2=(?P<p2>\d+),\s*P3=(?P<p3>\d+)",
29
+ re.IGNORECASE,
30
+ )
31
+ HEAD_SHA_RE = re.compile(r"Head SHA:\s*`?(?P<head>[0-9a-f]{7,40})`?", re.IGNORECASE)
32
+
33
+
34
+ def gh_pr_list_fields() -> str:
35
+ return ",".join(
36
+ [
37
+ "number",
38
+ "title",
39
+ "headRefOid",
40
+ "baseRefName",
41
+ "mergedAt",
42
+ "changedFiles",
43
+ "comments",
44
+ "reviews",
45
+ "labels",
46
+ ]
47
+ )
48
+
49
+
50
+ def load_auto_discovery_input(path: Path) -> list[dict[str, Any]]:
51
+ try:
52
+ payload = json.loads(path.read_text(encoding="utf-8"))
53
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
54
+ raise ValueError(f"unable to read {path}: {exc}") from exc
55
+ raw_items = (
56
+ payload.get("pull_requests", payload.get("prs", payload))
57
+ if isinstance(payload, Mapping)
58
+ else payload
59
+ )
60
+ if not isinstance(raw_items, list):
61
+ raise ValueError("auto-discover input must be a GitHub PR list JSON array")
62
+ items: list[dict[str, Any]] = []
63
+ for index, item in enumerate(raw_items):
64
+ if not isinstance(item, Mapping):
65
+ raise ValueError(f"auto-discover input[{index}] must be a JSON object")
66
+ items.append(dict(item))
67
+ return items
68
+
69
+
70
+ def fetch_merged_prs_for_auto_discovery(
71
+ *,
72
+ repo: str,
73
+ last_n: int,
74
+ runner: Any = subprocess.run,
75
+ ) -> list[dict[str, Any]]:
76
+ if last_n < 1:
77
+ raise ValueError("--last-n must be at least 1")
78
+ completed = runner(
79
+ [
80
+ "gh",
81
+ "pr",
82
+ "list",
83
+ "--repo",
84
+ repo,
85
+ "--state",
86
+ "merged",
87
+ "--limit",
88
+ str(last_n),
89
+ "--json",
90
+ gh_pr_list_fields(),
91
+ ],
92
+ text=True,
93
+ capture_output=True,
94
+ check=False,
95
+ )
96
+ if completed.returncode != 0:
97
+ stderr = str(getattr(completed, "stderr", "") or "").strip()
98
+ raise ValueError(
99
+ "gh pr list failed for calibration auto-discovery"
100
+ + (f": {stderr}" if stderr else "")
101
+ )
102
+ try:
103
+ payload = json.loads(str(getattr(completed, "stdout", "") or ""))
104
+ except json.JSONDecodeError as exc:
105
+ raise ValueError(f"gh pr list returned invalid JSON: {exc}") from exc
106
+ if not isinstance(payload, list):
107
+ raise ValueError("gh pr list returned a non-list payload")
108
+ return [dict(item) for item in payload if isinstance(item, Mapping)]
109
+
110
+
111
+ def _provider_from_audit_state(provider: str, state: str) -> str:
112
+ provider_slug = provider.lower().replace("_", "-")
113
+ if provider_slug in {"codex", "claude", "devin", "gitar"}:
114
+ return f"{provider_slug}-audit"
115
+ state_parts = state.split("-audit-", 1)
116
+ if len(state_parts) == 2 and state_parts[0]:
117
+ return f"{state_parts[0]}-audit"
118
+ return f"{provider_slug}-audit"
119
+
120
+
121
+ def _audit_status_from_state(state: str) -> str:
122
+ normalized = state.lower()
123
+ if normalized.endswith("-done") or normalized in {"done", "pass", "passed"}:
124
+ return RUN_STATUS_PASS
125
+ if normalized.endswith("-blocked") or normalized in {"blocked", "fail", "failed"}:
126
+ return RUN_STATUS_BLOCKED
127
+ return RUN_STATUS_UNKNOWN
128
+
129
+
130
+ def _finding_count_from_comment(body: str) -> int:
131
+ match = FINDINGS_COUNT_RE.search(body)
132
+ if not match:
133
+ return 0
134
+ return sum(int(match.group(name)) for name in ("p0", "p1", "p2", "p3"))
135
+
136
+
137
+ def _head_sha_from_comment(body: str) -> str:
138
+ match = HEAD_SHA_RE.search(body)
139
+ return match.group("head") if match else ""
140
+
141
+
142
+ def _audit_runs_from_comments(comments: Sequence[Any]) -> list[dict[str, Any]]:
143
+ runs: list[dict[str, Any]] = []
144
+ for comment in comments:
145
+ if not isinstance(comment, Mapping):
146
+ continue
147
+ body = str(comment.get("body") or "")
148
+ if not body:
149
+ continue
150
+ for match in AUDIT_STATE_RE.finditer(body):
151
+ state = match.group("state")
152
+ reviewer = _provider_from_audit_state(match.group("provider"), state)
153
+ status = _audit_status_from_state(state)
154
+ run: dict[str, Any] = {
155
+ "reviewer": reviewer,
156
+ "status": status,
157
+ "finding_count": _finding_count_from_comment(body),
158
+ "source": "github-comment-trailer",
159
+ }
160
+ head_sha = _head_sha_from_comment(body)
161
+ if head_sha:
162
+ run["head_sha"] = head_sha
163
+ if status == RUN_STATUS_BLOCKED:
164
+ run["expected_blocker_caught"] = True
165
+ run["disposition"] = "true_positive"
166
+ runs.append(run)
167
+ return runs
168
+
169
+
170
+ def _review_signal_count(reviews: Sequence[Any], comments: Sequence[Any]) -> int:
171
+ count = 0
172
+ for review in reviews:
173
+ if not isinstance(review, Mapping):
174
+ continue
175
+ state = str(review.get("state") or "").upper()
176
+ if state == "CHANGES_REQUESTED":
177
+ count += 1
178
+ for comment in comments:
179
+ if not isinstance(comment, Mapping):
180
+ continue
181
+ body = str(comment.get("body") or "").lower()
182
+ if "changes requested" in body or "requested changes" in body:
183
+ count += 1
184
+ return count
185
+
186
+
187
+ def _difficulty_from_changed_files(value: Any) -> str:
188
+ changed_files = parse_int(value or 0, field="changedFiles")
189
+ if changed_files >= 20:
190
+ return "hard"
191
+ if changed_files >= 6:
192
+ return "medium"
193
+ return "easy"
194
+
195
+
196
+ def build_auto_discovered_corpus(
197
+ *,
198
+ repo: str,
199
+ pull_requests: Sequence[Mapping[str, Any]],
200
+ last_n: int,
201
+ ) -> dict[str, Any]:
202
+ if "/" not in repo:
203
+ raise ValueError("--repo must be an owner/repo slug")
204
+ items: list[dict[str, Any]] = []
205
+ for raw in pull_requests:
206
+ pr_number = parse_int(raw.get("number"), field="pull_request.number")
207
+ final_head_sha = str(raw.get("headRefOid") or "")
208
+ comments = list(raw.get("comments", []) or [])
209
+ reviews = list(raw.get("reviews", []) or [])
210
+ reviewer_runs = _audit_runs_from_comments(comments)
211
+ blocked_runs = [
212
+ run for run in reviewer_runs if run.get("status") == RUN_STATUS_BLOCKED
213
+ ]
214
+ pass_runs = [
215
+ run for run in reviewer_runs if run.get("status") == RUN_STATUS_PASS
216
+ ]
217
+ review_signals = _review_signal_count(reviews, comments)
218
+ shared_signal_summary = {
219
+ "audit_blocked_runs": len(blocked_runs),
220
+ "review_signal_count": review_signals,
221
+ "audit_run_count": len(reviewer_runs),
222
+ }
223
+
224
+ def make_item(
225
+ *,
226
+ head_sha: str,
227
+ source: str,
228
+ expectation: str,
229
+ runs: list[dict[str, Any]],
230
+ signal_summary: Mapping[str, Any],
231
+ ) -> dict[str, Any]:
232
+ return {
233
+ "repo": repo,
234
+ "pr_number": pr_number,
235
+ "head_sha": head_sha,
236
+ "base_ref": str(raw.get("baseRefName") or ""),
237
+ "difficulty": _difficulty_from_changed_files(raw.get("changedFiles")),
238
+ "review_class": "auto-discovered",
239
+ "source": source,
240
+ "truth": {
241
+ "expectation": expectation,
242
+ "notes": (
243
+ "Draft auto-discovered from merged PR metadata. Confirm this "
244
+ "disposition before using it for lane promotion or merge policy."
245
+ ),
246
+ },
247
+ "expected_findings": [],
248
+ "reviewer_runs": runs,
249
+ "notes": (
250
+ f"PR title: {raw.get('title') or ''}. "
251
+ f"Discovery signals: {json.dumps(dict(signal_summary), sort_keys=True)}"
252
+ ),
253
+ "auto_discovery": dict(signal_summary),
254
+ }
255
+
256
+ blocked_runs_by_head: dict[str, list[dict[str, Any]]] = {}
257
+ for run in blocked_runs:
258
+ head_sha = str(run.get("head_sha") or "")
259
+ blocked_runs_by_head.setdefault(head_sha, []).append(run)
260
+
261
+ for head_sha, runs in sorted(blocked_runs_by_head.items()):
262
+ signal_summary = {
263
+ **shared_signal_summary,
264
+ "case": "historical-blocked-head",
265
+ "case_head_sha": head_sha,
266
+ }
267
+ items.append(
268
+ make_item(
269
+ head_sha=head_sha,
270
+ source="auto-discovered-structured-blocker",
271
+ expectation=TRUTH_EXPECTATION_KNOWN_BLOCKED,
272
+ runs=runs,
273
+ signal_summary=signal_summary,
274
+ )
275
+ )
276
+
277
+ final_pass_runs = [
278
+ run
279
+ for run in pass_runs
280
+ if not run.get("head_sha") or run.get("head_sha") == final_head_sha
281
+ ]
282
+ explicit_blocked_heads = {
283
+ str(run.get("head_sha")) for run in blocked_runs if run.get("head_sha")
284
+ }
285
+ if (
286
+ final_head_sha
287
+ and final_head_sha not in explicit_blocked_heads
288
+ and (not blocked_runs or final_pass_runs)
289
+ ):
290
+ signal_summary = {
291
+ **shared_signal_summary,
292
+ "case": "merged-final-head",
293
+ "case_head_sha": final_head_sha,
294
+ }
295
+ items.append(
296
+ make_item(
297
+ head_sha=final_head_sha,
298
+ source=(
299
+ "auto-discovered-merged-clean-after-fix"
300
+ if blocked_runs
301
+ else "auto-discovered-merged-clean"
302
+ ),
303
+ expectation=TRUTH_EXPECTATION_KNOWN_CLEAN
304
+ if not review_signals or blocked_runs
305
+ else TRUTH_EXPECTATION_UNKNOWN,
306
+ runs=final_pass_runs,
307
+ signal_summary=signal_summary,
308
+ )
309
+ )
310
+ elif review_signals and not blocked_runs:
311
+ signal_summary = {
312
+ **shared_signal_summary,
313
+ "case": "review-signal-needs-human-disposition",
314
+ "case_head_sha": final_head_sha,
315
+ }
316
+ items.append(
317
+ make_item(
318
+ head_sha=final_head_sha,
319
+ source="auto-discovered-review-signal-needs-human-disposition",
320
+ expectation=TRUTH_EXPECTATION_UNKNOWN,
321
+ runs=[],
322
+ signal_summary=signal_summary,
323
+ )
324
+ )
325
+ return {
326
+ "version": 1,
327
+ "name": f"auto-discovered-{safe_slug(repo)}",
328
+ "description": (
329
+ "Draft calibration corpus generated from recent merged GitHub PRs. "
330
+ "Review every disposition before treating it as benchmark truth."
331
+ ),
332
+ "discovery": {
333
+ "schema": AUTO_DISCOVERY_SCHEMA,
334
+ "repo": repo,
335
+ "last_n": last_n,
336
+ "generated_at": _dt.datetime.now(tz=_dt.timezone.utc)
337
+ .replace(microsecond=0)
338
+ .isoformat()
339
+ .replace("+00:00", "Z"),
340
+ "source": "gh pr list --state merged",
341
+ "caveat": (
342
+ "Known-clean and known-blocked labels are heuristics from merged "
343
+ "PR history, structured audit trailers, and review request signals. "
344
+ "They are starter dispositions, not automatic merge-gating truth."
345
+ ),
346
+ },
347
+ "corpus": items,
348
+ }
@@ -0,0 +1,192 @@
1
+ """Calibration command materialization helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any, Mapping, Sequence
8
+
9
+ from .identity import safe_slug
10
+
11
+
12
+ def parse_repo_path_map(entries: Sequence[str]) -> dict[str, str]:
13
+ mapping: dict[str, str] = {}
14
+ for entry in entries:
15
+ if "=" not in entry:
16
+ raise ValueError(
17
+ "repo path map entries must be OWNER/REPO=PATH, "
18
+ f"OWNER/REPO#PR=PATH, or OWNER/REPO@HEAD=PATH: {entry}"
19
+ )
20
+ selector, path = entry.split("=", 1)
21
+ selector = selector.strip()
22
+ path = path.strip()
23
+ repo = re.split(r"[#@]", selector, maxsplit=1)[0]
24
+ if "/" not in repo or not path:
25
+ raise ValueError(
26
+ "repo path map entries must be OWNER/REPO=PATH, "
27
+ f"OWNER/REPO#PR=PATH, or OWNER/REPO@HEAD=PATH: {entry}"
28
+ )
29
+ mapping[selector] = path
30
+ return mapping
31
+
32
+
33
+ def repo_path_for_item(item: Mapping[str, Any], repo_path_map: Mapping[str, str]) -> str:
34
+ repo = str(item.get("repo") or "")
35
+ pr_number = str(item.get("pr_number") or "")
36
+ head_sha = str(item.get("head_sha") or "")
37
+ selectors = [
38
+ f"{repo}#{pr_number}@{head_sha}" if repo and pr_number and head_sha else "",
39
+ f"{repo}#{pr_number}" if repo and pr_number else "",
40
+ f"{repo}@{head_sha}" if repo and head_sha else "",
41
+ repo,
42
+ ]
43
+ for selector in selectors:
44
+ if selector and selector in repo_path_map:
45
+ return repo_path_map[selector]
46
+ return ""
47
+
48
+
49
+ def command_lane_id(command: Sequence[Any]) -> str:
50
+ parts = [str(part) for part in command]
51
+ if len(parts) >= 2 and parts[0] == "code-mower":
52
+ if parts[1] == "local-llm" and len(parts) >= 3 and parts[2] == "bakeoff":
53
+ return "local-llm"
54
+ return parts[1].replace("_", "-")
55
+ return safe_slug(parts[0] if parts else "command", "command")
56
+
57
+
58
+ def option_value(command: Sequence[str], option: str) -> str:
59
+ for index, part in enumerate(command):
60
+ if part == option and index + 1 < len(command):
61
+ return command[index + 1]
62
+ if part.startswith(f"{option}="):
63
+ return part.split("=", 1)[1]
64
+ return ""
65
+
66
+
67
+ def reviewer_id_from_command(command: Sequence[Any]) -> str:
68
+ lane_id = command_lane_id(command)
69
+ output_dir = option_value([str(part) for part in command], "--output-dir")
70
+ output_leaf = safe_slug(Path(output_dir).name if output_dir else "", "")
71
+ default_leaf = {
72
+ "antigravity-cli": "antigravity-cli",
73
+ "gemini-cli": "gemini-cli",
74
+ "hermes-cli": "hermes-cli",
75
+ "coderabbit-cli": "coderabbit-cli",
76
+ "local-llm": "local-llm",
77
+ }.get(lane_id, lane_id)
78
+ if output_leaf and output_leaf != default_leaf:
79
+ return output_leaf
80
+ return lane_id
81
+
82
+
83
+ def command_metadata_for_run(run: Mapping[str, Any], command_index: int) -> dict[str, Any]:
84
+ command_metadata = run.get("command_metadata", [])
85
+ if (
86
+ isinstance(command_metadata, list)
87
+ and 0 <= command_index < len(command_metadata)
88
+ and isinstance(command_metadata[command_index], Mapping)
89
+ ):
90
+ return dict(command_metadata[command_index])
91
+ return {}
92
+
93
+
94
+ def local_llm_profiles_from_command(command: Sequence[Any]) -> list[str]:
95
+ profiles = option_value([str(part) for part in command], "--profiles")
96
+ return [profile.strip() for profile in profiles.split(",") if profile.strip()]
97
+
98
+
99
+ def set_option_value(command: list[str], option: str, value: str) -> None:
100
+ for index, part in enumerate(command):
101
+ if part == option and index + 1 < len(command):
102
+ command[index + 1] = value
103
+ return
104
+ if part.startswith(f"{option}="):
105
+ command[index] = f"{option}={value}"
106
+ return
107
+ command.extend([option, value])
108
+
109
+
110
+ def has_flag(command: Sequence[str], flag: str) -> bool:
111
+ return any(part == flag for part in command)
112
+
113
+
114
+ def rewrite_code_mower_command(
115
+ command: Sequence[Any],
116
+ *,
117
+ code_mower_command: Sequence[str],
118
+ ) -> list[str]:
119
+ parts = [str(part) for part in command]
120
+ if parts and parts[0] == "code-mower":
121
+ return [*code_mower_command, *parts[1:]]
122
+ return parts
123
+
124
+
125
+ def materialize_command(
126
+ command: Sequence[Any],
127
+ *,
128
+ item: Mapping[str, Any],
129
+ code_mower_command: Sequence[str],
130
+ repo_path_map: Mapping[str, str],
131
+ allow_historical_head: bool,
132
+ ) -> list[str]:
133
+ materialized = rewrite_code_mower_command(
134
+ command,
135
+ code_mower_command=code_mower_command,
136
+ )
137
+ lane_id = command_lane_id(command)
138
+ repo = str(item.get("repo") or "")
139
+ repo_path = repo_path_for_item(item, repo_path_map)
140
+ historical_local_cli_lanes = {"antigravity-cli", "gemini-cli", "hermes-cli"}
141
+ if lane_id in {"coderabbit-cli", "local-llm", *historical_local_cli_lanes}:
142
+ existing_repo_path = option_value(materialized, "--repo-path")
143
+ if repo_path:
144
+ set_option_value(materialized, "--repo-path", repo_path)
145
+ if lane_id in {"coderabbit-cli", "local-llm", *historical_local_cli_lanes} and allow_historical_head:
146
+ if not has_flag(materialized, "--allow-historical-head"):
147
+ materialized.append("--allow-historical-head")
148
+ if lane_id in historical_local_cli_lanes and not has_flag(
149
+ materialized,
150
+ "--historical-calibration",
151
+ ):
152
+ materialized.append("--historical-calibration")
153
+ elif existing_repo_path == "/path/to/pr-worktree":
154
+ raise ValueError(
155
+ f"{lane_id} for {repo} needs --repo-path-map {repo}=/path/to/pr-worktree"
156
+ )
157
+ return materialized
158
+
159
+
160
+ def summary_path_for_command(command: Sequence[Any]) -> Path | None:
161
+ lane_id = command_lane_id(command)
162
+ output_dir = option_value([str(part) for part in command], "--output-dir")
163
+ if not output_dir:
164
+ return None
165
+ root = Path(output_dir)
166
+ if lane_id == "local-llm":
167
+ return root / "summary.json"
168
+ if lane_id == "antigravity-cli":
169
+ return root / "antigravity-cli.summary.json"
170
+ if lane_id == "gemini-cli":
171
+ return root / "gemini-cli.summary.json"
172
+ if lane_id == "hermes-cli":
173
+ return root / "hermes-cli.summary.json"
174
+ if lane_id == "coderabbit-cli":
175
+ return root / "coderabbit-cli.summary.json"
176
+ return None
177
+
178
+
179
+ def resolve_path_for_cwd(path: Path | None, cwd: Path | None) -> Path | None:
180
+ if path is None:
181
+ return None
182
+ if path.is_absolute():
183
+ return path
184
+ return (cwd or Path.cwd()) / path
185
+
186
+
187
+ def text_from_timeout_stream(value: Any) -> str:
188
+ if value is None:
189
+ return ""
190
+ if isinstance(value, bytes):
191
+ return value.decode("utf-8", errors="replace")
192
+ return str(value)