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,345 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared local audit handoff log and active-lock helper.
|
|
3
|
+
|
|
4
|
+
The audit lanes are intentionally human-orchestrated: Claude, Codex, and
|
|
5
|
+
occasionally paid providers can all be asked to review the same PR. This
|
|
6
|
+
helper gives local agents a durable "someone is already doing this" check
|
|
7
|
+
without depending on chat memory.
|
|
8
|
+
|
|
9
|
+
Default storage lives outside the repo to avoid dirty worktrees:
|
|
10
|
+
|
|
11
|
+
~/.cache/code-mower-audits/events.jsonl
|
|
12
|
+
~/.cache/code-mower-audits/locks/*.json
|
|
13
|
+
|
|
14
|
+
Set AUDIT_HANDOFF_LOG_DIR to override the directory in tests or local
|
|
15
|
+
experiments.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Dict, Iterable, Optional, Sequence
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from zoneinfo import ZoneInfo
|
|
31
|
+
except ImportError: # pragma: no cover - Python 3.8+ in normal use
|
|
32
|
+
ZoneInfo = None # type: ignore[assignment]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
DEFAULT_DIR = Path("~/.cache/code-mower-audits").expanduser()
|
|
36
|
+
EVENTS_FILE = "events.jsonl"
|
|
37
|
+
LOCKS_DIR = "locks"
|
|
38
|
+
ACTIVE_EXIT_CODE = 20
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def audit_dir() -> Path:
|
|
42
|
+
return Path(os.environ.get("AUDIT_HANDOFF_LOG_DIR", str(DEFAULT_DIR))).expanduser()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def now_record() -> Dict[str, str]:
|
|
46
|
+
# Microsecond precision on `utc` (reference-app#TBD / reference-service#TBD).
|
|
47
|
+
# Previously second-precision, which was sufficient for human-
|
|
48
|
+
# readable display but caused a same-second race in the sweep-hook
|
|
49
|
+
# cross-check (reference-app#204 P3, fixed via array-index ordering).
|
|
50
|
+
# The display field (`pt`) stays at second precision because the
|
|
51
|
+
# session-start sweep header renders it directly to the operator,
|
|
52
|
+
# and microseconds add noise without value at that surface.
|
|
53
|
+
now_utc = datetime.now(timezone.utc)
|
|
54
|
+
record = {"utc": now_utc.isoformat(timespec="microseconds")}
|
|
55
|
+
try:
|
|
56
|
+
if ZoneInfo is not None:
|
|
57
|
+
record["pt"] = now_utc.astimezone(ZoneInfo("America/Los_Angeles")).strftime("%Y-%m-%d %H:%M:%S PT")
|
|
58
|
+
else:
|
|
59
|
+
record["pt"] = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S PT")
|
|
60
|
+
except Exception:
|
|
61
|
+
record["pt"] = record["utc"]
|
|
62
|
+
return record
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def display_time(value: Any) -> str:
|
|
66
|
+
if isinstance(value, dict):
|
|
67
|
+
return str(value.get("pt") or value.get("utc") or value)
|
|
68
|
+
return str(value)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _safe_key_part(value: str) -> str:
|
|
72
|
+
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "_", value.strip())
|
|
73
|
+
return cleaned.strip("_") or "unknown"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def lock_id_for(*, lane: str, repo: str, pr: int, head: str) -> str:
|
|
77
|
+
short_head = head[:12] if head and head != "unknown" else "unknown"
|
|
78
|
+
return "__".join(
|
|
79
|
+
(
|
|
80
|
+
_safe_key_part(lane),
|
|
81
|
+
_safe_key_part(repo),
|
|
82
|
+
f"pr{pr}",
|
|
83
|
+
_safe_key_part(short_head),
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def lock_path(lock_id: str) -> Path:
|
|
89
|
+
return audit_dir() / LOCKS_DIR / f"{lock_id}.json"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def events_path() -> Path:
|
|
93
|
+
return audit_dir() / EVENTS_FILE
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def ensure_dirs() -> None:
|
|
97
|
+
(audit_dir() / LOCKS_DIR).mkdir(parents=True, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def append_event(event: Dict[str, Any]) -> None:
|
|
101
|
+
ensure_dirs()
|
|
102
|
+
enriched = {"time": now_record(), **event}
|
|
103
|
+
with events_path().open("a", encoding="utf-8") as fh:
|
|
104
|
+
fh.write(json.dumps(enriched, sort_keys=True) + "\n")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_json(path: Path) -> Dict[str, Any]:
|
|
108
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def process_alive(pid: Any) -> bool:
|
|
112
|
+
try:
|
|
113
|
+
pid_int = int(pid)
|
|
114
|
+
except (TypeError, ValueError):
|
|
115
|
+
return False
|
|
116
|
+
if pid_int <= 0:
|
|
117
|
+
return False
|
|
118
|
+
try:
|
|
119
|
+
os.kill(pid_int, 0)
|
|
120
|
+
except ProcessLookupError:
|
|
121
|
+
return False
|
|
122
|
+
except PermissionError:
|
|
123
|
+
return True
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def resolve_pr_head(repo: str, pr: int) -> str:
|
|
128
|
+
try:
|
|
129
|
+
result = subprocess.run(
|
|
130
|
+
["gh", "api", f"repos/{repo}/pulls/{pr}", "--jq", ".head.sha"],
|
|
131
|
+
check=True,
|
|
132
|
+
text=True,
|
|
133
|
+
capture_output=True,
|
|
134
|
+
)
|
|
135
|
+
except (OSError, subprocess.CalledProcessError):
|
|
136
|
+
return "unknown"
|
|
137
|
+
return result.stdout.strip() or "unknown"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def start_lock(args: argparse.Namespace) -> int:
|
|
141
|
+
ensure_dirs()
|
|
142
|
+
head = args.head or resolve_pr_head(args.repo, args.pr)
|
|
143
|
+
lock_id = lock_id_for(lane=args.lane, repo=args.repo, pr=args.pr, head=head)
|
|
144
|
+
path = lock_path(lock_id)
|
|
145
|
+
|
|
146
|
+
if path.exists():
|
|
147
|
+
existing = load_json(path)
|
|
148
|
+
if process_alive(existing.get("pid")):
|
|
149
|
+
print(
|
|
150
|
+
(
|
|
151
|
+
"active audit already running: "
|
|
152
|
+
f"{existing.get('lane')} {existing.get('repo')}#{existing.get('pr')} "
|
|
153
|
+
f"@ {str(existing.get('head', 'unknown'))[:12]} "
|
|
154
|
+
f"pid={existing.get('pid')} actor={existing.get('actor')} "
|
|
155
|
+
f"started={display_time(existing.get('started'))}"
|
|
156
|
+
),
|
|
157
|
+
file=sys.stderr,
|
|
158
|
+
)
|
|
159
|
+
append_event({"event": "duplicate_refused", "lockId": lock_id, "active": existing})
|
|
160
|
+
return ACTIVE_EXIT_CODE
|
|
161
|
+
append_event({"event": "stale_lock_reaped", "lockId": lock_id, "stale": existing})
|
|
162
|
+
path.unlink()
|
|
163
|
+
|
|
164
|
+
record = {
|
|
165
|
+
"lockId": lock_id,
|
|
166
|
+
"event": "started",
|
|
167
|
+
"lane": args.lane,
|
|
168
|
+
"repo": args.repo,
|
|
169
|
+
"pr": args.pr,
|
|
170
|
+
"head": head,
|
|
171
|
+
"actor": args.actor,
|
|
172
|
+
"trigger": args.trigger,
|
|
173
|
+
"pid": args.pid,
|
|
174
|
+
"cwd": args.cwd,
|
|
175
|
+
"command": args.command,
|
|
176
|
+
"started": now_record(),
|
|
177
|
+
}
|
|
178
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
179
|
+
try:
|
|
180
|
+
fd = os.open(path, flags)
|
|
181
|
+
except FileExistsError:
|
|
182
|
+
# Another process won the race after our existence check.
|
|
183
|
+
return start_lock(args)
|
|
184
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
185
|
+
json.dump(record, fh, sort_keys=True)
|
|
186
|
+
fh.write("\n")
|
|
187
|
+
append_event(record)
|
|
188
|
+
print(lock_id)
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def finish_lock(args: argparse.Namespace) -> int:
|
|
193
|
+
path = lock_path(args.lock_id)
|
|
194
|
+
record: Dict[str, Any] = {"lockId": args.lock_id}
|
|
195
|
+
if path.exists():
|
|
196
|
+
record.update(load_json(path))
|
|
197
|
+
path.unlink()
|
|
198
|
+
append_event(
|
|
199
|
+
{
|
|
200
|
+
"event": "finished",
|
|
201
|
+
"lockId": args.lock_id,
|
|
202
|
+
"status": args.status,
|
|
203
|
+
"exitCode": args.exit_code,
|
|
204
|
+
"lock": record,
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def record_event(args: argparse.Namespace) -> int:
|
|
211
|
+
"""Append a single event to the shared log without acquiring a lock.
|
|
212
|
+
|
|
213
|
+
Use case: post-hoc recording of work that's already done — e.g. Claude
|
|
214
|
+
or Codex finishing a manual cross-review and wanting the other agent's
|
|
215
|
+
Monitor on the log to notice in real time. Reviews aren't long-running
|
|
216
|
+
operations that need duplicate-prevention locking like audits do, so we
|
|
217
|
+
skip the lock dance and just write the event.
|
|
218
|
+
|
|
219
|
+
The event shape mirrors what `finish_lock` writes (`"event": "finished"`)
|
|
220
|
+
so a Monitor filter like `grep '"event": "finished"'` catches both
|
|
221
|
+
audit-finished and review-finished events transparently. The `lane`
|
|
222
|
+
field disambiguates (`codex-audit` vs `claude-review` vs
|
|
223
|
+
`codex-review`).
|
|
224
|
+
"""
|
|
225
|
+
head = args.head or resolve_pr_head(args.repo, args.pr)
|
|
226
|
+
event: Dict[str, Any] = {
|
|
227
|
+
"event": args.event,
|
|
228
|
+
"lane": args.lane,
|
|
229
|
+
"repo": args.repo,
|
|
230
|
+
"pr": args.pr,
|
|
231
|
+
"head": head,
|
|
232
|
+
"actor": args.actor,
|
|
233
|
+
}
|
|
234
|
+
if args.verdict is not None:
|
|
235
|
+
event["verdict"] = args.verdict
|
|
236
|
+
if args.notes:
|
|
237
|
+
event["notes"] = args.notes
|
|
238
|
+
append_event(event)
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def active_locks() -> Iterable[Dict[str, Any]]:
|
|
243
|
+
locks_dir = audit_dir() / LOCKS_DIR
|
|
244
|
+
if not locks_dir.exists():
|
|
245
|
+
return []
|
|
246
|
+
active = []
|
|
247
|
+
for path in sorted(locks_dir.glob("*.json")):
|
|
248
|
+
record = load_json(path)
|
|
249
|
+
if process_alive(record.get("pid")):
|
|
250
|
+
active.append(record)
|
|
251
|
+
else:
|
|
252
|
+
append_event({"event": "stale_lock_reaped", "lockId": record.get("lockId", path.stem), "stale": record})
|
|
253
|
+
path.unlink()
|
|
254
|
+
return active
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def status(args: argparse.Namespace) -> int:
|
|
258
|
+
rows = []
|
|
259
|
+
for record in active_locks():
|
|
260
|
+
if args.lane and record.get("lane") != args.lane:
|
|
261
|
+
continue
|
|
262
|
+
if args.repo and record.get("repo") != args.repo:
|
|
263
|
+
continue
|
|
264
|
+
if args.pr is not None and int(record.get("pr", -1)) != args.pr:
|
|
265
|
+
continue
|
|
266
|
+
rows.append(record)
|
|
267
|
+
if args.json:
|
|
268
|
+
print(json.dumps(rows, indent=2, sort_keys=True))
|
|
269
|
+
return 0
|
|
270
|
+
if not rows:
|
|
271
|
+
print("no active audit locks")
|
|
272
|
+
return 0
|
|
273
|
+
for record in rows:
|
|
274
|
+
print(
|
|
275
|
+
f"{record.get('lane')} {record.get('repo')}#{record.get('pr')} "
|
|
276
|
+
f"@ {str(record.get('head', 'unknown'))[:12]} "
|
|
277
|
+
f"pid={record.get('pid')} actor={record.get('actor')} "
|
|
278
|
+
f"started={display_time(record.get('started'))}"
|
|
279
|
+
)
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _parser() -> argparse.ArgumentParser:
|
|
284
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
285
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
286
|
+
|
|
287
|
+
start = sub.add_parser("start", help="Create an active audit lock and log a start event.")
|
|
288
|
+
start.add_argument("--lane", required=True)
|
|
289
|
+
start.add_argument("--repo", required=True)
|
|
290
|
+
start.add_argument("--pr", type=int, required=True)
|
|
291
|
+
start.add_argument("--head")
|
|
292
|
+
start.add_argument("--actor", default=os.environ.get("AUDIT_ACTOR") or os.environ.get("USER") or "unknown")
|
|
293
|
+
start.add_argument("--trigger", default="manual")
|
|
294
|
+
start.add_argument("--pid", type=int, default=os.getpid())
|
|
295
|
+
start.add_argument("--cwd", default=os.getcwd())
|
|
296
|
+
start.add_argument("--command", default="")
|
|
297
|
+
start.set_defaults(func=start_lock)
|
|
298
|
+
|
|
299
|
+
finish = sub.add_parser("finish", help="Remove an active audit lock and log a finish event.")
|
|
300
|
+
finish.add_argument("--lock-id", required=True)
|
|
301
|
+
finish.add_argument("--status", required=True)
|
|
302
|
+
finish.add_argument("--exit-code", type=int, required=True)
|
|
303
|
+
finish.set_defaults(func=finish_lock)
|
|
304
|
+
|
|
305
|
+
stat = sub.add_parser("status", help="List active audit locks.")
|
|
306
|
+
stat.add_argument("--lane")
|
|
307
|
+
stat.add_argument("--repo")
|
|
308
|
+
stat.add_argument("--pr", type=int)
|
|
309
|
+
stat.add_argument("--json", action="store_true")
|
|
310
|
+
stat.set_defaults(func=status)
|
|
311
|
+
|
|
312
|
+
rec = sub.add_parser(
|
|
313
|
+
"record",
|
|
314
|
+
help="Append a single event (no lock). For lock-free, post-hoc "
|
|
315
|
+
"recording of completed work like manual reviews.",
|
|
316
|
+
)
|
|
317
|
+
rec.add_argument("--lane", required=True,
|
|
318
|
+
help="e.g. claude-review, codex-review, codex-audit")
|
|
319
|
+
rec.add_argument("--repo", required=True)
|
|
320
|
+
rec.add_argument("--pr", type=int, required=True)
|
|
321
|
+
rec.add_argument("--head",
|
|
322
|
+
help="Head SHA at the time of the event. If omitted, "
|
|
323
|
+
"fetched from gh api.")
|
|
324
|
+
rec.add_argument("--event", required=True,
|
|
325
|
+
help="Event kind, typically 'finished'. Monitor filters "
|
|
326
|
+
"match on this field.")
|
|
327
|
+
rec.add_argument("--verdict",
|
|
328
|
+
help="e.g. pass, blocked. Free-form; not interpreted.")
|
|
329
|
+
rec.add_argument("--notes",
|
|
330
|
+
help="Free-form note to attach to the event.")
|
|
331
|
+
rec.add_argument("--actor",
|
|
332
|
+
default=os.environ.get("AUDIT_ACTOR")
|
|
333
|
+
or os.environ.get("USER") or "unknown")
|
|
334
|
+
rec.set_defaults(func=record_event)
|
|
335
|
+
|
|
336
|
+
return parser
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
340
|
+
args = _parser().parse_args(argv)
|
|
341
|
+
return args.func(args)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
if __name__ == "__main__":
|
|
345
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared audit labeler primitives.
|
|
3
|
+
|
|
4
|
+
Phase 1 of the audit-adapter refactor extracts only the boring pieces that
|
|
5
|
+
were duplicated across the lane-specific labelers. Verdict parsing and workflow
|
|
6
|
+
entrypoints intentionally stay in the existing scripts until the staged rollout
|
|
7
|
+
has proven the shared library on main.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import functools
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Dict, Optional, Pattern, Sequence
|
|
21
|
+
from urllib.parse import quote
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
MIN_ABBREVIATED_SHA_LENGTH = 7
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class LabelDecision:
|
|
29
|
+
issue_number: int
|
|
30
|
+
add_label: str
|
|
31
|
+
remove_labels: tuple[str, ...]
|
|
32
|
+
reviewed_sha: Optional[str] = None
|
|
33
|
+
reason: str = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class GitHubToken:
|
|
38
|
+
name: str
|
|
39
|
+
value: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class LaneConfig:
|
|
44
|
+
name: str
|
|
45
|
+
display_name: str
|
|
46
|
+
needs_label: str
|
|
47
|
+
done_label: str
|
|
48
|
+
blocked_label: str
|
|
49
|
+
trailer_prefix: str
|
|
50
|
+
default_authors: tuple[str, ...]
|
|
51
|
+
authors_env_var: Optional[str]
|
|
52
|
+
pass_patterns: tuple[Pattern[str], ...]
|
|
53
|
+
blocked_patterns: tuple[Pattern[str], ...]
|
|
54
|
+
label_state_fallbacks: bool = False
|
|
55
|
+
token_env_vars: tuple[str, ...] = ("GITHUB_TOKEN",)
|
|
56
|
+
|
|
57
|
+
@functools.lru_cache(maxsize=1)
|
|
58
|
+
def trailer_pattern(self) -> Pattern[str]:
|
|
59
|
+
labels = "|".join(
|
|
60
|
+
re.escape(label)
|
|
61
|
+
for label in (self.done_label, self.blocked_label, self.needs_label)
|
|
62
|
+
)
|
|
63
|
+
return re.compile(
|
|
64
|
+
rf"<!--\s*{re.escape(self.trailer_prefix)}:\s*({labels})\s*-->",
|
|
65
|
+
flags=re.IGNORECASE,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def comment_authors(self) -> frozenset[str]:
|
|
69
|
+
if self.authors_env_var:
|
|
70
|
+
raw_authors = os.environ.get(self.authors_env_var) or ",".join(self.default_authors)
|
|
71
|
+
else:
|
|
72
|
+
raw_authors = ",".join(self.default_authors)
|
|
73
|
+
return frozenset(author.strip().lower() for author in raw_authors.split(",") if author.strip())
|
|
74
|
+
|
|
75
|
+
def github_tokens_from_env(self) -> tuple[GitHubToken, ...]:
|
|
76
|
+
tokens = []
|
|
77
|
+
seen = set()
|
|
78
|
+
for name in self.token_env_vars:
|
|
79
|
+
value = (os.environ.get(name) or "").strip()
|
|
80
|
+
if not value or value in seen:
|
|
81
|
+
continue
|
|
82
|
+
tokens.append(GitHubToken(name, value))
|
|
83
|
+
seen.add(value)
|
|
84
|
+
return tuple(tokens)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class GitHubRequestError(RuntimeError):
|
|
88
|
+
def __init__(self, method: str, path: str, code: int, response_body: str) -> None:
|
|
89
|
+
self.method = method
|
|
90
|
+
self.path = path
|
|
91
|
+
self.code = code
|
|
92
|
+
self.response_body = response_body
|
|
93
|
+
super().__init__(f"GitHub API {method} {path} failed: HTTP {code}\n{response_body}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class IssueCommentPaginationLimitExceeded(RuntimeError):
|
|
97
|
+
"""Raised when issue comments exceed the configured safe pagination cap."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_json(path: Path) -> Dict[str, Any]:
|
|
101
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def extract_reviewed_sha(body: str) -> Optional[str]:
|
|
105
|
+
patterns = [
|
|
106
|
+
r"Head SHA:\*\*\s*`?([0-9a-fA-F]{7,40})`?",
|
|
107
|
+
r"Head SHA:\s*`?([0-9a-fA-F]{7,40})`?",
|
|
108
|
+
]
|
|
109
|
+
for pattern in patterns:
|
|
110
|
+
match = re.search(pattern, body, flags=re.IGNORECASE)
|
|
111
|
+
if match:
|
|
112
|
+
return match.group(1)
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def sha_matches_reviewed_head(reviewed_sha: str, current_head_sha: str) -> bool:
|
|
117
|
+
reviewed = reviewed_sha.lower()
|
|
118
|
+
current = current_head_sha.lower()
|
|
119
|
+
if len(reviewed) < MIN_ABBREVIATED_SHA_LENGTH or len(current) < MIN_ABBREVIATED_SHA_LENGTH:
|
|
120
|
+
return False
|
|
121
|
+
return reviewed == current or current.startswith(reviewed)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def sha_matches(a: str, b: str) -> bool:
|
|
125
|
+
a, b = a.lower(), b.lower()
|
|
126
|
+
if len(a) < MIN_ABBREVIATED_SHA_LENGTH or len(b) < MIN_ABBREVIATED_SHA_LENGTH:
|
|
127
|
+
return False
|
|
128
|
+
return a == b or b.startswith(a) or a.startswith(b)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def github_request(
|
|
132
|
+
method: str,
|
|
133
|
+
path: str,
|
|
134
|
+
*,
|
|
135
|
+
token: str,
|
|
136
|
+
body: Optional[Dict[str, Any]] = None,
|
|
137
|
+
allow_missing: bool = False,
|
|
138
|
+
) -> Any:
|
|
139
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
140
|
+
request = urllib.request.Request(
|
|
141
|
+
f"https://api.github.com{path}",
|
|
142
|
+
data=data,
|
|
143
|
+
headers={
|
|
144
|
+
"Accept": "application/vnd.github+json",
|
|
145
|
+
"Authorization": f"Bearer {token}",
|
|
146
|
+
"Content-Type": "application/json",
|
|
147
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
148
|
+
},
|
|
149
|
+
method=method,
|
|
150
|
+
)
|
|
151
|
+
try:
|
|
152
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
153
|
+
response_body = response.read().decode("utf-8")
|
|
154
|
+
return json.loads(response_body) if response_body else None
|
|
155
|
+
except urllib.error.HTTPError as exc:
|
|
156
|
+
if allow_missing and exc.code == 404:
|
|
157
|
+
return None
|
|
158
|
+
response_body = exc.read().decode("utf-8", errors="replace")
|
|
159
|
+
raise GitHubRequestError(method, path, exc.code, response_body) from exc
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def github_request_with_fallback(
|
|
163
|
+
method: str,
|
|
164
|
+
path: str,
|
|
165
|
+
*,
|
|
166
|
+
tokens: Sequence[GitHubToken],
|
|
167
|
+
body: Optional[Dict[str, Any]] = None,
|
|
168
|
+
allow_missing: bool = False,
|
|
169
|
+
) -> Any:
|
|
170
|
+
"""Use the optional PAT first, then fall back to GITHUB_TOKEN on auth errors."""
|
|
171
|
+
token_list = tuple(tokens)
|
|
172
|
+
last_error: Optional[GitHubRequestError] = None
|
|
173
|
+
for index, token in enumerate(token_list):
|
|
174
|
+
try:
|
|
175
|
+
return github_request(
|
|
176
|
+
method,
|
|
177
|
+
path,
|
|
178
|
+
token=token.value,
|
|
179
|
+
body=body,
|
|
180
|
+
allow_missing=allow_missing,
|
|
181
|
+
)
|
|
182
|
+
except GitHubRequestError as exc:
|
|
183
|
+
last_error = exc
|
|
184
|
+
if exc.code not in {401, 403}:
|
|
185
|
+
raise
|
|
186
|
+
suffix = (
|
|
187
|
+
"; trying next token"
|
|
188
|
+
if index < len(token_list) - 1
|
|
189
|
+
else "; no more tokens"
|
|
190
|
+
)
|
|
191
|
+
print(
|
|
192
|
+
f"warning: {method} {path} failed with HTTP {exc.code} using "
|
|
193
|
+
f"{token.name}{suffix}",
|
|
194
|
+
file=sys.stderr,
|
|
195
|
+
)
|
|
196
|
+
if last_error is not None:
|
|
197
|
+
raise last_error
|
|
198
|
+
raise RuntimeError(f"no GitHub tokens available for {method} {path}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def fetch_pull_request(
|
|
202
|
+
repo: str,
|
|
203
|
+
issue_number: int,
|
|
204
|
+
*,
|
|
205
|
+
token: Optional[str] = None,
|
|
206
|
+
tokens: Optional[Sequence[GitHubToken]] = None,
|
|
207
|
+
) -> Dict[str, Any]:
|
|
208
|
+
path = f"/repos/{repo}/pulls/{issue_number}"
|
|
209
|
+
if tokens is not None:
|
|
210
|
+
response = github_request_with_fallback("GET", path, tokens=tokens)
|
|
211
|
+
else:
|
|
212
|
+
if token is None:
|
|
213
|
+
raise ValueError("token or tokens is required")
|
|
214
|
+
response = github_request("GET", path, token=token)
|
|
215
|
+
if not isinstance(response, dict):
|
|
216
|
+
raise RuntimeError(f"GitHub API GET {path} returned an empty or non-object response")
|
|
217
|
+
return response
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def fetch_issue_labels(
|
|
221
|
+
repo: str,
|
|
222
|
+
issue_number: int,
|
|
223
|
+
*,
|
|
224
|
+
tokens: Sequence[GitHubToken],
|
|
225
|
+
) -> list[str]:
|
|
226
|
+
response = github_request_with_fallback(
|
|
227
|
+
"GET",
|
|
228
|
+
f"/repos/{repo}/issues/{issue_number}",
|
|
229
|
+
tokens=tokens,
|
|
230
|
+
)
|
|
231
|
+
if not isinstance(response, dict):
|
|
232
|
+
raise RuntimeError("GitHub API issue lookup returned a non-object response")
|
|
233
|
+
return [
|
|
234
|
+
str(label.get("name") or "")
|
|
235
|
+
for label in response.get("labels") or []
|
|
236
|
+
if str(label.get("name") or "")
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def fetch_issue_comments(
|
|
241
|
+
repo: str,
|
|
242
|
+
issue_number: int,
|
|
243
|
+
*,
|
|
244
|
+
tokens: Sequence[GitHubToken],
|
|
245
|
+
page_cap: int,
|
|
246
|
+
) -> list[dict[str, Any]]:
|
|
247
|
+
comments: list[dict[str, Any]] = []
|
|
248
|
+
page = 1
|
|
249
|
+
while page <= page_cap:
|
|
250
|
+
chunk = github_request_with_fallback(
|
|
251
|
+
"GET",
|
|
252
|
+
f"/repos/{repo}/issues/{issue_number}/comments?per_page=100&page={page}",
|
|
253
|
+
tokens=tokens,
|
|
254
|
+
) or []
|
|
255
|
+
if not isinstance(chunk, list):
|
|
256
|
+
raise RuntimeError("GitHub API issue comments returned a non-list response")
|
|
257
|
+
if not chunk:
|
|
258
|
+
return comments
|
|
259
|
+
comments.extend(comment for comment in chunk if isinstance(comment, dict))
|
|
260
|
+
if len(chunk) < 100:
|
|
261
|
+
return comments
|
|
262
|
+
page += 1
|
|
263
|
+
raise IssueCommentPaginationLimitExceeded(
|
|
264
|
+
f"hit pagination cap of {page_cap} pages ({page_cap * 100} comments) "
|
|
265
|
+
f"for {repo}#{issue_number}; refusing to classify stale labels on partial data"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def apply_label_decision(
|
|
270
|
+
repo: str,
|
|
271
|
+
decision: LabelDecision,
|
|
272
|
+
*,
|
|
273
|
+
token: Optional[str] = None,
|
|
274
|
+
tokens: Optional[Sequence[GitHubToken]] = None,
|
|
275
|
+
) -> None:
|
|
276
|
+
if tokens is None:
|
|
277
|
+
if token is None:
|
|
278
|
+
raise ValueError("token or tokens is required")
|
|
279
|
+
tokens = (GitHubToken("GITHUB_TOKEN", token),)
|
|
280
|
+
github_request_with_fallback(
|
|
281
|
+
"POST",
|
|
282
|
+
f"/repos/{repo}/issues/{decision.issue_number}/labels",
|
|
283
|
+
tokens=tokens,
|
|
284
|
+
body={"labels": [decision.add_label]},
|
|
285
|
+
)
|
|
286
|
+
for label in decision.remove_labels:
|
|
287
|
+
github_request_with_fallback(
|
|
288
|
+
"DELETE",
|
|
289
|
+
f"/repos/{repo}/issues/{decision.issue_number}/labels/{quote(label, safe='')}",
|
|
290
|
+
tokens=tokens,
|
|
291
|
+
allow_missing=True,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def apply_or_log(repo: str, decision: LabelDecision, *, token: str, lane_name: str) -> None:
|
|
296
|
+
"""Apply a label decision; treat failures as non-fatal for informational lanes."""
|
|
297
|
+
try:
|
|
298
|
+
apply_label_decision(repo, decision, token=token)
|
|
299
|
+
print(
|
|
300
|
+
f"applied: add {decision.add_label}; remove "
|
|
301
|
+
f"{', '.join(decision.remove_labels)} "
|
|
302
|
+
f"on {repo}#{decision.issue_number} ({decision.reason})"
|
|
303
|
+
)
|
|
304
|
+
except Exception as exc:
|
|
305
|
+
print(
|
|
306
|
+
f"verdict (label apply skipped — {lane_name} lane is "
|
|
307
|
+
f"non-blocking): add {decision.add_label}; remove "
|
|
308
|
+
f"{', '.join(decision.remove_labels)} "
|
|
309
|
+
f"on {repo}#{decision.issue_number} ({decision.reason})"
|
|
310
|
+
)
|
|
311
|
+
print(f"warn: could not apply label: {exc}", file=sys.stderr)
|