@openhands/extensions 0.28.0 → 0.30.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 +44 -1
- package/.claude-plugin/marketplace.json +7 -5
- package/.codex-plugin/marketplace.json +7 -5
- package/.github/workflows/build-vscode-app.yml +3 -3
- package/.github/workflows/check-extensions.yml +1 -1
- package/.github/workflows/tests.yml +1 -1
- package/.plugin/marketplace.json +7 -5
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +7 -2
- package/README.md +3 -3
- 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 +9 -7
- 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 +5 -4
- 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 +7 -5
- package/package.json +1 -1
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +76 -8
- 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/SKILL.md +4 -1
- package/skills/canvas-extension-api/references/backend-safety.md +3 -3
- package/skills/canvas-extension-api/references/native-design.md +250 -0
- 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 +103 -1
- package/skills/github-pr-reviewer/SKILL.md +35 -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/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 +10 -10
- 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/openhands-automation/references/custom-automation.md +76 -8
- package/skills/slack-channel-monitor/README.md +6 -1
- package/skills/slack-channel-monitor/SKILL.md +6 -3
- package/skills/slack-channel-monitor/scripts/main.py +36 -4
- 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_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_vscode_app.py +8 -2
- package/tests/vscode-app.test.mjs +8 -2
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Real publisher/lease paths; only external KV, GitHub and SDK are simulated."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from agent_conversation import ReviewCoordination
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
15
|
+
SPEC = importlib.util.spec_from_file_location("review_publication", ROOT / "skills/github-pr-reviewer/scripts/publication.py")
|
|
16
|
+
publication = importlib.util.module_from_spec(SPEC)
|
|
17
|
+
SPEC.loader.exec_module(publication)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def artifact(**changes):
|
|
21
|
+
return json.dumps({"head_sha": "abc", "code_assessment": "clean", "merge_readiness": "ready",
|
|
22
|
+
"summary": "No material defects found.", "findings": [], "evidence": ["pytest: 10 passed"], **changes})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.parametrize("assessment,ready,findings,event,footer", [
|
|
26
|
+
("clean", "ready", [], "APPROVE", "✅ APPROVED"),
|
|
27
|
+
("clean", "blocked", [], "COMMENT", "⏸ INCONCLUSIVE"),
|
|
28
|
+
("material_findings", "blocked", ["file.py:2 crashes"], "COMMENT", "🔄 CHANGES REQUESTED"),
|
|
29
|
+
("inconclusive", "unknown", [], "COMMENT", "⏸ INCONCLUSIVE"),
|
|
30
|
+
])
|
|
31
|
+
def test_derived_outcome(assessment, ready, findings, event, footer):
|
|
32
|
+
payload = publication.review_payload(artifact(code_assessment=assessment, merge_readiness=ready, findings=findings), "abc", False, "marker")
|
|
33
|
+
assert payload["event"] == event
|
|
34
|
+
assert payload["body"].endswith(footer)
|
|
35
|
+
assert publication.review_payload(artifact(), "abc", True, "marker")["event"] == "COMMENT"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.parametrize("changes", [
|
|
39
|
+
{"findings": ["bug"]}, {"code_assessment": "material_findings"},
|
|
40
|
+
{"code_assessment": "inconclusive"}, {"evidence": []}, {"head_sha": "stale"},
|
|
41
|
+
{"event": "APPROVE"}, {"summary": "✅ APPROVED"}, {"findings": "not a list"},
|
|
42
|
+
])
|
|
43
|
+
def test_reject_contradictions(changes):
|
|
44
|
+
with pytest.raises(ValueError):
|
|
45
|
+
publication.review_payload(artifact(**changes), "abc", False, "marker")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture
|
|
49
|
+
def kv(monkeypatch):
|
|
50
|
+
# HTTP boundary implements the documented namespace-global CAS contract.
|
|
51
|
+
state, lock = {"version": 0, "values": {}}, threading.Lock()
|
|
52
|
+
class Handler(BaseHTTPRequestHandler):
|
|
53
|
+
def log_message(self, *args):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
def handle_request(self):
|
|
57
|
+
from urllib.parse import parse_qs, urlsplit
|
|
58
|
+
query = parse_qs(urlsplit(self.path).query)
|
|
59
|
+
key = urlsplit(self.path).path.rsplit("/", 1)[-1]
|
|
60
|
+
assert query["automation_id"] == ["11111111-1111-1111-1111-111111111111"]
|
|
61
|
+
assert self.headers["X-Session-API-Key"] == "local-test-only"
|
|
62
|
+
with lock:
|
|
63
|
+
value = state["values"].get(key)
|
|
64
|
+
code = 200
|
|
65
|
+
if self.command == "PUT":
|
|
66
|
+
incoming = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
|
67
|
+
if ("nx" in query and value is not None) or ("if_version" in query and int(query["if_version"][0]) != state["version"]):
|
|
68
|
+
code = 409
|
|
69
|
+
else:
|
|
70
|
+
state["values"][key] = value = incoming
|
|
71
|
+
state["version"] += 1
|
|
72
|
+
elif value is None:
|
|
73
|
+
code = 404
|
|
74
|
+
body = json.dumps({"value": value, "version": state["version"]}).encode()
|
|
75
|
+
self.send_response(code)
|
|
76
|
+
self.end_headers()
|
|
77
|
+
self.wfile.write(body)
|
|
78
|
+
|
|
79
|
+
do_GET = do_PUT = handle_request
|
|
80
|
+
|
|
81
|
+
class Server(ThreadingHTTPServer):
|
|
82
|
+
# Eight claimants connect together; the default backlog is only five.
|
|
83
|
+
request_queue_size = 16
|
|
84
|
+
|
|
85
|
+
server = Server(("127.0.0.1", 0), Handler)
|
|
86
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
87
|
+
thread.start()
|
|
88
|
+
monkeypatch.setenv("AUTOMATION_API_URL", f"http://127.0.0.1:{server.server_port}")
|
|
89
|
+
monkeypatch.setenv("REVIEW_COORDINATION_API_KEY", "local-test-only")
|
|
90
|
+
monkeypatch.setenv("REVIEW_COORDINATION_AUTOMATION_ID", "11111111-1111-1111-1111-111111111111")
|
|
91
|
+
yield state
|
|
92
|
+
server.shutdown()
|
|
93
|
+
thread.join()
|
|
94
|
+
server.server_close()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_atomic_claim_and_fencing(kv):
|
|
98
|
+
store = ReviewCoordination()
|
|
99
|
+
barrier = threading.Barrier(8)
|
|
100
|
+
leases = []
|
|
101
|
+
def claim():
|
|
102
|
+
barrier.wait()
|
|
103
|
+
leases.append(store.claim("common-subject"))
|
|
104
|
+
threads = [threading.Thread(target=claim) for _ in range(8)]
|
|
105
|
+
for thread in threads:
|
|
106
|
+
thread.start()
|
|
107
|
+
for thread in threads:
|
|
108
|
+
thread.join()
|
|
109
|
+
assert len(leases) == len(threads)
|
|
110
|
+
winners = [lease for lease in leases if lease]
|
|
111
|
+
assert len(winners) == 1
|
|
112
|
+
old = winners[0]
|
|
113
|
+
old.release()
|
|
114
|
+
new = store.claim("common-subject")
|
|
115
|
+
with pytest.raises(RuntimeError, match="lease lost"):
|
|
116
|
+
old.save(status="posting")
|
|
117
|
+
new.release()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.fixture
|
|
121
|
+
def runner(kv):
|
|
122
|
+
pr = {"number": 3, "head": {"sha": "abc"}, "user": {"login": "human"}, "state": "open"}
|
|
123
|
+
reviews, starts = [], []
|
|
124
|
+
run = SimpleNamespace(repository="Owner/Repo", github_login="bot", token_name="WRITE_TOKEN",
|
|
125
|
+
config={"review_profile_read_only": True, "review_read_token_secret": "READ_TOKEN"})
|
|
126
|
+
run.dispatcher = SimpleNamespace(start_review_work=lambda *args: starts.append(args), review_snapshot=lambda cid: ("finished", artifact()),
|
|
127
|
+
llm_provenance=lambda cid: None)
|
|
128
|
+
def gh(method, path, body=None):
|
|
129
|
+
if method == "GET":
|
|
130
|
+
return pr
|
|
131
|
+
review = {**body, "id": len(reviews) + 1, "state": "APPROVED" if body["event"] == "APPROVE" else "COMMENTED", "user": {"login": "bot"}}
|
|
132
|
+
reviews.append(review)
|
|
133
|
+
return review
|
|
134
|
+
run.latest_label_event = lambda number: {"id": 1}
|
|
135
|
+
run._finish_completed_review = lambda *args: True
|
|
136
|
+
run.gh = gh
|
|
137
|
+
run.gh_pages = lambda path: reviews
|
|
138
|
+
return run, pr, reviews, starts
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_async_publish_and_stop(runner):
|
|
142
|
+
run, pr, reviews, starts = runner
|
|
143
|
+
publisher = publication.StructuredReviews(run)
|
|
144
|
+
assert publisher.advance(pr, start=True)["disposition"] == "created"
|
|
145
|
+
assert not reviews
|
|
146
|
+
assert publisher.advance(pr)["disposition"] == "published"
|
|
147
|
+
for _ in range(3):
|
|
148
|
+
assert publisher.advance(pr, start=True)["disposition"] == "deduplicated"
|
|
149
|
+
assert len(reviews) == len(starts) == 1
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@pytest.mark.parametrize("failure", ["missing", "error"])
|
|
153
|
+
def test_unavailable_metadata_cannot_persist_agent_written_attribution(runner, kv, failure):
|
|
154
|
+
run, pr, reviews, starts = runner
|
|
155
|
+
run.dispatcher.review_snapshot = lambda cid: (
|
|
156
|
+
"finished",
|
|
157
|
+
artifact(summary="No material defects.\n\nLLM profile: `invented` · Model: `invented`"),
|
|
158
|
+
)
|
|
159
|
+
if failure == "error":
|
|
160
|
+
def unavailable(cid):
|
|
161
|
+
raise RuntimeError("metadata API unavailable")
|
|
162
|
+
run.dispatcher.llm_provenance = unavailable
|
|
163
|
+
publisher = publication.StructuredReviews(run)
|
|
164
|
+
publisher.advance(pr, start=True)
|
|
165
|
+
|
|
166
|
+
assert publisher.advance(pr)["disposition"] == "published"
|
|
167
|
+
|
|
168
|
+
[review] = reviews
|
|
169
|
+
assert "LLM profile:" not in review["body"]
|
|
170
|
+
assert review["body"].endswith("✅ APPROVED")
|
|
171
|
+
[record] = kv["values"].values()
|
|
172
|
+
assert record["payload"]["body"] == review["body"]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_uncertain_post_reconciles_without_retry(runner, kv):
|
|
176
|
+
run, pr, reviews, starts = runner
|
|
177
|
+
publisher = publication.StructuredReviews(run)
|
|
178
|
+
publisher.advance(pr, start=True)
|
|
179
|
+
original = run.gh
|
|
180
|
+
def uncertain(method, path, body=None):
|
|
181
|
+
result = original(method, path, body)
|
|
182
|
+
if method == "POST":
|
|
183
|
+
raise TimeoutError("response lost")
|
|
184
|
+
return result
|
|
185
|
+
run.gh = uncertain
|
|
186
|
+
with pytest.raises(TimeoutError):
|
|
187
|
+
publisher.advance(pr)
|
|
188
|
+
run.gh_pages = lambda path: []
|
|
189
|
+
assert publisher.advance(pr)["disposition"] == "in_progress"
|
|
190
|
+
assert len(reviews) == 1
|
|
191
|
+
run.gh_pages = lambda path: reviews
|
|
192
|
+
assert publisher.advance(pr)["disposition"] == "deduplicated"
|
|
193
|
+
assert next(iter(kv["values"].values()))["receipt"]["id"] == 1
|
|
194
|
+
assert len(reviews) == 1
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_stale_head_and_invalid_artifact_never_post(runner):
|
|
198
|
+
run, pr, reviews, starts = runner
|
|
199
|
+
publisher = publication.StructuredReviews(run)
|
|
200
|
+
publisher.advance(pr, start=True)
|
|
201
|
+
pr["head"]["sha"] = "new"
|
|
202
|
+
publisher.advance(pr)
|
|
203
|
+
assert not reviews
|
|
204
|
+
publisher.advance(pr, start=True)
|
|
205
|
+
with pytest.raises(ValueError, match="head"):
|
|
206
|
+
publisher.advance(pr)
|
|
207
|
+
assert not reviews
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_active_work_blocks_new_generation(runner):
|
|
211
|
+
run, pr, reviews, starts = runner
|
|
212
|
+
publisher = publication.StructuredReviews(run)
|
|
213
|
+
publisher.advance(pr, start=True)
|
|
214
|
+
run.dispatcher.review_snapshot = lambda cid: ("running", "")
|
|
215
|
+
assert publisher.advance(pr, "request:2", start=True)["disposition"] == "in_progress"
|
|
216
|
+
assert len(starts) == 1
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_generation_replay_does_not_republish(runner):
|
|
220
|
+
run, pr, reviews, starts = runner
|
|
221
|
+
publisher = publication.StructuredReviews(run)
|
|
222
|
+
for generation in ("request:1", "request:2"):
|
|
223
|
+
publisher.advance(pr, generation, start=True)
|
|
224
|
+
publisher.advance(pr)
|
|
225
|
+
assert publisher.advance(pr, "request:1", start=True)["disposition"] == "deduplicated"
|
|
226
|
+
assert len(reviews) == len(starts) == 2
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_untracked_scan_does_not_create_state(runner, kv):
|
|
230
|
+
run, pr, _, _ = runner
|
|
231
|
+
publication.StructuredReviews(run).advance(pr)
|
|
232
|
+
assert not kv["values"]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@pytest.mark.parametrize("event", [None, {"repository": {"full_name": "elsewhere/repo"}}])
|
|
236
|
+
def test_worker_reconciles_before_event_or_scan_selection(runner, tmp_path, monkeypatch, event):
|
|
237
|
+
from github_automation_helpers import worker
|
|
238
|
+
|
|
239
|
+
run, pr, reviews, starts = runner
|
|
240
|
+
publisher = publication.StructuredReviews(run)
|
|
241
|
+
publisher.advance(pr, start=True)
|
|
242
|
+
module = worker("github-pr-reviewer", tmp_path, monkeypatch)
|
|
243
|
+
actual = object.__new__(module.PullRequestReviewer)
|
|
244
|
+
actual.config = {}
|
|
245
|
+
actual.repository = run.repository
|
|
246
|
+
actual.github_login = run.github_login
|
|
247
|
+
actual.structured_reviews = publisher
|
|
248
|
+
actual.gh = lambda method, path: {"id": 1}
|
|
249
|
+
actual.gh_pages = lambda path: [pr] if path == "/pulls?state=open" else []
|
|
250
|
+
actual._event_payload = lambda: event
|
|
251
|
+
actual.run()
|
|
252
|
+
assert len(reviews) == 1
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_final_response_transport(monkeypatch):
|
|
256
|
+
from agent_conversation import AgentConversationDispatcher
|
|
257
|
+
calls = []
|
|
258
|
+
class Handler(BaseHTTPRequestHandler):
|
|
259
|
+
def log_message(self, *args):
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
def do_GET(self):
|
|
263
|
+
calls.append(self.path)
|
|
264
|
+
body = {"response": artifact()} if self.path.endswith("/agent_final_response") else {"execution_status": "finished"}
|
|
265
|
+
self.send_response(200)
|
|
266
|
+
self.end_headers()
|
|
267
|
+
self.wfile.write(json.dumps(body).encode())
|
|
268
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
269
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
270
|
+
thread.start()
|
|
271
|
+
try:
|
|
272
|
+
dispatcher = object.__new__(AgentConversationDispatcher)
|
|
273
|
+
dispatcher._cloud = None
|
|
274
|
+
dispatcher.agent_url = f"http://127.0.0.1:{server.server_port}"
|
|
275
|
+
dispatcher.api_key = "test-only"
|
|
276
|
+
assert dispatcher.review_snapshot("id") == ("finished", artifact())
|
|
277
|
+
assert calls == ["/api/conversations/id", "/api/conversations/id/agent_final_response"]
|
|
278
|
+
finally:
|
|
279
|
+
server.shutdown()
|
|
280
|
+
thread.join()
|
|
281
|
+
server.server_close()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_explicit_draft_is_publishable(runner):
|
|
285
|
+
run, pr, reviews, starts = runner
|
|
286
|
+
pr["draft"] = True
|
|
287
|
+
publisher = publication.StructuredReviews(run)
|
|
288
|
+
publisher.advance(pr, start=True, explicit=True)
|
|
289
|
+
assert publisher.advance(pr)["disposition"] == "published"
|
|
290
|
+
assert len(reviews) == 1
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_crash_after_acceptance_before_receipt(runner, monkeypatch):
|
|
294
|
+
from agent_conversation import ReviewLease
|
|
295
|
+
run, pr, reviews, starts = runner
|
|
296
|
+
publisher = publication.StructuredReviews(run)
|
|
297
|
+
publisher.advance(pr, start=True)
|
|
298
|
+
save = ReviewLease.save
|
|
299
|
+
def crash(self, **changes):
|
|
300
|
+
if changes.get("status") == "published":
|
|
301
|
+
raise RuntimeError("receipt persistence crashed")
|
|
302
|
+
return save(self, **changes)
|
|
303
|
+
with monkeypatch.context() as patch:
|
|
304
|
+
patch.setattr(ReviewLease, "save", crash)
|
|
305
|
+
with pytest.raises(RuntimeError, match="persistence"):
|
|
306
|
+
publisher.advance(pr)
|
|
307
|
+
assert publisher.advance(pr)["disposition"] == "deduplicated"
|
|
308
|
+
assert len(reviews) == 1
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@pytest.mark.parametrize("raw", ["```json\n{}\n```", "not json", "{}", "[]"])
|
|
312
|
+
def test_invalid_final_output_quarantined(runner, raw):
|
|
313
|
+
run, pr, reviews, starts = runner
|
|
314
|
+
publisher = publication.StructuredReviews(run)
|
|
315
|
+
publisher.advance(pr, start=True)
|
|
316
|
+
run.dispatcher.review_snapshot = lambda cid: ("finished", raw)
|
|
317
|
+
with pytest.raises(ValueError):
|
|
318
|
+
publisher.advance(pr)
|
|
319
|
+
assert publisher.advance(pr, start=True)["disposition"] == "deduplicated"
|
|
320
|
+
assert not reviews
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_new_request_is_not_consumed_by_old_result(runner):
|
|
324
|
+
run, pr, reviews, starts = runner
|
|
325
|
+
publisher = publication.StructuredReviews(run)
|
|
326
|
+
publisher.advance(pr, "request:1", start=True)
|
|
327
|
+
assert publisher.advance(pr, "request:2", start=True)["disposition"] == "published"
|
|
328
|
+
assert publisher.advance(pr, "request:2", start=True)["disposition"] == "created"
|
|
329
|
+
assert len(starts) == 2
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_completion_uses_claimed_trigger_not_new_request(runner):
|
|
333
|
+
run, pr, reviews, starts = runner
|
|
334
|
+
completions = []
|
|
335
|
+
run._finish_completed_review = lambda *args: completions.append(args)
|
|
336
|
+
publisher = publication.StructuredReviews(run)
|
|
337
|
+
publisher.advance(pr, "request:1", start=True, trigger={"id": 1}, label="review")
|
|
338
|
+
publisher.advance(pr, "request:2", start=True, trigger={"id": 2})
|
|
339
|
+
assert completions[0][1:] == ({"id": 1}, "review")
|
|
340
|
+
publisher.advance(pr)
|
|
341
|
+
assert len(completions) == 1
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_structured_publication_persists_the_claimed_conversation_provenance(
|
|
345
|
+
runner, kv, tmp_path, monkeypatch
|
|
346
|
+
):
|
|
347
|
+
from unittest.mock import Mock
|
|
348
|
+
from github_automation_helpers import worker
|
|
349
|
+
|
|
350
|
+
worker("github-pr-reviewer", tmp_path, monkeypatch)
|
|
351
|
+
run, pr, reviews, starts = runner
|
|
352
|
+
run.dispatcher.llm_provenance = Mock(return_value=("review-profile", "openai/review-model"))
|
|
353
|
+
publisher = publication.StructuredReviews(run)
|
|
354
|
+
started = publisher.advance(pr, start=True)
|
|
355
|
+
|
|
356
|
+
assert publisher.advance(pr)["disposition"] == "published"
|
|
357
|
+
|
|
358
|
+
run.dispatcher.llm_provenance.assert_called_once_with(started["conversation_id"])
|
|
359
|
+
[review] = reviews
|
|
360
|
+
[record] = kv["values"].values()
|
|
361
|
+
assert review["body"].endswith(
|
|
362
|
+
"✅ APPROVED\n\nLLM profile: `review-profile` · Model: `openai/review-model`"
|
|
363
|
+
)
|
|
364
|
+
assert record["payload"]["body"] == review["body"]
|
|
365
|
+
assert record["conversation_id"] == started["conversation_id"]
|
|
366
|
+
assert record["completion"] == "done"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_structured_receipt_recovery_keeps_provenance_after_profile_edit(
|
|
370
|
+
runner, kv, tmp_path, monkeypatch
|
|
371
|
+
):
|
|
372
|
+
from unittest.mock import Mock
|
|
373
|
+
from github_automation_helpers import worker
|
|
374
|
+
|
|
375
|
+
worker("github-pr-reviewer", tmp_path, monkeypatch)
|
|
376
|
+
run, pr, reviews, starts = runner
|
|
377
|
+
run.dispatcher.llm_provenance = Mock(return_value=("original-profile", "original-model"))
|
|
378
|
+
publisher = publication.StructuredReviews(run)
|
|
379
|
+
started = publisher.advance(pr, start=True)
|
|
380
|
+
original = run.gh
|
|
381
|
+
|
|
382
|
+
def uncertain(method, path, body=None):
|
|
383
|
+
result = original(method, path, body)
|
|
384
|
+
if method == "POST":
|
|
385
|
+
raise TimeoutError("receipt lost")
|
|
386
|
+
return result
|
|
387
|
+
|
|
388
|
+
run.gh = uncertain
|
|
389
|
+
with pytest.raises(TimeoutError):
|
|
390
|
+
publisher.advance(pr)
|
|
391
|
+
run.dispatcher.llm_provenance.return_value = ("edited-profile", "edited-model")
|
|
392
|
+
|
|
393
|
+
assert publisher.advance(pr)["disposition"] == "deduplicated"
|
|
394
|
+
|
|
395
|
+
run.dispatcher.llm_provenance.assert_called_once_with(started["conversation_id"])
|
|
396
|
+
[review] = reviews
|
|
397
|
+
[record] = kv["values"].values()
|
|
398
|
+
assert review["body"].endswith("LLM profile: `original-profile` · Model: `original-model`")
|
|
399
|
+
assert record["payload"]["body"] == review["body"]
|
|
400
|
+
assert record["receipt"]["id"] == review["id"]
|
|
401
|
+
assert record["completion"] == "done"
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@pytest.mark.parametrize("unavailable", [None, RuntimeError("metadata unavailable")])
|
|
405
|
+
def test_structured_provenance_failure_preserves_publication_and_completion(
|
|
406
|
+
runner, unavailable
|
|
407
|
+
):
|
|
408
|
+
from unittest.mock import Mock
|
|
409
|
+
|
|
410
|
+
run, pr, reviews, starts = runner
|
|
411
|
+
run.dispatcher.llm_provenance = Mock(return_value=None)
|
|
412
|
+
if unavailable is not None:
|
|
413
|
+
run.dispatcher.llm_provenance.side_effect = unavailable
|
|
414
|
+
run._finish_completed_review = Mock(return_value=True)
|
|
415
|
+
publisher = publication.StructuredReviews(run)
|
|
416
|
+
publisher.advance(pr, start=True)
|
|
417
|
+
|
|
418
|
+
assert publisher.advance(pr)["disposition"] == "published"
|
|
419
|
+
|
|
420
|
+
assert len(reviews) == 1
|
|
421
|
+
assert reviews[0]["body"].endswith("✅ APPROVED")
|
|
422
|
+
run._finish_completed_review.assert_called_once()
|