@openhands/extensions 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/custom-codereview-guide.md +47 -5
- package/.claude-plugin/marketplace.json +20 -5
- package/.codex-plugin/marketplace.json +20 -5
- package/.github/workflows/build-vscode-app.yml +3 -3
- package/.github/workflows/check-extensions.yml +1 -1
- package/.github/workflows/pr-artifacts.yml +4 -83
- package/.github/workflows/tests.yml +14 -1
- package/.plugin/marketplace.json +20 -5
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +4 -3
- package/README.md +6 -5
- package/apps/vscode/README.md +8 -6
- package/apps/vscode/canvas-extension.json +11 -3
- package/apps/vscode/extension.js +60 -12
- package/apps/vscode/scripts/package.mjs +20 -74
- package/automations/bundle-index.js +10 -8
- package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
- package/automations/catalog/github-issue-triage/manifest.json +4 -3
- package/automations/catalog/github-pr-reviewer/manifest.json +7 -6
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
- package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
- package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
- package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
- package/integrations/catalog/atlassian-rovo.json +4 -3
- package/integrations/catalog/bitbucket.json +16 -1
- package/integrations/catalog/github.json +1 -0
- package/integrations/catalog/gitlab.json +1 -1
- package/marketplaces/openhands-extensions.json +20 -5
- package/package.json +1 -1
- package/plugins/pr-review/README.md +1 -0
- package/plugins/pr-review/scripts/prompt.py +44 -11
- package/plugins/runtime-api-configs/SKILL.md +1 -1
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/canvas-extension-api/references/backend-safety.md +3 -3
- package/skills/canvas-extension-api/references/v1-contract.md +7 -4
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
- package/skills/github/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-triage/README.md +73 -14
- package/skills/github-issue-triage/SKILL.md +26 -46
- package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
- package/skills/github-issue-triage/scripts/worker.py +153 -103
- package/skills/github-pr-reviewer/README.md +109 -1
- package/skills/github-pr-reviewer/SKILL.md +42 -8
- package/skills/github-pr-reviewer/references/state-schema.md +8 -0
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
- package/skills/github-pr-reviewer/scripts/main.py +163 -39
- package/skills/github-pr-reviewer/scripts/publication.py +193 -0
- package/skills/github-pr-reviewer/scripts/worker.py +297 -8
- package/skills/github-pr-reviewer/tests/test_main.py +73 -5
- package/skills/github-stale-ci-pr-closer/scripts/worker.py +8 -2
- package/skills/gitlab-issue-to-mr/README.md +10 -3
- package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
- package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
- package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
- package/skills/index.js +18 -8
- package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/README.md +18 -11
- package/skills/jira-issue-to-pr/SKILL.md +72 -23
- package/skills/jira-issue-to-pr/references/setup.md +43 -12
- package/skills/jira-issue-to-pr/scripts/main.py +136 -26
- package/skills/slack-channel-monitor/README.md +12 -3
- package/skills/slack-channel-monitor/SKILL.md +35 -20
- package/skills/slack-channel-monitor/references/windows.md +6 -2
- package/skills/slack-channel-monitor/scripts/main.py +189 -12
- package/skills/software-factory/.claude-plugin/plugin.json +19 -0
- package/skills/software-factory/.codex-plugin/plugin.json +19 -0
- package/skills/software-factory/.plugin/plugin.json +19 -0
- package/skills/software-factory/README.md +64 -0
- package/skills/software-factory/SKILL.md +229 -0
- package/skills/software-factory/commands/software-factory.md +8 -0
- package/skills/software-factory/scripts/factory.py +630 -0
- package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/SKILL.md +5 -3
- package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/test_agent_conversation_dispatch.py +173 -6
- package/tests/test_automation_llm_provenance.py +1209 -0
- package/tests/test_github_review_publication.py +422 -0
- package/tests/test_github_reviewer_delivery.py +174 -21
- package/tests/test_github_stale_ci_pr_closer.py +52 -1
- package/tests/test_github_triage_delivery.py +411 -238
- package/tests/test_gitlab_issue_to_mr.py +284 -0
- package/tests/test_jira_issue_to_pr.py +200 -6
- package/tests/test_pr_review_prompt_injection.py +353 -0
- package/tests/test_slack_channel_monitor.py +32 -1
- package/tests/test_software_factory_script.py +231 -0
- package/tests/test_vscode_app.py +8 -2
- package/tests/vscode-app.test.mjs +8 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
|
66
|
-
GitLab integration
|
|
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
|
-
|
|
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.
|