@openhands/extensions 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.agents/skills/custom-codereview-guide.md +47 -5
  2. package/.claude-plugin/marketplace.json +20 -5
  3. package/.codex-plugin/marketplace.json +20 -5
  4. package/.github/workflows/build-vscode-app.yml +3 -3
  5. package/.github/workflows/check-extensions.yml +1 -1
  6. package/.github/workflows/pr-artifacts.yml +4 -83
  7. package/.github/workflows/tests.yml +14 -1
  8. package/.plugin/marketplace.json +20 -5
  9. package/.release-please-manifest.json +1 -1
  10. package/AGENTS.md +4 -3
  11. package/README.md +6 -5
  12. package/apps/vscode/README.md +8 -6
  13. package/apps/vscode/canvas-extension.json +11 -3
  14. package/apps/vscode/extension.js +60 -12
  15. package/apps/vscode/scripts/package.mjs +20 -74
  16. package/automations/bundle-index.js +10 -8
  17. package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
  18. package/automations/catalog/github-issue-triage/manifest.json +4 -3
  19. package/automations/catalog/github-pr-reviewer/manifest.json +7 -6
  20. package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
  21. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
  22. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
  23. package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
  24. package/integrations/catalog/atlassian-rovo.json +4 -3
  25. package/integrations/catalog/bitbucket.json +16 -1
  26. package/integrations/catalog/github.json +1 -0
  27. package/integrations/catalog/gitlab.json +1 -1
  28. package/marketplaces/openhands-extensions.json +20 -5
  29. package/package.json +1 -1
  30. package/plugins/pr-review/README.md +1 -0
  31. package/plugins/pr-review/scripts/prompt.py +44 -11
  32. package/plugins/runtime-api-configs/SKILL.md +1 -1
  33. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
  34. package/pyproject.toml +1 -1
  35. package/python/openhands_extensions/_version.py +1 -1
  36. package/skills/canvas-extension-api/references/backend-safety.md +3 -3
  37. package/skills/canvas-extension-api/references/v1-contract.md +7 -4
  38. package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
  39. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
  40. package/skills/github/scripts/agent_conversation.py +224 -8
  41. package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
  42. package/skills/github-issue-triage/README.md +73 -14
  43. package/skills/github-issue-triage/SKILL.md +26 -46
  44. package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
  45. package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
  46. package/skills/github-issue-triage/scripts/worker.py +153 -103
  47. package/skills/github-pr-reviewer/README.md +109 -1
  48. package/skills/github-pr-reviewer/SKILL.md +42 -8
  49. package/skills/github-pr-reviewer/references/state-schema.md +8 -0
  50. package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
  51. package/skills/github-pr-reviewer/scripts/main.py +163 -39
  52. package/skills/github-pr-reviewer/scripts/publication.py +193 -0
  53. package/skills/github-pr-reviewer/scripts/worker.py +297 -8
  54. package/skills/github-pr-reviewer/tests/test_main.py +73 -5
  55. package/skills/github-stale-ci-pr-closer/scripts/worker.py +8 -2
  56. package/skills/gitlab-issue-to-mr/README.md +10 -3
  57. package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
  58. package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
  59. package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
  60. package/skills/index.js +18 -8
  61. package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
  62. package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
  63. package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
  64. package/skills/jira-issue-to-pr/README.md +18 -11
  65. package/skills/jira-issue-to-pr/SKILL.md +72 -23
  66. package/skills/jira-issue-to-pr/references/setup.md +43 -12
  67. package/skills/jira-issue-to-pr/scripts/main.py +136 -26
  68. package/skills/slack-channel-monitor/README.md +12 -3
  69. package/skills/slack-channel-monitor/SKILL.md +35 -20
  70. package/skills/slack-channel-monitor/references/windows.md +6 -2
  71. package/skills/slack-channel-monitor/scripts/main.py +189 -12
  72. package/skills/software-factory/.claude-plugin/plugin.json +19 -0
  73. package/skills/software-factory/.codex-plugin/plugin.json +19 -0
  74. package/skills/software-factory/.plugin/plugin.json +19 -0
  75. package/skills/software-factory/README.md +64 -0
  76. package/skills/software-factory/SKILL.md +229 -0
  77. package/skills/software-factory/commands/software-factory.md +8 -0
  78. package/skills/software-factory/scripts/factory.py +630 -0
  79. package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
  80. package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
  81. package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
  82. package/skills/ticket-to-code-change/SKILL.md +5 -3
  83. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
  84. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  85. package/tests/test_agent_conversation_dispatch.py +173 -6
  86. package/tests/test_automation_llm_provenance.py +1209 -0
  87. package/tests/test_github_review_publication.py +422 -0
  88. package/tests/test_github_reviewer_delivery.py +174 -21
  89. package/tests/test_github_stale_ci_pr_closer.py +52 -1
  90. package/tests/test_github_triage_delivery.py +411 -238
  91. package/tests/test_gitlab_issue_to_mr.py +284 -0
  92. package/tests/test_jira_issue_to_pr.py +200 -6
  93. package/tests/test_pr_review_prompt_injection.py +353 -0
  94. package/tests/test_slack_channel_monitor.py +32 -1
  95. package/tests/test_software_factory_script.py +231 -0
  96. package/tests/test_vscode_app.py +8 -2
  97. package/tests/vscode-app.test.mjs +8 -2
