@openhands/extensions 0.22.1 → 0.24.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 (90) hide show
  1. package/.agents/skills/custom-codereview-guide.md +86 -17
  2. package/.claude-plugin/marketplace.json +14 -0
  3. package/.codex-plugin/marketplace.json +14 -0
  4. package/.github/workflows/check-extensions.yml +6 -6
  5. package/.github/workflows/deprecation-check.yml +1 -1
  6. package/.github/workflows/issue-readiness-check.yml +37 -106
  7. package/.github/workflows/npm-publish.yml +2 -2
  8. package/.github/workflows/pr-description-check.yml +1 -1
  9. package/.github/workflows/pypi-publish.yml +3 -3
  10. package/.github/workflows/tests.yml +2 -2
  11. package/.plugin/marketplace.json +14 -0
  12. package/.release-please-manifest.json +1 -1
  13. package/AGENTS.md +32 -2
  14. package/README.md +4 -3
  15. package/automations/bundle-index.js +12 -11
  16. package/automations/catalog/github-issue-triage/manifest.json +2 -2
  17. package/automations/catalog/github-pr-reviewer/manifest.json +42 -5
  18. package/automations/catalog.schema.json +0 -4
  19. package/integrations/catalog/granola.json +38 -0
  20. package/integrations/catalog-index.js +16 -14
  21. package/marketplaces/openhands-extensions.json +14 -0
  22. package/package.json +1 -1
  23. package/plugins/openhands/skills/openhands-sdk/SKILL.md +3 -1
  24. package/plugins/pr-review/README.md +2 -1
  25. package/plugins/pr-review/scripts/agent_script.py +198 -81
  26. package/plugins/pr-review/scripts/prompt.py +35 -11
  27. package/plugins/pr-review/skills/code-review/SKILL.md +165 -200
  28. package/plugins/pr-review/skills/code-review/commands/codereview-roasted.md +1 -1
  29. package/plugins/pr-review/skills/code-review/commands/codereview.md +1 -1
  30. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +8 -3
  31. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +4 -0
  32. package/pyproject.toml +2 -2
  33. package/python/openhands_extensions/_version.py +1 -1
  34. package/skills/add-skill/SKILL.md +5 -2
  35. package/skills/canvas-extension-api/.claude-plugin/plugin.json +19 -0
  36. package/skills/canvas-extension-api/.codex-plugin/plugin.json +19 -0
  37. package/skills/canvas-extension-api/.plugin/plugin.json +19 -0
  38. package/skills/canvas-extension-api/README.md +33 -0
  39. package/skills/canvas-extension-api/SKILL.md +254 -0
  40. package/skills/canvas-extension-api/references/acceptance-checklist.md +20 -0
  41. package/skills/canvas-extension-api/references/backend-safety.md +9 -0
  42. package/skills/canvas-extension-api/references/connections.md +126 -0
  43. package/skills/canvas-extension-api/references/packaging-recipes.md +43 -0
  44. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -0
  45. package/skills/canvas-extension-api/references/testing-and-installation.md +286 -0
  46. package/skills/canvas-extension-api/references/v1-contract.md +250 -0
  47. package/skills/canvas-extension-api/scripts/validate-extension.mjs +92 -0
  48. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +60 -0
  49. package/skills/code-review/SKILL.md +165 -200
  50. package/skills/code-review/commands/codereview-roasted.md +1 -1
  51. package/skills/code-review/commands/codereview.md +1 -1
  52. package/skills/github/scripts/agent_conversation.py +8 -3
  53. package/skills/github/scripts/github_client.py +4 -0
  54. package/skills/github-agents-md-maintainer/scripts/github_client.py +4 -0
  55. package/skills/github-delivery-watchdog/scripts/github_client.py +4 -0
  56. package/skills/github-issue-to-pr/scripts/agent_conversation.py +8 -3
  57. package/skills/github-issue-to-pr/scripts/github_client.py +4 -0
  58. package/skills/github-issue-triage/.claude-plugin/plugin.json +1 -1
  59. package/skills/github-issue-triage/.codex-plugin/plugin.json +1 -1
  60. package/skills/github-issue-triage/.plugin/plugin.json +1 -1
  61. package/skills/github-issue-triage/README.md +7 -0
  62. package/skills/github-issue-triage/SKILL.md +26 -3
  63. package/skills/github-issue-triage/scripts/agent_conversation.py +8 -3
  64. package/skills/github-issue-triage/scripts/github_client.py +4 -0
  65. package/skills/github-issue-triage/scripts/worker.py +142 -26
  66. package/skills/github-pr-reviewer/README.md +21 -10
  67. package/skills/github-pr-reviewer/SKILL.md +36 -10
  68. package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
  69. package/skills/github-pr-reviewer/scripts/agent_conversation.py +8 -3
  70. package/skills/github-pr-reviewer/scripts/github_client.py +4 -0
  71. package/skills/github-pr-reviewer/scripts/main.py +63 -16
  72. package/skills/github-pr-reviewer/scripts/maintainer_handoff.py +151 -0
  73. package/skills/github-pr-reviewer/scripts/worker.py +218 -21
  74. package/skills/github-pr-reviewer/tests/test_main.py +85 -0
  75. package/skills/github-repo-monitor/scripts/github_client.py +4 -0
  76. package/skills/index.js +16 -8
  77. package/skills/openhands-sdk/SKILL.md +3 -1
  78. package/tests/fixtures/automations/github-pr-reviewer.json +120 -26
  79. package/tests/github_automation_helpers.py +1 -0
  80. package/tests/test_agent_conversation_dispatch.py +19 -4
  81. package/tests/test_automation_setup.py +15 -0
  82. package/tests/test_code_review_risk_evaluation.py +2 -2
  83. package/tests/test_github_reviewer_delivery.py +448 -5
  84. package/tests/test_github_triage_delivery.py +200 -6
  85. package/tests/test_maintainer_handoff.py +198 -0
  86. package/tests/test_pr_review_prompt.py +6 -2
  87. package/tests/test_pr_review_review_context.py +144 -0
  88. package/.github/scripts/check_issue_readiness.py +0 -272
  89. package/.github/scripts/post-readiness-comment.mjs +0 -184
  90. package/tests/test_check_issue_readiness.py +0 -135
