@openhands/extensions 0.28.0 → 0.30.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 (89) hide show
  1. package/.agents/skills/custom-codereview-guide.md +44 -1
  2. package/.claude-plugin/marketplace.json +7 -5
  3. package/.codex-plugin/marketplace.json +7 -5
  4. package/.github/workflows/build-vscode-app.yml +3 -3
  5. package/.github/workflows/check-extensions.yml +1 -1
  6. package/.github/workflows/tests.yml +1 -1
  7. package/.plugin/marketplace.json +7 -5
  8. package/.release-please-manifest.json +1 -1
  9. package/AGENTS.md +7 -2
  10. package/README.md +3 -3
  11. package/apps/vscode/README.md +8 -6
  12. package/apps/vscode/canvas-extension.json +11 -3
  13. package/apps/vscode/extension.js +60 -12
  14. package/apps/vscode/scripts/package.mjs +20 -74
  15. package/automations/bundle-index.js +9 -7
  16. package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
  17. package/automations/catalog/github-issue-triage/manifest.json +4 -3
  18. package/automations/catalog/github-pr-reviewer/manifest.json +5 -4
  19. package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
  20. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
  21. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
  22. package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
  23. package/integrations/catalog/atlassian-rovo.json +4 -3
  24. package/integrations/catalog/bitbucket.json +16 -1
  25. package/integrations/catalog/github.json +1 -0
  26. package/integrations/catalog/gitlab.json +1 -1
  27. package/marketplaces/openhands-extensions.json +7 -5
  28. package/package.json +1 -1
  29. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +76 -8
  30. package/plugins/pr-review/README.md +1 -0
  31. package/plugins/pr-review/scripts/prompt.py +44 -11
  32. package/plugins/runtime-api-configs/SKILL.md +1 -1
  33. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
  34. package/pyproject.toml +1 -1
  35. package/python/openhands_extensions/_version.py +1 -1
  36. package/skills/canvas-extension-api/SKILL.md +4 -1
  37. package/skills/canvas-extension-api/references/backend-safety.md +3 -3
  38. package/skills/canvas-extension-api/references/native-design.md +250 -0
  39. package/skills/canvas-extension-api/references/v1-contract.md +7 -4
  40. package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
  41. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
  42. package/skills/github/scripts/agent_conversation.py +224 -8
  43. package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
  44. package/skills/github-issue-triage/README.md +73 -14
  45. package/skills/github-issue-triage/SKILL.md +26 -46
  46. package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
  47. package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
  48. package/skills/github-issue-triage/scripts/worker.py +153 -103
  49. package/skills/github-pr-reviewer/README.md +103 -1
  50. package/skills/github-pr-reviewer/SKILL.md +35 -8
  51. package/skills/github-pr-reviewer/references/state-schema.md +8 -0
  52. package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
  53. package/skills/github-pr-reviewer/scripts/main.py +163 -39
  54. package/skills/github-pr-reviewer/scripts/publication.py +193 -0
  55. package/skills/github-pr-reviewer/scripts/worker.py +297 -8
  56. package/skills/github-pr-reviewer/tests/test_main.py +73 -5
  57. package/skills/gitlab-issue-to-mr/README.md +10 -3
  58. package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
  59. package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
  60. package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
  61. package/skills/index.js +10 -10
  62. package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
  63. package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
  64. package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
  65. package/skills/jira-issue-to-pr/README.md +18 -11
  66. package/skills/jira-issue-to-pr/SKILL.md +72 -23
  67. package/skills/jira-issue-to-pr/references/setup.md +43 -12
  68. package/skills/jira-issue-to-pr/scripts/main.py +136 -26
  69. package/skills/openhands-automation/references/custom-automation.md +76 -8
  70. package/skills/slack-channel-monitor/README.md +6 -1
  71. package/skills/slack-channel-monitor/SKILL.md +6 -3
  72. package/skills/slack-channel-monitor/scripts/main.py +36 -4
  73. package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
  74. package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
  75. package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
  76. package/skills/ticket-to-code-change/SKILL.md +5 -3
  77. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
  78. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  79. package/tests/test_agent_conversation_dispatch.py +173 -6
  80. package/tests/test_automation_llm_provenance.py +1209 -0
  81. package/tests/test_github_review_publication.py +422 -0
  82. package/tests/test_github_reviewer_delivery.py +174 -21
  83. package/tests/test_github_triage_delivery.py +411 -238
  84. package/tests/test_gitlab_issue_to_mr.py +284 -0
  85. package/tests/test_jira_issue_to_pr.py +200 -6
  86. package/tests/test_pr_review_prompt_injection.py +353 -0
  87. package/tests/test_slack_channel_monitor.py +32 -1
  88. package/tests/test_vscode_app.py +8 -2
  89. package/tests/vscode-app.test.mjs +8 -2