@@ -2,11 +2,14 @@
2
2
 
3
3
  import json
4
4
  import os
5
+ import re
5
6
  import sys
6
7
  from functools import cached_property
8
+ from urllib.error import HTTPError
7
9
  from urllib.parse import quote
8
10
 
9
11
  import main as workflow
12
+ import agent_conversation
10
13
  from agent_conversation import AgentConversationDispatcher
11
14
  from github_client import GitHubRepository, run_repositories
12
15
  from maintainer_handoff import (
@@ -25,6 +28,7 @@ from maintainer_handoff import (
25
28
  # approving silently, and a required run that has not completed means waiting,
26
29
  # never approval. When the required set cannot be read, the gate falls back to
27
30
  # every current-head check and workflow run, so a red head still blocks.
31
+ SCAN_WINDOW = 10
28
32
  CHECK_GATE_MARKER = "<!-- openhands-review-gate:"
29
33
  NON_BLOCKING_CHECK_CONCLUSIONS = frozenset({"success", "neutral", "skipped"})
30
34
  # GitHub parks a fork head's workflow runs at this conclusion until a maintainer
@@ -114,6 +118,57 @@ class ReviewIntake:
114
118
 
115
119
 
116
120
 
121
+ class ScanCursor:
122
+ """The per-repository position of the rotating unrequested-PR window.
123
+
124
+ Classifying one unrequested head costs a review read, a check-run read, and
125
+ a workflow-run read, so a scan that examined every open PR spent the API
126
+ budget on the largest repository alone. The scan instead examines a bounded
127
+ slice of the unrequested backlog and remembers where the slice ended, in the
128
+ automation service's own KV store - the same facility `main.py` already uses
129
+ for review state, reached with the same `AUTOMATION_KV_TOKEN` and
130
+ `AUTOMATION_API_URL`, so no new secret or store is introduced. The next scan
131
+ resumes past that point, so the whole backlog is covered fairly over several
132
+ scans.
133
+
134
+ When the KV store is unavailable (a local run, or the tests) the position is
135
+ kept in memory, so the scan still rotates within the run and nothing else
136
+ about it changes. A KV read or write failure is not fatal: a scan position is
137
+ not worth aborting reviews over, so the in-memory position is used instead.
138
+ """
139
+
140
+ def __init__(self, repository):
141
+ self._key = f"review-scan:{workflow._repo_slug(repository)}"
142
+ self._memory = 0
143
+
144
+ def position(self, total):
145
+ """The stored cursor, reduced to a valid offset into `total` items."""
146
+ return self._read() % total if total else 0
147
+
148
+ def advance(self, cursor):
149
+ """Record where the next scan's window starts."""
150
+ self._memory = cursor
151
+ if not workflow._kv_available():
152
+ return
153
+ try:
154
+ workflow._kv_set(self._key, {"cursor": cursor})
155
+ except Exception as exc: # noqa: BLE001 - a cursor is not worth failing a scan
156
+ print(f" Warning: scan cursor write failed ({exc})")
157
+
158
+ def _read(self):
159
+ if not workflow._kv_available():
160
+ return self._memory
161
+ try:
162
+ data = workflow._kv_get(self._key) or {}
163
+ except Exception as exc: # noqa: BLE001 - fall back to the in-memory cursor
164
+ print(f" Warning: scan cursor read failed ({exc})")
165
+ return self._memory
166
+ try:
167
+ return int(data.get("cursor") or 0)
168
+ except (TypeError, ValueError):
169
+ return self._memory
170
+
171
+
117
172
  class PullRequestReviewer(GitHubRepository):
118
173
  name = "github-pr-reviewer"
119
174
  # The shared intake the shipped entrypoint creates once per scheduled scan,
@@ -122,6 +177,14 @@ class PullRequestReviewer(GitHubRepository):
122
177
  # conversations with a private intake instead.
123
178
  scan_intake = None
124
179
 
180
+ @cached_property
181
+ def structured_reviews(self):
182
+ if not self.config.get("structured_publication", False):
183
+ return None
184
+ from publication import StructuredReviews
185
+
186
+ return StructuredReviews(self)
187
+
125
188
  @property
126
189
  def intake(self):
127
190
  """The intake this run registers eligible candidates with.
@@ -193,7 +256,7 @@ class PullRequestReviewer(GitHubRepository):
193
256
  return pr if (author or "").lower() == self.trigger_reviewer else None
194
257
  return None
195
258
 
196
- def _prompt(self, pr, trigger, label=None, delivery_key=None):
259
+ def _prompt(self, pr, trigger, label=None, delivery_key=None, conversation_id=None):
197
260
  number = pr["number"]
198
261
  sha = pr["head"]["sha"]
199
262
  token = self.token_name
@@ -225,6 +288,7 @@ class PullRequestReviewer(GitHubRepository):
225
288
  workspace_instructions=workspace,
226
289
  github_token_secret=token,
227
290
  trigger_description=trigger_description,
291
+ conversation_id=conversation_id,
228
292
  )
229
293
  if label:
230
294
  moved_head_instruction = (
@@ -289,6 +353,32 @@ class PullRequestReviewer(GitHubRepository):
289
353
  f"second result.{self_review_note}"
290
354
  )
291
355
 
356
+ def _linked_issue_allows_handoff(self, pr):
357
+ """Require a successfully fetched closing issue labeled medium or high."""
358
+ numbers = {
359
+ int(number)
360
+ for number in re.findall(
361
+ r"(?im)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)",
362
+ pr.get("body") or "",
363
+ )
364
+ }
365
+ for number in numbers:
366
+ try:
367
+ issue = self.gh("GET", f"/issues/{number}")
368
+ except HTTPError as exc:
369
+ if exc.code != 404:
370
+ raise
371
+ continue
372
+ labels = {
373
+ (item.get("name") or "").casefold()
374
+ for item in issue.get("labels", [])
375
+ }
376
+ if "pull_request" not in issue and labels & {
377
+ "priority:medium", "priority:high"
378
+ }:
379
+ return True
380
+ return False
381
+
292
382
  def _finish_completed_review(self, pr, trigger, label=None, submitted_review=None):
293
383
  """Complete an exact-head review, including an optional human handoff.
294
384
 
@@ -340,18 +430,36 @@ class PullRequestReviewer(GitHubRepository):
340
430
  ]
341
431
  if not completed:
342
432
  return False
433
+ try:
434
+ self._annotate_llm_provenance(pr, completed)
435
+ except Exception as exc: # noqa: BLE001 - provenance never blocks completion
436
+ print(
437
+ json.dumps(
438
+ {
439
+ "repository": self.repository,
440
+ "pr": pr["number"],
441
+ "llm_provenance_error": f"{type(exc).__name__}: {exc}",
442
+ }
443
+ ),
444
+ flush=True,
445
+ )
343
446
  approved = None
344
447
  maintainer_decision = False
345
448
  for review in sorted(
346
449
  completed, key=lambda item: item["submitted_at"], reverse=True
347
450
  ):
348
- body = (review.get("body") or "").rstrip()
451
+ # The verdict ends the agent's text; the provenance footer the
452
+ # automation appends after it is not part of the verdict.
453
+ body = workflow._without_llm_provenance(review.get("body") or "")
349
454
  if body.endswith("✅ APPROVED"):
350
455
  approved = True
351
456
  break
352
457
  if body.endswith("🔄 CHANGES REQUESTED"):
353
458
  approved = False
354
459
  break
460
+ if self.structured_reviews and body.endswith("⏸ INCONCLUSIVE"):
461
+ approved = False
462
+ break
355
463
  if body.endswith(workflow.MAINTAINER_DECISION_VERDICT):
356
464
  maintainer_decision = True
357
465
  break
@@ -359,7 +467,7 @@ class PullRequestReviewer(GitHubRepository):
359
467
  return False
360
468
  # A scope stop is not an approval: it requests the maintainer decision
361
469
  # the change is missing, through the same handoff as an approval.
362
- if approved or maintainer_decision:
470
+ if (approved or maintainer_decision) and self._linked_issue_allows_handoff(pr):
363
471
  maintainers = parse_maintainers(self.config.get("maintainers"))
364
472
  if maintainers:
365
473
  try:
@@ -401,6 +509,110 @@ class PullRequestReviewer(GitHubRepository):
401
509
  )
