@openhands/extensions 0.27.0 → 0.29.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 (70) hide show
  1. package/.agents/skills/custom-codereview-guide.md +31 -0
  2. package/.claude-plugin/marketplace.json +9 -0
  3. package/.codex-plugin/marketplace.json +9 -0
  4. package/.github/workflows/build-vscode-app.yml +3 -3
  5. package/.github/workflows/deprecation-check.yml +1 -1
  6. package/.github/workflows/pypi-publish.yml +2 -2
  7. package/.github/workflows/tests.yml +1 -1
  8. package/.plugin/marketplace.json +9 -0
  9. package/.release-please-manifest.json +1 -1
  10. package/AGENTS.md +7 -2
  11. package/README.md +3 -2
  12. package/automations/bundle-index.js +5 -5
  13. package/automations/catalog/github-issue-to-pr/manifest.json +2 -2
  14. package/automations/catalog/github-issue-triage/manifest.json +2 -2
  15. package/automations/catalog/github-pr-reviewer/manifest.json +2 -2
  16. package/marketplaces/openhands-extensions.json +9 -0
  17. package/package.json +1 -1
  18. package/plugins/openhands/skills/openhands-automation/README.md +6 -4
  19. package/plugins/openhands/skills/openhands-automation/SKILL.md +103 -21
  20. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +79 -12
  21. package/plugins/openhands/skills/openhands-sdk/SKILL.md +2 -1
  22. package/plugins/qa-changes/README.md +4 -0
  23. package/plugins/qa-changes/skills/qa-changes/README.md +3 -1
  24. package/plugins/qa-changes/skills/qa-changes/SKILL.md +42 -0
  25. package/plugins/vulnerability-remediation/skills/github/README.md +5 -1
  26. package/plugins/vulnerability-remediation/skills/github/SKILL.md +27 -0
  27. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +51 -4
  28. package/pyproject.toml +1 -1
  29. package/python/openhands_extensions/_version.py +1 -1
  30. package/scripts/sync_openhands_sdk_skill.py +16 -13
  31. package/skills/azure-devops/README.md +3 -1
  32. package/skills/azure-devops/SKILL.md +4 -0
  33. package/skills/bitbucket/README.md +3 -1
  34. package/skills/bitbucket/SKILL.md +4 -2
  35. package/skills/bitbucket-cloud/README.md +2 -0
  36. package/skills/bitbucket-cloud/SKILL.md +2 -0
  37. package/skills/bitbucket-data-center/README.md +2 -0
  38. package/skills/bitbucket-data-center/SKILL.md +2 -0
  39. package/skills/canvas-extension-api/SKILL.md +4 -1
  40. package/skills/canvas-extension-api/references/native-design.md +250 -0
  41. package/skills/github/README.md +5 -1
  42. package/skills/github/SKILL.md +27 -0
  43. package/skills/github/scripts/agent_conversation.py +51 -4
  44. package/skills/github-issue-to-pr/scripts/agent_conversation.py +51 -4
  45. package/skills/github-issue-triage/scripts/agent_conversation.py +51 -4
  46. package/skills/github-pr-reviewer/README.md +5 -0
  47. package/skills/github-pr-reviewer/SKILL.md +12 -0
  48. package/skills/github-pr-reviewer/scripts/agent_conversation.py +51 -4
  49. package/skills/github-pr-reviewer/scripts/main.py +20 -6
  50. package/skills/github-pr-reviewer/scripts/worker.py +77 -15
  51. package/skills/github-pr-reviewer/tests/test_main.py +23 -0
  52. package/skills/gitlab/README.md +3 -1
  53. package/skills/gitlab/SKILL.md +4 -0
  54. package/skills/index.js +25 -13
  55. package/skills/linear/README.md +2 -0
  56. package/skills/linear/SKILL.md +6 -2
  57. package/skills/openhands-automation/README.md +6 -4
  58. package/skills/openhands-automation/SKILL.md +103 -21
  59. package/skills/openhands-automation/references/custom-automation.md +79 -12
  60. package/skills/openhands-sdk/SKILL.md +2 -1
  61. package/skills/qa-changes/README.md +3 -1
  62. package/skills/qa-changes/SKILL.md +42 -0
  63. package/skills/simplified-technical-english/.claude-plugin/plugin.json +15 -0
  64. package/skills/simplified-technical-english/.codex-plugin/plugin.json +15 -0
  65. package/skills/simplified-technical-english/.plugin/plugin.json +15 -0
  66. package/skills/simplified-technical-english/README.md +21 -0
  67. package/skills/simplified-technical-english/SKILL.md +65 -0
  68. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  69. package/tests/test_agent_conversation_dispatch.py +130 -0
  70. package/tests/test_github_reviewer_delivery.py +279 -2
