@openhands/extensions 0.25.0 → 0.27.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 (123) hide show
  1. package/.claude-plugin/marketplace.json +24 -0
  2. package/.codex-plugin/marketplace.json +24 -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 +24 -0
  10. package/.release-please-manifest.json +1 -1
  11. package/AGENTS.md +3 -2
  12. package/README.md +9 -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/README.md +7 -6
  22. package/automations/bundle-index.js +15 -15
  23. package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
  24. package/automations/catalog/github-pr-reviewer/manifest.json +5 -5
  25. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +3 -3
  26. package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
  27. package/automations/catalog/news-digest/manifest.json +2 -2
  28. package/automations/index.d.ts +9 -1
  29. package/automations/interface.json +9 -0
  30. package/automations/interface.schema.json +37 -2
  31. package/index.d.ts +2 -0
  32. package/index.js +1 -0
  33. package/marketplaces/openhands-extensions.json +24 -0
  34. package/package.json +9 -1
  35. package/plugins/openhands/skills/openhands-api/README.md +1 -1
  36. package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
  37. package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
  38. package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
  39. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
  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 +67 -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 +67 -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 +67 -15
  65. package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
  66. package/skills/github-delivery-watchdog/scripts/github_client.py +67 -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 +67 -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 +67 -15
  72. package/skills/github-pr-reviewer/README.md +5 -4
  73. package/skills/github-pr-reviewer/SKILL.md +8 -17
  74. package/skills/github-pr-reviewer/references/state-schema.md +0 -15
  75. package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
  76. package/skills/github-pr-reviewer/scripts/github_client.py +67 -15
  77. package/skills/github-pr-reviewer/scripts/worker.py +131 -108
  78. package/skills/github-repo-monitor/scripts/github_client.py +67 -15
  79. package/skills/github-stale-ci-pr-closer/scripts/worker.py +223 -50
  80. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +155 -15
  81. package/skills/gitlab-issue-to-mr/README.md +12 -0
  82. package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
  83. package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
  84. package/skills/incident-retrospective/SKILL.md +5 -5
  85. package/skills/index.js +23 -14
  86. package/skills/jira-issue-to-pr/README.md +12 -4
  87. package/skills/jira-issue-to-pr/SKILL.md +129 -18
  88. package/skills/jira-issue-to-pr/references/setup.md +8 -0
  89. package/skills/jira-issue-to-pr/scripts/main.py +181 -54
  90. package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
  91. package/skills/linear-triage/SKILL.md +5 -5
  92. package/skills/news-digest/README.md +6 -0
  93. package/skills/news-digest/SKILL.md +19 -9
  94. package/skills/news-digest/commands/news-digest-setup.md +1 -1
  95. package/skills/news-digest/scripts/main.py +133 -15
  96. package/skills/openhands-api/README.md +1 -1
  97. package/skills/openhands-api/SKILL.md +3 -3
  98. package/skills/openhands-api/scripts/openhands_api.py +13 -0
  99. package/skills/openhands-automation/SKILL.md +66 -4
  100. package/skills/openhands-automation/references/custom-automation.md +21 -7
  101. package/skills/research-brief/SKILL.md +5 -5
  102. package/skills/slack-standup-digest/SKILL.md +5 -5
  103. package/skills/ticket-to-code-change/SKILL.md +6 -3
  104. package/skills/use-jev/.claude-plugin/plugin.json +8 -0
  105. package/skills/use-jev/.codex-plugin/plugin.json +8 -0
  106. package/skills/use-jev/.plugin/plugin.json +8 -0
  107. package/skills/use-jev/README.md +66 -0
  108. package/skills/use-jev/SKILL.md +66 -0
  109. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  110. package/tests/test_agent_conversation_dispatch.py +212 -0
  111. package/tests/test_apps_catalog.py +26 -0
  112. package/tests/test_automation_setup.py +44 -0
  113. package/tests/test_check_pr_description.py +89 -6
  114. package/tests/test_github_reviewer_delivery.py +254 -118
  115. package/tests/test_github_stale_ci_pr_closer.py +56 -0
  116. package/tests/test_interface_manifest.py +23 -1
  117. package/tests/test_jira_issue_to_pr.py +312 -0
  118. package/tests/test_jira_setup_webhook.py +309 -0
  119. package/tests/test_openhands_api_python.py +37 -0
  120. package/tests/test_skill_plugin_loading.py +25 -15
  121. package/tests/test_sync_extensions.py +102 -0
  122. package/tests/test_vscode_app.py +33 -0
  123. package/tests/vscode-app.test.mjs +43 -0
