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,809 @@
1
+ #!/usr/bin/env python3
2
+ """Generic labeler for advisory SaaS reviewer lanes.
3
+
4
+ Greptile and Gitar expose different GitHub event shapes, but their label
5
+ state machine is the same: trust only the service bot, classify its native
6
+ verdict, then apply an advisory done/blocked/needs label without failing the
7
+ workflow if label writes are unavailable.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Any, Optional, Sequence
18
+
19
+ if __package__ and __package__.startswith("code_mower"):
20
+ from .adapters import load_adapter
21
+ from .adapters._base import SaaSReviewerAdapter
22
+ from .audit_labeler_lib import (
23
+ GitHubToken,
24
+ LabelDecision,
25
+ GitHubRequestError,
26
+ apply_label_decision,
27
+ fetch_pull_request,
28
+ github_request_with_fallback,
29
+ load_json,
30
+ sha_matches,
31
+ )
32
+ else:
33
+ try:
34
+ from tools.adapters import load_adapter
35
+ from tools.adapters._base import SaaSReviewerAdapter
36
+ from tools.audit_labeler_lib import (
37
+ GitHubToken,
38
+ LabelDecision,
39
+ GitHubRequestError,
40
+ apply_label_decision,
41
+ fetch_pull_request,
42
+ github_request_with_fallback,
43
+ load_json,
44
+ sha_matches,
45
+ )
46
+ except ImportError: # pragma: no cover - direct `python tools/foo.py` execution
47
+ from adapters import load_adapter
48
+ from adapters._base import SaaSReviewerAdapter
49
+ from audit_labeler_lib import (
50
+ GitHubToken,
51
+ LabelDecision,
52
+ GitHubRequestError,
53
+ apply_label_decision,
54
+ fetch_pull_request,
55
+ github_request_with_fallback,
56
+ load_json,
57
+ sha_matches,
58
+ )
59
+
60
+
61
+ class ReviewCommentsTruncated(RuntimeError):
62
+ """Raised when a review-comments fetch hits the safety page cap."""
63
+
64
+
65
+ def github_tokens_from_env(*env_names: str) -> tuple[GitHubToken, ...]:
66
+ tokens: list[GitHubToken] = []
67
+ seen_values: set[str] = set()
68
+ for name in env_names:
69
+ value = (os.environ.get(name) or "").strip()
70
+ if not value or value in seen_values:
71
+ continue
72
+ tokens.append(GitHubToken(name, value))
73
+ seen_values.add(value)
74
+ return tuple(tokens)
75
+
76
+
77
+ def env_flag(name: str) -> bool:
78
+ return (os.environ.get(name) or "").strip().lower() not in {
79
+ "",
80
+ "0",
81
+ "false",
82
+ "no",
83
+ }
84
+
85
+
86
+ def fetch_review_comments(
87
+ repo: str,
88
+ pr_number: int,
89
+ review_id: int,
90
+ *,
91
+ tokens: Sequence[GitHubToken],
92
+ page_cap: int,
93
+ ) -> list[dict[str, Any]]:
94
+ """Fetch inline comments for one pull request review with a safety cap."""
95
+ all_comments: list[dict[str, Any]] = []
96
+ page = 1
97
+ while page <= page_cap:
98
+ chunk = github_request_with_fallback(
99
+ "GET",
100
+ f"/repos/{repo}/pulls/{pr_number}/reviews/{review_id}/comments"
101
+ f"?per_page=100&page={page}",
102
+ tokens=tokens,
103
+ ) or []
104
+ if not chunk:
105
+ return all_comments
106
+ all_comments.extend(chunk)
107
+ if len(chunk) < 100:
108
+ return all_comments
109
+ page += 1
110
+ raise ReviewCommentsTruncated(
111
+ f"hit pagination cap of {page_cap} pages ({page_cap * 100} comments) "
112
+ f"for {repo}#{pr_number} review {review_id}; refusing to classify on partial data"
113
+ )
114
+
115
+
116
+ def fetch_pull_requests_for_commit(
117
+ repo: str,
118
+ head_sha: str,
119
+ *,
120
+ tokens: Sequence[GitHubToken],
121
+ ) -> list[dict[str, Any]]:
122
+ """Fetch pull requests associated with a commit SHA."""
123
+ pulls = github_request_with_fallback(
124
+ "GET",
125
+ f"/repos/{repo}/commits/{head_sha}/pulls?per_page=100",
126
+ tokens=tokens,
127
+ ) or []
128
+ return pulls if isinstance(pulls, list) else []
129
+
130
+
131
+ def fetch_pull_request_reviews(
132
+ repo: str,
133
+ pr_number: int,
134
+ *,
135
+ tokens: Sequence[GitHubToken],
136
+ page_cap: int,
137
+ ) -> list[dict[str, Any]]:
138
+ """Fetch pull request reviews with a safety cap."""
139
+ all_reviews: list[dict[str, Any]] = []
140
+ page = 1
141
+ while page <= page_cap:
142
+ chunk = github_request_with_fallback(
143
+ "GET",
144
+ f"/repos/{repo}/pulls/{pr_number}/reviews?per_page=100&page={page}",
145
+ tokens=tokens,
146
+ ) or []
147
+ if not chunk:
148
+ return all_reviews
149
+ all_reviews.extend(chunk)
150
+ if len(chunk) < 100:
151
+ return all_reviews
152
+ page += 1
153
+ raise ReviewCommentsTruncated(
154
+ f"hit pagination cap of {page_cap} pages ({page_cap * 100} reviews) "
155
+ f"for {repo}#{pr_number}; refusing to classify on partial data"
156
+ )
157
+
158
+
159
+ def fetch_issue_comments(
160
+ repo: str,
161
+ issue_number: int,
162
+ *,
163
+ tokens: Sequence[GitHubToken],
164
+ page_cap: int,
165
+ ) -> list[dict[str, Any]]:
166
+ """Fetch issue/PR comments with a safety cap."""
167
+ all_comments: list[dict[str, Any]] = []
168
+ page = 1
169
+ while page <= page_cap:
170
+ chunk = github_request_with_fallback(
171
+ "GET",
172
+ f"/repos/{repo}/issues/{issue_number}/comments?per_page=100&page={page}",
173
+ tokens=tokens,
174
+ ) or []
175
+ if not chunk:
176
+ return all_comments
177
+ all_comments.extend(chunk)
178
+ if len(chunk) < 100:
179
+ return all_comments
180
+ page += 1
181
+ raise ReviewCommentsTruncated(
182
+ f"hit pagination cap of {page_cap} pages ({page_cap * 100} comments) "
183
+ f"for {repo}#{issue_number}; refusing to classify on partial data"
184
+ )
185
+
186
+
187
+ def has_same_head_review(
188
+ reviews: list[dict[str, Any]],
189
+ *,
190
+ adapter: SaaSReviewerAdapter,
191
+ head_sha: Optional[str],
192
+ ) -> bool:
193
+ """Return whether the adapter already has a PR review for this head."""
194
+ if not head_sha:
195
+ return False
196
+ for review in reviews:
197
+ author = ((review.get("user") or {}).get("login") or "")
198
+ commit_id = review.get("commit_id") or ""
199
+ if adapter.is_review_author(author) and sha_matches(str(commit_id), head_sha):
200
+ return True
201
+ return False
202
+
203
+
204
+ def resolve_label_decision(
205
+ event: dict[str, Any],
206
+ *,
207
+ adapter: SaaSReviewerAdapter,
208
+ event_type: Optional[str] = None,
209
+ pr_number: Optional[int] = None,
210
+ pr_labels: Optional[list[str]] = None,
211
+ current_head_sha: Optional[str] = None,
212
+ review_comments: Optional[list[dict[str, Any]]] = None,
213
+ same_head_review_exists: bool = False,
214
+ ) -> tuple[Optional[LabelDecision], str]:
215
+ event_type = event_type or adapter.event_type
216
+ if event_type == "pull_request_review":
217
+ return _resolve_pull_request_review(
218
+ event,
219
+ adapter=adapter,
220
+ pr_labels=pr_labels or [],
221
+ current_head_sha=current_head_sha,
222
+ review_comments=review_comments or [],
223
+ )
224
+ if event_type == "issue_comment":
225
+ return _resolve_issue_comment(event, adapter=adapter, pr_labels=pr_labels or [])
226
+ if event_type == "check_run":
227
+ return _resolve_check_run(
228
+ event,
229
+ adapter=adapter,
230
+ pr_number=pr_number,
231
+ pr_labels=pr_labels or [],
232
+ current_head_sha=current_head_sha,
233
+ same_head_review_exists=same_head_review_exists,
234
+ )
235
+ return None, f"unsupported adapter event type: {event_type}"
236
+
237
+
238
+ def _resolve_pull_request_review(
239
+ event: dict[str, Any],
240
+ *,
241
+ adapter: SaaSReviewerAdapter,
242
+ pr_labels: list[str],
243
+ current_head_sha: Optional[str],
244
+ review_comments: list[dict[str, Any]],
245
+ ) -> tuple[Optional[LabelDecision], str]:
246
+ if event.get("action") not in ("submitted", "edited"):
247
+ return None, f"unsupported pull_request_review action: {event.get('action')}"
248
+
249
+ review = event.get("review") or {}
250
+ author = (review.get("user") or {}).get("login", "")
251
+ if not adapter.is_review_author(author):
252
+ return None, f"ignored review author: {author}"
253
+
254
+ pull_request = event.get("pull_request") or {}
255
+ issue_number = int(pull_request.get("number") or 0)
256
+ if not issue_number:
257
+ return None, "event has no pull request number"
258
+
259
+ if adapter.opt_in_required and not adapter.is_opted_in(pr_labels):
260
+ return None, (
261
+ f"PR does not carry an opt-in {adapter.label_prefix} label; "
262
+ f"add `{adapter.needs_label}` to opt in"
263
+ )
264
+
265
+ reviewed_sha, needs_stale_check = adapter.extract_stale_head_info(event)
266
+ if (
267
+ needs_stale_check
268
+ and current_head_sha
269
+ and reviewed_sha
270
+ and not sha_matches(reviewed_sha, current_head_sha)
271
+ ):
272
+ return LabelDecision(
273
+ issue_number=issue_number,
274
+ add_label=adapter.needs_label,
275
+ remove_labels=(adapter.done_label, adapter.blocked_label),
276
+ reviewed_sha=reviewed_sha,
277
+ reason=f"review SHA {reviewed_sha[:8]} != current head {current_head_sha[:8]}",
278
+ ), "stale review — re-queue"
279
+
280
+ status = adapter.classify_verdict(event, review_comments=review_comments)
281
+ return _decision_from_status(
282
+ adapter=adapter,
283
+ event=event,
284
+ issue_number=issue_number,
285
+ status=status,
286
+ reviewed_sha=reviewed_sha,
287
+ review_comments=review_comments,
288
+ )
289
+
290
+
291
+ def _resolve_issue_comment(
292
+ event: dict[str, Any],
293
+ *,
294
+ adapter: SaaSReviewerAdapter,
295
+ pr_labels: list[str],
296
+ ) -> tuple[Optional[LabelDecision], str]:
297
+ if event.get("action") not in ("created", "edited"):
298
+ return None, f"unsupported issue_comment action: {event.get('action')}"
299
+
300
+ issue = event.get("issue") or {}
301
+ if "pull_request" not in issue:
302
+ return None, "comment is on an issue, not a pull request"
303
+
304
+ issue_number = int(issue.get("number") or 0)
305
+ if not issue_number:
306
+ return None, "event has no issue/PR number"
307
+
308
+ comment = event.get("comment") or {}
309
+ author = (comment.get("user") or {}).get("login", "")
310
+ if not adapter.is_review_author(author):
311
+ return None, f"ignored comment author: {author}"
312
+
313
+ if adapter.opt_in_required and not adapter.is_opted_in(pr_labels):
314
+ return None, (
315
+ f"PR does not carry an opt-in {adapter.label_prefix} label; "
316
+ f"add `{adapter.needs_label}` to opt in"
317
+ )
318
+
319
+ comment_body = comment.get("body") or ""
320
+ status = adapter.classify_verdict(event, comment_body=comment_body)
321
+ return _decision_from_status(
322
+ adapter=adapter,
323
+ event=event,
324
+ issue_number=issue_number,
325
+ status=status,
326
+ comment_body=comment_body,
327
+ )
328
+
329
+
330
+ def _resolve_check_run(
331
+ event: dict[str, Any],
332
+ *,
333
+ adapter: SaaSReviewerAdapter,
334
+ pr_number: Optional[int],
335
+ pr_labels: list[str],
336
+ current_head_sha: Optional[str],
337
+ same_head_review_exists: bool,
338
+ ) -> tuple[Optional[LabelDecision], str]:
339
+ if event.get("action") != "completed":
340
+ return None, f"unsupported check_run action: {event.get('action')}"
341
+
342
+ check_run = event.get("check_run") or {}
343
+ if check_run.get("status") != "completed":
344
+ return None, f"check_run is not completed: {check_run.get('status')}"
345
+ if not adapter.is_check_run_author(check_run):
346
+ app = check_run.get("app") or {}
347
+ return None, f"ignored check_run app: {app.get('slug') or app.get('name')}"
348
+ if not adapter.is_check_run_name(check_run):
349
+ return None, f"ignored check_run name: {check_run.get('name')}"
350
+ if not pr_number:
351
+ return None, "check_run event has no associated pull request number"
352
+
353
+ if adapter.opt_in_required and not adapter.is_opted_in(pr_labels):
354
+ return None, (
355
+ f"PR does not carry an opt-in {adapter.label_prefix} label; "
356
+ f"add `{adapter.needs_label}` to opt in"
357
+ )
358
+
359
+ reviewed_sha = check_run.get("head_sha") or None
360
+ if reviewed_sha and not current_head_sha:
361
+ if adapter.blocked_label in pr_labels and adapter.needs_label not in pr_labels:
362
+ return None, (
363
+ f"check_run verdict ignored: could not verify current PR head "
364
+ f"and PR already carries `{adapter.blocked_label}`"
365
+ )
366
+ return LabelDecision(
367
+ issue_number=pr_number,
368
+ add_label=adapter.needs_label,
369
+ remove_labels=(adapter.done_label,),
370
+ reviewed_sha=str(reviewed_sha),
371
+ reason=(
372
+ "could not verify current PR head SHA for check_run "
373
+ f"{str(reviewed_sha)[:8]}"
374
+ ),
375
+ ), "missing current head — re-queue"
376
+ if (
377
+ current_head_sha
378
+ and reviewed_sha
379
+ and not sha_matches(str(reviewed_sha), current_head_sha)
380
+ ):
381
+ return LabelDecision(
382
+ issue_number=pr_number,
383
+ add_label=adapter.needs_label,
384
+ remove_labels=(adapter.done_label, adapter.blocked_label),
385
+ reviewed_sha=str(reviewed_sha),
386
+ reason=f"check_run SHA {str(reviewed_sha)[:8]} != current head {current_head_sha[:8]}",
387
+ ), "stale check_run — re-queue"
388
+
389
+ status = adapter.classify_verdict(event, check_run=check_run)
390
+ if status == "done" and same_head_review_exists:
391
+ return None, adapter.same_head_review_exists_reason()
392
+ if (
393
+ status != "blocked"
394
+ and adapter.blocked_label in pr_labels
395
+ and adapter.needs_label not in pr_labels
396
+ ):
397
+ return None, (
398
+ f"check_run verdict ({status}) ignored: PR already carries "
399
+ f"`{adapter.blocked_label}` from a prior review verdict"
400
+ )
401
+ if (
402
+ status == "needs"
403
+ and adapter.done_label in pr_labels
404
+ and adapter.needs_label not in pr_labels
405
+ ):
406
+ return None, (
407
+ f"non-final check_run ignored: PR already carries "
408
+ f"`{adapter.done_label}` from a prior review verdict"
409
+ )
410
+ return _decision_from_status(
411
+ adapter=adapter,
412
+ event=event,
413
+ issue_number=pr_number,
414
+ status=status,
415
+ reviewed_sha=str(reviewed_sha) if reviewed_sha else None,
416
+ check_run=check_run,
417
+ )
418
+
419
+
420
+ def _decision_from_status(
421
+ *,
422
+ adapter: SaaSReviewerAdapter,
423
+ event: dict[str, Any],
424
+ issue_number: int,
425
+ status: Optional[str],
426
+ reviewed_sha: Optional[str] = None,
427
+ **kwargs: Any,
428
+ ) -> tuple[Optional[LabelDecision], str]:
429
+ if status is None:
430
+ return None, f"no {adapter.name} verdict — skipping"
431
+ if status == "needs":
432
+ return LabelDecision(
433
+ issue_number=issue_number,
434
+ add_label=adapter.needs_label,
435
+ remove_labels=(adapter.done_label, adapter.blocked_label),
436
+ reviewed_sha=reviewed_sha,
437
+ reason=adapter.label_reason(status, event, **kwargs),
438
+ ), "format unknown — re-queue"
439
+ if status == "blocked":
440
+ return LabelDecision(
441
+ issue_number=issue_number,
442
+ add_label=adapter.blocked_label,
443
+ remove_labels=(adapter.needs_label, adapter.done_label),
444
+ reviewed_sha=reviewed_sha,
445
+ reason=adapter.label_reason(status, event, **kwargs),
446
+ ), "label blocked"
447
+ if status == "done":
448
+ return LabelDecision(
449
+ issue_number=issue_number,
450
+ add_label=adapter.done_label,
451
+ remove_labels=(adapter.needs_label, adapter.blocked_label),
452
+ reviewed_sha=reviewed_sha,
453
+ reason=adapter.label_reason(status, event, **kwargs),
454
+ ), "label done"
455
+ return None, f"unknown {adapter.name} verdict status: {status}"
456
+
457
+
458
+ def structural_requeue_decision(
459
+ event: dict[str, Any],
460
+ *,
461
+ adapter: SaaSReviewerAdapter,
462
+ pr_labels: list[str],
463
+ reason: str,
464
+ ) -> tuple[Optional[LabelDecision], str]:
465
+ """Fail closed to needs after the same author and opt-in gates pass."""
466
+ if adapter.event_type != "pull_request_review":
467
+ return None, f"{adapter.name} has no structural requeue path"
468
+
469
+ review = event.get("review") or {}
470
+ author = (review.get("user") or {}).get("login", "")
471
+ if not adapter.is_review_author(author):
472
+ return None, f"ignored review author: {author}"
473
+ if adapter.opt_in_required and not adapter.is_opted_in(pr_labels):
474
+ return None, f"PR does not carry an opt-in {adapter.label_prefix} label"
475
+
476
+ pull_request = event.get("pull_request") or {}
477
+ issue_number = int(pull_request.get("number") or 0)
478
+ if not issue_number:
479
+ return None, "event has no pull request number"
480
+ reviewed_sha, _ = adapter.extract_stale_head_info(event)
481
+ return LabelDecision(
482
+ issue_number=issue_number,
483
+ add_label=adapter.needs_label,
484
+ remove_labels=(adapter.done_label, adapter.blocked_label),
485
+ reviewed_sha=reviewed_sha,
486
+ reason=reason,
487
+ ), "structural failure — re-queue"
488
+
489
+
490
+ def _apply_or_log(
491
+ repo: str,
492
+ decision: LabelDecision,
493
+ *,
494
+ tokens: Sequence[GitHubToken],
495
+ lane_name: str,
496
+ ) -> None:
497
+ try:
498
+ apply_label_decision(repo, decision, tokens=tokens)
499
+ print(
500
+ f"applied: add {decision.add_label}; remove "
501
+ f"{', '.join(decision.remove_labels)} "
502
+ f"on {repo}#{decision.issue_number} ({decision.reason})"
503
+ )
504
+ except Exception as exc:
505
+ print(
506
+ f"verdict (label apply skipped — {lane_name} lane is non-blocking): "
507
+ f"add {decision.add_label}; remove {', '.join(decision.remove_labels)} "
508
+ f"on {repo}#{decision.issue_number} ({decision.reason})"
509
+ )
510
+ print(f"warn: could not apply label: {exc}", file=sys.stderr)
511
+
512
+
513
+ def _event_pr_number(
514
+ event: dict[str, Any],
515
+ adapter: SaaSReviewerAdapter,
516
+ event_type: str,
517
+ ) -> int:
518
+ if event_type == "pull_request_review":
519
+ return int((event.get("pull_request") or {}).get("number") or 0)
520
+ if event_type == "check_run":
521
+ pull_requests = ((event.get("check_run") or {}).get("pull_requests") or [])
522
+ if len(pull_requests) == 1:
523
+ return int(pull_requests[0].get("number") or 0)
524
+ return 0
525
+ return int((event.get("issue") or {}).get("number") or 0)
526
+
527
+
528
+ def _github_event_type(adapter: SaaSReviewerAdapter) -> str:
529
+ raw_event_type = os.environ.get("GITHUB_EVENT_NAME") or ""
530
+ if raw_event_type == "issues" and adapter.event_type == "issue_comment":
531
+ return "issues"
532
+ if raw_event_type in adapter.supported_event_types:
533
+ return raw_event_type
534
+ return adapter.event_type
535
+
536
+
537
+ def main(argv: Optional[Sequence[str]] = None) -> int:
538
+ parser = argparse.ArgumentParser()
539
+ parser.add_argument(
540
+ "--adapter",
541
+ required=True,
542
+ help="SaaS reviewer adapter: greptile, gitar, or qodo.",
543
+ )
544
+ args = parser.parse_args(argv)
545
+ adapter = load_adapter(args.adapter)
546
+
547
+ event_path = Path(os.environ["GITHUB_EVENT_PATH"])
548
+ repo = os.environ["GITHUB_REPOSITORY"]
549
+ event = load_json(event_path)
550
+ event_type = _github_event_type(adapter)
551
+
552
+ tokens = github_tokens_from_env(*adapter.token_env_vars)
553
+ dry_run = env_flag("DRY_RUN")
554
+ if not dry_run and not tokens:
555
+ token_names = " or ".join(adapter.token_env_vars)
556
+ print(f"error: {token_names} is required", file=sys.stderr)
557
+ return 1
558
+
559
+ pr_labels: list[str] = []
560
+ current_head_sha = os.environ.get("DRY_RUN_HEAD_SHA")
561
+ review_comments: list[dict[str, Any]] = []
562
+ same_head_review_exists = False
563
+ pr_number = _event_pr_number(event, adapter, event_type)
564
+
565
+ if dry_run:
566
+ if event_type == "pull_request_review":
567
+ pull_request = event.get("pull_request") or {}
568
+ pr_labels = [label.get("name", "") for label in pull_request.get("labels") or []]
569
+ review_comments = event.get("_dry_run_review_comments") or []
570
+ elif event_type == "issue_comment":
571
+ issue = event.get("issue") or {}
572
+ pr_labels = list(event.get("_dry_run_pr_labels") or [])
573
+ if not pr_labels:
574
+ pr_labels = [label.get("name", "") for label in issue.get("labels") or []]
575
+ elif event_type == "check_run":
576
+ pr_number = int(event.get("_dry_run_pr_number") or pr_number or 0)
577
+ pr_labels = list(event.get("_dry_run_pr_labels") or [])
578
+ current_head_sha = event.get("_dry_run_current_head_sha")
579
+ same_head_review_exists = bool(event.get("_dry_run_same_head_review_exists"))
580
+ elif event_type == "check_run":
581
+ check_run = event.get("check_run") or {}
582
+ if event.get("action") != "completed":
583
+ print(f"skip: unsupported check_run action: {event.get('action')}")
584
+ return 0
585
+ if check_run.get("status") != "completed":
586
+ print(f"skip: check_run is not completed: {check_run.get('status')}")
587
+ return 0
588
+ if not adapter.is_check_run_author(check_run):
589
+ app = check_run.get("app") or {}
590
+ print(f"skip: ignored check_run app: {app.get('slug') or app.get('name')}")
591
+ return 0
592
+ if not adapter.is_check_run_name(check_run):
593
+ print(f"skip: ignored check_run name: {check_run.get('name')}")
594
+ return 0
595
+ check_run_prs = list(check_run.get("pull_requests") or [])
596
+ head_sha = check_run.get("head_sha")
597
+ if not check_run_prs and head_sha:
598
+ try:
599
+ check_run_prs = fetch_pull_requests_for_commit(
600
+ repo, str(head_sha), tokens=tokens
601
+ )
602
+ except GitHubRequestError as exc:
603
+ print(
604
+ f"skip: could not resolve PRs for commit "
605
+ f"{str(head_sha)[:8]}: {exc}"
606
+ )
607
+ return 0
608
+ if not check_run_prs:
609
+ print("skip: check_run has no associated pull requests")
610
+ return 0
611
+
612
+ seen_prs: set[int] = set()
613
+ applied = 0
614
+ considered = 0
615
+ for pr_summary in check_run_prs:
616
+ candidate_number = int(pr_summary.get("number") or 0)
617
+ if not candidate_number or candidate_number in seen_prs:
618
+ continue
619
+ seen_prs.add(candidate_number)
620
+ considered += 1
621
+ try:
622
+ pr_current = fetch_pull_request(repo, candidate_number, tokens=tokens)
623
+ except GitHubRequestError as exc:
624
+ print(f"skip: could not fetch PR #{candidate_number}: {exc}")
625
+ continue
626
+ if pr_current.get("state") != "open":
627
+ print(f"skip: PR #{candidate_number} is not open")
628
+ continue
629
+ candidate_labels = [
630
+ label.get("name", "") for label in pr_current.get("labels") or []
631
+ ]
632
+ candidate_head = pr_current.get("head", {}).get("sha")
633
+ if adapter.opt_in_required and not adapter.is_opted_in(candidate_labels):
634
+ _, reason = resolve_label_decision(
635
+ event,
636
+ adapter=adapter,
637
+ event_type=event_type,
638
+ pr_number=candidate_number,
639
+ pr_labels=candidate_labels,
640
+ current_head_sha=candidate_head,
641
+ )
642
+ print(f"skip: {reason}")
643
+ continue
644
+
645
+ decision, reason = resolve_label_decision(
646
+ event,
647
+ adapter=adapter,
648
+ event_type=event_type,
649
+ pr_number=candidate_number,
650
+ pr_labels=candidate_labels,
651
+ current_head_sha=candidate_head,
652
+ )
653
+ if decision is None:
654
+ print(f"skip: {reason}")
655
+ continue
656
+ if (
657
+ adapter.check_run_done_requires_absent_same_head_review
658
+ and decision.add_label == adapter.done_label
659
+ ):
660
+ try:
661
+ if has_same_head_review(
662
+ fetch_pull_request_reviews(
663
+ repo,
664
+ candidate_number,
665
+ tokens=tokens,
666
+ page_cap=adapter.review_comments_page_cap,
667
+ ),
668
+ adapter=adapter,
669
+ head_sha=str(check_run.get("head_sha") or ""),
670
+ ):
671
+ print(f"skip: {adapter.same_head_review_exists_reason()}")
672
+ continue
673
+ except (GitHubRequestError, ReviewCommentsTruncated) as exc:
674
+ decision = LabelDecision(
675
+ issue_number=candidate_number,
676
+ add_label=adapter.needs_label,
677
+ remove_labels=(adapter.done_label, adapter.blocked_label),
678
+ reviewed_sha=str(check_run.get("head_sha") or "") or None,
679
+ reason=adapter.review_lookup_failed_reason(exc),
680
+ )
681
+ _apply_or_log(repo, decision, tokens=tokens, lane_name=adapter.name)
682
+ applied += 1
683
+ if applied == 0:
684
+ if considered:
685
+ print("skip: check_run-associated PRs produced no label updates")
686
+ else:
687
+ print("skip: no check_run-associated PR needed a label update")
688
+ return 0
689
+ elif event_type == "pull_request_review" and pr_number:
690
+ pr_current = fetch_pull_request(repo, pr_number, tokens=tokens)
691
+ pr_labels = [label.get("name", "") for label in pr_current.get("labels") or []]
692
+ current_head_sha = pr_current.get("head", {}).get("sha")
693
+ if adapter.requires_review_comments:
694
+ review = event.get("review") or {}
695
+ review_id = review.get("id")
696
+ if not review_id:
697
+ decision, reason = structural_requeue_decision(
698
+ event,
699
+ adapter=adapter,
700
+ pr_labels=pr_labels,
701
+ reason="review.id missing from event payload",
702
+ )
703
+ if decision is None:
704
+ print(f"skip: {reason}")
705
+ return 0
706
+ _apply_or_log(repo, decision, tokens=tokens, lane_name=adapter.name)
707
+ return 0
708
+ try:
709
+ review_comments = fetch_review_comments(
710
+ repo,
711
+ pr_number,
712
+ int(review_id),
713
+ tokens=tokens,
714
+ page_cap=adapter.review_comments_page_cap,
715
+ )
716
+ except (GitHubRequestError, ReviewCommentsTruncated) as exc:
717
+ decision, reason = structural_requeue_decision(
718
+ event,
719
+ adapter=adapter,
720
+ pr_labels=pr_labels,
721
+ reason=f"could not fetch review comments: {exc}",
722
+ )
723
+ if decision is None:
724
+ print(f"skip: {reason}")
725
+ return 0
726
+ _apply_or_log(repo, decision, tokens=tokens, lane_name=adapter.name)
727
+ return 0
728
+ elif event_type == "issue_comment" and pr_number and adapter.opt_in_required:
729
+ issue = event.get("issue") or {}
730
+ comment = event.get("comment") or {}
731
+ author = (comment.get("user") or {}).get("login", "")
732
+ if "pull_request" in issue and adapter.is_review_author(author):
733
+ pr_current = fetch_pull_request(repo, pr_number, tokens=tokens)
734
+ pr_labels = [label.get("name", "") for label in pr_current.get("labels") or []]
735
+ elif event_type == "issues" and pr_number and adapter.event_type == "issue_comment":
736
+ if event.get("action") != "labeled":
737
+ print(f"skip: unsupported issues action: {event.get('action')}")
738
+ return 0
739
+ issue = event.get("issue") or {}
740
+ if "pull_request" not in issue:
741
+ print("skip: label is on an issue, not a pull request")
742
+ return 0
743
+ label_name = str((event.get("label") or {}).get("name") or "")
744
+ if not adapter.is_opted_in([label_name]):
745
+ print(
746
+ f"skip: label `{label_name}` is not an opt-in "
747
+ f"{adapter.label_prefix} label"
748
+ )
749
+ return 0
750
+ pr_current = fetch_pull_request(repo, pr_number, tokens=tokens)
751
+ pr_labels = [label.get("name", "") for label in pr_current.get("labels") or []]
752
+ if adapter.opt_in_required and not adapter.is_opted_in(pr_labels):
753
+ print(f"skip: PR does not carry an opt-in {adapter.label_prefix} label")
754
+ return 0
755
+ try:
756
+ comments = fetch_issue_comments(
757
+ repo,
758
+ pr_number,
759
+ tokens=tokens,
760
+ page_cap=adapter.review_comments_page_cap,
761
+ )
762
+ except (GitHubRequestError, ReviewCommentsTruncated) as exc:
763
+ print(f"skip: could not fetch issue comments: {exc}")
764
+ return 0
765
+ for comment in reversed(comments):
766
+ synthetic_event = {
767
+ "action": "created",
768
+ "issue": issue,
769
+ "comment": comment,
770
+ }
771
+ decision, reason = resolve_label_decision(
772
+ synthetic_event,
773
+ adapter=adapter,
774
+ event_type=adapter.event_type,
775
+ pr_number=pr_number,
776
+ pr_labels=pr_labels,
777
+ current_head_sha=current_head_sha,
778
+ )
779
+ if decision is None:
780
+ continue
781
+ _apply_or_log(repo, decision, tokens=tokens, lane_name=adapter.name)
782
+ return 0
783
+ print("skip: no existing issue comment produced a label update")
784
+ return 0
785
+
786
+ decision, reason = resolve_label_decision(
787
+ event,
788
+ adapter=adapter,
789
+ event_type=event_type,
790
+ pr_number=pr_number,
791
+ pr_labels=pr_labels,
792
+ current_head_sha=current_head_sha,
793
+ review_comments=review_comments,
794
+ same_head_review_exists=same_head_review_exists,
795
+ )
796
+ if decision is None:
797
+ print(f"skip: {reason}")
798
+ return 0
799
+
800
+ if dry_run:
801
+ print(json.dumps({"decision": decision.__dict__, "reason": reason}, sort_keys=True))
802
+ return 0
803
+
804
+ _apply_or_log(repo, decision, tokens=tokens, lane_name=adapter.name)
805
+ return 0
806
+
807
+
808
+ if __name__ == "__main__":
809
+ raise SystemExit(main())