@openhands/extensions 0.22.0 → 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.
Files changed (60) hide show
  1. package/.agents/skills/custom-codereview-guide.md +84 -17
  2. package/.release-please-manifest.json +1 -1
  3. package/AGENTS.md +22 -1
  4. package/README.md +1 -1
  5. package/automations/bundle-index.js +12 -11
  6. package/automations/catalog/github-issue-to-pr/manifest.json +2 -2
  7. package/automations/catalog/github-issue-triage/manifest.json +2 -2
  8. package/automations/catalog/github-pr-reviewer/manifest.json +42 -5
  9. package/automations/catalog.schema.json +0 -4
  10. package/package.json +1 -1
  11. package/plugins/openhands/skills/openhands-sdk/SKILL.md +3 -1
  12. package/plugins/pr-review/README.md +2 -1
  13. package/plugins/pr-review/scripts/agent_script.py +198 -81
  14. package/plugins/pr-review/scripts/prompt.py +35 -11
  15. package/plugins/pr-review/skills/code-review/SKILL.md +165 -200
  16. package/plugins/pr-review/skills/code-review/commands/codereview-roasted.md +1 -1
  17. package/plugins/pr-review/skills/code-review/commands/codereview.md +1 -1
  18. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +15 -3
  19. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +4 -0
  20. package/pyproject.toml +1 -1
  21. package/python/openhands_extensions/_version.py +1 -1
  22. package/skills/code-review/SKILL.md +165 -200
  23. package/skills/code-review/commands/codereview-roasted.md +1 -1
  24. package/skills/code-review/commands/codereview.md +1 -1
  25. package/skills/github/scripts/agent_conversation.py +15 -3
  26. package/skills/github/scripts/github_client.py +4 -0
  27. package/skills/github-agents-md-maintainer/scripts/github_client.py +4 -0
  28. package/skills/github-delivery-watchdog/scripts/github_client.py +4 -0
  29. package/skills/github-issue-to-pr/scripts/agent_conversation.py +15 -3
  30. package/skills/github-issue-to-pr/scripts/github_client.py +4 -0
  31. package/skills/github-issue-triage/.claude-plugin/plugin.json +1 -1
  32. package/skills/github-issue-triage/.codex-plugin/plugin.json +1 -1
  33. package/skills/github-issue-triage/.plugin/plugin.json +1 -1
  34. package/skills/github-issue-triage/README.md +7 -0
  35. package/skills/github-issue-triage/SKILL.md +26 -3
  36. package/skills/github-issue-triage/scripts/agent_conversation.py +15 -3
  37. package/skills/github-issue-triage/scripts/github_client.py +4 -0
  38. package/skills/github-issue-triage/scripts/worker.py +142 -26
  39. package/skills/github-pr-reviewer/README.md +16 -10
  40. package/skills/github-pr-reviewer/SKILL.md +12 -9
  41. package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
  42. package/skills/github-pr-reviewer/scripts/agent_conversation.py +15 -3
  43. package/skills/github-pr-reviewer/scripts/github_client.py +4 -0
  44. package/skills/github-pr-reviewer/scripts/main.py +23 -5
  45. package/skills/github-pr-reviewer/scripts/maintainer_handoff.py +151 -0
  46. package/skills/github-pr-reviewer/scripts/worker.py +209 -21
  47. package/skills/github-pr-reviewer/tests/test_main.py +16 -0
  48. package/skills/github-repo-monitor/scripts/github_client.py +4 -0
  49. package/skills/index.js +6 -6
  50. package/skills/openhands-sdk/SKILL.md +3 -1
  51. package/tests/fixtures/automations/github-pr-reviewer.json +120 -26
  52. package/tests/github_automation_helpers.py +1 -0
  53. package/tests/test_agent_conversation_dispatch.py +35 -4
  54. package/tests/test_automation_setup.py +15 -0
  55. package/tests/test_code_review_risk_evaluation.py +2 -2
  56. package/tests/test_github_reviewer_delivery.py +320 -4
  57. package/tests/test_github_triage_delivery.py +200 -6
  58. package/tests/test_maintainer_handoff.py +198 -0
  59. package/tests/test_pr_review_prompt.py +6 -2
  60. package/tests/test_pr_review_review_context.py +144 -0
