@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
@@ -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 "software-factory/review" in prompt
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 test_reviewer_continues_after_one_submission_fails(tmp_path, monkeypatch):
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
- run.run()
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 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