@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.
Files changed (89) 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/AGENTS.md +7 -2
  10. package/README.md +3 -3
  11. package/apps/vscode/README.md +8 -6
  12. package/apps/vscode/canvas-extension.json +11 -3
  13. package/apps/vscode/extension.js +60 -12
  14. package/apps/vscode/scripts/package.mjs +20 -74
  15. package/automations/bundle-index.js +9 -7
  16. package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
  17. package/automations/catalog/github-issue-triage/manifest.json +4 -3
  18. package/automations/catalog/github-pr-reviewer/manifest.json +5 -4
  19. package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
  20. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
  21. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
  22. package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
  23. package/integrations/catalog/atlassian-rovo.json +4 -3
  24. package/integrations/catalog/bitbucket.json +16 -1
  25. package/integrations/catalog/github.json +1 -0
  26. package/integrations/catalog/gitlab.json +1 -1
  27. package/marketplaces/openhands-extensions.json +7 -5
  28. package/package.json +1 -1
  29. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +76 -8
  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/SKILL.md +4 -1
  37. package/skills/canvas-extension-api/references/backend-safety.md +3 -3
  38. package/skills/canvas-extension-api/references/native-design.md +250 -0
  39. package/skills/canvas-extension-api/references/v1-contract.md +7 -4
  40. package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
  41. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
  42. package/skills/github/scripts/agent_conversation.py +224 -8
  43. package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
  44. package/skills/github-issue-triage/README.md +73 -14
  45. package/skills/github-issue-triage/SKILL.md +26 -46
  46. package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
  47. package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
  48. package/skills/github-issue-triage/scripts/worker.py +153 -103
  49. package/skills/github-pr-reviewer/README.md +103 -1
  50. package/skills/github-pr-reviewer/SKILL.md +35 -8
  51. package/skills/github-pr-reviewer/references/state-schema.md +8 -0
  52. package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
  53. package/skills/github-pr-reviewer/scripts/main.py +163 -39
  54. package/skills/github-pr-reviewer/scripts/publication.py +193 -0
  55. package/skills/github-pr-reviewer/scripts/worker.py +297 -8
  56. package/skills/github-pr-reviewer/tests/test_main.py +73 -5
  57. package/skills/gitlab-issue-to-mr/README.md +10 -3
  58. package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
  59. package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
  60. package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
  61. package/skills/index.js +10 -10
  62. package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
  63. package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
  64. package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
  65. package/skills/jira-issue-to-pr/README.md +18 -11
  66. package/skills/jira-issue-to-pr/SKILL.md +72 -23
  67. package/skills/jira-issue-to-pr/references/setup.md +43 -12
  68. package/skills/jira-issue-to-pr/scripts/main.py +136 -26
  69. package/skills/openhands-automation/references/custom-automation.md +76 -8
  70. package/skills/slack-channel-monitor/README.md +6 -1
  71. package/skills/slack-channel-monitor/SKILL.md +6 -3
  72. package/skills/slack-channel-monitor/scripts/main.py +36 -4
  73. package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
  74. package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
  75. package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
  76. package/skills/ticket-to-code-change/SKILL.md +5 -3
  77. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
  78. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  79. package/tests/test_agent_conversation_dispatch.py +173 -6
  80. package/tests/test_automation_llm_provenance.py +1209 -0
  81. package/tests/test_github_review_publication.py +422 -0
  82. package/tests/test_github_reviewer_delivery.py +174 -21
  83. package/tests/test_github_triage_delivery.py +411 -238
  84. package/tests/test_gitlab_issue_to_mr.py +284 -0
  85. package/tests/test_jira_issue_to_pr.py +200 -6
  86. package/tests/test_pr_review_prompt_injection.py +353 -0
  87. package/tests/test_slack_channel_monitor.py +32 -1
  88. package/tests/test_vscode_app.py +8 -2
  89. package/tests/vscode-app.test.mjs +8 -2
@@ -3,7 +3,9 @@
3
3
  import json
4
4
  import os
5
5
  import time
6
+ from collections.abc import Callable
6
7
  from urllib.error import HTTPError
