code-mower 0.5.0b5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_mower/__init__.py +3 -0
- code_mower/adapters/__init__.py +39 -0
- code_mower/adapters/_base.py +148 -0
- code_mower/adapters/cursor_bugbot.py +97 -0
- code_mower/adapters/gitar.py +111 -0
- code_mower/adapters/greptile.py +191 -0
- code_mower/adapters/qodo.py +140 -0
- code_mower/antigravity_cli_audit_pr.py +241 -0
- code_mower/audit_handoff_log.py +345 -0
- code_mower/audit_labeler_lib.py +311 -0
- code_mower/audit_progress.py +203 -0
- code_mower/blind_review_artifacts.py +562 -0
- code_mower/blind_review_coordinator.py +276 -0
- code_mower/bootstrap.py +524 -0
- code_mower/builder_experiment.py +539 -0
- code_mower/calibration/__init__.py +181 -0
- code_mower/calibration/arms.py +267 -0
- code_mower/calibration/auto_discovery.py +348 -0
- code_mower/calibration/commands.py +192 -0
- code_mower/calibration/context_inputs.py +199 -0
- code_mower/calibration/corpus.py +91 -0
- code_mower/calibration/evidence.py +20 -0
- code_mower/calibration/evidence_report.py +360 -0
- code_mower/calibration/identity.py +27 -0
- code_mower/calibration/metrics.py +18 -0
- code_mower/calibration/overlap.py +103 -0
- code_mower/calibration/planning.py +309 -0
- code_mower/calibration/policy.py +207 -0
- code_mower/calibration/results.py +315 -0
- code_mower/calibration/run_results.py +147 -0
- code_mower/calibration/run_status.py +64 -0
- code_mower/calibration/runner.py +360 -0
- code_mower/calibration/truth.py +188 -0
- code_mower/calibration/value_report.py +142 -0
- code_mower/checks.py +402 -0
- code_mower/claude_audit_pr.py +1126 -0
- code_mower/claude_cli_bounce.py +303 -0
- code_mower/claude_cli_environment.py +73 -0
- code_mower/clear_stale.py +374 -0
- code_mower/cli.py +537 -0
- code_mower/cloud.py +674 -0
- code_mower/cloud_client/__init__.py +173 -0
- code_mower/cloud_client/bundle.py +155 -0
- code_mower/cloud_client/doctor.py +206 -0
- code_mower/cloud_client/dogfood.py +78 -0
- code_mower/cloud_client/endpoints.py +114 -0
- code_mower/cloud_client/errors.py +7 -0
- code_mower/cloud_client/events.py +278 -0
- code_mower/cloud_client/export.py +272 -0
- code_mower/cloud_client/git_metadata.py +46 -0
- code_mower/cloud_client/manifest.py +39 -0
- code_mower/cloud_client/operations.py +448 -0
- code_mower/cloud_client/reports.py +45 -0
- code_mower/cloud_client/setup.py +205 -0
- code_mower/cloud_client/upload.py +97 -0
- code_mower/code_mower_calibration.py +598 -0
- code_mower/code_mower_context_packs.py +591 -0
- code_mower/code_mower_merge.py +227 -0
- code_mower/code_mower_telemetry.py +561 -0
- code_mower/coderabbit_cli_audit_pr.py +526 -0
- code_mower/codex_audit_env_preflight.py +220 -0
- code_mower/codex_audit_pr.py +1738 -0
- code_mower/codex_audit_schema_smoke.py +160 -0
- code_mower/codex_audit_verdict.schema.json +44 -0
- code_mower/config.py +655 -0
- code_mower/doctor.py +161 -0
- code_mower/doctor_checks/__init__.py +104 -0
- code_mower/doctor_checks/cloud.py +129 -0
- code_mower/doctor_checks/common.py +215 -0
- code_mower/doctor_checks/github.py +128 -0
- code_mower/doctor_checks/github_actions.py +11 -0
- code_mower/doctor_checks/github_actions_cost.py +99 -0
- code_mower/doctor_checks/github_actions_cost_summary.py +111 -0
- code_mower/doctor_checks/github_actions_failure_annotations.py +27 -0
- code_mower/doctor_checks/github_actions_failure_models.py +47 -0
- code_mower/doctor_checks/github_actions_failure_scan.py +200 -0
- code_mower/doctor_checks/github_actions_failure_selection.py +63 -0
- code_mower/doctor_checks/github_actions_failures.py +103 -0
- code_mower/doctor_checks/github_actions_permissions.py +55 -0
- code_mower/doctor_checks/github_api.py +79 -0
- code_mower/doctor_checks/github_branch.py +56 -0
- code_mower/doctor_checks/github_config.py +25 -0
- code_mower/doctor_checks/github_provider.py +61 -0
- code_mower/doctor_checks/github_repo.py +120 -0
- code_mower/doctor_checks/groups.py +36 -0
- code_mower/doctor_checks/models.py +96 -0
- code_mower/doctor_checks/output.py +86 -0
- code_mower/doctor_checks/presets.py +64 -0
- code_mower/doctor_checks/privacy.py +20 -0
- code_mower/doctor_checks/provider_api_model.py +138 -0
- code_mower/doctor_checks/provider_api_model_openai.py +29 -0
- code_mower/doctor_checks/provider_api_model_profiles.py +137 -0
- code_mower/doctor_checks/provider_env.py +113 -0
- code_mower/doctor_checks/provider_env_required.py +56 -0
- code_mower/doctor_checks/provider_env_tokens.py +100 -0
- code_mower/doctor_checks/provider_local_cli.py +162 -0
- code_mower/doctor_checks/provider_local_cli_commands.py +47 -0
- code_mower/doctor_checks/provider_local_cli_probe_config.py +70 -0
- code_mower/doctor_checks/provider_probe.py +20 -0
- code_mower/doctor_checks/provider_probe_auth.py +52 -0
- code_mower/doctor_checks/provider_probe_evaluation.py +109 -0
- code_mower/doctor_checks/provider_probe_json.py +45 -0
- code_mower/doctor_checks/provider_probe_remediation.py +39 -0
- code_mower/doctor_checks/providers.py +159 -0
- code_mower/doctor_checks/registry.py +69 -0
- code_mower/doctor_checks/runner.py +188 -0
- code_mower/doctor_checks/runtime.py +89 -0
- code_mower/doctor_checks/runtime_github_auth.py +148 -0
- code_mower/gemini_cli_audit_pr.py +897 -0
- code_mower/hermes_cli_audit_pr.py +436 -0
- code_mower/init.py +888 -0
- code_mower/lane_configs/__init__.py +37 -0
- code_mower/lane_configs/aider.py +32 -0
- code_mower/lane_configs/antigravity_cli.py +35 -0
- code_mower/lane_configs/claude.py +35 -0
- code_mower/lane_configs/codex.py +32 -0
- code_mower/lane_configs/devin.py +33 -0
- code_mower/lane_configs/gemini_cli.py +35 -0
- code_mower/lane_configs/hermes_cli.py +35 -0
- code_mower/lane_configs/local_llm.py +31 -0
- code_mower/local_llm_audit_pr.py +1364 -0
- code_mower/local_llm_bakeoff.py +458 -0
- code_mower/local_llm_calibration.py +441 -0
- code_mower/local_llm_profiles.py +66 -0
- code_mower/migration.py +508 -0
- code_mower/migration_install.py +292 -0
- code_mower/migration_mirror.py +392 -0
- code_mower/migration_readiness.py +237 -0
- code_mower/migration_rehearsal.py +718 -0
- code_mower/next_steps.py +441 -0
- code_mower/package.py +673 -0
- code_mower/package_content.py +444 -0
- code_mower/package_manifest.py +452 -0
- code_mower/package_paths.py +53 -0
- code_mower/package_rendering.py +90 -0
- code_mower/package_static.py +585 -0
- code_mower/prompts.py +267 -0
- code_mower/provider_registry.py +469 -0
- code_mower/provider_runners/__init__.py +60 -0
- code_mower/provider_runners/comments.py +31 -0
- code_mower/provider_runners/git.py +46 -0
- code_mower/provider_runners/github_auth.py +61 -0
- code_mower/provider_runners/github_pr.py +120 -0
- code_mower/provider_runners/process.py +58 -0
- code_mower/provider_runners/repo_paths.py +23 -0
- code_mower/provider_runners/text_schema.py +41 -0
- code_mower/provider_runners/verdict_artifacts.py +103 -0
- code_mower/provider_runners/workspace.py +57 -0
- code_mower/release_readiness.py +549 -0
- code_mower/reviewer_metrics.py +389 -0
- code_mower/saas_reviewer_labeler.py +809 -0
- code_mower/secrets.py +89 -0
- code_mower/templates/builder-experiment.example.json +55 -0
- code_mower/templates/calibration-corpus.example.json +129 -0
- code_mower/templates/calibration-corpus.json +129 -0
- code_mower/templates/code-mower.example.yml +423 -0
- code_mower/templates/context-packs.example.json +150 -0
- code_mower/templates/lane_prompts/base-audit.md +22 -0
- code_mower/templates/lane_prompts/calibration-policy.md +21 -0
- code_mower/templates/lane_prompts/context-driven-quality.md +21 -0
- code_mower/templates/lane_prompts/docs-design.md +12 -0
- code_mower/templates/lane_prompts/generic-programming.md +21 -0
- code_mower/templates/lane_prompts/operability.md +22 -0
- code_mower/templates/lane_prompts/package-runtime.md +12 -0
- code_mower/templates/lane_prompts/security-threat-model.md +22 -0
- code_mower/templates/product-support/code_mower +216 -0
- code_mower/templates/product-support/code_mower_standalone_pin.env +7 -0
- code_mower/templates/product-support/code_mower_standalone_shadow.sh +151 -0
- code_mower/templates/product-support/run_claude_audit_pr.sh +32 -0
- code_mower/templates/product-support/run_codex_audit_pr.sh +32 -0
- code_mower/templates/product-support/safe_gh_comment.py +96 -0
- code_mower/templates/providers.yml +454 -0
- code_mower/templates/reviewer-spend.example.json +28 -0
- code_mower/templates/reviewer-value-report.example.md +20 -0
- code_mower/templates/workflows/private-standalone-shadow.yml.j2 +106 -0
- code_mower/templates/workflows/review-clear-stale.yml.j2 +83 -0
- code_mower/trailer_comment_labeler.py +207 -0
- code_mower/versioning.py +32 -0
- code_mower-0.5.0b5.dist-info/METADATA +302 -0
- code_mower-0.5.0b5.dist-info/RECORD +185 -0
- code_mower-0.5.0b5.dist-info/WHEEL +5 -0
- code_mower-0.5.0b5.dist-info/entry_points.txt +2 -0
- code_mower-0.5.0b5.dist-info/licenses/LICENSE +202 -0
- code_mower-0.5.0b5.dist-info/licenses/NOTICE +10 -0
- code_mower-0.5.0b5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
)
|