@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.
Files changed (133) hide show
  1. package/.claude-plugin/marketplace.json +30 -0
  2. package/.codex-plugin/marketplace.json +30 -0
  3. package/.github/pull_request_template.md +17 -2
  4. package/.github/scripts/check_pr_description.py +64 -5
  5. package/.github/scripts/markdown_sections.py +95 -0
  6. package/.github/workflows/build-vscode-app.yml +30 -0
  7. package/.github/workflows/check-extensions.yml +3 -0
  8. package/.github/workflows/tests.yml +26 -0
  9. package/.plugin/marketplace.json +30 -0
  10. package/.release-please-manifest.json +1 -1
  11. package/AGENTS.md +3 -2
  12. package/README.md +10 -3
  13. package/apps/README.md +20 -0
  14. package/apps/catalog.json +10 -0
  15. package/apps/index.d.ts +11 -0
  16. package/apps/index.js +5 -0
  17. package/apps/vscode/README.md +13 -0
  18. package/apps/vscode/canvas-extension.json +19 -0
  19. package/apps/vscode/extension.js +114 -0
  20. package/apps/vscode/scripts/package.mjs +84 -0
  21. package/automations/bundle-index.js +18 -14
  22. package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
  23. package/automations/catalog/github-pr-reviewer/manifest.json +19 -8
  24. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +71 -0
  25. package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
  26. package/automations/catalog/news-digest/manifest.json +2 -2
  27. package/automations/catalog-index.js +2 -0
  28. package/index.d.ts +2 -0
  29. package/index.js +1 -0
  30. package/marketplaces/openhands-extensions.json +30 -0
  31. package/package.json +9 -1
  32. package/plugins/openhands/skills/openhands-api/README.md +1 -1
  33. package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
  34. package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
  35. package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
  36. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
  37. package/plugins/openhands/skills/openhands-sdk/SKILL.md +1 -0
  38. package/plugins/pr-review/skills/github-pr-review/SKILL.md +12 -2
  39. package/plugins/qa-changes/skills/github-pr-review/SKILL.md +12 -2
  40. package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
  41. package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
  42. package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
  43. package/plugins/runtime-api-configs/README.md +59 -0
  44. package/plugins/runtime-api-configs/SKILL.md +240 -0
  45. package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
  46. package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
  47. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
  48. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +183 -15
  49. package/pyproject.toml +1 -1
  50. package/python/openhands_extensions/_version.py +1 -1
  51. package/scripts/check_plugin_resources.py +116 -0
  52. package/scripts/sync_extensions.py +32 -5
  53. package/skills/canvas-extension-api/SKILL.md +34 -3
  54. package/skills/canvas-extension-api/references/backend-safety.md +14 -0
  55. package/skills/canvas-extension-api/references/connections.md +24 -0
  56. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
  57. package/skills/canvas-extension-api/references/v1-contract.md +53 -3
  58. package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
  59. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
  60. package/skills/github/scripts/agent_conversation.py +223 -43
  61. package/skills/github/scripts/github_client.py +183 -15
  62. package/skills/github-agents-md-maintainer/README.md +7 -0
  63. package/skills/github-agents-md-maintainer/SKILL.md +13 -6
  64. package/skills/github-agents-md-maintainer/scripts/github_client.py +183 -15
  65. package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
  66. package/skills/github-delivery-watchdog/scripts/github_client.py +183 -15
  67. package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
  68. package/skills/github-issue-to-pr/scripts/github_client.py +183 -15
  69. package/skills/github-issue-to-pr/scripts/worker.py +10 -1
  70. package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
  71. package/skills/github-issue-triage/scripts/github_client.py +183 -15
  72. package/skills/github-pr-review/SKILL.md +12 -2
  73. package/skills/github-pr-reviewer/README.md +36 -0
  74. package/skills/github-pr-reviewer/SKILL.md +147 -4
  75. package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
  76. package/skills/github-pr-reviewer/scripts/github_client.py +183 -15
  77. package/skills/github-pr-reviewer/scripts/main.py +17 -1
  78. package/skills/github-pr-reviewer/scripts/worker.py +842 -57
  79. package/skills/github-repo-monitor/scripts/github_client.py +183 -15
  80. package/skills/github-repo-monitor/scripts/main.py +32 -1
  81. package/skills/github-stale-ci-pr-closer/.claude-plugin/plugin.json +9 -0
  82. package/skills/github-stale-ci-pr-closer/.codex-plugin/plugin.json +9 -0
  83. package/skills/github-stale-ci-pr-closer/.plugin/plugin.json +9 -0
  84. package/skills/github-stale-ci-pr-closer/README.md +9 -0
  85. package/skills/github-stale-ci-pr-closer/SKILL.md +24 -0
  86. package/skills/github-stale-ci-pr-closer/commands/github-stale-ci-pr-closer.md +8 -0
  87. package/skills/github-stale-ci-pr-closer/scripts/worker.py +508 -0
  88. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +353 -0
  89. package/skills/gitlab-issue-to-mr/README.md +12 -0
  90. package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
  91. package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
  92. package/skills/incident-retrospective/SKILL.md +5 -5
  93. package/skills/index.js +34 -16
  94. package/skills/jira-issue-to-pr/README.md +12 -4
  95. package/skills/jira-issue-to-pr/SKILL.md +129 -18
  96. package/skills/jira-issue-to-pr/references/setup.md +8 -0
  97. package/skills/jira-issue-to-pr/scripts/main.py +181 -54
  98. package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
  99. package/skills/linear-triage/SKILL.md +5 -5
  100. package/skills/news-digest/README.md +6 -0
  101. package/skills/news-digest/SKILL.md +19 -9
  102. package/skills/news-digest/commands/news-digest-setup.md +1 -1
  103. package/skills/news-digest/scripts/main.py +133 -15
  104. package/skills/openhands-api/README.md +1 -1
  105. package/skills/openhands-api/SKILL.md +3 -3
  106. package/skills/openhands-api/scripts/openhands_api.py +13 -0
  107. package/skills/openhands-automation/SKILL.md +66 -4
  108. package/skills/openhands-automation/references/custom-automation.md +21 -7
  109. package/skills/openhands-sdk/SKILL.md +1 -0
  110. package/skills/research-brief/SKILL.md +5 -5
  111. package/skills/slack-standup-digest/SKILL.md +5 -5
  112. package/skills/ticket-to-code-change/SKILL.md +6 -3
  113. package/skills/use-jev/.claude-plugin/plugin.json +8 -0
  114. package/skills/use-jev/.codex-plugin/plugin.json +8 -0
  115. package/skills/use-jev/.plugin/plugin.json +8 -0
  116. package/skills/use-jev/README.md +66 -0
  117. package/skills/use-jev/SKILL.md +66 -0
  118. package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
  119. package/tests/test_agent_conversation_dispatch.py +212 -0
  120. package/tests/test_apps_catalog.py +26 -0
  121. package/tests/test_automation_setup.py +44 -0
  122. package/tests/test_check_pr_description.py +89 -6
  123. package/tests/test_github_automation_foundation.py +158 -0
  124. package/tests/test_github_repo_monitor.py +107 -0
  125. package/tests/test_github_reviewer_delivery.py +2247 -6
  126. package/tests/test_github_stale_ci_pr_closer.py +56 -0
  127. package/tests/test_jira_issue_to_pr.py +312 -0
  128. package/tests/test_jira_setup_webhook.py +309 -0
  129. package/tests/test_openhands_api_python.py +37 -0
  130. package/tests/test_skill_plugin_loading.py +25 -15
  131. package/tests/test_sync_extensions.py +102 -0
  132. package/tests/test_vscode_app.py +33 -0
  133. 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
- 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"
112
- )
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"
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
- triggered_at = trigger.get("created_at") or ""
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
- if payload is None:
219
- prs = self.gh_pages("/pulls?state=open&sort=updated&direction=asc")
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
- 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"]
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 trigger is None:
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
- trigger_label = None if event_mode else label
247
- if self._finish_completed_review(pr, trigger, trigger_label):
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
- 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),
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
- 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
- }
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
- flush=True,
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
- run_repositories(PullRequestReviewer, dispatcher=dispatcher)
1076
+ run_scan(dispatcher)