@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.
Files changed (90) hide show
  1. package/.agents/skills/custom-codereview-guide.md +86 -17
  2. package/.claude-plugin/marketplace.json +14 -0
  3. package/.codex-plugin/marketplace.json +14 -0
  4. package/.github/workflows/check-extensions.yml +6 -6
  5. package/.github/workflows/deprecation-check.yml +1 -1
  6. package/.github/workflows/issue-readiness-check.yml +37 -106
  7. package/.github/workflows/npm-publish.yml +2 -2
  8. package/.github/workflows/pr-description-check.yml +1 -1
  9. package/.github/workflows/pypi-publish.yml +3 -3
  10. package/.github/workflows/tests.yml +2 -2
  11. package/.plugin/marketplace.json +14 -0
  12. package/.release-please-manifest.json +1 -1
  13. package/AGENTS.md +32 -2
  14. package/README.md +4 -3
  15. package/automations/bundle-index.js +12 -11
  16. package/automations/catalog/github-issue-triage/manifest.json +2 -2
  17. package/automations/catalog/github-pr-reviewer/manifest.json +42 -5
  18. package/automations/catalog.schema.json +0 -4
  19. package/integrations/catalog/granola.json +38 -0
  20. package/integrations/catalog-index.js +16 -14
  21. package/marketplaces/openhands-extensions.json +14 -0
  22. package/package.json +1 -1
  23. package/plugins/openhands/skills/openhands-sdk/SKILL.md +3 -1
  24. package/plugins/pr-review/README.md +2 -1
  25. package/plugins/pr-review/scripts/agent_script.py +198 -81
  26. package/plugins/pr-review/scripts/prompt.py +35 -11
  27. package/plugins/pr-review/skills/code-review/SKILL.md +165 -200
  28. package/plugins/pr-review/skills/code-review/commands/codereview-roasted.md +1 -1
  29. package/plugins/pr-review/skills/code-review/commands/codereview.md +1 -1
  30. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +8 -3
  31. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +4 -0
  32. package/pyproject.toml +2 -2
  33. package/python/openhands_extensions/_version.py +1 -1
  34. package/skills/add-skill/SKILL.md +5 -2
  35. package/skills/canvas-extension-api/.claude-plugin/plugin.json +19 -0
  36. package/skills/canvas-extension-api/.codex-plugin/plugin.json +19 -0
  37. package/skills/canvas-extension-api/.plugin/plugin.json +19 -0
  38. package/skills/canvas-extension-api/README.md +33 -0
  39. package/skills/canvas-extension-api/SKILL.md +254 -0
  40. package/skills/canvas-extension-api/references/acceptance-checklist.md +20 -0
  41. package/skills/canvas-extension-api/references/backend-safety.md +9 -0
  42. package/skills/canvas-extension-api/references/connections.md +126 -0
  43. package/skills/canvas-extension-api/references/packaging-recipes.md +43 -0
  44. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -0
  45. package/skills/canvas-extension-api/references/testing-and-installation.md +286 -0
  46. package/skills/canvas-extension-api/references/v1-contract.md +250 -0
  47. package/skills/canvas-extension-api/scripts/validate-extension.mjs +92 -0
  48. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +60 -0
  49. package/skills/code-review/SKILL.md +165 -200
  50. package/skills/code-review/commands/codereview-roasted.md +1 -1
  51. package/skills/code-review/commands/codereview.md +1 -1
  52. package/skills/github/scripts/agent_conversation.py +8 -3
  53. package/skills/github/scripts/github_client.py +4 -0
  54. package/skills/github-agents-md-maintainer/scripts/github_client.py +4 -0
  55. package/skills/github-delivery-watchdog/scripts/github_client.py +4 -0
  56. package/skills/github-issue-to-pr/scripts/agent_conversation.py +8 -3
  57. package/skills/github-issue-to-pr/scripts/github_client.py +4 -0
  58. package/skills/github-issue-triage/.claude-plugin/plugin.json +1 -1
  59. package/skills/github-issue-triage/.codex-plugin/plugin.json +1 -1
  60. package/skills/github-issue-triage/.plugin/plugin.json +1 -1
  61. package/skills/github-issue-triage/README.md +7 -0
  62. package/skills/github-issue-triage/SKILL.md +26 -3
  63. package/skills/github-issue-triage/scripts/agent_conversation.py +8 -3
  64. package/skills/github-issue-triage/scripts/github_client.py +4 -0
  65. package/skills/github-issue-triage/scripts/worker.py +142 -26
  66. package/skills/github-pr-reviewer/README.md +21 -10
  67. package/skills/github-pr-reviewer/SKILL.md +36 -10
  68. package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
  69. package/skills/github-pr-reviewer/scripts/agent_conversation.py +8 -3
  70. package/skills/github-pr-reviewer/scripts/github_client.py +4 -0
  71. package/skills/github-pr-reviewer/scripts/main.py +63 -16
  72. package/skills/github-pr-reviewer/scripts/maintainer_handoff.py +151 -0
  73. package/skills/github-pr-reviewer/scripts/worker.py +218 -21
  74. package/skills/github-pr-reviewer/tests/test_main.py +85 -0
  75. package/skills/github-repo-monitor/scripts/github_client.py +4 -0
  76. package/skills/index.js +16 -8
  77. package/skills/openhands-sdk/SKILL.md +3 -1
  78. package/tests/fixtures/automations/github-pr-reviewer.json +120 -26
  79. package/tests/github_automation_helpers.py +1 -0
  80. package/tests/test_agent_conversation_dispatch.py +19 -4
  81. package/tests/test_automation_setup.py +15 -0
  82. package/tests/test_code_review_risk_evaluation.py +2 -2
  83. package/tests/test_github_reviewer_delivery.py +448 -5
  84. package/tests/test_github_triage_delivery.py +200 -6
  85. package/tests/test_maintainer_handoff.py +198 -0
  86. package/tests/test_pr_review_prompt.py +6 -2
  87. package/tests/test_pr_review_review_context.py +144 -0
  88. package/.github/scripts/check_issue_readiness.py +0 -272
  89. package/.github/scripts/post-readiness-comment.mjs +0 -184
  90. package/tests/test_check_issue_readiness.py +0 -135
@@ -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 test_triage_skips_ready_blocked_and_unchanged_issues(tmp_path, monkeypatch):
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
- marker = module.hashlib.sha256(
82
- module.json.dumps(["Unchanged", "", []], sort_keys=True).encode()
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: [{"id": 10, "body": f"<!-- triage-source:{marker} -->"}]
85
- submit = Mock()
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.assert_not_called()
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
@@ -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()