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,120 @@
|
|
|
1
|
+
"""GitHub pull request API helpers for provider runners."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.client
|
|
6
|
+
import json
|
|
7
|
+
import socket
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _gh_request(
|
|
15
|
+
method: str,
|
|
16
|
+
path: str,
|
|
17
|
+
*,
|
|
18
|
+
token: str,
|
|
19
|
+
body: dict[str, Any] | None = None,
|
|
20
|
+
accept: str = "application/vnd.github+json",
|
|
21
|
+
timeout: int = 30,
|
|
22
|
+
) -> Any:
|
|
23
|
+
"""Make a GitHub REST request and return parsed JSON or text diffs."""
|
|
24
|
+
|
|
25
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
26
|
+
req = urllib.request.Request(
|
|
27
|
+
f"https://api.github.com{path}",
|
|
28
|
+
data=data,
|
|
29
|
+
headers={
|
|
30
|
+
"Accept": accept,
|
|
31
|
+
"Authorization": f"Bearer {token}",
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
34
|
+
},
|
|
35
|
+
method=method,
|
|
36
|
+
)
|
|
37
|
+
max_attempts = 3 if method.upper() in {"GET", "HEAD"} else 1
|
|
38
|
+
transient_errors = (
|
|
39
|
+
TimeoutError,
|
|
40
|
+
socket.timeout,
|
|
41
|
+
http.client.IncompleteRead,
|
|
42
|
+
http.client.RemoteDisconnected,
|
|
43
|
+
urllib.error.URLError,
|
|
44
|
+
)
|
|
45
|
+
for attempt in range(1, max_attempts + 1):
|
|
46
|
+
try:
|
|
47
|
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
48
|
+
text = response.read().decode("utf-8", errors="replace")
|
|
49
|
+
if accept.endswith("diff"):
|
|
50
|
+
return text
|
|
51
|
+
return json.loads(text) if text else None
|
|
52
|
+
except urllib.error.HTTPError:
|
|
53
|
+
raise
|
|
54
|
+
except transient_errors:
|
|
55
|
+
if attempt >= max_attempts:
|
|
56
|
+
raise
|
|
57
|
+
time.sleep(min(2 ** (attempt - 1), 4))
|
|
58
|
+
raise AssertionError("unreachable GitHub request retry loop")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def fetch_pull_request(repo: str, pr_number: int, *, token: str) -> dict[str, Any]:
|
|
62
|
+
return _gh_request("GET", f"/repos/{repo}/pulls/{pr_number}", token=token)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fetch_pull_request_diff(repo: str, pr_number: int, *, token: str) -> str:
|
|
66
|
+
return str(
|
|
67
|
+
_gh_request(
|
|
68
|
+
"GET",
|
|
69
|
+
f"/repos/{repo}/pulls/{pr_number}",
|
|
70
|
+
token=token,
|
|
71
|
+
accept="application/vnd.github.v3.diff",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def fetch_pull_request_files(
|
|
77
|
+
repo: str,
|
|
78
|
+
pr_number: int,
|
|
79
|
+
*,
|
|
80
|
+
token: str,
|
|
81
|
+
max_pages: int = 5,
|
|
82
|
+
per_page: int = 100,
|
|
83
|
+
) -> list[dict[str, Any]]:
|
|
84
|
+
"""Return changed-file entries for a pull request.
|
|
85
|
+
|
|
86
|
+
GitHub caps pull file pages at 100 entries. Provider runners usually do
|
|
87
|
+
not benefit from reviewing enormous PRs in full, so the default mirrors the
|
|
88
|
+
legacy local-LLM cap of 500 files while keeping the paging behavior shared.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
all_files: list[dict[str, Any]] = []
|
|
92
|
+
for page in range(1, max_pages + 1):
|
|
93
|
+
chunk = _gh_request(
|
|
94
|
+
"GET",
|
|
95
|
+
f"/repos/{repo}/pulls/{pr_number}/files?per_page={per_page}&page={page}",
|
|
96
|
+
token=token,
|
|
97
|
+
)
|
|
98
|
+
if not chunk:
|
|
99
|
+
break
|
|
100
|
+
if not isinstance(chunk, list):
|
|
101
|
+
raise ValueError("GitHub pull request files response was not a list")
|
|
102
|
+
all_files.extend(chunk)
|
|
103
|
+
if len(chunk) < per_page:
|
|
104
|
+
break
|
|
105
|
+
return all_files
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def post_pr_comment(
|
|
109
|
+
repo: str,
|
|
110
|
+
pr_number: int,
|
|
111
|
+
body: str,
|
|
112
|
+
*,
|
|
113
|
+
token: str,
|
|
114
|
+
) -> dict[str, Any]:
|
|
115
|
+
return _gh_request(
|
|
116
|
+
"POST",
|
|
117
|
+
f"/repos/{repo}/issues/{pr_number}/comments",
|
|
118
|
+
token=token,
|
|
119
|
+
body={"body": body},
|
|
120
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Shared subprocess environment helpers for provider-runner wrappers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterable, Mapping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_HOME_ENV_KEYS = ("HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _stringify_env_value(value: object) -> str:
|
|
14
|
+
if isinstance(value, Path):
|
|
15
|
+
return str(value)
|
|
16
|
+
return str(value)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_allowlisted_child_env(
|
|
20
|
+
allowlist: Iterable[str],
|
|
21
|
+
*,
|
|
22
|
+
exclude_env: Iterable[str] = (),
|
|
23
|
+
extra_env: Mapping[str, object | None] | None = None,
|
|
24
|
+
home_env: Mapping[str, object] | None = None,
|
|
25
|
+
preserve_ambient_home: bool = False,
|
|
26
|
+
ambient_home_keys: Iterable[str] = DEFAULT_HOME_ENV_KEYS,
|
|
27
|
+
) -> dict[str, str]:
|
|
28
|
+
"""Build a minimal child environment for provider subprocesses.
|
|
29
|
+
|
|
30
|
+
``allowlist`` controls which ambient variables are copied. ``exclude_env``
|
|
31
|
+
lets a caller suppress allowlisted values for negative-path tests. Provider
|
|
32
|
+
wrappers can pass ``home_env`` for deterministic isolated HOME/XDG paths, or
|
|
33
|
+
set ``preserve_ambient_home`` to copy selected home/config variables from
|
|
34
|
+
the ambient process instead. ``extra_env`` is applied last and ignores
|
|
35
|
+
``None`` values, so explicit provider keys can override ambient values.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
excluded = set(exclude_env)
|
|
39
|
+
child_env = {
|
|
40
|
+
key: value
|
|
41
|
+
for key in allowlist
|
|
42
|
+
if key not in excluded and (value := os.environ.get(key))
|
|
43
|
+
}
|
|
44
|
+
if preserve_ambient_home:
|
|
45
|
+
for key in ambient_home_keys:
|
|
46
|
+
if key not in excluded and (value := os.environ.get(key)):
|
|
47
|
+
child_env[key] = value
|
|
48
|
+
elif home_env:
|
|
49
|
+
for key, value in home_env.items():
|
|
50
|
+
if key not in excluded:
|
|
51
|
+
child_env[key] = _stringify_env_value(value)
|
|
52
|
+
|
|
53
|
+
if extra_env:
|
|
54
|
+
for key, value in extra_env.items():
|
|
55
|
+
if value is not None:
|
|
56
|
+
child_env[key] = _stringify_env_value(value)
|
|
57
|
+
|
|
58
|
+
return child_env
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Shared local repository path parsing for provider wrappers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_repo_paths(spec: str) -> Dict[str, Path]:
|
|
10
|
+
"""Parse `owner/repo:/path,owner/repo:/path,...` into a dict."""
|
|
11
|
+
|
|
12
|
+
out: Dict[str, Path] = {}
|
|
13
|
+
for entry in spec.split(","):
|
|
14
|
+
entry = entry.strip()
|
|
15
|
+
if not entry:
|
|
16
|
+
continue
|
|
17
|
+
if ":" not in entry:
|
|
18
|
+
raise ValueError(
|
|
19
|
+
f"bad repo paths entry: {entry!r} (expected owner/repo:/path)"
|
|
20
|
+
)
|
|
21
|
+
repo, path = entry.split(":", 1)
|
|
22
|
+
out[repo.strip()] = Path(path.strip())
|
|
23
|
+
return out
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Provider-neutral text and schema helpers for audit wrappers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional, Set
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def clip_text(value: str, limit: int) -> str:
|
|
9
|
+
"""Trim text to a stable display budget, preserving a clear truncation marker."""
|
|
10
|
+
|
|
11
|
+
value = value.strip()
|
|
12
|
+
if len(value) <= limit:
|
|
13
|
+
return value
|
|
14
|
+
suffix = "... [truncated]"
|
|
15
|
+
return value[: max(0, limit - len(suffix))].rstrip() + suffix
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def one_line(value: str, limit: int) -> str:
|
|
19
|
+
"""Render user/model text safely inside one-line GitHub comment fragments."""
|
|
20
|
+
|
|
21
|
+
return clip_text(
|
|
22
|
+
value.replace("\r", " ").replace("\n", " ").replace("`", "'"),
|
|
23
|
+
limit,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def require_exact_keys(
|
|
28
|
+
value: Dict[str, Any],
|
|
29
|
+
required: Set[str],
|
|
30
|
+
where: str,
|
|
31
|
+
) -> Optional[str]:
|
|
32
|
+
"""Return a validation error when a structured provider object drifts."""
|
|
33
|
+
|
|
34
|
+
keys = set(value.keys())
|
|
35
|
+
missing = sorted(required - keys)
|
|
36
|
+
extra = sorted(keys - required)
|
|
37
|
+
if missing:
|
|
38
|
+
return f"{where} missing required keys: {', '.join(missing)}"
|
|
39
|
+
if extra:
|
|
40
|
+
return f"{where} contains unsupported keys: {', '.join(extra)}"
|
|
41
|
+
return None
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Verdict artifact helpers shared by provider audit runners."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .github_pr import post_pr_comment
|
|
13
|
+
|
|
14
|
+
VERDICT_ARTIFACT_SCHEMA = "code_mower.auditVerdictArtifact.v1"
|
|
15
|
+
VERDICT_ARTIFACT_DIR_ENV = "CODE_MOWER_VERDICT_ARTIFACT_DIR"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _safe_artifact_slug(value: str) -> str:
|
|
19
|
+
safe = "".join(char if char.isalnum() or char in "._-" else "-" for char in value)
|
|
20
|
+
safe = safe.strip("._-")
|
|
21
|
+
while "--" in safe:
|
|
22
|
+
safe = safe.replace("--", "-")
|
|
23
|
+
return safe or "item"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _verdict_artifact_root() -> Path:
|
|
27
|
+
configured = os.environ.get(VERDICT_ARTIFACT_DIR_ENV, "").strip()
|
|
28
|
+
if configured:
|
|
29
|
+
return Path(configured).expanduser()
|
|
30
|
+
return Path.home() / ".cache" / "code-mower-audits" / "verdicts"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def write_audit_verdict_artifact(
|
|
34
|
+
*,
|
|
35
|
+
lane_id: str,
|
|
36
|
+
repo: str,
|
|
37
|
+
pr_number: int,
|
|
38
|
+
head_sha_start: str,
|
|
39
|
+
head_sha_end: str,
|
|
40
|
+
verdict: str,
|
|
41
|
+
trailer: str,
|
|
42
|
+
comment_body: str,
|
|
43
|
+
) -> Path | None:
|
|
44
|
+
"""Persist the rendered audit comment before posting to GitHub."""
|
|
45
|
+
|
|
46
|
+
root = _verdict_artifact_root()
|
|
47
|
+
repo_slug = _safe_artifact_slug(repo.replace("/", "__"))
|
|
48
|
+
head_slug = _safe_artifact_slug(head_sha_start[:16] or "unknown-head")
|
|
49
|
+
lane_slug = _safe_artifact_slug(lane_id)
|
|
50
|
+
now = datetime.now(timezone.utc)
|
|
51
|
+
timestamp = now.strftime("%Y%m%dT%H%M%SZ")
|
|
52
|
+
filename = f"{timestamp}-{lane_slug}-{_safe_artifact_slug(verdict.lower())}.json"
|
|
53
|
+
path = root / repo_slug / f"pr-{pr_number}" / head_slug / filename
|
|
54
|
+
payload = {
|
|
55
|
+
"schema": VERDICT_ARTIFACT_SCHEMA,
|
|
56
|
+
"lane_id": lane_id,
|
|
57
|
+
"repo": repo,
|
|
58
|
+
"pr_number": pr_number,
|
|
59
|
+
"head_sha_start": head_sha_start,
|
|
60
|
+
"head_sha_end": head_sha_end,
|
|
61
|
+
"verdict": verdict,
|
|
62
|
+
"trailer": trailer,
|
|
63
|
+
"comment_body": comment_body,
|
|
64
|
+
"created_at": now.isoformat().replace("+00:00", "Z"),
|
|
65
|
+
"posted_comment_url": None,
|
|
66
|
+
}
|
|
67
|
+
try:
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
path.write_text(
|
|
70
|
+
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
|
71
|
+
encoding="utf-8",
|
|
72
|
+
)
|
|
73
|
+
return path
|
|
74
|
+
except OSError as exc:
|
|
75
|
+
print(
|
|
76
|
+
f"warning: failed to write audit verdict artifact {path}: {exc}",
|
|
77
|
+
file=sys.stderr,
|
|
78
|
+
)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_audit_verdict_artifact(path: Path) -> dict[str, Any]:
|
|
83
|
+
payload = json.loads(path.expanduser().read_text(encoding="utf-8"))
|
|
84
|
+
if not isinstance(payload, dict):
|
|
85
|
+
raise ValueError("verdict artifact must contain a JSON object")
|
|
86
|
+
if payload.get("schema") != VERDICT_ARTIFACT_SCHEMA:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"unsupported verdict artifact schema: {payload.get('schema')!r}"
|
|
89
|
+
)
|
|
90
|
+
for key in ("repo", "pr_number", "comment_body"):
|
|
91
|
+
if key not in payload:
|
|
92
|
+
raise ValueError(f"verdict artifact missing {key}")
|
|
93
|
+
return payload
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def repost_audit_verdict_artifact(path: Path, *, token: str) -> dict[str, Any]:
|
|
97
|
+
artifact = load_audit_verdict_artifact(path)
|
|
98
|
+
return post_pr_comment(
|
|
99
|
+
str(artifact["repo"]),
|
|
100
|
+
int(artifact["pr_number"]),
|
|
101
|
+
str(artifact["comment_body"]),
|
|
102
|
+
token=token,
|
|
103
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Shared local checkout validation helpers for provider runners."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .git import local_head_sha, run_git
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProviderWorkspaceError(RuntimeError):
|
|
13
|
+
"""Raised when a provider runner cannot safely use a local checkout."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def working_tree_status(repo_path: Path) -> str:
|
|
17
|
+
"""Return porcelain status for ``repo_path``."""
|
|
18
|
+
|
|
19
|
+
return run_git(repo_path, ["status", "--porcelain"]).stdout
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def verify_checkout_at_head(
|
|
23
|
+
repo_path: Path,
|
|
24
|
+
*,
|
|
25
|
+
expected_head_sha: str,
|
|
26
|
+
allow_dirty: bool = False,
|
|
27
|
+
purpose: str = "provider runner",
|
|
28
|
+
dirty_remediation: str = (
|
|
29
|
+
"commit/stash them or pass --allow-dirty for an explicitly exploratory run"
|
|
30
|
+
),
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
"""Verify that ``repo_path`` is a git checkout at ``expected_head_sha``."""
|
|
33
|
+
|
|
34
|
+
if not repo_path.is_dir():
|
|
35
|
+
raise ProviderWorkspaceError(f"repo path does not exist: {repo_path}")
|
|
36
|
+
try:
|
|
37
|
+
local_head = local_head_sha(repo_path)
|
|
38
|
+
except subprocess.CalledProcessError as exc:
|
|
39
|
+
raise ProviderWorkspaceError(
|
|
40
|
+
f"repo path is not a git checkout: {repo_path}"
|
|
41
|
+
) from exc
|
|
42
|
+
if local_head.lower() != expected_head_sha.lower():
|
|
43
|
+
raise ProviderWorkspaceError(
|
|
44
|
+
"local checkout must be at the PR head for "
|
|
45
|
+
f"{purpose}; local={local_head} expected={expected_head_sha}."
|
|
46
|
+
)
|
|
47
|
+
status = working_tree_status(repo_path)
|
|
48
|
+
if status.strip() and not allow_dirty:
|
|
49
|
+
raise ProviderWorkspaceError(
|
|
50
|
+
"local checkout has uncommitted changes; "
|
|
51
|
+
f"{dirty_remediation}."
|
|
52
|
+
)
|
|
53
|
+
return {
|
|
54
|
+
"repo_path": str(repo_path),
|
|
55
|
+
"local_head_sha": local_head,
|
|
56
|
+
"dirty": bool(status.strip()),
|
|
57
|
+
}
|