@openhands/extensions 0.29.0 → 0.31.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 (97) hide show
  1. package/.agents/skills/custom-codereview-guide.md +47 -5
  2. package/.claude-plugin/marketplace.json +20 -5
  3. package/.codex-plugin/marketplace.json +20 -5
  4. package/.github/workflows/build-vscode-app.yml +3 -3
  5. package/.github/workflows/check-extensions.yml +1 -1
  6. package/.github/workflows/pr-artifacts.yml +4 -83
  7. package/.github/workflows/tests.yml +14 -1
  8. package/.plugin/marketplace.json +20 -5
  9. package/.release-please-manifest.json +1 -1
  10. package/AGENTS.md +4 -3
  11. package/README.md +6 -5
  12. package/apps/vscode/README.md +8 -6
  13. package/apps/vscode/canvas-extension.json +11 -3
  14. package/apps/vscode/extension.js +60 -12
  15. package/apps/vscode/scripts/package.mjs +20 -74
  16. package/automations/bundle-index.js +10 -8
  17. package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
  18. package/automations/catalog/github-issue-triage/manifest.json +4 -3
  19. package/automations/catalog/github-pr-reviewer/manifest.json +7 -6
  20. package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
  21. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
  22. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
  23. package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
  24. package/integrations/catalog/atlassian-rovo.json +4 -3
  25. package/integrations/catalog/bitbucket.json +16 -1
  26. package/integrations/catalog/github.json +1 -0
  27. package/integrations/catalog/gitlab.json +1 -1
  28. package/marketplaces/openhands-extensions.json +20 -5
  29. package/package.json +1 -1
  30. package/plugins/pr-review/README.md +1 -0
  31. package/plugins/pr-review/scripts/prompt.py +44 -11
  32. package/plugins/runtime-api-configs/SKILL.md +1 -1
  33. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
  34. package/pyproject.toml +1 -1
  35. package/python/openhands_extensions/_version.py +1 -1
  36. package/skills/canvas-extension-api/references/backend-safety.md +3 -3
  37. package/skills/canvas-extension-api/references/v1-contract.md +7 -4
  38. package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
  39. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
  40. package/skills/github/scripts/agent_conversation.py +224 -8
  41. package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
  42. package/skills/github-issue-triage/README.md +73 -14
  43. package/skills/github-issue-triage/SKILL.md +26 -46
  44. package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
  45. package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
  46. package/skills/github-issue-triage/scripts/worker.py +153 -103
  47. package/skills/github-pr-reviewer/README.md +109 -1
  48. package/skills/github-pr-reviewer/SKILL.md +42 -8
  49. package/skills/github-pr-reviewer/references/state-schema.md +8 -0
  50. package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
  51. package/skills/github-pr-reviewer/scripts/main.py +163 -39
  52. package/skills/github-pr-reviewer/scripts/publication.py +193 -0
  53. package/skills/github-pr-reviewer/scripts/worker.py +297 -8
  54. package/skills/github-pr-reviewer/tests/test_main.py +73 -5
  55. package/skills/github-stale-ci-pr-closer/scripts/worker.py +8 -2
  56. package/skills/gitlab-issue-to-mr/README.md +10 -3
  57. package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
  58. package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
  59. package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
  60. package/skills/index.js +18 -8
  61. package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
  62. package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
  63. package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
  64. package/skills/jira-issue-to-pr/README.md +18 -11
  65. package/skills/jira-issue-to-pr/SKILL.md +72 -23
  66. package/skills/jira-issue-to-pr/references/setup.md +43 -12
  67. package/skills/jira-issue-to-pr/scripts/main.py +136 -26
  68. package/skills/slack-channel-monitor/README.md +12 -3
  69. package/skills/slack-channel-monitor/SKILL.md +35 -20
  70. package/skills/slack-channel-monitor/references/windows.md +6 -2
  71. package/skills/slack-channel-monitor/scripts/main.py +189 -12
  72. package/skills/software-factory/.claude-plugin/plugin.json +19 -0
  73. package/skills/software-factory/.codex-plugin/plugin.json +19 -0
  74. package/skills/software-factory/.plugin/plugin.json +19 -0
  75. package/skills/software-factory/README.md +64 -0
  76. package/skills/software-factory/SKILL.md +229 -0
  77. package/skills/software-factory/commands/software-factory.md +8 -0
  78. package/skills/software-factory/scripts/factory.py +630 -0
  79. package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
  80. package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
  81. package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
  82. package/skills/ticket-to-code-change/SKILL.md +5 -3
  83. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
  84. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  85. package/tests/test_agent_conversation_dispatch.py +173 -6
  86. package/tests/test_automation_llm_provenance.py +1209 -0
  87. package/tests/test_github_review_publication.py +422 -0
  88. package/tests/test_github_reviewer_delivery.py +174 -21
  89. package/tests/test_github_stale_ci_pr_closer.py +52 -1
  90. package/tests/test_github_triage_delivery.py +411 -238
  91. package/tests/test_gitlab_issue_to_mr.py +284 -0
  92. package/tests/test_jira_issue_to_pr.py +200 -6
  93. package/tests/test_pr_review_prompt_injection.py +353 -0
  94. package/tests/test_slack_channel_monitor.py +32 -1
  95. package/tests/test_software_factory_script.py +231 -0
  96. package/tests/test_vscode_app.py +8 -2
  97. package/tests/vscode-app.test.mjs +8 -2
