@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.
- package/.agents/skills/custom-codereview-guide.md +86 -17
- package/.claude-plugin/marketplace.json +14 -0
- package/.codex-plugin/marketplace.json +14 -0
- package/.github/workflows/check-extensions.yml +6 -6
- package/.github/workflows/deprecation-check.yml +1 -1
- package/.github/workflows/issue-readiness-check.yml +37 -106
- package/.github/workflows/npm-publish.yml +2 -2
- package/.github/workflows/pr-description-check.yml +1 -1
- package/.github/workflows/pypi-publish.yml +3 -3
- package/.github/workflows/tests.yml +2 -2
- package/.plugin/marketplace.json +14 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +32 -2
- package/README.md +4 -3
- package/automations/bundle-index.js +12 -11
- package/automations/catalog/github-issue-triage/manifest.json +2 -2
- package/automations/catalog/github-pr-reviewer/manifest.json +42 -5
- package/automations/catalog.schema.json +0 -4
- package/integrations/catalog/granola.json +38 -0
- package/integrations/catalog-index.js +16 -14
- package/marketplaces/openhands-extensions.json +14 -0
- package/package.json +1 -1
- package/plugins/openhands/skills/openhands-sdk/SKILL.md +3 -1
- package/plugins/pr-review/README.md +2 -1
- package/plugins/pr-review/scripts/agent_script.py +198 -81
- package/plugins/pr-review/scripts/prompt.py +35 -11
- package/plugins/pr-review/skills/code-review/SKILL.md +165 -200
- package/plugins/pr-review/skills/code-review/commands/codereview-roasted.md +1 -1
- package/plugins/pr-review/skills/code-review/commands/codereview.md +1 -1
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +8 -3
- package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +4 -0
- package/pyproject.toml +2 -2
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/add-skill/SKILL.md +5 -2
- package/skills/canvas-extension-api/.claude-plugin/plugin.json +19 -0
- package/skills/canvas-extension-api/.codex-plugin/plugin.json +19 -0
- package/skills/canvas-extension-api/.plugin/plugin.json +19 -0
- package/skills/canvas-extension-api/README.md +33 -0
- package/skills/canvas-extension-api/SKILL.md +254 -0
- package/skills/canvas-extension-api/references/acceptance-checklist.md +20 -0
- package/skills/canvas-extension-api/references/backend-safety.md +9 -0
- package/skills/canvas-extension-api/references/connections.md +126 -0
- package/skills/canvas-extension-api/references/packaging-recipes.md +43 -0
- package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -0
- package/skills/canvas-extension-api/references/testing-and-installation.md +286 -0
- package/skills/canvas-extension-api/references/v1-contract.md +250 -0
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +92 -0
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +60 -0
- package/skills/code-review/SKILL.md +165 -200
- package/skills/code-review/commands/codereview-roasted.md +1 -1
- package/skills/code-review/commands/codereview.md +1 -1
- package/skills/github/scripts/agent_conversation.py +8 -3
- package/skills/github/scripts/github_client.py +4 -0
- package/skills/github-agents-md-maintainer/scripts/github_client.py +4 -0
- package/skills/github-delivery-watchdog/scripts/github_client.py +4 -0
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +8 -3
- package/skills/github-issue-to-pr/scripts/github_client.py +4 -0
- package/skills/github-issue-triage/.claude-plugin/plugin.json +1 -1
- package/skills/github-issue-triage/.codex-plugin/plugin.json +1 -1
- package/skills/github-issue-triage/.plugin/plugin.json +1 -1
- package/skills/github-issue-triage/README.md +7 -0
- package/skills/github-issue-triage/SKILL.md +26 -3
- package/skills/github-issue-triage/scripts/agent_conversation.py +8 -3
- package/skills/github-issue-triage/scripts/github_client.py +4 -0
- package/skills/github-issue-triage/scripts/worker.py +142 -26
- package/skills/github-pr-reviewer/README.md +21 -10
- package/skills/github-pr-reviewer/SKILL.md +36 -10
- package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +8 -3
- package/skills/github-pr-reviewer/scripts/github_client.py +4 -0
- package/skills/github-pr-reviewer/scripts/main.py +63 -16
- package/skills/github-pr-reviewer/scripts/maintainer_handoff.py +151 -0
- package/skills/github-pr-reviewer/scripts/worker.py +218 -21
- package/skills/github-pr-reviewer/tests/test_main.py +85 -0
- package/skills/github-repo-monitor/scripts/github_client.py +4 -0
- package/skills/index.js +16 -8
- package/skills/openhands-sdk/SKILL.md +3 -1
- package/tests/fixtures/automations/github-pr-reviewer.json +120 -26
- package/tests/github_automation_helpers.py +1 -0
- package/tests/test_agent_conversation_dispatch.py +19 -4
- package/tests/test_automation_setup.py +15 -0
- package/tests/test_code_review_risk_evaluation.py +2 -2
- package/tests/test_github_reviewer_delivery.py +448 -5
- package/tests/test_github_triage_delivery.py +200 -6
- package/tests/test_maintainer_handoff.py +198 -0
- package/tests/test_pr_review_prompt.py +6 -2
- package/tests/test_pr_review_review_context.py +144 -0
- package/.github/scripts/check_issue_readiness.py +0 -272
- package/.github/scripts/post-readiness-comment.mjs +0 -184
- 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 "
|
|
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
|
|
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
|
-
|
|
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()
|