402
510
  return True
403
511
 
512
+ def _annotate_llm_provenance(self, pr, reviews):
513
+ """Append the LLM profile and model to this automation's own reviews.
514
+
515
+ The footer describes the conversation that published the review, read
516
+ back from that conversation rather than from current settings, so a
517
+ reused conversation reports the LLM it actually runs. Login, head, and
518
+ time cannot tell two automations sharing a bot account apart, so a
519
+ review is annotated only when its single run marker names the
520
+ conversation this automation records for the pull request. Any other
521
+ review, including one by an overlapping run, is left as it is. A
522
+ review keeps the metadata recorded in this automation's KV namespace.
523
+ Review text alone cannot establish that a footer came from the worker.
524
+ """
525
+ reviews = [
526
+ review
527
+ for review in reviews
528
+ if workflow._REVIEW_RUN_MARKER_RE.search(review.get("body") or "")
529
+ ]
530
+ if not reviews:
531
+ return
532
+ repository_id = self.gh("GET", "")["id"]
533
+ conversation_id = self.dispatcher.subject_conversation(
534
+ f"{repository_id}:pr:{pr['number']}"
535
+ )
536
+ own = workflow._own_reviews(reviews, conversation_id)
537
+ if not own:
538
+ return
539
+ errors = []
540
+ for review in own:
541
+ original = review.get("body") or ""
542
+ has_footer = workflow._without_llm_provenance(original) != original.strip()
543
+ try:
544
+ provenance = self._recorded_review_provenance(conversation_id, review["id"])
545
+ except Exception as exc:
546
+ # Keep an unverifiable agent-written line from claiming a model.
547
+ # Finish the remaining reviews before the outer guard logs errors.
548
+ errors.append(exc)
549
+ provenance = None
550
+ if provenance is None:
551
+ if not has_footer:
552
+ continue
553
+ try:
554
+ # Fallback edits use the same atomically chosen tuple.
555
+ # A concurrent metadata collector may already have won.
556
+ provenance = self._recorded_review_provenance(
557
+ conversation_id, review["id"], unavailable=True
558
+ )
559
+ except Exception as exc:
560
+ errors.append(exc)
561
+ continue
562
+ body = workflow._with_llm_provenance(original, *provenance)
563
+ if body != review.get("body"):
564
+ try:
565
+ self.gh(
566
+ "PUT",
567
+ f"/pulls/{pr['number']}/reviews/{review['id']}",
568
+ {"body": body},
569
+ )
570
+ except Exception as exc:
571
+ errors.append(exc)
572
+ if errors:
573
+ raise errors[0]
574
+
575
+ def _recorded_review_provenance(self, conversation_id, review_id, *, unavailable=False):
576
+ """Record server metadata or an explicit unknown fallback outside agent text."""
577
+ key = f"review-provenance-{conversation_id}-{review_id}"
578
+
579
+ def recorded(value):
580
+ if (isinstance(value, dict)
581
+ and value.get("conversation_id") == conversation_id
582
+ and all(isinstance(value.get(field), str) and value[field]
583
+ for field in ("profile", "model"))):
584
+ return value["profile"], value["model"]
585
+ return None
586
+
587
+ try:
588
+ saved = recorded(agent_conversation._kv_request(key, "GET"))
589
+ if saved is not None:
590
+ return saved
591
+ provenance = (
592
+ ("unknown", "unknown") if unavailable
593
+ else self.dispatcher.llm_provenance(conversation_id)
594
+ )
595
+ if provenance is None:
596
+ # Another collector may have recorded it during the lookup.
597
+ return recorded(agent_conversation._kv_request(key, "GET"))
598
+ value = {"conversation_id": conversation_id,
599
+ "profile": provenance[0], "model": provenance[1]}
600
+ # Every edit, including an unknown fallback, uses the first tuple.
601
+ agent_conversation._kv_request(key + "?nx=true", "PUT", value)
602
+ except Exception as exc:
603
+ # A lookup can fail after another collector records the tuple, or
604
+ # a write can commit before its response is lost. Reconcile both.
605
+ try:
606
+ saved = recorded(agent_conversation._kv_request(key, "GET"))
607
+ except Exception:
608
+ raise exc
609
+ if saved is not None:
610
+ return saved
611
+ if isinstance(exc, HTTPError) and exc.code == 409:
612
+ raise ValueError("Stored review provenance does not match its conversation") from exc
613
+ raise
614
+ return provenance
615
+
404
616
  @staticmethod
