@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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Deterministic maintainer selection for positive automated reviews."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
from unittest.mock import Mock
|
|
7
|
+
from urllib.error import HTTPError
|
|
8
|
+
from urllib.parse import parse_qs, urlsplit
|
|
9
|
+
|
|
10
|
+
_MODULE_PATH = (
|
|
11
|
+
Path(__file__).resolve().parents[1]
|
|
12
|
+
/ "skills/github-pr-reviewer/scripts/maintainer_handoff.py"
|
|
13
|
+
)
|
|
14
|
+
_SPEC = importlib.util.spec_from_file_location("maintainer_handoff_test", _MODULE_PATH)
|
|
15
|
+
assert _SPEC and _SPEC.loader
|
|
16
|
+
maintainer_handoff = importlib.util.module_from_spec(_SPEC)
|
|
17
|
+
_SPEC.loader.exec_module(maintainer_handoff)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _pr(**overrides):
|
|
21
|
+
result = {
|
|
22
|
+
"number": 7,
|
|
23
|
+
"head": {"sha": "head"},
|
|
24
|
+
"base": {
|
|
25
|
+
"ref": "main",
|
|
26
|
+
"repo": {"owner": {"type": "Organization"}},
|
|
27
|
+
},
|
|
28
|
+
"user": {"login": "author"},
|
|
29
|
+
"requested_reviewers": [],
|
|
30
|
+
}
|
|
31
|
+
result.update(overrides)
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _repository(commits=None, reviews=None, loads=None):
|
|
36
|
+
commits = commits or {}
|
|
37
|
+
loads = loads or {}
|
|
38
|
+
|
|
39
|
+
def gh_pages(path):
|
|
40
|
+
if path.endswith("/files"):
|
|
41
|
+
return [{"filename": name} for name in commits]
|
|
42
|
+
if path.endswith("/reviews"):
|
|
43
|
+
return reviews or []
|
|
44
|
+
raise AssertionError(path)
|
|
45
|
+
|
|
46
|
+
def gh(method, path, body=None):
|
|
47
|
+
if path.startswith("/commits?"):
|
|
48
|
+
filename = parse_qs(urlsplit(path).query)["path"][0]
|
|
49
|
+
return [{"author": {"login": login}} for login in commits[filename]]
|
|
50
|
+
if method == "POST" and path.endswith("/requested_reviewers"):
|
|
51
|
+
return {}
|
|
52
|
+
raise AssertionError((method, path, body))
|
|
53
|
+
|
|
54
|
+
def api(method, path, params=None, body=None):
|
|
55
|
+
assert (method, path) == ("GET", "/search/issues")
|
|
56
|
+
login = params["q"].rsplit(":", 1)[1]
|
|
57
|
+
return {"total_count": loads.get(login, 0)}
|
|
58
|
+
|
|
59
|
+
return SimpleNamespace(
|
|
60
|
+
repository="OpenHands/OpenHands",
|
|
61
|
+
token="token",
|
|
62
|
+
gh_pages=gh_pages,
|
|
63
|
+
gh=Mock(side_effect=gh),
|
|
64
|
+
api=Mock(side_effect=api),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_code_proximity_precedes_review_load():
|
|
69
|
+
repo = _repository(
|
|
70
|
+
{"src/api.py": ["VascoSch92", "VascoSch92", "neubig"]},
|
|
71
|
+
loads={"VascoSch92": 8, "neubig": 0},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
selected = maintainer_handoff.request_maintainer_review(
|
|
75
|
+
repo, _pr(), ["neubig", "VascoSch92"]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
assert selected == "VascoSch92"
|
|
79
|
+
assert repo.gh.call_args_list[-1].args[:2] == (
|
|
80
|
+
"POST",
|
|
81
|
+
"/pulls/7/requested_reviewers",
|
|
82
|
+
)
|
|
83
|
+
assert repo.gh.call_args_list[-1].args[2] == {"reviewers": ["VascoSch92"]}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_review_load_breaks_equal_proximity():
|
|
87
|
+
repo = _repository({"src/api.py": []}, loads={"VascoSch92": 1, "neubig": 4})
|
|
88
|
+
|
|
89
|
+
selected = maintainer_handoff.request_maintainer_review(
|
|
90
|
+
repo, _pr(), ["neubig", "VascoSch92"]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
assert selected == "VascoSch92"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_review_load_uses_user_qualifier_for_user_owned_repo():
|
|
97
|
+
repo = _repository({"src/api.py": []}, loads={"VascoSch92": 1, "neubig": 4})
|
|
98
|
+
repo.repository = "neubig/project"
|
|
99
|
+
pr = _pr(base={"ref": "main", "repo": {"owner": {"type": "User"}}})
|
|
100
|
+
|
|
101
|
+
maintainer_handoff.request_maintainer_review(
|
|
102
|
+
repo, pr, ["neubig", "VascoSch92"]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
assert all(
|
|
106
|
+
"user:neubig" in call.kwargs["params"]["q"]
|
|
107
|
+
for call in repo.api.call_args_list
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_existing_request_is_idempotent():
|
|
112
|
+
repo = _repository()
|
|
113
|
+
pr = _pr(requested_reviewers=[{"login": "VascoSch92"}])
|
|
114
|
+
|
|
115
|
+
selected = maintainer_handoff.request_maintainer_review(
|
|
116
|
+
repo, pr, ["neubig", "VascoSch92"]
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
assert selected == "VascoSch92"
|
|
120
|
+
repo.api.assert_not_called()
|
|
121
|
+
repo.gh.assert_not_called()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_prior_head_approval_satisfies_handoff():
|
|
125
|
+
repo = _repository(
|
|
126
|
+
reviews=[
|
|
127
|
+
{
|
|
128
|
+
"commit_id": "prior-head",
|
|
129
|
+
"state": "APPROVED",
|
|
130
|
+
"user": {"login": "VascoSch92"},
|
|
131
|
+
}
|
|
132
|
+
]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
selected = maintainer_handoff.request_maintainer_review(
|
|
136
|
+
repo, _pr(), ["neubig", "VascoSch92"]
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
assert selected == "VascoSch92"
|
|
140
|
+
repo.api.assert_not_called()
|
|
141
|
+
assert not any(call.args[0] == "POST" for call in repo.gh.call_args_list)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_later_changes_request_supersedes_prior_approval():
|
|
145
|
+
repo = _repository(
|
|
146
|
+
{"src/api.py": []},
|
|
147
|
+
reviews=[
|
|
148
|
+
{
|
|
149
|
+
"commit_id": "prior-head",
|
|
150
|
+
"state": "APPROVED",
|
|
151
|
+
"user": {"login": "VascoSch92"},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"commit_id": "head",
|
|
155
|
+
"state": "CHANGES_REQUESTED",
|
|
156
|
+
"user": {"login": "VascoSch92"},
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
selected = maintainer_handoff.request_maintainer_review(
|
|
162
|
+
repo, _pr(), ["neubig", "VascoSch92"]
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
assert selected == "VascoSch92"
|
|
166
|
+
assert not any(call.args[0] == "POST" for call in repo.gh.call_args_list)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_author_is_excluded_and_empty_roster_is_compatible():
|
|
170
|
+
repo = _repository({"src/api.py": ["neubig"]})
|
|
171
|
+
|
|
172
|
+
selected = maintainer_handoff.request_maintainer_review(
|
|
173
|
+
repo,
|
|
174
|
+
_pr(user={"login": "neubig"}),
|
|
175
|
+
["neubig", "VascoSch92"],
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
assert selected == "VascoSch92"
|
|
179
|
+
assert maintainer_handoff.request_maintainer_review(repo, _pr(), "") is None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_unassignable_maintainer_is_a_configuration_error():
|
|
183
|
+
repo = _repository({"src/api.py": []})
|
|
184
|
+
original_gh = repo.gh.side_effect
|
|
185
|
+
|
|
186
|
+
def fail_assignment(method, path, body=None):
|
|
187
|
+
if method == "POST":
|
|
188
|
+
raise HTTPError("url", 422, "invalid reviewer", {}, None)
|
|
189
|
+
return original_gh(method, path, body)
|
|
190
|
+
|
|
191
|
+
repo.gh.side_effect = fail_assignment
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
maintainer_handoff.request_maintainer_review(repo, _pr(), ["neubig"])
|
|
195
|
+
except maintainer_handoff.HandoffConfigurationError as exc:
|
|
196
|
+
assert "@neubig" in str(exc)
|
|
197
|
+
else:
|
|
198
|
+
raise AssertionError("expected HandoffConfigurationError")
|
|
@@ -67,6 +67,8 @@ def test_prompt_instructs_reading_repo_guidance():
|
|
|
67
67
|
assert "AGENTS.md" in prompt
|
|
68
68
|
assert "MUST read" in prompt
|
|
69
69
|
assert "CONTRIBUTING.md" in prompt
|
|
70
|
+
assert "inspect how the affected feature or code path works" in prompt
|
|
71
|
+
assert "concrete failure on the current head" in prompt
|
|
70
72
|
|
|
71
73
|
|
|
72
74
|
def test_format_prompt_omits_evidence_requirements_by_default():
|
|
@@ -129,14 +131,16 @@ def test_file_reviewer_skill_content():
|
|
|
129
131
|
content = module.FILE_REVIEWER_SKILL
|
|
130
132
|
|
|
131
133
|
assert "file-level code reviewer" in content
|
|
132
|
-
#
|
|
133
|
-
assert "
|
|
134
|
+
# Concrete findings only, while retaining limited-impact defects.
|
|
135
|
+
assert "concrete failure" in content
|
|
134
136
|
# JSON schema documented
|
|
135
137
|
assert "path" in content
|
|
136
138
|
assert "line" in content
|
|
137
139
|
assert "severity" in content
|
|
138
140
|
assert "body" in content
|
|
139
141
|
assert "critical" in content
|
|
142
|
+
assert '`"minor"`' in content
|
|
143
|
+
assert '`"nit"`' not in content
|
|
140
144
|
# Tool access documented
|
|
141
145
|
assert "terminal" in content
|
|
142
146
|
assert "file_editor" in content
|
|
@@ -212,6 +212,150 @@ def test_format_thread_includes_rendered_suggestion_text_in_review_context():
|
|
|
212
212
|
assert "```suggestion" not in formatted
|
|
213
213
|
|
|
214
214
|
|
|
215
|
+
def test_get_linked_issues_uses_github_closing_issue_relation(monkeypatch):
|
|
216
|
+
module = _load_agent_script_module()
|
|
217
|
+
monkeypatch.setenv("REPO_NAME", "OpenHands/extensions")
|
|
218
|
+
calls = []
|
|
219
|
+
|
|
220
|
+
def fake_paginate(**kwargs):
|
|
221
|
+
calls.append(kwargs)
|
|
222
|
+
return [{"number": 12, "title": "criteria", "body": "- [ ] works"}]
|
|
223
|
+
|
|
224
|
+
monkeypatch.setattr(module, "_paginate_graphql", fake_paginate)
|
|
225
|
+
|
|
226
|
+
assert module.get_linked_issues("34") == [
|
|
227
|
+
{"number": 12, "title": "criteria", "body": "- [ ] works"}
|
|
228
|
+
]
|
|
229
|
+
assert calls[0]["query"] == module.LINKED_ISSUES_QUERY
|
|
230
|
+
assert calls[0]["variables"] == {
|
|
231
|
+
"owner": "OpenHands",
|
|
232
|
+
"repo": "extensions",
|
|
233
|
+
"pr_number": 34,
|
|
234
|
+
"count": 5,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_get_pr_issue_comments_fetches_latest_graphql_page(monkeypatch):
|
|
239
|
+
module = _load_agent_script_module()
|
|
240
|
+
monkeypatch.setenv("REPO_NAME", "OpenHands/extensions")
|
|
241
|
+
calls = []
|
|
242
|
+
|
|
243
|
+
def fake_paginate(**kwargs):
|
|
244
|
+
calls.append(kwargs)
|
|
245
|
+
return [
|
|
246
|
+
{
|
|
247
|
+
"id": "new",
|
|
248
|
+
"author": {"login": "last", "__typename": "User"},
|
|
249
|
+
"authorAssociation": "MEMBER",
|
|
250
|
+
"body": "latest concern",
|
|
251
|
+
"createdAt": "2026-01-02T00:00:00Z",
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
"id": "old",
|
|
255
|
+
"author": {"login": "first", "__typename": "User"},
|
|
256
|
+
"authorAssociation": "MEMBER",
|
|
257
|
+
"body": "older concern",
|
|
258
|
+
"createdAt": "2026-01-01T00:00:00Z",
|
|
259
|
+
},
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
monkeypatch.setattr(module, "_paginate_graphql", fake_paginate)
|
|
263
|
+
|
|
264
|
+
comments = module.get_pr_issue_comments("612", max_comments=25)
|
|
265
|
+
|
|
266
|
+
assert [comment["body"] for comment in comments] == [
|
|
267
|
+
"older concern",
|
|
268
|
+
"latest concern",
|
|
269
|
+
]
|
|
270
|
+
assert calls[0]["query"] == module.ISSUE_COMMENTS_QUERY
|
|
271
|
+
assert calls[0]["variables"]["count"] == 25
|
|
272
|
+
assert calls[0]["path_to_nodes"] == ["pullRequest", "comments"]
|
|
273
|
+
assert calls[0]["max_items"] == 25
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_review_context_prioritizes_issue_and_top_level_discussion():
|
|
277
|
+
module = _load_agent_script_module()
|
|
278
|
+
|
|
279
|
+
context = module.format_review_context(
|
|
280
|
+
reviews=[
|
|
281
|
+
{
|
|
282
|
+
"user": {"login": "all-hands-bot"},
|
|
283
|
+
"state": "APPROVED",
|
|
284
|
+
"body": "Looks good",
|
|
285
|
+
}
|
|
286
|
+
],
|
|
287
|
+
threads=[],
|
|
288
|
+
issue_comments=[
|
|
289
|
+
{
|
|
290
|
+
"user": {"login": "maintainer", "type": "User"},
|
|
291
|
+
"author_association": "MEMBER",
|
|
292
|
+
"body": "The credential must never be persisted in plaintext.",
|
|
293
|
+
}
|
|
294
|
+
],
|
|
295
|
+
linked_issues=[
|
|
296
|
+
{
|
|
297
|
+
"number": 42,
|
|
298
|
+
"title": "Protect stored credentials",
|
|
299
|
+
"body": "Acceptance: encrypt credentials outside the user home.",
|
|
300
|
+
}
|
|
301
|
+
],
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
assert "### Linked Issues and Acceptance Criteria" in context
|
|
305
|
+
assert "### Top-level PR Discussion" in context
|
|
306
|
+
assert "**maintainer** (MEMBER)" in context
|
|
307
|
+
assert "credential must never be persisted" in context
|
|
308
|
+
assert context.index("Linked Issues") < context.index("Previous Review Decisions")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_review_context_omits_bot_status_comments():
|
|
312
|
+
module = _load_agent_script_module()
|
|
313
|
+
|
|
314
|
+
context = module.format_review_context(
|
|
315
|
+
reviews=[],
|
|
316
|
+
threads=[],
|
|
317
|
+
issue_comments=[
|
|
318
|
+
{
|
|
319
|
+
"user": {"login": "github-actions", "type": "Bot"},
|
|
320
|
+
"body": "Coverage report",
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
"user": {"login": "all-hands-bot", "type": "User"},
|
|
324
|
+
"body": "OpenHands gave up on this review",
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
"user": {"login": "maintainer", "type": "User"},
|
|
328
|
+
"author_association": "MEMBER",
|
|
329
|
+
"body": "Check the cancellation path.",
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
assert "Coverage report" not in context
|
|
335
|
+
assert "OpenHands gave up" not in context
|
|
336
|
+
assert "Check the cancellation path." in context
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def test_review_context_keeps_latest_twelve_human_comments():
|
|
340
|
+
module = _load_agent_script_module()
|
|
341
|
+
comments = [
|
|
342
|
+
{
|
|
343
|
+
"user": {"login": "maintainer", "type": "User"},
|
|
344
|
+
"author_association": "MEMBER",
|
|
345
|
+
"body": f"Concern-{index:02d}",
|
|
346
|
+
"created_at": f"2026-01-{index + 1:02d}T00:00:00Z",
|
|
347
|
+
}
|
|
348
|
+
for index in range(13)
|
|
349
|
+
]
|
|
350
|
+
|
|
351
|
+
context = module.format_review_context([], [], issue_comments=comments)
|
|
352
|
+
|
|
353
|
+
assert "Concern-00" not in context
|
|
354
|
+
assert "Concern-01" in context
|
|
355
|
+
assert "Concern-12" in context
|
|
356
|
+
assert context.index("Concern-01") < context.index("Concern-12")
|
|
357
|
+
|
|
358
|
+
|
|
215
359
|
def test_register_sub_agents_completes_without_error():
|
|
216
360
|
"""Smoke test: _register_sub_agents() runs without raising."""
|
|
217
361
|
module = _load_agent_script_module()
|