@@ -4,6 +4,7 @@ import json
4
4
  import sys
5
5
  from pathlib import Path
6
6
  from unittest.mock import MagicMock, Mock
7
+ from urllib.error import HTTPError
7
8
  from uuid import NAMESPACE_URL, uuid5
8
9
 
9
10
  import pytest
@@ -164,7 +165,7 @@ def test_reviewer_submits_each_labeled_exact_head(tmp_path, monkeypatch):
164
165
 
165
166
  assert submit.call_args.kwargs["subject"] == "99:pr:2"
166
167
  assert submit.call_args.kwargs["delivery"] == "7:head-2"
167
- prompt = submit.call_args.kwargs["prompt"]
168
+ prompt = submit.call_args.kwargs["prompt"]("conversation")
168
169
  assert "publish the review directly to GitHub" in prompt
169
170
  assert "Do not create commit statuses or Checks" in prompt
170
171
  assert "FACTORY_GITHUB_REVIEWER_TOKEN" in prompt
@@ -203,7 +204,7 @@ def test_reviewer_prompt_forbids_republishing_an_unchanged_reviewed_head(
203
204
 
204
205
  run.run()
205
206
 
206
- prompt = submit.call_args.kwargs["prompt"]
207
+ prompt = submit.call_args.kwargs["prompt"]("conversation")
207
208
  assert "already published a review on `head-2`" in prompt
208
209
  assert "no one else has commented since that review" in prompt
209
210
  assert "Review the head again only when the head has moved" in prompt
@@ -232,8 +233,9 @@ def test_reviewer_submits_requested_exact_head(tmp_path, monkeypatch, draft):
232
233
  call = run.dispatcher.deliver.call_args.kwargs
233
234
  assert call["subject"] == "99:pr:2"
234
235
  assert call["delivery"] == "42:head-2"
235
- assert "latest review request for `all-hands-bot` event 42" in call["prompt"]
236
- assert "new head requires another reviewer request" in call["prompt"]
236
+ prompt = call["prompt"]("conversation")
237
+ assert "latest review request for `all-hands-bot` event 42" in prompt
238
+ assert "new head requires another reviewer request" in prompt
237
239
 
238
240
 
239
241
  def _real_dispatcher(dispatcher_class, monkeypatch, conversation):
@@ -341,7 +343,8 @@ def test_reviewer_ignores_request_for_another_reviewer(tmp_path, monkeypatch):
341
343
  run.dispatcher.deliver.assert_not_called()
342
344
 
343
345
 
344
- def test_reviewer_event_hands_positive_review_to_maintainer(tmp_path, monkeypatch):
346
+ @pytest.mark.parametrize("missing_issue", [False, True])
347
+ def test_reviewer_event_hands_positive_review_to_maintainer(tmp_path, monkeypatch, missing_issue):
345
348
  module, run = _reviewer(tmp_path, monkeypatch)
346
349
  _event(monkeypatch, action="submitted")
347
350
  run.config["maintainers"] = "neubig, VascoSch92"
@@ -352,14 +355,26 @@ def test_reviewer_event_hands_positive_review_to_maintainer(tmp_path, monkeypatc
352
355
  "created_at": "2026-01-01T00:00:00Z",
353
356
  "requested_reviewer": {"login": "all-hands-bot"},
354
357
  }
355
- run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
358
+ pr["body"] = "Fixes #7"
359
+
360
+ def gh(method, path, body=None):
361
+ if path == "/issues/7":
362
+ if missing_issue:
363
+ raise HTTPError(path, 404, "missing", {}, None)
364
+ return {"labels": [{"name": "priority:high"}]}
365
+ return {"id": 99} if path == "" else pr
366
+
367
+ run.gh = Mock(side_effect=gh) # GitHub transport boundary.
356
368
  run.gh_pages = lambda path: [request] if path.endswith("/events") else _reviews()
357
369
  handoff = Mock(return_value="VascoSch92")
358
370
  monkeypatch.setattr(module, "request_maintainer_review", handoff)
359
371
 
360
372
  run.run()
361
373
 
362
- handoff.assert_called_once_with(run, pr, ["neubig", "VascoSch92"])
374
+ if missing_issue:
375
+ handoff.assert_not_called()
376
+ else:
377
+ handoff.assert_called_once_with(run, pr, ["neubig", "VascoSch92"])
363
378
  assert all(call.args[0] != "DELETE" for call in run.gh.call_args_list)
364
379
  run.dispatcher.deliver.assert_not_called()
365
380
 
@@ -379,7 +394,7 @@ def test_reviewer_delayed_submitted_event_completes_prior_request(
379
394
  "created_at": "2026-01-03T00:00:00Z",
380
395
  "requested_reviewer": {"login": "all-hands-bot"},
381
396
  }
382
- run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
397
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"labels": [{"name": "priority:medium"}]}, pr])
383
398
  run.gh_pages = lambda path: (
384
399
  [request]
385
400
  if path.endswith("/events")
@@ -388,6 +403,7 @@ def test_reviewer_delayed_submitted_event_completes_prior_request(
388
403
  handoff = Mock(return_value="VascoSch92")
389
404
  monkeypatch.setattr(module, "request_maintainer_review", handoff)
390
405
 
406
+ pr["body"] = "Fixes #7"
391
407
  run.run()
392
408
 
393
409
  handoff.assert_called_once_with(run, pr, ["neubig", "VascoSch92"])
@@ -403,13 +419,14 @@ def test_reviewer_submitted_event_completes_without_request_event(
403
419
  _event(monkeypatch, action="submitted", review_id=555)
404
420
  run.config["maintainers"] = "neubig"
405
421
  pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
406
- run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
422
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"labels": [{"name": "priority:medium"}]}, pr])
407
423
  run.gh_pages = lambda path: (
408
424
  [] if path.endswith("/events") else _reviews(review_id=555)
409
425
  )
