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,897 @@
1
+ #!/usr/bin/env python3
2
+ """Run Gemini CLI as an informational Code Mower calibration reviewer."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import re
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ import time
14
+ import urllib.error
15
+ from pathlib import Path
16
+ from typing import Any, Mapping
17
+
18
+ if __package__ in {None, ""}:
19
+ module_dir = Path(__file__).resolve().parent
20
+ sys.path.insert(0, str(module_dir.parent))
21
+ if module_dir.name == "code_mower": # pragma: no cover - extracted direct CLI.
22
+ from code_mower import prompts as code_mower_prompts
23
+ from code_mower import secrets as code_mower_secrets
24
+ from code_mower.provider_runners import (
25
+ build_allowlisted_child_env,
26
+ fetch_local_checkout_diff,
27
+ fetch_pull_request as _fetch_pull_request,
28
+ fetch_pull_request_diff as _fetch_pull_request_diff,
29
+ local_head_sha as _local_head_sha,
30
+ resolve_github_token_from_env_or_gh,
31
+ )
32
+ else:
33
+ from tools import code_mower_prompts, code_mower_secrets
34
+ from tools.provider_runners import (
35
+ build_allowlisted_child_env,
36
+ fetch_local_checkout_diff,
37
+ fetch_pull_request as _fetch_pull_request,
38
+ fetch_pull_request_diff as _fetch_pull_request_diff,
39
+ local_head_sha as _local_head_sha,
40
+ resolve_github_token_from_env_or_gh,
41
+ )
42
+ elif __package__ == "tools":
43
+ from tools import code_mower_prompts, code_mower_secrets
44
+ from tools.provider_runners import (
45
+ build_allowlisted_child_env,
46
+ fetch_local_checkout_diff,
47
+ fetch_pull_request as _fetch_pull_request,
48
+ fetch_pull_request_diff as _fetch_pull_request_diff,
49
+ local_head_sha as _local_head_sha,
50
+ resolve_github_token_from_env_or_gh,
51
+ )
52
+ else: # pragma: no cover - exercised after package extraction.
53
+ from . import prompts as code_mower_prompts
54
+ from . import secrets as code_mower_secrets
55
+ from .provider_runners import (
56
+ build_allowlisted_child_env,
57
+ fetch_local_checkout_diff,
58
+ fetch_pull_request as _fetch_pull_request,
59
+ fetch_pull_request_diff as _fetch_pull_request_diff,
60
+ local_head_sha as _local_head_sha,
61
+ resolve_github_token_from_env_or_gh,
62
+ )
63
+
64
+
65
+ DEFAULT_GEMINI_COMMAND = "gemini"
66
+ DEFAULT_GEMINI_MODE = "gemini-cli-audit"
67
+ DEFAULT_GEMINI_OUTPUT_STEM = "gemini-cli"
68
+ DEFAULT_GEMINI_DISPLAY_NAME = "Gemini CLI"
69
+ DEFAULT_BASE_REF = "origin/main"
70
+ DEFAULT_MAX_DIFF_BYTES = 140_000
71
+ DEFAULT_TIMEOUT_SECONDS = 900
72
+ RESPONSE_JSON_RE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL | re.IGNORECASE)
73
+ AUDIT_INPUT_INSUFFICIENT_CATEGORY = "audit_input_insufficient"
74
+ CODE_REVIEW_CATEGORY = "code_review"
75
+ GEMINI_STDIN_PROMPT = (
76
+ "Use the complete Code Mower audit prompt supplied on stdin. "
77
+ "Return only the requested JSON verdict."
78
+ )
79
+ GEMINI_STDIN_HELP_SENTINEL = "Appended to input on stdin"
80
+ PROMPT_FILE_HELP_SENTINELS = ("--print", "--print-timeout", "--sandbox", "--add-dir")
81
+ GEMINI_ENV_ALLOWLIST = (
82
+ "GEMINI_API_KEY",
83
+ "GEMINI_MODEL",
84
+ "GOOGLE_API_KEY",
85
+ "PATH",
86
+ "LANG",
87
+ "LC_ALL",
88
+ "LC_CTYPE",
89
+ "SSL_CERT_FILE",
90
+ "REQUESTS_CA_BUNDLE",
91
+ "NODE_EXTRA_CA_CERTS",
92
+ "HTTPS_PROXY",
93
+ "HTTP_PROXY",
94
+ "NO_PROXY",
95
+ )
96
+ GEMINI_KEY_ENV_NAMES = ("GEMINI_API_KEY", "GOOGLE_API_KEY")
97
+ GEMINI_KEY_FILE_ENV_NAMES = ("GEMINI_API_KEY_FILE", "GOOGLE_API_KEY_FILE")
98
+
99
+
100
+ class GeminiCliHeadChangedError(RuntimeError):
101
+ pass
102
+
103
+
104
+ class GeminiCliUnsupportedError(RuntimeError):
105
+ pass
106
+
107
+
108
+ def fetch_pull_request(repo: str, pr_number: int, *, token: str) -> Mapping[str, Any]:
109
+ payload = _fetch_pull_request(repo, pr_number, token=token)
110
+ if not isinstance(payload, Mapping):
111
+ raise ValueError("GitHub pull request response was not an object")
112
+ return payload
113
+
114
+
115
+ def fetch_pull_request_diff(repo: str, pr_number: int, *, token: str) -> str:
116
+ return _fetch_pull_request_diff(repo, pr_number, token=token)
117
+
118
+
119
+ def resolve_github_token() -> str:
120
+ return resolve_github_token_from_env_or_gh()
121
+
122
+
123
+ def parse_api_key_file(text: str) -> str:
124
+ """Parse a raw key file or a one-line shell-style API key assignment."""
125
+
126
+ return code_mower_secrets.parse_secret_file_text(
127
+ text,
128
+ supported_env_names=set(GEMINI_KEY_ENV_NAMES),
129
+ ).value
130
+
131
+
132
+ def resolve_gemini_api_key() -> str:
133
+ for name in GEMINI_KEY_ENV_NAMES:
134
+ value = os.environ.get(name, "").strip()
135
+ if value:
136
+ return value
137
+ for name in GEMINI_KEY_FILE_ENV_NAMES:
138
+ path_text = os.environ.get(name, "").strip()
139
+ if not path_text:
140
+ continue
141
+ try:
142
+ value = parse_api_key_file(
143
+ Path(path_text).expanduser().read_text(encoding="utf-8")
144
+ )
145
+ except OSError:
146
+ continue
147
+ if value:
148
+ return value
149
+ return ""
150
+
151
+
152
+ def build_gemini_child_env(
153
+ home_dir: Path,
154
+ *,
155
+ gemini_api_key: str | None = None,
156
+ exclude_env: tuple[str, ...] = (),
157
+ preserve_ambient_home: bool = False,
158
+ ) -> dict[str, str]:
159
+ extra_env = {}
160
+ if gemini_api_key:
161
+ extra_env["GEMINI_API_KEY"] = gemini_api_key
162
+ extra_env["GOOGLE_API_KEY"] = gemini_api_key
163
+ return build_allowlisted_child_env(
164
+ GEMINI_ENV_ALLOWLIST,
165
+ exclude_env=exclude_env,
166
+ extra_env=extra_env,
167
+ home_env={
168
+ "HOME": home_dir,
169
+ "XDG_CONFIG_HOME": home_dir / ".config",
170
+ "XDG_CACHE_HOME": home_dir / ".cache",
171
+ "XDG_STATE_HOME": home_dir / ".local" / "state",
172
+ },
173
+ preserve_ambient_home=preserve_ambient_home,
174
+ )
175
+
176
+
177
+ def _google_cli_safety_settings() -> dict[str, Any]:
178
+ settings = {
179
+ "tools": {
180
+ "core": [],
181
+ "allowed": [],
182
+ "confirmationRequired": ["*"],
183
+ "exclude": ["*"],
184
+ "sandboxAllowedPaths": [],
185
+ "sandboxNetworkAccess": False,
186
+ },
187
+ "mcp": {
188
+ "allowed": [],
189
+ "excluded": ["*"],
190
+ },
191
+ "useWriteTodos": False,
192
+ "security": {
193
+ "disableYoloMode": True,
194
+ "disableAlwaysAllow": True,
195
+ "enablePermanentToolApproval": False,
196
+ },
197
+ }
198
+ return settings
199
+
200
+
201
+ def write_google_cli_safety_settings(
202
+ home_dir: Path,
203
+ *,
204
+ settings_subdirs: tuple[str, ...] = (".gemini",),
205
+ ) -> tuple[Path, ...]:
206
+ settings = _google_cli_safety_settings()
207
+ paths: list[Path] = []
208
+ for subdir in settings_subdirs:
209
+ settings_path = home_dir / subdir / "settings.json"
210
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
211
+ settings_path.write_text(
212
+ json.dumps(settings, indent=2, sort_keys=True) + "\n",
213
+ encoding="utf-8",
214
+ )
215
+ paths.append(settings_path)
216
+ return tuple(paths)
217
+
218
+
219
+ def write_gemini_safety_settings(home_dir: Path) -> Path:
220
+ return write_google_cli_safety_settings(home_dir)[0]
221
+
222
+
223
+ def verify_gemini_stdin_contract(
224
+ command: str,
225
+ *,
226
+ cwd: Path,
227
+ env: Mapping[str, str],
228
+ display_name: str = DEFAULT_GEMINI_DISPLAY_NAME,
229
+ ) -> None:
230
+ completed = subprocess.run(
231
+ [command, "--help"],
232
+ capture_output=True,
233
+ cwd=cwd,
234
+ env=dict(env),
235
+ text=True,
236
+ check=False,
237
+ timeout=10,
238
+ )
239
+ help_text = f"{completed.stdout}\n{completed.stderr}"
240
+ if completed.returncode != 0 or GEMINI_STDIN_HELP_SENTINEL not in help_text:
241
+ raise GeminiCliUnsupportedError(
242
+ f"{display_name} must support appending stdin to --prompt for "
243
+ "Code Mower calibration. Update the CLI or use a compatible "
244
+ f"command; missing help sentinel: {GEMINI_STDIN_HELP_SENTINEL!r}."
245
+ )
246
+
247
+
248
+ def verify_prompt_file_contract(
249
+ command: str,
250
+ *,
251
+ cwd: Path,
252
+ env: Mapping[str, str],
253
+ display_name: str,
254
+ ) -> None:
255
+ completed = subprocess.run(
256
+ [command, "--help"],
257
+ capture_output=True,
258
+ cwd=cwd,
259
+ env=dict(env),
260
+ text=True,
261
+ check=False,
262
+ timeout=10,
263
+ )
264
+ help_text = f"{completed.stdout}\n{completed.stderr}"
265
+ missing = [
266
+ sentinel
267
+ for sentinel in PROMPT_FILE_HELP_SENTINELS
268
+ if sentinel not in help_text
269
+ ]
270
+ if completed.returncode != 0 or missing:
271
+ raise GeminiCliUnsupportedError(
272
+ f"{display_name} must support --print, --print-timeout, --sandbox, "
273
+ "and --add-dir for Code Mower calibration prompt-file transport. "
274
+ f"Missing help sentinel(s): {missing!r}."
275
+ )
276
+
277
+
278
+ def _clip_diff(diff: str, max_bytes: int) -> tuple[str, bool, int, int]:
279
+ raw = diff.encode("utf-8", errors="replace")
280
+ full_bytes = len(raw)
281
+ if full_bytes <= max_bytes:
282
+ return diff, False, full_bytes, full_bytes
283
+ clipped = raw[:max_bytes].decode("utf-8", errors="replace")
284
+ clipped += (
285
+ "\n\n[Code Mower truncated this PR diff for Gemini CLI calibration: "
286
+ f"included {max_bytes} of {full_bytes} bytes. Treat missing context as a "
287
+ "review limitation, not permission to guess.]\n"
288
+ )
289
+ return clipped, True, full_bytes, len(clipped.encode("utf-8"))
290
+
291
+
292
+ def build_prompt(
293
+ *,
294
+ repo: str,
295
+ pr_number: int,
296
+ pr_meta: Mapping[str, Any],
297
+ head_sha: str,
298
+ diff: str,
299
+ prompt_lenses: tuple[str, ...],
300
+ prompt_dir: Path | None = None,
301
+ max_diff_bytes: int = DEFAULT_MAX_DIFF_BYTES,
302
+ historical_calibration: bool = False,
303
+ display_name: str = DEFAULT_GEMINI_DISPLAY_NAME,
304
+ context_pack_text: str = "",
305
+ ) -> tuple[str, dict[str, Any]]:
306
+ review_prompt = code_mower_prompts.load_review_prompt(
307
+ prompt_lenses,
308
+ prompt_dir=prompt_dir,
309
+ )
310
+ clipped_diff, truncated, full_diff_bytes, included_diff_bytes = _clip_diff(
311
+ diff,
312
+ max_diff_bytes,
313
+ )
314
+ body = str(pr_meta.get("body") or "").strip() or "(empty)"
315
+ title = str(pr_meta.get("title") or "").strip() or "(untitled)"
316
+ historical_note = ""
317
+ if historical_calibration:
318
+ historical_note = """
319
+ # Historical Calibration Mode
320
+
321
+ This is a non-merge-authority run against an archived PR head. Treat stale PR
322
+ metadata and current branch ownership as calibration context only: measure the
323
+ review signal on the supplied head and diff, report real code findings if you
324
+ can, and classify missing/truncated audit input as a review limitation rather
325
+ than guessing.
326
+
327
+ """
328
+ context_pack_section = ""
329
+ if context_pack_text.strip():
330
+ context_pack_section = f"""
331
+ # Selected Context Packs
332
+
333
+ These bounded context files were selected for this calibration item. Use them as
334
+ supporting evidence for the diff. If the selected context is still insufficient,
335
+ classify the limitation as audit_input_insufficient instead of guessing.
336
+
337
+ {context_pack_text.strip()}
338
+
339
+ """
340
+ prompt = f"""You are the {display_name} informational reviewer inside Code Mower.
341
+
342
+ This is calibration evidence only. Do not claim merge authority and do not ask
343
+ the operator to run tests. Review the PR for bugs CI is unlikely to catch.
344
+ {historical_note}
345
+
346
+ # Code Mower Review Doctrine
347
+
348
+ {review_prompt.strip()}
349
+
350
+ # Required Response
351
+
352
+ Return exactly one JSON object with this shape and no markdown:
353
+
354
+ {{
355
+ "verdict": "pass" | "blocked",
356
+ "summary": "short summary",
357
+ "findings": [
358
+ {{
359
+ "severity": "P0" | "P1" | "P2" | "P3",
360
+ "title": "short finding title",
361
+ "file": "path/from/repo",
362
+ "line": 1,
363
+ "detail": "specific reason this matters"
364
+ }}
365
+ ]
366
+ }}
367
+
368
+ Use verdict "blocked" if any P0, P1, or P2 finding is present. Use "pass" only
369
+ when there are no P0/P1/P2 findings. Keep PASS terse and do not pad it with
370
+ low-signal notes.
371
+
372
+ # Pull Request
373
+
374
+ Repository: {repo}
375
+ PR: #{pr_number}
376
+ Head SHA: {head_sha}
377
+ Title: {title}
378
+
379
+ Body:
380
+ {body}
381
+
382
+ {context_pack_section}
383
+ # Diff
384
+
385
+ ```diff
386
+ {clipped_diff}
387
+ ```
388
+ """
389
+ diagnostics = {
390
+ "full_diff_bytes": full_diff_bytes,
391
+ "included_diff_bytes": included_diff_bytes,
392
+ "max_diff_bytes": max_diff_bytes,
393
+ "diff_truncated": truncated,
394
+ "prompt_lenses": list(prompt_lenses),
395
+ "context_pack_bytes": len(context_pack_text.encode("utf-8")),
396
+ "context_pack_included": bool(context_pack_text.strip()),
397
+ "prompt_bytes": len(prompt.encode("utf-8")),
398
+ "historical_calibration": historical_calibration,
399
+ }
400
+ return prompt, diagnostics
401
+
402
+
403
+ def _unwrap_fenced_json(text: str) -> str:
404
+ stripped = text.strip()
405
+ match = RESPONSE_JSON_RE.fullmatch(stripped)
406
+ if match:
407
+ return match.group(1).strip()
408
+ return stripped
409
+
410
+
411
+ def parse_response_json(text: str) -> dict[str, Any] | None:
412
+ stripped = _unwrap_fenced_json(text)
413
+ try:
414
+ payload = json.loads(stripped)
415
+ except json.JSONDecodeError:
416
+ start = stripped.find("{")
417
+ end = stripped.rfind("}")
418
+ if start < 0 or end <= start:
419
+ return None
420
+ try:
421
+ payload = json.loads(stripped[start : end + 1])
422
+ except json.JSONDecodeError:
423
+ return None
424
+ return dict(payload) if isinstance(payload, Mapping) else None
425
+
426
+
427
+ AUDIT_INPUT_INSUFFICIENT_PATTERNS = (
428
+ "audit input incomplete",
429
+ "audit input is incomplete",
430
+ "audit input was incomplete",
431
+ "diff is incomplete",
432
+ "diff was incomplete",
433
+ "diff was truncated",
434
+ "diff truncation",
435
+ "incomplete diff",
436
+ "incomplete review context",
437
+ "insufficient audit input",
438
+ "review context is incomplete",
439
+ "truncated diff",
440
+ )
441
+
442
+
443
+ def _finding_is_audit_input_insufficient(finding: Mapping[str, Any]) -> bool:
444
+ text = " ".join(
445
+ str(finding.get(key) or "").strip().lower()
446
+ for key in ("title", "detail", "summary", "text", "message")
447
+ )
448
+ return any(pattern in text for pattern in AUDIT_INPUT_INSUFFICIENT_PATTERNS)
449
+
450
+
451
+ def _audit_input_insufficient_result(findings: list[Mapping[str, Any]]) -> bool:
452
+ blockers = [
453
+ finding
454
+ for finding in findings
455
+ if str(finding.get("severity") or "").strip().upper() in {"P0", "P1", "P2"}
456
+ ]
457
+ return bool(blockers) and all(
458
+ _finding_is_audit_input_insufficient(finding) for finding in blockers
459
+ )
460
+
461
+
462
+ def _validate_verdict(payload: Mapping[str, Any] | None) -> dict[str, Any]:
463
+ if not payload:
464
+ return {
465
+ "verdict": "unknown",
466
+ "summary": "Gemini response did not contain parseable verdict JSON.",
467
+ "findings": [],
468
+ "blocker_count": 0,
469
+ "parse_failed": True,
470
+ "result_category": "parse_failed",
471
+ }
472
+ verdict = str(payload.get("verdict") or "").strip().lower()
473
+ if verdict not in {"pass", "blocked"}:
474
+ verdict = "unknown"
475
+ findings = payload.get("findings", [])
476
+ if not isinstance(findings, list):
477
+ findings = []
478
+ blocker_count = 0
479
+ normalized_findings: list[dict[str, Any]] = []
480
+ for finding in findings:
481
+ if not isinstance(finding, Mapping):
482
+ continue
483
+ severity = str(finding.get("severity") or "").strip().upper()
484
+ if severity in {"P0", "P1", "P2"}:
485
+ blocker_count += 1
486
+ try:
487
+ line = int(finding.get("line") or 0)
488
+ except (TypeError, ValueError):
489
+ line = 0
490
+ normalized_findings.append(
491
+ {
492
+ "severity": severity,
493
+ "title": str(finding.get("title") or "").strip(),
494
+ "file": str(finding.get("file") or "").strip(),
495
+ "line": line,
496
+ "detail": str(finding.get("detail") or "").strip(),
497
+ }
498
+ )
499
+ if blocker_count and verdict == "pass":
500
+ verdict = "blocked"
501
+ result_category = (
502
+ AUDIT_INPUT_INSUFFICIENT_CATEGORY
503
+ if _audit_input_insufficient_result(normalized_findings)
504
+ else CODE_REVIEW_CATEGORY
505
+ )
506
+ return {
507
+ "verdict": verdict,
508
+ "summary": str(payload.get("summary") or "").strip(),
509
+ "findings": normalized_findings,
510
+ "blocker_count": blocker_count,
511
+ "parse_failed": False,
512
+ "result_category": result_category,
513
+ }
514
+
515
+
516
+ def _verdict_is_usable(verdict: Any) -> bool:
517
+ if not isinstance(verdict, Mapping):
518
+ return False
519
+ return (
520
+ not verdict.get("parse_failed")
521
+ and str(verdict.get("verdict") or "") in {"pass", "blocked"}
522
+ )
523
+
524
+
525
+ def run_gemini_cli_audit(
526
+ *,
527
+ repo: str,
528
+ pr_number: int,
529
+ github_token: str,
530
+ command: str = DEFAULT_GEMINI_COMMAND,
531
+ expected_head_sha: str | None = None,
532
+ prompt_lenses: tuple[str, ...] = code_mower_prompts.DEFAULT_REVIEW_LENSES,
533
+ prompt_dir: Path | None = None,
534
+ max_diff_bytes: int = DEFAULT_MAX_DIFF_BYTES,
535
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
536
+ output_dir: Path | None = None,
537
+ gemini_api_key: str | None = None,
538
+ repo_path: Path | None = None,
539
+ base_ref: str = DEFAULT_BASE_REF,
540
+ allow_historical_head: bool = False,
541
+ historical_calibration: bool = False,
542
+ mode: str = DEFAULT_GEMINI_MODE,
543
+ output_stem: str = DEFAULT_GEMINI_OUTPUT_STEM,
544
+ display_name: str = DEFAULT_GEMINI_DISPLAY_NAME,
545
+ settings_subdirs: tuple[str, ...] = (".gemini",),
546
+ model_env: str = "GEMINI_MODEL",
547
+ child_env_exclude: tuple[str, ...] = (),
548
+ cli_transport: str = "stdin_json",
549
+ preserve_ambient_home: bool = False,
550
+ context_pack_text: str = "",
551
+ ) -> dict[str, Any]:
552
+ pr_meta = fetch_pull_request(repo, pr_number, token=github_token)
553
+ pr_head_sha = str(pr_meta.get("head", {}).get("sha") or "")
554
+ if not pr_head_sha:
555
+ raise ValueError("GitHub pull request response did not include head.sha")
556
+ normalized_expected = str(expected_head_sha or "").strip().lower()
557
+ diff_source = "github_pr"
558
+ if repo_path is None:
559
+ head_sha = pr_head_sha
560
+ if normalized_expected and normalized_expected != head_sha.lower():
561
+ raise GeminiCliHeadChangedError(
562
+ "PR head does not match calibration corpus; "
563
+ f"expected {expected_head_sha}, current={head_sha}."
564
+ )
565
+ diff = fetch_pull_request_diff(repo, pr_number, token=github_token)
566
+ else:
567
+ head_sha, diff = fetch_local_checkout_diff(repo_path, base_ref=base_ref)
568
+ diff_source = "local_checkout"
569
+ if normalized_expected and normalized_expected != head_sha.lower():
570
+ raise GeminiCliHeadChangedError(
571
+ "local checkout does not match calibration corpus; "
572
+ f"expected {expected_head_sha}, current={head_sha}."
573
+ )
574
+ if (
575
+ not allow_historical_head
576
+ and not historical_calibration
577
+ and head_sha.lower() != pr_head_sha.lower()
578
+ ):
579
+ raise GeminiCliHeadChangedError(
580
+ "local checkout is not at the current PR head; pass "
581
+ "--historical-calibration for archived calibration runs. "
582
+ f"local={head_sha} current_pr={pr_head_sha}."
583
+ )
584
+ if not diff.strip():
585
+ raise ValueError(
586
+ "Gemini CLI calibration diff is empty; check --repo-path and --base-ref"
587
+ )
588
+ prompt, diagnostics = build_prompt(
589
+ repo=repo,
590
+ pr_number=pr_number,
591
+ pr_meta=pr_meta,
592
+ head_sha=head_sha,
593
+ diff=diff,
594
+ prompt_lenses=prompt_lenses,
595
+ prompt_dir=prompt_dir,
596
+ max_diff_bytes=max_diff_bytes,
597
+ historical_calibration=historical_calibration,
598
+ display_name=display_name,
599
+ context_pack_text=context_pack_text,
600
+ )
601
+ diagnostics["diff_source"] = diff_source
602
+ diagnostics["base_ref"] = base_ref if repo_path is not None else None
603
+ diagnostics["cli_transport"] = cli_transport
604
+ diagnostics["preserve_ambient_home"] = preserve_ambient_home
605
+
606
+ started = time.monotonic()
607
+ gemini_model = os.environ.get(model_env, "").strip()
608
+ with tempfile.TemporaryDirectory(prefix="code-mower-gemini-") as temp_dir_name:
609
+ temp_dir = Path(temp_dir_name)
610
+ home_dir = temp_dir / "home"
611
+ workspace_dir = temp_dir / "workspace"
612
+ home_dir.mkdir()
613
+ workspace_dir.mkdir()
614
+ write_google_cli_safety_settings(
615
+ home_dir,
616
+ settings_subdirs=settings_subdirs,
617
+ )
618
+ child_env = build_gemini_child_env(
619
+ home_dir,
620
+ gemini_api_key=gemini_api_key,
621
+ exclude_env=child_env_exclude,
622
+ preserve_ambient_home=preserve_ambient_home,
623
+ )
624
+ if cli_transport == "stdin_json":
625
+ gemini_args = [
626
+ command,
627
+ "-p",
628
+ GEMINI_STDIN_PROMPT,
629
+ "--output-format",
630
+ "json",
631
+ "--approval-mode",
632
+ "plan",
633
+ "--skip-trust",
634
+ ]
635
+ if gemini_model:
636
+ gemini_args.extend(["--model", gemini_model])
637
+ verify_gemini_stdin_contract(
638
+ command,
639
+ cwd=workspace_dir,
640
+ env=child_env,
641
+ display_name=display_name,
642
+ )
643
+ completed = subprocess.run(
644
+ gemini_args,
645
+ input=prompt,
646
+ capture_output=True,
647
+ cwd=workspace_dir,
648
+ env=child_env,
649
+ text=True,
650
+ check=False,
651
+ timeout=timeout_seconds,
652
+ )
653
+ elif cli_transport == "prompt_file":
654
+ prompt_path = workspace_dir / f"{output_stem}.prompt-input.txt"
655
+ prompt_path.write_text(prompt, encoding="utf-8")
656
+ prompt_instruction = (
657
+ f"Read {prompt_path.name} from the current workspace. Follow it as "
658
+ "the complete Code Mower audit prompt. Return only the requested "
659
+ "JSON verdict."
660
+ )
661
+ gemini_args = [
662
+ command,
663
+ "--sandbox",
664
+ "--add-dir",
665
+ str(workspace_dir),
666
+ "--print-timeout",
667
+ f"{timeout_seconds}s",
668
+ ]
669
+ if gemini_model:
670
+ gemini_args.extend(["--model", gemini_model])
671
+ gemini_args.extend(["--print", prompt_instruction])
672
+ verify_prompt_file_contract(
673
+ command,
674
+ cwd=workspace_dir,
675
+ env=child_env,
676
+ display_name=display_name,
677
+ )
678
+ completed = subprocess.run(
679
+ gemini_args,
680
+ capture_output=True,
681
+ cwd=workspace_dir,
682
+ env=child_env,
683
+ text=True,
684
+ check=False,
685
+ timeout=timeout_seconds,
686
+ )
687
+ else:
688
+ raise ValueError(f"unsupported CLI transport: {cli_transport}")
689
+ duration_seconds = time.monotonic() - started
690
+
691
+ raw_payload: dict[str, Any] | None = None
692
+ if completed.stdout.strip():
693
+ try:
694
+ loaded = json.loads(completed.stdout)
695
+ if isinstance(loaded, Mapping):
696
+ raw_payload = dict(loaded)
697
+ except json.JSONDecodeError:
698
+ raw_payload = None
699
+ response_text = completed.stdout
700
+ parsed_response: Mapping[str, Any] | None = None
701
+ if raw_payload is not None:
702
+ raw_response = raw_payload.get("response")
703
+ if isinstance(raw_response, str):
704
+ response_text = raw_response
705
+ parsed_response = parse_response_json(response_text)
706
+ elif isinstance(raw_response, Mapping):
707
+ response_text = json.dumps(raw_response, sort_keys=True)
708
+ parsed_response = raw_response
709
+ elif "verdict" in raw_payload or "findings" in raw_payload:
710
+ parsed_response = raw_payload
711
+ if parsed_response is None:
712
+ parsed_response = parse_response_json(response_text)
713
+ verdict = _validate_verdict(parsed_response)
714
+ if repo_path is None:
715
+ head_after_meta = fetch_pull_request(repo, pr_number, token=github_token)
716
+ head_after = str(head_after_meta.get("head", {}).get("sha") or "")
717
+ if head_after != head_sha:
718
+ raise GeminiCliHeadChangedError(
719
+ "PR head changed during Gemini CLI audit; "
720
+ f"start={head_sha} end={head_after}. Discard this run and rerun."
721
+ )
722
+ else:
723
+ head_after = _local_head_sha(repo_path.expanduser().resolve())
724
+ if head_after != head_sha:
725
+ raise GeminiCliHeadChangedError(
726
+ "local checkout head changed during Gemini CLI audit; "
727
+ f"start={head_sha} end={head_after}. Discard this run and rerun."
728
+ )
729
+
730
+ payload: dict[str, Any] = {
731
+ "mode": mode,
732
+ "repo": repo,
733
+ "pr_number": pr_number,
734
+ "head_sha": head_sha,
735
+ "head_sha_end": head_after,
736
+ "pr_head_sha": pr_head_sha,
737
+ "command": command,
738
+ "model": gemini_model or None,
739
+ "returncode": completed.returncode,
740
+ "duration_seconds": round(duration_seconds, 3),
741
+ "diagnostics": diagnostics,
742
+ "response_text": response_text,
743
+ "parsed_response": parsed_response,
744
+ "verdict": verdict,
745
+ "stderr": completed.stderr,
746
+ "historical_calibration": historical_calibration,
747
+ }
748
+ if raw_payload is not None:
749
+ payload["raw_output"] = raw_payload
750
+ stats = raw_payload.get("stats")
751
+ if isinstance(stats, Mapping):
752
+ payload["stats"] = stats
753
+ if output_dir is not None:
754
+ output_dir.mkdir(parents=True, exist_ok=True)
755
+ paths = {
756
+ "prompt": output_dir / f"{output_stem}.prompt.txt",
757
+ "response": output_dir / f"{output_stem}.response.md",
758
+ "summary": output_dir / f"{output_stem}.summary.json",
759
+ }
760
+ paths["prompt"].write_text(prompt, encoding="utf-8")
761
+ paths["response"].write_text(response_text, encoding="utf-8")
762
+ payload["output_paths"] = {name: str(path) for name, path in paths.items()}
763
+ paths["summary"].write_text(
764
+ json.dumps(payload, indent=2, sort_keys=True) + "\n",
765
+ encoding="utf-8",
766
+ )
767
+ return payload
768
+
769
+
770
+ def render_text(payload: Mapping[str, Any]) -> str:
771
+ verdict = payload.get("verdict", {})
772
+ if not isinstance(verdict, Mapping):
773
+ verdict = {}
774
+ lines = [
775
+ f"Gemini CLI audit for {payload.get('repo')}#{payload.get('pr_number')}",
776
+ f"head: {payload.get('head_sha')}",
777
+ f"verdict: {verdict.get('verdict', 'unknown')}",
778
+ f"findings: {len(verdict.get('findings', []) or [])}",
779
+ f"runtime: {payload.get('duration_seconds')}s",
780
+ ]
781
+ if payload.get("output_paths"):
782
+ lines.extend(["", "Artifacts:"])
783
+ output_paths = payload.get("output_paths", {})
784
+ if isinstance(output_paths, Mapping):
785
+ for name, path in sorted(output_paths.items()):
786
+ lines.append(f"- {name}: {path}")
787
+ return "\n".join(lines) + "\n"
788
+
789
+
790
+ def main(argv: list[str] | None = None) -> int:
791
+ parser = argparse.ArgumentParser(description=__doc__)
792
+ parser.add_argument("--repo", required=True, help="owner/repo")
793
+ parser.add_argument("--pr", type=int, required=True, help="PR number")
794
+ parser.add_argument("--expected-head-sha", default=None)
795
+ parser.add_argument(
796
+ "--repo-path",
797
+ type=Path,
798
+ default=None,
799
+ help="optional local checkout to diff for archived calibration heads",
800
+ )
801
+ parser.add_argument("--base-ref", default=DEFAULT_BASE_REF)
802
+ parser.add_argument(
803
+ "--allow-historical-head",
804
+ action="store_true",
805
+ help="allow --repo-path HEAD to differ from the current GitHub PR head",
806
+ )
807
+ parser.add_argument(
808
+ "--historical-calibration",
809
+ action="store_true",
810
+ help=(
811
+ "mark this as non-merge-authority evidence against an archived PR "
812
+ "head; implies --allow-historical-head for local checkouts"
813
+ ),
814
+ )
815
+ parser.add_argument(
816
+ "--command",
817
+ default=os.environ.get("GEMINI_CLI_COMMAND", DEFAULT_GEMINI_COMMAND),
818
+ )
819
+ parser.add_argument(
820
+ "--prompt-lenses",
821
+ default=",".join(code_mower_prompts.DEFAULT_REVIEW_LENSES),
822
+ )
823
+ parser.add_argument("--prompt-dir", type=Path, default=None)
824
+ parser.add_argument(
825
+ "--context-pack-file",
826
+ action="append",
827
+ type=Path,
828
+ default=[],
829
+ help="Bounded context-pack text file to append to the audit prompt.",
830
+ )
831
+ parser.add_argument("--max-diff-bytes", type=int, default=DEFAULT_MAX_DIFF_BYTES)
832
+ parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS)
833
+ parser.add_argument("--output-dir", type=Path, default=None)
834
+ parser.add_argument("--json", action="store_true")
835
+ args = parser.parse_args(argv)
836
+
837
+ token = resolve_github_token()
838
+ if not token:
839
+ print(
840
+ "error: set GITHUB_TOKEN or authenticate gh so `gh auth token` works",
841
+ file=sys.stderr,
842
+ )
843
+ return 1
844
+ gemini_api_key = resolve_gemini_api_key()
845
+ if not gemini_api_key:
846
+ print(
847
+ "error: set GEMINI_API_KEY, GOOGLE_API_KEY, GEMINI_API_KEY_FILE, or GOOGLE_API_KEY_FILE for Gemini CLI",
848
+ file=sys.stderr,
849
+ )
850
+ return 1
851
+ try:
852
+ context_pack_text = "\n\n".join(
853
+ path.read_text(encoding="utf-8") for path in args.context_pack_file
854
+ )
855
+ payload = run_gemini_cli_audit(
856
+ repo=args.repo,
857
+ pr_number=args.pr,
858
+ github_token=token,
859
+ command=args.command,
860
+ expected_head_sha=args.expected_head_sha,
861
+ prompt_lenses=code_mower_prompts.split_lenses(args.prompt_lenses),
862
+ prompt_dir=args.prompt_dir,
863
+ max_diff_bytes=args.max_diff_bytes,
864
+ timeout_seconds=args.timeout,
865
+ output_dir=args.output_dir,
866
+ gemini_api_key=gemini_api_key,
867
+ repo_path=args.repo_path,
868
+ base_ref=args.base_ref,
869
+ allow_historical_head=args.allow_historical_head,
870
+ historical_calibration=args.historical_calibration,
871
+ context_pack_text=context_pack_text,
872
+ )
873
+ except GeminiCliHeadChangedError as exc:
874
+ print(f"error: {exc}", file=sys.stderr)
875
+ return 2
876
+ except (
877
+ GeminiCliUnsupportedError,
878
+ OSError,
879
+ ValueError,
880
+ subprocess.CalledProcessError,
881
+ subprocess.TimeoutExpired,
882
+ urllib.error.URLError,
883
+ ) as exc:
884
+ print(f"error: {exc}", file=sys.stderr)
885
+ return 1
886
+
887
+ if args.json:
888
+ print(json.dumps(payload, indent=2, sort_keys=True))
889
+ else:
890
+ print(render_text(payload), end="")
891
+ if payload.get("returncode") != 0:
892
+ return 1
893
+ return 0 if _verdict_is_usable(payload.get("verdict")) else 1
894
+
895
+
896
+ if __name__ == "__main__":
897
+ raise SystemExit(main())