@@ -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,140 @@ 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
+ "Confirm its current body, labels, review threads, and the head's check "
127
+ "results, and re-read every linked issue's current body and labels: an "
128
+ "issue's readiness or priority may have changed since an earlier turn. "
129
+ f"If the head is no longer `{sha}`, {moved_head_instruction}.\n"
130
+ f"- {trigger_completion} after completing any configured "
131
+ "human-review handoff. Never paste JSON artifacts or full command logs "
132
+ "into comments.\n"
133
+ "- Once GitHub accepts the native review, stop immediately. Do not "
134
+ "continue inspecting the repository, run more commands, or publish a "
135
+ "second result."
50
136
  )
51
137
 
138
+ def _finish_completed_review(self, pr, trigger, label=None):
139
+ """Complete an exact-head review, including an optional human handoff."""
140
+ head_sha = pr["head"]["sha"]
141
+ triggered_at = trigger.get("created_at") or ""
142
+ reviews = self.gh_pages(f"/pulls/{pr['number']}/reviews")
143
+ completed = [
144
+ review
145
+ for review in reviews
146
+ if review.get("commit_id") == head_sha
147
+ and ((review.get("user") or {}).get("login") or "").lower()
148
+ == self.github_login.lower()
149
+ and (review.get("submitted_at") or "") > triggered_at
150
+ ]
151
+ if not completed:
152
+ return False
153
+ approved = None
154
+ maintainer_decision = False
155
+ for review in sorted(
156
+ completed, key=lambda item: item["submitted_at"], reverse=True
157
+ ):
158
+ body = (review.get("body") or "").rstrip()
159
+ if body.endswith("✅ APPROVED"):
160
+ approved = True
161
+ break
162
+ if body.endswith("🔄 CHANGES REQUESTED"):
163
+ approved = False
164
+ break
165
+ if body.endswith(workflow.MAINTAINER_DECISION_VERDICT):
166
+ maintainer_decision = True
167
+ break
168
+ if approved is None and not maintainer_decision:
169
+ return False
170
+ # A scope stop is not an approval: it requests the maintainer decision
171
+ # the change is missing, through the same handoff as an approval.
172
+ if approved or maintainer_decision:
173
+ maintainers = parse_maintainers(self.config.get("maintainers"))
174
+ if maintainers:
175
+ try:
176
+ selected = request_maintainer_review(self, pr, maintainers)
177
+ except HandoffConfigurationError as exc:
178
+ self.gh(
179
+ "POST",
180
+ f"/issues/{pr['number']}/comments",
181
+ {
182
+ "body": (
183
+ "⚠️ **Automated maintainer handoff could not be "
184
+ f"completed:** {exc}. Update the automation's "
185
+ "maintainer roster, then request another review."
186
+ "\n\n_This is an automated configuration check; "
187
+ "no AI was used to generate this comment._"
188
+ )
189
+ },
190
+ )
191
+ selected = None
192
+ print(
193
+ json.dumps(
194
+ {
195
+ "repository": self.repository,
196
+ "pr": pr["number"],
197
+ "human_reviewer": selected,
198
+ }
199
+ ),
200
+ flush=True,
201
+ )
202
+ current = self.gh("GET", f"/pulls/{pr['number']}")
203
+ if current["head"]["sha"] != pr["head"]["sha"]:
204
+ # Treat this scan as handled so it cannot redeliver the stale head.
205
+ # Scheduled mode leaves its label in place; event mode requires a
206
+ # fresh review request for the new head.
207
+ return True
208
+ if label:
209
+ self.gh(
210
+ "DELETE", f"/issues/{pr['number']}/labels/{quote(label, safe='')}"
211
+ )
212
+ return True
213
+
52
214
  def run(self):