405
617
  def _check_app_identity(run):
406
618
  """The app that reported a check run, as a stable grouping key."""
@@ -890,13 +1102,68 @@ class PullRequestReviewer(GitHubRepository):
890
1102
  candidates.append(pr)
891
1103
  return candidates
892
1104
 
1105
+ @property
1106
+ def _scan_cursor(self):
1107
+ """This repository's scan position, created lazily like the intake."""
1108
+ cursor = self.__dict__.get("_cursor")
1109
+ if cursor is None:
1110
+ cursor = self.__dict__["_cursor"] = ScanCursor(self.repository)
1111
+ return cursor
1112
+
1113
+ def _explicit_candidate(self, pr, label):
1114
+ """Whether a PR was explicitly requested by a caller.
1115
+
1116
+ An explicit `all-hands-bot` review request or a trigger label is a
1117
+ caller's decision, so it is never subject to the rotating window: every
1118
+ explicit candidate is examined on every scan, whatever the stored scan
1119
+ position is.
1120
+ """
1121
+ if label in {item["name"] for item in pr.get("labels", [])}:
1122
+ return True
1123
+ return self._outstanding_review_request(pr)
1124
+
1125
+ def _rotating_window(self, prs, label):
1126
+ """The explicit candidates plus a bounded slice of the unrequested ones.
1127
+
1128
+ Classifying an unrequested head costs a review read and the exact-head
1129
+ check/workflow reads, so examining every open PR in one scan is what let
1130
+ a single run exhaust the API budget and post a managed gate comment for
1131
+ every red or pending head. The unrequested backlog is therefore examined
1132
+ a bounded `SCAN_WINDOW` at a time, starting where the previous scan
1133
+ stopped (the per-repository position in the Automation KV store), so
1134
+ successive scans rotate through the whole backlog. Explicit candidates
1135
+ are always included, so a request is never delayed behind the window.
1136
+ """
1137
+ explicit, unrequested = [], []
1138
+ for pr in prs:
1139
+ if pr.get("draft") and label not in {
1140
+ item["name"] for item in pr.get("labels", [])
1141
+ } and not self._outstanding_review_request(pr):
1142
+ # A draft that is neither labeled nor requested is not reviewable
1143
+ # by the unrequested path either, so drop it before the full read.
1144
+ continue
1145
+ if self._explicit_candidate(pr, label):
1146
+ explicit.append(pr)
1147
+ else:
1148
+ unrequested.append(pr)
1149
+ if not unrequested:
1150
+ return explicit
1151
+ start = self._scan_cursor.position(len(unrequested))
1152
+ window = unrequested[start : start + SCAN_WINDOW]
1153
+ self._scan_cursor.advance(start + len(window))
1154
+ return explicit + window
1155
+
893
1156
  def run(self):
