@openhands/extensions 0.29.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.
Files changed (84) hide show
  1. package/.agents/skills/custom-codereview-guide.md +44 -1
  2. package/.claude-plugin/marketplace.json +7 -5
  3. package/.codex-plugin/marketplace.json +7 -5
  4. package/.github/workflows/build-vscode-app.yml +3 -3
  5. package/.github/workflows/check-extensions.yml +1 -1
  6. package/.github/workflows/tests.yml +1 -1
  7. package/.plugin/marketplace.json +7 -5
  8. package/.release-please-manifest.json +1 -1
  9. package/README.md +3 -3
  10. package/apps/vscode/README.md +8 -6
  11. package/apps/vscode/canvas-extension.json +11 -3
  12. package/apps/vscode/extension.js +60 -12
  13. package/apps/vscode/scripts/package.mjs +20 -74
  14. package/automations/bundle-index.js +9 -7
  15. package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
  16. package/automations/catalog/github-issue-triage/manifest.json +4 -3
  17. package/automations/catalog/github-pr-reviewer/manifest.json +5 -4
  18. package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
  19. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
  20. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
  21. package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
  22. package/integrations/catalog/atlassian-rovo.json +4 -3
  23. package/integrations/catalog/bitbucket.json +16 -1
  24. package/integrations/catalog/github.json +1 -0
  25. package/integrations/catalog/gitlab.json +1 -1
  26. package/marketplaces/openhands-extensions.json +7 -5
  27. package/package.json +1 -1
  28. package/plugins/pr-review/README.md +1 -0
  29. package/plugins/pr-review/scripts/prompt.py +44 -11
  30. package/plugins/runtime-api-configs/SKILL.md +1 -1
  31. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
  32. package/pyproject.toml +1 -1
  33. package/python/openhands_extensions/_version.py +1 -1
  34. package/skills/canvas-extension-api/references/backend-safety.md +3 -3
  35. package/skills/canvas-extension-api/references/v1-contract.md +7 -4
  36. package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
  37. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
  38. package/skills/github/scripts/agent_conversation.py +224 -8
  39. package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
  40. package/skills/github-issue-triage/README.md +73 -14
  41. package/skills/github-issue-triage/SKILL.md +26 -46
  42. package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
  43. package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
  44. package/skills/github-issue-triage/scripts/worker.py +153 -103
  45. package/skills/github-pr-reviewer/README.md +103 -1
  46. package/skills/github-pr-reviewer/SKILL.md +35 -8
  47. package/skills/github-pr-reviewer/references/state-schema.md +8 -0
  48. package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
  49. package/skills/github-pr-reviewer/scripts/main.py +163 -39
  50. package/skills/github-pr-reviewer/scripts/publication.py +193 -0
  51. package/skills/github-pr-reviewer/scripts/worker.py +297 -8
  52. package/skills/github-pr-reviewer/tests/test_main.py +73 -5
  53. package/skills/gitlab-issue-to-mr/README.md +10 -3
  54. package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
  55. package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
  56. package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
  57. package/skills/index.js +8 -8
  58. package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
  59. package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
  60. package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
  61. package/skills/jira-issue-to-pr/README.md +18 -11
  62. package/skills/jira-issue-to-pr/SKILL.md +72 -23
  63. package/skills/jira-issue-to-pr/references/setup.md +43 -12
  64. package/skills/jira-issue-to-pr/scripts/main.py +136 -26
  65. package/skills/slack-channel-monitor/README.md +6 -1
  66. package/skills/slack-channel-monitor/SKILL.md +6 -3
  67. package/skills/slack-channel-monitor/scripts/main.py +36 -4
  68. package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
  69. package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
  70. package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
  71. package/skills/ticket-to-code-change/SKILL.md +5 -3
  72. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
  73. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  74. package/tests/test_agent_conversation_dispatch.py +173 -6
  75. package/tests/test_automation_llm_provenance.py +1209 -0
  76. package/tests/test_github_review_publication.py +422 -0
  77. package/tests/test_github_reviewer_delivery.py +174 -21
  78. package/tests/test_github_triage_delivery.py +411 -238
  79. package/tests/test_gitlab_issue_to_mr.py +284 -0
  80. package/tests/test_jira_issue_to_pr.py +200 -6
  81. package/tests/test_pr_review_prompt_injection.py +353 -0
  82. package/tests/test_slack_channel_monitor.py +32 -1
  83. package/tests/test_vscode_app.py +8 -2
  84. 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()