410
426
  handoff = Mock(return_value="neubig")
411
427
  monkeypatch.setattr(module, "request_maintainer_review", handoff)
412
428
 
429
+ pr["body"] = "Fixes #7"
413
430
  run.run()
414
431
 
415
432
  handoff.assert_called_once_with(run, pr, ["neubig"])
@@ -889,10 +906,11 @@ def test_reviewer_hands_positive_exact_head_to_maintainer(tmp_path, monkeypatch)
889
906
  "labels": [{"name": "openhands-review"}],
890
907
  }
891
908
  run.gh_pages = lambda path: [pr] if path.startswith("/pulls?") else _reviews()
892
- run.gh = Mock(side_effect=[{"id": 99}, pr, pr, {}])
909
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"labels": [{"name": "priority:medium"}]}, pr, {}])
893
910
  handoff = Mock(return_value="VascoSch92")
894
911
  monkeypatch.setattr(module, "request_maintainer_review", handoff)
895
912
 
913
+ pr["body"] = "Fixes #7"
896
914
  run.run()
897
915
 
898
916
  handoff.assert_called_once_with(run, pr, ["neubig", "VascoSch92"])
@@ -912,13 +930,14 @@ def test_reviewer_retries_failed_handoff_without_clearing_label(tmp_path, monkey
912
930
  "labels": [{"name": "openhands-review"}],
913
931
  }
914
932
  run.gh_pages = lambda path: [pr] if path.startswith("/pulls?") else _reviews()