8
+ from urllib.parse import urlencode
7
9
  from urllib.request import Request, urlopen
8
10
  from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
9
11
 
@@ -113,6 +115,10 @@ class CloudConversations:
113
115
  },
114
116
  )
115
117
 
118
+ def agent_profiles(self) -> list[dict]:
119
+ listing = self._request("GET", "/api/agent-profiles")
120
+ return (listing or {}).get("profiles") or []
121
+
116
122
  def run(self, conversation: dict) -> None:
117
123
  """Start the next turn of an idle conversation on its own Agent Server."""
118
124
  try:
@@ -152,6 +158,17 @@ def _user_message(text: str) -> dict:
152
158
  return {"role": "user", "content": [{"type": "text", "text": text}]}
153
159
 
154
160
 
161
+ def _render(prompt, conversation_id) -> str:
162
+ """The text of `prompt` for the conversation it is delivered to.
163
+
164
+ A caller that must name the receiving conversation in its own prompt (a
165
+ marker the deterministic side later looks for, say) passes a function of
166
+ the conversation id, because only delivery decides which conversation that
167
+ is.
168
+ """
169
+ return prompt(str(conversation_id)) if callable(prompt) else prompt
170
+
171
+
155
172
  class AgentConversationDispatcher:
156
173
  """Deliver one revision at a time to a stable conversation for each subject."""
157
174
 
@@ -189,7 +206,11 @@ class AgentConversationDispatcher:
189
206
  return self._workspace.__exit__(*args)
190
207
 
191
208
  def deliver(
192
- self, subject: str, delivery: str, prompt: str, head: str = ""
209
+ self,
210
+ subject: str,
211
+ delivery: str,
212
+ prompt: str | Callable[[str], str],
213
+ head: str = "",
193
214
  ) -> dict[str, str]:
194
215
  """Deliver one revision of `subject`, deduping a repeated head.
195
216
 
@@ -202,6 +223,7 @@ class AgentConversationDispatcher:
202
223
  A conversation already running for this head is reported `in_progress`
203
224
  and left alone, whatever the new delivery says. Callers with no revision
204
225
  identity leave `head` empty and keep the delivery-key-only behavior.
226
+ `prompt` may be a function of the receiving conversation's id.
205
227
  """
206
228
  conversation_id = uuid5(NAMESPACE_URL, f"{self.automation_id}:{subject}")
207
229
  state_key = f"{_CONVERSATION_KEY_PREFIX}{conversation_id}"
@@ -252,6 +274,67 @@ class AgentConversationDispatcher:
252
274
  "conversation_id": str(conversation_id),
253
275
  }
254
276
 