894
1157
  repository_id = self.gh("GET", "")["id"]
895
1158
  label = self.config.get("trigger_label", workflow.TRIGGER_LABEL)
1159
+ if self.structured_reviews:
1160
+ for pending in self.gh_pages("/pulls?state=open"):
1161
+ self.structured_reviews.advance(pending)
896
1162
  payload = self._event_payload()
897
1163
  event_mode = payload is not None
898
1164
  if not event_mode:
899
- prs = self._scan_candidates(
1165
+ selector = self._rotating_window if self.config.get("scan_window", False) else self._scan_candidates
1166
+ prs = selector(
900
1167
  self.gh_pages("/pulls?state=open&sort=updated&direction=asc"), label
901
1168
  )
902
1169
  else:
@@ -945,7 +1212,7 @@ class PullRequestReviewer(GitHubRepository):
945
1212
  # conversation or review is created.
946
1213
  if pr.get("draft"):
947
1214
  continue
948
- if self._has_current_head_review(pr["number"], pr["head"]["sha"]):
1215
+ if not self.structured_reviews and self._has_current_head_review(pr["number"], pr["head"]["sha"]):
949
1216
  self._finish_completed_review(pr, None)
950
1217
  continue
951
1218
  trigger = None
@@ -967,7 +1234,7 @@ class PullRequestReviewer(GitHubRepository):
967
1234
  already = self._reviewed_current_head_without_clarification(
968
1235
  pr["number"], pr["head"]["sha"]
969
1236
  )
970
- if already:
1237
+ if already and not self.structured_reviews:
971
1238
  print(
972
1239
  json.dumps(
973
1240
  {
@@ -997,7 +1264,7 @@ class PullRequestReviewer(GitHubRepository):
997
1264
  f"{quote(trigger_label, safe='')}",
998
1265
  )
999
1266
  continue
1000
- if delivery_key is None and self._finish_completed_review(
1267
+ if not self.structured_reviews and delivery_key is None and self._finish_completed_review(
1001
1268
  pr, trigger, trigger_label, submitted_review
1002
1269
  ):
1003
1270
  continue
@@ -1085,13 +1352,35 @@ class PullRequestReviewer(GitHubRepository):
1085
1352
  + ", ".join(f"#{number}" for number in failures)
1086
1353
  )
1087
1354
 
1355
+ def latest_label_event(self, number):
1356
+ return workflow._latest_trigger_label_event(self.token, self.repository, number)
1357
+
1088
1358
  def _start_review(
1089
1359
  self, repository_id, pr, trigger, sha, trigger_label, delivery_key=None
1090
1360
  ):
1361
+ if self.structured_reviews:
1362
+ latest = self._latest_reviewer_request(pr["number"])
1363
+ generation = f"request:{latest['id']}" if latest else "initial"
1364
+ label_event = self.latest_label_event(pr["number"])
1365
+ if label_event:
1366
+ generation += f":label:{label_event['id']}"
1367
+ comments = self.gh_pages(f"/issues/{pr['number']}/comments")
1368
+ human = [item for item in comments if (item.get("user") or {}).get("type") != "Bot"
1369
+ and (item.get("user") or {}).get("login", "").lower() != self.github_login.lower()]
1370
+ if human:
1371
+ generation += f":clarification:{max(int(item['id']) for item in human)}"
1372
+ return self.structured_reviews.advance(
1373
+ pr, generation, start=True,
1374
+ explicit=bool(trigger), label=trigger_label, trigger=trigger,
1375
+ )
1091
1376
  result = self.dispatcher.deliver(
1092
1377
  subject=f"{repository_id}:pr:{pr['number']}",
1093
1378
  delivery=delivery_key or f"{trigger['id']}:{sha}",
1094
- prompt=self._prompt(pr, trigger, trigger_label, delivery_key),
1379
+ # The dispatcher names the conversation it delivers to, so the
1380
+ # prompt can carry that conversation's review marker.
1381
+ prompt=lambda conversation_id: self._prompt(
1382
+ pr, trigger, trigger_label, delivery_key, conversation_id
1383
+ ),
1095
1384
  head=sha,
1096
1385
  )
1097
1386
  print(
@@ -255,7 +255,7 @@ class TestState(_CheckoutTestCase):
255
255
  # ── Review verification ────────────────────────────────────────────────────────
256
256
 
257
257
 
258
- class TestMatchingReviewExists(unittest.TestCase):
258
+ class TestPublishedReviews(unittest.TestCase):
259
259
  def setUp(self):
260
260
  self._login = main._AUTH_LOGIN
261
261
  main._AUTH_LOGIN = "review-bot"
@@ -265,10 +265,10 @@ class TestMatchingReviewExists(unittest.TestCase):
265
265
 
266
266
  def _exists(self, reviews):
267
267
  with patch.object(main, "_github_paginate", return_value=reviews):
268
- return main._matching_review_exists("token", "owner/repo", 7, "abc123")
268
+ return bool(main._published_reviews("token", "owner/repo", 7, "abc123"))
269
269
 
270
270
  def test_true_for_our_review_at_this_commit(self):
271
- self.assertTrue(self._exists([{"user": {"login": "Review-Bot"}, "commit_id": "abc123"}]))
271
+ self.assertTrue(self._exists([{"user": {"login": "Review-Bot"}, "commit_id": "abc123", "state": "COMMENTED"}]))
272
272
 
273
273
  def test_false_for_someone_elses_review(self):
274
274
  self.assertFalse(self._exists([{"user": {"login": "human"}, "commit_id": "abc123"}]))
@@ -276,9 +276,10 @@ class TestMatchingReviewExists(unittest.TestCase):
276
276
  def test_false_for_our_review_at_another_commit(self):
277
277
  self.assertFalse(self._exists([{"user": {"login": "review-bot"}, "commit_id": "older"}]))
278
278
 
279
- def test_false_when_the_listing_fails(self):
279
+ def test_listing_failure_is_reported_to_the_caller(self):
280
280
  with patch.object(main, "_github_paginate", side_effect=RuntimeError("boom")):
281
- self.assertFalse(main._matching_review_exists("token", "owner/repo", 7, "abc123"))
281
+ with self.assertRaises(RuntimeError):
282
+ main._published_reviews("token", "owner/repo", 7, "abc123")
282
283
 
283
284
 
284
285
  # ── Claiming a label event before the review starts ────────────────────────────
@@ -304,6 +305,11 @@ class TestClaimBeforeReview(_CheckoutTestCase):
304
305
  create = create or (lambda *a, **k: "conv-1")
305
306
  with (
306
307
  patch.object(main, "_prepare_repository", side_effect=prepare),
308
+ patch.object(
309
+ main,
310
+ "_get_agent_dict",
311
+ return_value={"kind": "Agent", "llm": {"model": "review-model"}},
312
+ ),
307
313
  patch.object(main, "create_conversation", side_effect=create),
308
314
  patch.object(main, "_post_github_comment"),
309
315
  ):
@@ -332,6 +338,8 @@ class TestClaimBeforeReview(_CheckoutTestCase):
332
338
  rec = self.reviews[main._review_key(7, 4242)]
333
339
  self.assertEqual(rec["status"], "active")
334
340
  self.assertEqual(rec["conversation_id"], "conv-1")
341
+ self.assertEqual(rec["llm_profile"], "default")
342
+ self.assertEqual(rec["llm_model"], "review-model")
335
343
  self.assertEqual(self.snapshots[-1][main._review_key(7, 4242)]["status"], "active")
336
344
 
337
345
  def test_a_failed_start_releases_the_claim_so_the_next_poll_retries(self):
@@ -521,6 +529,9 @@ class TestRepoReviewGuide(unittest.TestCase):
521
529
  self.assertIn("AGENTS.md", prompt)
522
530
  self.assertIn("CONTRIBUTING.md", prompt)
523
531
  self.assertIn("nested `AGENTS.md`", prompt)
532
+ self.assertIn("supported dependency version", prompt)
533
+ self.assertIn("custom-codereview-guide.md", prompt)
534
+ self.assertIn("Never end with APPROVED", prompt)
524
535
 
525
536
  def test_prompt_uses_native_approval_for_a_clean_review(self):
526
537
  prompt = main._build_review_prompt(
@@ -631,6 +642,63 @@ class TestRepoReviewGuide(unittest.TestCase):
631
642
  self.assertIn("move repositories, close, or receive a maintainer decision", prompt)
632
643
 
633
644
 
645
+ class TestLlmProvenance(unittest.TestCase):
646
+ def test_review_prompt_names_its_conversation_marker(self):
647
+ prompt = main._build_review_prompt(
648
+ "owner/repo",
649
+ TestRepoReviewGuide()._pr(),
650
+ "0123456789abcdef",
651
+ {"id": "1", "created_at": "t"},
652
+ workspace_instructions="Check out the exact PR head before reviewing.",
653
+ github_token_secret="FACTORY_GITHUB_REVIEWER_TOKEN",
654
+ conversation_id="conv-1",
655
+ )
656
+
657
+ # The agent copies the marker; the script, not the agent, appends the
658
+ # provenance footer to the review that carries it.
659
+ self.assertIn("`<!-- openhands-review-run: conv-1 -->`", prompt)
660
+ self.assertIn("do not write that footer yourself", prompt)
661
+ self.assertNotIn("LLM profile:", prompt)
662
+ self.assertIn("Check out the exact PR head before reviewing.", prompt)
663
+ self.assertIn("FACTORY_GITHUB_REVIEWER_TOKEN", prompt)
664
+ self.assertNotIn("The workspace is already the repository root", prompt)
665
+
666
+ def test_fallback_comment_includes_llm_provenance(self):
667
+ rec = {
668
+ "conversation_id": "conv-1",
669
+ "pr_number": 42,
670
+ "head_sha": "0123456789abcdef",
671
+ "last_activity": 0.0,
672
+ "llm_profile": "review-profile",
673
+ "llm_model": "anthropic/claude-sonnet-4-6",
674
+ }
675
+ current_pr = {42: {"head": {"sha": "0123456789abcdef"}}}
676
+
677
+ with (
678
+ patch.object(main.time, "time", return_value=100.0),
679
+ patch.object(main, "conversation_status", return_value="finished"),
680
+ patch.object(main, "conversation_final_response", return_value="Review body"),
681
+ patch.object(main, "_published_reviews", return_value=[]),
682
+ patch.object(main, "_post_github_comment") as post_comment,
683
+ ):
684
+ main._check_conversation_completion(
685
+ rec,
686
+ current_pr,
687
+ "token",
688
+ "http://agent",
689
+ "key",
690
+ "owner/repo",
691
+ )
692
+
693
+ body = post_comment.call_args.args[3]
694
+ self.assertIn("Review body", body)
695
+ self.assertIn(
696
+ "LLM profile: `review-profile` · Model: `anthropic/claude-sonnet-4-6`",
697
+ body,
698
+ )
699
+
700
+
701
+
634
702
  class TestNormalizeRepo(unittest.TestCase):
635
703
  """A repository is written down in more than one way, and every API path in
636
704
  this script is built from owner/repo."""
@@ -79,7 +79,7 @@ def _state_path(repo):
79
79
  def _kv_request(key, method, value=None):
80
80
  if not KV_TOKEN or not KV_BASE:
81
81
  return None
82
- data = json.dumps({"value": value}).encode() if value is not None else None
82
+ data = json.dumps(value).encode() if value is not None else None
83
83
  request = Request(
84
84
  f"{KV_BASE}/v1/kv/{quote(key, safe='')}",
85
85
  data=data,
@@ -101,7 +101,13 @@ def _kv_request(key, method, value=None):
101
101
 
102
102
  def load_state(repo):
103
103
  if KV_TOKEN and KV_BASE:
104
- return _kv_request(_state_key(repo), "GET") or {"version": 1, "prs": {}}
104
+ state = _kv_request(_state_key(repo), "GET") or {"version": 1, "prs": {}}
105
+ if "prs" not in state and "value" in state:
106
+ # Earlier versions saved {"value": state}, and the store keeps the
107
+ # whole body, so each save nested the last one a level deeper.
108
+ state = state["value"]
109
+ state.pop("value", None)
110
+ return state
105
111
  path = _state_path(repo)
106
112
  if not path.exists():
107
113
  return {"version": 1, "prs": {}}
@@ -62,8 +62,11 @@ OpenHands API in a sandbox of its own, which changes three things:
62
62
  - A conversation that ends without a merge request is reported on the issue. The
63
63
  script cannot finish the work, because it has no copy of it.
64
64
 
65
- A user with no `GITLAB_TOKEN` secret is served the token of their connected
66
- GitLab integration instead.
65
+ A GitLab token is used whenever there is one: a `GITLAB_TOKEN` secret, or else the
66
+ token of the user's GitLab sign-in or connected integration. Without a token, a
67
+ connected GitLab MCP server is used directly, by the script and by the
68
+ conversation. Calling GitLab MCP directly needs GitLab 19.4 or later, because the
69
+ tools it uses arrived in 19.3 and 19.4, while a token works with any GitLab.
67
70
 
68
71
  ## Two setup paths
69
72
 
@@ -76,7 +79,11 @@ tarball the automation service runs on a cron, not a prompt handed to an agent.
76
79
 
77
80
  ## Prerequisites
78
81
 
79
- Set `GITLAB_TOKEN` in OpenHands Settings -> Secrets. It needs the `api` scope and
82
+ On OpenHands Cloud and Enterprise, connect GitLab under Settings - its MCP server
83
+ or the built-in integration, which signing in with GitLab connects - and no secret
84
+ is needed. Calling GitLab MCP directly needs GitLab 19.4 or later, so on an older
85
+ GitLab use the integration. Otherwise set
86
+ `GITLAB_TOKEN` in OpenHands Settings -> Secrets. It needs the `api` scope and
80
87
  at least the **Developer** role on every watched project - Developer is the
81
88
  lowest role that can push a branch and open a merge request. Leave the configured
82
89
  branch prefix out of any protected-branch rule, or the push is rejected.