@openhands/extensions 0.24.0 → 0.25.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 +6 -0
- package/.codex-plugin/marketplace.json +6 -0
- package/.plugin/marketplace.json +6 -0
- package/.release-please-manifest.json +1 -1
- package/README.md +3 -2
- package/automations/bundle-index.js +11 -7
- 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-index.js +2 -0
- package/marketplaces/openhands-extensions.json +6 -0
- package/package.json +1 -1
- 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/vulnerability-remediation/skills/github/scripts/github_client.py +116 -0
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/github/scripts/github_client.py +116 -0
- package/skills/github-agents-md-maintainer/scripts/github_client.py +116 -0
- package/skills/github-delivery-watchdog/scripts/github_client.py +116 -0
- package/skills/github-issue-to-pr/scripts/github_client.py +116 -0
- package/skills/github-issue-triage/scripts/github_client.py +116 -0
- package/skills/github-pr-review/SKILL.md +12 -2
- package/skills/github-pr-reviewer/README.md +35 -0
- package/skills/github-pr-reviewer/SKILL.md +156 -4
- package/skills/github-pr-reviewer/references/state-schema.md +15 -0
- package/skills/github-pr-reviewer/scripts/github_client.py +116 -0
- package/skills/github-pr-reviewer/scripts/main.py +17 -1
- package/skills/github-pr-reviewer/scripts/worker.py +820 -58
- package/skills/github-repo-monitor/scripts/github_client.py +116 -0
- 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 +335 -0
- package/skills/github-stale-ci-pr-closer/tests/test_worker.py +213 -0
- package/skills/index.js +12 -3
- package/skills/openhands-sdk/SKILL.md +1 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
- 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 +2108 -3
|
@@ -15,9 +15,177 @@ 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
|
+
# How many unrequested heads one scheduled scan examines, per repository. Each
|
|
31
|
+
# one costs a review read plus the exact-head check/workflow reads, so an
|
|
32
|
+
# unbounded scan spends the whole run's API budget in the largest repository.
|
|
33
|
+
# The window rotates by this many PRs per scan, so the backlog is still covered.
|
|
34
|
+
SCAN_WINDOW = 10
|
|
35
|
+
# The gate is deterministic; this disclosure is what tells a reader no model ran.
|
|
36
|
+
WORKFLOW_DISCLOSURE = "no AI was used to generate this comment"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ReviewIntake:
|
|
40
|
+
"""The per-scan bound on new review conversations, shared across repositories.
|
|
41
|
+
|
|
42
|
+
A scheduled scan drains every eligible pull request, but starting an agent
|
|
43
|
+
for each one at once exhausted the OSS Agent Canvas VM, so a small maximum
|
|
44
|
+
caps what a single scan may start. The maximum is per scan and shared by
|
|
45
|
+
every configured repository rather than reset per repository, and it bounds
|
|
46
|
+
the conversations a scan *starts*: a delivery that only deduplicates, or
|
|
47
|
+
reports an already-running conversation, reuses a runtime and consumes no
|
|
48
|
+
slot. Candidates are drained oldest reviewer-request first, then by
|
|
49
|
+
repository and pull-request number, so a scan over more candidates than the
|
|
50
|
+
maximum allows starts the oldest and a later scan reaches the remainder.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self):
|
|
54
|
+
# The budget lives on the intake so a scan that drains repository by
|
|
55
|
+
# repository still counts its conversations against one shared maximum.
|
|
56
|
+
self._pending = []
|
|
57
|
+
self._maximum = None
|
|
58
|
+
self._started = 0
|
|
59
|
+
|
|
60
|
+
def register(self, record):
|
|
61
|
+
"""Queue one eligible candidate for this scan's bounded drain.
|
|
62
|
+
|
|
63
|
+
`record["priority"]` is 0 for an explicit `all-hands-bot` request and 1
|
|
64
|
+
for an unrequested eligible PR, so explicit requests drain first.
|
|
65
|
+
"""
|
|
66
|
+
self._pending.append(record)
|
|
67
|
+
|
|
68
|
+
def drain(self):
|
|
69
|
+
"""Start the oldest pending conversations, up to the per-scan maximum.
|
|
70
|
+
|
|
71
|
+
The budget is held on the intake, so a scan that drains repository by
|
|
72
|
+
repository still counts its conversations against one shared maximum. A
|
|
73
|
+
candidate whose dispatch raises is reported and skipped without
|
|
74
|
+
consuming a slot, so the candidates behind it are still considered.
|
|
75
|
+
"""
|
|
76
|
+
pending, self._pending = self._pending, []
|
|
77
|
+
if not pending:
|
|
78
|
+
return
|
|
79
|
+
if self._maximum is None:
|
|
80
|
+
self._maximum = pending[0]["config"].get(
|
|
81
|
+
"max_new_per_run", workflow.MAX_NEW_PER_RUN
|
|
82
|
+
)
|
|
83
|
+
failures = []
|
|
84
|
+
for record in sorted(
|
|
85
|
+
pending,
|
|
86
|
+
key=lambda item: (
|
|
87
|
+
item["priority"],
|
|
88
|
+
item["created_at"],
|
|
89
|
+
item["repository"],
|
|
90
|
+
item["number"],
|
|
91
|
+
),
|
|
92
|
+
):
|
|
93
|
+
if self._started >= self._maximum:
|
|
94
|
+
break
|
|
95
|
+
try:
|
|
96
|
+
result = record["start"]()
|
|
97
|
+
except Exception as exc: # noqa: BLE001 - one PR must not block the scan
|
|
98
|
+
failures.append(record["number"])
|
|
99
|
+
print(
|
|
100
|
+
f"Failed to submit {record['repository']} PR "
|
|
101
|
+
f"#{record['number']}: {type(exc).__name__}: {exc}",
|
|
102
|
+
file=sys.stderr,
|
|
103
|
+
flush=True,
|
|
104
|
+
)
|
|
105
|
+
continue
|
|
106
|
+
if result["disposition"] == "created":
|
|
107
|
+
self._started += 1
|
|
108
|
+
if failures:
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
"Reviewer scan failed for PRs: "
|
|
111
|
+
+ ", ".join(f"#{number}" for number in failures)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ScanCursor:
|
|
116
|
+
"""The per-repository position of the rotating unrequested-PR window.
|
|
117
|
+
|
|
118
|
+
Classifying one unrequested head costs a review read, a check-run read, and
|
|
119
|
+
a workflow-run read, so a scan that examined every open PR spent the API
|
|
120
|
+
budget on the largest repository alone. The scan instead examines a bounded
|
|
121
|
+
slice of the unrequested backlog and remembers where the slice ended, in the
|
|
122
|
+
automation service's own KV store - the same facility `main.py` already uses
|
|
123
|
+
for review state, reached with the same `AUTOMATION_KV_TOKEN` and
|
|
124
|
+
`AUTOMATION_API_URL`, so no new secret or store is introduced. The next scan
|
|
125
|
+
resumes past that point, so the whole backlog is covered fairly over several
|
|
126
|
+
scans.
|
|
127
|
+
|
|
128
|
+
When the KV store is unavailable (a local run, or the tests) the position is
|
|
129
|
+
kept in memory, so the scan still rotates within the run and nothing else
|
|
130
|
+
about it changes. A KV read or write failure is not fatal: a scan position is
|
|
131
|
+
not worth aborting reviews over, so the in-memory position is used instead.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(self, repository):
|
|
135
|
+
self._key = f"review-scan:{workflow._repo_slug(repository)}"
|
|
136
|
+
self._memory = 0
|
|
137
|
+
|
|
138
|
+
def position(self, total):
|
|
139
|
+
"""The stored cursor, reduced to a valid offset into `total` items."""
|
|
140
|
+
return self._read() % total if total else 0
|
|
141
|
+
|
|
142
|
+
def advance(self, cursor):
|
|
143
|
+
"""Record where the next scan's window starts."""
|
|
144
|
+
self._memory = cursor
|
|
145
|
+
if not workflow._kv_available():
|
|
146
|
+
return
|
|
147
|
+
try:
|
|
148
|
+
workflow._kv_set(self._key, {"cursor": cursor})
|
|
149
|
+
except Exception as exc: # noqa: BLE001 - a cursor is not worth failing a scan
|
|
150
|
+
print(f" Warning: scan cursor write failed ({exc})")
|
|
151
|
+
|
|
152
|
+
def _read(self):
|
|
153
|
+
if not workflow._kv_available():
|
|
154
|
+
return self._memory
|
|
155
|
+
try:
|
|
156
|
+
data = workflow._kv_get(self._key) or {}
|
|
157
|
+
except Exception as exc: # noqa: BLE001 - fall back to the in-memory cursor
|
|
158
|
+
print(f" Warning: scan cursor read failed ({exc})")
|
|
159
|
+
return self._memory
|
|
160
|
+
try:
|
|
161
|
+
return int(data.get("cursor") or 0)
|
|
162
|
+
except (TypeError, ValueError):
|
|
163
|
+
return self._memory
|
|
164
|
+
|
|
18
165
|
|
|
19
166
|
class PullRequestReviewer(GitHubRepository):
|
|
20
167
|
name = "github-pr-reviewer"
|
|
168
|
+
# The shared intake the shipped entrypoint creates once per scheduled scan,
|
|
169
|
+
# so the per-run maximum spans every configured repository. It is None for a
|
|
170
|
+
# run() invoked on its own (tests, one-off scans), which then bounds its own
|
|
171
|
+
# conversations with a private intake instead.
|
|
172
|
+
scan_intake = None
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def intake(self):
|
|
176
|
+
"""The intake this run registers eligible candidates with.
|
|
177
|
+
|
|
178
|
+
The shipped entrypoint sets `scan_intake` once, so every repository in a
|
|
179
|
+
scan shares one budget and the drain happens after all repositories have
|
|
180
|
+
been scanned. A run() with no shared intake uses its own, created lazily
|
|
181
|
+
so it exists whether or not __init__ ran.
|
|
182
|
+
"""
|
|
183
|
+
if self.scan_intake is not None:
|
|
184
|
+
return self.scan_intake
|
|
185
|
+
intake = self.__dict__.get("_intake")
|
|
186
|
+
if intake is None:
|
|
187
|
+
intake = self.__dict__["_intake"] = ReviewIntake()
|
|
188
|
+
return intake
|
|
21
189
|
|
|
22
190
|
@cached_property
|
|
23
191
|
def github_login(self):
|
|
@@ -74,7 +242,7 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
74
242
|
return pr if (author or "").lower() == self.trigger_reviewer else None
|
|
75
243
|
return None
|
|
76
244
|
|
|
77
|
-
def _prompt(self, pr, trigger, label=None):
|
|
245
|
+
def _prompt(self, pr, trigger, label=None, delivery_key=None):
|
|
78
246
|
number = pr["number"]
|
|
79
247
|
sha = pr["head"]["sha"]
|
|
80
248
|
token = self.token_name
|
|
@@ -87,35 +255,60 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
87
255
|
"mode. Set `GIT_TERMINAL_PROMPT=0` on Git network commands so a missing "
|
|
88
256
|
"permission fails immediately instead of waiting for input."
|
|
89
257
|
)
|
|
258
|
+
if label:
|
|
259
|
+
trigger_description = None
|
|
260
|
+
elif delivery_key is not None:
|
|
261
|
+
trigger_description = (
|
|
262
|
+
f"scheduled scan of open, non-draft pull requests on head `{sha}`"
|
|
263
|
+
)
|
|
264
|
+
else:
|
|
265
|
+
trigger_description = (
|
|
266
|
+
f"latest review request for `{self.trigger_reviewer}` "
|
|
267
|
+
f"event {trigger.get('id', '?')} at {trigger.get('created_at', '?')}"
|
|
268
|
+
)
|
|
90
269
|
prompt = workflow._build_review_prompt(
|
|
91
270
|
self.repository,
|
|
92
271
|
pr,
|
|
93
272
|
sha,
|
|
94
|
-
trigger,
|
|
273
|
+
trigger or {},
|
|
95
274
|
workspace_instructions=workspace,
|
|
96
275
|
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
|
-
),
|
|
107
|
-
)
|
|
108
|
-
moved_head_instruction = (
|
|
109
|
-
f"leave `{label}` in place so the new head is reviewed"
|
|
110
|
-
if label
|
|
111
|
-
else "publish no review; the new head requires another reviewer request"
|
|
276
|
+
trigger_description=trigger_description,
|
|
112
277
|
)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
278
|
+
if label:
|
|
279
|
+
moved_head_instruction = (
|
|
280
|
+
f"leave `{label}` in place so the new head is reviewed"
|
|
281
|
+
)
|
|
282
|
+
trigger_completion = (
|
|
283
|
+
f"Leave the `{label}` label in place after GitHub accepts the review. "
|
|
284
|
+
"The deterministic scanner removes it"
|
|
285
|
+
)
|
|
286
|
+
elif delivery_key is not None:
|
|
287
|
+
# A scheduled scan reviews a new head again on its own, so there is
|
|
288
|
+
# no request to preserve and no label to leave behind.
|
|
289
|
+
moved_head_instruction = (
|
|
290
|
+
"publish no review; a later scheduled scan reviews the new head"
|
|
291
|
+
)
|
|
292
|
+
trigger_completion = (
|
|
293
|
+
"Do not change review requests after GitHub accepts the review. "
|
|
294
|
+
"The deterministic scan records the completed review"
|
|
295
|
+
)
|
|
296
|
+
else:
|
|
297
|
+
moved_head_instruction = (
|
|
298
|
+
"publish no review; the new head requires another reviewer request"
|
|
299
|
+
)
|
|
300
|
+
trigger_completion = (
|
|
301
|
+
"Do not change review requests after GitHub accepts the review. "
|
|
302
|
+
"The deterministic event handler completes the request"
|
|
303
|
+
)
|
|
304
|
+
author = ((pr.get("user") or {}).get("login") or "").lower()
|
|
305
|
+
self_review_note = (
|
|
306
|
+
"\n- This pull request is authored by the configured reviewer account, "
|
|
307
|
+
"so GitHub ignores a review request from it. Publish the clean review "
|
|
308
|
+
"with `event: COMMENT` and keep the approved verdict instead of "
|
|
309
|
+
"attempting `event: APPROVE`."
|
|
310
|
+
if author == self.trigger_reviewer
|
|
311
|
+
else ""
|
|
119
312
|
)
|
|
120
313
|
return (
|
|
121
314
|
prompt + "\n\nAcceptance reporting:\n"
|
|
@@ -132,13 +325,24 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
132
325
|
"into comments.\n"
|
|
133
326
|
"- Once GitHub accepts the native review, stop immediately. Do not "
|
|
134
327
|
"continue inspecting the repository, run more commands, or publish a "
|
|
135
|
-
"second result."
|
|
328
|
+
f"second result.{self_review_note}"
|
|
136
329
|
)
|
|
137
330
|
|
|
138
331
|
def _finish_completed_review(self, pr, trigger, label=None):
|
|
139
|
-
"""Complete an exact-head review, including an optional human handoff.
|
|
332
|
+
"""Complete an exact-head review, including an optional human handoff.
|
|
333
|
+
|
|
334
|
+
`trigger` is the event the current work is keyed on. It is None for an
|
|
335
|
+
unrequested scheduled-scan PR, which has no trigger event: the head SHA
|
|
336
|
+
is the whole key, so any submitted review by this account on the current
|
|
337
|
+
head is the completion of that work. That is what lets an unrequested
|
|
338
|
+
review the scan started be reconciled and handed off on a later scan
|
|
339
|
+
instead of being restarted.
|
|
340
|
+
"""
|
|
140
341
|
head_sha = pr["head"]["sha"]
|
|
141
|
-
|
|
342
|
+
if trigger is not None:
|
|
343
|
+
triggered_at = trigger.get("created_at") or ""
|
|
344
|
+
else:
|
|
345
|
+
triggered_at = ""
|
|
142
346
|
reviews = self.gh_pages(f"/pulls/{pr['number']}/reviews")
|
|
143
347
|
completed = [
|
|
144
348
|
review
|
|
@@ -211,12 +415,455 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
211
415
|
)
|
|
212
416
|
return True
|
|
213
417
|
|
|
418
|
+
@staticmethod
|
|
419
|
+
def _check_app_identity(run):
|
|
420
|
+
"""The app that reported a check run, as a stable grouping key."""
|
|
421
|
+
app = run.get("app") or {}
|
|
422
|
+
if isinstance(app, dict):
|
|
423
|
+
return str(app.get("slug") or app.get("id") or app.get("name") or "")
|
|
424
|
+
return str(app)
|
|
425
|
+
|
|
426
|
+
@staticmethod
|
|
427
|
+
def _check_run_order(run):
|
|
428
|
+
"""Deterministic ordering by run ID, then start time as a tie-break.
|
|
429
|
+
|
|
430
|
+
The check-run ID is a monotonically increasing per-repository counter,
|
|
431
|
+
so it tracks creation order even when `started_at` is absent, and it is
|
|
432
|
+
the primary key. Ordering by start time first broke in both directions:
|
|
433
|
+
a missing start time sorted before every real timestamp (an older queued
|
|
434
|
+
run lost to an earlier success), and the earlier `~{run_id}` fallback
|
|
435
|
+
sorted after every real timestamp (an older queued run outranked a newer
|
|
436
|
+
success). The start time only breaks a tie when two runs share an ID or
|
|
437
|
+
have none.
|
|
438
|
+
"""
|
|
439
|
+
run_id = int(run.get("id") or 0)
|
|
440
|
+
return (run_id, run.get("started_at") or "")
|
|
441
|
+
|
|
442
|
+
@staticmethod
|
|
443
|
+
def _workflow_run_order(run):
|
|
444
|
+
"""Deterministic ordering for workflow runs, keyed by run ID first.
|
|
445
|
+
|
|
446
|
+
Workflow-run IDs are the same monotonic per-repository counter as
|
|
447
|
+
check-run IDs, so the ID is again the primary key. The start time lives
|
|
448
|
+
in `run_started_at` (`created_at` before the run starts) and only breaks
|
|
449
|
+
a tie.
|
|
450
|
+
"""
|
|
451
|
+
run_id = int(run.get("id") or 0)
|
|
452
|
+
return (run_id, run.get("run_started_at") or run.get("created_at") or "")
|
|
453
|
+
|
|
454
|
+
@staticmethod
|
|
455
|
+
def _latest_by_group(runs, sha, order, group):
|
|
456
|
+
"""Keep the latest run per group, ignoring any other head's runs.
|
|
457
|
+
|
|
458
|
+
GitHub lists every run for a commit, so a check that was re-run after a
|
|
459
|
+
fix would otherwise contribute its superseded failure forever. Only runs
|
|
460
|
+
attributed to the exact head count: a run left behind on an obsolete head
|
|
461
|
+
must not block the push that fixed it. Within a group the latest run
|
|
462
|
+
wins, ordered by the run ID (the reliable creation sequence) with the
|
|
463
|
+
start time as a tie-break, so a newer queued or in-progress re-run
|
|
464
|
+
supersedes an earlier success and makes the head wait even when its start
|
|
465
|
+
time is still absent, while an older run without a start time cannot
|
|
466
|
+
outrank a newer success.
|
|
467
|
+
"""
|
|
468
|
+
latest = {}
|
|
469
|
+
for run in runs:
|
|
470
|
+
if run.get("head_sha") != sha:
|
|
471
|
+
continue
|
|
472
|
+
key = group(run)
|
|
473
|
+
current = latest.get(key)
|
|
474
|
+
if current is None or order(run) > order(current):
|
|
475
|
+
latest[key] = run
|
|
476
|
+
return list(latest.values())
|
|
477
|
+
|
|
478
|
+
def _latest_check_runs(self, sha, check_runs):
|
|
479
|
+
"""Return only the latest run of each logical check on the exact head.
|
|
480
|
+
|
|
481
|
+
A logical check is its name plus the reporting app identity, so two apps
|
|
482
|
+
that use the same name stay independent.
|
|
483
|
+
"""
|
|
484
|
+
return self._latest_by_group(
|
|
485
|
+
check_runs,
|
|
486
|
+
sha,
|
|
487
|
+
self._check_run_order,
|
|
488
|
+
lambda run: (
|
|
489
|
+
run.get("name") or "unnamed check",
|
|
490
|
+
self._check_app_identity(run),
|
|
491
|
+
),
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
def _latest_workflow_runs(self, sha, check_runs):
|
|
495
|
+
"""Return the current-head workflow runs that created no check runs.
|
|
496
|
+
|
|
497
|
+
A workflow run's check suite is the link to its check runs. When that
|
|
498
|
+
suite already reported check runs, those runs carry the conclusion and
|
|
499
|
+
re-reporting the workflow run would only duplicate them. A suite with no
|
|
500
|
+
check runs is a workflow that failed before any job reported - a
|
|
501
|
+
workflow-level error, or a `pull_request` run whose jobs never started -
|
|
502
|
+
which the commit's check-run rollup and `gh pr check`s cannot see. Group
|
|
503
|
+
by workflow identity so a re-run supersedes its earlier attempt.
|
|
504
|
+
"""
|
|
505
|
+
reported_suites = {
|
|
506
|
+
(run.get("check_suite") or {}).get("id")
|
|
507
|
+
for run in check_runs
|
|
508
|
+
if run.get("head_sha") == sha
|
|
509
|
+
}
|
|
510
|
+
return self._latest_by_group(
|
|
511
|
+
[
|
|
512
|
+
run
|
|
513
|
+
for run in self.workflow_runs(sha)
|
|
514
|
+
if run.get("check_suite_id") not in reported_suites
|
|
515
|
+
],
|
|
516
|
+
sha,
|
|
517
|
+
self._workflow_run_order,
|
|
518
|
+
lambda run: (
|
|
519
|
+
run.get("name") or "unnamed workflow",
|
|
520
|
+
run.get("workflow_id"),
|
|
521
|
+
),
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
@staticmethod
|
|
525
|
+
def _classify_runs(reporters):
|
|
526
|
+
"""Split runs into blocking, pending, and green by status/conclusion.
|
|
527
|
+
|
|
528
|
+
A completed run whose conclusion is neither blocking nor explicitly
|
|
529
|
+
non-blocking fails closed, so an unknown conclusion cannot silently
|
|
530
|
+
approve a PR. A run that has not completed means waiting, never
|
|
531
|
+
approval.
|
|
532
|
+
"""
|
|
533
|
+
blocking, pending = [], []
|
|
534
|
+
for run in reporters:
|
|
535
|
+
name = run.get("name") or "unnamed check"
|
|
536
|
+
status = (run.get("status") or "").lower()
|
|
537
|
+
if status != "completed":
|
|
538
|
+
pending.append(name)
|
|
539
|
+
elif (run.get("conclusion") or "").lower() in NON_BLOCKING_CHECK_CONCLUSIONS:
|
|
540
|
+
continue
|
|
541
|
+
else:
|
|
542
|
+
blocking.append(name)
|
|
543
|
+
if blocking:
|
|
544
|
+
return "blocked", sorted(set(blocking))
|
|
545
|
+
if pending:
|
|
546
|
+
return "waiting", sorted(set(pending))
|
|
547
|
+
return "green", []
|
|
548
|
+
|
|
549
|
+
def _classify_all_runs(self, sha):
|
|
550
|
+
"""Every current-head check and workflow run, regardless of requirement.
|
|
551
|
+
|
|
552
|
+
The conservative fallback used when the required-check set cannot be
|
|
553
|
+
read. Check runs are the primary signal, but a workflow can fail before
|
|
554
|
+
creating any check run, so workflow runs whose suites reported no check
|
|
555
|
+
runs are considered too.
|
|
556
|
+
"""
|
|
557
|
+
check_runs = self.check_runs(sha)
|
|
558
|
+
reporters = (
|
|
559
|
+
self._latest_check_runs(sha, check_runs)
|
|
560
|
+
+ self._latest_workflow_runs(sha, check_runs)
|
|
561
|
+
)
|
|
562
|
+
return self._classify_runs(reporters)
|
|
563
|
+
|
|
564
|
+
def _classify_required_runs(self, sha, required):
|
|
565
|
+
"""Classify only the required current-head checks.
|
|
566
|
+
|
|
567
|
+
`required` is the required contexts GitHub reports for the pull request.
|
|
568
|
+
Only a required check decides the scheduled gate, so an optional workflow
|
|
569
|
+
that fails before creating any check run - and whose context GitHub does
|
|
570
|
+
not mark required - cannot block a head whose required checks pass. A
|
|
571
|
+
required context that reported no current-head check run, such as a
|
|
572
|
+
required workflow that failed before any job, is expected but unfulfilled
|
|
573
|
+
and classifies as waiting.
|
|
574
|
+
"""
|
|
575
|
+
check_runs = self.check_runs(sha)
|
|
576
|
+
latest = {
|
|
577
|
+
(run.get("name") or "unnamed check"): run
|
|
578
|
+
for run in self._latest_check_runs(sha, check_runs)
|
|
579
|
+
}
|
|
580
|
+
reporters = []
|
|
581
|
+
for context in required:
|
|
582
|
+
name = context["name"]
|
|
583
|
+
if context.get("kind") == "StatusContext":
|
|
584
|
+
state = self.statuses(sha).get(name)
|
|
585
|
+
reporters.append(
|
|
586
|
+
{
|
|
587
|
+
"name": name,
|
|
588
|
+
"status": "completed" if state else "expected",
|
|
589
|
+
"conclusion": state,
|
|
590
|
+
}
|
|
591
|
+
)
|
|
592
|
+
continue
|
|
593
|
+
reporters.append(latest.get(name, {"name": name, "status": "expected"}))
|
|
594
|
+
return self._classify_runs(reporters)
|
|
595
|
+
|
|
596
|
+
def _classify_check_runs(self, pr):
|
|
597
|
+
"""Split a pull request head into blocked, waiting, or green.
|
|
598
|
+
|
|
599
|
+
Scheduled discovery classifies only GitHub-required checks, because an
|
|
600
|
+
optional workflow must not block the merge gate. The required set is read
|
|
601
|
+
per pull request; when it is unavailable or empty the classifier falls
|
|
602
|
+
back to every current-head check and workflow run, so a red head still
|
|
603
|
+
blocks. The exact-head, latest-run, and fail-closed behavior is identical
|
|
604
|
+
either way.
|
|
605
|
+
"""
|
|
606
|
+
sha = pr["head"]["sha"]
|
|
607
|
+
try:
|
|
608
|
+
required = self.required_check_contexts(pr["number"])
|
|
609
|
+
except Exception as exc: # noqa: BLE001 - fail closed to the full rollup
|
|
610
|
+
print(
|
|
611
|
+
json.dumps(
|
|
612
|
+
{
|
|
613
|
+
"repository": self.repository,
|
|
614
|
+
"pr": pr["number"],
|
|
615
|
+
"head_sha": sha,
|
|
616
|
+
"required_check_signal": "unavailable",
|
|
617
|
+
"error": type(exc).__name__,
|
|
618
|
+
}
|
|
619
|
+
),
|
|
620
|
+
flush=True,
|
|
621
|
+
)
|
|
622
|
+
return self._classify_all_runs(sha)
|
|
623
|
+
if not required:
|
|
624
|
+
# No required checks configured is not a red head, but it is also not
|
|
625
|
+
# evidence the head is reviewable, so fall back to the full rollup.
|
|
626
|
+
return self._classify_all_runs(sha)
|
|
627
|
+
return self._classify_required_runs(sha, required)
|
|
628
|
+
|
|
629
|
+
def _owns_comment(self, comment):
|
|
630
|
+
login = ((comment.get("user") or {}).get("login") or "").lower()
|
|
631
|
+
return login == self.github_login.lower()
|
|
632
|
+
|
|
633
|
+
@staticmethod
|
|
634
|
+
def _explains_checks(body, names):
|
|
635
|
+
"""Whether a deterministic comment names every check the gate reports."""
|
|
636
|
+
lowered = body.lower()
|
|
637
|
+
return all(name.lower() in lowered for name in names)
|
|
638
|
+
|
|
639
|
+
def _gate_comment(self, number, marker, body, names):
|
|
640
|
+
"""Post one gate explanation, upserting the automation's own comment.
|
|
641
|
+
|
|
642
|
+
The marker carries the head SHA and gate category, so a later run for a
|
|
643
|
+
different head updates the comment it owns instead of stacking another
|
|
644
|
+
one. A run that finds the same marker still compares the full body, so a
|
|
645
|
+
comment written for one deployment is reworded in place when the retry it
|
|
646
|
+
names changes -- an event-only gate comment becomes the scheduled one when
|
|
647
|
+
the automation is switched to cron -- and an unchanged body is a no-op.
|
|
648
|
+
Every PR comment is untrusted input: only a marker this account authored
|
|
649
|
+
is "managed", so a marker someone else placed can neither suppress the
|
|
650
|
+
explanation nor be edited. An unmarked deterministic comment an existing
|
|
651
|
+
repository workflow already posted is treated as the equivalent
|
|
652
|
+
explanation only when it names the same reported checks; a comment about
|
|
653
|
+
some other check is left alone and the gate posts its own.
|
|
654
|
+
"""
|
|
655
|
+
comments = self.gh_pages(f"/issues/{number}/comments")
|
|
656
|
+
managed = [
|
|
657
|
+
comment
|
|
658
|
+
for comment in comments
|
|
659
|
+
if CHECK_GATE_MARKER in (comment.get("body") or "")
|
|
660
|
+
and self._owns_comment(comment)
|
|
661
|
+
]
|
|
662
|
+
matching = [
|
|
663
|
+
comment for comment in managed if marker in (comment.get("body") or "")
|
|
664
|
+
]
|
|
665
|
+
if matching:
|
|
666
|
+
target = max(matching, key=lambda comment: int(comment["id"]))
|
|
667
|
+
if (target.get("body") or "").strip() == body.strip():
|
|
668
|
+
return None
|
|
669
|
+
self.gh("PATCH", f"/issues/comments/{target['id']}", {"body": body})
|
|
670
|
+
return target["id"]
|
|
671
|
+
if managed:
|
|
672
|
+
target = max(managed, key=lambda comment: int(comment["id"]))
|
|
673
|
+
self.gh("PATCH", f"/issues/comments/{target['id']}", {"body": body})
|
|
674
|
+
return target["id"]
|
|
675
|
+
if any(
|
|
676
|
+
WORKFLOW_DISCLOSURE.lower() in (comment.get("body") or "").lower()
|
|
677
|
+
and self._explains_checks(comment.get("body") or "", names)
|
|
678
|
+
for comment in comments
|
|
679
|
+
if CHECK_GATE_MARKER not in (comment.get("body") or "")
|
|
680
|
+
):
|
|
681
|
+
return None
|
|
682
|
+
created = self.gh("POST", f"/issues/{number}/comments", {"body": body})
|
|
683
|
+
return created.get("id")
|
|
684
|
+
|
|
685
|
+
def _gate_body(self, sha, state, names, scheduled):
|
|
686
|
+
"""Explain a stop, naming the retry this deployment actually has.
|
|
687
|
+
|
|
688
|
+
A scheduled run proves a scan is configured, so it may promise the next
|
|
689
|
+
scan. An event run does not, so it names the one mechanism this
|
|
690
|
+
deployment is guaranteed to honor: another native review request. GitHub
|
|
691
|
+
refuses to request a reviewer who is already requested, so an event-only
|
|
692
|
+
deployment must say that the outstanding request has to be removed and
|
|
693
|
+
re-requested before the review can start.
|
|
694
|
+
"""
|
|
695
|
+
short = sha[:12]
|
|
696
|
+
listed = "\n".join(f"- `{name}`" for name in names)
|
|
697
|
+
if state == "blocked":
|
|
698
|
+
heading = "### ⚠️ Review paused: current-head checks failed"
|
|
699
|
+
lead = (
|
|
700
|
+
f"The current head `{short}` has failing checks, so no review "
|
|
701
|
+
"conversation was started:"
|
|
702
|
+
)
|
|
703
|
+
action = (
|
|
704
|
+
"Fix the checks above and push. The scheduled scan then starts "
|
|
705
|
+
"the review on the updated head."
|
|
706
|
+
if scheduled
|
|
707
|
+
else "Fix the checks above and push. Then remove the outstanding "
|
|
708
|
+
f"`{self.trigger_reviewer}` request and request "
|
|
709
|
+
f"`{self.trigger_reviewer}` again: GitHub will not accept a "
|
|
710
|
+
"second request while the first is still outstanding. The review "
|
|
711
|
+
"starts on the updated head."
|
|
712
|
+
)
|
|
713
|
+
else:
|
|
714
|
+
heading = "### ⏳ Review waiting on checks"
|
|
715
|
+
lead = (
|
|
716
|
+
f"The current head `{short}` still has checks that have not "
|
|
717
|
+
"finished, so no review conversation was started:"
|
|
718
|
+
)
|
|
719
|
+
action = (
|
|
720
|
+
"No action is needed. The scheduled scan retries once every "
|
|
721
|
+
"check on the head reports a conclusion."
|
|
722
|
+
if scheduled
|
|
723
|
+
else "Once every check on the head reports a conclusion, remove "
|
|
724
|
+
f"the outstanding `{self.trigger_reviewer}` request and request "
|
|
725
|
+
f"`{self.trigger_reviewer}` again: GitHub will not accept a "
|
|
726
|
+
"second request while the first is still outstanding. That "
|
|
727
|
+
"starts the review."
|
|
728
|
+
)
|
|
729
|
+
return (
|
|
730
|
+
f"{heading}\n\n{lead}\n\n{listed}\n\n{action}\n\n"
|
|
731
|
+
f"{CHECK_GATE_MARKER}{state}:{sha} -->\n\n"
|
|
732
|
+
f"_This is an automated check - {WORKFLOW_DISCLOSURE}._"
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
def _gate_head(self, pr, requested=False, scheduled=False, explain=True):
|
|
736
|
+
"""Return the head's eligibility, explaining any stop on the PR.
|
|
737
|
+
|
|
738
|
+
An explicit `all-hands-bot` review request is the intake-policy
|
|
739
|
+
exception: the caller asked for this head by name, so the CI gate does
|
|
740
|
+
not apply and no gate comment is left. Scheduled discovery still gates on
|
|
741
|
+
the required checks. When it stops a scheduled run, the explanation names
|
|
742
|
+
the retry that deployment actually has, which is why `scheduled` is
|
|
743
|
+
threaded into the body.
|
|
744
|
+
|
|
745
|
+
`explain` is False for an unrequested candidate: a PR nobody asked about
|
|
746
|
+
that is merely red or pending gets no managed comment. Announcing a
|
|
747
|
+
blocked or waiting head for every open PR is what produced the comment
|
|
748
|
+
storm the rotating window is bounded against, and the managed comment is
|
|
749
|
+
the answer to an explicit request or trigger label. The gate still
|
|
750
|
+
classifies the head, so a red or pending unrequested head is skipped
|
|
751
|
+
without starting an agent.
|
|
752
|
+
"""
|
|
753
|
+
sha = pr["head"]["sha"]
|
|
754
|
+
if requested:
|
|
755
|
+
return "green", sha
|
|
756
|
+
state, names = self._classify_check_runs(pr)
|
|
757
|
+
if explain and state in ("blocked", "waiting"):
|
|
758
|
+
marker = f"{CHECK_GATE_MARKER}{state}:{sha} -->"
|
|
759
|
+
self._gate_comment(
|
|
760
|
+
pr["number"],
|
|
761
|
+
marker,
|
|
762
|
+
self._gate_body(sha, state, names, scheduled),
|
|
763
|
+
names,
|
|
764
|
+
)
|
|
765
|
+
return state, sha
|
|
766
|
+
|
|
767
|
+
def _outstanding_review_request(self, pr):
|
|
768
|
+
"""Whether an open, non-draft PR still holds a request for the reviewer.
|
|
769
|
+
|
|
770
|
+
The list endpoint already answers this: `requested_reviewers` is the live
|
|
771
|
+
set, so a review that was submitted, or a request that was withdrawn, is
|
|
772
|
+
simply absent. Drafts are excluded because a draft is not reviewable.
|
|
773
|
+
"""
|
|
774
|
+
if pr.get("draft"):
|
|
775
|
+
return False
|
|
776
|
+
return any(
|
|
777
|
+
(item.get("login") or "").lower() == self.trigger_reviewer
|
|
778
|
+
for item in pr.get("requested_reviewers") or []
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
def _has_current_head_review(self, number, sha):
|
|
782
|
+
"""Whether the reviewer account already published a review on this head.
|
|
783
|
+
|
|
784
|
+
This is the candidate filter's negative: an open, non-draft PR without a
|
|
785
|
+
current-head review by the configured reviewer is eligible for a
|
|
786
|
+
scheduled scan even when nobody requested the bot. The same predicate is
|
|
787
|
+
what the completion handler reconciles, so a review this scan starts and
|
|
788
|
+
a review it finds already present are the same set.
|
|
789
|
+
"""
|
|
790
|
+
return any(
|
|
791
|
+
review.get("commit_id") == sha
|
|
792
|
+
and ((review.get("user") or {}).get("login") or "").lower()
|
|
793
|
+
== self.github_login.lower()
|
|
794
|
+
for review in self.gh_pages(f"/pulls/{number}/reviews")
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
def _unrequested_head(self, pr):
|
|
798
|
+
"""The current-head delivery key for an unrequested PR, or None.
|
|
799
|
+
|
|
800
|
+
The key is the repository/PR identity plus the head SHA, so repeated
|
|
801
|
+
scheduled scans over one head reuse one conversation and one native
|
|
802
|
+
review, while a changed head becomes eligible again under its new SHA.
|
|
803
|
+
"""
|
|
804
|
+
sha = pr["head"]["sha"]
|
|
805
|
+
return f"scan:{self.repository}:{pr['number']}:{sha}"
|
|
806
|
+
|
|
807
|
+
@property
|
|
808
|
+
def _scan_cursor(self):
|
|
809
|
+
"""This repository's scan position, created lazily like the intake."""
|
|
810
|
+
cursor = self.__dict__.get("_cursor")
|
|
811
|
+
if cursor is None:
|
|
812
|
+
cursor = self.__dict__["_cursor"] = ScanCursor(self.repository)
|
|
813
|
+
return cursor
|
|
814
|
+
|
|
815
|
+
def _explicit_candidate(self, pr, label):
|
|
816
|
+
"""Whether a PR was explicitly requested by a caller.
|
|
817
|
+
|
|
818
|
+
An explicit `all-hands-bot` review request or a trigger label is a
|
|
819
|
+
caller's decision, so it is never subject to the rotating window: every
|
|
820
|
+
explicit candidate is examined on every scan, whatever the stored scan
|
|
821
|
+
position is.
|
|
822
|
+
"""
|
|
823
|
+
if label in {item["name"] for item in pr.get("labels", [])}:
|
|
824
|
+
return True
|
|
825
|
+
return self._outstanding_review_request(pr)
|
|
826
|
+
|
|
827
|
+
def _rotating_window(self, prs, label):
|
|
828
|
+
"""The explicit candidates plus a bounded slice of the unrequested ones.
|
|
829
|
+
|
|
830
|
+
Classifying an unrequested head costs a review read and the exact-head
|
|
831
|
+
check/workflow reads, so examining every open PR in one scan is what let
|
|
832
|
+
a single run exhaust the API budget and post a managed gate comment for
|
|
833
|
+
every red or pending head. The unrequested backlog is therefore examined
|
|
834
|
+
a bounded `SCAN_WINDOW` at a time, starting where the previous scan
|
|
835
|
+
stopped (the per-repository position in the Automation KV store), so
|
|
836
|
+
successive scans rotate through the whole backlog. Explicit candidates
|
|
837
|
+
are always included, so a request is never delayed behind the window.
|
|
838
|
+
"""
|
|
839
|
+
explicit, unrequested = [], []
|
|
840
|
+
for pr in prs:
|
|
841
|
+
if pr.get("draft") and label not in {
|
|
842
|
+
item["name"] for item in pr.get("labels", [])
|
|
843
|
+
} and not self._outstanding_review_request(pr):
|
|
844
|
+
# A draft that is neither labeled nor requested is not reviewable
|
|
845
|
+
# by the unrequested path either, so drop it before the full read.
|
|
846
|
+
continue
|
|
847
|
+
if self._explicit_candidate(pr, label):
|
|
848
|
+
explicit.append(pr)
|
|
849
|
+
else:
|
|
850
|
+
unrequested.append(pr)
|
|
851
|
+
if not unrequested:
|
|
852
|
+
return explicit
|
|
853
|
+
start = self._scan_cursor.position(len(unrequested))
|
|
854
|
+
window = unrequested[start : start + SCAN_WINDOW]
|
|
855
|
+
self._scan_cursor.advance(start + len(window))
|
|
856
|
+
return explicit + window
|
|
857
|
+
|
|
214
858
|
def run(self):
|
|
215
859
|
repository_id = self.gh("GET", "")["id"]
|
|
216
860
|
label = self.config.get("trigger_label", workflow.TRIGGER_LABEL)
|
|
217
861
|
payload = self._event_payload()
|
|
218
|
-
|
|
219
|
-
|
|
862
|
+
event_mode = payload is not None
|
|
863
|
+
if not event_mode:
|
|
864
|
+
prs = self._rotating_window(
|
|
865
|
+
self.gh_pages("/pulls?state=open&sort=updated&direction=asc"), label
|
|
866
|
+
)
|
|
220
867
|
else:
|
|
221
868
|
candidate = self._event_candidate(payload)
|
|
222
869
|
if candidate and self.github_login.lower() != self.trigger_reviewer:
|
|
@@ -227,24 +874,48 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
227
874
|
prs = [candidate] if candidate else []
|
|
228
875
|
failures = []
|
|
229
876
|
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
877
|
try:
|
|
236
878
|
pr = self.gh("GET", f"/pulls/{candidate['number']}")
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
879
|
+
has_label = label in {
|
|
880
|
+
item["name"] for item in pr.get("labels", [])
|
|
881
|
+
}
|
|
882
|
+
requested = self._outstanding_review_request(pr)
|
|
883
|
+
trigger_label = label if (not event_mode and has_label) else None
|
|
884
|
+
unrequested_candidate = False
|
|
885
|
+
if event_mode or has_label:
|
|
886
|
+
trigger = (
|
|
887
|
+
self._latest_reviewer_request(pr["number"])
|
|
888
|
+
if event_mode
|
|
889
|
+
else workflow._latest_trigger_label_event(
|
|
890
|
+
self.token, self.repository, pr["number"]
|
|
891
|
+
)
|
|
242
892
|
)
|
|
243
|
-
|
|
244
|
-
|
|
893
|
+
delivery_key = None
|
|
894
|
+
elif requested:
|
|
895
|
+
# The outstanding request is the trigger, so its own event
|
|
896
|
+
# keys the delivery and dedupes repeated scans.
|
|
897
|
+
trigger = self._latest_reviewer_request(pr["number"])
|
|
898
|
+
delivery_key = None
|
|
899
|
+
else:
|
|
900
|
+
# No label and no outstanding request: an unrequested PR the
|
|
901
|
+
# scheduled scan reviews on its own, keyed by repository/PR/
|
|
902
|
+
# head. A draft is not reviewable, and a head this account
|
|
903
|
+
# already reviewed is done - reconcile that review's verdict
|
|
904
|
+
# and maintainer handoff, then skip it so no second
|
|
905
|
+
# conversation or review is created.
|
|
906
|
+
if pr.get("draft"):
|
|
907
|
+
continue
|
|
908
|
+
if self._has_current_head_review(pr["number"], pr["head"]["sha"]):
|
|
909
|
+
self._finish_completed_review(pr, None)
|
|
910
|
+
continue
|
|
911
|
+
trigger = None
|
|
912
|
+
delivery_key = self._unrequested_head(pr)
|
|
913
|
+
unrequested_candidate = True
|
|
914
|
+
if trigger is None and delivery_key is None:
|
|
245
915
|
continue
|
|
246
|
-
|
|
247
|
-
|
|
916
|
+
if delivery_key is None and self._finish_completed_review(
|
|
917
|
+
pr, trigger, trigger_label
|
|
918
|
+
):
|
|
248
919
|
continue
|
|
249
920
|
if event_mode and payload.get("action") == "submitted":
|
|
250
921
|
# A submitted review is a completion signal, never a fresh
|
|
@@ -253,23 +924,64 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
253
924
|
# dispatching a review the caller never asked for.
|
|
254
925
|
continue
|
|
255
926
|
sha = pr["head"]["sha"]
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
927
|
+
gate_state, sha = self._gate_head(
|
|
928
|
+
pr,
|
|
929
|
+
requested=event_mode,
|
|
930
|
+
scheduled=not event_mode,
|
|
931
|
+
explain=not unrequested_candidate,
|
|
260
932
|
)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
933
|
+
if gate_state != "green":
|
|
934
|
+
# A deterministic blocker stops the run without spending a
|
|
935
|
+
# worker slot on an agent. The trigger is not consumed: the
|
|
936
|
+
# next scheduled scan or explicit request re-evaluates the
|
|
937
|
+
# head once its checks are non-blocking. An unrequested head
|
|
938
|
+
# that is merely red or pending gets no managed comment, so a
|
|
939
|
+
# scan over a large backlog cannot storm the PRs with gate
|
|
940
|
+
# comments; the managed comment answers an explicit request.
|
|
941
|
+
print(
|
|
942
|
+
json.dumps(
|
|
943
|
+
{
|
|
944
|
+
"repository": self.repository,
|
|
945
|
+
"pr": pr["number"],
|
|
946
|
+
"head_sha": sha,
|
|
947
|
+
"disposition": f"review-{gate_state}",
|
|
948
|
+
}
|
|
949
|
+
),
|
|
950
|
+
flush=True,
|
|
951
|
+
)
|
|
952
|
+
continue
|
|
953
|
+
record = {
|
|
954
|
+
"repository": self.repository,
|
|
955
|
+
"number": pr["number"],
|
|
956
|
+
# An explicit request is ordered before an unrequested
|
|
957
|
+
# candidate; within a priority the oldest candidate drains
|
|
958
|
+
# first - the request time for a requested PR, the PR's own
|
|
959
|
+
# creation time (oldest first) for an unrequested one - and
|
|
960
|
+
# repository and number break a tie so the order is
|
|
961
|
+
# deterministic.
|
|
962
|
+
"priority": 0 if (has_label or requested) else 1,
|
|
963
|
+
"created_at": (
|
|
964
|
+
trigger.get("created_at") or ""
|
|
965
|
+
if trigger
|
|
966
|
+
else pr.get("created_at") or ""
|
|
270
967
|
),
|
|
271
|
-
|
|
272
|
-
|
|
968
|
+
"config": self.config,
|
|
969
|
+
"start": lambda pr=pr, trigger=trigger, sha=sha,
|
|
970
|
+
trigger_label=trigger_label,
|
|
971
|
+
delivery_key=delivery_key: self._start_review(
|
|
972
|
+
repository_id, pr, trigger, sha, trigger_label, delivery_key
|
|
973
|
+
),
|
|
974
|
+
}
|
|
975
|
+
if event_mode:
|
|
976
|
+
# An explicit request is a caller's decision to spend a
|
|
977
|
+
# conversation now, not a backlog item, so the event path
|
|
978
|
+
# dispatches immediately and is never bounded by the
|
|
979
|
+
# scheduled scan's per-run maximum.
|
|
980
|
+
self._start_review(
|
|
981
|
+
repository_id, pr, trigger, sha, trigger_label, delivery_key
|
|
982
|
+
)
|
|
983
|
+
else:
|
|
984
|
+
self.intake.register(record)
|
|
273
985
|
except Exception as exc: # noqa: BLE001 - one PR must not block the scan
|
|
274
986
|
failures.append(candidate.get("number", "?"))
|
|
275
987
|
print(
|
|
@@ -279,13 +991,63 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
279
991
|
file=sys.stderr,
|
|
280
992
|
flush=True,
|
|
281
993
|
)
|
|
994
|
+
if self.scan_intake is None:
|
|
995
|
+
# A run() with no shared scan intake owns its whole scan, so it
|
|
996
|
+
# drains the candidates it collected, bounded by the per-run maximum.
|
|
997
|
+
self.intake.drain()
|
|
282
998
|
if failures:
|
|
283
999
|
raise RuntimeError(
|
|
284
1000
|
"Reviewer scan failed for PRs: "
|
|
285
1001
|
+ ", ".join(f"#{number}" for number in failures)
|
|
286
1002
|
)
|
|
287
1003
|
|
|
1004
|
+
def _start_review(
|
|
1005
|
+
self, repository_id, pr, trigger, sha, trigger_label, delivery_key=None
|
|
1006
|
+
):
|
|
1007
|
+
result = self.dispatcher.deliver(
|
|
1008
|
+
subject=f"{repository_id}:pr:{pr['number']}",
|
|
1009
|
+
delivery=delivery_key or f"{trigger['id']}:{sha}",
|
|
1010
|
+
prompt=self._prompt(pr, trigger, trigger_label, delivery_key),
|
|
1011
|
+
)
|
|
1012
|
+
print(
|
|
1013
|
+
json.dumps(
|
|
1014
|
+
{
|
|
1015
|
+
"repository": self.repository,
|
|
1016
|
+
"pr": pr["number"],
|
|
1017
|
+
"head_sha": sha,
|
|
1018
|
+
"disposition": result["disposition"],
|
|
1019
|
+
"conversation_id": result["conversation_id"],
|
|
1020
|
+
}
|
|
1021
|
+
),
|
|
1022
|
+
flush=True,
|
|
1023
|
+
)
|
|
1024
|
+
return result
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def run_scan(dispatcher):
|
|
1028
|
+
"""Run one scheduled scan over every configured repository, then drain.
|
|
1029
|
+
|
|
1030
|
+
One shared intake spans the whole scan, so the per-run maximum is global
|
|
1031
|
+
rather than reset per repository, and the drain happens after every
|
|
1032
|
+
repository has been scanned so the oldest outstanding request across all of
|
|
1033
|
+
them starts first. One repository failing must not discard another
|
|
1034
|
+
repository's drained candidates, so the drain still runs and the first
|
|
1035
|
+
failure is what the scan reports.
|
|
1036
|
+
"""
|
|
1037
|
+
PullRequestReviewer.scan_intake = ReviewIntake()
|
|
1038
|
+
failure = None
|
|
1039
|
+
try:
|
|
1040
|
+
run_repositories(PullRequestReviewer, dispatcher=dispatcher)
|
|
1041
|
+
except Exception as exc: # noqa: BLE001 - reported after the drain
|
|
1042
|
+
failure = exc
|
|
1043
|
+
try:
|
|
1044
|
+
PullRequestReviewer.scan_intake.drain()
|
|
1045
|
+
except Exception as exc: # noqa: BLE001 - reported after the scan failure
|
|
1046
|
+
failure = failure or exc
|
|
1047
|
+
if failure is not None:
|
|
1048
|
+
raise failure
|
|
1049
|
+
|
|
288
1050
|
|
|
289
1051
|
if __name__ == "__main__":
|
|
290
1052
|
with AgentConversationDispatcher() as dispatcher:
|
|
291
|
-
|
|
1053
|
+
run_scan(dispatcher)
|