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,114 @@
|
|
|
1
|
+
"""Endpoint normalization and health checks for CodeMower.com uploads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
DEFAULT_HEALTH_PATH = "/api/health"
|
|
13
|
+
DEFAULT_DASHBOARD_PATH = "/dashboard"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_local_http_endpoint(endpoint: str) -> bool:
|
|
17
|
+
parsed_endpoint = urllib.parse.urlparse(endpoint)
|
|
18
|
+
return (
|
|
19
|
+
parsed_endpoint.scheme == "http"
|
|
20
|
+
and parsed_endpoint.hostname in {"localhost", "127.0.0.1"}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_upload_endpoint(endpoint: str) -> None:
|
|
25
|
+
parsed_endpoint = urllib.parse.urlparse(endpoint)
|
|
26
|
+
if parsed_endpoint.scheme != "https" and not is_local_http_endpoint(endpoint):
|
|
27
|
+
raise ValueError("upload endpoint must be https:// or a local development endpoint")
|
|
28
|
+
if not parsed_endpoint.netloc:
|
|
29
|
+
raise ValueError(f"upload endpoint is missing a host: {endpoint!r}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def origin_for_endpoint(endpoint: str) -> str:
|
|
33
|
+
parsed_endpoint = urllib.parse.urlparse(endpoint)
|
|
34
|
+
if not parsed_endpoint.scheme or not parsed_endpoint.netloc:
|
|
35
|
+
return ""
|
|
36
|
+
return urllib.parse.urlunparse(
|
|
37
|
+
(parsed_endpoint.scheme, parsed_endpoint.netloc, "", "", "", "")
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def url_for_endpoint_path(endpoint: str, path: str) -> str:
|
|
42
|
+
origin = origin_for_endpoint(endpoint)
|
|
43
|
+
if not origin:
|
|
44
|
+
return ""
|
|
45
|
+
return urllib.parse.urljoin(origin + "/", path.lstrip("/"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def dashboard_url_for_endpoint(endpoint: str) -> str:
|
|
49
|
+
return url_for_endpoint_path(endpoint, DEFAULT_DASHBOARD_PATH)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def health_url_for_endpoint(endpoint: str) -> str:
|
|
53
|
+
return url_for_endpoint_path(endpoint, DEFAULT_HEALTH_PATH)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def probe_cloud_service(endpoint: str, *, timeout: float) -> dict[str, Any]:
|
|
57
|
+
health_url = health_url_for_endpoint(endpoint)
|
|
58
|
+
if not health_url:
|
|
59
|
+
return {
|
|
60
|
+
"name": "service",
|
|
61
|
+
"status": "fail",
|
|
62
|
+
"message": "unable to derive health URL from upload endpoint",
|
|
63
|
+
}
|
|
64
|
+
request = urllib.request.Request(
|
|
65
|
+
health_url,
|
|
66
|
+
headers={
|
|
67
|
+
"Accept": "application/json",
|
|
68
|
+
"User-Agent": "code-mower-cloud-doctor",
|
|
69
|
+
},
|
|
70
|
+
method="GET",
|
|
71
|
+
)
|
|
72
|
+
try:
|
|
73
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
74
|
+
response_body = response.read().decode("utf-8", errors="replace")
|
|
75
|
+
status_code = response.getcode()
|
|
76
|
+
except urllib.error.HTTPError as exc:
|
|
77
|
+
return {
|
|
78
|
+
"name": "service",
|
|
79
|
+
"status": "fail",
|
|
80
|
+
"message": f"health check failed with HTTP {exc.code}",
|
|
81
|
+
"detail": {"health_url": health_url, "status_code": exc.code},
|
|
82
|
+
}
|
|
83
|
+
except urllib.error.URLError as exc:
|
|
84
|
+
return {
|
|
85
|
+
"name": "service",
|
|
86
|
+
"status": "fail",
|
|
87
|
+
"message": f"health check failed: {exc.reason}",
|
|
88
|
+
"detail": {"health_url": health_url},
|
|
89
|
+
}
|
|
90
|
+
parsed: dict[str, Any] = {}
|
|
91
|
+
if response_body.strip():
|
|
92
|
+
try:
|
|
93
|
+
maybe_parsed = json.loads(response_body)
|
|
94
|
+
if isinstance(maybe_parsed, dict):
|
|
95
|
+
parsed = maybe_parsed
|
|
96
|
+
except json.JSONDecodeError:
|
|
97
|
+
parsed = {}
|
|
98
|
+
if 200 <= status_code < 300:
|
|
99
|
+
detail: dict[str, Any] = {"health_url": health_url, "status_code": status_code}
|
|
100
|
+
for key in ("app", "supabaseConfigured"):
|
|
101
|
+
if key in parsed and isinstance(parsed[key], str | bool | int | float):
|
|
102
|
+
detail[key] = parsed[key]
|
|
103
|
+
return {
|
|
104
|
+
"name": "service",
|
|
105
|
+
"status": "pass",
|
|
106
|
+
"message": f"health endpoint is reachable: {health_url}",
|
|
107
|
+
"detail": detail,
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
"name": "service",
|
|
111
|
+
"status": "fail",
|
|
112
|
+
"message": f"health check returned HTTP {status_code}",
|
|
113
|
+
"detail": {"health_url": health_url, "status_code": status_code},
|
|
114
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Structured cloud event and repository metadata helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .bundle import MAX_EVENT_COUNT, SAFE_EVENT_TYPES, SAFE_REPORT_KINDS, validate_metadata_payload
|
|
13
|
+
from .dogfood import DogfoodPlan
|
|
14
|
+
from .errors import CloudBundleError
|
|
15
|
+
from .git_metadata import run_git
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
EVENT_SCHEMA = "code_mower.benchmarkEvent.v1"
|
|
19
|
+
GITHUB_RUN_LIST_FIELDS = (
|
|
20
|
+
"databaseId",
|
|
21
|
+
"name",
|
|
22
|
+
"status",
|
|
23
|
+
"conclusion",
|
|
24
|
+
"event",
|
|
25
|
+
"headBranch",
|
|
26
|
+
"headSha",
|
|
27
|
+
"createdAt",
|
|
28
|
+
"updatedAt",
|
|
29
|
+
"url",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def safe_kind(value: str) -> str:
|
|
34
|
+
kind = value.strip()
|
|
35
|
+
if kind not in SAFE_REPORT_KINDS:
|
|
36
|
+
allowed = ", ".join(sorted(SAFE_REPORT_KINDS))
|
|
37
|
+
raise CloudBundleError(f"unsupported report kind {value!r}; allowed: {allowed}")
|
|
38
|
+
return kind
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def safe_event_type(value: str) -> str:
|
|
42
|
+
event_type = value.strip()
|
|
43
|
+
if event_type not in SAFE_EVENT_TYPES:
|
|
44
|
+
allowed = ", ".join(sorted(SAFE_EVENT_TYPES))
|
|
45
|
+
raise CloudBundleError(
|
|
46
|
+
f"unsupported event type {value!r}; allowed: {allowed}"
|
|
47
|
+
)
|
|
48
|
+
return event_type
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def utc_now() -> str:
|
|
52
|
+
return dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_dogfood_event(
|
|
56
|
+
*,
|
|
57
|
+
repo_path: Path,
|
|
58
|
+
plan: DogfoodPlan,
|
|
59
|
+
) -> dict[str, Any]:
|
|
60
|
+
status = run_git(repo_path, ["status", "--porcelain"])
|
|
61
|
+
return {
|
|
62
|
+
"schema": EVENT_SCHEMA,
|
|
63
|
+
"event_id": str(uuid.uuid4()),
|
|
64
|
+
"event_type": "dogfood_upload",
|
|
65
|
+
"created_at": utc_now(),
|
|
66
|
+
"repo_slug": plan.repo_slug,
|
|
67
|
+
"team_id": plan.team_id,
|
|
68
|
+
"install_id": plan.install_id,
|
|
69
|
+
"source": plan.source,
|
|
70
|
+
"provider": "",
|
|
71
|
+
"lens": "",
|
|
72
|
+
"status": "observed",
|
|
73
|
+
"git": {
|
|
74
|
+
"sha": run_git(repo_path, ["rev-parse", "HEAD"]),
|
|
75
|
+
"branch": run_git(repo_path, ["branch", "--show-current"]),
|
|
76
|
+
"dirty": bool(status),
|
|
77
|
+
},
|
|
78
|
+
"metrics": {
|
|
79
|
+
"report_count": plan.report_count,
|
|
80
|
+
"extra_event_count": plan.extra_event_count,
|
|
81
|
+
},
|
|
82
|
+
"dimensions": {
|
|
83
|
+
"command": "code-mower cloud dogfood",
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def event_id_from_github_run(repo_slug: str, run: dict[str, Any]) -> str:
|
|
89
|
+
database_id = str(run.get("databaseId") or "").strip()
|
|
90
|
+
if database_id:
|
|
91
|
+
run_url = f"https://github.com/{repo_slug}/actions/runs/{database_id}"
|
|
92
|
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, run_url))
|
|
93
|
+
run_url = str(run.get("url") or "").strip()
|
|
94
|
+
if run_url:
|
|
95
|
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, run_url))
|
|
96
|
+
return str(uuid.uuid4())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_gh_run_list(
|
|
100
|
+
*,
|
|
101
|
+
repo_slug: str,
|
|
102
|
+
limit: int,
|
|
103
|
+
repo_path: Path,
|
|
104
|
+
) -> list[dict[str, Any]]:
|
|
105
|
+
command = [
|
|
106
|
+
"gh",
|
|
107
|
+
"run",
|
|
108
|
+
"list",
|
|
109
|
+
"--repo",
|
|
110
|
+
repo_slug,
|
|
111
|
+
"--limit",
|
|
112
|
+
str(limit),
|
|
113
|
+
"--json",
|
|
114
|
+
",".join(GITHUB_RUN_LIST_FIELDS),
|
|
115
|
+
]
|
|
116
|
+
try:
|
|
117
|
+
completed = subprocess.run(
|
|
118
|
+
command,
|
|
119
|
+
cwd=repo_path,
|
|
120
|
+
text=True,
|
|
121
|
+
capture_output=True,
|
|
122
|
+
check=False,
|
|
123
|
+
)
|
|
124
|
+
except OSError as exc:
|
|
125
|
+
raise CloudBundleError("GitHub Actions catch-up requires the `gh` CLI") from exc
|
|
126
|
+
if completed.returncode != 0:
|
|
127
|
+
detail = completed.stderr.strip() or completed.stdout.strip()
|
|
128
|
+
raise CloudBundleError(
|
|
129
|
+
"unable to read GitHub Actions runs with `gh run list`"
|
|
130
|
+
+ (f": {detail}" if detail else "")
|
|
131
|
+
)
|
|
132
|
+
try:
|
|
133
|
+
parsed = json.loads(completed.stdout or "[]")
|
|
134
|
+
except json.JSONDecodeError as exc:
|
|
135
|
+
raise CloudBundleError("`gh run list --json` returned invalid JSON") from exc
|
|
136
|
+
if not isinstance(parsed, list):
|
|
137
|
+
raise CloudBundleError("`gh run list --json` must return a JSON array")
|
|
138
|
+
runs: list[dict[str, Any]] = []
|
|
139
|
+
for item in parsed:
|
|
140
|
+
if not isinstance(item, dict):
|
|
141
|
+
raise CloudBundleError("`gh run list --json` returned a non-object run")
|
|
142
|
+
runs.append(item)
|
|
143
|
+
return runs
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_workflow_run_event(
|
|
147
|
+
*,
|
|
148
|
+
repo_slug: str,
|
|
149
|
+
team_id: str,
|
|
150
|
+
install_id: str,
|
|
151
|
+
source: str,
|
|
152
|
+
run: dict[str, Any],
|
|
153
|
+
include_git_ref: bool,
|
|
154
|
+
) -> dict[str, Any]:
|
|
155
|
+
status = str(run.get("conclusion") or run.get("status") or "observed")
|
|
156
|
+
dimensions: dict[str, Any] = {
|
|
157
|
+
"workflow_name": str(run.get("name") or ""),
|
|
158
|
+
"trigger": str(run.get("event") or ""),
|
|
159
|
+
"run_status": str(run.get("status") or ""),
|
|
160
|
+
"run_conclusion": str(run.get("conclusion") or ""),
|
|
161
|
+
"run_url": str(run.get("url") or ""),
|
|
162
|
+
"updated_at": str(run.get("updatedAt") or ""),
|
|
163
|
+
}
|
|
164
|
+
if include_git_ref:
|
|
165
|
+
dimensions["head_branch"] = str(run.get("headBranch") or "")
|
|
166
|
+
dimensions["head_sha"] = str(run.get("headSha") or "")
|
|
167
|
+
event = {
|
|
168
|
+
"schema": EVENT_SCHEMA,
|
|
169
|
+
"event_id": event_id_from_github_run(repo_slug, run),
|
|
170
|
+
"event_type": "workflow_run",
|
|
171
|
+
"created_at": str(run.get("createdAt") or utc_now()),
|
|
172
|
+
"repo_slug": repo_slug,
|
|
173
|
+
"team_id": team_id,
|
|
174
|
+
"install_id": install_id,
|
|
175
|
+
"source": source,
|
|
176
|
+
"provider": "",
|
|
177
|
+
"lens": "base",
|
|
178
|
+
"status": status,
|
|
179
|
+
"metrics": {},
|
|
180
|
+
"dimensions": dimensions,
|
|
181
|
+
}
|
|
182
|
+
return normalize_event(event, "workflow_run")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def normalize_event(value: dict[str, Any], event_type: str) -> dict[str, Any]:
|
|
186
|
+
validate_metadata_payload(value)
|
|
187
|
+
normalized = dict(value)
|
|
188
|
+
normalized["schema"] = str(normalized.get("schema") or EVENT_SCHEMA)
|
|
189
|
+
if normalized["schema"] != EVENT_SCHEMA:
|
|
190
|
+
raise CloudBundleError(
|
|
191
|
+
f"unsupported event schema {normalized['schema']!r}; expected {EVENT_SCHEMA}"
|
|
192
|
+
)
|
|
193
|
+
normalized["event_type"] = safe_event_type(
|
|
194
|
+
str(normalized.get("event_type") or event_type)
|
|
195
|
+
)
|
|
196
|
+
normalized["event_id"] = str(normalized.get("event_id") or uuid.uuid4())
|
|
197
|
+
normalized["created_at"] = str(normalized.get("created_at") or utc_now())
|
|
198
|
+
for key in (
|
|
199
|
+
"repo_slug",
|
|
200
|
+
"team_id",
|
|
201
|
+
"install_id",
|
|
202
|
+
"source",
|
|
203
|
+
"provider",
|
|
204
|
+
"lens",
|
|
205
|
+
"status",
|
|
206
|
+
):
|
|
207
|
+
normalized[key] = str(normalized.get(key) or "")
|
|
208
|
+
metrics = normalized.get("metrics")
|
|
209
|
+
if metrics is None:
|
|
210
|
+
normalized["metrics"] = {}
|
|
211
|
+
elif not isinstance(metrics, dict):
|
|
212
|
+
raise CloudBundleError("structured event metrics must be an object")
|
|
213
|
+
dimensions = normalized.get("dimensions")
|
|
214
|
+
if dimensions is None:
|
|
215
|
+
normalized["dimensions"] = {}
|
|
216
|
+
elif not isinstance(dimensions, dict):
|
|
217
|
+
raise CloudBundleError("structured event dimensions must be an object")
|
|
218
|
+
validate_metadata_payload(normalized)
|
|
219
|
+
return normalized
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def load_event_file(path: Path, event_type: str) -> list[dict[str, Any]]:
|
|
223
|
+
source = path.expanduser()
|
|
224
|
+
if not source.is_file():
|
|
225
|
+
raise CloudBundleError(f"event file does not exist or is not a file: {source}")
|
|
226
|
+
try:
|
|
227
|
+
text = source.read_text(encoding="utf-8")
|
|
228
|
+
except UnicodeDecodeError as exc:
|
|
229
|
+
raise CloudBundleError(f"event file is not UTF-8 text: {source}") from exc
|
|
230
|
+
except OSError as exc:
|
|
231
|
+
raise CloudBundleError(f"unable to read event file {source}: {exc}") from exc
|
|
232
|
+
if not text.strip():
|
|
233
|
+
return []
|
|
234
|
+
try:
|
|
235
|
+
parsed = json.loads(text)
|
|
236
|
+
except json.JSONDecodeError:
|
|
237
|
+
parsed = []
|
|
238
|
+
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
239
|
+
if not line.strip():
|
|
240
|
+
continue
|
|
241
|
+
try:
|
|
242
|
+
parsed.append(json.loads(line))
|
|
243
|
+
except json.JSONDecodeError as exc:
|
|
244
|
+
raise CloudBundleError(
|
|
245
|
+
f"event file {source} line {line_number} is not JSON"
|
|
246
|
+
) from exc
|
|
247
|
+
if isinstance(parsed, dict):
|
|
248
|
+
parsed_events = [parsed]
|
|
249
|
+
elif isinstance(parsed, list):
|
|
250
|
+
parsed_events = parsed
|
|
251
|
+
else:
|
|
252
|
+
raise CloudBundleError(
|
|
253
|
+
f"event file must contain an object, array, or JSONL: {source}"
|
|
254
|
+
)
|
|
255
|
+
events: list[dict[str, Any]] = []
|
|
256
|
+
for item in parsed_events:
|
|
257
|
+
if not isinstance(item, dict):
|
|
258
|
+
raise CloudBundleError(
|
|
259
|
+
f"event file contains a non-object event: {source}"
|
|
260
|
+
)
|
|
261
|
+
events.append(normalize_event(item, event_type))
|
|
262
|
+
return events
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def parse_event_args(values: list[str]) -> list[dict[str, Any]]:
|
|
266
|
+
events: list[dict[str, Any]] = []
|
|
267
|
+
for raw in values:
|
|
268
|
+
if "=" not in raw:
|
|
269
|
+
raise CloudBundleError(
|
|
270
|
+
"--event entries must use EVENT_TYPE=PATH, for example reviewer_run=run.json"
|
|
271
|
+
)
|
|
272
|
+
event_type, path_text = raw.split("=", 1)
|
|
273
|
+
events.extend(load_event_file(Path(path_text), safe_event_type(event_type)))
|
|
274
|
+
if len(events) > MAX_EVENT_COUNT:
|
|
275
|
+
raise CloudBundleError(
|
|
276
|
+
f"too many events: {len(events)}; max {MAX_EVENT_COUNT}"
|
|
277
|
+
)
|
|
278
|
+
return events
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Local cloud benchmark bundle materialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .bundle import (
|
|
11
|
+
BUNDLE_MANIFEST_FILENAME,
|
|
12
|
+
BUNDLE_SCHEMA,
|
|
13
|
+
EXCLUDED_CONTENT,
|
|
14
|
+
EXPECTED_BUNDLE_ENTRIES,
|
|
15
|
+
MAX_EVENT_COUNT,
|
|
16
|
+
is_bundle_manifest,
|
|
17
|
+
)
|
|
18
|
+
from .errors import CloudBundleError
|
|
19
|
+
from .events import normalize_event, safe_kind
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def safe_target_name(path: Path, index: int, *, anonymous: bool = False) -> str:
|
|
23
|
+
suffix = path.suffix.lower()
|
|
24
|
+
if suffix not in {".json", ".jsonl", ".md", ".txt"}:
|
|
25
|
+
raise CloudBundleError(
|
|
26
|
+
f"unsupported report file extension for {path}; expected .json, .jsonl, .md, or .txt"
|
|
27
|
+
)
|
|
28
|
+
if anonymous:
|
|
29
|
+
return f"{index:02d}-report{suffix}"
|
|
30
|
+
stem = "".join(char if char.isalnum() or char in {"-", "_"} else "-" for char in path.stem)
|
|
31
|
+
stem = stem.strip("-_") or f"report-{index}"
|
|
32
|
+
return f"{index:02d}-{stem}{suffix}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def plan_report(
|
|
36
|
+
path: Path,
|
|
37
|
+
output_dir: Path,
|
|
38
|
+
kind: str,
|
|
39
|
+
index: int,
|
|
40
|
+
*,
|
|
41
|
+
anonymous: bool,
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
source = path.expanduser()
|
|
44
|
+
if not source.is_file():
|
|
45
|
+
raise CloudBundleError(f"report does not exist or is not a file: {source}")
|
|
46
|
+
target_name = safe_target_name(source, index, anonymous=anonymous)
|
|
47
|
+
reports_dir = output_dir / "reports"
|
|
48
|
+
try:
|
|
49
|
+
if source.resolve().is_relative_to(reports_dir.resolve()):
|
|
50
|
+
raise CloudBundleError(
|
|
51
|
+
f"report source must not be inside the bundle reports directory: {source}"
|
|
52
|
+
)
|
|
53
|
+
except OSError as exc:
|
|
54
|
+
raise CloudBundleError(f"unable to resolve report path {source}: {exc}") from exc
|
|
55
|
+
return {
|
|
56
|
+
"kind": kind,
|
|
57
|
+
"source": source,
|
|
58
|
+
"source_basename": "" if anonymous else source.name,
|
|
59
|
+
"target_name": target_name,
|
|
60
|
+
"target": f"reports/{target_name}",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def stage_reports(
|
|
65
|
+
planned_reports: list[dict[str, Any]],
|
|
66
|
+
output_dir: Path,
|
|
67
|
+
) -> tuple[list[dict[str, Any]], Path]:
|
|
68
|
+
stage_dir = output_dir / ".reports.tmp"
|
|
69
|
+
if stage_dir.exists():
|
|
70
|
+
if not stage_dir.is_dir():
|
|
71
|
+
raise CloudBundleError(f"bundle staging path is not a directory: {stage_dir}")
|
|
72
|
+
try:
|
|
73
|
+
shutil.rmtree(stage_dir)
|
|
74
|
+
except OSError as exc:
|
|
75
|
+
raise CloudBundleError(f"unable to clear stale staging directory: {exc}") from exc
|
|
76
|
+
try:
|
|
77
|
+
stage_dir.mkdir(parents=True)
|
|
78
|
+
except OSError as exc:
|
|
79
|
+
raise CloudBundleError(f"unable to create bundle staging directory: {exc}") from exc
|
|
80
|
+
|
|
81
|
+
included_reports: list[dict[str, Any]] = []
|
|
82
|
+
try:
|
|
83
|
+
for report in planned_reports:
|
|
84
|
+
source = report["source"]
|
|
85
|
+
target = stage_dir / report["target_name"]
|
|
86
|
+
shutil.copyfile(source, target)
|
|
87
|
+
stat = target.stat()
|
|
88
|
+
included_reports.append(
|
|
89
|
+
{
|
|
90
|
+
"kind": report["kind"],
|
|
91
|
+
"source_basename": report["source_basename"],
|
|
92
|
+
"target": report["target"],
|
|
93
|
+
"bytes": stat.st_size,
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
except OSError as exc:
|
|
97
|
+
shutil.rmtree(stage_dir, ignore_errors=True)
|
|
98
|
+
raise CloudBundleError(f"unable to stage bundle reports: {exc}") from exc
|
|
99
|
+
return included_reports, stage_dir
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def swap_reports(stage_dir: Path, reports_dir: Path) -> None:
|
|
103
|
+
try:
|
|
104
|
+
if reports_dir.exists():
|
|
105
|
+
shutil.rmtree(reports_dir)
|
|
106
|
+
stage_dir.replace(reports_dir)
|
|
107
|
+
except OSError as exc:
|
|
108
|
+
shutil.rmtree(stage_dir, ignore_errors=True)
|
|
109
|
+
raise CloudBundleError(f"unable to install staged bundle reports: {exc}") from exc
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def existing_bundle_manifest(output_dir: Path) -> bool:
|
|
113
|
+
manifest_path = output_dir / BUNDLE_MANIFEST_FILENAME
|
|
114
|
+
if not manifest_path.is_file():
|
|
115
|
+
return False
|
|
116
|
+
try:
|
|
117
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
118
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
119
|
+
return False
|
|
120
|
+
return is_bundle_manifest(manifest)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def unexpected_bundle_entries(output_dir: Path) -> list[str]:
|
|
124
|
+
try:
|
|
125
|
+
return sorted(
|
|
126
|
+
entry.name
|
|
127
|
+
for entry in output_dir.iterdir()
|
|
128
|
+
if entry.name not in EXPECTED_BUNDLE_ENTRIES
|
|
129
|
+
)
|
|
130
|
+
except OSError as exc:
|
|
131
|
+
raise CloudBundleError(f"unable to inspect bundle output directory: {exc}") from exc
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def build_cloud_bundle(
|
|
135
|
+
*,
|
|
136
|
+
reports: list[tuple[Path, str]],
|
|
137
|
+
events: list[dict[str, Any]] | None = None,
|
|
138
|
+
output_dir: Path,
|
|
139
|
+
repo_slug: str = "",
|
|
140
|
+
install_id: str = "",
|
|
141
|
+
team_id: str = "",
|
|
142
|
+
anonymous: bool = False,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
if output_dir.exists():
|
|
145
|
+
if not output_dir.is_dir():
|
|
146
|
+
raise CloudBundleError(f"bundle output path is not a directory: {output_dir}")
|
|
147
|
+
try:
|
|
148
|
+
has_existing_content = any(output_dir.iterdir())
|
|
149
|
+
except OSError as exc:
|
|
150
|
+
raise CloudBundleError(f"unable to inspect bundle output directory: {exc}") from exc
|
|
151
|
+
if has_existing_content and not existing_bundle_manifest(output_dir):
|
|
152
|
+
raise CloudBundleError(
|
|
153
|
+
"refusing to write into an existing non-bundle output directory; "
|
|
154
|
+
"choose a fresh --output-dir"
|
|
155
|
+
)
|
|
156
|
+
unexpected_entries = unexpected_bundle_entries(output_dir)
|
|
157
|
+
if unexpected_entries:
|
|
158
|
+
sample = ", ".join(unexpected_entries[:3])
|
|
159
|
+
if len(unexpected_entries) > 3:
|
|
160
|
+
sample += f", ... ({len(unexpected_entries)} total)"
|
|
161
|
+
raise CloudBundleError(
|
|
162
|
+
f"refusing to reuse bundle directory with unexpected entries: {sample}"
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
try:
|
|
166
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
except OSError as exc:
|
|
168
|
+
raise CloudBundleError(
|
|
169
|
+
f"unable to create bundle output directory {output_dir}: {exc}"
|
|
170
|
+
) from exc
|
|
171
|
+
planned_reports = [
|
|
172
|
+
plan_report(path, output_dir, safe_kind(kind), index, anonymous=anonymous)
|
|
173
|
+
for index, (path, kind) in enumerate(reports, start=1)
|
|
174
|
+
]
|
|
175
|
+
included_reports, stage_dir = stage_reports(planned_reports, output_dir)
|
|
176
|
+
included_events = [
|
|
177
|
+
normalize_event(event, str(event.get("event_type") or "dogfood_upload"))
|
|
178
|
+
for event in events or []
|
|
179
|
+
]
|
|
180
|
+
if len(included_events) > MAX_EVENT_COUNT:
|
|
181
|
+
raise CloudBundleError(
|
|
182
|
+
f"too many events: {len(included_events)}; max {MAX_EVENT_COUNT}"
|
|
183
|
+
)
|
|
184
|
+
manifest = {
|
|
185
|
+
"schema": BUNDLE_SCHEMA,
|
|
186
|
+
"privacy_mode": "anonymous" if anonymous else "metadata_and_reports",
|
|
187
|
+
"upload_ready": False,
|
|
188
|
+
"upload_status": "local_export_only",
|
|
189
|
+
"repo_slug": "" if anonymous else repo_slug,
|
|
190
|
+
"team_id": "" if anonymous else team_id,
|
|
191
|
+
"install_id": "" if anonymous else install_id,
|
|
192
|
+
"included_reports": included_reports,
|
|
193
|
+
"events": [] if anonymous else included_events,
|
|
194
|
+
"excluded_content": list(EXCLUDED_CONTENT),
|
|
195
|
+
"notes": [
|
|
196
|
+
"This bundle is local-only; upload support must present a dry-run before network transfer.",
|
|
197
|
+
"Do not include source code, raw diffs, raw transcripts, raw stdout/stderr, auth output, or secrets.",
|
|
198
|
+
"Reports are copied exactly as supplied; review them before sharing outside your machine.",
|
|
199
|
+
"Structured events are metadata-only and should not contain source, diffs, transcripts, stdout/stderr, auth output, or secrets.",
|
|
200
|
+
],
|
|
201
|
+
}
|
|
202
|
+
manifest_path = output_dir / BUNDLE_MANIFEST_FILENAME
|
|
203
|
+
readme = output_dir / "README.md"
|
|
204
|
+
manifest_tmp = output_dir / f".{BUNDLE_MANIFEST_FILENAME}.tmp"
|
|
205
|
+
readme_tmp = output_dir / ".README.md.tmp"
|
|
206
|
+
try:
|
|
207
|
+
manifest_tmp.write_text(
|
|
208
|
+
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
|
|
209
|
+
encoding="utf-8",
|
|
210
|
+
)
|
|
211
|
+
except OSError as exc:
|
|
212
|
+
shutil.rmtree(stage_dir, ignore_errors=True)
|
|
213
|
+
raise CloudBundleError(f"unable to write bundle manifest {manifest_tmp}: {exc}") from exc
|
|
214
|
+
try:
|
|
215
|
+
readme_tmp.write_text(render_bundle_readme(manifest), encoding="utf-8")
|
|
216
|
+
except OSError as exc:
|
|
217
|
+
shutil.rmtree(stage_dir, ignore_errors=True)
|
|
218
|
+
manifest_tmp.unlink(missing_ok=True)
|
|
219
|
+
raise CloudBundleError(f"unable to write bundle README {readme_tmp}: {exc}") from exc
|
|
220
|
+
swap_reports(stage_dir, output_dir / "reports")
|
|
221
|
+
try:
|
|
222
|
+
manifest_tmp.replace(manifest_path)
|
|
223
|
+
readme_tmp.replace(readme)
|
|
224
|
+
except OSError as exc:
|
|
225
|
+
raise CloudBundleError(f"unable to install bundle manifest files: {exc}") from exc
|
|
226
|
+
return {
|
|
227
|
+
"mode": "cloud-export",
|
|
228
|
+
"output_dir": str(output_dir),
|
|
229
|
+
"manifest": str(manifest_path),
|
|
230
|
+
"readme": str(readme),
|
|
231
|
+
"included_reports": included_reports,
|
|
232
|
+
"event_count": len(manifest["events"]),
|
|
233
|
+
"upload_ready": False,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def render_bundle_readme(manifest: dict[str, Any]) -> str:
|
|
238
|
+
lines = [
|
|
239
|
+
"# Code Mower Cloud Benchmark Bundle",
|
|
240
|
+
"",
|
|
241
|
+
"This local bundle is the cloud-ready handoff shape for opt-in benchmark reporting.",
|
|
242
|
+
"It is not uploaded by the OSS CLI.",
|
|
243
|
+
"",
|
|
244
|
+
"Excluded by default:",
|
|
245
|
+
]
|
|
246
|
+
lines.extend(f"- {item}" for item in manifest["excluded_content"])
|
|
247
|
+
lines.extend(["", "Included reports:"])
|
|
248
|
+
if manifest["included_reports"]:
|
|
249
|
+
lines.extend(
|
|
250
|
+
f"- {entry['target']} ({entry['kind']}, {entry['bytes']} bytes)"
|
|
251
|
+
for entry in manifest["included_reports"]
|
|
252
|
+
)
|
|
253
|
+
else:
|
|
254
|
+
lines.append("- none")
|
|
255
|
+
lines.extend(["", "Structured events:"])
|
|
256
|
+
events = manifest.get("events", [])
|
|
257
|
+
if events:
|
|
258
|
+
lines.extend(
|
|
259
|
+
f"- {entry.get('event_type', 'unknown')} ({entry.get('event_id', 'no-id')})"
|
|
260
|
+
for entry in events
|
|
261
|
+
)
|
|
262
|
+
else:
|
|
263
|
+
lines.append("- none")
|
|
264
|
+
lines.extend(
|
|
265
|
+
[
|
|
266
|
+
"",
|
|
267
|
+
"Before sharing this bundle, inspect every file under `reports/`.",
|
|
268
|
+
"Future upload support should require an explicit dry run and user confirmation.",
|
|
269
|
+
"",
|
|
270
|
+
]
|
|
271
|
+
)
|
|
272
|
+
return "\n".join(lines)
|