277
+ def subject_conversation(self, subject: str) -> str | None:
278
+ """The conversation this automation currently delivers `subject` to."""
279
+ conversation_id = uuid5(NAMESPACE_URL, f"{self.automation_id}:{subject}")
280
+ record = _kv_request(f"{_CONVERSATION_KEY_PREFIX}{conversation_id}", "GET")
281
+ return (record or {}).get("conversation_id") or None
282
+
283
+ def llm_provenance(self, conversation_id: str) -> tuple[str, str] | None:
284
+ """The LLM profile name and model a conversation runs, or None if it is gone.
285
+
286
+ Both are read back from the conversation, never from the automation's
287
+ current settings: the server resolves the agent profile when it starts
288
+ the conversation, and a reused conversation keeps that LLM after the
289
+ profile is edited. The model is the conversation's own. The LLM profile
290
+ name comes from the launching agent profile only while that profile is
291
+ still at its launch revision, and is `unknown` otherwise.
292
+ """
293
+ if self._cloud:
294
+ conversation = self._cloud.get(conversation_id)
295
+ if conversation is None:
296
+ return None
297
+ model = conversation.get("llm_model")
298
+ else:
299
+ try:
300
+ conversation = self._agent_server_get(
301
+ f"/api/conversations/{conversation_id}"
302
+ )
303
+ except HTTPError as exc:
304
+ if exc.code == 404:
305
+ return None
306
+ raise
307
+ agent = conversation.get("agent") or {}
308
+ model = conversation.get("current_model_id")
309
+ if model is None and agent.get("kind") != "ACPAgent":
310
+ model = (agent.get("llm") or {}).get("model")
311
+ launched = conversation.get("launched_agent_profile") or {}
312
+ profile = "unknown"
313
+ if launched.get("agent_profile_id"):
314
+ try:
315
+ if self._cloud:
316
+ profiles = self._cloud.agent_profiles()
317
+ else:
318
+ listing = self._agent_server_get("/api/agent-profiles")
319
+ profiles = (listing or {}).get("profiles") or []
320
+ except Exception as exc: # noqa: BLE001 - the model is still known
321
+ print(f"Could not read agent profiles: {type(exc).__name__}: {exc}")
322
+ profiles = []
323
+ for item in profiles:
324
+ if str(item.get("id")) == str(launched["agent_profile_id"]):
325
+ if item.get("revision") == launched.get("revision"):
326
+ profile = item.get("llm_profile_ref") or "unknown"
327
+ break
328
+ return profile, model or "unknown"
329
+
330
+ def _agent_server_get(self, path: str) -> dict:
331
+ request = Request(
332
+ f"{self.agent_url.rstrip('/')}{path}",
333
+ headers={"X-Session-API-Key": self.api_key},
334
+ )
335
+ with urlopen(request, timeout=90) as response:
336
+ return json.load(response)
337
+
255
338
  def _deliver_cloud(
256
339
  self,
257
340
  record,
@@ -287,7 +370,9 @@ class AgentConversationDispatcher:
287
370
 
288
371
  if conversation is None:
289
372
  new_id = uuid4() if record else conversation_id
290
- self._cloud.start(new_id, self.profile_id, subject, prompt)
373
+ self._cloud.start(
374
+ new_id, self.profile_id, subject, _render(prompt, new_id)
375
+ )
291
376
  return "created", new_id
292
377
 
293
378
  status = conversation.get("execution_status")
@@ -303,10 +388,10 @@ class AgentConversationDispatcher:
303
388
  # The API reports an execution status only while the sandbox
304
389
  # runs, so an errored conversation whose sandbox was already
305
390
  # paused is not seen here and keeps reporting deduplicated.
306
- self._cloud.send(conversation, prompt)
391
+ self._cloud.send(conversation, _render(prompt, current_id))
307
392
  return "retried", current_id
308
393
  return "deduplicated", current_id
309
- self._cloud.send(conversation, prompt)
394
+ self._cloud.send(conversation, _render(prompt, current_id))
310
395
  return "resumed", current_id
311
396
 
312
397
  def _deliver_local(
@@ -326,12 +411,13 @@ class AgentConversationDispatcher:
326
411
  conversation = RemoteConversation.create(
327
412
  self._workspace,
328
413
  StartConversationRequest(
329
- workspace=LocalWorkspace(working_dir="/workspace"),
414
+ workspace=LocalWorkspace(working_dir=self._workspace.working_dir),
330
415
  conversation_id=conversation_id,
331
416
  agent_profile_id=self.profile_id,
332
417
  secrets=self._secrets,
333
418
  initial_message=SendMessageRequest(
334
- content=[TextContent(text=prompt)], run=True
419
+ content=[TextContent(text=_render(prompt, conversation_id))],
420
+ run=True,
335
421
  ),
336
422
  ),
337
423
  visualizer=None,
@@ -365,7 +451,7 @@ class AgentConversationDispatcher:
365
451
  # the work finished, so send the revision again rather
366
452
  # than strand it.
367
453
  conversation.update_secrets(self._secrets)
368
- conversation.send_message(prompt)
454
+ conversation.send_message(_render(prompt, conversation_id))
369
455
  conversation.run(blocking=False)
370
456
  disposition = "retried"
371
457
  else:
@@ -375,8 +461,138 @@ class AgentConversationDispatcher:
375
461
  # work on the same subject: reuse the conversation and send
376
462
  # the revision as its next turn.
377
463
  conversation.update_secrets(self._secrets)
378
- conversation.send_message(prompt)
464
+ conversation.send_message(_render(prompt, conversation_id))
379
465
  conversation.run(blocking=False)
380
466
  finally:
381
467
  conversation.close()
382
468
  return disposition
469
+
470
+ def review_snapshot(self, conversation_id):
471
+ if self._cloud:
472
+ conversation = self._cloud.get(conversation_id)
473
+ if conversation is None:
474
+ return "missing", ""
475
+ status = conversation.get("execution_status", "unknown")
476
+ if status != "finished":
477
+ return status, ""
478
+ result = self._cloud._request(
479
+ "GET",
480
+ f"{conversation['conversation_url']}/agent_final_response",
481
+ headers={"X-Session-API-Key": conversation["session_api_key"]},
482
+ )
483
+ return status, result.get("response", "")
484
+ headers = {"X-Session-API-Key": self.api_key}
485
+ base = f"{self.agent_url.rstrip('/')}/api/conversations/{conversation_id}"
486
+ try:
487
+ with urlopen(Request(base, headers=headers), timeout=90) as response:
488
+ status = json.load(response).get("execution_status", "unknown")
489
+ except HTTPError as exc:
490
+ if exc.code == 404:
491
+ return "missing", ""
492
+ raise
493
+ if status != "finished":
494
+ return status, ""
495
+ with urlopen(
496
+ Request(base + "/agent_final_response", headers=headers), timeout=90
497
+ ) as response:
498
+ return status, json.load(response).get("response", "")
499
+
500
+ def start_review_work(self, conversation_id, subject, prompt):
501
+ if self._cloud:
502
+ self._cloud.start(UUID(conversation_id), self.profile_id, subject, prompt)
503
+ return
504
+ if self._workspace is None:
505
+ raise RuntimeError("Dispatcher must be used as a context")
506
+ conversation = RemoteConversation.create(
507
+ self._workspace,
508
+ StartConversationRequest(
509
+ workspace=LocalWorkspace(working_dir=self._workspace.working_dir),
510
+ conversation_id=UUID(conversation_id),
511
+ agent_profile_id=self.profile_id,
512
+ secrets=self._secrets,
513
+ initial_message=SendMessageRequest(
514
+ content=[TextContent(text=prompt)], run=True
515
+ ),
516
+ ),
517
+ visualizer=None,
518
+ )
519
+ conversation.close()
520
+
521
+
522
+ class ReviewCoordination:
523
+ """CAS against the automation service's shared, user-authorized KV namespace."""
524
+
525
+ def __init__(self):
526
+ namespace = os.environ.get("REVIEW_COORDINATION_AUTOMATION_ID")
527
+ key = os.environ.get("REVIEW_COORDINATION_API_KEY")
528
+ if not namespace or not key:
529
+ raise RuntimeError(
530
+ "Structured reviews require shared coordination namespace and API key"
531
+ )
532
+ self.query = urlencode({"automation_id": str(UUID(namespace))})
533
+ self.base = os.environ["AUTOMATION_API_URL"].rstrip("/")
534
+ self.headers = {"X-Session-API-Key": key, "Content-Type": "application/json"}
535
+
536
+ def request(self, key, method="GET", value=None, condition=""):
537
+ request = Request(
538
+ f"{self.base}/v1/kv/{key}?{self.query}&{condition}",
539
+ data=None if value is None else json.dumps(value).encode(),
540
+ headers=self.headers,
541
+ method=method,
542
+ )
543
+ try:
544
+ with urlopen(request, timeout=90) as response:
545
+ return json.load(response)
546
+ except HTTPError as exc:
547
+ if method == "GET" and exc.code == 404:
548
+ return None
549
+ raise
550
+
551
+ def claim(self, subject, *, existing_only=False):
552
+ key = "review-work-" + str(uuid5(NAMESPACE_URL, subject))
553
+ for _ in range(5):
554
+ current = self.request(key, condition="meta=true")
555
+ if current is None and existing_only:
556
+ return None
557
+ value = (current or {}).get("value") or {}
558
+ if value.get("lease_until", 0) > time.time():
559
+ return None
560
+ value = {**value, "owner": str(uuid4()), "lease_until": time.time() + 180}
561
+ condition = f"if_version={current['version']}" if current else "nx=true"
562
+ try:
563
+ self.request(key, "PUT", value, condition)
564
+ return ReviewLease(self, key, value)
565
+ except HTTPError as exc:
566
+ if exc.code != 409:
567
+ raise
568
+ return None
569
+
570
+
571
+ class ReviewLease:
572
+ def __init__(self, store, key, value):
573
+ self.store, self.key, self.value = store, key, value
574
+ self.owner = value["owner"]
575
+
576
+ def save(self, **changes):
577
+ for _ in range(5):
578
+ current = self.store.request(self.key, condition="meta=true")
579
+ value = current["value"]
580
+ if (
581
+ value.get("owner") != self.owner
582
+ or value.get("lease_until", 0) <= time.time()
583
+ ):
584
+ raise RuntimeError("Review lease lost; refusing side effects")
585
+ updated = {**self.value, **changes}
586
+ try:
587
+ self.store.request(
588
+ self.key, "PUT", updated, f"if_version={current['version']}"
589
+ )
590
+ self.value = updated
591
+ return
592
+ except HTTPError as exc:
593
+ if exc.code != 409:
594
+ raise
595
+ raise RuntimeError("Review coordination contention; retry next scan")
596
+
597
+ def release(self):
598
+ self.save(lease_until=0)
@@ -24,6 +24,7 @@ import tarfile
24
24
  import time
25
25
  import urllib.error
26
26
  import urllib.request
27
+ import uuid
27
28
  from collections.abc import Callable
28
29
  from pathlib import Path, PurePosixPath
29
30
 
@@ -427,7 +428,7 @@ def _latest_trigger_label_event(token: str, repo: str, pr_number: int) -> dict |
427
428
  return max(matching, key=lambda event: (event.get("created_at") or "", int(event.get("id") or 0)))
428
429
 
429
430
 
430
- def _post_github_comment(token: str, repo: str, pr_number: int, body: str) -> None:
431
+ def _post_github_comment(token: str, repo: str, pr_number: int, body: str) -> bool:
431
432
  try:
432
433
  _github_request(
433
434
  token,
@@ -437,28 +438,28 @@ def _post_github_comment(token: str, repo: str, pr_number: int, body: str) -> No
437
438
  )
438
439
  except Exception as exc:
439
440
  print(f" Warning: failed to post comment on PR #{pr_number}: {exc}")
441
+ return False
442
+ return True
440
443
 
441
444
 
442
- def _matching_review_exists(token: str, repo: str, pr_number: int, head_sha: str) -> bool:
443
- """Has this token's user already published a review for this exact commit?
445
+ def _published_reviews(token: str, repo: str, pr_number: int, head_sha: str) -> list[dict]:
446
+ """This token's user's submitted reviews of this exact commit.
444
447
 
445
448
  The agent is asked to report success, but a report is not evidence: reviews
446
449
  have been reported as posted when none existed. GitHub is the source of
447
- truth for whether the review landed.
450
+ truth for whether the review landed. A listing failure is raised rather
451
+ than read as "no review", so the caller retries instead of posting the
452
+ result a second time.
448
453
  """
449
454
  if not head_sha or not _AUTH_LOGIN:
450
- return False
451
- try:
452
- reviews = _github_paginate(token, f"/repos/{repo}/pulls/{pr_number}/reviews")
453
- except Exception as exc:
454
- print(f" Warning: could not list reviews for PR #{pr_number}: {exc}")
455
- return False
456
- for review in reviews:
457
- if (review.get("user") or {}).get("login", "").lower() != _AUTH_LOGIN.lower():
458
- continue
459
- if review.get("commit_id") == head_sha:
460
- return True
461
- return False
455
+ return []
456
+ return [
457
+ review
458
+ for review in _github_paginate(token, f"/repos/{repo}/pulls/{pr_number}/reviews")
459
+ if (review.get("user") or {}).get("login", "").lower() == _AUTH_LOGIN.lower()
460
+ and review.get("commit_id") == head_sha
461
+ and review.get("state") in {"COMMENTED", "APPROVED", "CHANGES_REQUESTED"}
462
+ ]
462
463
 
463
464
 
464
465
  # ── Repository checkout ───────────────────────────────────────────────────────
@@ -666,12 +667,17 @@ def create_conversation(
666
667
  api_key: str,
667
668
  initial_message: str,
668
669
  workspace_dir: Path,
670
+ agent: dict | None = None,
671
+ conversation_id: str | None = None,
669
672
  ) -> str:
670
673
  payload: dict = {
671
674
  "workspace": {"working_dir": str(workspace_dir)},
672
- "agent": _get_agent_dict(agent_url, api_key),
675
+ "agent": agent or _get_agent_dict(agent_url, api_key),
673
676
  "initial_message": {"content": [{"text": initial_message}]},
674
677
  }
678
+ if conversation_id:
679
+ # Chosen by the caller so the prompt can name it before it exists.
680
+ payload["conversation_id"] = conversation_id
675
681
  secrets = _build_secrets_payload(agent_url, api_key)
676
682
  if secrets:
677
683
  payload["secrets"] = secrets
@@ -733,6 +739,52 @@ def _with_ai_disclosure(body: str) -> str:
733
739
  return f"{body}\n\n{disclosure}" if body else disclosure
734
740
 
735
741
 
742
+ def _llm_provenance(profile: str, model: str) -> str:
743
+ return f"LLM profile: `{profile}` · Model: `{model}`"
744
+
745
+
746
+ def _without_llm_provenance(body: str) -> str:
747
+ """The body with any provenance footer removed, for reading its verdict."""
748
+ return "\n".join(
749
+ line
750
+ for line in (body or "").splitlines()
751
+ if not re.fullmatch(r"LLM profile: .* · Model: .*", line.strip())
752
+ ).strip()
753
+
754
+
755
+ def _with_llm_provenance(body: str, profile: str, model: str) -> str:
756
+ provenance = _llm_provenance(profile, model)
757
+ body = _without_llm_provenance(body)
758
+ return f"{body}\n\n{provenance}" if body else provenance
759
+
760
+
761
+ # Two automations can share one bot account and review the same head, so the
762
+ # login, commit, and submission time cannot say which run published a review.
763
+ # The review agent copies this hidden marker, naming its own conversation, into
764
+ # the review body; the deterministic script then annotates only the reviews
765
+ # that carry exactly one marker, and only that conversation's own.
766
+ _REVIEW_RUN_MARKER_RE = re.compile(r"<!-- openhands-review-run: ([0-9A-Za-z-]+) -->")
767
+
768
+
769
+ def _review_run_marker(conversation_id: str) -> str:
770
+ return f"<!-- openhands-review-run: {conversation_id} -->"
771
+
772
+
773
+ def _own_reviews(reviews: list[dict], conversation_id: str) -> list[dict]:
774
+ """The reviews whose only run marker names `conversation_id`.
775
+
776
+ A review with no marker, another run's marker, or several markers (an
777
+ agent quoting another review) is never attributed to this conversation.
778
+ """
779
+ if not conversation_id:
780
+ return []
781
+ return [
782
+ review
783
+ for review in reviews
784
+ if _REVIEW_RUN_MARKER_RE.findall(review.get("body") or "") == [str(conversation_id)]
785
+ ]
786
+
787
+
736
788
  def _load_repo_review_guide(workspace_dir: Path) -> str | None:
737
789
  """Read the repo-specific review guide from the checked-out repository.
738
790
 
@@ -764,6 +816,7 @@ def _build_review_prompt(
764
816
  workspace_instructions: str | None = None,
765
817
  github_token_secret: str = "GITHUB_PERSONAL_ACCESS_TOKEN",
766
818
  trigger_description: str | None = None,
819
+ conversation_id: str | None = None,
767
820
  ) -> str:
768
821
  number = pr.get("number", "?")
769
822
  title = pr.get("title", "(no title)")
@@ -792,6 +845,14 @@ def _build_review_prompt(
792
845
  "The workspace is already the repository root at the exact Head SHA above. "
793
846
  "Do not clone, fetch, check out, or delete the repository."
794
847
  )
848
+ run_marker = (
849
+ " On the next line put this hidden marker, copied exactly: "
850
+ f"`{_review_run_marker(conversation_id)}`. It identifies this conversation's "
851
+ "review, and the automation uses it to append the LLM profile and model after "
852
+ "GitHub accepts the review, so do not write that footer yourself."
853
+ if conversation_id
854
+ else ""
855
+ )
795
856
 
796
857
  return (
797
858
  "You are an AI code reviewer. Review the GitHub pull request below and publish "
@@ -827,6 +888,14 @@ def _build_review_prompt(
827
888
  "changed files), plus other relevant docs when present - e.g. `CONTRIBUTING.md`, "
828
889
  "`CLAUDE.md`, `.cursorrules`, and any review or coding-guideline docs. Apply that "
829
890
  "guidance to your review.\n"
891
+ " Read `.agents/skills/custom-codereview-guide.md` when present; apply only checks "
892
+ "relevant to this change. For cross-repo behavior, inspect the owning implementation at "
893
+ "the supported dependency version (check pins/deployment), not upstream main. software-"
894
+ "agent-sdk owns execution/events; automation owns scheduling/run state; extensions owns "
895
+ "reusable skills/API usage. Find where behavior first fails. Distinguish an upstream fix "
896
+ "from a needed consumer change; state dependency order without filing tickets or editing "
897
+ "other repos. Respect accepted defaults/non-goals. Do not repeat answered questions or "
898
+ "block on unrelated pre-existing defects.\n"
830
899
  "4. SCOPE GATE - before inspecting changed files, reading the diff, or running any test, "
831
900
  "use the repository guidance above (its scope categories and ownership boundaries) to decide "
832
901
  "whether this change belongs in this repository and has the product/architecture direction "
@@ -877,8 +946,11 @@ def _build_review_prompt(
877
946
  "If GitHub forbids the configured bot from approving its own PR, retry the clean review with "
878
947
  "`event: COMMENT` and keep the approved verdict.\n"
879
948
  "10. Begin the review body with this disclosure: "
880
- "`_This review was posted by an AI agent (OpenHands)._`\n"
881
- "11. End the review body with a verdict on its own line: either `✅ APPROVED` "
949
+ f"`_This review was posted by an AI agent (OpenHands)._`{run_marker}\n"
950
+ "11. Separate code correctness from merge readiness. Never end with APPROVED while "
951
+ "material findings or required checks/evidence remain unresolved. Missing evidence "
952
+ "is not itself a code defect. "
953
+ "End the review body with a verdict on its own line: either `✅ APPROVED` "
882
954
  "or `🔄 CHANGES REQUESTED`.\n"
883
955
  "12. If there are no material issues, still publish a review saying so, with the "
884
956
  "disclosure and the verdict.\n"
@@ -933,8 +1005,24 @@ def _process_review_request(
933
1005
  repo_review_guide = _load_repo_review_guide(workspace_dir)
934
1006
  if repo_review_guide:
935
1007
  print(f" Injected repo review guide for PR #{number}")
936
- prompt = _build_review_prompt(repo, pr, head_sha, label_event, repo_review_guide)
937
- conv_id = create_conversation(agent_url, api_key, prompt, workspace_dir)
1008
+ agent = _get_agent_dict(agent_url, api_key)
1009
+ conv_id = str(uuid.uuid4())
1010
+ prompt = _build_review_prompt(
1011
+ repo,
1012
+ pr,
1013
+ head_sha,
1014
+ label_event,
1015
+ repo_review_guide,
1016
+ conversation_id=conv_id,
1017
+ )
1018
+ conv_id = create_conversation(
1019
+ agent_url,
1020
+ api_key,
1021
+ prompt,
1022
+ workspace_dir,
1023
+ agent=agent,
1024
+ conversation_id=conv_id,
1025
+ )
938
1026
  except Exception as exc:
939
1027
  # The claim is dropped so the next poll retries this label event. The
940
1028
  # checkout goes with it rather than being left behind.
@@ -950,6 +1038,8 @@ def _process_review_request(
950
1038
  "status": "active",
951
1039
  "conversation_id": conv_id,
952
1040
  "workspace_dir": str(workspace_dir),
1041
+ "llm_profile": "default",
1042
+ "llm_model": agent["llm"].get("model") or "unknown",
953
1043
  "last_activity": time.time(),
954
1044
  }
955
1045
  )
@@ -1023,31 +1113,65 @@ def _check_conversation_completion(
1023
1113
  except Exception:
1024
1114
  final = ""
1025
1115
 
1116
+ # A record written before provenance was tracked cannot name either value.
1117
+ llm_profile = rec.get("llm_profile") or "unknown"
1118
+ llm_model = rec.get("llm_model") or "unknown"
1026
1119
  if status in {"error", "stuck"}:
1027
- _post_github_comment(
1120
+ posted = _post_github_comment(
1028
1121
  github_token,
1029
1122
  repo,
1030
1123
  pr_number,
1031
- _with_ai_disclosure(
1032
- f"⚠️ **OpenHands PR Reviewer encountered a problem** at commit `{reviewed_sha[:12]}` "
1033
- f"(status: `{status}`).\n\n{final}".strip()
1124
+ _with_llm_provenance(
1125
+ _with_ai_disclosure(
1126
+ f"⚠️ **OpenHands PR Reviewer encountered a problem** at commit `{reviewed_sha[:12]}` "
1127
+ f"(status: `{status}`).\n\n{final}".strip()
1128
+ ),
1129
+ llm_profile,
1130
+ llm_model,
1034
1131
  ),
1035
1132
  )
1036
- elif _matching_review_exists(github_token, repo, pr_number, reviewed_sha):
1037
- print(f" PR #{pr_number}: review confirmed on GitHub at {reviewed_sha[:12]}")
1133
+ if not posted:
1134
+ return
1038
1135
  else:
1039
- # The agent was asked to publish the review itself; it did not, so the
1040
- # work is not lost - post whatever it produced as a comment.
1041
- _post_github_comment(
1042
- github_token,
1043
- repo,
1044
- pr_number,
1045
- _with_ai_disclosure(
1046
- final
1047
- or f"✅ **OpenHands completed the review for commit `{reviewed_sha[:12]}`.** No review text was produced."
1048
- ),
1049
- )
1050
- print(f" PR #{pr_number}: no review found on GitHub; posted the result as a comment")
1136
+ try:
1137
+ reviews = _published_reviews(github_token, repo, pr_number, reviewed_sha)
1138
+ own = _own_reviews(reviews, conv_id)
1139
+ for review in own:
1140
+ body = _with_llm_provenance(review.get("body") or "", llm_profile, llm_model)
1141
+ if body != review.get("body"):
1142
+ _github_request(
1143
+ github_token,
1144
+ "PUT",
1145
+ f"/repos/{repo}/pulls/{pr_number}/reviews/{review['id']}",
1146
+ body={"body": body},
1147
+ )
1148
+ except Exception as exc:
1149
+ print(f" Warning: could not verify or complete review provenance for PR #{pr_number}: {exc}")
1150
+ return
1151
+ if reviews:
1152
+ print(
1153
+ f" PR #{pr_number}: review confirmed on GitHub at {reviewed_sha[:12]}"
1154
+ + ("" if own else "; none carries this conversation's marker, so none was annotated")
1155
+ )
1156
+ else:
1157
+ if final.strip() == "GITHUB_REVIEW_POSTED":
1158
+ final = ""
1159
+ posted = _post_github_comment(
1160
+ github_token,
1161
+ repo,
1162
+ pr_number,
1163
+ _with_llm_provenance(
1164
+ _with_ai_disclosure(
1165
+ final
1166
+ or f"✅ **OpenHands completed the review for commit `{reviewed_sha[:12]}`.** No review text was produced."
1167
+ ),
1168
+ llm_profile,
1169
+ llm_model,
1170
+ ),
1171
+ )
1172
+ if not posted:
1173
+ return
1174
+ print(f" PR #{pr_number}: no review found on GitHub; posted the result as a comment")
1051
1175
 
1052
1176
  rec["status"] = "closed"
1053
1177
  rec["completed_at"] = time.time()