@@ -1,284 +1,457 @@
1
- """Contract tests for independent triage delivery."""
1
+ """Exercise shipped triage parsing, rendering, guards and delivery contracts."""
2
2
 
3
+ import copy
4
+ import importlib
5
+ import json
6
+ import sys
7
+ from pathlib import Path
3
8
  from unittest.mock import Mock
9
+ from urllib.parse import unquote
4
10
 
11
+ import pytest
5
12
  from github_automation_helpers import worker
6
13
 
14
+ SCRIPTS = Path(__file__).resolve().parents[1] / "skills/github-issue-triage/scripts"
7
15
 
8
- def _triage(tmp_path, monkeypatch, issues):
16
+
17
+ @pytest.fixture
18
+ def triage(tmp_path, monkeypatch):
19
+ # Until the parent updates the bundle manifest, load the same source helper.
20
+ monkeypatch.syspath_prepend(str(SCRIPTS))
21
+ monkeypatch.delitem(sys.modules, "triage_publication", raising=False)
9
22
  module = worker("github-issue-triage", tmp_path, monkeypatch)
10
- cls = module.IssueTriage
11
- run = object.__new__(cls)
23
+ publication = importlib.import_module("triage_publication")
24
+ run = object.__new__(module.IssueTriage)
12
25
  run.repository = "owner/repo"
26
+ run.config = {}
13
27
  run.token_name = "GITHUB_PERSONAL_ACCESS_TOKEN"
14
- run.dispatcher = Mock()
15
- run.open_issues = lambda: issues
28
+ run.dispatcher = Mock() # Conversation service transport boundary.
29
+ run.dispatcher.deliver.return_value = {
30
+ "disposition": "created",
31
+ "conversation_id": "test",
32
+ }
33
+ run.api = lambda *args: {"login": "triage-bot"}
34
+ run.gh = lambda *args: {"id": 9876}
35
+ run.gh_pages = lambda *args: []
16
36
  run.dependencies_complete = lambda issue: True
17
- run.gh = lambda method, path, body=None: {"id": 9876}
18
- run.gh_pages = lambda path: []
19
- return module, run
37
+ return module, publication, run
20
38
 
21
39
 
22
- def test_triage_submits_each_changed_issue_as_agent_work(tmp_path, monkeypatch):
23
- issues = [
24
- {"number": 2, "title": "Second", "body": "B", "labels": []},
25
- {"number": 1, "title": "First", "body": "A", "labels": []},
26
- ]
27
- _module, run = _triage(tmp_path, monkeypatch, issues)
28
- submit = Mock(
29
- side_effect=lambda **kwargs: {
30
- "disposition": "created",
31
- "conversation_id": kwargs["subject"],
32
- }
33
- )
34
- run.dispatcher.deliver = submit
40
+ def issue():
41
+ return {
42
+ "number": 7,
43
+ "title": "Improve behavior",
44
+ "body": "Human request",
45
+ "state": "open",
46
+ "labels": [],
47
+ }
35
48
 
