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,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,7 @@
1
+ """Shared CodeMower.com client exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CloudBundleError(ValueError):
7
+ """Raised when a cloud bundle or upload request is unsafe or invalid."""
@@ -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)