@openhands/extensions 0.22.1 → 0.23.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 +84 -17
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +22 -1
- package/README.md +1 -1
- package/automations/bundle-index.js +12 -11
- package/automations/catalog/github-issue-triage/manifest.json +2 -2
- package/automations/catalog/github-pr-reviewer/manifest.json +42 -5
- package/automations/catalog.schema.json +0 -4
- package/package.json +1 -1
- package/plugins/openhands/skills/openhands-sdk/SKILL.md +3 -1
- package/plugins/pr-review/README.md +2 -1
- package/plugins/pr-review/scripts/agent_script.py +198 -81
- package/plugins/pr-review/scripts/prompt.py +35 -11
- package/plugins/pr-review/skills/code-review/SKILL.md +165 -200
- package/plugins/pr-review/skills/code-review/commands/codereview-roasted.md +1 -1
- package/plugins/pr-review/skills/code-review/commands/codereview.md +1 -1
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +8 -3
- package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +4 -0
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/code-review/SKILL.md +165 -200
- package/skills/code-review/commands/codereview-roasted.md +1 -1
- package/skills/code-review/commands/codereview.md +1 -1
- package/skills/github/scripts/agent_conversation.py +8 -3
- package/skills/github/scripts/github_client.py +4 -0
- package/skills/github-agents-md-maintainer/scripts/github_client.py +4 -0
- package/skills/github-delivery-watchdog/scripts/github_client.py +4 -0
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +8 -3
- package/skills/github-issue-to-pr/scripts/github_client.py +4 -0
- package/skills/github-issue-triage/.claude-plugin/plugin.json +1 -1
- package/skills/github-issue-triage/.codex-plugin/plugin.json +1 -1
- package/skills/github-issue-triage/.plugin/plugin.json +1 -1
- package/skills/github-issue-triage/README.md +7 -0
- package/skills/github-issue-triage/SKILL.md +26 -3
- package/skills/github-issue-triage/scripts/agent_conversation.py +8 -3
- package/skills/github-issue-triage/scripts/github_client.py +4 -0
- package/skills/github-issue-triage/scripts/worker.py +142 -26
- package/skills/github-pr-reviewer/README.md +16 -10
- package/skills/github-pr-reviewer/SKILL.md +12 -9
- package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +8 -3
- package/skills/github-pr-reviewer/scripts/github_client.py +4 -0
- package/skills/github-pr-reviewer/scripts/main.py +23 -5
- package/skills/github-pr-reviewer/scripts/maintainer_handoff.py +151 -0
- package/skills/github-pr-reviewer/scripts/worker.py +209 -21
- package/skills/github-pr-reviewer/tests/test_main.py +16 -0
- package/skills/github-repo-monitor/scripts/github_client.py +4 -0
- package/skills/index.js +6 -6
- package/skills/openhands-sdk/SKILL.md +3 -1
- package/tests/fixtures/automations/github-pr-reviewer.json +120 -26
- package/tests/github_automation_helpers.py +1 -0
- package/tests/test_agent_conversation_dispatch.py +19 -4
- package/tests/test_automation_setup.py +15 -0
- package/tests/test_code_review_risk_evaluation.py +2 -2
- package/tests/test_github_reviewer_delivery.py +320 -4
- package/tests/test_github_triage_delivery.py +200 -6
- package/tests/test_maintainer_handoff.py +198 -0
- package/tests/test_pr_review_prompt.py +6 -2
- package/tests/test_pr_review_review_context.py +144 -0
|
@@ -1,19 +1,82 @@
|
|
|
1
|
-
"""Select
|
|
1
|
+
"""Select requested PR heads and delegate each review to a sandboxed agent."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from urllib.parse import quote
|
|
4
8
|
|
|
5
9
|
import main as workflow
|
|
6
10
|
from agent_conversation import AgentConversationDispatcher
|
|
7
11
|
from github_client import GitHubRepository, run_repositories
|
|
12
|
+
from maintainer_handoff import (
|
|
13
|
+
HandoffConfigurationError,
|
|
14
|
+
parse_maintainers,
|
|
15
|
+
request_maintainer_review,
|
|
16
|
+
)
|
|
8
17
|
|
|
9
18
|
|
|
10
19
|
class PullRequestReviewer(GitHubRepository):
|
|
11
20
|
name = "github-pr-reviewer"
|
|
12
21
|
|
|
13
|
-
|
|
22
|
+
@cached_property
|
|
23
|
+
def github_login(self):
|
|
24
|
+
return self.api("GET", "/user")["login"]
|
|
25
|
+
|
|
26
|
+
@cached_property
|
|
27
|
+
def trigger_reviewer(self):
|
|
28
|
+
return self.config.get("trigger_reviewer", "all-hands-bot").lower()
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def _event_payload():
|
|
32
|
+
"""Return the GitHub webhook payload, or None for a scheduled run."""
|
|
33
|
+
raw = os.environ.get("AUTOMATION_EVENT_PAYLOAD")
|
|
34
|
+
if not raw:
|
|
35
|
+
return None
|
|
36
|
+
outer = json.loads(raw)
|
|
37
|
+
payload = (outer.get("event") or {}).get("payload")
|
|
38
|
+
return payload if isinstance(payload, dict) else None
|
|
39
|
+
|
|
40
|
+
def _latest_reviewer_request(self, number):
|
|
41
|
+
matching = [
|
|
42
|
+
event
|
|
43
|
+
for event in self.gh_pages(f"/issues/{number}/events")
|
|
44
|
+
if event.get("event") == "review_requested"
|
|
45
|
+
and ((event.get("requested_reviewer") or {}).get("login") or "").lower()
|
|
46
|
+
== self.trigger_reviewer
|
|
47
|
+
and event.get("id") is not None
|
|
48
|
+
]
|
|
49
|
+
if not matching:
|
|
50
|
+
return None
|
|
51
|
+
return max(
|
|
52
|
+
matching,
|
|
53
|
+
key=lambda event: (
|
|
54
|
+
event.get("created_at") or "",
|
|
55
|
+
int(event.get("id") or 0),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def _event_candidate(self, payload):
|
|
60
|
+
repository = ((payload.get("repository") or {}).get("full_name") or "")
|
|
61
|
+
if repository.lower() != self.repository.lower():
|
|
62
|
+
return None
|
|
63
|
+
pr = payload.get("pull_request")
|
|
64
|
+
if not isinstance(pr, dict) or pr.get("number") is None:
|
|
65
|
+
return None
|
|
66
|
+
action = payload.get("action")
|
|
67
|
+
if action == "review_requested":
|
|
68
|
+
requested = (
|
|
69
|
+
(payload.get("requested_reviewer") or {}).get("login") or ""
|
|
70
|
+
).lower()
|
|
71
|
+
return pr if requested == self.trigger_reviewer else None
|
|
72
|
+
if action == "submitted":
|
|
73
|
+
author = ((payload.get("review") or {}).get("user") or {}).get("login")
|
|
74
|
+
return pr if (author or "").lower() == self.trigger_reviewer else None
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def _prompt(self, pr, trigger, label=None):
|
|
14
78
|
number = pr["number"]
|
|
15
79
|
sha = pr["head"]["sha"]
|
|
16
|
-
label = self.config.get("trigger_label", workflow.TRIGGER_LABEL)
|
|
17
80
|
token = self.token_name
|
|
18
81
|
workspace = (
|
|
19
82
|
"The workspace contains an empty Git repository. Before fetching, run "
|
|
@@ -28,9 +91,31 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
28
91
|
self.repository,
|
|
29
92
|
pr,
|
|
30
93
|
sha,
|
|
31
|
-
|
|
94
|
+
trigger,
|
|
32
95
|
workspace_instructions=workspace,
|
|
33
96
|
github_token_secret=token,
|
|
97
|
+
trigger_description=(
|
|
98
|
+
None
|
|
99
|
+
if label
|
|
100
|
+
else (
|
|
101
|
+
f"latest review request for "
|
|
102
|
+
f"`{self.trigger_reviewer}` "
|
|
103
|
+
f"event {trigger.get('id', '?')} at "
|
|
104
|
+
f"{trigger.get('created_at', '?')}"
|
|
105
|
+
)
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
moved_head_instruction = (
|
|
109
|
+
f"leave `{label}` in place so the new head is reviewed"
|
|
110
|
+
if label
|
|
111
|
+
else "publish no review; the new head requires another reviewer request"
|
|
112
|
+
)
|
|
113
|
+
trigger_completion = (
|
|
114
|
+
f"Leave the `{label}` label in place after GitHub accepts the review. "
|
|
115
|
+
"The deterministic scanner removes it"
|
|
116
|
+
if label
|
|
117
|
+
else "Do not change review requests after GitHub accepts the review. "
|
|
118
|
+
"The deterministic event handler completes the request"
|
|
34
119
|
)
|
|
35
120
|
return (
|
|
36
121
|
prompt + "\n\nAcceptance reporting:\n"
|
|
@@ -38,36 +123,131 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
38
123
|
"the repository's appropriate focused tests in the workspace. Do not "
|
|
39
124
|
"modify tracked files.\n"
|
|
40
125
|
f"- Re-read {self.repository} PR #{number} immediately before reporting. "
|
|
41
|
-
f"If its head is no longer `{sha}`,
|
|
42
|
-
"
|
|
43
|
-
"-
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
f"- After both statuses are visible on that head, remove the `{label}` "
|
|
49
|
-
"label. Never paste JSON artifacts or full command logs into comments."
|
|
126
|
+
f"If its head is no longer `{sha}`, {moved_head_instruction}.\n"
|
|
127
|
+
f"- {trigger_completion} after completing any configured "
|
|
128
|
+
"human-review handoff. Never paste JSON artifacts or full command logs "
|
|
129
|
+
"into comments.\n"
|
|
130
|
+
"- Once GitHub accepts the native review, stop immediately. Do not "
|
|
131
|
+
"continue inspecting the repository, run more commands, or publish a "
|
|
132
|
+
"second result."
|
|
50
133
|
)
|
|
51
134
|
|
|
135
|
+
def _finish_completed_review(self, pr, trigger, label=None):
|
|
136
|
+
"""Complete an exact-head review, including an optional human handoff."""
|
|
137
|
+
head_sha = pr["head"]["sha"]
|
|
138
|
+
triggered_at = trigger.get("created_at") or ""
|
|
139
|
+
reviews = self.gh_pages(f"/pulls/{pr['number']}/reviews")
|
|
140
|
+
completed = [
|
|
141
|
+
review
|
|
142
|
+
for review in reviews
|
|
143
|
+
if review.get("commit_id") == head_sha
|
|
144
|
+
and ((review.get("user") or {}).get("login") or "").lower()
|
|
145
|
+
== self.github_login.lower()
|
|
146
|
+
and (review.get("submitted_at") or "") > triggered_at
|
|
147
|
+
]
|
|
148
|
+
if not completed:
|
|
149
|
+
return False
|
|
150
|
+
approved = None
|
|
151
|
+
for review in sorted(
|
|
152
|
+
completed, key=lambda item: item["submitted_at"], reverse=True
|
|
153
|
+
):
|
|
154
|
+
body = (review.get("body") or "").rstrip()
|
|
155
|
+
if body.endswith("✅ APPROVED"):
|
|
156
|
+
approved = True
|
|
157
|
+
break
|
|
158
|
+
if body.endswith("🔄 CHANGES REQUESTED"):
|
|
159
|
+
approved = False
|
|
160
|
+
break
|
|
161
|
+
if approved is None:
|
|
162
|
+
return False
|
|
163
|
+
if approved:
|
|
164
|
+
maintainers = parse_maintainers(self.config.get("maintainers"))
|
|
165
|
+
if maintainers:
|
|
166
|
+
try:
|
|
167
|
+
selected = request_maintainer_review(self, pr, maintainers)
|
|
168
|
+
except HandoffConfigurationError as exc:
|
|
169
|
+
self.gh(
|
|
170
|
+
"POST",
|
|
171
|
+
f"/issues/{pr['number']}/comments",
|
|
172
|
+
{
|
|
173
|
+
"body": (
|
|
174
|
+
"⚠️ **Automated maintainer handoff could not be "
|
|
175
|
+
f"completed:** {exc}. Update the automation's "
|
|
176
|
+
"maintainer roster, then request another review."
|
|
177
|
+
"\n\n_This is an automated configuration check; "
|
|
178
|
+
"no AI was used to generate this comment._"
|
|
179
|
+
)
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
selected = None
|
|
183
|
+
print(
|
|
184
|
+
json.dumps(
|
|
185
|
+
{
|
|
186
|
+
"repository": self.repository,
|
|
187
|
+
"pr": pr["number"],
|
|
188
|
+
"human_reviewer": selected,
|
|
189
|
+
}
|
|
190
|
+
),
|
|
191
|
+
flush=True,
|
|
192
|
+
)
|
|
193
|
+
current = self.gh("GET", f"/pulls/{pr['number']}")
|
|
194
|
+
if current["head"]["sha"] != pr["head"]["sha"]:
|
|
195
|
+
# Treat this scan as handled so it cannot redeliver the stale head.
|
|
196
|
+
# Scheduled mode leaves its label in place; event mode requires a
|
|
197
|
+
# fresh review request for the new head.
|
|
198
|
+
return True
|
|
199
|
+
if label:
|
|
200
|
+
self.gh(
|
|
201
|
+
"DELETE", f"/issues/{pr['number']}/labels/{quote(label, safe='')}"
|
|
202
|
+
)
|
|
203
|
+
return True
|
|
204
|
+
|
|
52
205
|
def run(self):
|
|
53
206
|
repository_id = self.gh("GET", "")["id"]
|
|
54
207
|
label = self.config.get("trigger_label", workflow.TRIGGER_LABEL)
|
|
55
|
-
|
|
208
|
+
payload = self._event_payload()
|
|
209
|
+
if payload is None:
|
|
210
|
+
prs = self.gh_pages("/pulls?state=open&sort=updated&direction=asc")
|
|
211
|
+
else:
|
|
212
|
+
candidate = self._event_candidate(payload)
|
|
213
|
+
if candidate and self.github_login.lower() != self.trigger_reviewer:
|
|
214
|
+
raise RuntimeError(
|
|
215
|
+
"The configured GitHub credential must authenticate as "
|
|
216
|
+
f"{self.trigger_reviewer} for reviewer-request mode"
|
|
217
|
+
)
|
|
218
|
+
prs = [candidate] if candidate else []
|
|
219
|
+
failures = []
|
|
56
220
|
for candidate in prs:
|
|
57
|
-
|
|
221
|
+
event_mode = payload is not None
|
|
222
|
+
if not event_mode and label not in {
|
|
223
|
+
item["name"] for item in candidate.get("labels", [])
|
|
224
|
+
}:
|
|
58
225
|
continue
|
|
59
226
|
try:
|
|
60
227
|
pr = self.gh("GET", f"/pulls/{candidate['number']}")
|
|
61
|
-
|
|
62
|
-
self.
|
|
228
|
+
trigger = (
|
|
229
|
+
self._latest_reviewer_request(pr["number"])
|
|
230
|
+
if event_mode
|
|
231
|
+
else workflow._latest_trigger_label_event(
|
|
232
|
+
self.token, self.repository, pr["number"]
|
|
233
|
+
)
|
|
63
234
|
)
|
|
64
|
-
if
|
|
235
|
+
if trigger is None:
|
|
236
|
+
continue
|
|
237
|
+
trigger_label = None if event_mode else label
|
|
238
|
+
if self._finish_completed_review(pr, trigger, trigger_label):
|
|
239
|
+
continue
|
|
240
|
+
if event_mode and payload.get("action") == "submitted":
|
|
241
|
+
# A submitted review is a completion signal, never a fresh
|
|
242
|
+
# trigger. A non-decisive review, or one superseded by a new
|
|
243
|
+
# head, must wait for another reviewer request instead of
|
|
244
|
+
# dispatching a review the caller never asked for.
|
|
65
245
|
continue
|
|
66
246
|
sha = pr["head"]["sha"]
|
|
67
247
|
result = self.dispatcher.deliver(
|
|
68
248
|
subject=f"{repository_id}:pr:{pr['number']}",
|
|
69
|
-
delivery=f"{
|
|
70
|
-
prompt=self._prompt(pr,
|
|
249
|
+
delivery=f"{trigger['id']}:{sha}",
|
|
250
|
+
prompt=self._prompt(pr, trigger, trigger_label),
|
|
71
251
|
)
|
|
72
252
|
print(
|
|
73
253
|
json.dumps(
|
|
@@ -82,11 +262,19 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
82
262
|
flush=True,
|
|
83
263
|
)
|
|
84
264
|
except Exception as exc: # noqa: BLE001 - one PR must not block the scan
|
|
265
|
+
failures.append(candidate.get("number", "?"))
|
|
85
266
|
print(
|
|
86
267
|
f"Failed to submit {self.repository} PR "
|
|
87
|
-
f"#{candidate.get('number', '?')}:
|
|
268
|
+
f"#{candidate.get('number', '?')}: "
|
|
269
|
+
f"{type(exc).__name__}: {exc}",
|
|
270
|
+
file=sys.stderr,
|
|
88
271
|
flush=True,
|
|
89
272
|
)
|
|
273
|
+
if failures:
|
|
274
|
+
raise RuntimeError(
|
|
275
|
+
"Reviewer scan failed for PRs: "
|
|
276
|
+
+ ", ".join(f"#{number}" for number in failures)
|
|
277
|
+
)
|
|
90
278
|
|
|
91
279
|
|
|
92
280
|
if __name__ == "__main__":
|
|
@@ -522,6 +522,22 @@ class TestRepoReviewGuide(unittest.TestCase):
|
|
|
522
522
|
self.assertIn("CONTRIBUTING.md", prompt)
|
|
523
523
|
self.assertIn("nested `AGENTS.md`", prompt)
|
|
524
524
|
|
|
525
|
+
def test_prompt_uses_native_approval_for_a_clean_review(self):
|
|
526
|
+
prompt = main._build_review_prompt(
|
|
527
|
+
"owner/repo",
|
|
528
|
+
self._pr(),
|
|
529
|
+
"0123456789abcdef",
|
|
530
|
+
{"id": "1", "created_at": "t"},
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
self.assertIn("`event: APPROVE`", prompt)
|
|
534
|
+
self.assertIn("otherwise use `event: COMMENT`", prompt)
|
|
535
|
+
self.assertIn("Never use `REQUEST_CHANGES`", prompt)
|
|
536
|
+
self.assertIn("native GitHub review is the only result channel", prompt)
|
|
537
|
+
self.assertIn("Do not create commit statuses or Checks", prompt)
|
|
538
|
+
self.assertIn("Do not add speculative or out-of-scope notes", prompt)
|
|
539
|
+
self.assertIn("forbids the configured bot from approving its own PR", prompt)
|
|
540
|
+
|
|
525
541
|
|
|
526
542
|
class TestNormalizeRepo(unittest.TestCase):
|
|
527
543
|
"""A repository is written down in more than one way, and every API path in
|
|
@@ -123,6 +123,10 @@ class GitHubRepository:
|
|
|
123
123
|
self.token, method, f"/repos/{self.repository}" + path, body=body
|
|
124
124
|
)[0]
|
|
125
125
|
|
|
126
|
+
def api(self, method, path, params=None, body=None):
|
|
127
|
+
"""Call a GitHub endpoint that is not scoped to one repository."""
|
|
128
|
+
return github_request(self.token, method, path, params=params, body=body)[0]
|
|
129
|
+
|
|
126
130
|
def shell(self, args, cwd=None, timeout=300):
|
|
127
131
|
result = subprocess.run(
|
|
128
132
|
args,
|