code-mower 0.5.0b5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. code_mower/__init__.py +3 -0
  2. code_mower/adapters/__init__.py +39 -0
  3. code_mower/adapters/_base.py +148 -0
  4. code_mower/adapters/cursor_bugbot.py +97 -0
  5. code_mower/adapters/gitar.py +111 -0
  6. code_mower/adapters/greptile.py +191 -0
  7. code_mower/adapters/qodo.py +140 -0
  8. code_mower/antigravity_cli_audit_pr.py +241 -0
  9. code_mower/audit_handoff_log.py +345 -0
  10. code_mower/audit_labeler_lib.py +311 -0
  11. code_mower/audit_progress.py +203 -0
  12. code_mower/blind_review_artifacts.py +562 -0
  13. code_mower/blind_review_coordinator.py +276 -0
  14. code_mower/bootstrap.py +524 -0
  15. code_mower/builder_experiment.py +539 -0
  16. code_mower/calibration/__init__.py +181 -0
  17. code_mower/calibration/arms.py +267 -0
  18. code_mower/calibration/auto_discovery.py +348 -0
  19. code_mower/calibration/commands.py +192 -0
  20. code_mower/calibration/context_inputs.py +199 -0
  21. code_mower/calibration/corpus.py +91 -0
  22. code_mower/calibration/evidence.py +20 -0
  23. code_mower/calibration/evidence_report.py +360 -0
  24. code_mower/calibration/identity.py +27 -0
  25. code_mower/calibration/metrics.py +18 -0
  26. code_mower/calibration/overlap.py +103 -0
  27. code_mower/calibration/planning.py +309 -0
  28. code_mower/calibration/policy.py +207 -0
  29. code_mower/calibration/results.py +315 -0
  30. code_mower/calibration/run_results.py +147 -0
  31. code_mower/calibration/run_status.py +64 -0
  32. code_mower/calibration/runner.py +360 -0
  33. code_mower/calibration/truth.py +188 -0
  34. code_mower/calibration/value_report.py +142 -0
  35. code_mower/checks.py +402 -0
  36. code_mower/claude_audit_pr.py +1126 -0
  37. code_mower/claude_cli_bounce.py +303 -0
  38. code_mower/claude_cli_environment.py +73 -0
  39. code_mower/clear_stale.py +374 -0
  40. code_mower/cli.py +537 -0
  41. code_mower/cloud.py +674 -0
  42. code_mower/cloud_client/__init__.py +173 -0
  43. code_mower/cloud_client/bundle.py +155 -0
  44. code_mower/cloud_client/doctor.py +206 -0
  45. code_mower/cloud_client/dogfood.py +78 -0
  46. code_mower/cloud_client/endpoints.py +114 -0
  47. code_mower/cloud_client/errors.py +7 -0
  48. code_mower/cloud_client/events.py +278 -0
  49. code_mower/cloud_client/export.py +272 -0
  50. code_mower/cloud_client/git_metadata.py +46 -0
  51. code_mower/cloud_client/manifest.py +39 -0
  52. code_mower/cloud_client/operations.py +448 -0
  53. code_mower/cloud_client/reports.py +45 -0
  54. code_mower/cloud_client/setup.py +205 -0
  55. code_mower/cloud_client/upload.py +97 -0
  56. code_mower/code_mower_calibration.py +598 -0
  57. code_mower/code_mower_context_packs.py +591 -0
  58. code_mower/code_mower_merge.py +227 -0
  59. code_mower/code_mower_telemetry.py +561 -0
  60. code_mower/coderabbit_cli_audit_pr.py +526 -0
  61. code_mower/codex_audit_env_preflight.py +220 -0
  62. code_mower/codex_audit_pr.py +1738 -0
  63. code_mower/codex_audit_schema_smoke.py +160 -0
  64. code_mower/codex_audit_verdict.schema.json +44 -0
  65. code_mower/config.py +655 -0
  66. code_mower/doctor.py +161 -0
  67. code_mower/doctor_checks/__init__.py +104 -0
  68. code_mower/doctor_checks/cloud.py +129 -0
  69. code_mower/doctor_checks/common.py +215 -0
  70. code_mower/doctor_checks/github.py +128 -0
  71. code_mower/doctor_checks/github_actions.py +11 -0
  72. code_mower/doctor_checks/github_actions_cost.py +99 -0
  73. code_mower/doctor_checks/github_actions_cost_summary.py +111 -0
  74. code_mower/doctor_checks/github_actions_failure_annotations.py +27 -0
  75. code_mower/doctor_checks/github_actions_failure_models.py +47 -0
  76. code_mower/doctor_checks/github_actions_failure_scan.py +200 -0
  77. code_mower/doctor_checks/github_actions_failure_selection.py +63 -0
  78. code_mower/doctor_checks/github_actions_failures.py +103 -0
  79. code_mower/doctor_checks/github_actions_permissions.py +55 -0
  80. code_mower/doctor_checks/github_api.py +79 -0
  81. code_mower/doctor_checks/github_branch.py +56 -0
  82. code_mower/doctor_checks/github_config.py +25 -0
  83. code_mower/doctor_checks/github_provider.py +61 -0
  84. code_mower/doctor_checks/github_repo.py +120 -0
  85. code_mower/doctor_checks/groups.py +36 -0
  86. code_mower/doctor_checks/models.py +96 -0
  87. code_mower/doctor_checks/output.py +86 -0
  88. code_mower/doctor_checks/presets.py +64 -0
  89. code_mower/doctor_checks/privacy.py +20 -0
  90. code_mower/doctor_checks/provider_api_model.py +138 -0
  91. code_mower/doctor_checks/provider_api_model_openai.py +29 -0
  92. code_mower/doctor_checks/provider_api_model_profiles.py +137 -0
  93. code_mower/doctor_checks/provider_env.py +113 -0
  94. code_mower/doctor_checks/provider_env_required.py +56 -0
  95. code_mower/doctor_checks/provider_env_tokens.py +100 -0
  96. code_mower/doctor_checks/provider_local_cli.py +162 -0
  97. code_mower/doctor_checks/provider_local_cli_commands.py +47 -0
  98. code_mower/doctor_checks/provider_local_cli_probe_config.py +70 -0
  99. code_mower/doctor_checks/provider_probe.py +20 -0
  100. code_mower/doctor_checks/provider_probe_auth.py +52 -0
  101. code_mower/doctor_checks/provider_probe_evaluation.py +109 -0
  102. code_mower/doctor_checks/provider_probe_json.py +45 -0
  103. code_mower/doctor_checks/provider_probe_remediation.py +39 -0
  104. code_mower/doctor_checks/providers.py +159 -0
  105. code_mower/doctor_checks/registry.py +69 -0
  106. code_mower/doctor_checks/runner.py +188 -0
  107. code_mower/doctor_checks/runtime.py +89 -0
  108. code_mower/doctor_checks/runtime_github_auth.py +148 -0
  109. code_mower/gemini_cli_audit_pr.py +897 -0
  110. code_mower/hermes_cli_audit_pr.py +436 -0
  111. code_mower/init.py +888 -0
  112. code_mower/lane_configs/__init__.py +37 -0
  113. code_mower/lane_configs/aider.py +32 -0
  114. code_mower/lane_configs/antigravity_cli.py +35 -0
  115. code_mower/lane_configs/claude.py +35 -0
  116. code_mower/lane_configs/codex.py +32 -0
  117. code_mower/lane_configs/devin.py +33 -0
  118. code_mower/lane_configs/gemini_cli.py +35 -0
  119. code_mower/lane_configs/hermes_cli.py +35 -0
  120. code_mower/lane_configs/local_llm.py +31 -0
  121. code_mower/local_llm_audit_pr.py +1364 -0
  122. code_mower/local_llm_bakeoff.py +458 -0
  123. code_mower/local_llm_calibration.py +441 -0
  124. code_mower/local_llm_profiles.py +66 -0
  125. code_mower/migration.py +508 -0
  126. code_mower/migration_install.py +292 -0
  127. code_mower/migration_mirror.py +392 -0
  128. code_mower/migration_readiness.py +237 -0
  129. code_mower/migration_rehearsal.py +718 -0
  130. code_mower/next_steps.py +441 -0
  131. code_mower/package.py +673 -0
  132. code_mower/package_content.py +444 -0
  133. code_mower/package_manifest.py +452 -0
  134. code_mower/package_paths.py +53 -0
  135. code_mower/package_rendering.py +90 -0
  136. code_mower/package_static.py +585 -0
  137. code_mower/prompts.py +267 -0
  138. code_mower/provider_registry.py +469 -0
  139. code_mower/provider_runners/__init__.py +60 -0
  140. code_mower/provider_runners/comments.py +31 -0
  141. code_mower/provider_runners/git.py +46 -0
  142. code_mower/provider_runners/github_auth.py +61 -0
  143. code_mower/provider_runners/github_pr.py +120 -0
  144. code_mower/provider_runners/process.py +58 -0
  145. code_mower/provider_runners/repo_paths.py +23 -0
  146. code_mower/provider_runners/text_schema.py +41 -0
  147. code_mower/provider_runners/verdict_artifacts.py +103 -0
  148. code_mower/provider_runners/workspace.py +57 -0
  149. code_mower/release_readiness.py +549 -0
  150. code_mower/reviewer_metrics.py +389 -0
  151. code_mower/saas_reviewer_labeler.py +809 -0
  152. code_mower/secrets.py +89 -0
  153. code_mower/templates/builder-experiment.example.json +55 -0
  154. code_mower/templates/calibration-corpus.example.json +129 -0
  155. code_mower/templates/calibration-corpus.json +129 -0
  156. code_mower/templates/code-mower.example.yml +423 -0
  157. code_mower/templates/context-packs.example.json +150 -0
  158. code_mower/templates/lane_prompts/base-audit.md +22 -0
  159. code_mower/templates/lane_prompts/calibration-policy.md +21 -0
  160. code_mower/templates/lane_prompts/context-driven-quality.md +21 -0
  161. code_mower/templates/lane_prompts/docs-design.md +12 -0
  162. code_mower/templates/lane_prompts/generic-programming.md +21 -0
  163. code_mower/templates/lane_prompts/operability.md +22 -0
  164. code_mower/templates/lane_prompts/package-runtime.md +12 -0
  165. code_mower/templates/lane_prompts/security-threat-model.md +22 -0
  166. code_mower/templates/product-support/code_mower +216 -0
  167. code_mower/templates/product-support/code_mower_standalone_pin.env +7 -0
  168. code_mower/templates/product-support/code_mower_standalone_shadow.sh +151 -0
  169. code_mower/templates/product-support/run_claude_audit_pr.sh +32 -0
  170. code_mower/templates/product-support/run_codex_audit_pr.sh +32 -0
  171. code_mower/templates/product-support/safe_gh_comment.py +96 -0
  172. code_mower/templates/providers.yml +454 -0
  173. code_mower/templates/reviewer-spend.example.json +28 -0
  174. code_mower/templates/reviewer-value-report.example.md +20 -0
  175. code_mower/templates/workflows/private-standalone-shadow.yml.j2 +106 -0
  176. code_mower/templates/workflows/review-clear-stale.yml.j2 +83 -0
  177. code_mower/trailer_comment_labeler.py +207 -0
  178. code_mower/versioning.py +32 -0
  179. code_mower-0.5.0b5.dist-info/METADATA +302 -0
  180. code_mower-0.5.0b5.dist-info/RECORD +185 -0
  181. code_mower-0.5.0b5.dist-info/WHEEL +5 -0
  182. code_mower-0.5.0b5.dist-info/entry_points.txt +2 -0
  183. code_mower-0.5.0b5.dist-info/licenses/LICENSE +202 -0
  184. code_mower-0.5.0b5.dist-info/licenses/NOTICE +10 -0
  185. code_mower-0.5.0b5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)