@openhands/extensions 0.22.1 → 0.24.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 (90) hide show
  1. package/.agents/skills/custom-codereview-guide.md +86 -17
  2. package/.claude-plugin/marketplace.json +14 -0
  3. package/.codex-plugin/marketplace.json +14 -0
  4. package/.github/workflows/check-extensions.yml +6 -6
  5. package/.github/workflows/deprecation-check.yml +1 -1
  6. package/.github/workflows/issue-readiness-check.yml +37 -106
  7. package/.github/workflows/npm-publish.yml +2 -2
  8. package/.github/workflows/pr-description-check.yml +1 -1
  9. package/.github/workflows/pypi-publish.yml +3 -3
  10. package/.github/workflows/tests.yml +2 -2
  11. package/.plugin/marketplace.json +14 -0
  12. package/.release-please-manifest.json +1 -1
  13. package/AGENTS.md +32 -2
  14. package/README.md +4 -3
  15. package/automations/bundle-index.js +12 -11
  16. package/automations/catalog/github-issue-triage/manifest.json +2 -2
  17. package/automations/catalog/github-pr-reviewer/manifest.json +42 -5
  18. package/automations/catalog.schema.json +0 -4
  19. package/integrations/catalog/granola.json +38 -0
  20. package/integrations/catalog-index.js +16 -14
  21. package/marketplaces/openhands-extensions.json +14 -0
  22. package/package.json +1 -1
  23. package/plugins/openhands/skills/openhands-sdk/SKILL.md +3 -1
  24. package/plugins/pr-review/README.md +2 -1
  25. package/plugins/pr-review/scripts/agent_script.py +198 -81
  26. package/plugins/pr-review/scripts/prompt.py +35 -11
  27. package/plugins/pr-review/skills/code-review/SKILL.md +165 -200
  28. package/plugins/pr-review/skills/code-review/commands/codereview-roasted.md +1 -1
  29. package/plugins/pr-review/skills/code-review/commands/codereview.md +1 -1
  30. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +8 -3
  31. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +4 -0
  32. package/pyproject.toml +2 -2
  33. package/python/openhands_extensions/_version.py +1 -1
  34. package/skills/add-skill/SKILL.md +5 -2
  35. package/skills/canvas-extension-api/.claude-plugin/plugin.json +19 -0
  36. package/skills/canvas-extension-api/.codex-plugin/plugin.json +19 -0
  37. package/skills/canvas-extension-api/.plugin/plugin.json +19 -0
  38. package/skills/canvas-extension-api/README.md +33 -0
  39. package/skills/canvas-extension-api/SKILL.md +254 -0
  40. package/skills/canvas-extension-api/references/acceptance-checklist.md +20 -0
  41. package/skills/canvas-extension-api/references/backend-safety.md +9 -0
  42. package/skills/canvas-extension-api/references/connections.md +126 -0
  43. package/skills/canvas-extension-api/references/packaging-recipes.md +43 -0
  44. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -0
  45. package/skills/canvas-extension-api/references/testing-and-installation.md +286 -0
  46. package/skills/canvas-extension-api/references/v1-contract.md +250 -0
  47. package/skills/canvas-extension-api/scripts/validate-extension.mjs +92 -0
  48. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +60 -0
  49. package/skills/code-review/SKILL.md +165 -200
  50. package/skills/code-review/commands/codereview-roasted.md +1 -1
  51. package/skills/code-review/commands/codereview.md +1 -1
  52. package/skills/github/scripts/agent_conversation.py +8 -3
  53. package/skills/github/scripts/github_client.py +4 -0
  54. package/skills/github-agents-md-maintainer/scripts/github_client.py +4 -0
  55. package/skills/github-delivery-watchdog/scripts/github_client.py +4 -0
  56. package/skills/github-issue-to-pr/scripts/agent_conversation.py +8 -3
  57. package/skills/github-issue-to-pr/scripts/github_client.py +4 -0
  58. package/skills/github-issue-triage/.claude-plugin/plugin.json +1 -1
  59. package/skills/github-issue-triage/.codex-plugin/plugin.json +1 -1
  60. package/skills/github-issue-triage/.plugin/plugin.json +1 -1
  61. package/skills/github-issue-triage/README.md +7 -0
  62. package/skills/github-issue-triage/SKILL.md +26 -3
  63. package/skills/github-issue-triage/scripts/agent_conversation.py +8 -3
  64. package/skills/github-issue-triage/scripts/github_client.py +4 -0
  65. package/skills/github-issue-triage/scripts/worker.py +142 -26
  66. package/skills/github-pr-reviewer/README.md +21 -10
  67. package/skills/github-pr-reviewer/SKILL.md +36 -10
  68. package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
  69. package/skills/github-pr-reviewer/scripts/agent_conversation.py +8 -3
  70. package/skills/github-pr-reviewer/scripts/github_client.py +4 -0
  71. package/skills/github-pr-reviewer/scripts/main.py +63 -16
  72. package/skills/github-pr-reviewer/scripts/maintainer_handoff.py +151 -0
  73. package/skills/github-pr-reviewer/scripts/worker.py +218 -21
  74. package/skills/github-pr-reviewer/tests/test_main.py +85 -0
  75. package/skills/github-repo-monitor/scripts/github_client.py +4 -0
  76. package/skills/index.js +16 -8
  77. package/skills/openhands-sdk/SKILL.md +3 -1
  78. package/tests/fixtures/automations/github-pr-reviewer.json +120 -26
  79. package/tests/github_automation_helpers.py +1 -0
  80. package/tests/test_agent_conversation_dispatch.py +19 -4
  81. package/tests/test_automation_setup.py +15 -0
  82. package/tests/test_code_review_risk_evaluation.py +2 -2
  83. package/tests/test_github_reviewer_delivery.py +448 -5
  84. package/tests/test_github_triage_delivery.py +200 -6
  85. package/tests/test_maintainer_handoff.py +198 -0
  86. package/tests/test_pr_review_prompt.py +6 -2
  87. package/tests/test_pr_review_review_context.py +144 -0
  88. package/.github/scripts/check_issue_readiness.py +0 -272
  89. package/.github/scripts/post-readiness-comment.mjs +0 -184
  90. package/tests/test_check_issue_readiness.py +0 -135