36
- run.run()
37
49
 
38
- assert [call.kwargs["subject"] for call in submit.call_args_list] == [
39
- "9876:issue:1",
40
- "9876:issue:2",
41
- ]
42
- assert "Do not implement code" in submit.call_args_list[0].kwargs["prompt"]
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
67
-
68
-
69
- def test_triage_continues_after_one_submission_fails(tmp_path, monkeypatch):
70
- issues = [
71
- {"number": 1, "title": "First", "body": "A", "labels": []},
72
- {"number": 2, "title": "Second", "body": "B", "labels": []},
73
- ]
74
- _module, run = _triage(tmp_path, monkeypatch, issues)
75
- submit = Mock(
76
- side_effect=[
77
- RuntimeError("unavailable"),
78
- {"disposition": "created", "conversation_id": "second"},
79
- ]
80
- )
81
- run.dispatcher.deliver = submit
50
+ def ready():
51
+ return {
52
+ "decision": "ready",
53
+ "priority": "low",
54
+ "scope": "Preserve API",
55
+ "non_goals": "No new backend",
56
+ "criteria": ["Saved settings still load"],
57
+ "questions": [],
58
+ }
82
59
 
83
- run.run()
84
60
 
85
- assert [call.kwargs["subject"] for call in submit.call_args_list] == [
86
- "9876:issue:1",
87
- "9876:issue:2",
88
- ]
61
+ def decision():
62
+ return {
63
+ **ready(),
64
+ "decision": "decision_needed",
65
+ "criteria": [],
66
+ "questions": ["Which retention limit?"],
67
+ }
89
68
 
90
69
 
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": []},
70
+ def test_delivery_and_prompt(triage):
71
+ _, _, run = triage
72
+ run.open_issues = lambda: [{**issue(), "number": 2}, {**issue(), "number": 1}]
73
+ run.run()
74
+ calls = run.dispatcher.deliver.call_args_list
75
+ assert [c.kwargs["subject"] for c in calls] == ["9876:issue:1", "9876:issue:2"]
76
+ prompt = calls[0].kwargs["prompt"]
77
+ for text in (
78
+ "do not implement code",
79
+ "custom-codereview-guide.md",
80
+ "affected version",
81
+ "software-agent-sdk",
82
+ "current decisions override stale",
83
+ "ask only unanswered questions",
84
+ "Issue readiness is separate",
85
+ "Legacy direct mode",
86
+ "Direct publication",
87
+ ):
88
+ assert text in prompt
89
+
90
+
91
+ def test_event_scope_and_failure_isolation(triage, monkeypatch):
92
+ _, _, run = triage
93
+ run.open_issues = lambda: [{**issue(), "number": 1}, {**issue(), "number": 2}]
94
+ run.dispatcher.deliver.side_effect = [
95
+ RuntimeError("offline"),
96
+ {"disposition": "created", "conversation_id": "2"},
95
97
  ]
96
- _module, run = _triage(tmp_path, monkeypatch, issues)
98
+ run.run()
99
+ assert run.dispatcher.deliver.call_count == 2
100
+ run.dispatcher.deliver.reset_mock(side_effect=True)
97
101
  monkeypatch.setenv(
98
102
  "AUTOMATION_EVENT_PAYLOAD",
99
- '{"event":{"payload":{"repository":{"full_name":"owner/repo"},'
100
- '"issue":{"number":2}}}}',
103
+ '{"event":{"payload":{"repository":{"full_name":"owner/repo"},"issue":{"number":2}}}}',
101
104
  )
102
- run.dispatcher.deliver.return_value = {
103
- "disposition": "created",
104
- "conversation_id": "second",
105
- }
106
-
107
105
  run.run()
108
-
109
106
  assert run.dispatcher.deliver.call_args.kwargs["subject"] == "9876:issue:2"
110
107
 
111
108
 
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
-
109
+ def test_self_owned_output_no_loop_human_marker_not_ignored(triage):
110
+ _, p, run = triage
111
+ i = issue()
112
+ digest = p.fingerprint(i, [], "triage-bot")
113
+ i["body"], _ = p.render(i["body"], ready(), digest)
114
+ run.open_issues = lambda: [i]
121
115
  run.run()