@@ -166,12 +166,48 @@ def test_reviewer_submits_each_labeled_exact_head(tmp_path, monkeypatch):
166
166
  assert "GIT_TERMINAL_PROMPT=0" in prompt
167
167
  assert "Never paste JSON artifacts" in prompt
168
168
  assert "stop immediately" in prompt
169
+ # The prompt carries the dedup rule as well as the worker enforcing it: the
170
+ # agent is the one that reads GitHub before publishing, so it must be told
171
+ # not to publish a second review of a head it already reviewed.
172
+ assert "do NOT publish a second review" in prompt
173
+ assert "currently underway for this head" in prompt
169
174
 
170
175
 
171
- def test_reviewer_submits_requested_exact_head(tmp_path, monkeypatch):
176
+ def test_reviewer_prompt_forbids_republishing_an_unchanged_reviewed_head(
177
+ tmp_path, monkeypatch
178
+ ):
179
+ """The reviewer prompt names the head and the no-clarification condition."""
180
+ module, run = _reviewer(tmp_path, monkeypatch)
181
+ pr = {
182
+ "number": 2,
183
+ "head": {"sha": "head-2"},
184
+ "labels": [{"name": "openhands-review"}],
185
+ }
186
+ run.gh_pages = lambda path: [pr]
187
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
188
+ monkeypatch.setattr(
189
+ module.workflow,
190
+ "_latest_trigger_label_event",
191
+ lambda *args: {"id": 7, "created_at": "now"},
192
+ )
193
+ submit = Mock(
194
+ return_value={"disposition": "created", "conversation_id": "conversation"}
195
+ )
196
+ run.dispatcher.deliver = submit
197
+
198
+ run.run()
199
+
200
+ prompt = submit.call_args.kwargs["prompt"]
201
+ assert "already published a review on `head-2`" in prompt
202
+ assert "no one else has commented since that review" in prompt
203
+ assert "Review the head again only when the head has moved" in prompt
204
+
205
+
206
+ @pytest.mark.parametrize("draft", [False, True])
207
+ def test_reviewer_submits_requested_exact_head(tmp_path, monkeypatch, draft):
172
208
  _module, run = _reviewer(tmp_path, monkeypatch)
173
209
  _event(monkeypatch)
