@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.
Files changed (45) hide show
  1. package/.claude-plugin/marketplace.json +6 -0
  2. package/.codex-plugin/marketplace.json +6 -0
  3. package/.plugin/marketplace.json +6 -0
  4. package/.release-please-manifest.json +1 -1
  5. package/README.md +3 -2
  6. package/automations/bundle-index.js +11 -7
  7. package/automations/catalog/github-pr-reviewer/manifest.json +19 -8
  8. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +71 -0
  9. package/automations/catalog-index.js +2 -0
  10. package/marketplaces/openhands-extensions.json +6 -0
  11. package/package.json +1 -1
  12. package/plugins/openhands/skills/openhands-sdk/SKILL.md +1 -0
  13. package/plugins/pr-review/skills/github-pr-review/SKILL.md +12 -2
  14. package/plugins/qa-changes/skills/github-pr-review/SKILL.md +12 -2
  15. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +116 -0
  16. package/pyproject.toml +1 -1
  17. package/python/openhands_extensions/_version.py +1 -1
  18. package/skills/github/scripts/github_client.py +116 -0
  19. package/skills/github-agents-md-maintainer/scripts/github_client.py +116 -0
  20. package/skills/github-delivery-watchdog/scripts/github_client.py +116 -0
  21. package/skills/github-issue-to-pr/scripts/github_client.py +116 -0
  22. package/skills/github-issue-triage/scripts/github_client.py +116 -0
  23. package/skills/github-pr-review/SKILL.md +12 -2
  24. package/skills/github-pr-reviewer/README.md +35 -0
  25. package/skills/github-pr-reviewer/SKILL.md +156 -4
  26. package/skills/github-pr-reviewer/references/state-schema.md +15 -0
  27. package/skills/github-pr-reviewer/scripts/github_client.py +116 -0
  28. package/skills/github-pr-reviewer/scripts/main.py +17 -1
  29. package/skills/github-pr-reviewer/scripts/worker.py +820 -58
  30. package/skills/github-repo-monitor/scripts/github_client.py +116 -0
  31. package/skills/github-repo-monitor/scripts/main.py +32 -1
  32. package/skills/github-stale-ci-pr-closer/.claude-plugin/plugin.json +9 -0
  33. package/skills/github-stale-ci-pr-closer/.codex-plugin/plugin.json +9 -0
  34. package/skills/github-stale-ci-pr-closer/.plugin/plugin.json +9 -0
  35. package/skills/github-stale-ci-pr-closer/README.md +9 -0
  36. package/skills/github-stale-ci-pr-closer/SKILL.md +24 -0
  37. package/skills/github-stale-ci-pr-closer/commands/github-stale-ci-pr-closer.md +8 -0
  38. package/skills/github-stale-ci-pr-closer/scripts/worker.py +335 -0
  39. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +213 -0
  40. package/skills/index.js +12 -3
  41. package/skills/openhands-sdk/SKILL.md +1 -0
  42. package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
  43. package/tests/test_github_automation_foundation.py +158 -0
  44. package/tests/test_github_repo_monitor.py +107 -0
  45. 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
- trigger_completion = (
114
- f"Leave the `{label}` label in place after GitHub accepts the review. "
115
- "The deterministic scanner removes it"
116
- if label
117
- else "Do not change review requests after GitHub accepts the review. "
118
- "The deterministic event handler completes the request"
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
- triggered_at = trigger.get("created_at") or ""
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
- if payload is None:
219
- prs = self.gh_pages("/pulls?state=open&sort=updated&direction=asc")
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
- trigger = (
238
- self._latest_reviewer_request(pr["number"])
239
- if event_mode
240
- else workflow._latest_trigger_label_event(
241
- self.token, self.repository, pr["number"]
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
- if trigger is None:
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
- trigger_label = None if event_mode else label
247
- if self._finish_completed_review(pr, trigger, trigger_label):
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
- result = self.dispatcher.deliver(
257
- subject=f"{repository_id}:pr:{pr['number']}",
258
- delivery=f"{trigger['id']}:{sha}",
259
- prompt=self._prompt(pr, trigger, trigger_label),
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
- print(
262
- json.dumps(
263
- {
264
- "repository": self.repository,
265
- "pr": pr["number"],
266
- "head_sha": sha,
267
- "disposition": result["disposition"],
268
- "conversation_id": result["conversation_id"],
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
- flush=True,
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
- run_repositories(PullRequestReviewer, dispatcher=dispatcher)
1053
+ run_scan(dispatcher)