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