174
- pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
210
+ pr = {"number": 2, "head": {"sha": "head-2"}, "labels": [], "draft": draft}
175
211
  request = {
176
212
  "id": 42,
177
213
  "event": "review_requested",
@@ -374,7 +410,16 @@ def test_reviewer_submitted_review_on_superseded_head_does_not_dispatch(
374
410
  handoff.assert_not_called()
375
411
 
376
412
 
377
- def test_reviewer_redelivers_when_review_predates_latest_label(tmp_path, monkeypatch):
413
+ def test_reviewer_does_not_redeliver_a_label_for_an_already_reviewed_head(
414
+ tmp_path, monkeypatch
415
+ ):
416
+ """A re-applied label on an unchanged, already-reviewed head is not new work.
417
+
418
+ The label event predates the existing review, so `_finish_completed_review`
419
+ would not treat the review as this event's result and the old code dispatched
420
+ a second review of identical code. A review on the current head with nothing
421
+ posted after it is already the answer, whatever trigger arrives next.
422
+ """
378
423
  _module, run = _reviewer(tmp_path, monkeypatch)
379
424
  pr = {
380
425
  "number": 2,
@@ -386,6 +431,42 @@ def test_reviewer_redelivers_when_review_predates_latest_label(tmp_path, monkeyp
386
431
  if path.startswith("/pulls?")
387
432
  else _reviews(submitted_at="2025-12-31T00:00:00Z")
388
433
  )
434
+ run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
435
+ run.dispatcher.deliver.return_value = {
436
+ "disposition": "continued",
437
+ "conversation_id": "conversation",
438
+ }
439
+
440
+ run.run()
441
+
442
+ run.dispatcher.deliver.assert_not_called()
443
+
444
+
445
+ def test_reviewer_redelivers_a_label_when_a_human_clarified_since(
446
+ tmp_path, monkeypatch
447
+ ):
448
+ """A comment from someone else after the review makes the head reviewable."""
449
+ _module, run = _reviewer(tmp_path, monkeypatch)
450
+ pr = {
451
+ "number": 2,
452
+ "head": {"sha": "head-2"},
453
+ "labels": [{"name": "openhands-review"}],
454
+ }
455
+
456
+ def pages(path):
457
+ if path.startswith("/pulls?"):
458
+ return [pr]
459
+ if path.endswith("/comments"):
460
+ return [
461
+ {
462
+ "body": "I added the missing test you asked for.",
463
+ "created_at": "2026-01-02T00:00:00Z",
464
+ "user": {"login": "neubig"},
465
+ }
466
+ ]
467
+ return _reviews(submitted_at="2025-12-31T00:00:00Z")
468
+
469
+ run.gh_pages = pages
389
470
  run.gh = Mock(side_effect=[{"id": 99}, pr])
390
471
  run.dispatcher.deliver.return_value = {
391
472
  "disposition": "continued",
@@ -396,6 +477,130 @@ def test_reviewer_redelivers_when_review_predates_latest_label(tmp_path, monkeyp
396
477
 
397
478
  run.dispatcher.deliver.assert_called_once()
398
479
  assert run.dispatcher.deliver.call_args.kwargs["delivery"] == "7:head-2"
480
+ assert run.dispatcher.deliver.call_args.kwargs["head"] == "head-2"
481
+
482
+
483
+ def test_reviewer_does_not_re_review_a_head_after_a_repeat_request(
484
+ tmp_path, monkeypatch
485
+ ):
486
+ """The extensions#698 sequence: repeat requests must not review twice.
487
+
488
+ A review landed on `head-2`; a second review request arrived for the same
489
+ head with no clarifying comment in between. The old trigger-keyed delivery
490
+ started another conversation and published a second review of identical
491
+ code (three APPROVED reviews on one head in production). The head is now
492
+ the guard: an unchanged head with nothing posted after the review is done.
493
+ """
494
+ _module, run = _reviewer(tmp_path, monkeypatch)
495
+ pr = _requested_pr(2, "head-2")
496
+ run.check_runs = lambda sha: _checks(("ci", "completed", "success"), sha=sha)
497
+ submitted = Mock(
498
+ return_value={"disposition": "created", "conversation_id": "first"}
499
+ )
500
+ run.dispatcher.deliver = submitted
501
+
502
+ # First request: no review exists yet, so the review is dispatched.
503
+ run.gh_pages = _request_pages(pr, _review_request_event(42), [])
504
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
505
+ _event(monkeypatch)
506
+ run.run()
507
+ assert submitted.call_count == 1
508
+
509
+ # Second request for the same head, and the review is now on GitHub with no
510
+ # comment after it: the repeat request starts nothing. The review predates
511
+ # this request (the #698 ordering), so `_finish_completed_review` alone would
512
+ # not recognize it as this request's result and the old code dispatched again.
513
+ reviewed = _reviews(
514
+ sha="head-2", submitted_at="2026-01-02T00:00:00Z"
515
+ )
516
+ later_request = {**_review_request_event(43), "created_at": "2026-01-03T00:00:00Z"}
517
+
518
+ def pages(path):
519
+ if path.startswith("/pulls?"):
520
+ return [pr]
521
+ if path.endswith("/events"):
522
+ return [later_request]
523
+ if path.endswith("/reviews"):
524
+ return reviewed
525
+ return []
526
+
527
+ run.gh_pages = pages
528
+ run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
529
+ _event(monkeypatch)
530
+ run.run()
531
+
532
+ assert submitted.call_count == 1
533
+
534
+
535
+ def test_reviewer_rereviews_a_head_after_a_dismissed_review(
536
+ tmp_path, monkeypatch
537
+ ):
538
+ """A dismissed review does not stand: the head is reviewable again.
539
+
540
+ GitHub keeps a dismissed review in /pulls/{n}/reviews with its commit_id and
541
+ state "DISMISSED". Counting it as a standing review would suppress every
542
+ later review of that head forever, because no other condition clears it. A
543
+ maintainer who dismisses the verdict and re-requests review is asking for a
544
+ fresh look, so the guard must skip dismissed reviews.
545
+ """
546
+ _module, run = _reviewer(tmp_path, monkeypatch)
547
+ pr = {
548
+ "number": 2,
549
+ "head": {"sha": "head-2"},
550
+ "labels": [{"name": "openhands-review"}],
551
+ }
552
+
553
+ def pages(path):
554
+ if path.startswith("/pulls?"):
555
+ return [pr]
556
+ reviews = _reviews(submitted_at="2025-12-31T00:00:00Z")
557
+ reviews[0]["state"] = "DISMISSED"
558
+ return reviews
559
+
560
+ run.gh_pages = pages
561
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
562
+ run.dispatcher.deliver.return_value = {
563
+ "disposition": "continued",
564
+ "conversation_id": "conversation",
565
+ }
566
+
567
+ run.run()
568
+
569
+ run.dispatcher.deliver.assert_called_once()
570
+
571
+
572
+ def test_reviewer_clears_the_label_when_the_review_predates_the_trigger(
573
+ tmp_path, monkeypatch
574
+ ):
575
+ """The guard must clear the label, not leave a scan re-reading forever.
576
+
577
+ A review predating the re-applied label yields no `completed` entry in
578
+ `_finish_completed_review`, so that call returns before its label DELETE.
579
+ Without clearing the label here the label stays and every later scan re-reads
580
+ this head and re-logs `review-already-published` with no effect.
581
+ """
582
+ _module, run = _reviewer(tmp_path, monkeypatch)
583
+ pr = {
584
+ "number": 2,
585
+ "head": {"sha": "head-2"},
586
+ "labels": [{"name": "openhands-review"}],
587
+ }
588
+ run.gh_pages = lambda path: (
589
+ [pr]
590
+ if path.startswith("/pulls?")
591
+ else _reviews(submitted_at="2025-12-31T00:00:00Z")
592
+ )
593
+ run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
594
+
595
+ run.run()
596
+
597
+ run.dispatcher.deliver.assert_not_called()
598
+ deleted = [
599
+ call.args[1]
600
+ for call in run.gh.call_args_list
601
+ if call.args[0] == "DELETE"
602
+ ]
603
+ assert "/issues/2/labels/openhands-review" in deleted
399
604
 
400
605
 
401
606
  def test_reviewer_does_not_trust_another_reviewers_verdict(tmp_path, monkeypatch):
@@ -1565,10 +1770,11 @@ def _request_pages(pr, request, comments):
1565
1770
  )
1566
1771
 
1567
1772
 
1568
- def test_reviewer_scheduled_scan_reviews_an_outstanding_request(tmp_path, monkeypatch):
1773
+ @pytest.mark.parametrize("draft", [False, True])
1774
+ def test_reviewer_scheduled_scan_reviews_an_outstanding_request(tmp_path, monkeypatch, draft):
1569
1775
  """A request made before CI finished is reviewed once the head is green."""
1570
1776
  _module, run = _reviewer(tmp_path, monkeypatch)
1571
- pr = _requested_pr()
1777
+ pr = _requested_pr(draft=draft)
1572
1778
  state = {"runs": _checks(("slow-e2e", "queued", None), sha="head-2")}
1573
1779
  run.check_runs = lambda sha: state["runs"]
1574
1780
  run.gh_pages = _request_pages(pr, _review_request_event(), [])
@@ -1597,12 +1803,13 @@ def test_reviewer_scheduled_scan_reviews_an_outstanding_request(tmp_path, monkey
1597
1803
  assert "latest review request for `all-hands-bot` event 42" in call["prompt"]
1598
1804
 
1599
1805
 
1806
+ @pytest.mark.parametrize("draft", [False, True])
1600
1807
  def test_reviewer_repeated_scans_do_not_duplicate_a_requested_review(
1601
- tmp_path, monkeypatch
1808
+ tmp_path, monkeypatch, draft
1602
1809
  ):
1603
1810
  """Two scans over one outstanding request produce one stable delivery key."""
1604
1811
  _module, run = _reviewer(tmp_path, monkeypatch)
1605
- pr = _requested_pr()
1812
+ pr = _requested_pr(draft=draft)
1606
1813
  run.check_runs = lambda sha: _checks(("ci", "completed", "success"), sha=sha)
1607
1814
  run.dispatcher.deliver.return_value = {
1608
1815
  "disposition": "created",
@@ -1621,11 +1828,12 @@ def test_reviewer_repeated_scans_do_not_duplicate_a_requested_review(
1621
1828
  assert deliveries == {"42:head-2"}
1622
1829
 
1623
1830
 
1624
- def test_reviewer_scheduled_scan_ignores_a_draft_with_a_request(
1831
+ def test_reviewer_scheduled_scan_ignores_a_draft_requested_from_someone_else(
1625
1832
  tmp_path, monkeypatch
1626
1833
  ):
1627
1834
  _module, run = _reviewer(tmp_path, monkeypatch)
1628
1835
  pr = _requested_pr(draft=True)
1836
+ pr["requested_reviewers"] = [{"login": "another-reviewer"}]
1629
1837
  run.gh_pages = lambda path: [pr]
1630
1838
  run.gh = Mock(return_value={"id": 99})
1631
1839
 
@@ -1789,17 +1997,21 @@ class _DedupeDispatcher:
1789
1997
  reproduces only the contract the worker relies on: a delivery keyed by
1790
1998
  (subject, delivery) starts one conversation and reports `created` once, then
1791
1999
  reports `deduplicated` for the same key. That is what lets a repeat scan
1792
- reuse a conversation without spending another intake slot.
2000
+ reuse a conversation without spending another intake slot. It also records
2001
+ the head the caller named, which the worker's own pre-dispatch guard uses to
2002
+ suppress a second review of an unchanged head.
1793
2003
  """
1794
2004
 
1795
2005
  def __init__(self):
1796
2006
  self.seen = {}
2007
+ self.heads = []
1797
2008
  self.calls = []
1798
2009
  self.prompts = []
1799
2010
 
1800
- def deliver(self, *, subject, delivery, prompt):
2011
+ def deliver(self, *, subject, delivery, prompt, head=""):
1801
2012
  self.calls.append((subject, delivery))
1802
2013
  self.prompts.append(prompt)
2014
+ self.heads.append(head)
1803
2015
  if self.seen.get(subject) == delivery:
1804
2016
  return {"disposition": "deduplicated", "conversation_id": subject}
1805
2017
  self.seen[subject] = delivery
@@ -2077,7 +2289,7 @@ def test_a_failing_dispatch_is_reported_without_consuming_a_slot_or_aborting(
2077
2289
  101,
2078
2290
  )
2079
2291
 
2080
- def deliver(*, subject, delivery, prompt):
2292
+ def deliver(*, subject, delivery, prompt, head=""):
2081
2293
  if subject == "101:pr:5":
2082
2294
  raise RuntimeError("agent server unavailable")
2083
2295
  one.dispatcher.seen[subject] = delivery
@@ -2474,32 +2686,10 @@ def test_unrequested_scan_marks_a_self_authored_pr_for_the_comment_verdict(
2474
2686
 
2475
2687
 
2476
2688
  # --------------------------------------------------------------------------- #
2477
- # Bounded rotating scan window: each scheduled scan examines a bounded slice of
2478
- # the unrequested backlog, remembers its position in the Automation KV store, and
2479
- # resumes there on the next scan. Explicit requests are never subject to it.
2689
+ # Comprehensive scheduled scan: inspect the backlog, cap only review launches.
2480
2690
  # --------------------------------------------------------------------------- #
2481
2691
 
2482
2692
 
2483
- def _kv_store(module, monkeypatch):
2484
- """Back the scan cursor with an in-memory stand-in for the Automation KV store.
2485
-
2486
- The real store is an HTTP service; this reproduces the contract the cursor
2487
- relies on - a per-key `GET` that misses with None and a `PUT` that replaces
2488
- the value - so the shipped `ScanCursor` persists and reloads through it.
2489
- """
2490
- values = {}
2491
- monkeypatch.setattr(module.workflow, "_kv_available", lambda: True)
2492
- monkeypatch.setattr(
2493
- module.workflow, "_kv_get", lambda key: values.get(key)
2494
- )
2495
-
2496
- def put(key, value):
2497
- values[key] = value
2498
-
2499
- monkeypatch.setattr(module.workflow, "_kv_set", put)
2500
- return values
2501
-
2502
-
2503
2693
  def _many_unrequested(count, *, checks=()):
2504
2694
  """`count` open, non-draft PRs nobody requested, numbered 1..count."""
2505
2695
  return [
@@ -2518,74 +2708,37 @@ def _examined(run):
2518
2708
  return [int(subject.split(":")[-1]) for subject, _ in run.dispatcher.calls]
2519
2709
 
2520
2710
 
2521
- def test_scan_examines_a_bounded_rotating_window_of_unrequested_prs(
2522
- tmp_path, monkeypatch
2523
- ):
2524
- """Each scan covers the next slice, so the whole backlog is reached in turn."""
2525
- module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=5)
2526
- monkeypatch.setattr(module, "SCAN_WINDOW", 2)
2527
- _kv_store(module, monkeypatch)
2528
- _wire_scan(one, _many_unrequested(5), 101)
2529
-
2530
- _run_scan(module, [one])
2531
- assert _examined(one) == [1, 2]
2532
-
2533
- _run_scan(module, [one])
2534
- assert _examined(one)[-2:] == [3, 4]
2535
-
2536
- _run_scan(module, [one])
2537
- assert _examined(one)[-1:] == [5]
2538
-
2539
- # Past the end the window wraps, so the backlog keeps rotating.
2540
- _run_scan(module, [one])
2541
- assert _examined(one)[-2:] == [1, 2]
2542
-
2543
-
2544
- def test_the_scan_position_is_persisted_per_repository_in_the_kv_store(
2711
+ def test_scan_skips_blocked_heads_until_it_fills_the_review_quota(
2545
2712
  tmp_path, monkeypatch
2546
2713
  ):
2547
- """The cursor is written under a per-repository key, so a later run resumes.
2714
+ """The quota limits launches, not how many backlog entries are inspected.
2548
2715
 
2549
- A cron run is a fresh process, so the only way the window survives is the
2550
- Automation KV store; this pins the key and the value the next scan reads.
2716
+ Ten blocked heads at the front of the backlog must not consume the ten-review
2717
+ launch quota or prevent the ten green heads behind them from being reviewed.
2551
2718
  """
2552
- module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=5)
2553
- monkeypatch.setattr(module, "SCAN_WINDOW", 2)
2554
- values = _kv_store(module, monkeypatch)
2555
- _wire_scan(one, _many_unrequested(5), 101)
2556
-
2557
- _run_scan(module, [one])
2558
-
2559
- assert values == {"review-scan:owner__one": {"cursor": 2}}
2560
-
2561
-
2562
- def test_explicit_requests_are_examined_regardless_of_the_rotation_window(
2563
- tmp_path, monkeypatch
2564
- ):
2565
- """An explicit request is never skipped because the window sits elsewhere.
2566
-
2567
- The cursor points at the last unrequested PR, but the explicit request on
2568
- PR #1 is still examined and dispatched, because a caller asked for it.
2569
- """
2570
- module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=5)
2571
- monkeypatch.setattr(module, "SCAN_WINDOW", 1)
2572
- values = _kv_store(module, monkeypatch)
2573
- # Only PR #1 holds a request; the rest are unrequested and the window sits on
2574
- # the last of them, so the request must still be examined.
2575
- _wire_scan(
2576
- one,
2577
- [_eligible_pr(1, "head-1", 100, "2026-01-01T00:00:00Z")]
2578
- + _many_unrequested(4)[1:],
2579
- 101,
2580
- )
2581
- values["review-scan:owner__one"] = {"cursor": 2}
2719
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=10)
2720
+ blocked = [
2721
+ _unrequested_pr(
2722
+ number,
2723
+ f"head-{number}",
2724
+ created_at=f"2026-01-{number:02d}T00:00:00Z",
2725
+ checks=(("ci", "completed", "failure"),),
2726
+ )
2727
+ for number in range(1, 11)
2728
+ ]
2729
+ green = [
2730
+ _unrequested_pr(
2731
+ number,
2732
+ f"head-{number}",
2733
+ created_at=f"2026-01-{number:02d}T00:00:00Z",
2734
+ )
2735
+ for number in range(11, 21)
2736
+ ]
2737
+ _wire_scan(one, blocked + green, 101)
2582
2738
 
2583
2739
  _run_scan(module, [one])
2584
2740
 
2585
- assert one.dispatcher.seen == {
2586
- "101:pr:1": "100:head-1",
2587
- "101:pr:4": "scan:owner/one:4:head-4",
2588
- }
2741
+ assert _examined(one) == list(range(11, 21))
2589
2742
 
2590
2743
 
2591
2744
  def test_unrequested_red_and_pending_prs_post_no_gate_comments(
@@ -2598,7 +2751,6 @@ def test_unrequested_red_and_pending_prs_post_no_gate_comments(
2598
2751
  managed explanation.
2599
2752
  """
2600
2753
  module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=5)
2601
- monkeypatch.setattr(module, "SCAN_WINDOW", 20)
2602
2754
  _wire_scan(
2603
2755
  one,
2604
2756
  [
@@ -2623,36 +2775,20 @@ def test_unrequested_red_and_pending_prs_post_no_gate_comments(
2623
2775
  assert one.dispatcher.seen == {"101:pr:7": "scan:owner/one:7:head-7"}
2624
2776
 
2625
2777
 
2626
- def test_scan_reads_a_bounded_number_of_pull_requests_per_repository(
2778
+ def test_scan_reads_every_pull_request_but_starts_only_the_quota(
2627
2779
  tmp_path, monkeypatch
2628
2780
  ):
2629
- """A scan reads one list page plus at most one head per examined candidate.
2630
-
2631
- A full scan would issue one full pull read per open PR - the API cost that
2632
- made the canary time out. With a window of 10 over 40 PRs, only the 10 in the
2633
- window are read.
2634
- """
2781
+ """Classification is comprehensive while launches remain bounded."""
2635
2782
  module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=5)
2636
- monkeypatch.setattr(module, "SCAN_WINDOW", 10)
2637
- _kv_store(module, monkeypatch)
2638
2783
  _wire_scan(one, _many_unrequested(40), 101)
2639
-
2640
- list_reads = []
2641
- original_pages = one.gh_pages
2642
-
2643
- def counting_pages(path):
2644
- if path.startswith("/pulls?"):
2645
- list_reads.append(path)
2646
- return original_pages(path)
2647
-
2648
- one.gh_pages = counting_pages
2649
2784
  one.gh = Mock(side_effect=one.gh)
2650
2785
 
2651
2786
  _run_scan(module, [one])
2652
2787
 
2653
2788
  full_reads = [
2654
- call for call in one.gh.call_args_list
2789
+ call
2790
+ for call in one.gh.call_args_list
2655
2791
  if call.args[0] == "GET" and str(call.args[1]).startswith("/pulls/")
2656
2792
  ]
2657
- assert len(list_reads) == 1
2658
- assert len(full_reads) == 10
2793
+ assert len(full_reads) == 40
2794
+ assert _examined(one) == [1, 2, 3, 4, 5]
@@ -0,0 +1,56 @@
1
+ """Tests for how the stale-CI pull request closer reports its run."""
2
+
3
+ import json
4
+
5
+ import pytest
6
+ from github_automation_helpers import worker
7
+
8
+
9
+ @pytest.mark.parametrize(
10
+ ("environment", "expected"),
11
+ [
12
+ pytest.param(
13
+ {"AUTOMATION_CALLBACK_API_KEY": "callback-key"},
14
+ "Bearer callback-key",
15
+ id="local-run-uses-its-callback-key",
16
+ ),
17
+ pytest.param(
18
+ {"OPENHANDS_API_KEY": "openhands-key"},
19
+ "Bearer openhands-key",
20
+ id="cloud-run-has-no-callback-key-and-uses-its-api-key",
21
+ ),
22
+ ],
23
+ )
24
+ def test_completion_is_reported_with_the_key_the_run_was_given(
25
+ tmp_path, monkeypatch, environment, expected
26
+ ):
27
+ # Arrange
28
+ module = worker("github-stale-ci-pr-closer", tmp_path, monkeypatch)
29
+ for name in ("AUTOMATION_CALLBACK_API_KEY", "OPENHANDS_API_KEY"):
30
+ monkeypatch.delenv(name, raising=False)
31
+ for name, value in environment.items():
32
+ monkeypatch.setenv(name, value)
33
+ monkeypatch.setenv("AUTOMATION_CALLBACK_URL", "https://automation.test/callback")
34
+ monkeypatch.setenv("AUTOMATION_RUN_ID", "run-1")
35
+ sent = []
36
+
37
+ class _Response:
38
+ def __enter__(self):
39
+ return self
40
+
41
+ def __exit__(self, *args):
42
+ return False
43
+
44
+ def urlopen(request):
45
+ sent.append(request)
46
+ return _Response()
47
+
48
+ monkeypatch.setattr(module, "urlopen", urlopen)
49
+
50
+ # Act
51
+ module.complete_run()
52
+
53
+ # Assert
54
+ [request] = sent
55
+ assert request.get_header("Authorization") == expected
56
+ assert json.loads(request.data) == {"status": "COMPLETED", "run_id": "run-1"}
@@ -30,6 +30,12 @@ def _with(mutation):
30
30
  return document
31
31
 
32
32
 
33
+ def _filter(document, filter_id):
34
+ return next(
35
+ f for f in document["pages"]["list"]["filters"] if f["id"] == filter_id
36
+ )
37
+
38
+
33
39
  def test_schema_is_a_valid_draft_2020_12_schema():
34
40
  jsonschema.Draft202012Validator.check_schema(SCHEMA)
35
41
 
@@ -94,7 +100,23 @@ def test_featured_automations_resolve_to_catalog_entries():
94
100
  ),
95
101
  (
96
102
  "a status filter without the all option",
97
- lambda doc: doc["pages"]["list"]["filters"][0]["options"].pop(0),
103
+ lambda doc: _filter(doc, "status")["options"].pop(0),
104
+ ),
105
+ (
106
+ "a creator filter without the all option",
107
+ lambda doc: _filter(doc, "created_by")["options"].pop(0),
108
+ ),
109
+ (
110
+ "a creator filter value the host does not implement",
111
+ lambda doc: _filter(doc, "created_by")["options"].append(
112
+ {"value": "team", "label": "My team"}
113
+ ),
114
+ ),
115
+ (
116
+ "more filters than the host renders",
117
+ lambda doc: doc["pages"]["list"]["filters"].append(
118
+ {**doc["pages"]["list"]["filters"][0], "label": "Filter again"}
119
+ ),
98
120
  ),
99
121
  ],
100
122
  )