@@ -0,0 +1,65 @@
1
+ ---
2
+ name: simplified-technical-english
3
+ description: >
4
+ Write explanations and chat in the style of ASD-STE100 Simplified Technical
5
+ English: short active sentences, one idea each, plain approved words. Use this
6
+ when the reader benefits from maximum clarity: summaries, PR descriptions,
7
+ safety- or ops-critical instructions, or any comment or reply that should be easy
8
+ to read the first time. Applies to PROSE ONLY; it must never change code, identifiers,
9
+ APIs, or commit messages.
10
+ license: MIT
11
+ triggers:
12
+ - simplified technical english
13
+ - STE
14
+ - ASD-STE100
15
+ ---
16
+
17
+ # Simplified Technical English
18
+
19
+ ASD-STE100 Simplified Technical English is a controlled-language standard from
20
+ aerospace and defense. It exists to make technical text easy to read the first
21
+ time, especially for people who read English as a second language. This skill
22
+ adapts its spirit for agent prose.
23
+
24
+ Apply it to your **prose**: chat replies, explanations, summaries, step lists,
25
+ recommendations. The goal is easy reading, not dumbed-down thinking. Open the
26
+ text up and keep the substance.
27
+
28
+ ## Core rules
29
+
30
+ - **Prefer short sentences.** Aim for 20 words or fewer, particularly for instructions.
31
+ - **One idea per sentence, one topic per paragraph.** Split a sentence that carries two ideas.
32
+ - **Active voice.** Write "Run the tests", not "The tests should be run".
33
+ - **Plain, consistent words.** Prefer a common word over a fancy one. Use one word for one idea. Do not switch synonyms for the same thing mid-text.
34
+ - **Say the thing directly.** Lead with the answer. Give the number, name, or path. Cut filler like "in terms of", "a range of", "going forward".
35
+ - **Positive instructions.** Tell the reader what to do, not only what to avoid.
36
+
37
+ ## The hard boundary: prose, not code
38
+
39
+ A controlled *English* vocabulary
40
+ must never bleed into the code the agent writes.
41
+
42
+ - **Do not** simplify or rename variables, functions, types, files, config keys, CLI flags, API fields, or error strings to obey "approved words". Code needs exact, domain-precise names.
43
+ - **Do not** apply it to commit messages, code comments, or reference/API documentation, where technical precision and established terminology win.
44
+ - **Do** keep using the project's real domain terms in prose. Naming a concept correctly beats naming it "simply".
45
+
46
+ If clarity and a required technical term ever conflict in prose, keep the term
47
+ and add a short plain-English gloss beside it.
48
+
49
+ ## When to use it
50
+
51
+ - The user asked for plain or simple or clear text.
52
+ - Instructions where a misread is costly (ops, deploys, security steps).
53
+ - Long or dense explanations that need to be scannable.
54
+
55
+ ## When not to use it
56
+
57
+ - Code, identifiers, commit messages, or API/reference docs (see the boundary above).
58
+ - Creative or personal-voice writing, where rhythm and personality matter more than the controlled register.
59
+ - Cases where a precise domain term must stay exact. Keep the term.
60
+
61
+ ## Attribution
62
+
63
+ Standard: ASD-STE100 (ASD, the AeroSpace and Defence Industries Association of
64
+ Europe). This skill adapts its principles for agent prose; it is not the
65
+ official specification and does not reproduce its controlled dictionary.
@@ -68,7 +68,7 @@
68
68
  "timeout": 300,