@@ -1,11 +1,33 @@
1
1
  """Contract tests for delegated GitHub PR review."""
2
2
 
3
+ import json
4
+ import sys
3
5
  from pathlib import Path
4
- from unittest.mock import Mock
6
+ from unittest.mock import MagicMock, Mock
7
+ from uuid import NAMESPACE_URL, uuid5
8
+
9
+ import pytest
10
+ from openhands.sdk.conversation.state import ConversationExecutionStatus
5
11
 
6
12
  from github_automation_helpers import worker
7
13
 
8
14
 
15
+ def _reviews(
16
+ verdict="✅ APPROVED",
17
+ submitted_at="2026-01-02T00:00:00Z",
18
+ sha="head-2",
19
+ login="all-hands-bot",
20
+ ):
21
+ return [
22
+ {
23
+ "body": f"Review body\n\n{verdict}",
24
+ "commit_id": sha,
25
+ "submitted_at": submitted_at,
26
+ "user": {"login": login},
27
+ }
28
+ ]
29
+
30
+
9
31
  def _reviewer(tmp_path, monkeypatch):
10
32
  module = worker("github-pr-reviewer", tmp_path, monkeypatch)
11
33
  run = object.__new__(module.PullRequestReviewer)
@@ -13,10 +35,33 @@ def _reviewer(tmp_path, monkeypatch):
13
35
  run.repository = "owner/repo"
14
36
  run.token = "token"
15
37
  run.token_name = "FACTORY_GITHUB_REVIEWER_TOKEN"
38
+ run.github_login = "all-hands-bot"
16
39
  run.dispatcher = Mock()
40
+ monkeypatch.delenv("AUTOMATION_EVENT_PAYLOAD", raising=False)
41
+ monkeypatch.setattr(
42
+ module.workflow,
43
+ "_latest_trigger_label_event",
44
+ lambda *args: {"id": 7, "created_at": "2026-01-01T00:00:00Z"},
45
+ )
17
46
  return module, run
