@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.
Files changed (60) hide show
  1. package/.agents/skills/custom-codereview-guide.md +84 -17
  2. package/.release-please-manifest.json +1 -1
  3. package/AGENTS.md +22 -1
  4. package/README.md +1 -1
  5. package/automations/bundle-index.js +12 -11
  6. package/automations/catalog/github-issue-to-pr/manifest.json +2 -2
  7. package/automations/catalog/github-issue-triage/manifest.json +2 -2
  8. package/automations/catalog/github-pr-reviewer/manifest.json +42 -5
  9. package/automations/catalog.schema.json +0 -4
  10. package/package.json +1 -1
  11. package/plugins/openhands/skills/openhands-sdk/SKILL.md +3 -1
  12. package/plugins/pr-review/README.md +2 -1
  13. package/plugins/pr-review/scripts/agent_script.py +198 -81
  14. package/plugins/pr-review/scripts/prompt.py +35 -11
  15. package/plugins/pr-review/skills/code-review/SKILL.md +165 -200
  16. package/plugins/pr-review/skills/code-review/commands/codereview-roasted.md +1 -1
  17. package/plugins/pr-review/skills/code-review/commands/codereview.md +1 -1
  18. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +15 -3
  19. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +4 -0
  20. package/pyproject.toml +1 -1
  21. package/python/openhands_extensions/_version.py +1 -1
  22. package/skills/code-review/SKILL.md +165 -200
  23. package/skills/code-review/commands/codereview-roasted.md +1 -1
  24. package/skills/code-review/commands/codereview.md +1 -1
  25. package/skills/github/scripts/agent_conversation.py +15 -3
  26. package/skills/github/scripts/github_client.py +4 -0
  27. package/skills/github-agents-md-maintainer/scripts/github_client.py +4 -0
  28. package/skills/github-delivery-watchdog/scripts/github_client.py +4 -0
  29. package/skills/github-issue-to-pr/scripts/agent_conversation.py +15 -3
  30. package/skills/github-issue-to-pr/scripts/github_client.py +4 -0
  31. package/skills/github-issue-triage/.claude-plugin/plugin.json +1 -1
  32. package/skills/github-issue-triage/.codex-plugin/plugin.json +1 -1
  33. package/skills/github-issue-triage/.plugin/plugin.json +1 -1
  34. package/skills/github-issue-triage/README.md +7 -0
  35. package/skills/github-issue-triage/SKILL.md +26 -3
  36. package/skills/github-issue-triage/scripts/agent_conversation.py +15 -3
  37. package/skills/github-issue-triage/scripts/github_client.py +4 -0
  38. package/skills/github-issue-triage/scripts/worker.py +142 -26
  39. package/skills/github-pr-reviewer/README.md +16 -10
  40. package/skills/github-pr-reviewer/SKILL.md +12 -9
  41. package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
  42. package/skills/github-pr-reviewer/scripts/agent_conversation.py +15 -3
  43. package/skills/github-pr-reviewer/scripts/github_client.py +4 -0
  44. package/skills/github-pr-reviewer/scripts/main.py +23 -5
  45. package/skills/github-pr-reviewer/scripts/maintainer_handoff.py +151 -0
  46. package/skills/github-pr-reviewer/scripts/worker.py +209 -21
  47. package/skills/github-pr-reviewer/tests/test_main.py +16 -0
  48. package/skills/github-repo-monitor/scripts/github_client.py +4 -0
  49. package/skills/index.js +6 -6
  50. package/skills/openhands-sdk/SKILL.md +3 -1
  51. package/tests/fixtures/automations/github-pr-reviewer.json +120 -26
  52. package/tests/github_automation_helpers.py +1 -0
  53. package/tests/test_agent_conversation_dispatch.py +35 -4
  54. package/tests/test_automation_setup.py +15 -0
  55. package/tests/test_code_review_risk_evaluation.py +2 -2
  56. package/tests/test_github_reviewer_delivery.py +320 -4
  57. package/tests/test_github_triage_delivery.py +200 -6
  58. package/tests/test_maintainer_handoff.py +198 -0
  59. package/tests/test_pr_review_prompt.py +6 -2
  60. 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
- # Unified review style
133
- assert "pragmatic" in content
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()