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,549 @@
1
+ """Static release-readiness checks for Code Mower package promotion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from . import package as package_module
13
+ from . import versioning as code_mower_versioning
14
+
15
+
16
+ RELEASE_DOC_PATHS = (
17
+ "README.md",
18
+ "docs/quickstart.md",
19
+ "docs/try-in-10-minutes.md",
20
+ "docs/first-user-install-rehearsal.md",
21
+ "docs/pypi-release.md",
22
+ "docs/public-release-checklist.md",
23
+ )
24
+ REQUIRED_RELEASE_TAG_DOC_PATHS = (
25
+ "README.md",
26
+ "docs/quickstart.md",
27
+ "docs/try-in-10-minutes.md",
28
+ "docs/first-user-install-rehearsal.md",
29
+ "docs/public-release-checklist.md",
30
+ )
31
+ PUBLIC_HYGIENE_DOC_PATHS = (
32
+ "CODE_OF_CONDUCT.md",
33
+ "CONTRIBUTING.md",
34
+ "SECURITY.md",
35
+ "SUPPORT.md",
36
+ ".github/ISSUE_TEMPLATE/bug_report.yml",
37
+ ".github/ISSUE_TEMPLATE/feature_request.yml",
38
+ ".github/PULL_REQUEST_TEMPLATE.md",
39
+ ".github/dependabot.yml",
40
+ )
41
+ PACKAGE_INDEX_SETUP_URLS = {
42
+ "github_environments": (
43
+ "https://github.com/codemower-ai/code-mower/settings/environments"
44
+ ),
45
+ "release_workflow": (
46
+ "https://github.com/codemower-ai/code-mower/actions/workflows/release.yml"
47
+ ),
48
+ "testpypi_project": "https://test.pypi.org/project/code-mower/",
49
+ "testpypi_trusted_publishers": (
50
+ "https://test.pypi.org/manage/project/code-mower/settings/publishing/"
51
+ ),
52
+ "pypi_project": "https://pypi.org/project/code-mower/",
53
+ "pypi_trusted_publishers": (
54
+ "https://pypi.org/manage/project/code-mower/settings/publishing/"
55
+ ),
56
+ }
57
+
58
+
59
+ def _release_check(
60
+ *,
61
+ check_id: str,
62
+ title: str,
63
+ status: str,
64
+ evidence: str,
65
+ detail: dict[str, Any] | None = None,
66
+ ) -> dict[str, Any]:
67
+ check: dict[str, Any] = {
68
+ "id": check_id,
69
+ "title": title,
70
+ "status": status,
71
+ "evidence": evidence,
72
+ }
73
+ if detail:
74
+ check["detail"] = detail
75
+ return check
76
+
77
+
78
+ def _read_text_if_exists(path: Path) -> str:
79
+ try:
80
+ return path.read_text(encoding="utf-8")
81
+ except OSError:
82
+ return ""
83
+
84
+
85
+ def _python_package_version(repo_path: Path) -> str:
86
+ init_text = _read_text_if_exists(repo_path / "src" / "code_mower" / "__init__.py")
87
+ match = re.search(r"__version__\s*=\s*[\"']([^\"']+)[\"']", init_text)
88
+ return match.group(1) if match else ""
89
+
90
+
91
+ def _pyproject_version(repo_path: Path) -> str:
92
+ pyproject_text = _read_text_if_exists(repo_path / "pyproject.toml")
93
+ match = re.search(r"^version\s*=\s*[\"']([^\"']+)[\"']", pyproject_text, re.MULTILINE)
94
+ return match.group(1) if match else ""
95
+
96
+
97
+ def _materialized_package_versions(repo_path: Path) -> dict[str, Any]:
98
+ try:
99
+ plan = package_module.render_package_plan(
100
+ package_module.load_config(
101
+ repo_path / "src" / "code_mower" / "templates" / "code-mower.example.yml"
102
+ ),
103
+ package_module.load_provider_templates(
104
+ repo_path / "src" / "code_mower" / "templates" / "providers.yml"
105
+ ),
106
+ )
107
+ with tempfile.TemporaryDirectory() as tmp:
108
+ output_dir = Path(tmp)
109
+ package_module.materialize_package_plan(
110
+ plan,
111
+ output_dir=output_dir,
112
+ repo_root=repo_path,
113
+ force=True,
114
+ )
115
+ return {
116
+ "error": "",
117
+ "init_version": _python_package_version(output_dir),
118
+ "pyproject_version": _pyproject_version(output_dir),
119
+ }
120
+ except Exception as exc: # pragma: no cover - exercised through status output.
121
+ return {
122
+ "error": str(exc),
123
+ "init_version": "",
124
+ "pyproject_version": "",
125
+ }
126
+
127
+
128
+ def _release_tag_for_version(version: str) -> str:
129
+ return code_mower_versioning.release_tag_for_version(version)
130
+
131
+
132
+ def _release_docs(repo_path: Path) -> dict[str, str]:
133
+ return {
134
+ relative_path: _read_text_if_exists(repo_path / relative_path)
135
+ for relative_path in RELEASE_DOC_PATHS
136
+ }
137
+
138
+
139
+ def _workflow_jobs(workflow: str) -> dict[str, Any]:
140
+ try:
141
+ parsed = yaml.safe_load(workflow) if workflow.strip() else {}
142
+ except yaml.YAMLError:
143
+ return {}
144
+ if not isinstance(parsed, dict):
145
+ return {}
146
+ jobs = parsed.get("jobs")
147
+ return jobs if isinstance(jobs, dict) else {}
148
+
149
+
150
+ def _needs_job(job: Any, required: str) -> bool:
151
+ if not isinstance(job, dict):
152
+ return False
153
+ needs = job.get("needs")
154
+ if isinstance(needs, str):
155
+ return needs == required
156
+ if isinstance(needs, list):
157
+ return required in needs
158
+ return False
159
+
160
+
161
+ def _permissions_include(job: Any, key: str, value: str) -> bool:
162
+ if not isinstance(job, dict):
163
+ return False
164
+ permissions = job.get("permissions")
165
+ return isinstance(permissions, dict) and permissions.get(key) == value
166
+
167
+
168
+ def _job_uses_environment(job: Any, environment: str) -> bool:
169
+ if not isinstance(job, dict):
170
+ return False
171
+ value = job.get("environment")
172
+ if isinstance(value, str):
173
+ return value == environment
174
+ if isinstance(value, dict):
175
+ return value.get("name") == environment
176
+ return False
177
+
178
+
179
+ def _job_uses_action(job: Any, action_prefix: str) -> bool:
180
+ if not isinstance(job, dict):
181
+ return False
182
+ steps = job.get("steps")
183
+ if not isinstance(steps, list):
184
+ return False
185
+ for step in steps:
186
+ if not isinstance(step, dict):
187
+ continue
188
+ uses = step.get("uses")
189
+ if isinstance(uses, str) and uses.startswith(action_prefix):
190
+ return True
191
+ return False
192
+
193
+
194
+ def _job_text(job: Any) -> str:
195
+ return yaml.safe_dump(job, sort_keys=True) if isinstance(job, dict) else ""
196
+
197
+
198
+ def render_release_readiness(repo_path: Path) -> dict[str, Any]:
199
+ """Inspect whether the standalone package is ready for package-index promotion."""
200
+
201
+ repo_path = repo_path.expanduser().resolve()
202
+ workflow_path = repo_path / ".github" / "workflows" / "release.yml"
203
+ workflow = _read_text_if_exists(workflow_path)
204
+ ci_workflow_path = repo_path / ".github" / "workflows" / "ci.yml"
205
+ ci_workflow = _read_text_if_exists(ci_workflow_path)
206
+ workflow_jobs = _workflow_jobs(workflow)
207
+ testpypi_job = workflow_jobs.get("publish-testpypi")
208
+ pypi_job = workflow_jobs.get("publish-pypi")
209
+ testpypi_job_text = _job_text(testpypi_job)
210
+ pypi_job_text = _job_text(pypi_job)
211
+ docs = _release_docs(repo_path)
212
+ public_hygiene_docs = {
213
+ relative_path: _read_text_if_exists(repo_path / relative_path)
214
+ for relative_path in PUBLIC_HYGIENE_DOC_PATHS
215
+ }
216
+ init_version = _python_package_version(repo_path)
217
+ pyproject_version = _pyproject_version(repo_path)
218
+ version = init_version or pyproject_version
219
+ materialized_versions = _materialized_package_versions(repo_path)
220
+ release_tag = _release_tag_for_version(version) if version else ""
221
+ package_index_spec = f"code-mower=={version}" if version else ""
222
+ doc_blob = "\n".join(docs.values())
223
+ public_hygiene_blobs = {
224
+ relative_path: text.lower()
225
+ for relative_path, text in public_hygiene_docs.items()
226
+ }
227
+
228
+ version_docs = [
229
+ relative_path
230
+ for relative_path, text in docs.items()
231
+ if release_tag and release_tag in text
232
+ ]
233
+ missing_release_docs = [
234
+ relative_path
235
+ for relative_path in REQUIRED_RELEASE_TAG_DOC_PATHS
236
+ if release_tag and release_tag not in docs.get(relative_path, "")
237
+ ]
238
+ package_index_docs = [
239
+ relative_path
240
+ for relative_path, text in docs.items()
241
+ if package_index_spec and package_index_spec in text
242
+ ]
243
+ missing_public_hygiene_docs = [
244
+ relative_path
245
+ for relative_path, text in public_hygiene_docs.items()
246
+ if not text
247
+ ]
248
+ redaction_terms = (
249
+ "tokens",
250
+ "private source",
251
+ "raw diffs",
252
+ "raw model transcripts",
253
+ "auth output",
254
+ "security.md",
255
+ )
256
+ public_redaction_docs = ("SUPPORT.md", "CODE_OF_CONDUCT.md")
257
+ missing_redaction_terms = {
258
+ relative_path: [
259
+ term
260
+ for term in redaction_terms
261
+ if term not in public_hygiene_blobs.get(relative_path, "")
262
+ ]
263
+ for relative_path in public_redaction_docs
264
+ }
265
+ missing_redaction_terms = {
266
+ relative_path: terms
267
+ for relative_path, terms in missing_redaction_terms.items()
268
+ if terms
269
+ }
270
+
271
+ checks = [
272
+ _release_check(
273
+ check_id="package-version-consistency",
274
+ title="Package versions agree",
275
+ status=(
276
+ "pass"
277
+ if init_version and pyproject_version and init_version == pyproject_version
278
+ else "fail"
279
+ ),
280
+ evidence=(
281
+ f"src/code_mower/__init__.py={init_version or 'missing'}, "
282
+ f"pyproject.toml={pyproject_version or 'missing'}"
283
+ ),
284
+ detail={"init_version": init_version, "pyproject_version": pyproject_version},
285
+ ),
286
+ _release_check(
287
+ check_id="materialized-package-version-consistency",
288
+ title="Materialized package versions agree with source",
289
+ status=(
290
+ "pass"
291
+ if (
292
+ version
293
+ and not materialized_versions["error"]
294
+ and materialized_versions["init_version"] == version
295
+ and materialized_versions["pyproject_version"] == version
296
+ )
297
+ else "fail"
298
+ ),
299
+ evidence=(
300
+ f"generated src/code_mower/__init__.py="
301
+ f"{materialized_versions['init_version'] or 'missing'}, "
302
+ f"generated pyproject.toml="
303
+ f"{materialized_versions['pyproject_version'] or 'missing'}"
304
+ ),
305
+ detail={
306
+ "source_version": version,
307
+ "generated_init_version": materialized_versions["init_version"],
308
+ "generated_pyproject_version": materialized_versions[
309
+ "pyproject_version"
310
+ ],
311
+ "error": materialized_versions["error"],
312
+ },
313
+ ),
314
+ _release_check(
315
+ check_id="release-workflow-present",
316
+ title="Release workflow exists",
317
+ status="pass" if workflow_path.is_file() else "fail",
318
+ evidence=str(workflow_path),
319
+ ),
320
+ _release_check(
321
+ check_id="distribution-build-and-verify",
322
+ title="Release workflow builds and verifies distributions before publish",
323
+ status=(
324
+ "pass"
325
+ if (
326
+ " build-distributions:\n" in workflow
327
+ and " verify-distributions:\n" in workflow
328
+ and " needs: build-distributions\n" in workflow
329
+ and "python -m build" in workflow
330
+ and "python -m twine check dist/*" in workflow
331
+ )
332
+ else "fail"
333
+ ),
334
+ evidence=str(workflow_path),
335
+ ),
336
+ _release_check(
337
+ check_id="manual-dispatch-gates",
338
+ title="Manual workflow dispatch has separate TestPyPI and PyPI inputs",
339
+ status=(
340
+ "pass"
341
+ if "workflow_dispatch:" in workflow
342
+ and "publish_testpypi:" in workflow
343
+ and "publish_pypi:" in workflow
344
+ else "fail"
345
+ ),
346
+ evidence=str(workflow_path),
347
+ ),
348
+ _release_check(
349
+ check_id="testpypi-gate",
350
+ title="TestPyPI publishing is gated separately",
351
+ status=(
352
+ "pass"
353
+ if (
354
+ _needs_job(testpypi_job, "verify-distributions")
355
+ and "inputs.publish_testpypi" in testpypi_job_text
356
+ and "CODE_MOWER_TESTPYPI_PUBLISH" in testpypi_job_text
357
+ and _job_uses_environment(testpypi_job, "testpypi")
358
+ and _permissions_include(testpypi_job, "id-token", "write")
359
+ and _job_uses_action(testpypi_job, "pypa/gh-action-pypi-publish@")
360
+ and "https://test.pypi.org/legacy/" in testpypi_job_text
361
+ )
362
+ else "fail"
363
+ ),
364
+ evidence=str(workflow_path),
365
+ ),
366
+ _release_check(
367
+ check_id="pypi-gate",
368
+ title="Production PyPI publishing is gated separately",
369
+ status=(
370
+ "pass"
371
+ if (
372
+ _needs_job(pypi_job, "verify-distributions")
373
+ and "inputs.publish_pypi" in pypi_job_text
374
+ and "CODE_MOWER_PYPI_PUBLISH" in pypi_job_text
375
+ and _job_uses_environment(pypi_job, "pypi")
376
+ and _permissions_include(pypi_job, "id-token", "write")
377
+ and _job_uses_action(pypi_job, "pypa/gh-action-pypi-publish@")
378
+ and "test.pypi.org" not in pypi_job_text
379
+ )
380
+ else "fail"
381
+ ),
382
+ evidence=str(workflow_path),
383
+ ),
384
+ _release_check(
385
+ check_id="release-tag-docs-current",
386
+ title="Current release tag is present in public install docs",
387
+ status="pass" if release_tag and not missing_release_docs else "fail",
388
+ evidence=release_tag or "missing version",
389
+ detail={
390
+ "docs": version_docs,
391
+ "required_docs": list(REQUIRED_RELEASE_TAG_DOC_PATHS),
392
+ "missing_docs": missing_release_docs,
393
+ },
394
+ ),
395
+ _release_check(
396
+ check_id="package-index-rehearsal-docs",
397
+ title="Package-index rehearsal is documented",
398
+ status=(
399
+ "pass"
400
+ if (
401
+ package_index_spec
402
+ and package_index_spec in doc_blob
403
+ and "--pip-index-url https://test.pypi.org/simple/" in doc_blob
404
+ and "--pip-extra-index-url https://pypi.org/simple/" in doc_blob
405
+ and "package-install-rehearsal" in doc_blob
406
+ )
407
+ else "fail"
408
+ ),
409
+ evidence=package_index_spec or "missing version",
410
+ detail={"docs": package_index_docs},
411
+ ),
412
+ _release_check(
413
+ check_id="ci-package-install-rehearsal",
414
+ title="CI proves the package-installed first-user path",
415
+ status=(
416
+ "pass"
417
+ if (
418
+ "Package-install first-user rehearsal" in ci_workflow
419
+ and "package-install-rehearsal" in ci_workflow
420
+ and '--package-spec "$GITHUB_WORKSPACE"' in ci_workflow
421
+ and '--work-dir "$RUNNER_TEMP/code-mower-package-install"' in ci_workflow
422
+ and "--json" in ci_workflow
423
+ )
424
+ else "fail"
425
+ ),
426
+ evidence=str(ci_workflow_path),
427
+ ),
428
+ _release_check(
429
+ check_id="trusted-publishing-runbook",
430
+ title="Trusted publishing setup is documented",
431
+ status=(
432
+ "pass"
433
+ if (
434
+ "trusted publishing" in doc_blob.lower()
435
+ and "environment: `testpypi`" in doc_blob
436
+ and "environment: `pypi`" in doc_blob
437
+ and "Workflow Dispatch Matrix" in doc_blob
438
+ )
439
+ else "fail"
440
+ ),
441
+ evidence="docs/pypi-release.md",
442
+ ),
443
+ _release_check(
444
+ check_id="public-maintainer-docs",
445
+ title="Public maintainer and community files are present",
446
+ status="pass" if not missing_public_hygiene_docs else "fail",
447
+ evidence=", ".join(PUBLIC_HYGIENE_DOC_PATHS),
448
+ detail={"missing_docs": missing_public_hygiene_docs},
449
+ ),
450
+ _release_check(
451
+ check_id="public-docs-linked-from-readme",
452
+ title="Public support, security, and conduct docs are linked from README",
453
+ status=(
454
+ "pass"
455
+ if (
456
+ "[Support](SUPPORT.md)" in docs.get("README.md", "")
457
+ and "[Security Policy](SECURITY.md)" in docs.get("README.md", "")
458
+ and "[Code of Conduct](CODE_OF_CONDUCT.md)" in docs.get("README.md", "")
459
+ )
460
+ else "fail"
461
+ ),
462
+ evidence="README.md",
463
+ ),
464
+ _release_check(
465
+ check_id="public-support-redaction-guidance",
466
+ title="Public support docs warn against sharing sensitive artifacts",
467
+ status="pass" if not missing_redaction_terms else "fail",
468
+ evidence="SUPPORT.md, CODE_OF_CONDUCT.md, SECURITY.md",
469
+ detail={"missing_terms_by_doc": missing_redaction_terms},
470
+ ),
471
+ ]
472
+ failed = sum(1 for check in checks if check["status"] == "fail")
473
+ warnings = sum(1 for check in checks if check["status"] == "warn")
474
+ passed = sum(1 for check in checks if check["status"] == "pass")
475
+ status = "pass" if failed == 0 else "fail"
476
+ next_actions = [
477
+ {
478
+ "id": "dry-run-release-workflow",
479
+ "title": "Run the release workflow without publishing",
480
+ "command": (
481
+ "gh workflow run release.yml --repo codemower-ai/code-mower "
482
+ "--ref main -f publish_testpypi=false -f publish_pypi=false"
483
+ ),
484
+ "url": PACKAGE_INDEX_SETUP_URLS["release_workflow"],
485
+ },
486
+ {
487
+ "id": "publish-testpypi-candidate",
488
+ "title": "Publish the verified distribution to TestPyPI",
489
+ "command": (
490
+ "gh workflow run release.yml --repo codemower-ai/code-mower "
491
+ "--ref main -f publish_testpypi=true -f publish_pypi=false"
492
+ ),
493
+ "url": PACKAGE_INDEX_SETUP_URLS["release_workflow"],
494
+ },
495
+ {
496
+ "id": "testpypi-install-rehearsal",
497
+ "title": "Install from TestPyPI in a fresh toy repo",
498
+ "command": (
499
+ "code-mower migration package-install-rehearsal "
500
+ f"--package-spec {package_index_spec} "
501
+ "--pip-index-url https://test.pypi.org/simple/ "
502
+ "--pip-extra-index-url https://pypi.org/simple/ "
503
+ "--json"
504
+ ),
505
+ "url": PACKAGE_INDEX_SETUP_URLS["testpypi_project"],
506
+ },
507
+ ]
508
+ return {
509
+ "mode": "code-mower-release-readiness",
510
+ "status": status,
511
+ "repo_path": str(repo_path),
512
+ "version": version,
513
+ "release_tag": release_tag,
514
+ "alpha_tag": release_tag,
515
+ "package_index_spec": package_index_spec,
516
+ "passed": passed,
517
+ "failed": failed,
518
+ "warnings": warnings,
519
+ "total": len(checks),
520
+ "checks": checks,
521
+ "next_actions": next_actions,
522
+ "setup_urls": PACKAGE_INDEX_SETUP_URLS,
523
+ }
524
+
525
+
526
+ def render_release_readiness_text(payload: dict[str, Any]) -> str:
527
+ lines = [
528
+ "Code Mower release readiness",
529
+ "",
530
+ f"status: {payload['status']}",
531
+ f"version: {payload.get('version') or 'unknown'}",
532
+ f"release_tag: {payload.get('release_tag') or payload.get('alpha_tag') or 'unknown'}",
533
+ f"checks: {payload['passed']} passed, {payload['failed']} failed, {payload['warnings']} warnings",
534
+ "",
535
+ "Checks:",
536
+ ]
537
+ for check in payload["checks"]:
538
+ lines.append(f"- {check['status'].upper()} {check['id']}: {check['title']}")
539
+ lines.append(f" evidence: {check['evidence']}")
540
+ lines.extend(["", "Setup URLs:"])
541
+ for label, url in payload.get("setup_urls", {}).items():
542
+ lines.append(f"- {label}: {url}")
543
+ lines.extend(["", "Next actions:"])
544
+ for action in payload["next_actions"]:
545
+ lines.append(f"- {action['title']}")
546
+ lines.append(f" {action['command']}")
547
+ if action.get("url"):
548
+ lines.append(f" {action['url']}")
549
+ return "\n".join(lines) + "\n"