18
47
 
19
48
 
49
+ def _event(monkeypatch, *, action="review_requested", login="all-hands-bot"):
50
+ payload = {
51
+ "action": action,
52
+ "repository": {"full_name": "owner/repo"},
53
+ "pull_request": {"number": 2},
54
+ }
55
+ if action == "review_requested":
56
+ payload["requested_reviewer"] = {"login": login}
57
+ else:
58
+ payload["review"] = {"user": {"login": login}}
59
+ monkeypatch.setenv(
60
+ "AUTOMATION_EVENT_PAYLOAD",
61
+ json.dumps({"automation_id": "automation", "event": {"payload": payload}}),
62
+ )
63
+
64
+
20
65
  def test_reviewer_submits_each_labeled_exact_head(tmp_path, monkeypatch):
21
66
  module, run = _reviewer(tmp_path, monkeypatch)
22
67
  prs = [
@@ -45,12 +90,263 @@ def test_reviewer_submits_each_labeled_exact_head(tmp_path, monkeypatch):
45
90
  assert submit.call_args.kwargs["delivery"] == "7:head-2"
46
91
  prompt = submit.call_args.kwargs["prompt"]
47
92
  assert "publish the review directly to GitHub" in prompt
48
- assert "software-factory/review" in prompt
49
- assert "software-factory/tests" in prompt
93
+ assert "Do not create commit statuses or Checks" in prompt
50
94
  assert "FACTORY_GITHUB_REVIEWER_TOKEN" in prompt
51
95
  assert "gh auth setup-git" in prompt
52
96
  assert "GIT_TERMINAL_PROMPT=0" in prompt
53
97
  assert "Never paste JSON artifacts" in prompt
98
+ assert "stop immediately" in prompt
99
+
100
+
101
+ def test_reviewer_submits_requested_exact_head(tmp_path, monkeypatch):
102
+ _module, run = _reviewer(tmp_path, monkeypatch)
103
+ _event(monkeypatch)
104
+ pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
105
+ request = {
106
+ "id": 42,
107
+ "event": "review_requested",
108
+ "created_at": "2026-01-01T00:00:00Z",
109
+ "requested_reviewer": {"login": "all-hands-bot"},
110
+ }
111
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
112
+ run.gh_pages = lambda path: [request] if path.endswith("/events") else []
113
+ run.dispatcher.deliver.return_value = {
114
+ "disposition": "created",
115
+ "conversation_id": "conversation",
116
+ }
117
+
118
+ run.run()
119
+
120
+ call = run.dispatcher.deliver.call_args.kwargs
121
+ assert call["subject"] == "99:pr:2"
122
+ assert call["delivery"] == "42:head-2"
123
+ assert "latest review request for `all-hands-bot` event 42" in call["prompt"]
124
+ assert "new head requires another reviewer request" in call["prompt"]
125
+
126
+
127
+ def _real_dispatcher(dispatcher_class, monkeypatch, conversation):
128
+ """The shipped dispatcher with its SDK and KV boundaries stubbed."""
129
+ state: dict = {}
130
+ module = sys.modules[dispatcher_class.__module__]
131
+
132
+ def kv(key, method, value=None):
133
+ if method == "GET":
134
+ return state.get(key)
135
+ state[key] = value
136
+ return {"key": key, "value": value}
137
+
138
+ monkeypatch.setattr(module, "_kv_request", kv)
139
+ monkeypatch.setattr(module, "_register_tools", lambda: None)
140
+ monkeypatch.setenv("AGENT_SERVER_URL", "http://agent")
141
+ monkeypatch.setenv("SESSION_API_KEY", "session")
142
+ monkeypatch.setenv(
143
+ "AUTOMATION_AGENT_PROFILE_ID", "11111111-1111-4111-8111-111111111111"
144
+ )
145
+ monkeypatch.setenv(
146
+ "AUTOMATION_EVENT_PAYLOAD", json.dumps({"automation_id": "automation"})
147
+ )
148
+ workspace = MagicMock()
149
+ workspace.__enter__.return_value = workspace
150
+ workspace.get_secrets.return_value = {}
151
+ monkeypatch.setattr(module, "RemoteWorkspace", lambda **_: workspace)
152
+ attach = MagicMock(return_value=conversation)
153
+ monkeypatch.setattr(module.RemoteConversation, "attach", attach)
154
+ monkeypatch.setattr(
155
+ module.RemoteConversation, "create", MagicMock(return_value=conversation)
156
+ )
157
+ return dispatcher_class(), attach
158
+
159
+
160
+ def test_same_head_re_review_resumes_keyed_conversation_with_refreshed_state(
161
+ tmp_path, monkeypatch
162
+ ):
163
+ """A first review completes, the linked issue and head checks then change,
164
+ and an explicit re-review at the same head must resume the same conversation
165
+ with a prompt that re-establishes current GitHub state."""
166
+ module, run = _reviewer(tmp_path, monkeypatch)
167
+ conversation = MagicMock()
168
+ conversation.state.execution_status = ConversationExecutionStatus.IDLE
169
+ dispatcher, attach = _real_dispatcher(
170
+ module.AgentConversationDispatcher, monkeypatch, conversation
171
+ )
172
+ run.dispatcher = dispatcher
173
+
174
+ first_pr = {
175
+ "number": 2,
176
+ "title": "Add widget",
177
+ "body": "First body",
178
+ "head": {"sha": "head-2"},
179
+ "labels": [{"name": "openhands-review"}],
180
+ }
181
+ # The linked issue was not ready and the head's checks were pending when the
182
+ # first review ran; both moved before the re-review, at the same head.
183
+ refreshed_pr = {
184
+ **first_pr,
185
+ "body": "First body\n\nNow links #5038, which is ready-for-dev.",
186
+ "labels": [{"name": "openhands-review"}, {"name": "reviewed"}],
187
+ }
188
+
189
+ def review(label_event_id, pr):
190
+ monkeypatch.setattr(
191
+ module.workflow,
192
+ "_latest_trigger_label_event",
193
+ lambda *args: {"id": label_event_id, "created_at": "now"},
194
+ )
195
+ run.gh_pages = lambda path: [pr]
196
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
197
+ run.run()
198
+
199
+ with dispatcher:
200
+ review(7, first_pr)
201
+ review(8, refreshed_pr)
202
+
203
+ keyed_id = str(uuid5(NAMESPACE_URL, "automation:99:pr:2"))
204
+ # Both deliveries attach the same keyed conversation and send exactly one
205
+ # new turn each, so reuse and context retention are intact.
206
+ assert str(attach.call_args.args[1]) == keyed_id
207
+ assert {str(call.args[1]) for call in attach.call_args_list} == {keyed_id}
208
+ assert conversation.send_message.call_count == 2
209
+ first, second = (call.args[0] for call in conversation.send_message.call_args_list)
210
+
211
+ assert "event 7" in first and "event 8" in second
212
+ # The refreshed PR state reaches the resumed turn, and that turn requires a
213
+ # live re-read of every mutable surface rather than an earlier observation.
214
+ assert "First body" in first
215
+ assert "ready-for-dev" in second
216
+ assert "CURRENT STATE" in second
217
+ assert "body and labels of every linked issue" in second
218
+ assert "GitHub Actions check results" in second
219
+ assert "never repeat an earlier finding" in second
220
+
221
+
222
+ def test_reviewer_ignores_request_for_another_reviewer(tmp_path, monkeypatch):
223
+ _module, run = _reviewer(tmp_path, monkeypatch)
224
+ _event(monkeypatch, login="human")
225
+ run.gh = Mock(return_value={"id": 99})
226
+
227
+ run.run()
228
+
229
+ run.dispatcher.deliver.assert_not_called()
230
+
231
+
232
+ def test_reviewer_event_hands_positive_review_to_maintainer(tmp_path, monkeypatch):
233
+ module, run = _reviewer(tmp_path, monkeypatch)
234
+ _event(monkeypatch, action="submitted")
235
+ run.config["maintainers"] = "neubig, VascoSch92"
236
+ pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
237
+ request = {
238
+ "id": 42,
239
+ "event": "review_requested",
240
+ "created_at": "2026-01-01T00:00:00Z",
241
+ "requested_reviewer": {"login": "all-hands-bot"},
242
+ }
243
+ run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
244
+ run.gh_pages = lambda path: [request] if path.endswith("/events") else _reviews()
245
+ handoff = Mock(return_value="VascoSch92")
246
+ monkeypatch.setattr(module, "request_maintainer_review", handoff)
247
+
248
+ run.run()
249
+
250
+ handoff.assert_called_once_with(run, pr, ["neubig", "VascoSch92"])
251
+ assert all(call.args[0] != "DELETE" for call in run.gh.call_args_list)
252
+ run.dispatcher.deliver.assert_not_called()
253
+
254
+
255
+ def test_reviewer_submitted_non_decisive_review_does_not_dispatch(
256
+ tmp_path, monkeypatch
257
+ ):
258
+ module, run = _reviewer(tmp_path, monkeypatch)
259
+ _event(monkeypatch, action="submitted")
260
+ run.config["maintainers"] = "neubig"
261
+ pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
262
+ request = {
263
+ "id": 42,
264
+ "event": "review_requested",
265
+ "created_at": "2026-01-01T00:00:00Z",
266
+ "requested_reviewer": {"login": "all-hands-bot"},
267
+ }
268
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
269
+ run.gh_pages = lambda path: (
270
+ [request] if path.endswith("/events") else _reviews(verdict="LGTM")
271
+ )
272
+ handoff = Mock()
273
+ monkeypatch.setattr(module, "request_maintainer_review", handoff)
274
+
275
+ run.run()
276
+
277
+ run.dispatcher.deliver.assert_not_called()
278
+ handoff.assert_not_called()
279
+
280
+
281
+ def test_reviewer_submitted_review_on_superseded_head_does_not_dispatch(
282
+ tmp_path, monkeypatch
283
+ ):
284
+ module, run = _reviewer(tmp_path, monkeypatch)
285
+ _event(monkeypatch, action="submitted")
286
+ run.config["maintainers"] = "neubig"
287
+ pr = {"number": 2, "head": {"sha": "head-3"}, "labels": []}
288
+ request = {
289
+ "id": 42,
290
+ "event": "review_requested",
291
+ "created_at": "2026-01-01T00:00:00Z",
292
+ "requested_reviewer": {"login": "all-hands-bot"},
293
+ }
294
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
295
+ run.gh_pages = lambda path: (
296
+ [request] if path.endswith("/events") else _reviews(sha="head-2")
297
+ )
298
+ handoff = Mock()
299
+ monkeypatch.setattr(module, "request_maintainer_review", handoff)
300
+
301
+ run.run()
302
+
303
+ run.dispatcher.deliver.assert_not_called()
304
+ handoff.assert_not_called()
305
+
306
+
307
+ def test_reviewer_redelivers_when_review_predates_latest_label(tmp_path, monkeypatch):
308
+ _module, run = _reviewer(tmp_path, monkeypatch)
309
+ pr = {
310
+ "number": 2,
311
+ "head": {"sha": "head-2"},
312
+ "labels": [{"name": "openhands-review"}],
313
+ }
314
+ run.gh_pages = lambda path: (
315
+ [pr]
316
+ if path.startswith("/pulls?")
317
+ else _reviews(submitted_at="2025-12-31T00:00:00Z")
318
+ )
319
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
320
+ run.dispatcher.deliver.return_value = {
321
+ "disposition": "continued",
322
+ "conversation_id": "conversation",
323
+ }
324
+
325
+ run.run()
326
+
327
+ run.dispatcher.deliver.assert_called_once()
328
+ assert run.dispatcher.deliver.call_args.kwargs["delivery"] == "7:head-2"
329
+
330
+
331
+ def test_reviewer_does_not_trust_another_reviewers_verdict(tmp_path, monkeypatch):
332
+ _module, run = _reviewer(tmp_path, monkeypatch)
333
+ pr = {
334
+ "number": 2,
335
+ "head": {"sha": "head-2"},
336
+ "labels": [{"name": "openhands-review"}],
337
+ }
338
+ run.gh_pages = lambda path: (
339
+ [pr] if path.startswith("/pulls?") else _reviews(login="other-reviewer")
340
+ )
341
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
342
+ run.dispatcher.deliver.return_value = {
343
+ "disposition": "continued",
344
+ "conversation_id": "conversation",
345
+ }
346
+
347
+ run.run()
348
+
349
+ run.dispatcher.deliver.assert_called_once()
54
350
 
55
351
 
56
352
  def test_reviewer_ignores_unlabeled_prs(tmp_path, monkeypatch):
@@ -65,7 +361,9 @@ def test_reviewer_ignores_unlabeled_prs(tmp_path, monkeypatch):
65
361
  submit.assert_not_called()
66
362
 
67
363
 
68
- def test_reviewer_continues_after_one_submission_fails(tmp_path, monkeypatch):
364
+ def test_reviewer_continues_after_one_submission_fails_then_reports_run_failure(
365
+ tmp_path, monkeypatch
366
+ ):
69
367
  module, run = _reviewer(tmp_path, monkeypatch)
70
368
  prs = [
71
369
  {
@@ -90,7 +388,8 @@ def test_reviewer_continues_after_one_submission_fails(tmp_path, monkeypatch):
90
388
  )
91
389
  run.dispatcher.deliver = submit
92
390
 
93
- run.run()
391
+ with pytest.raises(RuntimeError, match="Reviewer scan failed for PRs: #1"):
392
+ run.run()
94
393
 
95
394
  assert submit.call_count == 2
96
395
 
@@ -108,3 +407,147 @@ def test_reviewer_scanner_has_no_conversation_or_runtime_code():
108
407
  "AUTOMATION_CONVERSATION_ID",
109
408
  ):
110
409
  assert forbidden not in source
410
+
411
+
412
+ def test_reviewer_hands_positive_exact_head_to_maintainer(tmp_path, monkeypatch):
413
+ module, run = _reviewer(tmp_path, monkeypatch)
414
+ run.config["maintainers"] = "neubig, VascoSch92"
415
+ pr = {
416
+ "number": 2,
417
+ "head": {"sha": "head-2"},
418
+ "labels": [{"name": "openhands-review"}],
419
+ }
420
+ run.gh_pages = lambda path: [pr] if path.startswith("/pulls?") else _reviews()
421
+ run.gh = Mock(side_effect=[{"id": 99}, pr, pr, {}])
422
+ handoff = Mock(return_value="VascoSch92")
423
+ monkeypatch.setattr(module, "request_maintainer_review", handoff)
424
+
425
+ run.run()
426
+
427
+ handoff.assert_called_once_with(run, pr, ["neubig", "VascoSch92"])
428
+ assert run.gh.call_args_list[-1].args == (
429
+ "DELETE",
430
+ "/issues/2/labels/openhands-review",
431
+ )
432
+ run.dispatcher.deliver.assert_not_called()
433
+
434
+
435
+ def test_reviewer_retries_failed_handoff_without_clearing_label(tmp_path, monkeypatch):
436
+ module, run = _reviewer(tmp_path, monkeypatch)
437
+ run.config["maintainers"] = "neubig"
438
+ pr = {
439
+ "number": 2,
440
+ "head": {"sha": "head-2"},
441
+ "labels": [{"name": "openhands-review"}],
442
+ }
443
+ run.gh_pages = lambda path: [pr] if path.startswith("/pulls?") else _reviews()
444
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
445
+ monkeypatch.setattr(
446
+ module,
447
+ "request_maintainer_review",
448
+ Mock(side_effect=RuntimeError("temporary GitHub failure")),
449
+ )
450
+
451
+ with pytest.raises(RuntimeError, match="Reviewer scan failed for PRs: #2"):
452
+ run.run()
453
+
454
+ assert all(call.args[0] != "DELETE" for call in run.gh.call_args_list)
455
+ run.dispatcher.deliver.assert_not_called()
456
+
457
+
458
+ def test_reviewer_reports_permanent_handoff_error_once(tmp_path, monkeypatch):
459
+ module, run = _reviewer(tmp_path, monkeypatch)
460
+ run.config["maintainers"] = "author"
461
+ pr = {
462
+ "number": 2,
463
+ "head": {"sha": "head-2"},
464
+ "labels": [{"name": "openhands-review"}],
465
+ }
466
+ run.gh_pages = lambda path: [pr] if path.startswith("/pulls?") else _reviews()
467
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {}, pr, {}])
468
+ monkeypatch.setattr(
469
+ module,
470
+ "request_maintainer_review",
471
+ Mock(
472
+ side_effect=module.HandoffConfigurationError(
473
+ "No eligible maintainer remains"
474
+ )
475
+ ),
476
+ )
477
+
478
+ run.run()
479
+
480
+ assert "configuration check" in run.gh.call_args_list[2].args[2]["body"]
481
+ assert run.gh.call_args_list[-1].args == (
482
+ "DELETE",
483
+ "/issues/2/labels/openhands-review",
484
+ )
485
+
486
+
487
+ def test_reviewer_keeps_label_when_head_moves_before_cleanup(tmp_path, monkeypatch):
488
+ _module, run = _reviewer(tmp_path, monkeypatch)
489
+ pr = {
490
+ "number": 2,
491
+ "head": {"sha": "head-2"},
492
+ "labels": [{"name": "openhands-review"}],
493
+ }
494
+ moved = {**pr, "head": {"sha": "head-3"}}
495
+ run.gh_pages = lambda path: (
496
+ [pr] if path.startswith("/pulls?") else _reviews(verdict="🔄 CHANGES REQUESTED")
497
+ )
498
+ run.gh = Mock(side_effect=[{"id": 99}, pr, moved])
499
+
500
+ run.run()
501
+
502
+ assert all(call.args[0] != "DELETE" for call in run.gh.call_args_list)
503
+ run.dispatcher.deliver.assert_not_called()
504
+
505
+
506
+ def test_reviewer_does_not_handoff_failed_review(tmp_path, monkeypatch):
507
+ module, run = _reviewer(tmp_path, monkeypatch)
508
+ run.config["maintainers"] = "neubig"
509
+ pr = {
510
+ "number": 2,
511
+ "head": {"sha": "head-2"},
512
+ "labels": [{"name": "openhands-review"}],
513
+ }
514
+ run.gh_pages = lambda path: (
515
+ [pr] if path.startswith("/pulls?") else _reviews(verdict="🔄 CHANGES REQUESTED")
516
+ )
517
+ run.gh = Mock(side_effect=[{"id": 99}, pr, pr, {}])
518
+ handoff = Mock()
519
+ monkeypatch.setattr(module, "request_maintainer_review", handoff)
520
+
521
+ run.run()
522
+
523
+ handoff.assert_not_called()
524
+ run.dispatcher.deliver.assert_not_called()
525
+
526
+
527
+ def test_reviewer_hands_scope_stop_to_maintainer(tmp_path, monkeypatch):
528
+ """A scope stop is not an approval but still reaches the maintainer."""
529
+ module, run = _reviewer(tmp_path, monkeypatch)
530
+ run.config["maintainers"] = "neubig, VascoSch92"
531
+ pr = {
532
+ "number": 2,
533
+ "head": {"sha": "head-2"},
534
+ "labels": [{"name": "openhands-review"}],
535
+ }
536
+ run.gh_pages = lambda path: (
537
+ [pr]
538
+ if path.startswith("/pulls?")
539
+ else _reviews(verdict="🛑 MAINTAINER DECISION REQUIRED")
540
+ )
541
+ run.gh = Mock(side_effect=[{"id": 99}, pr, pr, {}])
542
+ handoff = Mock(return_value="VascoSch92")
543
+ monkeypatch.setattr(module, "request_maintainer_review", handoff)
544
+
545
+ run.run()
546
+
547
+ handoff.assert_called_once_with(run, pr, ["neubig", "VascoSch92"])
548
+ assert all(call.args[0] != "PUT" for call in run.gh.call_args_list)
549
+ assert run.gh.call_args_list[-1].args == (
550
+ "DELETE",
551
+ "/issues/2/labels/openhands-review",
552
+ )
553
+ run.dispatcher.deliver.assert_not_called()