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,562 @@
1
+ #!/usr/bin/env python3
2
+ """Render blind-review artifact storage and release manifests."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import re
10
+ import shutil
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any, Mapping
14
+
15
+ if __package__ in {None, ""}:
16
+ module_dir = Path(__file__).resolve().parent
17
+ if module_dir.name == "code_mower": # pragma: no cover - extracted direct CLI.
18
+ sys.path.insert(0, str(module_dir.parent))
19
+ from code_mower import blind_review_coordinator
20
+ else:
21
+ sys.path.insert(0, str(module_dir.parent))
22
+ from tools import blind_review_coordinator
23
+ elif __package__ == "tools":
24
+ from tools import blind_review_coordinator
25
+ else: # pragma: no cover - exercised after package extraction.
26
+ from . import blind_review_coordinator
27
+
28
+
29
+ DEFAULT_OUTPUT_DIR = ".code-mower/blind-review"
30
+ DEFAULT_STORAGE_BACKEND = "github_actions_artifact"
31
+ SUPPORTED_STORAGE_BACKENDS = {DEFAULT_STORAGE_BACKEND}
32
+ DEFAULT_RETENTION_DAYS = 14
33
+ GITHUB_ACTIONS_MAX_RETENTION_DAYS = 90
34
+ SAFE_SLUG_RE = re.compile(r"[^A-Za-z0-9_.-]+")
35
+
36
+
37
+ def _safe_slug(value: Any, fallback: str) -> str:
38
+ text = str(value or "").strip()
39
+ text = SAFE_SLUG_RE.sub("-", text).strip("._-")
40
+ while ".." in text:
41
+ text = text.replace("..", ".")
42
+ text = text.strip("._-")
43
+ return text or fallback
44
+
45
+
46
+ def _repo_slug(repo: Any) -> str:
47
+ return _safe_slug(str(repo or "").replace("/", "__"), "unknown-repo")
48
+
49
+
50
+ def _pr_slug(pr_number: Any) -> str:
51
+ return _safe_slug(pr_number, "unknown-pr")
52
+
53
+
54
+ def _head_slug(head_sha: Any) -> str:
55
+ text = str(head_sha or "").strip()
56
+ if text:
57
+ return _safe_slug(text, "unknown-head")
58
+ return "unknown-head"
59
+
60
+
61
+ def _required_manifest_value(value: Any, field: str) -> Any:
62
+ text = "" if value is None else str(value).strip()
63
+ if not text:
64
+ raise ValueError(f"blind-review artifact manifest requires {field}")
65
+ return value
66
+
67
+
68
+ def _artifact_name(
69
+ repo_slug: str,
70
+ pr_slug: str,
71
+ head_slug: str,
72
+ lane_slug: str,
73
+ ) -> str:
74
+ return f"code-mower-blind-{repo_slug}-pr-{pr_slug}-{head_slug}-{lane_slug}"
75
+
76
+
77
+ def build_blind_review_artifact_plan(
78
+ manifest: Mapping[str, Any],
79
+ *,
80
+ output_dir: str | Path = DEFAULT_OUTPUT_DIR,
81
+ storage_backend: str = DEFAULT_STORAGE_BACKEND,
82
+ retention_days: int = DEFAULT_RETENTION_DAYS,
83
+ ) -> dict[str, Any]:
84
+ """Build deterministic hidden-artifact and release paths for a manifest."""
85
+
86
+ if storage_backend not in SUPPORTED_STORAGE_BACKENDS:
87
+ supported = ", ".join(sorted(SUPPORTED_STORAGE_BACKENDS))
88
+ raise ValueError(
89
+ f"unsupported storage_backend {storage_backend!r}; supported: {supported}"
90
+ )
91
+ if retention_days <= 0:
92
+ raise ValueError("retention_days must be greater than zero")
93
+ if (
94
+ storage_backend == "github_actions_artifact"
95
+ and retention_days > GITHUB_ACTIONS_MAX_RETENTION_DAYS
96
+ ):
97
+ raise ValueError(
98
+ "github_actions_artifact retention_days must be between "
99
+ f"1 and {GITHUB_ACTIONS_MAX_RETENTION_DAYS}"
100
+ )
101
+
102
+ plan = blind_review_coordinator.build_blind_review_plan(manifest)
103
+ repo = _required_manifest_value(plan.get("repo", manifest.get("repo")), "repo")
104
+ pr_number = _required_manifest_value(
105
+ plan.get("pr_number", manifest.get("pr_number")),
106
+ "pr_number",
107
+ )
108
+ head_sha = _required_manifest_value(
109
+ plan.get("head_sha", manifest.get("head_sha")),
110
+ "head_sha",
111
+ )
112
+ repo_id = _repo_slug(repo)
113
+ pr_id = _pr_slug(pr_number)
114
+ head_id = _head_slug(head_sha)
115
+ base_path = Path(output_dir) / repo_id / f"pr-{pr_id}" / head_id
116
+ lane_states = plan.get("lane_states", {})
117
+ held_artifacts: list[dict[str, Any]] = []
118
+ seen_lanes: set[str] = set()
119
+ seen_lane_slugs: dict[str, str] = {}
120
+
121
+ for lane in plan.get("required_lanes", []):
122
+ if lane in seen_lanes:
123
+ raise ValueError(f"blind-review lane is duplicated: {lane!r}")
124
+ seen_lanes.add(lane)
125
+ lane_slug = _safe_slug(lane, "unknown-lane")
126
+ previous_lane = seen_lane_slugs.get(lane_slug)
127
+ if previous_lane is not None and previous_lane != lane:
128
+ raise ValueError(
129
+ "blind-review lanes produce duplicate artifact slug "
130
+ f"{lane_slug!r}: {previous_lane!r}, {lane!r}"
131
+ )
132
+ seen_lane_slugs[lane_slug] = lane
133
+ state = lane_states.get(lane, {}) if isinstance(lane_states, Mapping) else {}
134
+ if not isinstance(state, Mapping):
135
+ state = {}
136
+ lane_path = base_path / lane_slug
137
+ artifact_id = _artifact_name(repo_id, pr_id, head_id, lane_slug)
138
+ held_artifacts.append(
139
+ {
140
+ "lane": lane,
141
+ "state": state.get("state", "pending"),
142
+ "source_artifact": state.get("artifact", ""),
143
+ "head_sha": state.get("head_sha", ""),
144
+ "artifact_id": artifact_id,
145
+ "held_path": str(lane_path / "held.md"),
146
+ "metadata_path": str(lane_path / "metadata.json"),
147
+ "github_actions_artifact": {
148
+ "name": artifact_id,
149
+ "path": str(lane_path),
150
+ "retention_days": retention_days,
151
+ },
152
+ }
153
+ )
154
+
155
+ artifacts_by_lane = {
156
+ artifact["lane"]: artifact
157
+ for artifact in held_artifacts
158
+ }
159
+ release_ready = bool(plan.get("ready_to_release"))
160
+ release_artifacts: list[dict[str, Any]] = []
161
+ if release_ready:
162
+ for lane in plan.get("release_order", []):
163
+ artifact = artifacts_by_lane.get(lane)
164
+ if artifact is None:
165
+ continue
166
+ lane_slug = _safe_slug(lane, "unknown-lane")
167
+ release_artifact = dict(artifact)
168
+ release_artifact["release_path"] = str(
169
+ base_path / "release" / f"{lane_slug}.md"
170
+ )
171
+ release_artifacts.append(release_artifact)
172
+ release_manifest = {
173
+ "ready_to_release": release_ready,
174
+ "release_order": list(plan.get("release_order", [])),
175
+ "artifacts": release_artifacts,
176
+ }
177
+ if release_ready:
178
+ release_manifest["path"] = str(base_path / "release" / "manifest.json")
179
+
180
+ return {
181
+ "mode": "blind-review-artifacts",
182
+ "storage": {
183
+ "backend": storage_backend,
184
+ "output_dir": str(output_dir),
185
+ "retention_days": retention_days,
186
+ },
187
+ "repo": repo,
188
+ "pr_number": pr_number,
189
+ "head_sha": head_sha,
190
+ "plan": plan,
191
+ "held_artifacts": held_artifacts,
192
+ "release_manifest": release_manifest,
193
+ "github_actions": {
194
+ "upload_when": "lane_completed",
195
+ "download_when": "coordinator_ready_to_release",
196
+ "artifact_name_pattern": "code-mower-blind-{repo}-pr-{pr}-{head}-{lane}",
197
+ "retention_days": retention_days,
198
+ },
199
+ "warnings": list(plan.get("warnings", [])),
200
+ }
201
+
202
+
203
+ def _write_text(path: Path, content: str, *, force: bool) -> None:
204
+ if path.exists() and not force:
205
+ raise ValueError(f"refusing to overwrite existing artifact file: {path}")
206
+ path.parent.mkdir(parents=True, exist_ok=True)
207
+ path.write_text(content, encoding="utf-8")
208
+
209
+
210
+ def _write_json(path: Path, payload: Mapping[str, Any], *, force: bool) -> None:
211
+ _write_text(path, json.dumps(payload, indent=2, sort_keys=True) + "\n", force=force)
212
+
213
+
214
+ def _copy_file(source: Path, target: Path, *, force: bool) -> None:
215
+ if target.exists() and not force:
216
+ raise ValueError(f"refusing to overwrite existing artifact file: {target}")
217
+ target.parent.mkdir(parents=True, exist_ok=True)
218
+ shutil.copyfile(source, target)
219
+
220
+
221
+ def _resolve_source_artifact(
222
+ source_artifact: str,
223
+ *,
224
+ source_base_dir: Path | None = None,
225
+ ) -> Path | None:
226
+ source_text = str(source_artifact or "").strip()
227
+ if not source_text:
228
+ return None
229
+ source_path = Path(source_text).expanduser()
230
+ if not source_path.is_absolute() and source_base_dir is not None:
231
+ source_path = source_base_dir / source_path
232
+ return source_path
233
+
234
+
235
+ def _placeholder_artifact_text(artifact: Mapping[str, Any]) -> str:
236
+ return "\n".join(
237
+ [
238
+ "# Blind Review Artifact Placeholder",
239
+ "",
240
+ "The source artifact was not available when this plan was materialized.",
241
+ "",
242
+ f"Lane: {artifact.get('lane')}",
243
+ f"State: {artifact.get('state')}",
244
+ f"Head SHA: {artifact.get('head_sha')}",
245
+ f"Source artifact: {artifact.get('source_artifact') or '(none)'}",
246
+ "",
247
+ ]
248
+ )
249
+
250
+
251
+ def _is_placeholder_artifact(path: Path) -> bool:
252
+ try:
253
+ with path.open("r", encoding="utf-8") as handle:
254
+ return handle.readline().strip() == "# Blind Review Artifact Placeholder"
255
+ except UnicodeDecodeError:
256
+ return False
257
+
258
+
259
+ def _metadata_marks_missing_source(path: Path) -> bool:
260
+ if not path.is_file():
261
+ return False
262
+ try:
263
+ payload = json.loads(path.read_text(encoding="utf-8"))
264
+ except (OSError, json.JSONDecodeError):
265
+ return False
266
+ return isinstance(payload, Mapping) and payload.get("source_available") is False
267
+
268
+
269
+ def _sha256_file(path: Path) -> str:
270
+ digest = hashlib.sha256()
271
+ with path.open("rb") as handle:
272
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
273
+ digest.update(chunk)
274
+ return digest.hexdigest()
275
+
276
+
277
+ def materialize_blind_review_artifact_plan(
278
+ artifact_plan: Mapping[str, Any],
279
+ *,
280
+ force: bool = False,
281
+ require_sources: bool = False,
282
+ source_base_dir: Path | None = None,
283
+ ) -> dict[str, Any]:
284
+ """Write held artifacts, metadata, and release files for a plan."""
285
+
286
+ plan = json.loads(json.dumps(artifact_plan))
287
+ writes: list[dict[str, Any]] = []
288
+ release_sources: dict[str, Path] = {}
289
+ release_held_available: dict[str, bool] = {}
290
+ storage = plan.get("storage", {})
291
+ release_manifest = plan.get("release_manifest", {})
292
+ release_ready = (
293
+ bool(release_manifest.get("ready_to_release"))
294
+ if isinstance(release_manifest, Mapping)
295
+ else False
296
+ )
297
+
298
+ for artifact in plan.get("held_artifacts", []):
299
+ if not isinstance(artifact, Mapping):
300
+ continue
301
+ held_path_text = str(artifact.get("held_path") or "").strip()
302
+ metadata_path_text = str(artifact.get("metadata_path") or "").strip()
303
+ if not held_path_text or not metadata_path_text:
304
+ raise ValueError("held artifact paths must be present before materializing")
305
+ held_path = Path(held_path_text)
306
+ metadata_path = Path(metadata_path_text)
307
+
308
+ source_path = _resolve_source_artifact(
309
+ str(artifact.get("source_artifact") or ""),
310
+ source_base_dir=source_base_dir,
311
+ )
312
+ source_available = bool(source_path and source_path.is_file())
313
+ source_sha256 = (
314
+ _sha256_file(source_path)
315
+ if source_available and source_path is not None
316
+ else ""
317
+ )
318
+ held_available = held_path.is_file()
319
+ if require_sources and not source_available:
320
+ raise ValueError(
321
+ "source artifact is required but missing for lane "
322
+ f"{artifact.get('lane')}: {artifact.get('source_artifact') or '(none)'}"
323
+ )
324
+ if release_ready and not source_available:
325
+ if not held_available or _is_placeholder_artifact(held_path):
326
+ raise ValueError(
327
+ "refusing to release placeholder artifact for lane "
328
+ f"{artifact.get('lane')}: {artifact.get('source_artifact') or '(none)'}"
329
+ )
330
+
331
+ metadata = {
332
+ "lane": artifact.get("lane"),
333
+ "state": artifact.get("state"),
334
+ "head_sha": artifact.get("head_sha"),
335
+ "artifact_id": artifact.get("artifact_id"),
336
+ "source_artifact": artifact.get("source_artifact"),
337
+ "source_available": source_available,
338
+ "source_sha256": source_sha256,
339
+ "storage": storage,
340
+ }
341
+
342
+ if source_available and source_path is not None:
343
+ refresh_placeholder = held_path.is_file() and _is_placeholder_artifact(held_path)
344
+ reuse_existing_held = held_path.is_file() and not refresh_placeholder and not force
345
+ if reuse_existing_held:
346
+ if not metadata_path.is_file():
347
+ _write_json(metadata_path, metadata, force=force)
348
+ writes.append({"kind": "metadata", "path": str(metadata_path)})
349
+ else:
350
+ _write_json(
351
+ metadata_path,
352
+ metadata,
353
+ force=force
354
+ or refresh_placeholder
355
+ or _metadata_marks_missing_source(metadata_path),
356
+ )
357
+ writes.append({"kind": "metadata", "path": str(metadata_path)})
358
+ _copy_file(source_path, held_path, force=force or refresh_placeholder)
359
+ writes.append(
360
+ {
361
+ "kind": "held_artifact",
362
+ "path": str(held_path),
363
+ "source": str(source_path),
364
+ "source_available": True,
365
+ "sha256": _sha256_file(held_path),
366
+ }
367
+ )
368
+ releasable = True
369
+ elif release_ready and held_available:
370
+ if not metadata_path.is_file():
371
+ _write_json(metadata_path, metadata, force=force)
372
+ writes.append({"kind": "metadata", "path": str(metadata_path)})
373
+ plan.setdefault("warnings", []).append(
374
+ f"source artifact missing for lane {artifact.get('lane')}; "
375
+ "using existing held artifact for release"
376
+ )
377
+ releasable = True
378
+ else:
379
+ if held_available:
380
+ if not metadata_path.is_file():
381
+ _write_json(metadata_path, metadata, force=force)
382
+ writes.append({"kind": "metadata", "path": str(metadata_path)})
383
+ if _is_placeholder_artifact(held_path):
384
+ plan.setdefault("warnings", []).append(
385
+ f"source artifact missing for lane {artifact.get('lane')}; "
386
+ "reused held placeholder"
387
+ )
388
+ releasable = False
389
+ else:
390
+ plan.setdefault("warnings", []).append(
391
+ f"source artifact missing for lane {artifact.get('lane')}; "
392
+ "using existing held artifact"
393
+ )
394
+ releasable = True
395
+ else:
396
+ _write_json(
397
+ metadata_path,
398
+ metadata,
399
+ force=force or _metadata_marks_missing_source(metadata_path),
400
+ )
401
+ writes.append({"kind": "metadata", "path": str(metadata_path)})
402
+ _write_text(held_path, _placeholder_artifact_text(artifact), force=force)
403
+ plan.setdefault("warnings", []).append(
404
+ f"source artifact missing for lane {artifact.get('lane')}; "
405
+ "materialized held placeholder"
406
+ )
407
+ writes.append(
408
+ {
409
+ "kind": "held_artifact",
410
+ "path": str(held_path),
411
+ "source": str(source_path) if source_path is not None else "",
412
+ "source_available": False,
413
+ "sha256": _sha256_file(held_path),
414
+ }
415
+ )
416
+ releasable = False
417
+ lane = str(artifact.get("lane") or "")
418
+ release_sources[lane] = held_path
419
+ release_held_available[lane] = releasable
420
+
421
+ if isinstance(release_manifest, Mapping) and release_manifest.get("ready_to_release"):
422
+ for artifact in release_manifest.get("artifacts", []) or []:
423
+ if not isinstance(artifact, Mapping):
424
+ continue
425
+ lane = str(artifact.get("lane") or "")
426
+ held_path = release_sources.get(lane)
427
+ release_path = Path(str(artifact.get("release_path") or ""))
428
+ if held_path is None:
429
+ raise ValueError(f"release artifact has no held source for lane {lane}")
430
+ if not release_held_available.get(lane, False):
431
+ raise ValueError(
432
+ f"refusing to release placeholder artifact for lane {lane}; "
433
+ "held artifact was unavailable"
434
+ )
435
+ held_sha256 = _sha256_file(held_path)
436
+ _copy_file(held_path, release_path, force=force)
437
+ release_sha256 = _sha256_file(release_path)
438
+ if isinstance(artifact, dict):
439
+ artifact["held_sha256"] = held_sha256
440
+ artifact["release_sha256"] = release_sha256
441
+ writes.append(
442
+ {
443
+ "kind": "release_artifact",
444
+ "path": str(release_path),
445
+ "source": str(held_path),
446
+ "sha256": release_sha256,
447
+ }
448
+ )
449
+ manifest_path = release_manifest.get("path")
450
+ if manifest_path:
451
+ _write_json(Path(str(manifest_path)), release_manifest, force=force)
452
+ writes.append({"kind": "release_manifest", "path": str(manifest_path)})
453
+
454
+ plan["writes"] = writes
455
+ return plan
456
+
457
+
458
+ def render_blind_review_artifacts_text(artifact_plan: Mapping[str, Any]) -> str:
459
+ storage = artifact_plan.get("storage", {})
460
+ release_manifest = artifact_plan.get("release_manifest", {})
461
+ storage_backend = storage.get("backend", "") if isinstance(storage, Mapping) else ""
462
+ ready = (
463
+ release_manifest.get("ready_to_release")
464
+ if isinstance(release_manifest, Mapping)
465
+ else False
466
+ )
467
+ lines = [
468
+ f"Blind review artifacts for {artifact_plan.get('repo') or 'unknown repo'}#{artifact_plan.get('pr_number') or '?'}",
469
+ f"storage_backend: {storage_backend}",
470
+ f"ready_to_release: {str(ready).lower()}",
471
+ "",
472
+ "Held artifacts:",
473
+ ]
474
+ for artifact in artifact_plan.get("held_artifacts", []):
475
+ if not isinstance(artifact, Mapping):
476
+ continue
477
+ lines.append(
478
+ f"- {artifact.get('lane')}: {artifact.get('state')} "
479
+ f"name={artifact.get('artifact_id')} path={artifact.get('held_path')}"
480
+ )
481
+ if isinstance(release_manifest, Mapping) and release_manifest.get("artifacts"):
482
+ lines.extend(["", "Release artifacts:"])
483
+ for artifact in release_manifest.get("artifacts", []):
484
+ if isinstance(artifact, Mapping):
485
+ lines.append(f"- {artifact.get('lane')}: {artifact.get('release_path')}")
486
+ if artifact_plan.get("warnings"):
487
+ lines.extend(["", "Warnings:"])
488
+ lines.extend(f"- {warning}" for warning in artifact_plan.get("warnings", []))
489
+ if artifact_plan.get("writes"):
490
+ lines.extend(["", "Writes:"])
491
+ for write in artifact_plan.get("writes", []):
492
+ if isinstance(write, Mapping):
493
+ lines.append(f"- {write.get('kind')}: {write.get('path')}")
494
+ return "\n".join(lines) + "\n"
495
+
496
+
497
+ def _load_manifest(path: Path) -> Mapping[str, Any]:
498
+ try:
499
+ payload = json.loads(path.read_text(encoding="utf-8"))
500
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
501
+ raise ValueError(f"unable to read {path}: {exc}") from exc
502
+ if not isinstance(payload, Mapping):
503
+ raise ValueError("blind-review artifact manifest must be a JSON object")
504
+ return payload
505
+
506
+
507
+ def main(argv: list[str] | None = None) -> int:
508
+ parser = argparse.ArgumentParser(description=__doc__)
509
+ parser.add_argument("manifest", type=Path)
510
+ parser.add_argument("--output-dir", default=DEFAULT_OUTPUT_DIR)
511
+ parser.add_argument(
512
+ "--storage-backend",
513
+ choices=sorted(SUPPORTED_STORAGE_BACKENDS),
514
+ default=DEFAULT_STORAGE_BACKEND,
515
+ )
516
+ parser.add_argument("--retention-days", type=int, default=DEFAULT_RETENTION_DAYS)
517
+ parser.add_argument(
518
+ "--write",
519
+ action="store_true",
520
+ help="Materialize held artifacts, metadata, and release files.",
521
+ )
522
+ parser.add_argument(
523
+ "--force",
524
+ action="store_true",
525
+ help="Allow --write to overwrite existing artifact files.",
526
+ )
527
+ parser.add_argument(
528
+ "--require-sources",
529
+ action="store_true",
530
+ help="Fail --write if a source artifact path is missing or unreadable.",
531
+ )
532
+ parser.add_argument("--json", action="store_true")
533
+ args = parser.parse_args(argv)
534
+
535
+ try:
536
+ manifest = _load_manifest(args.manifest)
537
+ artifact_plan = build_blind_review_artifact_plan(
538
+ manifest,
539
+ output_dir=args.output_dir,
540
+ storage_backend=args.storage_backend,
541
+ retention_days=args.retention_days,
542
+ )
543
+ if args.write:
544
+ artifact_plan = materialize_blind_review_artifact_plan(
545
+ artifact_plan,
546
+ force=args.force,
547
+ require_sources=args.require_sources,
548
+ source_base_dir=args.manifest.parent,
549
+ )
550
+ except ValueError as exc:
551
+ print(f"error: {exc}", file=sys.stderr)
552
+ return 1
553
+
554
+ if args.json:
555
+ print(json.dumps(artifact_plan, indent=2, sort_keys=True))
556
+ else:
557
+ print(render_blind_review_artifacts_text(artifact_plan), end="")
558
+ return 0
559
+
560
+
561
+ if __name__ == "__main__":
562
+ raise SystemExit(main())