122
-
123
116
  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
- ):
129
- issues = [
130
- {
131
- "number": 1,
132
- "title": "Ready",
133
- "body": "",
134
- "labels": [{"name": "ready-for-dev"}],
135
- },
136
- {"number": 2, "title": "Blocked", "body": "", "labels": []},
137
- {"number": 3, "title": "Unchanged", "body": "", "labels": []},
138
- ]
139
- module, run = _triage(tmp_path, monkeypatch, issues)
140
- run.dependencies_complete = lambda issue: issue["number"] != 2
141
- unchanged_marker = module.hashlib.sha256(
142
- module.json.dumps(
143
- [module.TRIAGE_FORMAT_VERSION, "Unchanged", "", []], sort_keys=True
144
- ).encode()
145
- ).hexdigest()
146
- run.gh_pages = lambda path: (
147
- [
148
- {
149
- "id": 10,
150
- "body": f"<!-- triage-source:{unchanged_marker} -->",
117
+ human = {
118
+ "id": 8,
119
+ "user": {"login": "human"},
120
+ "body": f"No, revise scope. <!-- triage-source:{digest} -->",
121
+ }
122
+ run.gh_pages = lambda path: [human]
123
+ run.run()
124
+ assert "No, revise scope" in run.dispatcher.deliver.call_args.kwargs["prompt"]
125
+
126
+
127
+ @pytest.mark.parametrize(
128
+ "body",
129
+ [
130
+ "<!-- openhands-ai-triage:start -->",
131
+ "<!-- openhands-ai-triage:end -->",
132
+ "<!-- openhands-ai-triage:end --><!-- openhands-ai-triage:start -->",
133
+ "<!-- openhands-ai-triage:start --><!-- openhands-ai-triage:start --><!-- openhands-ai-triage:end -->",
134
+ ],
135
+ )
136
+ def test_ambiguous_markers_fail_closed(triage, body):
137
+ _, p, _ = triage
138
+ with pytest.raises(ValueError):
139
+ p.render(body, ready(), "a" * 64)
140
+
141
+
142
+ def test_render_preserves_outside_bytes_and_is_idempotent(triage):
143
+ _, p, _ = triage
144
+ before, after = "Human \n\n", "\n\nHuman conclusion \n"
145
+ original = before + p.START + "stale" + p.END + after
146
+ body, comment = p.render(original, ready(), "a" * 64)
147
+ assert body.startswith(before) and body.endswith(after) and comment is None
148
+ assert p.render(body, ready(), "a" * 64)[0] == body
149
+ removed, comment = p.render(body, decision(), "a" * 64)
150
+ assert removed == before + after
151
+ assert "Acceptance Criteria" not in comment
152
+ assert "on behalf of" in comment
153
+
154
+
155
+ @pytest.mark.parametrize(
156
+ "changes",
157
+ [
158
+ {"decision": "other"},
159
+ {"criteria": []},
160
+ {"questions": ["already answered"]},
161
+ {"scope": ""},
162
+ {"non_goals": "<!-- spoof -->"},
163
+ {"criteria": [3]},
164
+ {"extra": True},
165
+ ],
166
+ )
167
+ def test_parser_rejects_invalid_recommendations(triage, changes):
168
+ _, p, _ = triage
169
+ with pytest.raises(ValueError):
170
+ p.parse_recommendation({**ready(), **changes})
171
+
172
+
173
+ class GitHubTransport:
174
+ """In-memory HTTP boundary; all triage algorithms above are real."""
175
+
176
+ repository = "owner/repo"
177
+
178
+ def __init__(self):
179
+ self.config = {}
180
+ self.issue = issue()
181
+ self.comments = []
182
+ self.writes = []
183
+ self.permission = "write"
184
+ self.sha = "a" * 40
185
+ self.reads = 0
186
+ self.change_on_read = None
187
+
188
+ def api(self, *args):
189
+ return {"login": "triage-bot"}
190
+
191
+ def gh_pages(self, path):
192
+ return copy.deepcopy(self.comments) if path.endswith("/comments") else []
193
+
194
+ def gh(self, method, path, body=None):
195
+ if method == "GET":
196
+ if path.startswith("/contents/"):
197
+ return {"sha": self.sha}
198
+ if path.endswith("/permission"):
199
+ return {"permission": self.permission}
200
+ self.reads += 1
201
+ if self.reads == self.change_on_read:
202
+ self.issue["body"] += " Human changed scope"
203
+ return copy.deepcopy(self.issue)
204
+ self.writes.append((method, path, body))
205
+ if path == "/issues/7" and method == "PATCH":
206
+ self.issue.update(body)
207
+ elif path == "/issues/7/labels":
208
+ self.issue["labels"] += [{"name": name} for name in body["labels"]]
209
+ elif "/labels/" in path and method == "DELETE":
210
+ self.issue["labels"] = [
211
+ l
212
+ for l in self.issue["labels"]
213
+ if l["name"] != unquote(path.split("/labels/")[1])
214
+ ]
215
+ elif path == "/issues/7/comments":
216
+ self.comments.append({"id": 100, "user": {"login": "triage-bot"}, **body})
217
+ elif path.startswith("/issues/comments/"):
218
+ number = int(path.rsplit("/", 1)[1])
219
+ if method == "DELETE":
220
+ self.comments = [c for c in self.comments if c["id"] != number]
221
+ else:
222
+ next(c for c in self.comments if c["id"] == number).update(body)
223
+ return {}
224
+
225
+
226
+ def test_publish_ready_policy_and_noop(triage):
227
+ _, p, _ = triage
228
+ transport = GitHubTransport()
229
+ transport.config = {
230
+ "triage_readiness_policies": {
231
+ "owner/repo": {
232
+ "mode": "authorized-writers",
233
+ "files": {".github/workflows/issue-readiness-check.yml": transport.sha},
151
234
  }
152
- ]
153
- if path == "/issues/3/comments"
154
- else []
235
+ }
236
+ }
237
+ digest = p.fingerprint(
238
+ transport.issue,
239
+ [],
240
+ "triage-bot",
241
+ policy=transport.config.get("triage_readiness_policies"),
155
242
  )
156
- submit = Mock(return_value={"disposition": "created", "conversation_id": "ready"})
157
- run.dispatcher.deliver = submit
158
-
159
- run.run()
160
-
161
- assert [call.kwargs["subject"] for call in submit.call_args_list] == [
162
- "9876:issue:1"
163
- ]
243
+ assert p.publish(transport, 7, digest, ready())["readiness_policy_allowed"]
244
+ assert {l["name"] for l in transport.issue["labels"]} == {
245
+ "ready-for-dev",
246
+ "priority:low",
247
+ }
248
+ assert p.publish(transport, 7, digest, ready())["writes"] == 0
249
+
250
+
251
+ @pytest.mark.parametrize("mode", ["unknown", "policy_changed", "unauthorized"])
252
+ def test_readiness_fail_closed_without_media_rules(triage, mode):
253
+ _, p, _ = triage
254
+ t = GitHubTransport()
255
+ t.issue["labels"] = [{"name": "ready-for-dev"}, {"name": "human-label"}]
256
+ if mode != "unknown":
257
+ t.config = {
258
+ "triage_readiness_policies": {
259
+ t.repository: {
260
+ "mode": "authorized-writers",
261
+ "files": {"policy.yml": "a" * 40},
262
+ }
263
+ }
264
+ }
265
+ if mode == "policy_changed":
266
+ t.sha = "b" * 40
267
+ if mode == "unauthorized":
268
+ t.permission = "read"
269
+ digest = p.fingerprint(
270
+ t.issue, [], "triage-bot", policy=t.config.get("triage_readiness_policies")
271
+ )
272
+ assert not p.publish(t, 7, digest, ready())["readiness_policy_allowed"]
273
+ assert {l["name"] for l in t.issue["labels"]} == {
274
+ "human-label",
275
+ "priority:low",
276
+ "ready-for-dev",
277
+ }
164
278
 
165
279
 
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"}],
280
+ @pytest.mark.parametrize("when", [1, 2])
281
+ def test_reject_changed_inputs_before_any_write(triage, when):
282
+ _, p, _ = triage
283
+ t = GitHubTransport()
284
+ digest = p.fingerprint(
285
+ t.issue, [], "triage-bot", policy=t.config.get("triage_readiness_policies")
286
+ )
287
+ t.change_on_read = when
288
+ with pytest.raises(ValueError, match="changed"):
289
+ p.publish(t, 7, digest, ready())
290
+ assert not t.writes
291
+
292
+
293
+ def test_decision_updates_only_owned_comments_and_noop(triage):
294
+ _, p, _ = triage
295
+ t = GitHubTransport()
296
+ human = {
297
+ "id": 1,
298
+ "user": {"login": "human"},
299
+ "body": "My decision <!-- triage-source:" + "b" * 64 + " -->",
172
300
  }
173
- _module, run = _triage(tmp_path, monkeypatch, [issue])
174
- run.gh_pages = lambda path: [
301
+ t.comments = [
302
+ human,
175
303
  {
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 -->",
304
+ "id": 2,
305
+ "user": {"login": "triage-bot"},
306
+ "body": "Old <!-- triage-source:" + "a" * 64 + " -->",
188
307
  },
189
308
  ]
190
- run.dispatcher.deliver.return_value = {
191
- "disposition": "resumed",
192
- "conversation_id": "triage",
309
+ digest = p.fingerprint(t.issue, t.comments, "triage-bot")
310
+ p.publish(t, 7, digest, decision())
311
+ assert t.comments[0] == human
312
+ assert len(t.comments) == 2 and "Decision needed" in t.comments[1]["body"]
313
+ assert p.publish(t, 7, digest, decision())["writes"] == 0
314
+
315
+
316
+ def test_human_label_transitions_but_not_self_churn(triage):
317
+ _, p, run = triage
318
+ event = {
319
+ "id": 1,
320
+ "event": "unlabeled",
321
+ "label": {"name": "ready-for-dev"},
322
+ "actor": {"login": "human"},
193
323
  }
194
-
324
+ run.gh_pages = lambda path: [event]
325
+ assert p.readiness_transitions(run, 7, "triage-bot")
326
+ event["actor"]["login"] = "triage-bot"
327
+ assert not p.readiness_transitions(run, 7, "triage-bot")
328
+ assert p.fingerprint(
329
+ issue(), [], "triage-bot", policy={"version": 1}
330
+ ) != p.fingerprint(issue(), [], "triage-bot", policy={"version": 2})
331
+
332
+
333
+ def test_opt_in_configuration_fails_before_dispatch(triage, tmp_path):
334
+ _, _, run = triage
335
+ run.config = {"triage_publisher_path": "/missing"}
336
+ with pytest.raises(ValueError):
337
+ run.run()
338
+ run.dispatcher.deliver.assert_not_called()
339
+ publisher = tmp_path / "triage_publication.py"
340
+ publisher.write_text("# trusted helper")
341
+ config = tmp_path / "config.json"
342
+ config.write_text('{"repos": ["owner/repo"]}')
343
+ run.config = {
344
+ "triage_publisher_path": str(publisher),
345
+ "triage_publisher_config_path": str(config),
346
+ }
347
+ with pytest.raises(ValueError, match="remote unsupported"):
348
+ run.run()
349
+ run.config["triage_publisher_workspace"] = "shared-host"
350
+ run.open_issues = lambda: [issue()]
195
351
  run.run()
196
-
197
352
  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
353
+ assert str(publisher) in prompt
354
+ assert "Direct publication (legacy" not in prompt
203
355
 
204
356
 
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"}],
357
+ def test_checker_self_updates_do_not_change_human_fingerprint(triage):
358
+ _, p, _ = triage
359
+ c = {
360
+ "id": 9,
361
+ "user": {"login": "github-actions[bot]"},
362
+ "body": "<!-- issue-readiness-check --> Old rule",
363
+ "updated_at": "one",
212
364
  }
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",
365
+ digest = p.fingerprint(issue(), [c], "triage-bot")
366
+ c["updated_at"] = "two"
367
+ c["body"] += " more output"
368
+ assert p.fingerprint(issue(), [c], "triage-bot") == digest
369
+ c["user"]["login"] = "human"
370
+ assert p.fingerprint(issue(), [c], "triage-bot") != digest
371
+
372
+
373
+ def test_closed_issue_and_mid_publication_change_abort(triage):
374
+ _, p, _ = triage
375
+ t = GitHubTransport()
376
+ digest = p.fingerprint(t.issue, [], "triage-bot")
377
+ t.issue["state"] = "closed"
378
+ with pytest.raises(ValueError, match="eligible"):
379
+ p.publish(t, 7, digest, ready())
380
+ assert not t.writes
381
+ t.issue["state"] = "open"
382
+ t.reads = 0
383
+ t.change_on_read = 3
384
+ with pytest.raises(ValueError, match="changed"):
385
+ p.publish(t, 7, digest, ready())
386
+ assert len(t.writes) == 1
387
+ assert "Human changed scope" in t.issue["body"]
388
+
389
+
390
+ def test_partial_write_has_no_receipt_and_retry_finishes(triage):
391
+ _, p, run = triage
392
+ t = GitHubTransport()
393
+ digest = p.fingerprint(t.issue, [], "triage-bot")
394
+ original = t.gh
395
+
396
+ def fail_labels(method, path, body=None):
397
+ if method == "POST" and path.endswith("/labels"):
398
+ raise RuntimeError("transport interrupted")
399
+ return original(method, path, body)
400
+
401
+ t.gh = fail_labels
402
+ with pytest.raises(RuntimeError):
403
+ p.publish(t, 7, digest, ready())
404
+ assert "triage-source:" in t.issue["body"]
405
+ assert "triage-complete:" not in t.issue["body"]
406
+ run.config = {
407
+ "triage_publisher_path": "/trusted/publisher.py",
408
+ "triage_publisher_config_path": "/trusted/config.json",
246
409
  }
247
-
248
- run.run()
249
-
410
+ run.gh_pages = t.gh_pages
411
+ run._submit(9876, t.issue, [t.issue])
250
412
  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",
413
+ t.gh = original
414
+ p.publish(t, 7, digest, ready())
415
+ assert "triage-complete:" in t.issue["body"]
416
+ assert p.publish(t, 7, digest, ready())["writes"] == 0
417
+
418
+
419
+ def test_concurrent_labels_abort_before_write(triage):
420
+ _, p, _ = triage
421
+ t = GitHubTransport()
422
+ digest = p.fingerprint(t.issue, [], "triage-bot")
423
+ original = t.gh
424
+
425
+ def changed(method, path, body=None):
426
+ if method == "GET" and path == "/issues/7" and t.reads == 1:
427
+ t.issue["labels"].append({"name": "ready-for-dev"})
428
+ return original(method, path, body)
429
+
430
+ t.gh = changed
431
+ with pytest.raises(ValueError, match="changed"):
432
+ p.publish(t, 7, digest, ready())
433
+ assert not t.writes
434
+
435
+
436
+ def test_publisher_policy_is_scanner_fingerprint_source(triage, tmp_path):
437
+ _, p, run = triage
438
+ publisher = tmp_path / "publisher.py"
439
+ publisher.write_text("# trusted helper")
440
+ config = tmp_path / "publisher.json"
441
+ transport = GitHubTransport()
442
+ policy = {"owner/repo": {"mode": "authorized-writers", "files": {"policy.yml": transport.sha}}}
443
+ transport.config = {"repos": [run.repository], "triage_readiness_policies": policy}
444
+ config.write_text(json.dumps(transport.config))
445
+ run.config = {
446
+ "triage_publisher_path": str(publisher),
447
+ "triage_publisher_config_path": str(config),
448
+ "triage_publisher_workspace": "shared-host",
270
449
  }
271
-
450
+ run.open_issues = lambda: [issue()]
272
451
  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
452
+ digest = p.fingerprint(transport.issue, [], "triage-bot", policy=policy)
453
+ assert digest in run.dispatcher.deliver.call_args.kwargs["prompt"]
454
+ assert p.publish(transport, 7, digest, ready())["readiness_policy_allowed"]
455
+ config.write_text(json.dumps({"repos": [run.repository]}))
456
+ run.run()
457
+ assert p.fingerprint(issue(), [], "triage-bot") in run.dispatcher.deliver.call_args.kwargs["prompt"]