@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.
- package/.agents/skills/custom-codereview-guide.md +86 -17
- package/.claude-plugin/marketplace.json +14 -0
- package/.codex-plugin/marketplace.json +14 -0
- package/.github/workflows/check-extensions.yml +6 -6
- package/.github/workflows/deprecation-check.yml +1 -1
- package/.github/workflows/issue-readiness-check.yml +37 -106
- package/.github/workflows/npm-publish.yml +2 -2
- package/.github/workflows/pr-description-check.yml +1 -1
- package/.github/workflows/pypi-publish.yml +3 -3
- package/.github/workflows/tests.yml +2 -2
- package/.plugin/marketplace.json +14 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +32 -2
- package/README.md +4 -3
- 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/integrations/catalog/granola.json +38 -0
- package/integrations/catalog-index.js +16 -14
- package/marketplaces/openhands-extensions.json +14 -0
- 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 +2 -2
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/add-skill/SKILL.md +5 -2
- package/skills/canvas-extension-api/.claude-plugin/plugin.json +19 -0
- package/skills/canvas-extension-api/.codex-plugin/plugin.json +19 -0
- package/skills/canvas-extension-api/.plugin/plugin.json +19 -0
- package/skills/canvas-extension-api/README.md +33 -0
- package/skills/canvas-extension-api/SKILL.md +254 -0
- package/skills/canvas-extension-api/references/acceptance-checklist.md +20 -0
- package/skills/canvas-extension-api/references/backend-safety.md +9 -0
- package/skills/canvas-extension-api/references/connections.md +126 -0
- package/skills/canvas-extension-api/references/packaging-recipes.md +43 -0
- package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -0
- package/skills/canvas-extension-api/references/testing-and-installation.md +286 -0
- package/skills/canvas-extension-api/references/v1-contract.md +250 -0
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +92 -0
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +60 -0
- 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 +21 -10
- package/skills/github-pr-reviewer/SKILL.md +36 -10
- 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 +63 -16
- package/skills/github-pr-reviewer/scripts/maintainer_handoff.py +151 -0
- package/skills/github-pr-reviewer/scripts/worker.py +218 -21
- package/skills/github-pr-reviewer/tests/test_main.py +85 -0
- package/skills/github-repo-monitor/scripts/github_client.py +4 -0
- package/skills/index.js +16 -8
- 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 +448 -5
- 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
- package/.github/scripts/check_issue_readiness.py +0 -272
- package/.github/scripts/post-readiness-comment.mjs +0 -184
- 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
|
|
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,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
|
-
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
self.
|
|
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
|
|
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"{
|
|
70
|
-
prompt=self._prompt(pr,
|
|
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', '?')}:
|
|
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,
|