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,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())
|