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,128 @@
1
+ """GitHub auth, repository, and Actions cost diagnostics facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from typing import Any, Mapping, Sequence
7
+
8
+ from .common import (
9
+ ACTIONS_COST_SAMPLE_DEFAULT,
10
+ DoctorCheck,
11
+ STATUS_PASS,
12
+ STATUS_WARN,
13
+ )
14
+ from .github_actions import (
15
+ _check_actions_cost_sample,
16
+ _check_recent_actions_billing_blocks,
17
+ )
18
+ from .github_actions_permissions import check_actions_permissions
19
+ from .github_branch import check_branch_protection
20
+ from .github_config import configured_repositories, selected_saas_or_hosted_lanes
21
+ from .github_provider import check_private_repo_provider_surface
22
+ from .github_repo import check_repo_metadata, check_repo_permissions
23
+
24
+
25
+ def check_github_setup(
26
+ *,
27
+ config: Mapping[str, Any],
28
+ lanes: Sequence[tuple[str, Mapping[str, Any]]],
29
+ http_timeout: int,
30
+ actions_cost_sample: int = ACTIONS_COST_SAMPLE_DEFAULT,
31
+ ) -> list[DoctorCheck]:
32
+ checks: list[DoctorCheck] = []
33
+ gh_path = shutil.which("gh")
34
+ if not gh_path:
35
+ return [
36
+ DoctorCheck(
37
+ name="github.cli",
38
+ status=STATUS_WARN,
39
+ message="GitHub setup checks require the gh CLI",
40
+ detail={"gh_path": ""},
41
+ remediation=(
42
+ "Install GitHub CLI, run `gh auth login`, then rerun "
43
+ "`code-mower doctor --github`."
44
+ ),
45
+ )
46
+ ]
47
+
48
+ checks.append(
49
+ DoctorCheck(
50
+ name="github.cli",
51
+ status=STATUS_PASS,
52
+ message="gh found for GitHub setup checks",
53
+ detail={"gh_path": gh_path},
54
+ )
55
+ )
56
+
57
+ repos = configured_repositories(config)
58
+ if not repos:
59
+ checks.append(
60
+ DoctorCheck(
61
+ name="github.repositories",
62
+ status=STATUS_WARN,
63
+ message="config declares no repositories for GitHub setup checks",
64
+ remediation=(
65
+ "Add repositories[].slug entries to code-mower.yml before "
66
+ "running GitHub-backed audits."
67
+ ),
68
+ )
69
+ )
70
+ return checks
71
+
72
+ selected_saas_or_hosted = selected_saas_or_hosted_lanes(lanes)
73
+ private_repos: list[str] = []
74
+ unknown_visibility_repos: list[str] = []
75
+ for repo in repos:
76
+ slug = str(repo.get("slug") or "")
77
+ metadata_check, metadata = check_repo_metadata(
78
+ gh_path=gh_path,
79
+ slug=slug,
80
+ configured_default_branch=str(repo.get("default_branch") or "main"),
81
+ http_timeout=http_timeout,
82
+ )
83
+ checks.append(metadata_check)
84
+ if metadata is None:
85
+ unknown_visibility_repos.append(slug)
86
+ continue
87
+
88
+ if metadata.is_private:
89
+ private_repos.append(slug)
90
+
91
+ checks.extend(
92
+ [
93
+ check_repo_permissions(slug=slug, repo_payload=metadata.payload),
94
+ check_actions_permissions(
95
+ gh_path=gh_path,
96
+ slug=slug,
97
+ http_timeout=http_timeout,
98
+ ),
99
+ _check_recent_actions_billing_blocks(
100
+ gh_path=gh_path,
101
+ slug=slug,
102
+ http_timeout=http_timeout,
103
+ ),
104
+ _check_actions_cost_sample(
105
+ gh_path=gh_path,
106
+ slug=slug,
107
+ private=metadata.is_private,
108
+ http_timeout=http_timeout,
109
+ sample_limit=actions_cost_sample,
110
+ ),
111
+ check_branch_protection(
112
+ gh_path=gh_path,
113
+ slug=slug,
114
+ default_branch=metadata.default_branch,
115
+ http_timeout=http_timeout,
116
+ ),
117
+ ]
118
+ )
119
+
120
+ provider_check = check_private_repo_provider_surface(
121
+ private_repos=private_repos,
122
+ unknown_visibility_repos=unknown_visibility_repos,
123
+ selected_saas_or_hosted=selected_saas_or_hosted,
124
+ )
125
+ if provider_check is not None:
126
+ checks.append(provider_check)
127
+
128
+ return checks
@@ -0,0 +1,11 @@
1
+ """GitHub Actions diagnostics compatibility facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .github_actions_cost import _check_actions_cost_sample
6
+ from .github_actions_failures import _check_recent_actions_billing_blocks
7
+
8
+ __all__ = (
9
+ "_check_actions_cost_sample",
10
+ "_check_recent_actions_billing_blocks",
11
+ )
@@ -0,0 +1,99 @@
1
+ """GitHub Actions private-repo cost heuristics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .common import (
6
+ ACTIONS_COST_SAMPLE_MAX,
7
+ DoctorCheck,
8
+ STATUS_PASS,
9
+ STATUS_WARN,
10
+ )
11
+ from .github_api import _github_api_json
12
+ from .github_actions_cost_summary import summarize_actions_cost_runs
13
+
14
+
15
+ def _check_actions_cost_sample(
16
+ *,
17
+ gh_path: str,
18
+ slug: str,
19
+ private: bool,
20
+ http_timeout: int,
21
+ sample_limit: int,
22
+ ) -> DoctorCheck:
23
+ bounded_limit = max(1, min(sample_limit, ACTIONS_COST_SAMPLE_MAX))
24
+ runs_payload, runs_detail = _github_api_json(
25
+ gh_path,
26
+ f"repos/{slug}/actions/runs?per_page={bounded_limit}",
27
+ http_timeout=http_timeout,
28
+ )
29
+ if runs_payload is None:
30
+ return DoctorCheck(
31
+ name="github.actions.cost_sample",
32
+ status=STATUS_WARN,
33
+ message=f"could not sample recent GitHub Actions usage for {slug}",
34
+ detail={"repo": slug, **runs_detail},
35
+ remediation=(
36
+ "Verify gh auth can read Actions run metadata. Private repos "
37
+ "should periodically inspect Actions usage metrics after enabling "
38
+ "hosted or informational lanes."
39
+ ),
40
+ )
41
+
42
+ raw_runs = runs_payload.get("workflow_runs")
43
+ if not isinstance(raw_runs, list):
44
+ return DoctorCheck(
45
+ name="github.actions.cost_sample",
46
+ status=STATUS_WARN,
47
+ message=f"Actions usage response for {slug} did not include workflow_runs",
48
+ detail={"repo": slug},
49
+ )
50
+
51
+ detail = summarize_actions_cost_runs(
52
+ raw_runs,
53
+ slug=slug,
54
+ private=private,
55
+ bounded_limit=bounded_limit,
56
+ )
57
+ sample_size = int(detail["sampled_runs"])
58
+ metadata_run_count = int(detail["metadata_workflow_runs"])
59
+ schedule_runs = int(detail["schedule_runs"])
60
+ if not sample_size:
61
+ return DoctorCheck(
62
+ name="github.actions.cost_sample",
63
+ status=STATUS_PASS,
64
+ message=f"{slug} has no recent Actions runs in the sampled window",
65
+ detail=detail,
66
+ )
67
+
68
+ noisy_metadata = private and metadata_run_count >= max(5, int(sample_size * 0.2))
69
+ scheduled_private = private and schedule_runs > 0
70
+ if noisy_metadata or scheduled_private:
71
+ reasons: list[str] = []
72
+ if noisy_metadata:
73
+ reasons.append(
74
+ f"{metadata_run_count}/{sample_size} sampled runs look like "
75
+ "metadata or reviewer labeler workflows"
76
+ )
77
+ if scheduled_private:
78
+ reasons.append(f"{schedule_runs} sampled runs were scheduled")
79
+ return DoctorCheck(
80
+ name="github.actions.cost_sample",
81
+ status=STATUS_WARN,
82
+ message=(
83
+ f"{slug} private-repo Actions sample suggests avoidable spend: "
84
+ f"{'; '.join(reasons)}"
85
+ ),
86
+ detail=detail,
87
+ remediation=(
88
+ "Prefer label, trusted-comment, or workflow_dispatch triggers for "
89
+ "optional hosted reviewers; add job-level if guards before checkout; "
90
+ "keep informational lanes out of branch protection."
91
+ ),
92
+ )
93
+
94
+ return DoctorCheck(
95
+ name="github.actions.cost_sample",
96
+ status=STATUS_PASS,
97
+ message=f"{slug} recent Actions sample has no obvious private-repo cost traps",
98
+ detail=detail,
99
+ )
@@ -0,0 +1,111 @@
1
+ """Summarize recent GitHub Actions runs for private-repo cost diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter, defaultdict
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Mapping
8
+
9
+ from .common import ACTIONS_METADATA_WORKFLOW_MARKERS
10
+
11
+
12
+ def _parse_github_timestamp(value: Any) -> datetime | None:
13
+ if not value:
14
+ return None
15
+ try:
16
+ text = str(value)
17
+ if text.endswith("Z"):
18
+ text = text[:-1] + "+00:00"
19
+ return datetime.fromisoformat(text).astimezone(timezone.utc)
20
+ except ValueError:
21
+ return None
22
+
23
+
24
+ def _approx_run_seconds(run: Mapping[str, Any]) -> float:
25
+ started = _parse_github_timestamp(
26
+ run.get("run_started_at") or run.get("created_at")
27
+ )
28
+ updated = _parse_github_timestamp(run.get("updated_at"))
29
+ if started is None or updated is None or updated < started:
30
+ return 0.0
31
+ return (updated - started).total_seconds()
32
+
33
+
34
+ def _is_metadata_workflow(name: str, path: str) -> bool:
35
+ haystack = f"{name}\n{path}".lower()
36
+ return any(marker in haystack for marker in ACTIONS_METADATA_WORKFLOW_MARKERS)
37
+
38
+
39
+ def summarize_actions_cost_runs(
40
+ raw_runs: list[Any],
41
+ *,
42
+ slug: str,
43
+ private: bool,
44
+ bounded_limit: int,
45
+ ) -> dict[str, Any]:
46
+ workflow_counts: Counter[str] = Counter()
47
+ event_counts: Counter[str] = Counter()
48
+ workflow_seconds: defaultdict[str, float] = defaultdict(float)
49
+ metadata_counts: Counter[str] = Counter()
50
+ metadata_seconds: defaultdict[str, float] = defaultdict(float)
51
+ schedule_runs = 0
52
+ total_seconds = 0.0
53
+
54
+ for run in raw_runs:
55
+ if not isinstance(run, Mapping):
56
+ continue
57
+ workflow_name = str(run.get("name") or run.get("display_title") or "unknown")
58
+ workflow_path = str(run.get("path") or "")
59
+ event = str(run.get("event") or "unknown")
60
+ seconds = _approx_run_seconds(run)
61
+ workflow_counts[workflow_name] += 1
62
+ event_counts[event] += 1
63
+ workflow_seconds[workflow_name] += seconds
64
+ total_seconds += seconds
65
+ if event == "schedule":
66
+ schedule_runs += 1
67
+ if _is_metadata_workflow(workflow_name, workflow_path):
68
+ metadata_counts[workflow_name] += 1
69
+ metadata_seconds[workflow_name] += seconds
70
+
71
+ sample_size = sum(workflow_counts.values())
72
+ metadata_run_count = sum(metadata_counts.values())
73
+ metadata_share = (metadata_run_count / sample_size) if sample_size else 0.0
74
+ return {
75
+ "repo": slug,
76
+ "private": private,
77
+ "sample_limit": bounded_limit,
78
+ "sampled_runs": sample_size,
79
+ "approx_total_minutes": round(total_seconds / 60, 2),
80
+ "metadata_workflow_runs": metadata_run_count,
81
+ "metadata_workflow_share": round(metadata_share, 3),
82
+ "schedule_runs": schedule_runs,
83
+ "top_workflows": [
84
+ {
85
+ "workflow": workflow,
86
+ "runs": count,
87
+ "approx_minutes": round(workflow_seconds[workflow] / 60, 2),
88
+ }
89
+ for workflow, count in workflow_counts.most_common(10)
90
+ ],
91
+ "top_metadata_workflows": [
92
+ {
93
+ "workflow": workflow,
94
+ "runs": count,
95
+ "approx_minutes": round(metadata_seconds[workflow] / 60, 2),
96
+ }
97
+ for workflow, count in metadata_counts.most_common(10)
98
+ ],
99
+ "events": [
100
+ {"event": event, "runs": count}
101
+ for event, count in event_counts.most_common(10)
102
+ ],
103
+ }
104
+
105
+
106
+ __all__ = (
107
+ "_approx_run_seconds",
108
+ "_is_metadata_workflow",
109
+ "_parse_github_timestamp",
110
+ "summarize_actions_cost_runs",
111
+ )
@@ -0,0 +1,27 @@
1
+ """GitHub Actions annotation parsing helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any, Mapping
7
+
8
+ from .common import ACTIONS_BILLING_BLOCK_PATTERNS
9
+
10
+
11
+ def _annotation_mentions_actions_billing_block(message: str) -> bool:
12
+ lowered = message.lower()
13
+ return any(pattern in lowered for pattern in ACTIONS_BILLING_BLOCK_PATTERNS)
14
+
15
+
16
+ def _check_run_id_from_actions_job(job: Mapping[str, Any]) -> Any | None:
17
+ check_run_url = str(job.get("check_run_url") or "")
18
+ match = re.search(r"/check-runs/([0-9]+)$", check_run_url)
19
+ if match:
20
+ return match.group(1)
21
+ return None
22
+
23
+
24
+ __all__ = (
25
+ "_annotation_mentions_actions_billing_block",
26
+ "_check_run_id_from_actions_job",
27
+ )
@@ -0,0 +1,47 @@
1
+ """Structured GitHub Actions recent-failure inspection results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Mapping
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ActionsBillingBlock:
11
+ run_id: Any
12
+ workflow: str
13
+ head_sha: str
14
+ job_id: Any
15
+ check_run_id: str
16
+ job: str
17
+
18
+ def as_detail(self) -> dict[str, Any]:
19
+ return {
20
+ "run_id": self.run_id,
21
+ "workflow": self.workflow,
22
+ "head_sha": self.head_sha,
23
+ "job_id": self.job_id,
24
+ "check_run_id": self.check_run_id,
25
+ "job": self.job,
26
+ }
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ActionsFailureInspection:
31
+ inspected_failed_runs: int = 0
32
+ inspected_failed_jobs: int = 0
33
+ billing_blocks: tuple[ActionsBillingBlock, ...] = ()
34
+ incomplete_inspections: tuple[dict[str, Any], ...] = ()
35
+ unavailable_detail: Mapping[str, Any] | None = None
36
+ missing_workflow_runs: bool = False
37
+
38
+ @property
39
+ def has_billing_blocks(self) -> bool:
40
+ return bool(self.billing_blocks)
41
+
42
+ @property
43
+ def incomplete_inspection_count(self) -> int:
44
+ return len(self.incomplete_inspections)
45
+
46
+
47
+ __all__ = ("ActionsBillingBlock", "ActionsFailureInspection")
@@ -0,0 +1,200 @@
1
+ """GitHub Actions recent-failure inspection helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping
6
+
7
+ from .common import (
8
+ MAX_ACTIONS_FAILED_JOBS_TO_INSPECT,
9
+ MAX_ACTIONS_FAILED_RUNS_TO_INSPECT,
10
+ )
11
+ from .github_actions_failure_annotations import (
12
+ _annotation_mentions_actions_billing_block,
13
+ _check_run_id_from_actions_job,
14
+ )
15
+ from .github_actions_failure_models import (
16
+ ActionsBillingBlock,
17
+ ActionsFailureInspection,
18
+ )
19
+ from .github_actions_failure_selection import (
20
+ _actions_job_context,
21
+ _actions_run_context,
22
+ _incomplete_inspection_detail,
23
+ _is_inspectable_actions_failure,
24
+ )
25
+ from .github_api import _github_api_json, _github_api_list
26
+
27
+
28
+ def _append_incomplete(
29
+ incomplete_inspections: list[dict[str, Any]],
30
+ detail: Mapping[str, Any],
31
+ ) -> None:
32
+ incomplete_inspections.append(dict(detail))
33
+
34
+
35
+ def inspect_recent_actions_failures(
36
+ *,
37
+ gh_path: str,
38
+ slug: str,
39
+ http_timeout: int,
40
+ ) -> ActionsFailureInspection:
41
+ runs_payload, runs_detail = _github_api_json(
42
+ gh_path,
43
+ f"repos/{slug}/actions/runs?per_page=10",
44
+ http_timeout=http_timeout,
45
+ )
46
+ if runs_payload is None:
47
+ return ActionsFailureInspection(unavailable_detail=runs_detail)
48
+
49
+ raw_runs = runs_payload.get("workflow_runs")
50
+ if not isinstance(raw_runs, list):
51
+ return ActionsFailureInspection(missing_workflow_runs=True)
52
+
53
+ inspected_runs = 0
54
+ inspected_jobs = 0
55
+ billing_blocks: list[ActionsBillingBlock] = []
56
+ incomplete_inspections: list[dict[str, Any]] = []
57
+ for run in raw_runs:
58
+ if not _is_inspectable_actions_failure(run):
59
+ continue
60
+ if inspected_runs >= MAX_ACTIONS_FAILED_RUNS_TO_INSPECT:
61
+ _append_incomplete(
62
+ incomplete_inspections,
63
+ _incomplete_inspection_detail(
64
+ stage="runs",
65
+ reason="inspection_limit_reached",
66
+ limit=MAX_ACTIONS_FAILED_RUNS_TO_INSPECT,
67
+ ),
68
+ )
69
+ break
70
+ run_context = _actions_run_context(run)
71
+ run_id = run_context["run_id"]
72
+ if run_id is None:
73
+ continue
74
+ inspected_runs += 1
75
+ jobs_payload, _jobs_detail = _github_api_json(
76
+ gh_path,
77
+ f"repos/{slug}/actions/runs/{run_id}/jobs?per_page=20",
78
+ http_timeout=http_timeout,
79
+ )
80
+ workflow = run_context["workflow"]
81
+ if jobs_payload is None:
82
+ _append_incomplete(
83
+ incomplete_inspections,
84
+ _incomplete_inspection_detail(
85
+ run_id=run_id,
86
+ workflow=workflow,
87
+ stage="jobs",
88
+ ),
89
+ )
90
+ continue
91
+ raw_jobs = jobs_payload.get("jobs")
92
+ if not isinstance(raw_jobs, list):
93
+ _append_incomplete(
94
+ incomplete_inspections,
95
+ _incomplete_inspection_detail(
96
+ run_id=run_id,
97
+ workflow=workflow,
98
+ stage="jobs",
99
+ reason="missing_jobs",
100
+ ),
101
+ )
102
+ continue
103
+ if not raw_jobs:
104
+ _append_incomplete(
105
+ incomplete_inspections,
106
+ _incomplete_inspection_detail(
107
+ run_id=run_id,
108
+ workflow=workflow,
109
+ stage="jobs",
110
+ reason="no_jobs",
111
+ ),
112
+ )
113
+ continue
114
+ for job in raw_jobs:
115
+ if not _is_inspectable_actions_failure(job):
116
+ continue
117
+ if inspected_jobs >= MAX_ACTIONS_FAILED_JOBS_TO_INSPECT:
118
+ _append_incomplete(
119
+ incomplete_inspections,
120
+ _incomplete_inspection_detail(
121
+ run_id=run_id,
122
+ workflow=workflow,
123
+ stage="annotations",
124
+ reason="inspection_limit_reached",
125
+ limit=MAX_ACTIONS_FAILED_JOBS_TO_INSPECT,
126
+ ),
127
+ )
128
+ break
129
+ job_context = _actions_job_context(job)
130
+ job_id = job_context["job_id"]
131
+ if job_id is None:
132
+ continue
133
+ check_run_id = _check_run_id_from_actions_job(job)
134
+ job_name = job_context["job"]
135
+ if check_run_id is None:
136
+ _append_incomplete(
137
+ incomplete_inspections,
138
+ _incomplete_inspection_detail(
139
+ run_id=run_id,
140
+ workflow=workflow,
141
+ job=job_name,
142
+ stage="annotations",
143
+ reason="missing_check_run_id",
144
+ ),
145
+ )
146
+ continue
147
+ inspected_jobs += 1
148
+ annotations, _annotations_detail = _github_api_list(
149
+ gh_path,
150
+ f"repos/{slug}/check-runs/{check_run_id}/annotations?per_page=20",
151
+ http_timeout=http_timeout,
152
+ )
153
+ if annotations is None:
154
+ _append_incomplete(
155
+ incomplete_inspections,
156
+ _incomplete_inspection_detail(
157
+ run_id=run_id,
158
+ workflow=workflow,
159
+ job_id=job_id,
160
+ job=job_name,
161
+ stage="annotations",
162
+ ),
163
+ )
164
+ continue
165
+ for annotation in annotations:
166
+ if not isinstance(annotation, Mapping):
167
+ continue
168
+ message = str(annotation.get("message") or "")
169
+ if _annotation_mentions_actions_billing_block(message):
170
+ billing_blocks.append(
171
+ ActionsBillingBlock(
172
+ run_id=run_id,
173
+ workflow=workflow,
174
+ head_sha=run_context["head_sha"],
175
+ job_id=job_id,
176
+ check_run_id=str(check_run_id),
177
+ job=job_name,
178
+ )
179
+ )
180
+ return ActionsFailureInspection(
181
+ inspected_failed_runs=inspected_runs,
182
+ inspected_failed_jobs=inspected_jobs,
183
+ billing_blocks=tuple(billing_blocks),
184
+ incomplete_inspections=tuple(incomplete_inspections),
185
+ )
186
+
187
+ return ActionsFailureInspection(
188
+ inspected_failed_runs=inspected_runs,
189
+ inspected_failed_jobs=inspected_jobs,
190
+ incomplete_inspections=tuple(incomplete_inspections),
191
+ )
192
+
193
+
194
+ __all__ = (
195
+ "ActionsBillingBlock",
196
+ "ActionsFailureInspection",
197
+ "_annotation_mentions_actions_billing_block",
198
+ "_check_run_id_from_actions_job",
199
+ "inspect_recent_actions_failures",
200
+ )
@@ -0,0 +1,63 @@
1
+ """GitHub Actions failure selection and detail helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping
6
+
7
+ from .common import ACTIONS_INSPECTABLE_FAILURE_CONCLUSIONS
8
+
9
+
10
+ def _is_inspectable_actions_failure(record: Any) -> bool:
11
+ return (
12
+ isinstance(record, Mapping)
13
+ and record.get("conclusion") in ACTIONS_INSPECTABLE_FAILURE_CONCLUSIONS
14
+ )
15
+
16
+
17
+ def _actions_run_context(run: Mapping[str, Any]) -> dict[str, Any]:
18
+ return {
19
+ "run_id": run.get("id"),
20
+ "workflow": str(run.get("name") or ""),
21
+ "head_sha": str(run.get("head_sha") or ""),
22
+ }
23
+
24
+
25
+ def _actions_job_context(job: Mapping[str, Any]) -> dict[str, Any]:
26
+ return {
27
+ "job_id": job.get("id"),
28
+ "job": str(job.get("name") or ""),
29
+ }
30
+
31
+
32
+ def _incomplete_inspection_detail(
33
+ *,
34
+ stage: str,
35
+ run_id: Any | None = None,
36
+ workflow: str | None = None,
37
+ job_id: Any | None = None,
38
+ job: str | None = None,
39
+ reason: str | None = None,
40
+ limit: int | None = None,
41
+ ) -> dict[str, Any]:
42
+ detail: dict[str, Any] = {"stage": stage}
43
+ if run_id is not None:
44
+ detail["run_id"] = run_id
45
+ if workflow:
46
+ detail["workflow"] = workflow
47
+ if job_id is not None:
48
+ detail["job_id"] = job_id
49
+ if job:
50
+ detail["job"] = job
51
+ if reason:
52
+ detail["reason"] = reason
53
+ if limit is not None:
54
+ detail["limit"] = limit
55
+ return detail
56
+
57
+
58
+ __all__ = (
59
+ "_actions_job_context",
60
+ "_actions_run_context",
61
+ "_incomplete_inspection_detail",
62
+ "_is_inspectable_actions_failure",
63
+ )