@openhands/extensions 0.24.0 → 0.26.0
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.
- package/.claude-plugin/marketplace.json +30 -0
- package/.codex-plugin/marketplace.json +30 -0
- package/.github/pull_request_template.md +17 -2
- package/.github/scripts/check_pr_description.py +64 -5
- package/.github/scripts/markdown_sections.py +95 -0
- package/.github/workflows/build-vscode-app.yml +30 -0
- package/.github/workflows/check-extensions.yml +3 -0
- package/.github/workflows/tests.yml +26 -0
- package/.plugin/marketplace.json +30 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +3 -2
- package/README.md +10 -3
- package/apps/README.md +20 -0
- package/apps/catalog.json +10 -0
- package/apps/index.d.ts +11 -0
- package/apps/index.js +5 -0
- package/apps/vscode/README.md +13 -0
- package/apps/vscode/canvas-extension.json +19 -0
- package/apps/vscode/extension.js +114 -0
- package/apps/vscode/scripts/package.mjs +84 -0
- package/automations/bundle-index.js +18 -14
- package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
- package/automations/catalog/github-pr-reviewer/manifest.json +19 -8
- package/automations/catalog/github-stale-ci-pr-closer/manifest.json +71 -0
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
- package/automations/catalog/news-digest/manifest.json +2 -2
- package/automations/catalog-index.js +2 -0
- package/index.d.ts +2 -0
- package/index.js +1 -0
- package/marketplaces/openhands-extensions.json +30 -0
- package/package.json +9 -1
- package/plugins/openhands/skills/openhands-api/README.md +1 -1
- package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
- package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
- package/plugins/openhands/skills/openhands-sdk/SKILL.md +1 -0
- package/plugins/pr-review/skills/github-pr-review/SKILL.md +12 -2
- package/plugins/qa-changes/skills/github-pr-review/SKILL.md +12 -2
- package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/README.md +59 -0
- package/plugins/runtime-api-configs/SKILL.md +240 -0
- package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
- package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
- package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +183 -15
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/scripts/check_plugin_resources.py +116 -0
- package/scripts/sync_extensions.py +32 -5
- package/skills/canvas-extension-api/SKILL.md +34 -3
- package/skills/canvas-extension-api/references/backend-safety.md +14 -0
- package/skills/canvas-extension-api/references/connections.md +24 -0
- package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
- package/skills/canvas-extension-api/references/v1-contract.md +53 -3
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
- package/skills/github/scripts/agent_conversation.py +223 -43
- package/skills/github/scripts/github_client.py +183 -15
- package/skills/github-agents-md-maintainer/README.md +7 -0
- package/skills/github-agents-md-maintainer/SKILL.md +13 -6
- package/skills/github-agents-md-maintainer/scripts/github_client.py +183 -15
- package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
- package/skills/github-delivery-watchdog/scripts/github_client.py +183 -15
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-to-pr/scripts/github_client.py +183 -15
- package/skills/github-issue-to-pr/scripts/worker.py +10 -1
- package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-triage/scripts/github_client.py +183 -15
- package/skills/github-pr-review/SKILL.md +12 -2
- package/skills/github-pr-reviewer/README.md +36 -0
- package/skills/github-pr-reviewer/SKILL.md +147 -4
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
- package/skills/github-pr-reviewer/scripts/github_client.py +183 -15
- package/skills/github-pr-reviewer/scripts/main.py +17 -1
- package/skills/github-pr-reviewer/scripts/worker.py +842 -57
- package/skills/github-repo-monitor/scripts/github_client.py +183 -15
- package/skills/github-repo-monitor/scripts/main.py +32 -1
- package/skills/github-stale-ci-pr-closer/.claude-plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/.codex-plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/.plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/README.md +9 -0
- package/skills/github-stale-ci-pr-closer/SKILL.md +24 -0
- package/skills/github-stale-ci-pr-closer/commands/github-stale-ci-pr-closer.md +8 -0
- package/skills/github-stale-ci-pr-closer/scripts/worker.py +508 -0
- package/skills/github-stale-ci-pr-closer/tests/test_worker.py +353 -0
- package/skills/gitlab-issue-to-mr/README.md +12 -0
- package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
- package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
- package/skills/incident-retrospective/SKILL.md +5 -5
- package/skills/index.js +34 -16
- package/skills/jira-issue-to-pr/README.md +12 -4
- package/skills/jira-issue-to-pr/SKILL.md +129 -18
- package/skills/jira-issue-to-pr/references/setup.md +8 -0
- package/skills/jira-issue-to-pr/scripts/main.py +181 -54
- package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
- package/skills/linear-triage/SKILL.md +5 -5
- package/skills/news-digest/README.md +6 -0
- package/skills/news-digest/SKILL.md +19 -9
- package/skills/news-digest/commands/news-digest-setup.md +1 -1
- package/skills/news-digest/scripts/main.py +133 -15
- package/skills/openhands-api/README.md +1 -1
- package/skills/openhands-api/SKILL.md +3 -3
- package/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/skills/openhands-automation/SKILL.md +66 -4
- package/skills/openhands-automation/references/custom-automation.md +21 -7
- package/skills/openhands-sdk/SKILL.md +1 -0
- package/skills/research-brief/SKILL.md +5 -5
- package/skills/slack-standup-digest/SKILL.md +5 -5
- package/skills/ticket-to-code-change/SKILL.md +6 -3
- package/skills/use-jev/.claude-plugin/plugin.json +8 -0
- package/skills/use-jev/.codex-plugin/plugin.json +8 -0
- package/skills/use-jev/.plugin/plugin.json +8 -0
- package/skills/use-jev/README.md +66 -0
- package/skills/use-jev/SKILL.md +66 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
- package/tests/test_agent_conversation_dispatch.py +212 -0
- package/tests/test_apps_catalog.py +26 -0
- package/tests/test_automation_setup.py +44 -0
- package/tests/test_check_pr_description.py +89 -6
- package/tests/test_github_automation_foundation.py +158 -0
- package/tests/test_github_repo_monitor.py +107 -0
- package/tests/test_github_reviewer_delivery.py +2247 -6
- package/tests/test_github_stale_ci_pr_closer.py +56 -0
- package/tests/test_jira_issue_to_pr.py +312 -0
- package/tests/test_jira_setup_webhook.py +309 -0
- package/tests/test_openhands_api_python.py +37 -0
- package/tests/test_skill_plugin_loading.py +25 -15
- package/tests/test_sync_extensions.py +102 -0
- package/tests/test_vscode_app.py +33 -0
- package/tests/vscode-app.test.mjs +43 -0
|
@@ -15,9 +15,122 @@ from maintainer_handoff import (
|
|
|
15
15
|
request_maintainer_review,
|
|
16
16
|
)
|
|
17
17
|
|
|
18
|
+
# The head-eligibility gate. Scheduled discovery classifies only the checks
|
|
19
|
+
# GitHub reports as required for the pull request, read through the GraphQL
|
|
20
|
+
# `isRequired` signal, so an optional workflow that fails before creating any
|
|
21
|
+
# check run cannot block a head whose required checks pass. An explicit
|
|
22
|
+
# `all-hands-bot` review request is the intake-policy exception and bypasses the
|
|
23
|
+
# gate entirely. A completed required run blocks unless its conclusion is
|
|
24
|
+
# explicitly non-blocking, so an unrecognized conclusion fails closed rather than
|
|
25
|
+
# approving silently, and a required run that has not completed means waiting,
|
|
26
|
+
# never approval. When the required set cannot be read, the gate falls back to
|
|
27
|
+
# every current-head check and workflow run, so a red head still blocks.
|
|
28
|
+
CHECK_GATE_MARKER = "<!-- openhands-review-gate:"
|
|
29
|
+
NON_BLOCKING_CHECK_CONCLUSIONS = frozenset({"success", "neutral", "skipped"})
|
|
30
|
+
# The gate is deterministic; this disclosure is what tells a reader no model ran.
|
|
31
|
+
WORKFLOW_DISCLOSURE = "no AI was used to generate this comment"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ReviewIntake:
|
|
35
|
+
"""The per-scan bound on new review conversations, shared across repositories.
|
|
36
|
+
|
|
37
|
+
A scheduled scan drains every eligible pull request, but starting an agent
|
|
38
|
+
for each one at once exhausted the OSS Agent Canvas VM, so a small maximum
|
|
39
|
+
caps what a single scan may start. The maximum is per scan and shared by
|
|
40
|
+
every configured repository rather than reset per repository, and it bounds
|
|
41
|
+
the conversations a scan *starts*: a delivery that only deduplicates, or
|
|
42
|
+
reports an already-running conversation, reuses a runtime and consumes no
|
|
43
|
+
slot. Candidates are drained oldest reviewer-request first, then by
|
|
44
|
+
repository and pull-request number, so a scan over more candidates than the
|
|
45
|
+
maximum allows starts the oldest and a later scan reaches the remainder.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self):
|
|
49
|
+
# The budget lives on the intake so a scan that drains repository by
|
|
50
|
+
# repository still counts its conversations against one shared maximum.
|
|
51
|
+
self._pending = []
|
|
52
|
+
self._maximum = None
|
|
53
|
+
self._started = 0
|
|
54
|
+
|
|
55
|
+
def register(self, record):
|
|
56
|
+
"""Queue one eligible candidate for this scan's bounded drain.
|
|
57
|
+
|
|
58
|
+
`record["priority"]` is 0 for an explicit `all-hands-bot` request and 1
|
|
59
|
+
for an unrequested eligible PR, so explicit requests drain first.
|
|
60
|
+
"""
|
|
61
|
+
self._pending.append(record)
|
|
62
|
+
|
|
63
|
+
def drain(self):
|
|
64
|
+
"""Start the oldest pending conversations, up to the per-scan maximum.
|
|
65
|
+
|
|
66
|
+
The budget is held on the intake, so a scan that drains repository by
|
|
67
|
+
repository still counts its conversations against one shared maximum. A
|
|
68
|
+
candidate whose dispatch raises is reported and skipped without
|
|
69
|
+
consuming a slot, so the candidates behind it are still considered.
|
|
70
|
+
"""
|
|
71
|
+
pending, self._pending = self._pending, []
|
|
72
|
+
if not pending:
|
|
73
|
+
return
|
|
74
|
+
if self._maximum is None:
|
|
75
|
+
self._maximum = pending[0]["config"].get(
|
|
76
|
+
"max_new_per_run", workflow.MAX_NEW_PER_RUN
|
|
77
|
+
)
|
|
78
|
+
failures = []
|
|
79
|
+
for record in sorted(
|
|
80
|
+
pending,
|
|
81
|
+
key=lambda item: (
|
|
82
|
+
item["priority"],
|
|
83
|
+
item["created_at"],
|
|
84
|
+
item["repository"],
|
|
85
|
+
item["number"],
|
|
86
|
+
),
|
|
87
|
+
):
|
|
88
|
+
if self._started >= self._maximum:
|
|
89
|
+
break
|
|
90
|
+
try:
|
|
91
|
+
result = record["start"]()
|
|
92
|
+
except Exception as exc: # noqa: BLE001 - one PR must not block the scan
|
|
93
|
+
failures.append(record["number"])
|
|
94
|
+
print(
|
|
95
|
+
f"Failed to submit {record['repository']} PR "
|
|
96
|
+
f"#{record['number']}: {type(exc).__name__}: {exc}",
|
|
97
|
+
file=sys.stderr,
|
|
98
|
+
flush=True,
|
|
99
|
+
)
|
|
100
|
+
continue
|
|
101
|
+
if result["disposition"] == "created":
|
|
102
|
+
self._started += 1
|
|
103
|
+
if failures:
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
"Reviewer scan failed for PRs: "
|
|
106
|
+
+ ", ".join(f"#{number}" for number in failures)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
18
110
|
|
|
19
111
|
class PullRequestReviewer(GitHubRepository):
|
|
20
112
|
name = "github-pr-reviewer"
|
|
113
|
+
# The shared intake the shipped entrypoint creates once per scheduled scan,
|
|
114
|
+
# so the per-run maximum spans every configured repository. It is None for a
|
|
115
|
+
# run() invoked on its own (tests, one-off scans), which then bounds its own
|
|
116
|
+
# conversations with a private intake instead.
|
|
117
|
+
scan_intake = None
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def intake(self):
|
|
121
|
+
"""The intake this run registers eligible candidates with.
|
|
122
|
+
|
|
123
|
+
The shipped entrypoint sets `scan_intake` once, so every repository in a
|
|
124
|
+
scan shares one budget and the drain happens after all repositories have
|
|
125
|
+
been scanned. A run() with no shared intake uses its own, created lazily
|
|
126
|
+
so it exists whether or not __init__ ran.
|
|
127
|
+
"""
|
|
128
|
+
if self.scan_intake is not None:
|
|
129
|
+
return self.scan_intake
|
|
130
|
+
intake = self.__dict__.get("_intake")
|
|
131
|
+
if intake is None:
|
|
132
|
+
intake = self.__dict__["_intake"] = ReviewIntake()
|
|
133
|
+
return intake
|
|
21
134
|
|
|
22
135
|
@cached_property
|
|
23
136
|
def github_login(self):
|
|
@@ -74,7 +187,7 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
74
187
|
return pr if (author or "").lower() == self.trigger_reviewer else None
|
|
75
188
|
return None
|
|
76
189
|
|
|
77
|
-
def _prompt(self, pr, trigger, label=None):
|
|
190
|
+
def _prompt(self, pr, trigger, label=None, delivery_key=None):
|
|
78
191
|
number = pr["number"]
|
|
79
192
|
sha = pr["head"]["sha"]
|
|
80
193
|
token = self.token_name
|
|
@@ -87,41 +200,76 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
87
200
|
"mode. Set `GIT_TERMINAL_PROMPT=0` on Git network commands so a missing "
|
|
88
201
|
"permission fails immediately instead of waiting for input."
|
|
89
202
|
)
|
|
203
|
+
if label:
|
|
204
|
+
trigger_description = None
|
|
205
|
+
elif delivery_key is not None:
|
|
206
|
+
trigger_description = (
|
|
207
|
+
f"scheduled scan of open, non-draft pull requests on head `{sha}`"
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
trigger_description = (
|
|
211
|
+
f"latest review request for `{self.trigger_reviewer}` "
|
|
212
|
+
f"event {trigger.get('id', '?')} at {trigger.get('created_at', '?')}"
|
|
213
|
+
)
|
|
90
214
|
prompt = workflow._build_review_prompt(
|
|
91
215
|
self.repository,
|
|
92
216
|
pr,
|
|
93
217
|
sha,
|
|
94
|
-
trigger,
|
|
218
|
+
trigger or {},
|
|
95
219
|
workspace_instructions=workspace,
|
|
96
220
|
github_token_secret=token,
|
|
97
|
-
trigger_description=
|
|
98
|
-
None
|
|
99
|
-
if label
|
|
100
|
-
else (
|
|
101
|
-
f"latest review request for "
|
|
102
|
-
f"`{self.trigger_reviewer}` "
|
|
103
|
-
f"event {trigger.get('id', '?')} at "
|
|
104
|
-
f"{trigger.get('created_at', '?')}"
|
|
105
|
-
)
|
|
106
|
-
),
|
|
221
|
+
trigger_description=trigger_description,
|
|
107
222
|
)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
223
|
+
if label:
|
|
224
|
+
moved_head_instruction = (
|
|
225
|
+
f"leave `{label}` in place so the new head is reviewed"
|
|
226
|
+
)
|
|
227
|
+
trigger_completion = (
|
|
228
|
+
f"Leave the `{label}` label in place after GitHub accepts the review. "
|
|
229
|
+
"The deterministic scanner removes it"
|
|
230
|
+
)
|
|
231
|
+
elif delivery_key is not None:
|
|
232
|
+
# A scheduled scan reviews a new head again on its own, so there is
|
|
233
|
+
# no request to preserve and no label to leave behind.
|
|
234
|
+
moved_head_instruction = (
|
|
235
|
+
"publish no review; a later scheduled scan reviews the new head"
|
|
236
|
+
)
|
|
237
|
+
trigger_completion = (
|
|
238
|
+
"Do not change review requests after GitHub accepts the review. "
|
|
239
|
+
"The deterministic scan records the completed review"
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
moved_head_instruction = (
|
|
243
|
+
"publish no review; the new head requires another reviewer request"
|
|
244
|
+
)
|
|
245
|
+
trigger_completion = (
|
|
246
|
+
"Do not change review requests after GitHub accepts the review. "
|
|
247
|
+
"The deterministic event handler completes the request"
|
|
248
|
+
)
|
|
249
|
+
author = ((pr.get("user") or {}).get("login") or "").lower()
|
|
250
|
+
self_review_note = (
|
|
251
|
+
"\n- This pull request is authored by the configured reviewer account, "
|
|
252
|
+
"so GitHub ignores a review request from it. Publish the clean review "
|
|
253
|
+
"with `event: COMMENT` and keep the approved verdict instead of "
|
|
254
|
+
"attempting `event: APPROVE`."
|
|
255
|
+
if author == self.trigger_reviewer
|
|
256
|
+
else ""
|
|
119
257
|
)
|
|
120
258
|
return (
|
|
121
259
|
prompt + "\n\nAcceptance reporting:\n"
|
|
122
260
|
"- Inspect the current GitHub Actions results for the exact head and run "
|
|
123
261
|
"the repository's appropriate focused tests in the workspace. Do not "
|
|
124
262
|
"modify tracked files.\n"
|
|
263
|
+
"- Before publishing anything, read the PR's existing reviews and "
|
|
264
|
+
"comments on GitHub. If this account has already published a review on "
|
|
265
|
+
f"`{sha}` and no one else has commented since that review, do NOT "
|
|
266
|
+
"publish a second review and do NOT start another review pass: another "
|
|
267
|
+
"review of identical code is a duplicate. Report the earlier verdict as "
|
|
268
|
+
"still current and stop. Review the head again only when the head has "
|
|
269
|
+
"moved, or when a comment from someone other than this account was "
|
|
270
|
+
"posted after the earlier review.\n"
|
|
271
|
+
"- If a review by this account is currently underway for this head, wait "
|
|
272
|
+
"for it instead of starting a second one.\n"
|
|
125
273
|
f"- Re-read {self.repository} PR #{number} immediately before reporting. "
|
|
126
274
|
"Confirm its current body, labels, review threads, and the head's check "
|
|
127
275
|
"results, and re-read every linked issue's current body and labels: an "
|
|
@@ -132,13 +280,24 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
132
280
|
"into comments.\n"
|
|
133
281
|
"- Once GitHub accepts the native review, stop immediately. Do not "
|
|
134
282
|
"continue inspecting the repository, run more commands, or publish a "
|
|
135
|
-
"second result."
|
|
283
|
+
f"second result.{self_review_note}"
|
|
136
284
|
)
|
|
137
285
|
|
|
138
286
|
def _finish_completed_review(self, pr, trigger, label=None):
|
|
139
|
-
"""Complete an exact-head review, including an optional human handoff.
|
|
287
|
+
"""Complete an exact-head review, including an optional human handoff.
|
|
288
|
+
|
|
289
|
+
`trigger` is the event the current work is keyed on. It is None for an
|
|
290
|
+
unrequested scheduled-scan PR, which has no trigger event: the head SHA
|
|
291
|
+
is the whole key, so any submitted review by this account on the current
|
|
292
|
+
head is the completion of that work. That is what lets an unrequested
|
|
293
|
+
review the scan started be reconciled and handed off on a later scan
|
|
294
|
+
instead of being restarted.
|
|
295
|
+
"""
|
|
140
296
|
head_sha = pr["head"]["sha"]
|
|
141
|
-
|
|
297
|
+
if trigger is not None:
|
|
298
|
+
triggered_at = trigger.get("created_at") or ""
|
|
299
|
+
else:
|
|
300
|
+
triggered_at = ""
|
|
142
301
|
reviews = self.gh_pages(f"/pulls/{pr['number']}/reviews")
|
|
143
302
|
completed = [
|
|
144
303
|
review
|
|
@@ -211,12 +370,482 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
211
370
|
)
|
|
212
371
|
return True
|
|
213
372
|
|
|
373
|
+
@staticmethod
|
|
374
|
+
def _check_app_identity(run):
|
|
375
|
+
"""The app that reported a check run, as a stable grouping key."""
|
|
376
|
+
app = run.get("app") or {}
|
|
377
|
+
if isinstance(app, dict):
|
|
378
|
+
return str(app.get("slug") or app.get("id") or app.get("name") or "")
|
|
379
|
+
return str(app)
|
|
380
|
+
|
|
381
|
+
@staticmethod
|
|
382
|
+
def _check_run_order(run):
|
|
383
|
+
"""Deterministic ordering by run ID, then start time as a tie-break.
|
|
384
|
+
|
|
385
|
+
The check-run ID is a monotonically increasing per-repository counter,
|
|
386
|
+
so it tracks creation order even when `started_at` is absent, and it is
|
|
387
|
+
the primary key. Ordering by start time first broke in both directions:
|
|
388
|
+
a missing start time sorted before every real timestamp (an older queued
|
|
389
|
+
run lost to an earlier success), and the earlier `~{run_id}` fallback
|
|
390
|
+
sorted after every real timestamp (an older queued run outranked a newer
|
|
391
|
+
success). The start time only breaks a tie when two runs share an ID or
|
|
392
|
+
have none.
|
|
393
|
+
"""
|
|
394
|
+
run_id = int(run.get("id") or 0)
|
|
395
|
+
return (run_id, run.get("started_at") or "")
|
|
396
|
+
|
|
397
|
+
@staticmethod
|
|
398
|
+
def _workflow_run_order(run):
|
|
399
|
+
"""Deterministic ordering for workflow runs, keyed by run ID first.
|
|
400
|
+
|
|
401
|
+
Workflow-run IDs are the same monotonic per-repository counter as
|
|
402
|
+
check-run IDs, so the ID is again the primary key. The start time lives
|
|
403
|
+
in `run_started_at` (`created_at` before the run starts) and only breaks
|
|
404
|
+
a tie.
|
|
405
|
+
"""
|
|
406
|
+
run_id = int(run.get("id") or 0)
|
|
407
|
+
return (run_id, run.get("run_started_at") or run.get("created_at") or "")
|
|
408
|
+
|
|
409
|
+
@staticmethod
|
|
410
|
+
def _latest_by_group(runs, sha, order, group):
|
|
411
|
+
"""Keep the latest run per group, ignoring any other head's runs.
|
|
412
|
+
|
|
413
|
+
GitHub lists every run for a commit, so a check that was re-run after a
|
|
414
|
+
fix would otherwise contribute its superseded failure forever. Only runs
|
|
415
|
+
attributed to the exact head count: a run left behind on an obsolete head
|
|
416
|
+
must not block the push that fixed it. Within a group the latest run
|
|
417
|
+
wins, ordered by the run ID (the reliable creation sequence) with the
|
|
418
|
+
start time as a tie-break, so a newer queued or in-progress re-run
|
|
419
|
+
supersedes an earlier success and makes the head wait even when its start
|
|
420
|
+
time is still absent, while an older run without a start time cannot
|
|
421
|
+
outrank a newer success.
|
|
422
|
+
"""
|
|
423
|
+
latest = {}
|
|
424
|
+
for run in runs:
|
|
425
|
+
if run.get("head_sha") != sha:
|
|
426
|
+
continue
|
|
427
|
+
key = group(run)
|
|
428
|
+
current = latest.get(key)
|
|
429
|
+
if current is None or order(run) > order(current):
|
|
430
|
+
latest[key] = run
|
|
431
|
+
return list(latest.values())
|
|
432
|
+
|
|
433
|
+
def _latest_check_runs(self, sha, check_runs):
|
|
434
|
+
"""Return only the latest run of each logical check on the exact head.
|
|
435
|
+
|
|
436
|
+
A logical check is its name plus the reporting app identity, so two apps
|
|
437
|
+
that use the same name stay independent.
|
|
438
|
+
"""
|
|
439
|
+
return self._latest_by_group(
|
|
440
|
+
check_runs,
|
|
441
|
+
sha,
|
|
442
|
+
self._check_run_order,
|
|
443
|
+
lambda run: (
|
|
444
|
+
run.get("name") or "unnamed check",
|
|
445
|
+
self._check_app_identity(run),
|
|
446
|
+
),
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def _latest_workflow_runs(self, sha, check_runs):
|
|
450
|
+
"""Return the current-head workflow runs that created no check runs.
|
|
451
|
+
|
|
452
|
+
A workflow run's check suite is the link to its check runs. When that
|
|
453
|
+
suite already reported check runs, those runs carry the conclusion and
|
|
454
|
+
re-reporting the workflow run would only duplicate them. A suite with no
|
|
455
|
+
check runs is a workflow that failed before any job reported - a
|
|
456
|
+
workflow-level error, or a `pull_request` run whose jobs never started -
|
|
457
|
+
which the commit's check-run rollup and `gh pr check`s cannot see. Group
|
|
458
|
+
by workflow identity so a re-run supersedes its earlier attempt.
|
|
459
|
+
"""
|
|
460
|
+
reported_suites = {
|
|
461
|
+
(run.get("check_suite") or {}).get("id")
|
|
462
|
+
for run in check_runs
|
|
463
|
+
if run.get("head_sha") == sha
|
|
464
|
+
}
|
|
465
|
+
return self._latest_by_group(
|
|
466
|
+
[
|
|
467
|
+
run
|
|
468
|
+
for run in self.workflow_runs(sha)
|
|
469
|
+
if run.get("check_suite_id") not in reported_suites
|
|
470
|
+
],
|
|
471
|
+
sha,
|
|
472
|
+
self._workflow_run_order,
|
|
473
|
+
lambda run: (
|
|
474
|
+
run.get("name") or "unnamed workflow",
|
|
475
|
+
run.get("workflow_id"),
|
|
476
|
+
),
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
@staticmethod
|
|
480
|
+
def _classify_runs(reporters):
|
|
481
|
+
"""Split runs into blocking, pending, and green by status/conclusion.
|
|
482
|
+
|
|
483
|
+
A completed run whose conclusion is neither blocking nor explicitly
|
|
484
|
+
non-blocking fails closed, so an unknown conclusion cannot silently
|
|
485
|
+
approve a PR. A run that has not completed means waiting, never
|
|
486
|
+
approval.
|
|
487
|
+
"""
|
|
488
|
+
blocking, pending = [], []
|
|
489
|
+
for run in reporters:
|
|
490
|
+
name = run.get("name") or "unnamed check"
|
|
491
|
+
status = (run.get("status") or "").lower()
|
|
492
|
+
if status != "completed":
|
|
493
|
+
pending.append(name)
|
|
494
|
+
elif (run.get("conclusion") or "").lower() in NON_BLOCKING_CHECK_CONCLUSIONS:
|
|
495
|
+
continue
|
|
496
|
+
else:
|
|
497
|
+
blocking.append(name)
|
|
498
|
+
if blocking:
|
|
499
|
+
return "blocked", sorted(set(blocking))
|
|
500
|
+
if pending:
|
|
501
|
+
return "waiting", sorted(set(pending))
|
|
502
|
+
return "green", []
|
|
503
|
+
|
|
504
|
+
def _classify_all_runs(self, sha):
|
|
505
|
+
"""Every current-head check and workflow run, regardless of requirement.
|
|
506
|
+
|
|
507
|
+
The conservative fallback used when the required-check set cannot be
|
|
508
|
+
read. Check runs are the primary signal, but a workflow can fail before
|
|
509
|
+
creating any check run, so workflow runs whose suites reported no check
|
|
510
|
+
runs are considered too.
|
|
511
|
+
"""
|
|
512
|
+
check_runs = self.check_runs(sha)
|
|
513
|
+
reporters = (
|
|
514
|
+
self._latest_check_runs(sha, check_runs)
|
|
515
|
+
+ self._latest_workflow_runs(sha, check_runs)
|
|
516
|
+
)
|
|
517
|
+
return self._classify_runs(reporters)
|
|
518
|
+
|
|
519
|
+
def _classify_required_runs(self, sha, required):
|
|
520
|
+
"""Classify only the required current-head checks.
|
|
521
|
+
|
|
522
|
+
`required` is the required contexts GitHub reports for the pull request.
|
|
523
|
+
Only a required check decides the scheduled gate, so an optional workflow
|
|
524
|
+
that fails before creating any check run - and whose context GitHub does
|
|
525
|
+
not mark required - cannot block a head whose required checks pass. A
|
|
526
|
+
required context that reported no current-head check run, such as a
|
|
527
|
+
required workflow that failed before any job, is expected but unfulfilled
|
|
528
|
+
and classifies as waiting.
|
|
529
|
+
"""
|
|
530
|
+
check_runs = self.check_runs(sha)
|
|
531
|
+
latest = {
|
|
532
|
+
(run.get("name") or "unnamed check"): run
|
|
533
|
+
for run in self._latest_check_runs(sha, check_runs)
|
|
534
|
+
}
|
|
535
|
+
reporters = []
|
|
536
|
+
for context in required:
|
|
537
|
+
name = context["name"]
|
|
538
|
+
if context.get("kind") == "StatusContext":
|
|
539
|
+
state = self.statuses(sha).get(name)
|
|
540
|
+
reporters.append(
|
|
541
|
+
{
|
|
542
|
+
"name": name,
|
|
543
|
+
"status": "completed" if state else "expected",
|
|
544
|
+
"conclusion": state,
|
|
545
|
+
}
|
|
546
|
+
)
|
|
547
|
+
continue
|
|
548
|
+
reporters.append(latest.get(name, {"name": name, "status": "expected"}))
|
|
549
|
+
return self._classify_runs(reporters)
|
|
550
|
+
|
|
551
|
+
def _classify_check_runs(self, pr):
|
|
552
|
+
"""Split a pull request head into blocked, waiting, or green.
|
|
553
|
+
|
|
554
|
+
Scheduled discovery classifies only GitHub-required checks, because an
|
|
555
|
+
optional workflow must not block the merge gate. The required set is read
|
|
556
|
+
per pull request; when it is unavailable or empty the classifier falls
|
|
557
|
+
back to every current-head check and workflow run, so a red head still
|
|
558
|
+
blocks. The exact-head, latest-run, and fail-closed behavior is identical
|
|
559
|
+
either way.
|
|
560
|
+
"""
|
|
561
|
+
sha = pr["head"]["sha"]
|
|
562
|
+
try:
|
|
563
|
+
required = self.required_check_contexts(pr["number"])
|
|
564
|
+
except Exception as exc: # noqa: BLE001 - fail closed to the full rollup
|
|
565
|
+
print(
|
|
566
|
+
json.dumps(
|
|
567
|
+
{
|
|
568
|
+
"repository": self.repository,
|
|
569
|
+
"pr": pr["number"],
|
|
570
|
+
"head_sha": sha,
|
|
571
|
+
"required_check_signal": "unavailable",
|
|
572
|
+
"error": type(exc).__name__,
|
|
573
|
+
}
|
|
574
|
+
),
|
|
575
|
+
flush=True,
|
|
576
|
+
)
|
|
577
|
+
return self._classify_all_runs(sha)
|
|
578
|
+
if not required:
|
|
579
|
+
# No required checks configured is not a red head, but it is also not
|
|
580
|
+
# evidence the head is reviewable, so fall back to the full rollup.
|
|
581
|
+
return self._classify_all_runs(sha)
|
|
582
|
+
return self._classify_required_runs(sha, required)
|
|
583
|
+
|
|
584
|
+
def _owns_comment(self, comment):
|
|
585
|
+
login = ((comment.get("user") or {}).get("login") or "").lower()
|
|
586
|
+
return login == self.github_login.lower()
|
|
587
|
+
|
|
588
|
+
@staticmethod
|
|
589
|
+
def _explains_checks(body, names):
|
|
590
|
+
"""Whether a deterministic comment names every check the gate reports."""
|
|
591
|
+
lowered = body.lower()
|
|
592
|
+
return all(name.lower() in lowered for name in names)
|
|
593
|
+
|
|
594
|
+
def _gate_comment(self, number, marker, body, names):
|
|
595
|
+
"""Post one gate explanation, upserting the automation's own comment.
|
|
596
|
+
|
|
597
|
+
The marker carries the head SHA and gate category, so a later run for a
|
|
598
|
+
different head updates the comment it owns instead of stacking another
|
|
599
|
+
one. A run that finds the same marker still compares the full body, so a
|
|
600
|
+
comment written for one deployment is reworded in place when the retry it
|
|
601
|
+
names changes -- an event-only gate comment becomes the scheduled one when
|
|
602
|
+
the automation is switched to cron -- and an unchanged body is a no-op.
|
|
603
|
+
Every PR comment is untrusted input: only a marker this account authored
|
|
604
|
+
is "managed", so a marker someone else placed can neither suppress the
|
|
605
|
+
explanation nor be edited. An unmarked deterministic comment an existing
|
|
606
|
+
repository workflow already posted is treated as the equivalent
|
|
607
|
+
explanation only when it names the same reported checks; a comment about
|
|
608
|
+
some other check is left alone and the gate posts its own.
|
|
609
|
+
"""
|
|
610
|
+
comments = self.gh_pages(f"/issues/{number}/comments")
|
|
611
|
+
managed = [
|
|
612
|
+
comment
|
|
613
|
+
for comment in comments
|
|
614
|
+
if CHECK_GATE_MARKER in (comment.get("body") or "")
|
|
615
|
+
and self._owns_comment(comment)
|
|
616
|
+
]
|
|
617
|
+
matching = [
|
|
618
|
+
comment for comment in managed if marker in (comment.get("body") or "")
|
|
619
|
+
]
|
|
620
|
+
if matching:
|
|
621
|
+
target = max(matching, key=lambda comment: int(comment["id"]))
|
|
622
|
+
if (target.get("body") or "").strip() == body.strip():
|
|
623
|
+
return None
|
|
624
|
+
self.gh("PATCH", f"/issues/comments/{target['id']}", {"body": body})
|
|
625
|
+
return target["id"]
|
|
626
|
+
if managed:
|
|
627
|
+
target = max(managed, key=lambda comment: int(comment["id"]))
|
|
628
|
+
self.gh("PATCH", f"/issues/comments/{target['id']}", {"body": body})
|
|
629
|
+
return target["id"]
|
|
630
|
+
if any(
|
|
631
|
+
WORKFLOW_DISCLOSURE.lower() in (comment.get("body") or "").lower()
|
|
632
|
+
and self._explains_checks(comment.get("body") or "", names)
|
|
633
|
+
for comment in comments
|
|
634
|
+
if CHECK_GATE_MARKER not in (comment.get("body") or "")
|
|
635
|
+
):
|
|
636
|
+
return None
|
|
637
|
+
created = self.gh("POST", f"/issues/{number}/comments", {"body": body})
|
|
638
|
+
return created.get("id")
|
|
639
|
+
|
|
640
|
+
def _gate_body(self, sha, state, names, scheduled):
|
|
641
|
+
"""Explain a stop, naming the retry this deployment actually has.
|
|
642
|
+
|
|
643
|
+
A scheduled run proves a scan is configured, so it may promise the next
|
|
644
|
+
scan. An event run does not, so it names the one mechanism this
|
|
645
|
+
deployment is guaranteed to honor: another native review request. GitHub
|
|
646
|
+
refuses to request a reviewer who is already requested, so an event-only
|
|
647
|
+
deployment must say that the outstanding request has to be removed and
|
|
648
|
+
re-requested before the review can start.
|
|
649
|
+
"""
|
|
650
|
+
short = sha[:12]
|
|
651
|
+
listed = "\n".join(f"- `{name}`" for name in names)
|
|
652
|
+
if state == "blocked":
|
|
653
|
+
heading = "### ⚠️ Review paused: current-head checks failed"
|
|
654
|
+
lead = (
|
|
655
|
+
f"The current head `{short}` has failing checks, so no review "
|
|
656
|
+
"conversation was started:"
|
|
657
|
+
)
|
|
658
|
+
action = (
|
|
659
|
+
"Fix the checks above and push. The scheduled scan then starts "
|
|
660
|
+
"the review on the updated head."
|
|
661
|
+
if scheduled
|
|
662
|
+
else "Fix the checks above and push. Then remove the outstanding "
|
|
663
|
+
f"`{self.trigger_reviewer}` request and request "
|
|
664
|
+
f"`{self.trigger_reviewer}` again: GitHub will not accept a "
|
|
665
|
+
"second request while the first is still outstanding. The review "
|
|
666
|
+
"starts on the updated head."
|
|
667
|
+
)
|
|
668
|
+
else:
|
|
669
|
+
heading = "### ⏳ Review waiting on checks"
|
|
670
|
+
lead = (
|
|
671
|
+
f"The current head `{short}` still has checks that have not "
|
|
672
|
+
"finished, so no review conversation was started:"
|
|
673
|
+
)
|
|
674
|
+
action = (
|
|
675
|
+
"No action is needed. The scheduled scan retries once every "
|
|
676
|
+
"check on the head reports a conclusion."
|
|
677
|
+
if scheduled
|
|
678
|
+
else "Once every check on the head reports a conclusion, remove "
|
|
679
|
+
f"the outstanding `{self.trigger_reviewer}` request and request "
|
|
680
|
+
f"`{self.trigger_reviewer}` again: GitHub will not accept a "
|
|
681
|
+
"second request while the first is still outstanding. That "
|
|
682
|
+
"starts the review."
|
|
683
|
+
)
|
|
684
|
+
return (
|
|
685
|
+
f"{heading}\n\n{lead}\n\n{listed}\n\n{action}\n\n"
|
|
686
|
+
f"{CHECK_GATE_MARKER}{state}:{sha} -->\n\n"
|
|
687
|
+
f"_This is an automated check - {WORKFLOW_DISCLOSURE}._"
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
def _gate_head(self, pr, requested=False, scheduled=False, explain=True):
|
|
691
|
+
"""Return the head's eligibility, explaining any stop on the PR.
|
|
692
|
+
|
|
693
|
+
An explicit `all-hands-bot` review request is the intake-policy
|
|
694
|
+
exception: the caller asked for this head by name, so the CI gate does
|
|
695
|
+
not apply and no gate comment is left. Scheduled discovery still gates on
|
|
696
|
+
the required checks. When it stops a scheduled run, the explanation names
|
|
697
|
+
the retry that deployment actually has, which is why `scheduled` is
|
|
698
|
+
threaded into the body.
|
|
699
|
+
|
|
700
|
+
`explain` is False for an unrequested candidate: a PR nobody asked about
|
|
701
|
+
that is merely red or pending gets no managed comment. Announcing a
|
|
702
|
+
blocked or waiting head for every open PR is what produced the comment
|
|
703
|
+
storm that full-backlog scans must avoid, and the managed comment is
|
|
704
|
+
the answer to an explicit request or trigger label. The gate still
|
|
705
|
+
classifies the head, so a red or pending unrequested head is skipped
|
|
706
|
+
without starting an agent.
|
|
707
|
+
"""
|
|
708
|
+
sha = pr["head"]["sha"]
|
|
709
|
+
if requested:
|
|
710
|
+
return "green", sha
|
|
711
|
+
state, names = self._classify_check_runs(pr)
|
|
712
|
+
if explain and state in ("blocked", "waiting"):
|
|
713
|
+
marker = f"{CHECK_GATE_MARKER}{state}:{sha} -->"
|
|
714
|
+
self._gate_comment(
|
|
715
|
+
pr["number"],
|
|
716
|
+
marker,
|
|
717
|
+
self._gate_body(sha, state, names, scheduled),
|
|
718
|
+
names,
|
|
719
|
+
)
|
|
720
|
+
return state, sha
|
|
721
|
+
|
|
722
|
+
def _outstanding_review_request(self, pr):
|
|
723
|
+
"""Whether an open PR, including a draft, still requests this reviewer.
|
|
724
|
+
|
|
725
|
+
The list endpoint already answers this: `requested_reviewers` is the live
|
|
726
|
+
set, so a review that was submitted, or a request that was withdrawn, is
|
|
727
|
+
simply absent. An explicit request opts a draft into review.
|
|
728
|
+
"""
|
|
729
|
+
return any(
|
|
730
|
+
(item.get("login") or "").lower() == self.trigger_reviewer
|
|
731
|
+
for item in pr.get("requested_reviewers") or []
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
def _has_current_head_review(self, number, sha):
|
|
735
|
+
"""Whether the reviewer account already published a review on this head.
|
|
736
|
+
|
|
737
|
+
This is the candidate filter's negative: an open, non-draft PR without a
|
|
738
|
+
current-head review by the configured reviewer is eligible for a
|
|
739
|
+
scheduled scan even when nobody requested the bot. The same predicate is
|
|
740
|
+
what the completion handler reconciles, so a review this scan starts and
|
|
741
|
+
a review it finds already present are the same set.
|
|
742
|
+
"""
|
|
743
|
+
return any(
|
|
744
|
+
review.get("commit_id") == sha
|
|
745
|
+
and ((review.get("user") or {}).get("login") or "").lower()
|
|
746
|
+
== self.github_login.lower()
|
|
747
|
+
for review in self.gh_pages(f"/pulls/{number}/reviews")
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
def _clarified_since(self, number, submitted_at):
|
|
751
|
+
"""Whether a human clarified the pull request after a review.
|
|
752
|
+
|
|
753
|
+
A comment by someone other than the reviewer accounts is the one thing
|
|
754
|
+
that makes reviewing an unchanged head worth another conversation: the
|
|
755
|
+
author answered a finding, so the earlier verdict no longer speaks to
|
|
756
|
+
the current state. Any other account (an author, a maintainer, a
|
|
757
|
+
different bot workflow posting a new result) counts, and the reviewer
|
|
758
|
+
account's own comments are excluded so its "reviewing now" and verdict
|
|
759
|
+
comments cannot re-trigger it.
|
|
760
|
+
"""
|
|
761
|
+
if not submitted_at:
|
|
762
|
+
return False
|
|
763
|
+
for comment in self.gh_pages(f"/issues/{number}/comments"):
|
|
764
|
+
login = ((comment.get("user") or {}).get("login") or "").lower()
|
|
765
|
+
if login in {"", self.github_login.lower(), self.trigger_reviewer}:
|
|
766
|
+
continue
|
|
767
|
+
if (comment.get("created_at") or "") > submitted_at:
|
|
768
|
+
return True
|
|
769
|
+
return False
|
|
770
|
+
|
|
771
|
+
def _reviewed_current_head_without_clarification(self, number, sha):
|
|
772
|
+
"""Whether the current head is already reviewed and nothing has changed.
|
|
773
|
+
|
|
774
|
+
A review on this exact head, followed by no clarifying comment from
|
|
775
|
+
anyone else, means another conversation would publish a second review of
|
|
776
|
+
the same code — the same verdict twice, spending a runtime to repeat it.
|
|
777
|
+
The head advancing, or any human comment arriving afterwards, makes the
|
|
778
|
+
head eligible again under the normal rules.
|
|
779
|
+
|
|
780
|
+
Returns the reviewed head's latest submission time (for the log line) or
|
|
781
|
+
None when the head should be reviewed.
|
|
782
|
+
"""
|
|
783
|
+
if not sha:
|
|
784
|
+
return None
|
|
785
|
+
submitted_at = ""
|
|
786
|
+
for review in self.gh_pages(f"/pulls/{number}/reviews"):
|
|
787
|
+
if review.get("commit_id") != sha:
|
|
788
|
+
continue
|
|
789
|
+
if ((review.get("user") or {}).get("login") or "").lower() != (
|
|
790
|
+
self.github_login.lower()
|
|
791
|
+
):
|
|
792
|
+
continue
|
|
793
|
+
# A dismissal removes the review's standing, exactly as
|
|
794
|
+
# maintainer_handoff._reviewer_states treats it: a maintainer who
|
|
795
|
+
# dismisses this account's verdict and re-requests a review on the
|
|
796
|
+
# same head is asking for a fresh look, not repeating a completed
|
|
797
|
+
# one. Keeping the dismissed review here would suppress that review
|
|
798
|
+
# permanently, because nothing else makes an unchanged head eligible.
|
|
799
|
+
if (review.get("state") or "").upper() == "DISMISSED":
|
|
800
|
+
continue
|
|
801
|
+
when = review.get("submitted_at") or ""
|
|
802
|
+
if when > submitted_at:
|
|
803
|
+
submitted_at = when
|
|
804
|
+
if not submitted_at:
|
|
805
|
+
return None
|
|
806
|
+
if self._clarified_since(number, submitted_at):
|
|
807
|
+
return None
|
|
808
|
+
return submitted_at
|
|
809
|
+
|
|
810
|
+
def _unrequested_head(self, pr):
|
|
811
|
+
"""The current-head delivery key for an unrequested PR, or None.
|
|
812
|
+
|
|
813
|
+
The key is the repository/PR identity plus the head SHA, so repeated
|
|
814
|
+
scheduled scans over one head reuse one conversation and one native
|
|
815
|
+
review, while a changed head becomes eligible again under its new SHA.
|
|
816
|
+
"""
|
|
817
|
+
sha = pr["head"]["sha"]
|
|
818
|
+
return f"scan:{self.repository}:{pr['number']}:{sha}"
|
|
819
|
+
|
|
820
|
+
def _scan_candidates(self, prs, label):
|
|
821
|
+
"""Return every potentially reviewable PR in the scheduled backlog.
|
|
822
|
+
|
|
823
|
+
The scan must classify the whole backlog so blocked, pending, draft, or
|
|
824
|
+
already-reviewed heads cannot hide eligible heads behind an inspection
|
|
825
|
+
window. The shared ReviewIntake applies the configured maximum only when
|
|
826
|
+
conversations are launched.
|
|
827
|
+
"""
|
|
828
|
+
candidates = []
|
|
829
|
+
for pr in prs:
|
|
830
|
+
labels = {item["name"] for item in pr.get("labels", [])}
|
|
831
|
+
if (
|
|
832
|
+
pr.get("draft")
|
|
833
|
+
and label not in labels
|
|
834
|
+
and not self._outstanding_review_request(pr)
|
|
835
|
+
):
|
|
836
|
+
continue
|
|
837
|
+
candidates.append(pr)
|
|
838
|
+
return candidates
|
|
839
|
+
|
|
214
840
|
def run(self):
|
|
215
841
|
repository_id = self.gh("GET", "")["id"]
|
|
216
842
|
label = self.config.get("trigger_label", workflow.TRIGGER_LABEL)
|
|
217
843
|
payload = self._event_payload()
|
|
218
|
-
|
|
219
|
-
|
|
844
|
+
event_mode = payload is not None
|
|
845
|
+
if not event_mode:
|
|
846
|
+
prs = self._scan_candidates(
|
|
847
|
+
self.gh_pages("/pulls?state=open&sort=updated&direction=asc"), label
|
|
848
|
+
)
|
|
220
849
|
else:
|
|
221
850
|
candidate = self._event_candidate(payload)
|
|
222
851
|
if candidate and self.github_login.lower() != self.trigger_reviewer:
|
|
@@ -227,24 +856,88 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
227
856
|
prs = [candidate] if candidate else []
|
|
228
857
|
failures = []
|
|
229
858
|
for candidate in prs:
|
|
230
|
-
event_mode = payload is not None
|
|
231
|
-
if not event_mode and label not in {
|
|
232
|
-
item["name"] for item in candidate.get("labels", [])
|
|
233
|
-
}:
|
|
234
|
-
continue
|
|
235
859
|
try:
|
|
236
860
|
pr = self.gh("GET", f"/pulls/{candidate['number']}")
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
861
|
+
has_label = label in {
|
|
862
|
+
item["name"] for item in pr.get("labels", [])
|
|
863
|
+
}
|
|
864
|
+
requested = self._outstanding_review_request(pr)
|
|
865
|
+
trigger_label = label if (not event_mode and has_label) else None
|
|
866
|
+
unrequested_candidate = False
|
|
867
|
+
if event_mode or has_label:
|
|
868
|
+
trigger = (
|
|
869
|
+
self._latest_reviewer_request(pr["number"])
|
|
870
|
+
if event_mode
|
|
871
|
+
else workflow._latest_trigger_label_event(
|
|
872
|
+
self.token, self.repository, pr["number"]
|
|
873
|
+
)
|
|
242
874
|
)
|
|
875
|
+
delivery_key = None
|
|
876
|
+
elif requested:
|
|
877
|
+
# The outstanding request is the trigger, so its own event
|
|
878
|
+
# keys the delivery and dedupes repeated scans.
|
|
879
|
+
trigger = self._latest_reviewer_request(pr["number"])
|
|
880
|
+
delivery_key = None
|
|
881
|
+
else:
|
|
882
|
+
# No label and no outstanding request: an unrequested PR the
|
|
883
|
+
# scheduled scan reviews on its own, keyed by repository/PR/
|
|
884
|
+
# head. A draft is not reviewable, and a head this account
|
|
885
|
+
# already reviewed is done - reconcile that review's verdict
|
|
886
|
+
# and maintainer handoff, then skip it so no second
|
|
887
|
+
# conversation or review is created.
|
|
888
|
+
if pr.get("draft"):
|
|
889
|
+
continue
|
|
890
|
+
if self._has_current_head_review(pr["number"], pr["head"]["sha"]):
|
|
891
|
+
self._finish_completed_review(pr, None)
|
|
892
|
+
continue
|
|
893
|
+
trigger = None
|
|
894
|
+
delivery_key = self._unrequested_head(pr)
|
|
895
|
+
unrequested_candidate = True
|
|
896
|
+
if trigger is None and delivery_key is None:
|
|
897
|
+
continue
|
|
898
|
+
# A head this account already reviewed, with no clarifying
|
|
899
|
+
# comment since, is done: another conversation would publish a
|
|
900
|
+
# second review of identical code. This must come before the
|
|
901
|
+
# delivery dispatch, because a fresh trigger (a new review
|
|
902
|
+
# request, or a label re-applied after the bot's own handoff)
|
|
903
|
+
# produces a new delivery key that would otherwise start a
|
|
904
|
+
# second review on an unchanged head.
|
|
905
|
+
already = self._reviewed_current_head_without_clarification(
|
|
906
|
+
pr["number"], pr["head"]["sha"]
|
|
243
907
|
)
|
|
244
|
-
if
|
|
908
|
+
if already:
|
|
909
|
+
print(
|
|
910
|
+
json.dumps(
|
|
911
|
+
{
|
|
912
|
+
"repository": self.repository,
|
|
913
|
+
"pr": pr["number"],
|
|
914
|
+
"head_sha": pr["head"]["sha"],
|
|
915
|
+
"disposition": "review-already-published",
|
|
916
|
+
"reviewed_at": already,
|
|
917
|
+
}
|
|
918
|
+
),
|
|
919
|
+
flush=True,
|
|
920
|
+
)
|
|
921
|
+
if trigger is not None:
|
|
922
|
+
# Reconcile the completed review (clearing the label and
|
|
923
|
+
# running the maintainer handoff) where the review is
|
|
924
|
+
# this trigger's result. When it predates the trigger -
|
|
925
|
+
# the #698 ordering, a re-applied label - there is no
|
|
926
|
+
# completion to reconcile, so clear the label directly:
|
|
927
|
+
# otherwise the label stays and every later scan re-reads
|
|
928
|
+
# this head and re-logs with no effect.
|
|
929
|
+
if not self._finish_completed_review(
|
|
930
|
+
pr, trigger, trigger_label
|
|
931
|
+
) and trigger_label:
|
|
932
|
+
self.gh(
|
|
933
|
+
"DELETE",
|
|
934
|
+
f"/issues/{pr['number']}/labels/"
|
|
935
|
+
f"{quote(trigger_label, safe='')}",
|
|
936
|
+
)
|
|
245
937
|
continue
|
|
246
|
-
|
|
247
|
-
|
|
938
|
+
if delivery_key is None and self._finish_completed_review(
|
|
939
|
+
pr, trigger, trigger_label
|
|
940
|
+
):
|
|
248
941
|
continue
|
|
249
942
|
if event_mode and payload.get("action") == "submitted":
|
|
250
943
|
# A submitted review is a completion signal, never a fresh
|
|
@@ -253,23 +946,64 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
253
946
|
# dispatching a review the caller never asked for.
|
|
254
947
|
continue
|
|
255
948
|
sha = pr["head"]["sha"]
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
949
|
+
gate_state, sha = self._gate_head(
|
|
950
|
+
pr,
|
|
951
|
+
requested=event_mode,
|
|
952
|
+
scheduled=not event_mode,
|
|
953
|
+
explain=not unrequested_candidate,
|
|
260
954
|
)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
955
|
+
if gate_state != "green":
|
|
956
|
+
# A deterministic blocker stops the run without spending a
|
|
957
|
+
# worker slot on an agent. The trigger is not consumed: the
|
|
958
|
+
# next scheduled scan or explicit request re-evaluates the
|
|
959
|
+
# head once its checks are non-blocking. An unrequested head
|
|
960
|
+
# that is merely red or pending gets no managed comment, so a
|
|
961
|
+
# scan over a large backlog cannot storm the PRs with gate
|
|
962
|
+
# comments; the managed comment answers an explicit request.
|
|
963
|
+
print(
|
|
964
|
+
json.dumps(
|
|
965
|
+
{
|
|
966
|
+
"repository": self.repository,
|
|
967
|
+
"pr": pr["number"],
|
|
968
|
+
"head_sha": sha,
|
|
969
|
+
"disposition": f"review-{gate_state}",
|
|
970
|
+
}
|
|
971
|
+
),
|
|
972
|
+
flush=True,
|
|
973
|
+
)
|
|
974
|
+
continue
|
|
975
|
+
record = {
|
|
976
|
+
"repository": self.repository,
|
|
977
|
+
"number": pr["number"],
|
|
978
|
+
# An explicit request is ordered before an unrequested
|
|
979
|
+
# candidate; within a priority the oldest candidate drains
|
|
980
|
+
# first - the request time for a requested PR, the PR's own
|
|
981
|
+
# creation time (oldest first) for an unrequested one - and
|
|
982
|
+
# repository and number break a tie so the order is
|
|
983
|
+
# deterministic.
|
|
984
|
+
"priority": 0 if (has_label or requested) else 1,
|
|
985
|
+
"created_at": (
|
|
986
|
+
trigger.get("created_at") or ""
|
|
987
|
+
if trigger
|
|
988
|
+
else pr.get("created_at") or ""
|
|
270
989
|
),
|
|
271
|
-
|
|
272
|
-
|
|
990
|
+
"config": self.config,
|
|
991
|
+
"start": lambda pr=pr, trigger=trigger, sha=sha,
|
|
992
|
+
trigger_label=trigger_label,
|
|
993
|
+
delivery_key=delivery_key: self._start_review(
|
|
994
|
+
repository_id, pr, trigger, sha, trigger_label, delivery_key
|
|
995
|
+
),
|
|
996
|
+
}
|
|
997
|
+
if event_mode:
|
|
998
|
+
# An explicit request is a caller's decision to spend a
|
|
999
|
+
# conversation now, not a backlog item, so the event path
|
|
1000
|
+
# dispatches immediately and is never bounded by the
|
|
1001
|
+
# scheduled scan's per-run maximum.
|
|
1002
|
+
self._start_review(
|
|
1003
|
+
repository_id, pr, trigger, sha, trigger_label, delivery_key
|
|
1004
|
+
)
|
|
1005
|
+
else:
|
|
1006
|
+
self.intake.register(record)
|
|
273
1007
|
except Exception as exc: # noqa: BLE001 - one PR must not block the scan
|
|
274
1008
|
failures.append(candidate.get("number", "?"))
|
|
275
1009
|
print(
|
|
@@ -279,13 +1013,64 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
279
1013
|
file=sys.stderr,
|
|
280
1014
|
flush=True,
|
|
281
1015
|
)
|
|
1016
|
+
if self.scan_intake is None:
|
|
1017
|
+
# A run() with no shared scan intake owns its whole scan, so it
|
|
1018
|
+
# drains the candidates it collected, bounded by the per-run maximum.
|
|
1019
|
+
self.intake.drain()
|
|
282
1020
|
if failures:
|
|
283
1021
|
raise RuntimeError(
|
|
284
1022
|
"Reviewer scan failed for PRs: "
|
|
285
1023
|
+ ", ".join(f"#{number}" for number in failures)
|
|
286
1024
|
)
|
|
287
1025
|
|
|
1026
|
+
def _start_review(
|
|
1027
|
+
self, repository_id, pr, trigger, sha, trigger_label, delivery_key=None
|
|
1028
|
+
):
|
|
1029
|
+
result = self.dispatcher.deliver(
|
|
1030
|
+
subject=f"{repository_id}:pr:{pr['number']}",
|
|
1031
|
+
delivery=delivery_key or f"{trigger['id']}:{sha}",
|
|
1032
|
+
prompt=self._prompt(pr, trigger, trigger_label, delivery_key),
|
|
1033
|
+
head=sha,
|
|
1034
|
+
)
|
|
1035
|
+
print(
|
|
1036
|
+
json.dumps(
|
|
1037
|
+
{
|
|
1038
|
+
"repository": self.repository,
|
|
1039
|
+
"pr": pr["number"],
|
|
1040
|
+
"head_sha": sha,
|
|
1041
|
+
"disposition": result["disposition"],
|
|
1042
|
+
"conversation_id": result["conversation_id"],
|
|
1043
|
+
}
|
|
1044
|
+
),
|
|
1045
|
+
flush=True,
|
|
1046
|
+
)
|
|
1047
|
+
return result
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def run_scan(dispatcher):
|
|
1051
|
+
"""Run one scheduled scan over every configured repository, then drain.
|
|
1052
|
+
|
|
1053
|
+
One shared intake spans the whole scan, so the per-run maximum is global
|
|
1054
|
+
rather than reset per repository, and the drain happens after every
|
|
1055
|
+
repository has been scanned so the oldest outstanding request across all of
|
|
1056
|
+
them starts first. One repository failing must not discard another
|
|
1057
|
+
repository's drained candidates, so the drain still runs and the first
|
|
1058
|
+
failure is what the scan reports.
|
|
1059
|
+
"""
|
|
1060
|
+
PullRequestReviewer.scan_intake = ReviewIntake()
|
|
1061
|
+
failure = None
|
|
1062
|
+
try:
|
|
1063
|
+
run_repositories(PullRequestReviewer, dispatcher=dispatcher)
|
|
1064
|
+
except Exception as exc: # noqa: BLE001 - reported after the drain
|
|
1065
|
+
failure = exc
|
|
1066
|
+
try:
|
|
1067
|
+
PullRequestReviewer.scan_intake.drain()
|
|
1068
|
+
except Exception as exc: # noqa: BLE001 - reported after the scan failure
|
|
1069
|
+
failure = failure or exc
|
|
1070
|
+
if failure is not None:
|
|
1071
|
+
raise failure
|
|
1072
|
+
|
|
288
1073
|
|
|
289
1074
|
if __name__ == "__main__":
|
|
290
1075
|
with AgentConversationDispatcher() as dispatcher:
|
|
291
|
-
|
|
1076
|
+
run_scan(dispatcher)
|