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,1364 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Local LLM audit CLI — review a single PR.
|
|
3
|
+
|
|
4
|
+
Standalone command + Python module. Reviews ONE PR end-to-end:
|
|
5
|
+
|
|
6
|
+
1. Fetch PR metadata + per-file diffs from GitHub API.
|
|
7
|
+
2. For each changed file (under size budget): fetch full file content at
|
|
8
|
+
head SHA. This is the "local checkout" context Codex flagged as
|
|
9
|
+
necessary — diff-only review is too weak.
|
|
10
|
+
3. Per-file LLM pass: prompt sees full file content + diff hunks + PR
|
|
11
|
+
title/body. Asks the LLM to classify findings as BLOCKER / CONCERN /
|
|
12
|
+
NONE for that file.
|
|
13
|
+
4. Synthesis LLM pass: gathers per-file findings + cross-cutting
|
|
14
|
+
concerns (test coverage, doc-index, lane discipline) → final verdict.
|
|
15
|
+
5. Refetch head SHA at end. If it changed mid-review, emit the
|
|
16
|
+
`needs-local-llm-audit` trailer instead of PASS/BLOCKED.
|
|
17
|
+
6. Post a structured comment ending with the authoritative trailer.
|
|
18
|
+
|
|
19
|
+
CLI usage:
|
|
20
|
+
|
|
21
|
+
GITHUB_TOKEN=... python3 tools/local_llm_audit_pr.py \
|
|
22
|
+
--repo owner/repo --pr 142
|
|
23
|
+
|
|
24
|
+
Module usage (called by `local_llm_audit_bridge.py`):
|
|
25
|
+
|
|
26
|
+
from tools.local_llm_audit_pr import AuditConfig, audit_pr
|
|
27
|
+
result = audit_pr(config, "owner/repo", 142)
|
|
28
|
+
# result.posted_comment_url, result.verdict, result.trailer
|
|
29
|
+
|
|
30
|
+
Exit codes (CLI mode):
|
|
31
|
+
0 comment posted (or dry-run printed)
|
|
32
|
+
1 generic error (config, network, API)
|
|
33
|
+
2 stale head SHA detected mid-review (caller may requeue)
|
|
34
|
+
|
|
35
|
+
Severity contract (REQUIRED in the prompt):
|
|
36
|
+
BLOCKER — must fix before merge (correctness, test gap, secrets, etc.)
|
|
37
|
+
CONCERN — non-blocking observation
|
|
38
|
+
NONE — no issues
|
|
39
|
+
|
|
40
|
+
Final verdict:
|
|
41
|
+
BLOCKED — any BLOCKER findings
|
|
42
|
+
PASS — no BLOCKERs (CONCERNs allowed and surfaced)
|
|
43
|
+
|
|
44
|
+
Codex's "don't approve by vibes" safety net is enforced around reviewer
|
|
45
|
+
coverage: parse failures, missing changed-file coverage, and unreviewed file
|
|
46
|
+
budgets become blockers. PR-description claims are handled with more nuance:
|
|
47
|
+
unverifiable claims are blockers only when the missing evidence could hide a
|
|
48
|
+
correctness, security, schema, or test-coverage gap; otherwise they are
|
|
49
|
+
concerns so informational local models do not block on harmless release prose.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import argparse
|
|
55
|
+
import base64
|
|
56
|
+
import json
|
|
57
|
+
import os
|
|
58
|
+
import subprocess
|
|
59
|
+
import sys
|
|
60
|
+
import textwrap
|
|
61
|
+
import urllib.error
|
|
62
|
+
import urllib.request
|
|
63
|
+
from dataclasses import dataclass, field
|
|
64
|
+
from pathlib import Path
|
|
65
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
66
|
+
|
|
67
|
+
if __package__ in {None, ""}:
|
|
68
|
+
module_dir = Path(__file__).resolve().parent
|
|
69
|
+
sys.path.insert(0, str(module_dir.parent))
|
|
70
|
+
if module_dir.name == "code_mower": # pragma: no cover - extracted direct CLI.
|
|
71
|
+
from code_mower import local_llm_profiles
|
|
72
|
+
from code_mower import prompts as code_mower_prompts
|
|
73
|
+
from code_mower.provider_runners import (
|
|
74
|
+
fetch_pull_request as _fetch_pull_request,
|
|
75
|
+
fetch_pull_request_files as _fetch_pull_request_files,
|
|
76
|
+
resolve_github_token_from_env_or_gh,
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
from tools import code_mower_prompts, local_llm_profiles
|
|
80
|
+
from tools.provider_runners import (
|
|
81
|
+
fetch_pull_request as _fetch_pull_request,
|
|
82
|
+
fetch_pull_request_files as _fetch_pull_request_files,
|
|
83
|
+
resolve_github_token_from_env_or_gh,
|
|
84
|
+
)
|
|
85
|
+
elif __package__ == "tools":
|
|
86
|
+
from tools import code_mower_prompts, local_llm_profiles
|
|
87
|
+
from tools.provider_runners import (
|
|
88
|
+
fetch_pull_request as _fetch_pull_request,
|
|
89
|
+
fetch_pull_request_files as _fetch_pull_request_files,
|
|
90
|
+
resolve_github_token_from_env_or_gh,
|
|
91
|
+
)
|
|
92
|
+
else: # pragma: no cover - exercised after package extraction.
|
|
93
|
+
from . import local_llm_profiles
|
|
94
|
+
from . import prompts as code_mower_prompts
|
|
95
|
+
from .provider_runners import (
|
|
96
|
+
fetch_pull_request as _fetch_pull_request,
|
|
97
|
+
fetch_pull_request_files as _fetch_pull_request_files,
|
|
98
|
+
resolve_github_token_from_env_or_gh,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ----- Configuration / defaults -----
|
|
103
|
+
|
|
104
|
+
DEFAULT_API_BASE = "http://localhost:1234/v1" # LM Studio
|
|
105
|
+
DEFAULT_MODEL = "qwen/qwen3-coder-next" # LM Studio model id; override via LOCAL_LLM_MODEL
|
|
106
|
+
DEFAULT_API_KEY = "EMPTY"
|
|
107
|
+
DEFAULT_HTTP_TIMEOUT = 600
|
|
108
|
+
DEFAULT_PROMPT_REF = "origin/main"
|
|
109
|
+
DEFAULT_BASE_REF = "origin/main"
|
|
110
|
+
|
|
111
|
+
# File-size budgets. Files over MAX_FILE_BYTES are referenced by name only;
|
|
112
|
+
# the LLM sees the diff hunks but not the full content. Files under that limit
|
|
113
|
+
# are included in full.
|
|
114
|
+
MAX_FILE_BYTES = 60_000 # ~1500 lines of typical code
|
|
115
|
+
MAX_FILES_PER_REVIEW = 25 # synthesis pass struggles past ~25 per-file findings
|
|
116
|
+
MAX_PER_FILE_PROMPT_TOKENS = 6000 # rough budget; we truncate content if hit
|
|
117
|
+
MAX_FETCHED_PR_FILES = 500 # GitHub pulls/files pagination cap in fetch_pr_files()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ----- Data classes -----
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class AuditConfig:
|
|
125
|
+
github_token: str
|
|
126
|
+
api_base: str = DEFAULT_API_BASE
|
|
127
|
+
model: str = DEFAULT_MODEL
|
|
128
|
+
api_key: str = DEFAULT_API_KEY
|
|
129
|
+
http_timeout: int = DEFAULT_HTTP_TIMEOUT
|
|
130
|
+
dry_run: bool = False
|
|
131
|
+
max_file_bytes: int = MAX_FILE_BYTES
|
|
132
|
+
max_files: int = MAX_FILES_PER_REVIEW
|
|
133
|
+
profile_id: str = ""
|
|
134
|
+
context_window: int = 0
|
|
135
|
+
json_repair_retries: int = 1
|
|
136
|
+
prompt_lenses: tuple[str, ...] = field(
|
|
137
|
+
default_factory=lambda: code_mower_prompts.DEFAULT_REVIEW_LENSES
|
|
138
|
+
)
|
|
139
|
+
prompt_dir: Optional[Path] = None
|
|
140
|
+
prompt_ref: str = DEFAULT_PROMPT_REF
|
|
141
|
+
prompt_repo: Optional[Path] = None
|
|
142
|
+
repo_path: Optional[Path] = None
|
|
143
|
+
base_ref: str = DEFAULT_BASE_REF
|
|
144
|
+
allow_historical_head: bool = False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class FileFinding:
|
|
149
|
+
"""Per-file finding emitted by the per-file LLM pass."""
|
|
150
|
+
path: str
|
|
151
|
+
status: str # "added" | "modified" | "removed" | "renamed" | "skipped-binary" | "skipped-toobig"
|
|
152
|
+
blockers: List[str] = field(default_factory=list)
|
|
153
|
+
concerns: List[str] = field(default_factory=list)
|
|
154
|
+
raw_response: str = "" # for debugging / dry-run inspection
|
|
155
|
+
parse_attempts: int = 1
|
|
156
|
+
json_repair_used: bool = False
|
|
157
|
+
parse_failed: bool = False
|
|
158
|
+
|
|
159
|
+
def has_blocker(self) -> bool:
|
|
160
|
+
return bool(self.blockers)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class AuditResult:
|
|
165
|
+
repo: str
|
|
166
|
+
pr_number: int
|
|
167
|
+
head_sha_start: str
|
|
168
|
+
head_sha_end: str
|
|
169
|
+
file_findings: List[FileFinding]
|
|
170
|
+
synthesis_response: str
|
|
171
|
+
verdict: str # "PASS" | "BLOCKED" | "STALE"
|
|
172
|
+
trailer: str # the full HTML-comment trailer line
|
|
173
|
+
comment_body: str
|
|
174
|
+
posted_comment_url: Optional[str] = None
|
|
175
|
+
# PR-level blockers — issues that aren't tied to a single file but that
|
|
176
|
+
# gate the verdict. Currently used for the file-truncation case (PR has
|
|
177
|
+
# more changed files than config.max_files): Codex blocker on #231
|
|
178
|
+
# required this to be surfaced rather than silently dropped.
|
|
179
|
+
pr_level_blockers: List[str] = field(default_factory=list)
|
|
180
|
+
|
|
181
|
+
def head_changed_during_review(self) -> bool:
|
|
182
|
+
return self.head_sha_start != self.head_sha_end
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ----- GitHub helpers -----
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _gh_request(
|
|
189
|
+
method: str,
|
|
190
|
+
path: str,
|
|
191
|
+
*,
|
|
192
|
+
token: str,
|
|
193
|
+
body: Optional[Dict[str, Any]] = None,
|
|
194
|
+
accept: str = "application/vnd.github+json",
|
|
195
|
+
timeout: int = 30,
|
|
196
|
+
) -> Any:
|
|
197
|
+
"""Single GitHub REST call. Returns parsed JSON, or text for diff Accept."""
|
|
198
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
199
|
+
req = urllib.request.Request(
|
|
200
|
+
f"https://api.github.com{path}",
|
|
201
|
+
data=data,
|
|
202
|
+
headers={
|
|
203
|
+
"Accept": accept,
|
|
204
|
+
"Authorization": f"Bearer {token}",
|
|
205
|
+
"Content-Type": "application/json",
|
|
206
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
207
|
+
},
|
|
208
|
+
method=method,
|
|
209
|
+
)
|
|
210
|
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
211
|
+
body_bytes = response.read()
|
|
212
|
+
if accept.endswith("diff"):
|
|
213
|
+
return body_bytes.decode("utf-8", errors="replace")
|
|
214
|
+
text = body_bytes.decode("utf-8")
|
|
215
|
+
return json.loads(text) if text else None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def fetch_pull_request(repo: str, pr_number: int, *, token: str) -> Dict[str, Any]:
|
|
219
|
+
payload = _fetch_pull_request(repo, pr_number, token=token)
|
|
220
|
+
if not isinstance(payload, dict):
|
|
221
|
+
raise ValueError("GitHub pull request response was not an object")
|
|
222
|
+
return payload
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def fetch_pr_files(repo: str, pr_number: int, *, token: str) -> List[Dict[str, Any]]:
|
|
226
|
+
"""Return per-file diff entries (status, filename, patch, etc.)."""
|
|
227
|
+
|
|
228
|
+
return _fetch_pull_request_files(
|
|
229
|
+
repo,
|
|
230
|
+
pr_number,
|
|
231
|
+
token=token,
|
|
232
|
+
max_pages=MAX_FETCHED_PR_FILES // 100,
|
|
233
|
+
per_page=100,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def fetch_file_content(repo: str, path: str, ref: str, *, token: str) -> Optional[bytes]:
|
|
238
|
+
"""Fetch raw file content at a given ref. Returns None if not found (e.g.
|
|
239
|
+
file deleted in the PR), or if the path is a directory / symlink / submodule."""
|
|
240
|
+
try:
|
|
241
|
+
meta = _gh_request(
|
|
242
|
+
"GET",
|
|
243
|
+
f"/repos/{repo}/contents/{path}?ref={ref}",
|
|
244
|
+
token=token,
|
|
245
|
+
)
|
|
246
|
+
except urllib.error.HTTPError as exc:
|
|
247
|
+
if exc.code == 404:
|
|
248
|
+
return None
|
|
249
|
+
raise
|
|
250
|
+
if not isinstance(meta, dict):
|
|
251
|
+
# Directory listing — we asked for a path that's actually a directory.
|
|
252
|
+
return None
|
|
253
|
+
if meta.get("type") != "file":
|
|
254
|
+
return None
|
|
255
|
+
encoding = meta.get("encoding", "")
|
|
256
|
+
content = meta.get("content", "")
|
|
257
|
+
if encoding == "base64":
|
|
258
|
+
try:
|
|
259
|
+
return base64.b64decode(content)
|
|
260
|
+
except (ValueError, TypeError):
|
|
261
|
+
return None
|
|
262
|
+
# Large files: GitHub returns a download_url instead of inline content.
|
|
263
|
+
download_url = meta.get("download_url")
|
|
264
|
+
if download_url:
|
|
265
|
+
try:
|
|
266
|
+
with urllib.request.urlopen(download_url, timeout=30) as response:
|
|
267
|
+
return response.read()
|
|
268
|
+
except urllib.error.URLError:
|
|
269
|
+
return None
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _git(
|
|
274
|
+
repo_path: Path,
|
|
275
|
+
args: List[str],
|
|
276
|
+
*,
|
|
277
|
+
timeout: int = 60,
|
|
278
|
+
) -> subprocess.CompletedProcess[str]:
|
|
279
|
+
return subprocess.run(
|
|
280
|
+
["git", "-C", str(repo_path), *args],
|
|
281
|
+
capture_output=True,
|
|
282
|
+
text=True,
|
|
283
|
+
check=True,
|
|
284
|
+
timeout=timeout,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _local_head_sha(repo_path: Path) -> str:
|
|
289
|
+
return _git(repo_path, ["rev-parse", "HEAD"]).stdout.strip()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _local_file_content(repo_path: Path, path: str) -> Optional[bytes]:
|
|
293
|
+
relative = Path(path)
|
|
294
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
295
|
+
return None
|
|
296
|
+
resolved_repo_path = repo_path.expanduser().resolve()
|
|
297
|
+
try:
|
|
298
|
+
completed = subprocess.run(
|
|
299
|
+
["git", "-C", str(resolved_repo_path), "show", f"HEAD:{path}"],
|
|
300
|
+
capture_output=True,
|
|
301
|
+
check=True,
|
|
302
|
+
timeout=60,
|
|
303
|
+
)
|
|
304
|
+
return completed.stdout
|
|
305
|
+
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _status_name(code: str) -> str:
|
|
310
|
+
if code.startswith("A"):
|
|
311
|
+
return "added"
|
|
312
|
+
if code.startswith("D"):
|
|
313
|
+
return "removed"
|
|
314
|
+
if code.startswith("R"):
|
|
315
|
+
return "renamed"
|
|
316
|
+
if code.startswith("C"):
|
|
317
|
+
return "copied"
|
|
318
|
+
return "modified"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def fetch_local_pr_files(repo_path: Path, *, base_ref: str) -> List[Dict[str, Any]]:
|
|
322
|
+
resolved_repo_path = repo_path.expanduser().resolve()
|
|
323
|
+
status_lines = _git(
|
|
324
|
+
resolved_repo_path,
|
|
325
|
+
["diff", "--name-status", "--find-renames", f"{base_ref}...HEAD"],
|
|
326
|
+
).stdout.splitlines()
|
|
327
|
+
files: List[Dict[str, Any]] = []
|
|
328
|
+
for line in status_lines:
|
|
329
|
+
parts = line.split("\t")
|
|
330
|
+
if len(parts) < 2:
|
|
331
|
+
continue
|
|
332
|
+
status_code = parts[0]
|
|
333
|
+
filename = parts[-1]
|
|
334
|
+
patch = _git(
|
|
335
|
+
resolved_repo_path,
|
|
336
|
+
[
|
|
337
|
+
"diff",
|
|
338
|
+
"--no-ext-diff",
|
|
339
|
+
"--find-renames",
|
|
340
|
+
f"{base_ref}...HEAD",
|
|
341
|
+
"--",
|
|
342
|
+
filename,
|
|
343
|
+
],
|
|
344
|
+
timeout=120,
|
|
345
|
+
).stdout
|
|
346
|
+
files.append(
|
|
347
|
+
{
|
|
348
|
+
"filename": filename,
|
|
349
|
+
"status": _status_name(status_code),
|
|
350
|
+
"patch": patch,
|
|
351
|
+
}
|
|
352
|
+
)
|
|
353
|
+
return files
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def post_pr_comment(repo: str, pr_number: int, body: str, *, token: str) -> Dict[str, Any]:
|
|
357
|
+
return _gh_request(
|
|
358
|
+
"POST",
|
|
359
|
+
f"/repos/{repo}/issues/{pr_number}/comments",
|
|
360
|
+
token=token,
|
|
361
|
+
body={"body": body},
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# ----- LLM call -----
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def call_llm(config: AuditConfig, system: str, user: str, *, max_tokens: int = 2048) -> str:
|
|
369
|
+
"""Single LLM chat-completion. Returns raw text."""
|
|
370
|
+
req_body = {
|
|
371
|
+
"model": config.model,
|
|
372
|
+
"messages": [
|
|
373
|
+
{"role": "system", "content": system},
|
|
374
|
+
{"role": "user", "content": user},
|
|
375
|
+
],
|
|
376
|
+
"temperature": 0.1,
|
|
377
|
+
"max_tokens": max_tokens,
|
|
378
|
+
}
|
|
379
|
+
data = json.dumps(req_body).encode("utf-8")
|
|
380
|
+
req = urllib.request.Request(
|
|
381
|
+
f"{config.api_base.rstrip('/')}/chat/completions",
|
|
382
|
+
data=data,
|
|
383
|
+
headers={
|
|
384
|
+
"Authorization": f"Bearer {config.api_key}",
|
|
385
|
+
"Content-Type": "application/json",
|
|
386
|
+
},
|
|
387
|
+
method="POST",
|
|
388
|
+
)
|
|
389
|
+
with urllib.request.urlopen(req, timeout=config.http_timeout) as response:
|
|
390
|
+
body = json.loads(response.read().decode("utf-8"))
|
|
391
|
+
return body["choices"][0]["message"]["content"]
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ----- Prompts -----
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
PER_FILE_SYSTEM_PROMPT = """\
|
|
398
|
+
You are an automated code reviewer. Your job is to find BLOCKERS — issues
|
|
399
|
+
that must be fixed before merge — and surface non-blocking CONCERNS.
|
|
400
|
+
|
|
401
|
+
You will see ONE file at a time: its full current contents (at PR head)
|
|
402
|
+
plus the unified diff of what changed. You also see the PR's title and
|
|
403
|
+
body for context.
|
|
404
|
+
|
|
405
|
+
You MUST classify every observation into one of three severities:
|
|
406
|
+
|
|
407
|
+
BLOCKER — must fix before merge. Examples:
|
|
408
|
+
- correctness bug introduced by the change
|
|
409
|
+
- missing test for new behavior (when the PR claims tests)
|
|
410
|
+
- schema / contract break
|
|
411
|
+
- committed secret or credential
|
|
412
|
+
- claim in PR body cannot be verified from the visible code
|
|
413
|
+
AND the missing evidence could hide a correctness,
|
|
414
|
+
security, schema, or test-coverage gap
|
|
415
|
+
CONCERN — non-blocking observation (style, naming, refactor opportunity)
|
|
416
|
+
- PR prose claims that are not fully visible but do not
|
|
417
|
+
create a concrete merge risk
|
|
418
|
+
NONE — no issues to report
|
|
419
|
+
|
|
420
|
+
Output STRICTLY in this JSON format and NOTHING else (no prose around it,
|
|
421
|
+
no markdown fences):
|
|
422
|
+
|
|
423
|
+
{"blockers": ["...", "..."], "concerns": ["...", "..."]}
|
|
424
|
+
|
|
425
|
+
Empty arrays are fine. Each entry is one specific finding, ideally with
|
|
426
|
+
file location ("line ~123: ...") and what the fix would be.
|
|
427
|
+
"""
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
PER_FILE_REPAIR_SYSTEM_PROMPT = PER_FILE_SYSTEM_PROMPT + """\
|
|
431
|
+
|
|
432
|
+
Your previous response for this same file was not parseable JSON. Re-review
|
|
433
|
+
the file and return ONLY the strict JSON object. Do not include markdown,
|
|
434
|
+
commentary, or a code fence.
|
|
435
|
+
"""
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
SYNTHESIS_SYSTEM_PROMPT = """\
|
|
439
|
+
You are an automated code reviewer producing the FINAL verdict for a
|
|
440
|
+
pull request. The per-file pass already produced findings for each
|
|
441
|
+
changed file. Your job is two things:
|
|
442
|
+
|
|
443
|
+
1. Surface CROSS-FILE / PR-LEVEL issues the per-file pass cannot see:
|
|
444
|
+
- test coverage: does the changed behavior have tests that
|
|
445
|
+
actually exercise the changed code paths?
|
|
446
|
+
- doc-index consistency: did the PR add tools/fixtures that
|
|
447
|
+
should be registered in README / index docs?
|
|
448
|
+
- lane discipline: does the PR touch paths that are owned by
|
|
449
|
+
a different lane (e.g., Codex-owned production code in a
|
|
450
|
+
Claude PR) without coordination?
|
|
451
|
+
- secrets / config: any `.env`, credentials, or tokens?
|
|
452
|
+
- schema / regression-gate: if production geometry / recognizer
|
|
453
|
+
behavior changed, does the PR include the row-level baseline
|
|
454
|
+
diff (not just aggregate A/B)?
|
|
455
|
+
|
|
456
|
+
2. Produce the FINAL human-readable comment.
|
|
457
|
+
|
|
458
|
+
Severity rules (same as per-file pass):
|
|
459
|
+
BLOCKER → must fix before merge
|
|
460
|
+
CONCERN → non-blocking
|
|
461
|
+
NONE → no issues
|
|
462
|
+
|
|
463
|
+
Final verdict logic:
|
|
464
|
+
Any BLOCKER (from per-file findings OR your cross-cutting pass) → BLOCKED
|
|
465
|
+
Else → PASS
|
|
466
|
+
|
|
467
|
+
Output STRICTLY in this format (no JSON, no markdown fences around the
|
|
468
|
+
whole output — but you may use markdown WITHIN the human-readable body):
|
|
469
|
+
|
|
470
|
+
Local LLM Audit: <PASS or BLOCKED>
|
|
471
|
+
|
|
472
|
+
<one or two sentence headline>
|
|
473
|
+
|
|
474
|
+
<detailed body — markdown OK. List BLOCKERs first if any, then CONCERNs,
|
|
475
|
+
then "Cross-cutting notes" with anything from your PR-level pass.>
|
|
476
|
+
|
|
477
|
+
<!-- LOCAL_LLM_AUDIT_STATE: local-llm-audit-done -->
|
|
478
|
+
or
|
|
479
|
+
<!-- LOCAL_LLM_AUDIT_STATE: local-llm-audit-blocked -->
|
|
480
|
+
|
|
481
|
+
Pick exactly ONE trailer matching the verdict. The trailer MUST be the
|
|
482
|
+
final line of your output.
|
|
483
|
+
|
|
484
|
+
Important:
|
|
485
|
+
- Do not approve by vibes. If a claim made in the PR body is unverifiable and
|
|
486
|
+
could hide a correctness, security, schema, or test-coverage gap, treat it as
|
|
487
|
+
a BLOCKER ("cannot verify: ...").
|
|
488
|
+
- If an unverifiable claim is just release prose or background context with no
|
|
489
|
+
concrete merge risk, list it as a CONCERN rather than blocking the PR.
|
|
490
|
+
- If you only have CONCERNs (no BLOCKERs), the verdict is PASS, and
|
|
491
|
+
the CONCERNs are listed in the body for the author to consider.
|
|
492
|
+
"""
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _review_doctrine(config: AuditConfig) -> str:
|
|
496
|
+
return code_mower_prompts.load_review_prompt(
|
|
497
|
+
config.prompt_lenses,
|
|
498
|
+
prompt_dir=config.prompt_dir,
|
|
499
|
+
trusted_git_ref=None if config.prompt_dir else config.prompt_ref,
|
|
500
|
+
repo_root=config.prompt_repo or Path.cwd(),
|
|
501
|
+
missing_ok=True,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _with_review_doctrine(system_prompt: str, doctrine: str) -> str:
|
|
506
|
+
return code_mower_prompts.append_review_prompt(system_prompt, doctrine)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _safe_decode(content_bytes: bytes) -> Tuple[Optional[str], str]:
|
|
510
|
+
"""Return (decoded_text, reason) where reason is 'ok', 'binary', or 'too-big'."""
|
|
511
|
+
if b"\x00" in content_bytes[:8192]:
|
|
512
|
+
return None, "binary"
|
|
513
|
+
try:
|
|
514
|
+
return content_bytes.decode("utf-8"), "ok"
|
|
515
|
+
except UnicodeDecodeError:
|
|
516
|
+
return None, "binary"
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def build_per_file_user_prompt(
|
|
520
|
+
file_meta: Dict[str, Any],
|
|
521
|
+
file_content: Optional[str],
|
|
522
|
+
pr_meta: Dict[str, Any],
|
|
523
|
+
*,
|
|
524
|
+
max_file_bytes: int = MAX_FILE_BYTES,
|
|
525
|
+
) -> str:
|
|
526
|
+
"""Compose the per-file LLM prompt."""
|
|
527
|
+
patch = file_meta.get("patch", "") or "(no patch — likely a binary or rename-only)"
|
|
528
|
+
status = file_meta.get("status", "modified")
|
|
529
|
+
path = file_meta.get("filename", "?")
|
|
530
|
+
|
|
531
|
+
if file_content is None:
|
|
532
|
+
content_block = "(file content not available — file may have been deleted,\nbe binary, exceed size limit, or be a directory/submodule)"
|
|
533
|
+
elif len(file_content) > max_file_bytes:
|
|
534
|
+
content_block = (
|
|
535
|
+
file_content[:max_file_bytes]
|
|
536
|
+
+ f"\n\n... (truncated: file is {len(file_content)} bytes, showing first {max_file_bytes})\n"
|
|
537
|
+
)
|
|
538
|
+
else:
|
|
539
|
+
content_block = file_content
|
|
540
|
+
|
|
541
|
+
pr_title = pr_meta.get("title", "")
|
|
542
|
+
pr_body = (pr_meta.get("body") or "").strip()
|
|
543
|
+
pr_body_short = pr_body[:2000] + ("\n... (truncated)\n" if len(pr_body) > 2000 else "")
|
|
544
|
+
|
|
545
|
+
return textwrap.dedent(
|
|
546
|
+
"""\
|
|
547
|
+
PR title: {title}
|
|
548
|
+
PR body (truncated to 2000 chars):
|
|
549
|
+
---
|
|
550
|
+
{body}
|
|
551
|
+
---
|
|
552
|
+
|
|
553
|
+
File path: {path}
|
|
554
|
+
Status: {status}
|
|
555
|
+
|
|
556
|
+
## Full file content at PR head SHA
|
|
557
|
+
|
|
558
|
+
```
|
|
559
|
+
{content}
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
## Diff (unified)
|
|
563
|
+
|
|
564
|
+
```diff
|
|
565
|
+
{patch}
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
Review this single file. Output the JSON object only.
|
|
569
|
+
"""
|
|
570
|
+
).format(
|
|
571
|
+
title=pr_title,
|
|
572
|
+
body=pr_body_short or "(empty)",
|
|
573
|
+
path=path,
|
|
574
|
+
status=status,
|
|
575
|
+
content=content_block,
|
|
576
|
+
patch=patch,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def build_synthesis_user_prompt(
|
|
581
|
+
pr_meta: Dict[str, Any],
|
|
582
|
+
findings: List[FileFinding],
|
|
583
|
+
pr_level_blockers: Optional[List[str]] = None,
|
|
584
|
+
) -> str:
|
|
585
|
+
"""Compose the synthesis LLM prompt."""
|
|
586
|
+
pr_level_blockers = pr_level_blockers or []
|
|
587
|
+
pr_title = pr_meta.get("title", "")
|
|
588
|
+
pr_body = (pr_meta.get("body") or "").strip()
|
|
589
|
+
pr_body_short = pr_body[:3000] + ("\n... (truncated)\n" if len(pr_body) > 3000 else "")
|
|
590
|
+
repo = pr_meta.get("base", {}).get("repo", {}).get("full_name", "?")
|
|
591
|
+
head_sha = pr_meta.get("head", {}).get("sha", "?")
|
|
592
|
+
|
|
593
|
+
file_list_lines = []
|
|
594
|
+
findings_lines = []
|
|
595
|
+
for f in findings:
|
|
596
|
+
file_list_lines.append(f"- `{f.path}` ({f.status})")
|
|
597
|
+
if f.blockers or f.concerns:
|
|
598
|
+
findings_lines.append(f"### {f.path}")
|
|
599
|
+
for b in f.blockers:
|
|
600
|
+
findings_lines.append(f"- BLOCKER: {b}")
|
|
601
|
+
for c in f.concerns:
|
|
602
|
+
findings_lines.append(f"- CONCERN: {c}")
|
|
603
|
+
findings_lines.append("")
|
|
604
|
+
elif f.status.startswith("skipped"):
|
|
605
|
+
findings_lines.append(f"### {f.path}")
|
|
606
|
+
findings_lines.append(f"- {f.status} (no review)")
|
|
607
|
+
findings_lines.append("")
|
|
608
|
+
|
|
609
|
+
files_block = "\n".join(file_list_lines) or "(none)"
|
|
610
|
+
findings_block = "\n".join(findings_lines) or "(per-file pass found no issues)"
|
|
611
|
+
|
|
612
|
+
pr_level_block_text = ""
|
|
613
|
+
if pr_level_blockers:
|
|
614
|
+
pr_level_lines = ["## PR-level BLOCKERs (these gate the verdict regardless of per-file findings)\n"]
|
|
615
|
+
for b in pr_level_blockers:
|
|
616
|
+
pr_level_lines.append(f"- {b}")
|
|
617
|
+
pr_level_block_text = "\n".join(pr_level_lines) + "\n\n"
|
|
618
|
+
|
|
619
|
+
return textwrap.dedent(
|
|
620
|
+
"""\
|
|
621
|
+
Repository: {repo}
|
|
622
|
+
PR #{pr_number}: {title}
|
|
623
|
+
Head SHA: {head_sha}
|
|
624
|
+
Author: {author}
|
|
625
|
+
Files changed ({n_files}):
|
|
626
|
+
{files}
|
|
627
|
+
|
|
628
|
+
{pr_level_block}## PR body (truncated to 3000 chars)
|
|
629
|
+
|
|
630
|
+
{body}
|
|
631
|
+
|
|
632
|
+
## Per-file findings
|
|
633
|
+
|
|
634
|
+
{findings}
|
|
635
|
+
|
|
636
|
+
Now produce the FINAL audit comment per your system prompt. Remember:
|
|
637
|
+
- Surface cross-file / PR-level issues the per-file pass cannot see.
|
|
638
|
+
- If you cannot verify a claim in the PR body and the missing evidence
|
|
639
|
+
could hide a correctness, security, schema, or test-coverage gap,
|
|
640
|
+
that's a BLOCKER, not a PASS.
|
|
641
|
+
- If the unverifiable claim does not create a concrete merge risk,
|
|
642
|
+
list it as a CONCERN.
|
|
643
|
+
- If the PR-level BLOCKERs section above is non-empty, the verdict
|
|
644
|
+
MUST be BLOCKED and you must reference those PR-level blockers
|
|
645
|
+
in the body.
|
|
646
|
+
- Final line MUST be the trailer:
|
|
647
|
+
<!-- LOCAL_LLM_AUDIT_STATE: local-llm-audit-done --> (PASS)
|
|
648
|
+
<!-- LOCAL_LLM_AUDIT_STATE: local-llm-audit-blocked --> (BLOCKED)
|
|
649
|
+
"""
|
|
650
|
+
).format(
|
|
651
|
+
repo=repo,
|
|
652
|
+
pr_number=pr_meta.get("number", "?"),
|
|
653
|
+
title=pr_title,
|
|
654
|
+
head_sha=head_sha,
|
|
655
|
+
author=pr_meta.get("user", {}).get("login", "?"),
|
|
656
|
+
n_files=len(findings),
|
|
657
|
+
files=files_block,
|
|
658
|
+
pr_level_block=pr_level_block_text,
|
|
659
|
+
body=pr_body_short or "(empty)",
|
|
660
|
+
findings=findings_block,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
# ----- Response parsing -----
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def parse_per_file_response(text: str) -> Optional[Tuple[List[str], List[str]]]:
|
|
668
|
+
"""Parse the JSON `{blockers: [...], concerns: [...]}` from the per-file
|
|
669
|
+
pass. Tolerant of extra prose / markdown fences.
|
|
670
|
+
|
|
671
|
+
Returns `(blockers, concerns)` on successful parse (either list may be
|
|
672
|
+
empty, meaning "the LLM reviewed this file and found nothing").
|
|
673
|
+
|
|
674
|
+
Returns ``None`` when no parseable JSON object was found in the response
|
|
675
|
+
OR when the JSON didn't have the expected shape. Callers MUST treat
|
|
676
|
+
``None`` differently from `([], [])` — a parse failure is a reviewer
|
|
677
|
+
breakdown, not a clean review. The caller in `review_one_file` converts
|
|
678
|
+
this into a BLOCKER per Codex's "don't approve by vibes" rule on PR #231.
|
|
679
|
+
"""
|
|
680
|
+
text = text.strip()
|
|
681
|
+
# Strip code fences if present.
|
|
682
|
+
if text.startswith("```"):
|
|
683
|
+
first_newline = text.find("\n")
|
|
684
|
+
if first_newline != -1:
|
|
685
|
+
text = text[first_newline + 1 :]
|
|
686
|
+
if text.endswith("```"):
|
|
687
|
+
text = text[: -3]
|
|
688
|
+
text = text.strip()
|
|
689
|
+
# Find the outermost JSON object.
|
|
690
|
+
start = text.find("{")
|
|
691
|
+
end = text.rfind("}")
|
|
692
|
+
if start == -1 or end == -1 or end <= start:
|
|
693
|
+
return None
|
|
694
|
+
candidate = text[start : end + 1]
|
|
695
|
+
try:
|
|
696
|
+
obj = json.loads(candidate)
|
|
697
|
+
except json.JSONDecodeError:
|
|
698
|
+
return None
|
|
699
|
+
if not isinstance(obj, dict):
|
|
700
|
+
return None
|
|
701
|
+
# If the parsed object doesn't expose at least one of the expected keys,
|
|
702
|
+
# treat as parse failure — the LLM emitted JSON in a format we can't trust.
|
|
703
|
+
if "blockers" not in obj and "concerns" not in obj:
|
|
704
|
+
return None
|
|
705
|
+
blockers = [str(x) for x in obj.get("blockers", []) if x]
|
|
706
|
+
concerns = [str(x) for x in obj.get("concerns", []) if x]
|
|
707
|
+
return blockers, concerns
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
STALE_TRAILER = "<!-- LOCAL_LLM_AUDIT_STATE: needs-local-llm-audit -->"
|
|
711
|
+
DONE_TRAILER = "<!-- LOCAL_LLM_AUDIT_STATE: local-llm-audit-done -->"
|
|
712
|
+
BLOCKED_TRAILER = "<!-- LOCAL_LLM_AUDIT_STATE: local-llm-audit-blocked -->"
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def ensure_trailer(synthesis_text: str, verdict: str) -> str:
|
|
716
|
+
"""Ensure the synthesis response ends with the correct authoritative trailer.
|
|
717
|
+
Strips any other LOCAL_LLM_AUDIT_STATE trailers the LLM may have emitted and
|
|
718
|
+
appends the canonical one for the verdict."""
|
|
719
|
+
canonical = DONE_TRAILER if verdict == "PASS" else BLOCKED_TRAILER
|
|
720
|
+
cleaned_lines = [
|
|
721
|
+
line for line in synthesis_text.splitlines()
|
|
722
|
+
if "LOCAL_LLM_AUDIT_STATE" not in line
|
|
723
|
+
]
|
|
724
|
+
while cleaned_lines and not cleaned_lines[-1].strip():
|
|
725
|
+
cleaned_lines.pop()
|
|
726
|
+
cleaned_lines.append("")
|
|
727
|
+
cleaned_lines.append(canonical)
|
|
728
|
+
return "\n".join(cleaned_lines) + "\n"
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# ----- Orchestration -----
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _is_likely_binary_filename(path: str) -> bool:
|
|
735
|
+
binary_exts = {
|
|
736
|
+
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp", ".ico",
|
|
737
|
+
".pdf", ".zip", ".tar", ".gz", ".bz2", ".7z", ".rar",
|
|
738
|
+
".woff", ".woff2", ".ttf", ".otf", ".eot",
|
|
739
|
+
".mp3", ".mp4", ".mov", ".avi", ".wav", ".flac",
|
|
740
|
+
".pyc", ".so", ".dylib", ".dll", ".exe",
|
|
741
|
+
".onnx", ".pt", ".pth", ".bin", ".npy", ".npz",
|
|
742
|
+
}
|
|
743
|
+
lower = path.lower()
|
|
744
|
+
return any(lower.endswith(ext) for ext in binary_exts)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def review_one_file(
|
|
748
|
+
config: AuditConfig,
|
|
749
|
+
repo: str,
|
|
750
|
+
head_sha: str,
|
|
751
|
+
file_meta: Dict[str, Any],
|
|
752
|
+
pr_meta: Dict[str, Any],
|
|
753
|
+
*,
|
|
754
|
+
log: bool = True,
|
|
755
|
+
review_doctrine: str | None = None,
|
|
756
|
+
) -> FileFinding:
|
|
757
|
+
path = file_meta.get("filename", "?")
|
|
758
|
+
status = file_meta.get("status", "modified")
|
|
759
|
+
|
|
760
|
+
if _is_likely_binary_filename(path):
|
|
761
|
+
if log:
|
|
762
|
+
print(f" skip-binary {path}", file=sys.stderr, flush=True)
|
|
763
|
+
return FileFinding(path=path, status="skipped-binary")
|
|
764
|
+
|
|
765
|
+
# Removed files have no content at head; only patch.
|
|
766
|
+
file_content: Optional[str] = None
|
|
767
|
+
if status != "removed":
|
|
768
|
+
raw = (
|
|
769
|
+
_local_file_content(config.repo_path, path)
|
|
770
|
+
if config.repo_path is not None
|
|
771
|
+
else fetch_file_content(repo, path, head_sha, token=config.github_token)
|
|
772
|
+
)
|
|
773
|
+
if raw is None:
|
|
774
|
+
file_content = None
|
|
775
|
+
else:
|
|
776
|
+
text, reason = _safe_decode(raw)
|
|
777
|
+
if reason == "binary":
|
|
778
|
+
if log:
|
|
779
|
+
print(f" skip-binary-content {path}", file=sys.stderr, flush=True)
|
|
780
|
+
return FileFinding(path=path, status="skipped-binary")
|
|
781
|
+
if len(raw) > config.max_file_bytes:
|
|
782
|
+
if log:
|
|
783
|
+
print(f" truncate-toobig {path} ({len(raw)}B)", file=sys.stderr, flush=True)
|
|
784
|
+
file_content = text
|
|
785
|
+
|
|
786
|
+
user_prompt = build_per_file_user_prompt(
|
|
787
|
+
file_meta,
|
|
788
|
+
file_content,
|
|
789
|
+
pr_meta,
|
|
790
|
+
max_file_bytes=config.max_file_bytes,
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
if log:
|
|
794
|
+
size_hint = len(file_content) if file_content else 0
|
|
795
|
+
print(f" review {path} (content={size_hint}ch)", file=sys.stderr, flush=True)
|
|
796
|
+
|
|
797
|
+
response = ""
|
|
798
|
+
parsed: Optional[Tuple[List[str], List[str]]] = None
|
|
799
|
+
parse_attempts = 0
|
|
800
|
+
json_repair_used = False
|
|
801
|
+
call_error: Optional[Exception] = None
|
|
802
|
+
doctrine = _review_doctrine(config) if review_doctrine is None else review_doctrine
|
|
803
|
+
prompts = [_with_review_doctrine(PER_FILE_SYSTEM_PROMPT, doctrine)] + [
|
|
804
|
+
_with_review_doctrine(PER_FILE_REPAIR_SYSTEM_PROMPT, doctrine)
|
|
805
|
+
for _ in range(max(0, config.json_repair_retries))
|
|
806
|
+
]
|
|
807
|
+
|
|
808
|
+
for attempt, system_prompt in enumerate(prompts, start=1):
|
|
809
|
+
parse_attempts = attempt
|
|
810
|
+
if attempt > 1:
|
|
811
|
+
json_repair_used = True
|
|
812
|
+
if log:
|
|
813
|
+
print(f" json-retry {path} attempt={attempt}", file=sys.stderr, flush=True)
|
|
814
|
+
try:
|
|
815
|
+
# 2048 tokens is empirically enough for the verbose-concerns case on
|
|
816
|
+
# files up to ~15K chars (observed on reference-app#142 per-file passes;
|
|
817
|
+
# 1024 was hitting length truncation mid-JSON, which previously
|
|
818
|
+
# silently became PASS — now becomes a parse-failure BLOCKER per
|
|
819
|
+
# Codex's #231 review).
|
|
820
|
+
response = call_llm(config, system_prompt, user_prompt, max_tokens=2048)
|
|
821
|
+
except (urllib.error.URLError, KeyError, json.JSONDecodeError) as exc:
|
|
822
|
+
call_error = exc
|
|
823
|
+
break
|
|
824
|
+
|
|
825
|
+
parsed = parse_per_file_response(response)
|
|
826
|
+
if parsed is not None:
|
|
827
|
+
break
|
|
828
|
+
|
|
829
|
+
preview = response.strip().replace("\n", " ")[:200]
|
|
830
|
+
if log:
|
|
831
|
+
print(
|
|
832
|
+
f" parse-failure {path} attempt={attempt}: {preview[:80]}",
|
|
833
|
+
file=sys.stderr,
|
|
834
|
+
flush=True,
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
if call_error is not None:
|
|
838
|
+
if log:
|
|
839
|
+
print(f" llm-error {path}: {call_error}", file=sys.stderr, flush=True)
|
|
840
|
+
return FileFinding(
|
|
841
|
+
path=path,
|
|
842
|
+
status=status,
|
|
843
|
+
blockers=[f"Per-file LLM call failed: {call_error}. Cannot review this file."],
|
|
844
|
+
raw_response=response,
|
|
845
|
+
parse_attempts=parse_attempts,
|
|
846
|
+
json_repair_used=json_repair_used,
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
if parsed is None:
|
|
850
|
+
# Codex blocker on #231: malformed LLM output must NOT be treated
|
|
851
|
+
# as clean. Emit a BLOCKER so the synthesis pass and final verdict
|
|
852
|
+
# reflect the reviewer's breakdown.
|
|
853
|
+
preview = response.strip().replace("\n", " ")[:200]
|
|
854
|
+
return FileFinding(
|
|
855
|
+
path=path,
|
|
856
|
+
status=status,
|
|
857
|
+
blockers=[
|
|
858
|
+
"Per-file LLM output was not parseable JSON after retry — "
|
|
859
|
+
"cannot trust the verdict for this file. Response preview: "
|
|
860
|
+
f"{preview!r}"
|
|
861
|
+
],
|
|
862
|
+
raw_response=response,
|
|
863
|
+
parse_attempts=parse_attempts,
|
|
864
|
+
json_repair_used=json_repair_used,
|
|
865
|
+
parse_failed=True,
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
blockers, concerns = parsed
|
|
869
|
+
return FileFinding(
|
|
870
|
+
path=path,
|
|
871
|
+
status=status,
|
|
872
|
+
blockers=blockers,
|
|
873
|
+
concerns=concerns,
|
|
874
|
+
raw_response=response,
|
|
875
|
+
parse_attempts=parse_attempts,
|
|
876
|
+
json_repair_used=json_repair_used,
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def audit_pr(config: AuditConfig, repo: str, pr_number: int) -> AuditResult:
|
|
881
|
+
"""End-to-end audit of one PR. Returns AuditResult (does NOT post by itself
|
|
882
|
+
unless caller wants — this function is pure orchestration plus optional
|
|
883
|
+
posting controlled by config.dry_run).
|
|
884
|
+
"""
|
|
885
|
+
pr_meta = fetch_pull_request(repo, pr_number, token=config.github_token)
|
|
886
|
+
pr_head_sha = pr_meta["head"]["sha"]
|
|
887
|
+
if config.repo_path is not None:
|
|
888
|
+
repo_path = config.repo_path.expanduser().resolve()
|
|
889
|
+
head_sha_start = _local_head_sha(repo_path)
|
|
890
|
+
if not config.allow_historical_head and head_sha_start != pr_head_sha:
|
|
891
|
+
raise RuntimeError(
|
|
892
|
+
"local checkout is not at the current PR head; use "
|
|
893
|
+
"allow_historical_head for archived calibration runs"
|
|
894
|
+
)
|
|
895
|
+
pr_meta = dict(pr_meta)
|
|
896
|
+
pr_meta["head"] = {**dict(pr_meta.get("head", {})), "sha": head_sha_start}
|
|
897
|
+
else:
|
|
898
|
+
repo_path = None
|
|
899
|
+
head_sha_start = pr_head_sha
|
|
900
|
+
|
|
901
|
+
all_files = (
|
|
902
|
+
fetch_local_pr_files(repo_path, base_ref=config.base_ref)
|
|
903
|
+
if repo_path is not None
|
|
904
|
+
else fetch_pr_files(repo, pr_number, token=config.github_token)
|
|
905
|
+
)
|
|
906
|
+
if repo_path is not None and not all_files:
|
|
907
|
+
raise RuntimeError(
|
|
908
|
+
"local checkout diff produced no files; set --base-ref to the "
|
|
909
|
+
"PR base or merge-base for archived calibration runs"
|
|
910
|
+
)
|
|
911
|
+
pr_level_blockers: List[str] = []
|
|
912
|
+
reported_changed_files = None if repo_path is not None else pr_meta.get("changed_files")
|
|
913
|
+
if isinstance(reported_changed_files, int) and reported_changed_files > len(all_files):
|
|
914
|
+
pr_level_blockers.append(
|
|
915
|
+
f"GitHub reports {reported_changed_files} changed files, but the "
|
|
916
|
+
f"reviewer fetched only {len(all_files)} via the pulls/files API "
|
|
917
|
+
f"pagination cap ({MAX_FETCHED_PR_FILES}). Files beyond the fetch "
|
|
918
|
+
"cap were not reviewed, so this verdict must remain blocked until "
|
|
919
|
+
"the PR is split or the fetcher is expanded."
|
|
920
|
+
)
|
|
921
|
+
if len(all_files) > config.max_files:
|
|
922
|
+
# Codex blocker on #231: silently reviewing the first N files of a
|
|
923
|
+
# bigger PR could allow a blocker in an omitted file to slip past
|
|
924
|
+
# as PASS. Surface this explicitly as a PR-level blocker that gates
|
|
925
|
+
# the verdict; the synthesis prompt also sees the omitted-files list.
|
|
926
|
+
omitted = [f.get("filename", "?") for f in all_files[config.max_files :]]
|
|
927
|
+
pr_level_blockers.append(
|
|
928
|
+
f"PR has {len(all_files)} changed files, exceeding the per-audit "
|
|
929
|
+
f"budget of {config.max_files}. Reviewed only the first "
|
|
930
|
+
f"{config.max_files}; {len(omitted)} files omitted: "
|
|
931
|
+
+ ", ".join(omitted[:10])
|
|
932
|
+
+ (f", ... ({len(omitted) - 10} more)" if len(omitted) > 10 else "")
|
|
933
|
+
+ ". Split the PR into smaller pieces, or re-audit with a larger "
|
|
934
|
+
"max_files setting, before treating this verdict as authoritative."
|
|
935
|
+
)
|
|
936
|
+
files = all_files[: config.max_files]
|
|
937
|
+
else:
|
|
938
|
+
files = all_files
|
|
939
|
+
|
|
940
|
+
print(
|
|
941
|
+
f"audit {repo}#{pr_number} head={head_sha_start[:8]} files={len(files)}"
|
|
942
|
+
f"{f' (of {len(all_files)} — truncated)' if pr_level_blockers else ''}",
|
|
943
|
+
file=sys.stderr, flush=True,
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
findings: List[FileFinding] = []
|
|
947
|
+
review_doctrine = _review_doctrine(config)
|
|
948
|
+
for file_meta in files:
|
|
949
|
+
finding = review_one_file(
|
|
950
|
+
config,
|
|
951
|
+
repo,
|
|
952
|
+
head_sha_start,
|
|
953
|
+
file_meta,
|
|
954
|
+
pr_meta,
|
|
955
|
+
review_doctrine=review_doctrine,
|
|
956
|
+
)
|
|
957
|
+
findings.append(finding)
|
|
958
|
+
|
|
959
|
+
# Stale HEAD check before synthesis. If HEAD changed during the per-file
|
|
960
|
+
# passes, requeue rather than produce a misleading verdict.
|
|
961
|
+
if repo_path is not None:
|
|
962
|
+
head_sha_end = _local_head_sha(repo_path)
|
|
963
|
+
else:
|
|
964
|
+
pr_meta_after = fetch_pull_request(repo, pr_number, token=config.github_token)
|
|
965
|
+
head_sha_end = pr_meta_after["head"]["sha"]
|
|
966
|
+
|
|
967
|
+
if head_sha_start != head_sha_end:
|
|
968
|
+
comment_body = _format_stale_comment(repo, pr_number, head_sha_start, head_sha_end)
|
|
969
|
+
result = AuditResult(
|
|
970
|
+
repo=repo,
|
|
971
|
+
pr_number=pr_number,
|
|
972
|
+
head_sha_start=head_sha_start,
|
|
973
|
+
head_sha_end=head_sha_end,
|
|
974
|
+
file_findings=findings,
|
|
975
|
+
synthesis_response="",
|
|
976
|
+
verdict="STALE",
|
|
977
|
+
trailer=STALE_TRAILER,
|
|
978
|
+
comment_body=comment_body,
|
|
979
|
+
)
|
|
980
|
+
if not config.dry_run:
|
|
981
|
+
posted = post_pr_comment(repo, pr_number, comment_body, token=config.github_token)
|
|
982
|
+
result.posted_comment_url = posted.get("html_url")
|
|
983
|
+
return result
|
|
984
|
+
|
|
985
|
+
# Synthesis pass. The PR-level blockers (if any) are passed alongside
|
|
986
|
+
# per-file findings so the LLM can incorporate them into its reasoning.
|
|
987
|
+
synth_user = build_synthesis_user_prompt(pr_meta, findings, pr_level_blockers)
|
|
988
|
+
synthesis_system_prompt = _with_review_doctrine(
|
|
989
|
+
SYNTHESIS_SYSTEM_PROMPT,
|
|
990
|
+
review_doctrine,
|
|
991
|
+
)
|
|
992
|
+
try:
|
|
993
|
+
synth_response = call_llm(config, synthesis_system_prompt, synth_user, max_tokens=3000)
|
|
994
|
+
except (urllib.error.URLError, KeyError, json.JSONDecodeError) as exc:
|
|
995
|
+
synth_response = (
|
|
996
|
+
f"Local LLM Audit: BLOCKED\n\n"
|
|
997
|
+
f"Synthesis pass failed: {exc}. The per-file pass produced the "
|
|
998
|
+
f"findings above but the final synthesis could not complete. "
|
|
999
|
+
f"Treating as BLOCKED out of caution.\n"
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
# Determine verdict from per-file findings, PR-level blockers, and the
|
|
1003
|
+
# synthesis self-claim. ANY of the three trips BLOCKED — per-file or
|
|
1004
|
+
# PR-level blockers cannot be overridden by a synthesis-pass PASS claim
|
|
1005
|
+
# (Codex's "don't approve by vibes" rule).
|
|
1006
|
+
has_per_file_blocker = any(f.has_blocker() for f in findings)
|
|
1007
|
+
has_pr_level_blocker = bool(pr_level_blockers)
|
|
1008
|
+
llm_says_blocked = "Local LLM Audit: BLOCKED" in synth_response
|
|
1009
|
+
verdict = "BLOCKED" if (has_per_file_blocker or has_pr_level_blocker or llm_says_blocked) else "PASS"
|
|
1010
|
+
|
|
1011
|
+
full_body = ensure_trailer(synth_response, verdict)
|
|
1012
|
+
comment_body = (
|
|
1013
|
+
_format_top_matter(repo, pr_number, head_sha_start, findings, pr_level_blockers)
|
|
1014
|
+
+ full_body
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
result = AuditResult(
|
|
1018
|
+
repo=repo,
|
|
1019
|
+
pr_number=pr_number,
|
|
1020
|
+
head_sha_start=head_sha_start,
|
|
1021
|
+
head_sha_end=head_sha_end,
|
|
1022
|
+
file_findings=findings,
|
|
1023
|
+
synthesis_response=synth_response,
|
|
1024
|
+
verdict=verdict,
|
|
1025
|
+
trailer=DONE_TRAILER if verdict == "PASS" else BLOCKED_TRAILER,
|
|
1026
|
+
comment_body=comment_body,
|
|
1027
|
+
pr_level_blockers=pr_level_blockers,
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
if not config.dry_run:
|
|
1031
|
+
posted = post_pr_comment(repo, pr_number, comment_body, token=config.github_token)
|
|
1032
|
+
result.posted_comment_url = posted.get("html_url")
|
|
1033
|
+
|
|
1034
|
+
return result
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _format_top_matter(
|
|
1038
|
+
repo: str,
|
|
1039
|
+
pr_number: int,
|
|
1040
|
+
head_sha: str,
|
|
1041
|
+
findings: List[FileFinding],
|
|
1042
|
+
pr_level_blockers: Optional[List[str]] = None,
|
|
1043
|
+
) -> str:
|
|
1044
|
+
n_files = len(findings)
|
|
1045
|
+
n_blocker_files = sum(1 for f in findings if f.has_blocker())
|
|
1046
|
+
n_concern_files = sum(1 for f in findings if f.concerns and not f.has_blocker())
|
|
1047
|
+
n_clean = n_files - n_blocker_files - n_concern_files
|
|
1048
|
+
|
|
1049
|
+
top = (
|
|
1050
|
+
f"## Local LLM audit (calibration phase — informational only)\n\n"
|
|
1051
|
+
f"Head SHA: `{head_sha}`\n"
|
|
1052
|
+
f"Files reviewed: {n_files} "
|
|
1053
|
+
f"(blocker findings: {n_blocker_files}, concern-only: {n_concern_files}, clean: {n_clean})\n\n"
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
if pr_level_blockers:
|
|
1057
|
+
top += "### PR-level blockers\n\n"
|
|
1058
|
+
for b in pr_level_blockers:
|
|
1059
|
+
top += f"- {b}\n"
|
|
1060
|
+
top += "\n"
|
|
1061
|
+
|
|
1062
|
+
return top
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _format_stale_comment(repo: str, pr_number: int, start_sha: str, end_sha: str) -> str:
|
|
1066
|
+
return (
|
|
1067
|
+
f"## Local LLM audit (calibration phase — informational only)\n\n"
|
|
1068
|
+
f"Head SHA changed during review (`{start_sha[:8]}` → `{end_sha[:8]}`). "
|
|
1069
|
+
f"Skipping this verdict and requeuing for re-review of the new head.\n\n"
|
|
1070
|
+
f"{STALE_TRAILER}\n"
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
# ----- CLI entry point -----
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _env_int(name: str) -> Optional[int]:
|
|
1078
|
+
value = os.environ.get(name)
|
|
1079
|
+
if value is None or value == "":
|
|
1080
|
+
return None
|
|
1081
|
+
try:
|
|
1082
|
+
return int(value)
|
|
1083
|
+
except ValueError as exc:
|
|
1084
|
+
raise ValueError(f"env var {name} must be an integer, got {value!r}") from exc
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _resolve_int_option(
|
|
1088
|
+
explicit_value: Optional[int],
|
|
1089
|
+
env_name: str,
|
|
1090
|
+
profile_value: Optional[int],
|
|
1091
|
+
default_value: int,
|
|
1092
|
+
) -> int:
|
|
1093
|
+
if explicit_value is not None:
|
|
1094
|
+
return explicit_value
|
|
1095
|
+
env_value = _env_int(env_name)
|
|
1096
|
+
if env_value is not None:
|
|
1097
|
+
return env_value
|
|
1098
|
+
if profile_value is not None:
|
|
1099
|
+
return profile_value
|
|
1100
|
+
return default_value
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def resolve_runtime_options(
|
|
1104
|
+
*,
|
|
1105
|
+
profile_id: Optional[str] = None,
|
|
1106
|
+
api_base: Optional[str] = None,
|
|
1107
|
+
model: Optional[str] = None,
|
|
1108
|
+
api_key: Optional[str] = None,
|
|
1109
|
+
http_timeout: Optional[int] = None,
|
|
1110
|
+
max_files: Optional[int] = None,
|
|
1111
|
+
max_file_bytes: Optional[int] = None,
|
|
1112
|
+
context_window: Optional[int] = None,
|
|
1113
|
+
json_repair_retries: Optional[int] = None,
|
|
1114
|
+
) -> Dict[str, Any]:
|
|
1115
|
+
"""Resolve local LLM runtime options.
|
|
1116
|
+
|
|
1117
|
+
Precedence is explicit CLI value, environment variable, named profile,
|
|
1118
|
+
hardcoded default. Profiles are data-only and never grant merge authority.
|
|
1119
|
+
"""
|
|
1120
|
+
resolved_profile_id = profile_id or os.environ.get("LOCAL_LLM_PROFILE") or ""
|
|
1121
|
+
profile = (
|
|
1122
|
+
local_llm_profiles.get_profile(resolved_profile_id)
|
|
1123
|
+
if resolved_profile_id
|
|
1124
|
+
else None
|
|
1125
|
+
)
|
|
1126
|
+
return {
|
|
1127
|
+
"profile_id": resolved_profile_id,
|
|
1128
|
+
"api_base": (
|
|
1129
|
+
api_base
|
|
1130
|
+
or os.environ.get("LOCAL_LLM_API_BASE")
|
|
1131
|
+
or (profile.api_base if profile else DEFAULT_API_BASE)
|
|
1132
|
+
),
|
|
1133
|
+
"model": (
|
|
1134
|
+
model
|
|
1135
|
+
or os.environ.get("LOCAL_LLM_MODEL")
|
|
1136
|
+
or (profile.model if profile else DEFAULT_MODEL)
|
|
1137
|
+
),
|
|
1138
|
+
"api_key": (
|
|
1139
|
+
api_key
|
|
1140
|
+
or os.environ.get("LOCAL_LLM_API_KEY")
|
|
1141
|
+
or (profile.api_key if profile else DEFAULT_API_KEY)
|
|
1142
|
+
),
|
|
1143
|
+
"http_timeout": _resolve_int_option(
|
|
1144
|
+
http_timeout,
|
|
1145
|
+
"LOCAL_LLM_HTTP_TIMEOUT",
|
|
1146
|
+
profile.http_timeout if profile else None,
|
|
1147
|
+
DEFAULT_HTTP_TIMEOUT,
|
|
1148
|
+
),
|
|
1149
|
+
"max_files": _resolve_int_option(
|
|
1150
|
+
max_files,
|
|
1151
|
+
"LOCAL_LLM_MAX_FILES",
|
|
1152
|
+
profile.max_files if profile else None,
|
|
1153
|
+
MAX_FILES_PER_REVIEW,
|
|
1154
|
+
),
|
|
1155
|
+
"max_file_bytes": _resolve_int_option(
|
|
1156
|
+
max_file_bytes,
|
|
1157
|
+
"LOCAL_LLM_MAX_FILE_BYTES",
|
|
1158
|
+
profile.max_file_bytes if profile else None,
|
|
1159
|
+
MAX_FILE_BYTES,
|
|
1160
|
+
),
|
|
1161
|
+
"context_window": _resolve_int_option(
|
|
1162
|
+
context_window,
|
|
1163
|
+
"LOCAL_LLM_CONTEXT_WINDOW",
|
|
1164
|
+
profile.context_window if profile else None,
|
|
1165
|
+
0,
|
|
1166
|
+
),
|
|
1167
|
+
"json_repair_retries": _resolve_int_option(
|
|
1168
|
+
json_repair_retries,
|
|
1169
|
+
"LOCAL_LLM_JSON_REPAIR_RETRIES",
|
|
1170
|
+
None,
|
|
1171
|
+
1,
|
|
1172
|
+
),
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
|
|
1177
|
+
ap = argparse.ArgumentParser(
|
|
1178
|
+
description="Local LLM audit CLI — review a single pull request.",
|
|
1179
|
+
)
|
|
1180
|
+
ap.add_argument("--repo", required=True, help="owner/repo, e.g. owner/repo")
|
|
1181
|
+
ap.add_argument("--pr", type=int, required=True, help="PR number")
|
|
1182
|
+
ap.add_argument(
|
|
1183
|
+
"--profile",
|
|
1184
|
+
choices=local_llm_profiles.profile_ids(),
|
|
1185
|
+
default=None,
|
|
1186
|
+
help="Named local LLM runtime profile.",
|
|
1187
|
+
)
|
|
1188
|
+
ap.add_argument("--api-base", default=None)
|
|
1189
|
+
ap.add_argument("--model", default=None)
|
|
1190
|
+
ap.add_argument("--api-key", default=None)
|
|
1191
|
+
ap.add_argument(
|
|
1192
|
+
"--http-timeout",
|
|
1193
|
+
type=int,
|
|
1194
|
+
default=None,
|
|
1195
|
+
help="HTTP timeout in seconds for local LLM calls.",
|
|
1196
|
+
)
|
|
1197
|
+
ap.add_argument(
|
|
1198
|
+
"--max-files",
|
|
1199
|
+
type=int,
|
|
1200
|
+
default=None,
|
|
1201
|
+
help=(
|
|
1202
|
+
"Maximum changed files to review before emitting a PR-level "
|
|
1203
|
+
"truncation blocker."
|
|
1204
|
+
),
|
|
1205
|
+
)
|
|
1206
|
+
ap.add_argument(
|
|
1207
|
+
"--max-file-bytes",
|
|
1208
|
+
type=int,
|
|
1209
|
+
default=None,
|
|
1210
|
+
help="Maximum file-content bytes included in each per-file prompt.",
|
|
1211
|
+
)
|
|
1212
|
+
ap.add_argument(
|
|
1213
|
+
"--context-window",
|
|
1214
|
+
type=int,
|
|
1215
|
+
default=None,
|
|
1216
|
+
help="Declared model context window for metadata and bakeoff reporting.",
|
|
1217
|
+
)
|
|
1218
|
+
ap.add_argument(
|
|
1219
|
+
"--json-repair-retries",
|
|
1220
|
+
type=int,
|
|
1221
|
+
default=None,
|
|
1222
|
+
help="Malformed per-file JSON retry count before fail-closed blocker.",
|
|
1223
|
+
)
|
|
1224
|
+
ap.add_argument(
|
|
1225
|
+
"--repo-path",
|
|
1226
|
+
type=Path,
|
|
1227
|
+
default=None,
|
|
1228
|
+
help="optional local checkout to review for archived calibration heads",
|
|
1229
|
+
)
|
|
1230
|
+
ap.add_argument("--base-ref", default=DEFAULT_BASE_REF)
|
|
1231
|
+
ap.add_argument(
|
|
1232
|
+
"--allow-historical-head",
|
|
1233
|
+
action="store_true",
|
|
1234
|
+
help="allow --repo-path HEAD to differ from the current GitHub PR head",
|
|
1235
|
+
)
|
|
1236
|
+
ap.add_argument(
|
|
1237
|
+
"--prompt-lenses",
|
|
1238
|
+
default=(
|
|
1239
|
+
os.environ.get("LOCAL_LLM_PROMPT_LENSES")
|
|
1240
|
+
or os.environ.get("CODE_MOWER_REVIEW_LENSES")
|
|
1241
|
+
or ",".join(code_mower_prompts.DEFAULT_REVIEW_LENSES)
|
|
1242
|
+
),
|
|
1243
|
+
help="Comma-separated Code Mower review prompt lenses.",
|
|
1244
|
+
)
|
|
1245
|
+
ap.add_argument(
|
|
1246
|
+
"--prompt-dir",
|
|
1247
|
+
type=Path,
|
|
1248
|
+
default=(
|
|
1249
|
+
os.environ.get("LOCAL_LLM_PROMPT_DIR")
|
|
1250
|
+
or os.environ.get("CODE_MOWER_PROMPT_DIR")
|
|
1251
|
+
or None
|
|
1252
|
+
),
|
|
1253
|
+
help="Directory containing Code Mower review lens markdown files.",
|
|
1254
|
+
)
|
|
1255
|
+
ap.add_argument(
|
|
1256
|
+
"--prompt-ref",
|
|
1257
|
+
default=(
|
|
1258
|
+
os.environ.get("LOCAL_LLM_PROMPT_REF")
|
|
1259
|
+
or os.environ.get("CODE_MOWER_PROMPT_REF")
|
|
1260
|
+
or DEFAULT_PROMPT_REF
|
|
1261
|
+
),
|
|
1262
|
+
help=(
|
|
1263
|
+
"Trusted git ref for default review lenses when --prompt-dir is "
|
|
1264
|
+
"not supplied."
|
|
1265
|
+
),
|
|
1266
|
+
)
|
|
1267
|
+
ap.add_argument(
|
|
1268
|
+
"--prompt-repo",
|
|
1269
|
+
type=Path,
|
|
1270
|
+
default=(
|
|
1271
|
+
os.environ.get("LOCAL_LLM_PROMPT_REPO")
|
|
1272
|
+
or os.environ.get("CODE_MOWER_PROMPT_REPO")
|
|
1273
|
+
or None
|
|
1274
|
+
),
|
|
1275
|
+
help="Repository root used with --prompt-ref. Defaults to cwd.",
|
|
1276
|
+
)
|
|
1277
|
+
ap.add_argument(
|
|
1278
|
+
"--dry-run",
|
|
1279
|
+
action="store_true",
|
|
1280
|
+
default=bool(os.environ.get("LOCAL_LLM_AUDIT_DRY_RUN")),
|
|
1281
|
+
help="Print the audit comment to stdout instead of posting it.",
|
|
1282
|
+
)
|
|
1283
|
+
return ap.parse_args(argv)
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
1287
|
+
args = _parse_args(argv)
|
|
1288
|
+
token = resolve_github_token_from_env_or_gh()
|
|
1289
|
+
if not token:
|
|
1290
|
+
print(
|
|
1291
|
+
"error: set GITHUB_TOKEN or authenticate gh so `gh auth token` works",
|
|
1292
|
+
file=sys.stderr,
|
|
1293
|
+
)
|
|
1294
|
+
return 1
|
|
1295
|
+
try:
|
|
1296
|
+
runtime_options = resolve_runtime_options(
|
|
1297
|
+
profile_id=args.profile,
|
|
1298
|
+
api_base=args.api_base,
|
|
1299
|
+
model=args.model,
|
|
1300
|
+
api_key=args.api_key,
|
|
1301
|
+
http_timeout=args.http_timeout,
|
|
1302
|
+
max_files=args.max_files,
|
|
1303
|
+
max_file_bytes=args.max_file_bytes,
|
|
1304
|
+
context_window=args.context_window,
|
|
1305
|
+
json_repair_retries=args.json_repair_retries,
|
|
1306
|
+
)
|
|
1307
|
+
except (KeyError, ValueError) as exc:
|
|
1308
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1309
|
+
return 1
|
|
1310
|
+
|
|
1311
|
+
config = AuditConfig(
|
|
1312
|
+
github_token=token,
|
|
1313
|
+
api_base=runtime_options["api_base"],
|
|
1314
|
+
model=runtime_options["model"],
|
|
1315
|
+
api_key=runtime_options["api_key"],
|
|
1316
|
+
http_timeout=runtime_options["http_timeout"],
|
|
1317
|
+
max_file_bytes=runtime_options["max_file_bytes"],
|
|
1318
|
+
max_files=runtime_options["max_files"],
|
|
1319
|
+
profile_id=runtime_options["profile_id"],
|
|
1320
|
+
context_window=runtime_options["context_window"],
|
|
1321
|
+
json_repair_retries=runtime_options["json_repair_retries"],
|
|
1322
|
+
prompt_lenses=code_mower_prompts.split_lenses(args.prompt_lenses),
|
|
1323
|
+
prompt_dir=args.prompt_dir,
|
|
1324
|
+
prompt_ref=args.prompt_ref,
|
|
1325
|
+
prompt_repo=args.prompt_repo,
|
|
1326
|
+
repo_path=args.repo_path,
|
|
1327
|
+
base_ref=args.base_ref,
|
|
1328
|
+
allow_historical_head=args.allow_historical_head,
|
|
1329
|
+
dry_run=args.dry_run,
|
|
1330
|
+
)
|
|
1331
|
+
|
|
1332
|
+
try:
|
|
1333
|
+
result = audit_pr(config, args.repo, args.pr)
|
|
1334
|
+
except urllib.error.HTTPError as exc:
|
|
1335
|
+
print(f"error: GitHub API HTTP {exc.code} — {exc.reason}", file=sys.stderr)
|
|
1336
|
+
return 1
|
|
1337
|
+
except urllib.error.URLError as exc:
|
|
1338
|
+
print(f"error: network — {exc}", file=sys.stderr)
|
|
1339
|
+
return 1
|
|
1340
|
+
except (
|
|
1341
|
+
OSError,
|
|
1342
|
+
RuntimeError,
|
|
1343
|
+
subprocess.CalledProcessError,
|
|
1344
|
+
subprocess.TimeoutExpired,
|
|
1345
|
+
) as exc:
|
|
1346
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1347
|
+
return 1
|
|
1348
|
+
|
|
1349
|
+
if args.dry_run:
|
|
1350
|
+
print(result.comment_body)
|
|
1351
|
+
else:
|
|
1352
|
+
print(
|
|
1353
|
+
f"posted {args.repo}#{args.pr} verdict={result.verdict} "
|
|
1354
|
+
f"url={result.posted_comment_url}",
|
|
1355
|
+
file=sys.stderr,
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
if result.verdict == "STALE":
|
|
1359
|
+
return 2
|
|
1360
|
+
return 0
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
if __name__ == "__main__":
|
|
1364
|
+
raise SystemExit(main())
|