@openhands/extensions 0.22.0 → 0.23.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 +84 -17
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +22 -1
- package/README.md +1 -1
- package/automations/bundle-index.js +12 -11
- package/automations/catalog/github-issue-to-pr/manifest.json +2 -2
- 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/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 +15 -3
- package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +4 -0
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- 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 +15 -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 +15 -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 +15 -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 +16 -10
- package/skills/github-pr-reviewer/SKILL.md +12 -9
- package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +15 -3
- package/skills/github-pr-reviewer/scripts/github_client.py +4 -0
- package/skills/github-pr-reviewer/scripts/main.py +23 -5
- package/skills/github-pr-reviewer/scripts/maintainer_handoff.py +151 -0
- package/skills/github-pr-reviewer/scripts/worker.py +209 -21
- package/skills/github-pr-reviewer/tests/test_main.py +16 -0
- package/skills/github-repo-monitor/scripts/github_client.py +4 -0
- package/skills/index.js +6 -6
- 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 +35 -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 +320 -4
- 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
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
"""Contract tests for delegated GitHub PR review."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
from unittest.mock import Mock
|
|
5
6
|
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
6
9
|
from github_automation_helpers import worker
|
|
7
10
|
|
|
8
11
|
|
|
12
|
+
def _reviews(
|
|
13
|
+
verdict="✅ APPROVED",
|
|
14
|
+
submitted_at="2026-01-02T00:00:00Z",
|
|
15
|
+
sha="head-2",
|
|
16
|
+
login="all-hands-bot",
|
|
17
|
+
):
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
"body": f"Review body\n\n{verdict}",
|
|
21
|
+
"commit_id": sha,
|
|
22
|
+
"submitted_at": submitted_at,
|
|
23
|
+
"user": {"login": login},
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
9
28
|
def _reviewer(tmp_path, monkeypatch):
|
|
10
29
|
module = worker("github-pr-reviewer", tmp_path, monkeypatch)
|
|
11
30
|
run = object.__new__(module.PullRequestReviewer)
|
|
@@ -13,10 +32,33 @@ def _reviewer(tmp_path, monkeypatch):
|
|
|
13
32
|
run.repository = "owner/repo"
|
|
14
33
|
run.token = "token"
|
|
15
34
|
run.token_name = "FACTORY_GITHUB_REVIEWER_TOKEN"
|
|
35
|
+
run.github_login = "all-hands-bot"
|
|
16
36
|
run.dispatcher = Mock()
|
|
37
|
+
monkeypatch.delenv("AUTOMATION_EVENT_PAYLOAD", raising=False)
|
|
38
|
+
monkeypatch.setattr(
|
|
39
|
+
module.workflow,
|
|
40
|
+
"_latest_trigger_label_event",
|
|
41
|
+
lambda *args: {"id": 7, "created_at": "2026-01-01T00:00:00Z"},
|
|
42
|
+
)
|
|
17
43
|
return module, run
|
|
18
44
|
|
|
19
45
|
|
|
46
|
+
def _event(monkeypatch, *, action="review_requested", login="all-hands-bot"):
|
|
47
|
+
payload = {
|
|
48
|
+
"action": action,
|
|
49
|
+
"repository": {"full_name": "owner/repo"},
|
|
50
|
+
"pull_request": {"number": 2},
|
|
51
|
+
}
|
|
52
|
+
if action == "review_requested":
|
|
53
|
+
payload["requested_reviewer"] = {"login": login}
|
|
54
|
+
else:
|
|
55
|
+
payload["review"] = {"user": {"login": login}}
|
|
56
|
+
monkeypatch.setenv(
|
|
57
|
+
"AUTOMATION_EVENT_PAYLOAD",
|
|
58
|
+
json.dumps({"automation_id": "automation", "event": {"payload": payload}}),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
20
62
|
def test_reviewer_submits_each_labeled_exact_head(tmp_path, monkeypatch):
|
|
21
63
|
module, run = _reviewer(tmp_path, monkeypatch)
|
|
22
64
|
prs = [
|
|
@@ -45,12 +87,168 @@ def test_reviewer_submits_each_labeled_exact_head(tmp_path, monkeypatch):
|
|
|
45
87
|
assert submit.call_args.kwargs["delivery"] == "7:head-2"
|
|
46
88
|
prompt = submit.call_args.kwargs["prompt"]
|
|
47
89
|
assert "publish the review directly to GitHub" in prompt
|
|
48
|
-
assert "
|
|
49
|
-
assert "software-factory/tests" in prompt
|
|
90
|
+
assert "Do not create commit statuses or Checks" in prompt
|
|
50
91
|
assert "FACTORY_GITHUB_REVIEWER_TOKEN" in prompt
|
|
51
92
|
assert "gh auth setup-git" in prompt
|
|
52
93
|
assert "GIT_TERMINAL_PROMPT=0" in prompt
|
|
53
94
|
assert "Never paste JSON artifacts" in prompt
|
|
95
|
+
assert "stop immediately" in prompt
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_reviewer_submits_requested_exact_head(tmp_path, monkeypatch):
|
|
99
|
+
_module, run = _reviewer(tmp_path, monkeypatch)
|
|
100
|
+
_event(monkeypatch)
|
|
101
|
+
pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
|
|
102
|
+
request = {
|
|
103
|
+
"id": 42,
|
|
104
|
+
"event": "review_requested",
|
|
105
|
+
"created_at": "2026-01-01T00:00:00Z",
|
|
106
|
+
"requested_reviewer": {"login": "all-hands-bot"},
|
|
107
|
+
}
|
|
108
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr])
|
|
109
|
+
run.gh_pages = lambda path: [request] if path.endswith("/events") else []
|
|
110
|
+
run.dispatcher.deliver.return_value = {
|
|
111
|
+
"disposition": "created",
|
|
112
|
+
"conversation_id": "conversation",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
run.run()
|
|
116
|
+
|
|
117
|
+
call = run.dispatcher.deliver.call_args.kwargs
|
|
118
|
+
assert call["subject"] == "99:pr:2"
|
|
119
|
+
assert call["delivery"] == "42:head-2"
|
|
120
|
+
assert "latest review request for `all-hands-bot` event 42" in call["prompt"]
|
|
121
|
+
assert "new head requires another reviewer request" in call["prompt"]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_reviewer_ignores_request_for_another_reviewer(tmp_path, monkeypatch):
|
|
125
|
+
_module, run = _reviewer(tmp_path, monkeypatch)
|
|
126
|
+
_event(monkeypatch, login="human")
|
|
127
|
+
run.gh = Mock(return_value={"id": 99})
|
|
128
|
+
|
|
129
|
+
run.run()
|
|
130
|
+
|
|
131
|
+
run.dispatcher.deliver.assert_not_called()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_reviewer_event_hands_positive_review_to_maintainer(tmp_path, monkeypatch):
|
|
135
|
+
module, run = _reviewer(tmp_path, monkeypatch)
|
|
136
|
+
_event(monkeypatch, action="submitted")
|
|
137
|
+
run.config["maintainers"] = "neubig, VascoSch92"
|
|
138
|
+
pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
|
|
139
|
+
request = {
|
|
140
|
+
"id": 42,
|
|
141
|
+
"event": "review_requested",
|
|
142
|
+
"created_at": "2026-01-01T00:00:00Z",
|
|
143
|
+
"requested_reviewer": {"login": "all-hands-bot"},
|
|
144
|
+
}
|
|
145
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
|
|
146
|
+
run.gh_pages = lambda path: [request] if path.endswith("/events") else _reviews()
|
|
147
|
+
handoff = Mock(return_value="VascoSch92")
|
|
148
|
+
monkeypatch.setattr(module, "request_maintainer_review", handoff)
|
|
149
|
+
|
|
150
|
+
run.run()
|
|
151
|
+
|
|
152
|
+
handoff.assert_called_once_with(run, pr, ["neubig", "VascoSch92"])
|
|
153
|
+
assert all(call.args[0] != "DELETE" for call in run.gh.call_args_list)
|
|
154
|
+
run.dispatcher.deliver.assert_not_called()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_reviewer_submitted_non_decisive_review_does_not_dispatch(
|
|
158
|
+
tmp_path, monkeypatch
|
|
159
|
+
):
|
|
160
|
+
module, run = _reviewer(tmp_path, monkeypatch)
|
|
161
|
+
_event(monkeypatch, action="submitted")
|
|
162
|
+
run.config["maintainers"] = "neubig"
|
|
163
|
+
pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
|
|
164
|
+
request = {
|
|
165
|
+
"id": 42,
|
|
166
|
+
"event": "review_requested",
|
|
167
|
+
"created_at": "2026-01-01T00:00:00Z",
|
|
168
|
+
"requested_reviewer": {"login": "all-hands-bot"},
|
|
169
|
+
}
|
|
170
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr])
|
|
171
|
+
run.gh_pages = lambda path: (
|
|
172
|
+
[request] if path.endswith("/events") else _reviews(verdict="LGTM")
|
|
173
|
+
)
|
|
174
|
+
handoff = Mock()
|
|
175
|
+
monkeypatch.setattr(module, "request_maintainer_review", handoff)
|
|
176
|
+
|
|
177
|
+
run.run()
|
|
178
|
+
|
|
179
|
+
run.dispatcher.deliver.assert_not_called()
|
|
180
|
+
handoff.assert_not_called()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_reviewer_submitted_review_on_superseded_head_does_not_dispatch(
|
|
184
|
+
tmp_path, monkeypatch
|
|
185
|
+
):
|
|
186
|
+
module, run = _reviewer(tmp_path, monkeypatch)
|
|
187
|
+
_event(monkeypatch, action="submitted")
|
|
188
|
+
run.config["maintainers"] = "neubig"
|
|
189
|
+
pr = {"number": 2, "head": {"sha": "head-3"}, "labels": []}
|
|
190
|
+
request = {
|
|
191
|
+
"id": 42,
|
|
192
|
+
"event": "review_requested",
|
|
193
|
+
"created_at": "2026-01-01T00:00:00Z",
|
|
194
|
+
"requested_reviewer": {"login": "all-hands-bot"},
|
|
195
|
+
}
|
|
196
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr])
|
|
197
|
+
run.gh_pages = lambda path: (
|
|
198
|
+
[request] if path.endswith("/events") else _reviews(sha="head-2")
|
|
199
|
+
)
|
|
200
|
+
handoff = Mock()
|
|
201
|
+
monkeypatch.setattr(module, "request_maintainer_review", handoff)
|
|
202
|
+
|
|
203
|
+
run.run()
|
|
204
|
+
|
|
205
|
+
run.dispatcher.deliver.assert_not_called()
|
|
206
|
+
handoff.assert_not_called()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_reviewer_redelivers_when_review_predates_latest_label(tmp_path, monkeypatch):
|
|
210
|
+
_module, run = _reviewer(tmp_path, monkeypatch)
|
|
211
|
+
pr = {
|
|
212
|
+
"number": 2,
|
|
213
|
+
"head": {"sha": "head-2"},
|
|
214
|
+
"labels": [{"name": "openhands-review"}],
|
|
215
|
+
}
|
|
216
|
+
run.gh_pages = lambda path: (
|
|
217
|
+
[pr]
|
|
218
|
+
if path.startswith("/pulls?")
|
|
219
|
+
else _reviews(submitted_at="2025-12-31T00:00:00Z")
|
|
220
|
+
)
|
|
221
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr])
|
|
222
|
+
run.dispatcher.deliver.return_value = {
|
|
223
|
+
"disposition": "continued",
|
|
224
|
+
"conversation_id": "conversation",
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
run.run()
|
|
228
|
+
|
|
229
|
+
run.dispatcher.deliver.assert_called_once()
|
|
230
|
+
assert run.dispatcher.deliver.call_args.kwargs["delivery"] == "7:head-2"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_reviewer_does_not_trust_another_reviewers_verdict(tmp_path, monkeypatch):
|
|
234
|
+
_module, run = _reviewer(tmp_path, monkeypatch)
|
|
235
|
+
pr = {
|
|
236
|
+
"number": 2,
|
|
237
|
+
"head": {"sha": "head-2"},
|
|
238
|
+
"labels": [{"name": "openhands-review"}],
|
|
239
|
+
}
|
|
240
|
+
run.gh_pages = lambda path: (
|
|
241
|
+
[pr] if path.startswith("/pulls?") else _reviews(login="other-reviewer")
|
|
242
|
+
)
|
|
243
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr])
|
|
244
|
+
run.dispatcher.deliver.return_value = {
|
|
245
|
+
"disposition": "continued",
|
|
246
|
+
"conversation_id": "conversation",
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
run.run()
|
|
250
|
+
|
|
251
|
+
run.dispatcher.deliver.assert_called_once()
|
|
54
252
|
|
|
55
253
|
|
|
56
254
|
def test_reviewer_ignores_unlabeled_prs(tmp_path, monkeypatch):
|
|
@@ -65,7 +263,9 @@ def test_reviewer_ignores_unlabeled_prs(tmp_path, monkeypatch):
|
|
|
65
263
|
submit.assert_not_called()
|
|
66
264
|
|
|
67
265
|
|
|
68
|
-
def
|
|
266
|
+
def test_reviewer_continues_after_one_submission_fails_then_reports_run_failure(
|
|
267
|
+
tmp_path, monkeypatch
|
|
268
|
+
):
|
|
69
269
|
module, run = _reviewer(tmp_path, monkeypatch)
|
|
70
270
|
prs = [
|
|
71
271
|
{
|
|
@@ -90,7 +290,8 @@ def test_reviewer_continues_after_one_submission_fails(tmp_path, monkeypatch):
|
|
|
90
290
|
)
|
|
91
291
|
run.dispatcher.deliver = submit
|
|
92
292
|
|
|
93
|
-
|
|
293
|
+
with pytest.raises(RuntimeError, match="Reviewer scan failed for PRs: #1"):
|
|
294
|
+
run.run()
|
|
94
295
|
|
|
95
296
|
assert submit.call_count == 2
|
|
96
297
|
|
|
@@ -108,3 +309,118 @@ def test_reviewer_scanner_has_no_conversation_or_runtime_code():
|
|
|
108
309
|
"AUTOMATION_CONVERSATION_ID",
|
|
109
310
|
):
|
|
110
311
|
assert forbidden not in source
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def test_reviewer_hands_positive_exact_head_to_maintainer(tmp_path, monkeypatch):
|
|
315
|
+
module, run = _reviewer(tmp_path, monkeypatch)
|
|
316
|
+
run.config["maintainers"] = "neubig, VascoSch92"
|
|
317
|
+
pr = {
|
|
318
|
+
"number": 2,
|
|
319
|
+
"head": {"sha": "head-2"},
|
|
320
|
+
"labels": [{"name": "openhands-review"}],
|
|
321
|
+
}
|
|
322
|
+
run.gh_pages = lambda path: [pr] if path.startswith("/pulls?") else _reviews()
|
|
323
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr, pr, {}])
|
|
324
|
+
handoff = Mock(return_value="VascoSch92")
|
|
325
|
+
monkeypatch.setattr(module, "request_maintainer_review", handoff)
|
|
326
|
+
|
|
327
|
+
run.run()
|
|
328
|
+
|
|
329
|
+
handoff.assert_called_once_with(run, pr, ["neubig", "VascoSch92"])
|
|
330
|
+
assert run.gh.call_args_list[-1].args == (
|
|
331
|
+
"DELETE",
|
|
332
|
+
"/issues/2/labels/openhands-review",
|
|
333
|
+
)
|
|
334
|
+
run.dispatcher.deliver.assert_not_called()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_reviewer_retries_failed_handoff_without_clearing_label(tmp_path, monkeypatch):
|
|
338
|
+
module, run = _reviewer(tmp_path, monkeypatch)
|
|
339
|
+
run.config["maintainers"] = "neubig"
|
|
340
|
+
pr = {
|
|
341
|
+
"number": 2,
|
|
342
|
+
"head": {"sha": "head-2"},
|
|
343
|
+
"labels": [{"name": "openhands-review"}],
|
|
344
|
+
}
|
|
345
|
+
run.gh_pages = lambda path: [pr] if path.startswith("/pulls?") else _reviews()
|
|
346
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr])
|
|
347
|
+
monkeypatch.setattr(
|
|
348
|
+
module,
|
|
349
|
+
"request_maintainer_review",
|
|
350
|
+
Mock(side_effect=RuntimeError("temporary GitHub failure")),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
with pytest.raises(RuntimeError, match="Reviewer scan failed for PRs: #2"):
|
|
354
|
+
run.run()
|
|
355
|
+
|
|
356
|
+
assert all(call.args[0] != "DELETE" for call in run.gh.call_args_list)
|
|
357
|
+
run.dispatcher.deliver.assert_not_called()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_reviewer_reports_permanent_handoff_error_once(tmp_path, monkeypatch):
|
|
361
|
+
module, run = _reviewer(tmp_path, monkeypatch)
|
|
362
|
+
run.config["maintainers"] = "author"
|
|
363
|
+
pr = {
|
|
364
|
+
"number": 2,
|
|
365
|
+
"head": {"sha": "head-2"},
|
|
366
|
+
"labels": [{"name": "openhands-review"}],
|
|
367
|
+
}
|
|
368
|
+
run.gh_pages = lambda path: [pr] if path.startswith("/pulls?") else _reviews()
|
|
369
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr, {}, pr, {}])
|
|
370
|
+
monkeypatch.setattr(
|
|
371
|
+
module,
|
|
372
|
+
"request_maintainer_review",
|
|
373
|
+
Mock(
|
|
374
|
+
side_effect=module.HandoffConfigurationError(
|
|
375
|
+
"No eligible maintainer remains"
|
|
376
|
+
)
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
run.run()
|
|
381
|
+
|
|
382
|
+
assert "configuration check" in run.gh.call_args_list[2].args[2]["body"]
|
|
383
|
+
assert run.gh.call_args_list[-1].args == (
|
|
384
|
+
"DELETE",
|
|
385
|
+
"/issues/2/labels/openhands-review",
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def test_reviewer_keeps_label_when_head_moves_before_cleanup(tmp_path, monkeypatch):
|
|
390
|
+
_module, run = _reviewer(tmp_path, monkeypatch)
|
|
391
|
+
pr = {
|
|
392
|
+
"number": 2,
|
|
393
|
+
"head": {"sha": "head-2"},
|
|
394
|
+
"labels": [{"name": "openhands-review"}],
|
|
395
|
+
}
|
|
396
|
+
moved = {**pr, "head": {"sha": "head-3"}}
|
|
397
|
+
run.gh_pages = lambda path: (
|
|
398
|
+
[pr] if path.startswith("/pulls?") else _reviews(verdict="🔄 CHANGES REQUESTED")
|
|
399
|
+
)
|
|
400
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr, moved])
|
|
401
|
+
|
|
402
|
+
run.run()
|
|
403
|
+
|
|
404
|
+
assert all(call.args[0] != "DELETE" for call in run.gh.call_args_list)
|
|
405
|
+
run.dispatcher.deliver.assert_not_called()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def test_reviewer_does_not_handoff_failed_review(tmp_path, monkeypatch):
|
|
409
|
+
module, run = _reviewer(tmp_path, monkeypatch)
|
|
410
|
+
run.config["maintainers"] = "neubig"
|
|
411
|
+
pr = {
|
|
412
|
+
"number": 2,
|
|
413
|
+
"head": {"sha": "head-2"},
|
|
414
|
+
"labels": [{"name": "openhands-review"}],
|
|
415
|
+
}
|
|
416
|
+
run.gh_pages = lambda path: (
|
|
417
|
+
[pr] if path.startswith("/pulls?") else _reviews(verdict="🔄 CHANGES REQUESTED")
|
|
418
|
+
)
|
|
419
|
+
run.gh = Mock(side_effect=[{"id": 99}, pr, pr, {}])
|
|
420
|
+
handoff = Mock()
|
|
421
|
+
monkeypatch.setattr(module, "request_maintainer_review", handoff)
|
|
422
|
+
|
|
423
|
+
run.run()
|
|
424
|
+
|
|
425
|
+
handoff.assert_not_called()
|
|
426
|
+
run.dispatcher.deliver.assert_not_called()
|
|
@@ -41,6 +41,29 @@ def test_triage_submits_each_changed_issue_as_agent_work(tmp_path, monkeypatch):
|
|
|
41
41
|
]
|
|
42
42
|
assert "Do not implement code" in submit.call_args_list[0].kwargs["prompt"]
|
|
43
43
|
assert "GITHUB_PERSONAL_ACCESS_TOKEN" in submit.call_args_list[0].kwargs["prompt"]
|
|
44
|
+
assert (
|
|
45
|
+
"The following comments and acceptance criteria were added by the "
|
|
46
|
+
"OpenHands AI agent." in submit.call_args_list[0].kwargs["prompt"]
|
|
47
|
+
)
|
|
48
|
+
assert (
|
|
49
|
+
"closest existing or adjacent implementation"
|
|
50
|
+
in submit.call_args_list[0].kwargs["prompt"]
|
|
51
|
+
)
|
|
52
|
+
assert "Add `ready-for-dev` only after" in submit.call_args_list[0].kwargs["prompt"]
|
|
53
|
+
assert (
|
|
54
|
+
"Do not invent acceptance criteria around an arbitrary choice"
|
|
55
|
+
in submit.call_args_list[0].kwargs["prompt"]
|
|
56
|
+
)
|
|
57
|
+
assert (
|
|
58
|
+
"ask only the focused follow-up questions needed to resolve it"
|
|
59
|
+
in submit.call_args_list[0].kwargs["prompt"]
|
|
60
|
+
)
|
|
61
|
+
prompt = submit.call_args_list[0].kwargs["prompt"]
|
|
62
|
+
assert "<!-- openhands-ai-triage:start -->" in prompt
|
|
63
|
+
assert "<!-- openhands-ai-triage:end -->" in prompt
|
|
64
|
+
assert "Do not post a triage comment" in prompt
|
|
65
|
+
assert "DECISION NEEDED" in prompt
|
|
66
|
+
assert "Leave the human-owned issue body unchanged" in prompt
|
|
44
67
|
|
|
45
68
|
|
|
46
69
|
def test_triage_continues_after_one_submission_fails(tmp_path, monkeypatch):
|
|
@@ -65,7 +88,44 @@ def test_triage_continues_after_one_submission_fails(tmp_path, monkeypatch):
|
|
|
65
88
|
]
|
|
66
89
|
|
|
67
90
|
|
|
68
|
-
def
|
|
91
|
+
def test_triage_event_submits_only_the_named_issue(tmp_path, monkeypatch):
|
|
92
|
+
issues = [
|
|
93
|
+
{"number": 1, "title": "First", "body": "A", "labels": []},
|
|
94
|
+
{"number": 2, "title": "Second", "body": "B", "labels": []},
|
|
95
|
+
]
|
|
96
|
+
_module, run = _triage(tmp_path, monkeypatch, issues)
|
|
97
|
+
monkeypatch.setenv(
|
|
98
|
+
"AUTOMATION_EVENT_PAYLOAD",
|
|
99
|
+
'{"event":{"payload":{"repository":{"full_name":"owner/repo"},'
|
|
100
|
+
'"issue":{"number":2}}}}',
|
|
101
|
+
)
|
|
102
|
+
run.dispatcher.deliver.return_value = {
|
|
103
|
+
"disposition": "created",
|
|
104
|
+
"conversation_id": "second",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
run.run()
|
|
108
|
+
|
|
109
|
+
assert run.dispatcher.deliver.call_args.kwargs["subject"] == "9876:issue:2"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_triage_event_skips_other_repositories(tmp_path, monkeypatch):
|
|
113
|
+
issues = [{"number": 1, "title": "First", "body": "A", "labels": []}]
|
|
114
|
+
_module, run = _triage(tmp_path, monkeypatch, issues)
|
|
115
|
+
monkeypatch.setenv(
|
|
116
|
+
"AUTOMATION_EVENT_PAYLOAD",
|
|
117
|
+
'{"event":{"payload":{"repository":{"full_name":"other/repo"},'
|
|
118
|
+
'"issue":{"number":1}}}}',
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
run.run()
|
|
122
|
+
|
|
123
|
+
run.dispatcher.deliver.assert_not_called()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_triage_checks_ready_issue_but_skips_blocked_and_unchanged_issues(
|
|
127
|
+
tmp_path, monkeypatch
|
|
128
|
+
):
|
|
69
129
|
issues = [
|
|
70
130
|
{
|
|
71
131
|
"number": 1,
|
|
@@ -78,13 +138,147 @@ def test_triage_skips_ready_blocked_and_unchanged_issues(tmp_path, monkeypatch):
|
|
|
78
138
|
]
|
|
79
139
|
module, run = _triage(tmp_path, monkeypatch, issues)
|
|
80
140
|
run.dependencies_complete = lambda issue: issue["number"] != 2
|
|
81
|
-
|
|
82
|
-
module.json.dumps(
|
|
141
|
+
unchanged_marker = module.hashlib.sha256(
|
|
142
|
+
module.json.dumps(
|
|
143
|
+
[module.TRIAGE_FORMAT_VERSION, "Unchanged", "", []], sort_keys=True
|
|
144
|
+
).encode()
|
|
83
145
|
).hexdigest()
|
|
84
|
-
run.gh_pages = lambda path:
|
|
85
|
-
|
|
146
|
+
run.gh_pages = lambda path: (
|
|
147
|
+
[
|
|
148
|
+
{
|
|
149
|
+
"id": 10,
|
|
150
|
+
"body": f"<!-- triage-source:{unchanged_marker} -->",
|
|
151
|
+
}
|
|
152
|
+
]
|
|
153
|
+
if path == "/issues/3/comments"
|
|
154
|
+
else []
|
|
155
|
+
)
|
|
156
|
+
submit = Mock(return_value={"disposition": "created", "conversation_id": "ready"})
|
|
86
157
|
run.dispatcher.deliver = submit
|
|
87
158
|
|
|
88
159
|
run.run()
|
|
89
160
|
|
|
90
|
-
submit.
|
|
161
|
+
assert [call.kwargs["subject"] for call in submit.call_args_list] == [
|
|
162
|
+
"9876:issue:1"
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_triage_passes_human_discussion_and_stale_bot_comment(tmp_path, monkeypatch):
|
|
167
|
+
issue = {
|
|
168
|
+
"number": 7,
|
|
169
|
+
"title": "Improve behavior",
|
|
170
|
+
"body": "Original request",
|
|
171
|
+
"labels": [{"name": "enhancement"}],
|
|
172
|
+
}
|
|
173
|
+
_module, run = _triage(tmp_path, monkeypatch, [issue])
|
|
174
|
+
run.gh_pages = lambda path: [
|
|
175
|
+
{
|
|
176
|
+
"id": 101,
|
|
177
|
+
"user": {"login": "maintainer"},
|
|
178
|
+
"created_at": "2026-09-18T00:00:00Z",
|
|
179
|
+
"updated_at": "2026-09-18T00:00:00Z",
|
|
180
|
+
"body": "Keep compatibility with saved configurations.",
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"id": 102,
|
|
184
|
+
"user": {"login": "all-hands-bot"},
|
|
185
|
+
"created_at": "2026-09-18T00:01:00Z",
|
|
186
|
+
"updated_at": "2026-09-18T00:01:00Z",
|
|
187
|
+
"body": "Old triage\n<!-- triage-source:old -->",
|
|
188
|
+
},
|
|
189
|
+
]
|
|
190
|
+
run.dispatcher.deliver.return_value = {
|
|
191
|
+
"disposition": "resumed",
|
|
192
|
+
"conversation_id": "triage",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
run.run()
|
|
196
|
+
|
|
197
|
+
prompt = run.dispatcher.deliver.call_args.kwargs["prompt"]
|
|
198
|
+
assert '"author": "maintainer"' in prompt
|
|
199
|
+
assert "Keep compatibility with saved configurations." in prompt
|
|
200
|
+
assert '"id": 102' in prompt
|
|
201
|
+
assert "Old triage" not in prompt
|
|
202
|
+
assert "Delete prior comments listed as automated triage comments" in prompt
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_triage_ignores_managed_body_when_computing_delivery(tmp_path, monkeypatch):
|
|
206
|
+
original = "Human request\n\n### Desired Behavior\nKeep the API stable."
|
|
207
|
+
issue = {
|
|
208
|
+
"number": 7,
|
|
209
|
+
"title": "Improve behavior",
|
|
210
|
+
"body": original,
|
|
211
|
+
"labels": [{"name": "enhancement"}],
|
|
212
|
+
}
|
|
213
|
+
module, run = _triage(tmp_path, monkeypatch, [issue])
|
|
214
|
+
digest = module.hashlib.sha256(
|
|
215
|
+
module.json.dumps(
|
|
216
|
+
[module.TRIAGE_FORMAT_VERSION, "Improve behavior", original, []],
|
|
217
|
+
sort_keys=True,
|
|
218
|
+
).encode()
|
|
219
|
+
).hexdigest()
|
|
220
|
+
issue["body"] += (
|
|
221
|
+
"\n\n<!-- openhands-ai-triage:start -->\n"
|
|
222
|
+
"---\n## OpenHands AI triage\n"
|
|
223
|
+
"### Acceptance Criteria\n- [ ] Preserve behavior\n"
|
|
224
|
+
f"<!-- triage-source:{digest} -->\n"
|
|
225
|
+
"<!-- openhands-ai-triage:end -->"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
run.run()
|
|
229
|
+
|
|
230
|
+
run.dispatcher.deliver.assert_not_called()
|
|
231
|
+
assert module.author_body(issue["body"]) == original
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_triage_reprocesses_legacy_comment_digest(tmp_path, monkeypatch):
|
|
235
|
+
issue = {"number": 7, "title": "Improve behavior", "body": "Request", "labels": []}
|
|
236
|
+
module, run = _triage(tmp_path, monkeypatch, [issue])
|
|
237
|
+
legacy_digest = module.hashlib.sha256(
|
|
238
|
+
module.json.dumps(["Improve behavior", "Request", []], sort_keys=True).encode()
|
|
239
|
+
).hexdigest()
|
|
240
|
+
run.gh_pages = lambda path: [
|
|
241
|
+
{"id": 10, "body": f"<!-- triage-source:{legacy_digest} -->"}
|
|
242
|
+
]
|
|
243
|
+
run.dispatcher.deliver.return_value = {
|
|
244
|
+
"disposition": "created",
|
|
245
|
+
"conversation_id": "triage",
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
run.run()
|
|
249
|
+
|
|
250
|
+
run.dispatcher.deliver.assert_called_once()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_triage_passes_only_human_owned_body_to_agent(tmp_path, monkeypatch):
|
|
254
|
+
issue = {
|
|
255
|
+
"number": 8,
|
|
256
|
+
"title": "Clarify behavior",
|
|
257
|
+
"body": (
|
|
258
|
+
"Original description\n\n"
|
|
259
|
+
"<!-- openhands-ai-triage:start -->\n"
|
|
260
|
+
"Old generated criteria\n"
|
|
261
|
+
"<!-- triage-source:old -->\n"
|
|
262
|
+
"<!-- openhands-ai-triage:end -->"
|
|
263
|
+
),
|
|
264
|
+
"labels": [{"name": "enhancement"}],
|
|
265
|
+
}
|
|
266
|
+
_module, run = _triage(tmp_path, monkeypatch, [issue])
|
|
267
|
+
run.dispatcher.deliver.return_value = {
|
|
268
|
+
"disposition": "resumed",
|
|
269
|
+
"conversation_id": "triage",
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
run.run()
|
|
273
|
+
|
|
274
|
+
prompt = run.dispatcher.deliver.call_args.kwargs["prompt"]
|
|
275
|
+
assert '"author_body": "Original description"' in prompt
|
|
276
|
+
assert '"managed_triage_section_present": true' in prompt
|
|
277
|
+
assert "Old generated criteria" not in prompt
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_incomplete_marker_pair_remains_human_owned(tmp_path, monkeypatch):
|
|
281
|
+
module, _run = _triage(tmp_path, monkeypatch, [])
|
|
282
|
+
body = "Human text\n<!-- openhands-ai-triage:start -->\nUnclosed text"
|
|
283
|
+
|
|
284
|
+
assert module.author_body(body) == body
|