53
215
  repository_id = self.gh("GET", "")["id"]
54
216
  label = self.config.get("trigger_label", workflow.TRIGGER_LABEL)
55
- prs = self.gh_pages("/pulls?state=open&sort=updated&direction=asc")
217
+ payload = self._event_payload()
218
+ if payload is None:
219
+ prs = self.gh_pages("/pulls?state=open&sort=updated&direction=asc")
220
+ else:
221
+ candidate = self._event_candidate(payload)
222
+ if candidate and self.github_login.lower() != self.trigger_reviewer:
223
+ raise RuntimeError(
224
+ "The configured GitHub credential must authenticate as "
225
+ f"{self.trigger_reviewer} for reviewer-request mode"
226
+ )
227
+ prs = [candidate] if candidate else []
228
+ failures = []
56
229
  for candidate in prs:
57
- if label not in {item["name"] for item in candidate.get("labels", [])}:
230
+ event_mode = payload is not None
231
+ if not event_mode and label not in {
232
+ item["name"] for item in candidate.get("labels", [])
233
+ }:
58
234
  continue
59
235
  try:
60
236
  pr = self.gh("GET", f"/pulls/{candidate['number']}")
61
- event = workflow._latest_trigger_label_event(
62
- self.token, self.repository, pr["number"]
237
+ trigger = (
238
+ self._latest_reviewer_request(pr["number"])
239
+ if event_mode
240
+ else workflow._latest_trigger_label_event(
241
+ self.token, self.repository, pr["number"]
242
+ )
63
243
  )
64
- if event is None:
244
+ if trigger is None:
245
+ continue
246
+ trigger_label = None if event_mode else label
247
+ if self._finish_completed_review(pr, trigger, trigger_label):
248
+ continue
249
+ if event_mode and payload.get("action") == "submitted":
250
+ # A submitted review is a completion signal, never a fresh
251
+ # trigger. A non-decisive review, or one superseded by a new
252
+ # head, must wait for another reviewer request instead of
253
+ # dispatching a review the caller never asked for.
65
254
  continue
66
255
  sha = pr["head"]["sha"]
67
256
  result = self.dispatcher.deliver(
68
257
  subject=f"{repository_id}:pr:{pr['number']}",
69
- delivery=f"{event['id']}:{sha}",
70
- prompt=self._prompt(pr, event),
258
+ delivery=f"{trigger['id']}:{sha}",
259
+ prompt=self._prompt(pr, trigger, trigger_label),
71
260
  )
72
261
  print(
73
262
  json.dumps(
@@ -82,11 +271,19 @@ class PullRequestReviewer(GitHubRepository):
82
271
  flush=True,
83
272
  )
84
273
  except Exception as exc: # noqa: BLE001 - one PR must not block the scan
274
+ failures.append(candidate.get("number", "?"))
85
275
  print(
86
276
  f"Failed to submit {self.repository} PR "
87
- f"#{candidate.get('number', '?')}: {exc}",
277
+ f"#{candidate.get('number', '?')}: "
278
+ f"{type(exc).__name__}: {exc}",
279
+ file=sys.stderr,
88
280
  flush=True,
89
281
  )
282
+ if failures:
283
+ raise RuntimeError(
284
+ "Reviewer scan failed for PRs: "
285
+ + ", ".join(f"#{number}" for number in failures)
286
+ )
90
287
 
91
288
 
92
289
  if __name__ == "__main__":
@@ -522,6 +522,91 @@ 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
+
541
+ def test_prompt_requires_refreshing_mutable_github_state(self):
542
+ """A resumed conversation must not trust an earlier turn's observations."""
543
+ prompt = main._build_review_prompt(
544
+ "owner/repo",
545
+ self._pr(),
546
+ "0123456789abcdef",
547
+ {"id": "1", "created_at": "t"},
548
+ )
549
+
550
+ assert "CURRENT STATE" in prompt
551
+ for surface in (
552
+ "still matches the Head SHA",
553
+ "current PR title and body",
554
+ "current review comments and threads",
555
+ "current review requests",
556
+ "GitHub Actions check results",
557
+ "body and labels of every linked issue",
558
+ ):
559
+ self.assertIn(surface, prompt)
560
+ # The readiness case from the report: a linked issue gaining a label.
561
+ self.assertIn("ready-for-dev", prompt)
562
+
563
+ def test_prompt_forbids_repeating_an_earlier_observation(self):
564
+ prompt = main._build_review_prompt(
565
+ "owner/repo",
566
+ self._pr(),
567
+ "0123456789abcdef",
568
+ {"id": "1", "created_at": "t"},
569
+ )
570
+
571
+ self.assertIn("fresh review", prompt)
572
+ self.assertIn("re-established now", prompt)
573
+ self.assertIn(
574
+ "never repeat an earlier finding, verdict, or "
575
+ "label/priority claim",
576
+ prompt,
577
+ )
578
+
579
+ def test_prompt_refreshes_state_before_the_scope_gate_and_keeps_analysis(self):
580
+ prompt = main._build_review_prompt(
581
+ "owner/repo",
582
+ self._pr(),
583
+ "0123456789abcdef",
584
+ {"id": "1", "created_at": "t"},
585
+ )
586
+
587
+ # The refresh precedes the verdict-shaping steps, while the repository
588
+ # analysis the conversation already did stays part of the workflow.
589
+ self.assertLess(prompt.index("CURRENT STATE"), prompt.index("SCOPE GATE"))
590
+ self.assertLess(prompt.index("CURRENT STATE"), prompt.index("MUST read"))
591
+ self.assertIn("useful background", prompt)
592
+ self.assertIn("AGENTS.md", prompt)
593
+
594
+ def test_prompt_scope_gate_stops_before_the_technical_review(self):
595
+ prompt = main._build_review_prompt(
596
+ "owner/repo",
597
+ self._pr(),
598
+ "0123456789abcdef",
599
+ {"id": "1", "created_at": "t"},
600
+ )
601
+
602
+ gate = prompt.index("SCOPE GATE")
603
+ self.assertLess(gate, prompt.index("Inspect the PR discussion"))
604
+ self.assertLess(gate, prompt.index("Ground every finding"))
605
+ self.assertIn("before inspecting changed files, reading the diff, or running", prompt)
606
+ self.assertIn("scope categories and ownership boundaries", prompt)
607
+ self.assertIn("continue the technical review unchanged", prompt)
608
+ self.assertIn("move repositories, close, or receive a maintainer decision", prompt)
609
+
525
610
 
526
611
  class TestNormalizeRepo(unittest.TestCase):
527
612
  """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,