69
69
  "template": {
70
70
  "id": "github-pr-reviewer",
71
- "version": "1.8.1",
71
+ "version": "1.9.2",
72
72
  "config": {
73
73
  "repos": [
74
74
  "OpenHands/agent-server-gui"
@@ -108,7 +108,7 @@
108
108
  "timeout": 300,
109
109
  "template": {
110
110
  "id": "github-pr-reviewer",
111
- "version": "1.8.1",
111
+ "version": "1.9.2",
112
112
  "config": {
113
113
  "repos": [
114
114
  "OpenHands/agent-server-gui"
@@ -213,7 +213,7 @@
213
213
  "timeout": 300,
214
214
  "template": {
215
215
  "id": "github-pr-reviewer",
216
- "version": "1.8.1",
216
+ "version": "1.9.2",
217
217
  "config": {
218
218
  "repos": [
219
219
  "OpenHands/agent-server-gui",
@@ -254,7 +254,7 @@
254
254
  "timeout": 300,
255
255
  "template": {
256
256
  "id": "github-pr-reviewer",
257
- "version": "1.8.1",
257
+ "version": "1.9.2",
258
258
  "config": {
259
259
  "repos": [
260
260
  "OpenHands/agent-server-gui",
@@ -296,7 +296,7 @@
296
296
  "preset_metadata": {
297
297
  "template": {
298
298
  "id": "github-pr-reviewer",
299
- "version": "1.8.1",
299
+ "version": "1.9.2",
300
300
  "config": {
301
301
  "repos": [
302
302
  "OpenHands/agent-server-gui",
@@ -351,7 +351,7 @@
351
351
  "timeout": 300,
352
352
  "template": {
353
353
  "id": "github-pr-reviewer",
354
- "version": "1.8.1",
354
+ "version": "1.9.2",
355
355
  "config": {
356
356
  "repos": [
357
357
  "OpenHands/agent-server-gui"
@@ -444,7 +444,7 @@
444
444
  "timeout": 300,
445
445
  "template": {
446
446
  "id": "github-pr-reviewer",
447
- "version": "1.8.1",
447
+ "version": "1.9.2",
448
448
  "config": {
449
449
  "repos": [
450
450
  "OpenHands/agent-server-gui"
@@ -540,7 +540,7 @@
540
540
  "timeout": 300,
541
541
  "template": {
542
542
  "id": "github-pr-reviewer",
543
- "version": "1.8.1",
543
+ "version": "1.9.2",
544
544
  "config": {
545
545
  "repos": [
546
546
  "OpenHands/agent-server-gui"
@@ -616,7 +616,7 @@
616
616
  "timeout": 300,
617
617
  "template": {
618
618
  "id": "github-pr-reviewer",
619
- "version": "1.8.1",
619
+ "version": "1.9.2",
620
620
  "config": {
621
621
  "repos": [
622
622
  "OpenHands/automation"
@@ -263,6 +263,91 @@ def test_new_delivery_on_a_running_new_head_still_sends_a_turn(monkeypatch):
263
263
  }
264
264
 
265
265
 
266
+ def _errored_local_conversation(monkeypatch):
267
+ monkeypatch.setattr(agent_conversation, "_register_tools", lambda: None)
268
+ workspace = MagicMock()
269
+ workspace.__enter__.return_value = workspace
270
+ workspace.get_secrets.return_value = {}
271
+ monkeypatch.setattr(
272
+ agent_conversation, "RemoteWorkspace", lambda **kwargs: workspace
273
+ )
274
+ conversation = MagicMock()
275
+ conversation.state.execution_status = ConversationExecutionStatus.ERROR
276
+ monkeypatch.setattr(
277
+ agent_conversation.RemoteConversation,
278
+ "attach",
279
+ MagicMock(return_value=conversation),
280
+ )
281
+ return conversation
282
+
283
+
284
+ def test_errored_conversation_retries_its_delivery(monkeypatch):
285
+ """A matched delivery whose conversation died is retried, not deduplicated.
286
+
287
+ The delivery string never changes for a stable subject, so treating ERROR as
288
+ "deduplicated" would strand that subject forever with its work unfinished.
289
+ """
290
+ state = {
291
+ _state_key("repo:pr:9"): {
292
+ "subject": "repo:pr:9",
293
+ "conversation_id": "22222222-2222-4222-8222-222222222222",
294
+ "delivery": "head-1",
295
+ }
296
+ }
297
+ _fake_kv(monkeypatch, state)
298
+ conversation = _errored_local_conversation(monkeypatch)
299
+
300
+ with _dispatcher(monkeypatch) as dispatcher:
301
+ result = dispatcher.deliver("repo:pr:9", "head-1", "old")
302
+
303
+ assert result["disposition"] == "retried"
304
+ conversation.update_secrets.assert_called_once_with(dispatcher._secrets)
305
+ conversation.send_message.assert_called_once_with("old")
306
+ conversation.run.assert_called_once_with(blocking=False)
307
+ assert state[_state_key("repo:pr:9")]["error_retries"] == 1
308
+
309
+
310
+ def test_errored_conversation_stops_retrying_after_the_bound(monkeypatch):
311
+ """A conversation that keeps failing is not re-run on every scan."""
312
+ state = {
313
+ _state_key("repo:pr:9"): {
314
+ "subject": "repo:pr:9",
315
+ "conversation_id": "22222222-2222-4222-8222-222222222222",
316
+ "delivery": "head-1",
317
+ "error_retries": agent_conversation._MAX_ERROR_RETRIES,
318
+ }
319
+ }
320
+ _fake_kv(monkeypatch, state)
321
+ conversation = _errored_local_conversation(monkeypatch)
322
+
323
+ with _dispatcher(monkeypatch) as dispatcher:
324
+ result = dispatcher.deliver("repo:pr:9", "head-1", "old")
325
+
326
+ assert result["disposition"] == "deduplicated"
327
+ conversation.send_message.assert_not_called()
328
+ conversation.run.assert_not_called()
329
+
330
+
331
+ def test_new_delivery_resets_the_error_retry_count(monkeypatch):
332
+ state = {
333
+ _state_key("repo:pr:9"): {
334
+ "subject": "repo:pr:9",
335
+ "conversation_id": "22222222-2222-4222-8222-222222222222",
336
+ "delivery": "head-1",
337
+ "error_retries": agent_conversation._MAX_ERROR_RETRIES,
338
+ }
339
+ }
340
+ _fake_kv(monkeypatch, state)
341
+ conversation = _errored_local_conversation(monkeypatch)
342
+
343
+ with _dispatcher(monkeypatch) as dispatcher:
344
+ result = dispatcher.deliver("repo:pr:9", "head-2", "new")
345
+
346
+ assert result["disposition"] == "resumed"
347
+ conversation.send_message.assert_called_once_with("new")
348
+ assert "error_retries" not in state[_state_key("repo:pr:9")]
349
+
350
+
266
351
  def test_subjects_use_independent_kv_records(monkeypatch):
267
352
  state = {}
268
353
  _fake_kv(monkeypatch, state)
@@ -421,3 +506,48 @@ def test_cloud_run_replaces_a_conversation_whose_sandbox_is_gone(monkeypatch):
421
506
  [started] = cloud.started
422
507
  assert started["id"] != gone
423
508
  assert state[_state_key("repo:pr:7")]["conversation_id"] == started["id"]
509
+
510
+
511
+ def test_cloud_run_retries_a_delivery_whose_conversation_errored(monkeypatch):
512
+ # Arrange
513
+ state = {
514
+ _state_key("repo:pr:7"): {
515
+ "conversation_id": "22222222-2222-4222-8222-222222222222",
516
+ "delivery": "revision-1",
517
+ "head": "sha-1",
518
+ }
519
+ }
520
+ _fake_kv(monkeypatch, state)
521
+ cloud = _FakeCloud({"sandbox_status": "RUNNING", "execution_status": "error"})
522
+
523
+ # Act
524
+ with _cloud_dispatcher(monkeypatch, cloud) as dispatcher:
525
+ result = dispatcher.deliver("repo:pr:7", "revision-1", "review it", head="sha-1")
526
+
527
+ # Assert
528
+ assert result["disposition"] == "retried"
529
+ assert cloud.sent == ["review it"]
530
+ assert cloud.started == []
531
+ assert state[_state_key("repo:pr:7")]["error_retries"] == 1
532
+
533
+
534
+ def test_cloud_run_stops_retrying_an_errored_delivery_after_the_bound(monkeypatch):
535
+ # Arrange
536
+ state = {
537
+ _state_key("repo:pr:7"): {
538
+ "conversation_id": "22222222-2222-4222-8222-222222222222",
539
+ "delivery": "revision-1",
540
+ "head": "sha-1",
541
+ "error_retries": agent_conversation._MAX_ERROR_RETRIES,
542
+ }
543
+ }
544
+ _fake_kv(monkeypatch, state)
545
+ cloud = _FakeCloud({"sandbox_status": "RUNNING", "execution_status": "error"})
546
+
547
+ # Act
548
+ with _cloud_dispatcher(monkeypatch, cloud) as dispatcher:
549
+ result = dispatcher.deliver("repo:pr:7", "revision-1", "review it", head="sha-1")
550
+
551
+ # Assert
552
+ assert result["disposition"] == "deduplicated"
553
+ assert cloud.sent == []
@@ -17,9 +17,11 @@ def _reviews(
17
17
  submitted_at="2026-01-02T00:00:00Z",
18
18
  sha="head-2",
19
19
  login="all-hands-bot",
20
+ review_id=None,
20
21
  ):
21
22
  return [
22
23
  {
24
+ "id": review_id,
23
25
  "body": f"Review body\n\n{verdict}",
24
26
  "commit_id": sha,
25
27
  "submitted_at": submitted_at,
@@ -114,7 +116,9 @@ def _workflow_run(
114
116
  }
115
117
 
116
118
 
117
- def _event(monkeypatch, *, action="review_requested", login="all-hands-bot"):
119
+ def _event(
120
+ monkeypatch, *, action="review_requested", login="all-hands-bot", review_id=None
121
+ ):
118
122
  payload = {
119
123
  "action": action,
120
124
  "repository": {"full_name": "owner/repo"},
@@ -124,6 +128,8 @@ def _event(monkeypatch, *, action="review_requested", login="all-hands-bot"):
124
128
  payload["requested_reviewer"] = {"login": login}
125
129
  else:
126
130
  payload["review"] = {"user": {"login": login}}
131
+ if review_id is not None:
132
+ payload["review"]["id"] = review_id
127
133
  monkeypatch.setenv(
128
134
  "AUTOMATION_EVENT_PAYLOAD",
129
135
  json.dumps({"automation_id": "automation", "event": {"payload": payload}}),
@@ -358,6 +364,191 @@ def test_reviewer_event_hands_positive_review_to_maintainer(tmp_path, monkeypatc
358
364
  run.dispatcher.deliver.assert_not_called()
359
365
 
360
366
 
367
+ def test_reviewer_delayed_submitted_event_completes_prior_request(
368
+ tmp_path, monkeypatch
369
+ ):
370
+ """The review a submitted delivery reports can predate the latest
371
+ `review_requested` event, and it still completes that work."""
372
+ module, run = _reviewer(tmp_path, monkeypatch)
373
+ _event(monkeypatch, action="submitted", review_id=555)
374
+ run.config["maintainers"] = "neubig, VascoSch92"
375
+ pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
376
+ request = {
377
+ "id": 42,
378
+ "event": "review_requested",
379
+ "created_at": "2026-01-03T00:00:00Z",
380
+ "requested_reviewer": {"login": "all-hands-bot"},
381
+ }
382
+ run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
383
+ run.gh_pages = lambda path: (
384
+ [request]
385
+ if path.endswith("/events")
386
+ else _reviews(submitted_at="2026-01-01T00:00:00Z", review_id=555)
387
+ )
388
+ handoff = Mock(return_value="VascoSch92")
389
+ monkeypatch.setattr(module, "request_maintainer_review", handoff)
390
+
391
+ run.run()
392
+
393
+ handoff.assert_called_once_with(run, pr, ["neubig", "VascoSch92"])
394
+ run.dispatcher.deliver.assert_not_called()
395
+
396
+
397
+ def test_reviewer_submitted_event_completes_without_request_event(
398
+ tmp_path, monkeypatch
399
+ ):
400
+ """A review whose request is outside the issue-event window still completes
401
+ through its own submitted delivery."""
402
+ module, run = _reviewer(tmp_path, monkeypatch)
403
+ _event(monkeypatch, action="submitted", review_id=555)
404
+ run.config["maintainers"] = "neubig"
405
+ pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
406
+ run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
407
+ run.gh_pages = lambda path: (
408
+ [] if path.endswith("/events") else _reviews(review_id=555)
409
+ )
410
+ handoff = Mock(return_value="neubig")
411
+ monkeypatch.setattr(module, "request_maintainer_review", handoff)
412
+
413
+ run.run()
414
+
415
+ handoff.assert_called_once_with(run, pr, ["neubig"])
416
+ run.dispatcher.deliver.assert_not_called()
417
+
418
+
419
+ def test_reviewer_submitted_event_matches_only_the_reported_review(
420
+ tmp_path, monkeypatch
421
+ ):
422
+ """Another decisive review on the head is not the one the delivery reports."""
423
+ module, run = _reviewer(tmp_path, monkeypatch)
424
+ _event(monkeypatch, action="submitted", review_id=556)
425
+ run.config["maintainers"] = "neubig"
426
+ pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
427
+ request = {
428
+ "id": 42,
429
+ "event": "review_requested",
430
+ "created_at": "2026-01-01T00:00:00Z",
431
+ "requested_reviewer": {"login": "all-hands-bot"},
432
+ }
433
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
434
+ run.gh_pages = lambda path: (
435
+ [request] if path.endswith("/events") else _reviews(review_id=555)
436
+ )
437
+ handoff = Mock()
438
+ monkeypatch.setattr(module, "request_maintainer_review", handoff)
439
+
440
+ run.run()
441
+
442
+ handoff.assert_not_called()
443
+ run.dispatcher.deliver.assert_not_called()
444
+
445
+
446
+
447
+ def test_reviewer_delayed_submitted_approval_yields_to_a_newer_verdict(
448
+ tmp_path, monkeypatch
449
+ ):
450
+ """A late delivery for an earlier approval does not hand off when this
451
+ account has since requested changes on the same head."""
452
+ module, run = _reviewer(tmp_path, monkeypatch)
453
+ _event(monkeypatch, action="submitted", review_id=555)
454
+ run.config["maintainers"] = "neubig"
455
+ pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
456
+ request = {
457
+ "id": 42,
458
+ "event": "review_requested",
459
+ "created_at": "2026-01-03T00:00:00Z",
460
+ "requested_reviewer": {"login": "all-hands-bot"},
461
+ }
462
+ reviews = _reviews(submitted_at="2026-01-02T00:00:00Z", review_id=555) + _reviews(
463
+ verdict="🔄 CHANGES REQUESTED",
464
+ submitted_at="2026-01-04T00:00:00Z",
465
+ review_id=556,
466
+ )
467
+ run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
468
+ run.gh_pages = lambda path: [request] if path.endswith("/events") else reviews
469
+ handoff = Mock()
470
+ monkeypatch.setattr(module, "request_maintainer_review", handoff)
471
+
472
+ run.run()
473
+
474
+ handoff.assert_not_called()
475
+ run.dispatcher.deliver.assert_not_called()
476
+
477
+
478
+ def test_reviewer_submitted_event_ignores_a_dismissed_review(tmp_path, monkeypatch):
479
+ module, run = _reviewer(tmp_path, monkeypatch)
480
+ _event(monkeypatch, action="submitted", review_id=555)
481
+ run.config["maintainers"] = "neubig"
482
+ pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
483
+ reviews = _reviews(review_id=555)
484
+ reviews[0]["state"] = "DISMISSED"
485
+ run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
486
+ run.gh_pages = lambda path: [] if path.endswith("/events") else reviews
487
+ handoff = Mock()
488
+ monkeypatch.setattr(module, "request_maintainer_review", handoff)
489
+
490
+ run.run()
491
+
492
+ handoff.assert_not_called()
493
+ run.dispatcher.deliver.assert_not_called()
494
+
495
+ def test_reviewer_delayed_submitted_event_without_roster_is_a_noop(
496
+ tmp_path, monkeypatch
497
+ ):
498
+ module, run = _reviewer(tmp_path, monkeypatch)
499
+ _event(monkeypatch, action="submitted", review_id=555)
500
+ pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
501
+ request = {
502
+ "id": 42,
503
+ "event": "review_requested",
504
+ "created_at": "2026-01-03T00:00:00Z",
505
+ "requested_reviewer": {"login": "all-hands-bot"},
506
+ }
507
+ run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
508
+ run.gh_pages = lambda path: (
509
+ [request]
510
+ if path.endswith("/events")
511
+ else _reviews(submitted_at="2026-01-01T00:00:00Z", review_id=555)
512
+ )
513
+ handoff = Mock()
514
+ monkeypatch.setattr(module, "request_maintainer_review", handoff)
515
+
516
+ run.run()
517
+
518
+ handoff.assert_not_called()
519
+ run.dispatcher.deliver.assert_not_called()
520
+
521
+
522
+ def test_reviewer_subsequent_review_request_for_new_head_starts_new_review(
523
+ tmp_path, monkeypatch
524
+ ):
525
+ """A completed review of an earlier head does not swallow a later explicit
526
+ request once the head has moved."""
527
+ _module, run = _reviewer(tmp_path, monkeypatch)
528
+ _event(monkeypatch)
529
+ pr = {"number": 2, "head": {"sha": "head-3"}, "labels": []}
530
+ request = {
531
+ "id": 43,
532
+ "event": "review_requested",
533
+ "created_at": "2026-01-03T00:00:00Z",
534
+ "requested_reviewer": {"login": "all-hands-bot"},
535
+ }
536
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
537
+ run.gh_pages = lambda path: (
538
+ [request]
539
+ if path.endswith("/events")
540
+ else _reviews(sha="head-2", submitted_at="2026-01-02T00:00:00Z", review_id=555)
541
+ )
542
+ run.dispatcher.deliver.return_value = {
543
+ "disposition": "created",
544
+ "conversation_id": "conversation",
545
+ }
546
+
547
+ run.run()
548
+
549
+ assert run.dispatcher.deliver.call_args.kwargs["delivery"] == "43:head-3"
550
+
551
+
361
552
  def test_reviewer_submitted_non_decisive_review_does_not_dispatch(
362
553
  tmp_path, monkeypatch
363
554
  ):
@@ -1088,6 +1279,38 @@ def test_reviewer_waits_for_a_workflow_run_that_has_not_finished(
1088
1279
  assert "`Tests`" in body
1089
1280
 
1090
1281
 
1282
+
1283
+ def test_reviewer_waits_on_a_fork_workflow_run_awaiting_approval(
1284
+ tmp_path, monkeypatch
1285
+ ):
1286
+ """On a fork head, GitHub parks the workflow run itself at action_required
1287
+ and creates no check runs until a maintainer approves it."""
1288
+ _module, run = _reviewer(tmp_path, monkeypatch)
1289
+ pr = _labeled_pr()
1290
+ run.gh_pages = _gate_pages(pr, [])
1291
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
1292
+ run.workflow_runs = lambda sha: [
1293
+ _workflow_run(
1294
+ "Tests",
1295
+ "completed",
1296
+ "action_required",
1297
+ run_id=4,
1298
+ suite_id=4004,
1299
+ workflow_id=236324519,
1300
+ sha=sha,
1301
+ )
1302
+ ]
1303
+
1304
+ run.run()
1305
+
1306
+ run.dispatcher.deliver.assert_not_called()
1307
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
1308
+ assert len(posted) == 1
1309
+ body = posted[0].args[2]["body"]
1310
+ assert "<!-- openhands-review-gate:waiting:head-2 -->" in body
1311
+ assert "`Tests (awaiting maintainer approval)`" in body
1312
+ assert "A maintainer needs to approve the workflow runs" in body
1313
+
1091
1314
  def test_reviewer_ignores_workflow_runs_from_an_obsolete_head(
1092
1315
  tmp_path, monkeypatch
1093
1316
  ):
@@ -1372,7 +1595,9 @@ def test_reviewer_fails_closed_on_an_unknown_conclusion(tmp_path, monkeypatch):
1372
1595
  pr = _labeled_pr()
1373
1596
  run.gh_pages = _gate_pages(pr, [])
1374
1597
  run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
1375
- run.check_runs = lambda sha: _checks(("mystery", "completed", "action_required"), sha=sha)
1598
+ run.check_runs = lambda sha: _checks(
1599
+ ("mystery", "completed", "some_new_conclusion"), sha=sha
1600
+ )
1376
1601
 
1377
1602
  run.run()
1378
1603
 
@@ -1385,6 +1610,35 @@ def test_reviewer_fails_closed_on_an_unknown_conclusion(tmp_path, monkeypatch):
1385
1610
  assert "<!-- openhands-review-gate:blocked:head-2 -->" in body
1386
1611
 
1387
1612
 
1613
+
1614
+ def test_reviewer_waits_on_a_run_awaiting_maintainer_approval(tmp_path, monkeypatch):
1615
+ """A fork head parked at action_required is waiting on a maintainer.
1616
+
1617
+ GitHub sets this conclusion on a fork head's workflow runs until a
1618
+ maintainer approves them. That is not a failure, and the explanation must
1619
+ ask for the approval rather than say no action is needed.
1620
+ """
1621
+ _module, run = _reviewer(tmp_path, monkeypatch)
1622
+ pr = _labeled_pr()
1623
+ run.gh_pages = _gate_pages(pr, [])
1624
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
1625
+ run.check_runs = lambda sha: _checks(
1626
+ ("tests", "completed", "action_required"), sha=sha
1627
+ )
1628
+
1629
+ run.run()
1630
+
1631
+ run.dispatcher.deliver.assert_not_called()
1632
+ body = [
1633
+ call.args[2]["body"]
1634
+ for call in _gate_comment_calls(run)
1635
+ if call.args[0] == "POST"
1636
+ ][0]
1637
+ assert "<!-- openhands-review-gate:waiting:head-2 -->" in body
1638
+ assert "`tests (awaiting maintainer approval)`" in body
1639
+ assert "A maintainer needs to approve the workflow runs" in body
1640
+ assert "No action is needed" not in body
1641
+
1388
1642
  def test_reviewer_does_not_duplicate_the_gate_comment_for_the_same_head(
1389
1643
  tmp_path, monkeypatch
1390
1644
  ):
@@ -2792,3 +3046,26 @@ def test_scan_reads_every_pull_request_but_starts_only_the_quota(
2792
3046
  ]
2793
3047
  assert len(full_reads) == 40
2794
3048
  assert _examined(one) == [1, 2, 3, 4, 5]
3049
+
3050
+
3051
+ def test_a_retried_conversation_uses_a_launch_slot(tmp_path, monkeypatch):
3052
+ """Retrying errored reviews must not bypass the per-scan launch bound."""
3053
+ module = worker("github-pr-reviewer", tmp_path, monkeypatch)
3054
+ intake = module.ReviewIntake()
3055
+ started = []
3056
+ for number in (1, 2, 3):
3057
+ intake.register(
3058
+ {
3059
+ "priority": 1,
3060
+ "created_at": f"2026-01-0{number}T00:00:00Z",
3061
+ "repository": "owner/repo",
3062
+ "number": number,
3063
+ "config": {"max_new_per_run": 1},
3064
+ "start": lambda number=number: started.append(number)
3065
+ or {"disposition": "retried", "conversation_id": str(number)},
3066
+ }
3067
+ )
3068
+
3069
+ intake.drain()
3070
+
3071
+ assert started == [1]