915
- run.gh = Mock(side_effect=[{"id": 99}, pr])
933
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"labels": [{"name": "priority:medium"}]}])
916
934
  monkeypatch.setattr(
917
935
  module,
918
936
  "request_maintainer_review",
919
937
  Mock(side_effect=RuntimeError("temporary GitHub failure")),
920
938
  )
921
939
 
940
+ pr["body"] = "Fixes #7"
922
941
  with pytest.raises(RuntimeError, match="Reviewer scan failed for PRs: #2"):
923
942
  run.run()
924
943
 
@@ -935,7 +954,7 @@ def test_reviewer_reports_permanent_handoff_error_once(tmp_path, monkeypatch):
935
954
  "labels": [{"name": "openhands-review"}],
936
955
  }
937
956
  run.gh_pages = lambda path: [pr] if path.startswith("/pulls?") else _reviews()
938
- run.gh = Mock(side_effect=[{"id": 99}, pr, {}, pr, {}])
957
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"labels": [{"name": "priority:medium"}]}, {}, pr, {}])
939
958
  monkeypatch.setattr(
940
959
  module,
941
960
  "request_maintainer_review",
@@ -946,9 +965,10 @@ def test_reviewer_reports_permanent_handoff_error_once(tmp_path, monkeypatch):
946
965
  ),
947
966
  )
948
967
 
968
+ pr["body"] = "Fixes #7"
949
969
  run.run()
950
970
 
951
- assert "configuration check" in run.gh.call_args_list[2].args[2]["body"]
971
+ assert "configuration check" in run.gh.call_args_list[3].args[2]["body"]
952
972
  assert run.gh.call_args_list[-1].args == (
953
973
  "DELETE",
954
974
  "/issues/2/labels/openhands-review",
@@ -1009,10 +1029,11 @@ def test_reviewer_hands_scope_stop_to_maintainer(tmp_path, monkeypatch):
1009
1029
  if path.startswith("/pulls?")
1010
1030
  else _reviews(verdict="🛑 MAINTAINER DECISION REQUIRED")
1011
1031
  )
1012
- run.gh = Mock(side_effect=[{"id": 99}, pr, pr, {}])
1032
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"labels": [{"name": "priority:medium"}]}, pr, {}])
1013
1033
  handoff = Mock(return_value="VascoSch92")
1014
1034
  monkeypatch.setattr(module, "request_maintainer_review", handoff)
1015
1035
 
1036
+ pr["body"] = "Fixes #7"
1016
1037
  run.run()
1017
1038
 
1018
1039
  handoff.assert_called_once_with(run, pr, ["neubig", "VascoSch92"])