@@ -0,0 +1,151 @@
1
+ """Choose one code-aware, currently available maintainer for a reviewed PR."""
2
+
3
+ from urllib.error import HTTPError
4
+ from urllib.parse import urlencode
5
+
6
+ _DECISIVE_REVIEW_STATES = {"APPROVED", "CHANGES_REQUESTED"}
7
+ _MAX_PATHS = 8
8
+ _COMMITS_PER_PATH = 10
9
+
10
+
11
+ class HandoffConfigurationError(ValueError):
12
+ """The configured roster cannot produce a GitHub review request."""
13
+
14
+
15
+ def parse_maintainers(value):
16
+ """Normalize a comma-separated catalog value or a JSON-style list."""
17
+ if isinstance(value, str):
18
+ value = value.split(",")
19
+ result = []
20
+ seen = set()
21
+ for item in value or []:
22
+ login = str(item).strip()
23
+ key = login.lower()
24
+ if login and key not in seen:
25
+ seen.add(key)
26
+ result.append(login)
27
+ return result
28
+
29
+
30
+ def _reviewer_states(reviews, head_sha=None):
31
+ standing = {}
32
+ for review in reviews:
33
+ if head_sha is not None and review.get("commit_id") != head_sha:
34
+ continue
35
+ state = (review.get("state") or "").upper()
36
+ login = (review.get("user") or {}).get("login", "").lower()
37
+ if not login:
38
+ continue
39
+ if state == "DISMISSED":
40
+ standing.pop(login, None)
41
+ elif state in _DECISIVE_REVIEW_STATES:
42
+ standing[login] = state
43
+ return standing
44
+
45
+
46
+ def _path_scores(repository, pr_number, base_ref, candidates):
47
+ scores = dict.fromkeys(candidates, 0)
48
+ files = repository.gh_pages(f"/pulls/{pr_number}/files")[:_MAX_PATHS]
49
+ for changed_file in files:
50
+ path = changed_file.get("filename")
51
+ if not path:
52
+ continue
53
+ query = urlencode(
54
+ {"path": path, "sha": base_ref, "per_page": _COMMITS_PER_PATH}
55
+ )
56
+ commits = repository.gh("GET", f"/commits?{query}")
57
+ for rank, commit in enumerate(commits[:_COMMITS_PER_PATH]):
58
+ login = ((commit.get("author") or {}).get("login") or "").lower()
59
+ if login in scores:
60
+ scores[login] += _COMMITS_PER_PATH - rank
61
+ return scores
62
+
63
+
64
+ def _open_review_load(repository, owner, owner_type, login):
65
+ owner_qualifier = "org" if owner_type.lower() == "organization" else "user"
66
+ response = repository.api(
67
+ "GET",
68
+ "/search/issues",
69
+ params={
70
+ "q": (
71
+ f"is:pr is:open {owner_qualifier}:{owner} "
72
+ f"review-requested:{login}"
73
+ ),
74
+ "per_page": 1,
75
+ },
76
+ )
77
+ return int(response.get("total_count", 0))
78
+
79
+
80
+ def request_maintainer_review(repository, pr, maintainers):
81
+ """Ensure one configured maintainer is reviewing *pr*.
82
+
83
+ API failures propagate so the scanner can retry without clearing its trigger
84
+ label. The return value is the existing or newly requested login.
85
+ """
86
+ roster = parse_maintainers(maintainers)
87
+ if not roster:
88
+ return None
89
+
90
+ by_key = {login.lower(): login for login in roster}
91
+ requested = {
92
+ (item.get("login") or "").lower() for item in pr.get("requested_reviewers", [])
93
+ }
94
+ for login in roster:
95
+ if login.lower() in requested:
96
+ return login
97
+
98
+ number = pr["number"]
99
+ head_sha = pr["head"]["sha"]
100
+ reviews = repository.gh_pages(f"/pulls/{number}/reviews")
101
+ # A human approval remains useful after a later push unless GitHub dismisses
102
+ # it or the reviewer subsequently requests changes. Do not ask that person
103
+ # to review the same PR again merely because the automated review targets a
104
+ # newer commit.
105
+ review_states = _reviewer_states(reviews)
106
+ for login in roster:
107
+ if review_states.get(login.lower()) == "APPROVED":
108
+ return login
109
+
110
+ standing = _reviewer_states(reviews, head_sha)
111
+ for login in roster:
112
+ if login.lower() in standing:
113
+ return login
114
+
115
+ author = ((pr.get("user") or {}).get("login") or "").lower()
116
+ candidates = [key for key in by_key if key != author]
117
+ if not candidates:
118
+ raise HandoffConfigurationError(
119
+ "No eligible maintainer remains after excluding the PR author"
120
+ )
121
+
122
+ base_ref = (pr.get("base") or {}).get("ref") or "main"
123
+ scores = _path_scores(repository, number, base_ref, candidates)
124
+ owner = repository.repository.split("/", 1)[0]
125
+ owner_type = (
126
+ (((pr.get("base") or {}).get("repo") or {}).get("owner") or {}).get("type")
127
+ or "Organization"
128
+ )
129
+ loads = {
130
+ login: _open_review_load(repository, owner, owner_type, by_key[login])
131
+ for login in candidates
132
+ }
133
+ order = {login.lower(): index for index, login in enumerate(roster)}
134
+ selected_key = min(
135
+ candidates,
136
+ key=lambda login: (-scores[login], loads[login], order[login]),
137
+ )
138
+ selected = by_key[selected_key]
139
+ try:
140
+ repository.gh(
141
+ "POST",
142
+ f"/pulls/{number}/requested_reviewers",
143
+ {"reviewers": [selected]},
144
+ )
145
+ except HTTPError as exc:
146
+ if exc.code == 422:
147
+ raise HandoffConfigurationError(
148
+ f"GitHub cannot assign configured maintainer @{selected}"
149
+ ) from exc
150
+ raise
151
+ return selected
@@ -1,19 +1,82 @@
1
- """Select labeled PR heads and delegate each review to a sandboxed agent."""
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
- def _prompt(self, pr, label_event):
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
- label_event,
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}`, publish no status and leave `{label}` "
42
- "in place so the new head is reviewed.\n"
43
- "- After the readable native review is accepted by GitHub, set commit "
44
- "status `software-factory/review` to success only for an `✅ APPROVED` "
45
- "verdict, otherwise failure. Set `software-factory/tests` to success only "
46
- "when the relevant checks and tests pass, otherwise failure. Use the exact "
47
- f"head `{sha}` for both statuses.\n"
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
- prs = self.gh_pages("/pulls?state=open&sort=updated&direction=asc")
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
- if label not in {item["name"] for item in candidate.get("labels", [])}:
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
- event = workflow._latest_trigger_label_event(
62
- self.token, self.repository, pr["number"]
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 event is None:
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"{event['id']}:{sha}",
70
- prompt=self._prompt(pr, event),
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', '?')}: {exc}",
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,