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,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
+ }