@@ -2054,7 +2075,9 @@ def test_reviewer_scheduled_scan_reviews_an_outstanding_request(tmp_path, monkey
2054
2075
  run.dispatcher.deliver.assert_called_once()
2055
2076
  call = run.dispatcher.deliver.call_args.kwargs
2056
2077
  assert call["delivery"] == "42:head-2"
2057
- assert "latest review request for `all-hands-bot` event 42" in call["prompt"]
2078
+ assert "latest review request for `all-hands-bot` event 42" in call["prompt"](
2079
+ "conversation"
2080
+ )
2058
2081
 
2059
2082
 
2060
2083
  @pytest.mark.parametrize("draft", [False, True])
@@ -2118,7 +2141,9 @@ def test_reviewer_scheduled_scan_reviews_an_unrequested_green_pr(
2118
2141
  call = run.dispatcher.deliver.call_args.kwargs
2119
2142
  assert call["subject"] == "99:pr:3"
2120
2143
  assert call["delivery"] == "scan:owner/repo:3:head-3"
2121
- assert "scheduled scan of open, non-draft pull requests" in call["prompt"]
2144
+ assert "scheduled scan of open, non-draft pull requests" in call["prompt"](
2145
+ "conversation"
2146
+ )
2122
2147
 
2123
2148
 
2124
2149
  def test_reviewer_scheduled_scan_skips_a_pr_with_a_current_head_review(
@@ -2264,7 +2289,9 @@ class _DedupeDispatcher:
2264
2289
 
2265
2290
  def deliver(self, *, subject, delivery, prompt, head=""):
2266
2291
  self.calls.append((subject, delivery))
2267
- self.prompts.append(prompt)
2292
+ # The worker's prompt names the receiving conversation, so render it
2293
+ # the way the real dispatcher does.
2294
+ self.prompts.append(prompt(subject))
2268
2295
  self.heads.append(head)
2269
2296
  if self.seen.get(subject) == delivery:
2270
2297
  return {"disposition": "deduplicated", "conversation_id": subject}
@@ -2817,13 +2844,13 @@ def test_unrequested_scan_reviews_the_oldest_eligible_prs_under_the_cap(
2817
2844
  }
2818
2845
 
2819
2846
 
2820
- def test_unrequested_scan_reconciles_a_completed_review_and_hands_off(
2847
+ def test_unrequested_scan_reconciles_a_completed_review_without_handoff(
2821
2848
  tmp_path, monkeypatch
2822
2849
  ):
2823
2850
  """A head that already carries a completed review is not re-dispatched.
2824
2851
 
2825
- The review is found on the current head, so the scan reconciles it and runs
2826
- the existing maintainer handoff instead of starting a second conversation.
2852
+ The review is reconciled without a second conversation or a handoff when
2853
+ no linked issue has an eligible priority.
2827
2854
  """
2828
2855
  module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"])
2829
2856
  _wire_scan(
@@ -2851,7 +2878,7 @@ def test_unrequested_scan_reconciles_a_completed_review_and_hands_off(
2851
2878
  _run_scan(module, [one])
2852
2879
 
2853
2880
  assert one.dispatcher.seen == {}
2854
- handoff.assert_called_once()
2881
+ handoff.assert_not_called()
2855
2882
 
2856
2883
 
2857
2884
  def test_unrequested_scan_still_gates_a_blocked_pr_past_the_cap(
@@ -3069,3 +3096,129 @@ def test_a_retried_conversation_uses_a_launch_slot(tmp_path, monkeypatch):
3069
3096
  intake.drain()
3070
3097
 
3071
3098
  assert started == [1]
3099
+
3100
+
3101
+ @pytest.mark.parametrize("status", [404, 403, 500])
3102
+ def test_linked_issue_lookup_http_errors(tmp_path, monkeypatch, status):
3103
+ _, run = _reviewer(tmp_path, monkeypatch)
3104
+ def gh(method, path):
3105
+ raise HTTPError(path, status, "unavailable", {}, None)
3106
+ run.gh = gh
3107
+ pr = {"body": "Fixes #7"}
3108
+ if status == 404:
3109
+ assert not run._linked_issue_allows_handoff(pr)
3110
+ else:
3111
+ with pytest.raises(HTTPError):
3112
+ run._linked_issue_allows_handoff(pr)
3113
+
3114
+
3115
+ def test_missing_link_does_not_hide_low_priority_link(tmp_path, monkeypatch):
3116
+ _, run = _reviewer(tmp_path, monkeypatch)
3117
+ def gh(method, path):
3118
+ if path == "/issues/7":
3119
+ raise HTTPError(path, 404, "missing", {}, None)
3120
+ return {"labels": [{"name": "priority:low"}]}
3121
+ run.gh = gh
3122
+ assert not run._linked_issue_allows_handoff({"body": "Fixes #7, fixes #8"})
3123
+
3124
+
3125
+ def test_rotating_window_revisits_budget_deferred_heads(tmp_path, monkeypatch):
3126
+ module, run = _reviewer(tmp_path, monkeypatch)
3127
+ monkeypatch.setattr(module.workflow, "_kv_available", lambda: False)
3128
+ prs = [{"number": n, "labels": []} for n in range(27)]
3129
+ reviewed = set(range(10))
3130
+ blocked = {10, 11}
3131
+ visited = set()
3132
+ started = []
3133
+ for _ in range(30):
3134
+ intake = module.ReviewIntake()
3135
+ window = run._rotating_window(prs, "openhands-review")
3136
+ visited.update(pr["number"] for pr in window)
3137
+ for pr in window:
3138
+ n = pr["number"]
3139
+ if n in reviewed or n in blocked:
3140
+ continue
3141
+ def start(n=n):
3142
+ reviewed.add(n)
3143
+ started.append(n)
3144
+ return {"disposition": "created"}
3145
+ intake.register({"priority": 1, "created_at": "", "repository": run.repository,
3146
+ "number": n, "config": {"max_new_per_run": 2}, "start": start})
3147
+ intake.drain()
3148
+ assert visited == set(range(27))
3149
+ assert set(started) == set(range(27)) - set(range(10)) - blocked
3150
+ assert len(started) == len(set(started))
3151
+
3152
+
3153
+ @pytest.mark.parametrize("trigger_kind", ["scheduled", "requested", "submitted"])
3154
+ @pytest.mark.parametrize("scope_stop", [False, True])
3155
+ @pytest.mark.parametrize(
3156
+ "body,issues,expected",
3157
+ [
3158
+ ("", {}, False),
3159
+ ("Fixes #7", {7: {}}, False),
3160
+ ("Fixes #7", {7: {"labels": [{"name": "priority:low"}]}}, False),
3161
+ ("Fixes #7", {7: None}, False),
3162
+ ("Fixes #7", {7: {"labels": [{"name": "priority:medium"}]}}, True),
3163
+ ("Fixes #7", {7: {"labels": [{"name": "priority:high"}]}}, True),
3164
+ (
3165
+ "Fixes #7, fixes #8",
3166
+ {
3167
+ 7: {"labels": [{"name": "priority:low"}]},
3168
+ 8: {"labels": [{"name": "priority:high"}]},
3169
+ },
3170
+ True,
3171
+ ),
3172
+ (
3173
+ "Fixes #7, fixes #8",
3174
+ {7: None, 8: {"labels": [{"name": "priority:medium"}]}},
3175
+ True,
3176
+ ),
3177
+ (
3178
+ "Fixes #7",
3179
+ {7: {"labels": [{"name": "priority:high"}], "pull_request": {}}},
3180
+ False,
3181
+ ),
3182
+ ("Fixes #7", {7: {"labels": [{"name": "priority:normal"}]}}, False),
3183
+ ],
3184
+ )
3185
+ def test_priority_gates_real_handoff(
3186
+ tmp_path, monkeypatch, trigger_kind, scope_stop, body, issues, expected
3187
+ ):
3188
+ """Only the GitHub boundary is substituted; completion and selection are real."""
3189
+ module, run = _reviewer(tmp_path, monkeypatch)
3190
+ run.config["maintainers"] = ["maintainer"]
3191
+ verdict = (
3192
+ module.workflow.MAINTAINER_DECISION_VERDICT if scope_stop else "✅ APPROVED"
3193
+ )
3194
+ pr = {
3195
+ "number": 2,
3196
+ "body": body,
3197
+ "head": {"sha": "head-2"},
3198
+ "user": {"login": "author"},
3199
+ }
3200
+ writes = []
3201
+
3202
+ def gh(method, path, data=None):
3203
+ if method == "GET" and path == "/pulls/2":
3204
+ return pr
3205
+ if method == "GET" and path.startswith("/issues/"):
3206
+ issue = issues[int(path.rsplit("/", 1)[1])]
3207
+ if issue is None:
3208
+ raise HTTPError(path, 404, "missing", {}, None)
3209
+ return issue
3210
+ assert method == "POST" and path == "/pulls/2/requested_reviewers"
3211
+ writes.append(data)
3212
+ return {}
3213
+
3214
+ run.gh = gh
3215
+ run.gh_pages = lambda path: (
3216
+ _reviews(verdict=verdict, review_id=42) if path.endswith("/reviews") else []
3217
+ )
3218
+ run.api = lambda *args, **kwargs: {"total_count": 0}
3219
+ trigger = (
3220
+ None if trigger_kind == "scheduled" else {"created_at": "2026-01-01T00:00:00Z"}
3221
+ )
3222
+ submitted = {"id": 42} if trigger_kind == "submitted" else None
3223
+ assert run._finish_completed_review(pr, trigger, submitted_review=submitted)
3224
+ assert writes == ([{"reviewers": ["maintainer"]}] if expected else [])