@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.
- package/.agents/skills/custom-codereview-guide.md +47 -5
- package/.claude-plugin/marketplace.json +20 -5
- package/.codex-plugin/marketplace.json +20 -5
- package/.github/workflows/build-vscode-app.yml +3 -3
- package/.github/workflows/check-extensions.yml +1 -1
- package/.github/workflows/pr-artifacts.yml +4 -83
- package/.github/workflows/tests.yml +14 -1
- package/.plugin/marketplace.json +20 -5
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +4 -3
- package/README.md +6 -5
- package/apps/vscode/README.md +8 -6
- package/apps/vscode/canvas-extension.json +11 -3
- package/apps/vscode/extension.js +60 -12
- package/apps/vscode/scripts/package.mjs +20 -74
- package/automations/bundle-index.js +10 -8
- package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
- package/automations/catalog/github-issue-triage/manifest.json +4 -3
- package/automations/catalog/github-pr-reviewer/manifest.json +7 -6
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
- package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
- package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
- package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
- package/integrations/catalog/atlassian-rovo.json +4 -3
- package/integrations/catalog/bitbucket.json +16 -1
- package/integrations/catalog/github.json +1 -0
- package/integrations/catalog/gitlab.json +1 -1
- package/marketplaces/openhands-extensions.json +20 -5
- package/package.json +1 -1
- package/plugins/pr-review/README.md +1 -0
- package/plugins/pr-review/scripts/prompt.py +44 -11
- package/plugins/runtime-api-configs/SKILL.md +1 -1
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/canvas-extension-api/references/backend-safety.md +3 -3
- package/skills/canvas-extension-api/references/v1-contract.md +7 -4
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
- package/skills/github/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-triage/README.md +73 -14
- package/skills/github-issue-triage/SKILL.md +26 -46
- package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
- package/skills/github-issue-triage/scripts/worker.py +153 -103
- package/skills/github-pr-reviewer/README.md +109 -1
- package/skills/github-pr-reviewer/SKILL.md +42 -8
- package/skills/github-pr-reviewer/references/state-schema.md +8 -0
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
- package/skills/github-pr-reviewer/scripts/main.py +163 -39
- package/skills/github-pr-reviewer/scripts/publication.py +193 -0
- package/skills/github-pr-reviewer/scripts/worker.py +297 -8
- package/skills/github-pr-reviewer/tests/test_main.py +73 -5
- package/skills/github-stale-ci-pr-closer/scripts/worker.py +8 -2
- package/skills/gitlab-issue-to-mr/README.md +10 -3
- package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
- package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
- package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
- package/skills/index.js +18 -8
- package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/README.md +18 -11
- package/skills/jira-issue-to-pr/SKILL.md +72 -23
- package/skills/jira-issue-to-pr/references/setup.md +43 -12
- package/skills/jira-issue-to-pr/scripts/main.py +136 -26
- package/skills/slack-channel-monitor/README.md +12 -3
- package/skills/slack-channel-monitor/SKILL.md +35 -20
- package/skills/slack-channel-monitor/references/windows.md +6 -2
- package/skills/slack-channel-monitor/scripts/main.py +189 -12
- package/skills/software-factory/.claude-plugin/plugin.json +19 -0
- package/skills/software-factory/.codex-plugin/plugin.json +19 -0
- package/skills/software-factory/.plugin/plugin.json +19 -0
- package/skills/software-factory/README.md +64 -0
- package/skills/software-factory/SKILL.md +229 -0
- package/skills/software-factory/commands/software-factory.md +8 -0
- package/skills/software-factory/scripts/factory.py +630 -0
- package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/SKILL.md +5 -3
- package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/test_agent_conversation_dispatch.py +173 -6
- package/tests/test_automation_llm_provenance.py +1209 -0
- package/tests/test_github_review_publication.py +422 -0
- package/tests/test_github_reviewer_delivery.py +174 -21
- package/tests/test_github_stale_ci_pr_closer.py +52 -1
- package/tests/test_github_triage_delivery.py +411 -238
- package/tests/test_gitlab_issue_to_mr.py +284 -0
- package/tests/test_jira_issue_to_pr.py +200 -6
- package/tests/test_pr_review_prompt_injection.py +353 -0
- package/tests/test_slack_channel_monitor.py +32 -1
- package/tests/test_software_factory_script.py +231 -0
- package/tests/test_vscode_app.py +8 -2
- package/tests/vscode-app.test.mjs +8 -2
|
@@ -1,284 +1,457 @@
|
|
|
1
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
run = object.__new__(
|
|
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.
|
|
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
|
-
|
|
18
|
-
run.gh_pages = lambda path: []
|
|
19
|
-
return module, run
|
|
37
|
+
return module, publication, run
|
|
20
38
|
|
|
21
39
|
|
|
22
|
-
def
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
digest = p.fingerprint(
|
|
238
|
+
transport.issue,
|
|
239
|
+
[],
|
|
240
|
+
"triage-bot",
|
|
241
|
+
policy=transport.config.get("triage_readiness_policies"),
|
|
155
242
|
)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
assert
|
|
162
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
301
|
+
t.comments = [
|
|
302
|
+
human,
|
|
175
303
|
{
|
|
176
|
-
"id":
|
|
177
|
-
"user": {"login": "
|
|
178
|
-
"
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
199
|
-
assert "
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
"
|
|
209
|
-
"
|
|
210
|
-
"body":
|
|
211
|
-
"
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
"
|
|
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.
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
"
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
275
|
-
assert
|
|
276
|
-
|
|
277
|
-
|
|
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"]
|