@openhands/extensions 0.4.0 → 0.4.2
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/.release-please-manifest.json +1 -1
- package/automations/catalog/github-pr-reviewer.json +2 -2
- package/package.json +1 -1
- package/plugins/qa-changes/action.yml +1 -1
- package/pyproject.toml +1 -1
- package/skills/github-pr-reviewer/.plugin/plugin.json +1 -1
- package/skills/github-pr-reviewer/README.md +21 -15
- package/skills/github-pr-reviewer/SKILL.md +206 -44
- package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
- package/skills/github-pr-reviewer/references/state-schema.md +126 -0
- package/skills/github-pr-reviewer/scripts/main.py +608 -0
- package/skills/index.js +3 -3
- package/skills/openhands-sdk/SKILL.md +3 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GitHub PR Reviewer - OpenHands Automation Script
|
|
3
|
+
|
|
4
|
+
Cron-polls a GitHub repository for open pull requests carrying the configured
|
|
5
|
+
trigger label. A review is queued only when the latest matching GitHub `labeled`
|
|
6
|
+
event has not already been processed by this automation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from urllib.parse import urlencode
|
|
17
|
+
|
|
18
|
+
REPO = "owner/repo"
|
|
19
|
+
TRIGGER_LABEL = "openhands-review"
|
|
20
|
+
REVIEW_TONE = "thorough"
|
|
21
|
+
REVIEW_STYLE_INSTRUCTIONS = ""
|
|
22
|
+
DEFAULT_OPENHANDS_URL = "http://localhost:8000"
|
|
23
|
+
|
|
24
|
+
DONE_DEBOUNCE = 15
|
|
25
|
+
TERMINAL_STATUSES = {"idle", "finished", "error", "stuck"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_env_key() -> str:
|
|
29
|
+
return os.environ.get("SESSION_API_KEY") or os.environ.get("OH_SESSION_API_KEYS_0") or ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_secret(name: str) -> str:
|
|
33
|
+
url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
|
|
34
|
+
key = _get_env_key()
|
|
35
|
+
req = urllib.request.Request(
|
|
36
|
+
f"{url}/api/settings/secrets/{name}",
|
|
37
|
+
headers={"X-Session-API-Key": key},
|
|
38
|
+
)
|
|
39
|
+
with urllib.request.urlopen(req) as r:
|
|
40
|
+
return r.read().decode().strip()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def fire_callback(
|
|
44
|
+
status: str = "COMPLETED",
|
|
45
|
+
error: str | None = None,
|
|
46
|
+
conversation_id: str | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
url = os.environ.get("AUTOMATION_CALLBACK_URL", "")
|
|
49
|
+
if not url:
|
|
50
|
+
return
|
|
51
|
+
body: dict = {"status": status, "run_id": os.environ.get("AUTOMATION_RUN_ID", "")}
|
|
52
|
+
if error:
|
|
53
|
+
body["error"] = error
|
|
54
|
+
if conversation_id:
|
|
55
|
+
body["conversation_id"] = conversation_id
|
|
56
|
+
req = urllib.request.Request(
|
|
57
|
+
url,
|
|
58
|
+
data=json.dumps(body).encode(),
|
|
59
|
+
headers={
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
"Authorization": f"Bearer {os.environ.get('AUTOMATION_CALLBACK_API_KEY', '')}",
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
urllib.request.urlopen(req)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
print(f"Callback error (non-fatal): {exc}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _state_file_path() -> str:
|
|
71
|
+
workspace_base = os.environ.get("WORKSPACE_BASE", "")
|
|
72
|
+
event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}"))
|
|
73
|
+
automation_id = event_payload.get("automation_id", "default")
|
|
74
|
+
|
|
75
|
+
if workspace_base:
|
|
76
|
+
root = Path(workspace_base).resolve().parent.parent
|
|
77
|
+
else:
|
|
78
|
+
root = Path.home() / ".openhands" / "workspaces"
|
|
79
|
+
|
|
80
|
+
state_dir = root / "automation-state"
|
|
81
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
return str(state_dir / f"github_pr_reviewer_label_event_{automation_id}.json")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_state(path: str) -> dict:
|
|
86
|
+
if os.path.exists(path):
|
|
87
|
+
try:
|
|
88
|
+
with open(path) as f:
|
|
89
|
+
return json.load(f)
|
|
90
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
91
|
+
print(f"Warning: state file {path} unreadable ({exc}); starting fresh")
|
|
92
|
+
return {
|
|
93
|
+
"version": 2,
|
|
94
|
+
"repo": REPO,
|
|
95
|
+
"trigger_label": TRIGGER_LABEL,
|
|
96
|
+
"reviews": {},
|
|
97
|
+
"prs": {},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def save_state(path: str, state: dict) -> None:
|
|
102
|
+
tmp_path = f"{path}.tmp"
|
|
103
|
+
with open(tmp_path, "w") as f:
|
|
104
|
+
json.dump(state, f, indent=2, sort_keys=True)
|
|
105
|
+
os.replace(tmp_path, path)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _github_request(
|
|
109
|
+
token: str,
|
|
110
|
+
method: str,
|
|
111
|
+
path: str,
|
|
112
|
+
params: dict | None = None,
|
|
113
|
+
body: dict | None = None,
|
|
114
|
+
accept: str = "application/vnd.github+json",
|
|
115
|
+
) -> tuple:
|
|
116
|
+
url = f"https://api.github.com{path}"
|
|
117
|
+
if params:
|
|
118
|
+
url = f"{url}?{urlencode(params)}"
|
|
119
|
+
headers = {
|
|
120
|
+
"Authorization": f"Bearer {token}",
|
|
121
|
+
"Accept": accept,
|
|
122
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
123
|
+
"Content-Type": "application/json",
|
|
124
|
+
}
|
|
125
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
126
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
127
|
+
with urllib.request.urlopen(req) as r:
|
|
128
|
+
raw = r.read()
|
|
129
|
+
return (json.loads(raw) if raw.strip() else {}), dict(r.headers)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _github_paginate(token: str, path: str, params: dict | None = None) -> list:
|
|
133
|
+
results = []
|
|
134
|
+
page = 1
|
|
135
|
+
base_params = dict(params or {})
|
|
136
|
+
base_params.setdefault("per_page", 100)
|
|
137
|
+
while True:
|
|
138
|
+
base_params["page"] = page
|
|
139
|
+
data, _ = _github_request(token, "GET", path, params=base_params)
|
|
140
|
+
if not isinstance(data, list):
|
|
141
|
+
break
|
|
142
|
+
results.extend(data)
|
|
143
|
+
if len(data) < base_params["per_page"]:
|
|
144
|
+
break
|
|
145
|
+
page += 1
|
|
146
|
+
return results
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _resolve_github_token() -> str:
|
|
150
|
+
try:
|
|
151
|
+
token = get_secret("GITHUB_PERSONAL_ACCESS_TOKEN")
|
|
152
|
+
if token:
|
|
153
|
+
return token
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
raise RuntimeError(
|
|
157
|
+
"GITHUB_PERSONAL_ACCESS_TOKEN secret is not set. "
|
|
158
|
+
"Go to OpenHands Settings → Secrets and add your GitHub Personal Access Token."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _verify_token_and_repo(token: str, repo: str) -> None:
|
|
163
|
+
try:
|
|
164
|
+
user_data, _ = _github_request(token, "GET", "/user")
|
|
165
|
+
except urllib.error.HTTPError as exc:
|
|
166
|
+
if exc.code == 401:
|
|
167
|
+
raise RuntimeError("GITHUB_PERSONAL_ACCESS_TOKEN is invalid or expired.") from exc
|
|
168
|
+
raise RuntimeError(f"GitHub /user check failed: {exc.code}") from exc
|
|
169
|
+
|
|
170
|
+
print(f"Authenticated as GitHub user: {user_data.get('login', '?')}")
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
_github_request(token, "GET", f"/repos/{repo}")
|
|
174
|
+
except urllib.error.HTTPError as exc:
|
|
175
|
+
if exc.code == 404:
|
|
176
|
+
raise RuntimeError(f"Repository '{repo}' is not accessible with the current token.") from exc
|
|
177
|
+
raise RuntimeError(f"GitHub /repos/{repo} check failed: {exc.code}") from exc
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _list_open_prs(token: str, repo: str) -> list[dict]:
|
|
181
|
+
return _github_paginate(
|
|
182
|
+
token,
|
|
183
|
+
f"/repos/{repo}/pulls",
|
|
184
|
+
{"state": "open", "sort": "updated", "direction": "desc"},
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _get_pr(token: str, repo: str, pr_number: int) -> dict:
|
|
189
|
+
pr, _ = _github_request(token, "GET", f"/repos/{repo}/pulls/{pr_number}")
|
|
190
|
+
return pr
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _get_issue_events(token: str, repo: str, pr_number: int) -> list[dict]:
|
|
194
|
+
return _github_paginate(token, f"/repos/{repo}/issues/{pr_number}/events")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _latest_trigger_label_event(token: str, repo: str, pr_number: int) -> dict | None:
|
|
198
|
+
events = _get_issue_events(token, repo, pr_number)
|
|
199
|
+
matching = [
|
|
200
|
+
event for event in events
|
|
201
|
+
if event.get("event") == "labeled"
|
|
202
|
+
and (event.get("label") or {}).get("name", "").lower() == TRIGGER_LABEL.lower()
|
|
203
|
+
and event.get("id") is not None
|
|
204
|
+
]
|
|
205
|
+
if not matching:
|
|
206
|
+
return None
|
|
207
|
+
return max(matching, key=lambda event: (event.get("created_at") or "", int(event.get("id") or 0)))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _post_github_comment(token: str, repo: str, pr_number: int, body: str) -> None:
|
|
211
|
+
try:
|
|
212
|
+
_github_request(
|
|
213
|
+
token,
|
|
214
|
+
"POST",
|
|
215
|
+
f"/repos/{repo}/issues/{pr_number}/comments",
|
|
216
|
+
body={"body": body},
|
|
217
|
+
)
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
print(f" Warning: failed to post comment on PR #{pr_number}: {exc}")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _oh_request(agent_url: str, api_key: str, method: str, path: str, body: dict | None = None) -> dict:
|
|
223
|
+
url = f"{agent_url}{path}"
|
|
224
|
+
headers = {"X-Session-API-Key": api_key, "Content-Type": "application/json"}
|
|
225
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
226
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
227
|
+
try:
|
|
228
|
+
with urllib.request.urlopen(req) as r:
|
|
229
|
+
raw = r.read()
|
|
230
|
+
return json.loads(raw) if raw.strip() else {}
|
|
231
|
+
except urllib.error.HTTPError as exc:
|
|
232
|
+
body_text = exc.read().decode()
|
|
233
|
+
raise RuntimeError(f"Agent API {method} {path} → {exc.code}: {body_text}") from exc
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _fetch_settings(agent_url: str, api_key: str) -> dict:
|
|
237
|
+
req = urllib.request.Request(
|
|
238
|
+
f"{agent_url}/api/settings",
|
|
239
|
+
headers={"X-Session-API-Key": api_key, "X-Expose-Secrets": "plaintext"},
|
|
240
|
+
)
|
|
241
|
+
with urllib.request.urlopen(req) as r:
|
|
242
|
+
return json.loads(r.read())
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _get_agent_dict(agent_url: str, api_key: str) -> dict:
|
|
246
|
+
data = _fetch_settings(agent_url, api_key)
|
|
247
|
+
llm = data.get("agent_settings", {}).get("llm", {})
|
|
248
|
+
return {
|
|
249
|
+
"kind": "Agent",
|
|
250
|
+
"llm": llm,
|
|
251
|
+
"tools": [{"name": "terminal"}, {"name": "file_editor"}],
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _get_mcp_config(agent_url: str, api_key: str) -> dict | None:
|
|
256
|
+
try:
|
|
257
|
+
data = _fetch_settings(agent_url, api_key)
|
|
258
|
+
mcp_config = data.get("agent_settings", {}).get("mcp_config")
|
|
259
|
+
if isinstance(mcp_config, dict) and mcp_config.get("mcpServers"):
|
|
260
|
+
return mcp_config
|
|
261
|
+
except Exception as exc:
|
|
262
|
+
print(f"Warning: could not fetch MCP config: {exc}")
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _list_secret_names(agent_url: str, api_key: str) -> list[dict]:
|
|
267
|
+
try:
|
|
268
|
+
result = _oh_request(agent_url, api_key, "GET", "/api/settings/secrets")
|
|
269
|
+
return result.get("secrets", [])
|
|
270
|
+
except Exception as exc:
|
|
271
|
+
print(f"Warning: could not list secrets: {exc}")
|
|
272
|
+
return []
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _build_secrets_payload(agent_url: str, api_key: str) -> dict:
|
|
276
|
+
secrets = {}
|
|
277
|
+
for secret in _list_secret_names(agent_url, api_key):
|
|
278
|
+
name = secret.get("name", "")
|
|
279
|
+
if not name:
|
|
280
|
+
continue
|
|
281
|
+
lookup: dict = {
|
|
282
|
+
"kind": "LookupSecret",
|
|
283
|
+
"url": f"/api/settings/secrets/{name}",
|
|
284
|
+
}
|
|
285
|
+
if api_key:
|
|
286
|
+
lookup["headers"] = {"X-Session-API-Key": api_key}
|
|
287
|
+
desc = secret.get("description")
|
|
288
|
+
if desc:
|
|
289
|
+
lookup["description"] = desc
|
|
290
|
+
secrets[name] = lookup
|
|
291
|
+
return secrets
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def create_conversation(agent_url: str, api_key: str, initial_message: str) -> str:
|
|
295
|
+
workspace_dir = os.environ.get("WORKSPACE_BASE", "/workspace")
|
|
296
|
+
payload: dict = {
|
|
297
|
+
"workspace": {"working_dir": workspace_dir},
|
|
298
|
+
"agent": _get_agent_dict(agent_url, api_key),
|
|
299
|
+
"initial_message": {"content": [{"text": initial_message}]},
|
|
300
|
+
}
|
|
301
|
+
secrets = _build_secrets_payload(agent_url, api_key)
|
|
302
|
+
if secrets:
|
|
303
|
+
payload["secrets"] = secrets
|
|
304
|
+
mcp_config = _get_mcp_config(agent_url, api_key)
|
|
305
|
+
if mcp_config:
|
|
306
|
+
payload["mcp_config"] = mcp_config
|
|
307
|
+
result = _oh_request(agent_url, api_key, "POST", "/api/conversations", payload)
|
|
308
|
+
return result["id"]
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def conversation_status(agent_url: str, api_key: str, conv_id: str) -> str:
|
|
312
|
+
result = _oh_request(agent_url, api_key, "GET", f"/api/conversations/{conv_id}")
|
|
313
|
+
return result.get("execution_status", "unknown")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def conversation_final_response(agent_url: str, api_key: str, conv_id: str) -> str:
|
|
317
|
+
result = _oh_request(agent_url, api_key, "GET", f"/api/conversations/{conv_id}/agent_final_response")
|
|
318
|
+
return result.get("response", "")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
_TONE_INSTRUCTIONS = {
|
|
322
|
+
"thorough": (
|
|
323
|
+
"Provide a comprehensive review. Cover correctness, security vulnerabilities, "
|
|
324
|
+
"missing or inadequate tests, code style, maintainability, and potential edge cases. "
|
|
325
|
+
"Reference specific files and line numbers where relevant."
|
|
326
|
+
),
|
|
327
|
+
"concise": (
|
|
328
|
+
"Provide a brief, high-signal review. Focus only on important bugs, security problems, "
|
|
329
|
+
"or significant design flaws. Omit minor style feedback."
|
|
330
|
+
),
|
|
331
|
+
"friendly": (
|
|
332
|
+
"Provide a constructive, encouraging review. Acknowledge what is done well before "
|
|
333
|
+
"raising concerns while still noting real issues."
|
|
334
|
+
),
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _labels(pr: dict) -> list[str]:
|
|
339
|
+
return [label.get("name", "") for label in pr.get("labels", [])]
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _has_trigger_label(pr: dict) -> bool:
|
|
343
|
+
return any(label.lower() == TRIGGER_LABEL.lower() for label in _labels(pr))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _head_sha(pr: dict) -> str:
|
|
347
|
+
return ((pr.get("head") or {}).get("sha") or "").strip()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _review_key(pr_number: int, label_event_id: int | str) -> str:
|
|
351
|
+
return f"{pr_number}:label:{label_event_id}"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _with_ai_disclosure(body: str) -> str:
|
|
355
|
+
disclosure = "_This comment was posted by an AI agent (OpenHands)._"
|
|
356
|
+
body = (body or "").strip()
|
|
357
|
+
if disclosure.lower() in body.lower():
|
|
358
|
+
return body
|
|
359
|
+
return f"{body}\n\n{disclosure}" if body else disclosure
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _build_review_prompt(pr: dict, head_sha: str, label_event: dict) -> str:
|
|
363
|
+
number = pr.get("number", "?")
|
|
364
|
+
title = pr.get("title", "(no title)")
|
|
365
|
+
body = (pr.get("body") or "").strip() or "(no description)"
|
|
366
|
+
html_url = pr.get("html_url", "")
|
|
367
|
+
author = (pr.get("user") or {}).get("login", "?")
|
|
368
|
+
base_branch = (pr.get("base") or {}).get("ref", "?")
|
|
369
|
+
head_branch = (pr.get("head") or {}).get("ref", "?")
|
|
370
|
+
label_str = ", ".join(_labels(pr)) or "(none)"
|
|
371
|
+
label_event_id = label_event.get("id", "?")
|
|
372
|
+
label_event_created_at = label_event.get("created_at", "?")
|
|
373
|
+
changed_files = pr.get("changed_files", "?")
|
|
374
|
+
additions = pr.get("additions", "?")
|
|
375
|
+
deletions = pr.get("deletions", "?")
|
|
376
|
+
clone_url = f"https://github.com/{REPO}.git"
|
|
377
|
+
tone = _TONE_INSTRUCTIONS.get(REVIEW_TONE, _TONE_INSTRUCTIONS["thorough"])
|
|
378
|
+
extra = f"\n\nAdditional style instructions:\n{REVIEW_STYLE_INSTRUCTIONS}" if REVIEW_STYLE_INSTRUCTIONS.strip() else ""
|
|
379
|
+
|
|
380
|
+
return (
|
|
381
|
+
"You are an AI code reviewer. Review the GitHub pull request below and write "
|
|
382
|
+
"a single review comment. Do not modify files, push commits, approve via the GitHub "
|
|
383
|
+
"API, or request changes via the review API; only produce the final comment text.\n\n"
|
|
384
|
+
f"Repository : {REPO}\n"
|
|
385
|
+
f"Clone URL : {clone_url}\n"
|
|
386
|
+
f"PR #{number}: \"{title}\"\n"
|
|
387
|
+
f"Author : @{author}\n"
|
|
388
|
+
f"Base → Head: {base_branch} ← {head_branch}\n"
|
|
389
|
+
f"Head SHA : {head_sha}\n"
|
|
390
|
+
f"Trigger : latest `{TRIGGER_LABEL}` labeled event {label_event_id} at {label_event_created_at}\n"
|
|
391
|
+
f"Labels : {label_str}\n"
|
|
392
|
+
f"Changes : +{additions} -{deletions} across {changed_files} file(s)\n"
|
|
393
|
+
f"URL : {html_url}\n"
|
|
394
|
+
f"\nPR Description:\n---\n{body}\n---\n\n"
|
|
395
|
+
"Required workflow:\n"
|
|
396
|
+
"1. Clone the repository into a fresh working directory inside the workspace.\n"
|
|
397
|
+
f" Example: `git clone {clone_url} pr-review-{number}`.\n"
|
|
398
|
+
"2. Check out the exact pull request branch by PR number, then verify HEAD matches the SHA above.\n"
|
|
399
|
+
f" Example: `git fetch origin pull/{number}/head:openhands-pr-{number}` followed by `git checkout openhands-pr-{number}`.\n"
|
|
400
|
+
"3. Inspect the existing PR context before reviewing, including PR description, issue comments, review comments, changed files, and the diff.\n"
|
|
401
|
+
" Prefer `gh pr view`, `gh pr diff`, `gh pr checkout`, or GitHub REST API calls with `GITHUB_PERSONAL_ACCESS_TOKEN`; do not print secret values.\n"
|
|
402
|
+
"4. Use the checked-out repository to inspect relevant files and surrounding code, not just the patch.\n"
|
|
403
|
+
"5. Before producing the final review text, delete only the cloned repository directory created in step 1.\n"
|
|
404
|
+
f" Example: `rm -rf pr-review-{number}`. Do not delete any other files or directories.\n"
|
|
405
|
+
"6. Write a high-signal review comment with specific findings. If there are no material issues, say so.\n"
|
|
406
|
+
f"\nReview instructions:\n{tone}{extra}\n\n"
|
|
407
|
+
"Output ONLY the review text — no preamble, no meta-commentary. "
|
|
408
|
+
"This text will be posted verbatim as a comment on the pull request. "
|
|
409
|
+
"End your review with a clear verdict on its own line: either `✅ APPROVED` "
|
|
410
|
+
"or `🔄 CHANGES REQUESTED`."
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
def _process_review_request(
|
|
414
|
+
github_token: str,
|
|
415
|
+
agent_url: str,
|
|
416
|
+
api_key: str,
|
|
417
|
+
openhands_url: str,
|
|
418
|
+
pr: dict,
|
|
419
|
+
label_event: dict,
|
|
420
|
+
reviews: dict,
|
|
421
|
+
) -> str | None:
|
|
422
|
+
number = pr["number"]
|
|
423
|
+
head_sha = _head_sha(pr)
|
|
424
|
+
label_event_id = label_event["id"]
|
|
425
|
+
key = _review_key(number, label_event_id)
|
|
426
|
+
title = pr.get("title", "(no title)")
|
|
427
|
+
html_url = pr.get("html_url", "")
|
|
428
|
+
|
|
429
|
+
print(f" Queuing review for PR #{number} from `{TRIGGER_LABEL}` event {label_event_id} at {head_sha[:12]}: {title}")
|
|
430
|
+
prompt = _build_review_prompt(pr, head_sha, label_event)
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
conv_id = create_conversation(agent_url, api_key, prompt)
|
|
434
|
+
except Exception as exc:
|
|
435
|
+
print(f" Error creating conversation for PR #{number}: {exc}")
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
reviews[key] = {
|
|
439
|
+
"pr_number": number,
|
|
440
|
+
"head_sha": head_sha,
|
|
441
|
+
"trigger_label_event_id": label_event_id,
|
|
442
|
+
"trigger_label_event_created_at": label_event.get("created_at"),
|
|
443
|
+
"html_url": html_url,
|
|
444
|
+
"status": "active",
|
|
445
|
+
"conversation_id": conv_id,
|
|
446
|
+
"last_activity": time.time(),
|
|
447
|
+
}
|
|
448
|
+
print(f" Created review conversation {conv_id}")
|
|
449
|
+
|
|
450
|
+
conv_url = f"{openhands_url}/conversations/{conv_id}"
|
|
451
|
+
_post_github_comment(
|
|
452
|
+
github_token,
|
|
453
|
+
REPO,
|
|
454
|
+
number,
|
|
455
|
+
_with_ai_disclosure(
|
|
456
|
+
"🤖 **OpenHands is reviewing this PR.**\n\n"
|
|
457
|
+
f"Trigger label: `{TRIGGER_LABEL}`\n"
|
|
458
|
+
f"Label event: `{label_event_id}` at `{label_event.get('created_at', '?')}`\n"
|
|
459
|
+
f"Head commit: `{head_sha}`\n"
|
|
460
|
+
f"View the conversation: {conv_url}"
|
|
461
|
+
),
|
|
462
|
+
)
|
|
463
|
+
return conv_id
|
|
464
|
+
|
|
465
|
+
def _check_conversation_completion(
|
|
466
|
+
rec: dict,
|
|
467
|
+
latest_open_prs: dict[int, dict],
|
|
468
|
+
github_token: str,
|
|
469
|
+
agent_url: str,
|
|
470
|
+
api_key: str,
|
|
471
|
+
) -> None:
|
|
472
|
+
if (time.time() - rec.get("last_activity", 0.0)) < DONE_DEBOUNCE:
|
|
473
|
+
return
|
|
474
|
+
|
|
475
|
+
conv_id = rec["conversation_id"]
|
|
476
|
+
pr_number = rec["pr_number"]
|
|
477
|
+
reviewed_sha = rec.get("head_sha", "")
|
|
478
|
+
current_pr = latest_open_prs.get(pr_number)
|
|
479
|
+
|
|
480
|
+
if not current_pr:
|
|
481
|
+
rec["status"] = "closed"
|
|
482
|
+
print(f" PR #{pr_number} closed/merged — skipping result post")
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
current_sha = _head_sha(current_pr)
|
|
486
|
+
if current_sha and reviewed_sha and current_sha != reviewed_sha:
|
|
487
|
+
rec["status"] = "stale"
|
|
488
|
+
rec["stale_reason"] = f"head changed from {reviewed_sha} to {current_sha}"
|
|
489
|
+
print(f" PR #{pr_number} advanced to {current_sha[:12]} — suppressing stale review {conv_id}")
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
status = conversation_status(agent_url, api_key, conv_id)
|
|
494
|
+
except Exception as exc:
|
|
495
|
+
print(f" Warning: could not get status for {conv_id}: {exc}")
|
|
496
|
+
return
|
|
497
|
+
|
|
498
|
+
print(f" PR #{pr_number} conversation {conv_id} → status={status}")
|
|
499
|
+
if status not in TERMINAL_STATUSES:
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
final = conversation_final_response(agent_url, api_key, conv_id)
|
|
504
|
+
except Exception:
|
|
505
|
+
final = ""
|
|
506
|
+
|
|
507
|
+
if status in {"error", "stuck"}:
|
|
508
|
+
comment_body = _with_ai_disclosure(
|
|
509
|
+
f"⚠️ **OpenHands PR Reviewer encountered a problem** at commit `{reviewed_sha[:12]}` "
|
|
510
|
+
f"(status: `{status}`).\n\n{final}".strip()
|
|
511
|
+
)
|
|
512
|
+
else:
|
|
513
|
+
comment_body = _with_ai_disclosure(
|
|
514
|
+
final
|
|
515
|
+
or f"✅ **OpenHands completed the review for commit `{reviewed_sha[:12]}`.** No review text was produced."
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
_post_github_comment(github_token, REPO, pr_number, comment_body)
|
|
519
|
+
rec["status"] = "closed"
|
|
520
|
+
rec["completed_at"] = time.time()
|
|
521
|
+
print(f" Posted review for PR #{pr_number} at {reviewed_sha[:12]}")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def main() -> str | None:
|
|
525
|
+
state_path = _state_file_path()
|
|
526
|
+
state = load_state(state_path)
|
|
527
|
+
agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
|
|
528
|
+
api_key = _get_env_key()
|
|
529
|
+
|
|
530
|
+
github_token = _resolve_github_token()
|
|
531
|
+
_verify_token_and_repo(github_token, REPO)
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
|
|
535
|
+
except Exception:
|
|
536
|
+
openhands_url = DEFAULT_OPENHANDS_URL
|
|
537
|
+
|
|
538
|
+
reviews: dict = state.setdefault("reviews", {})
|
|
539
|
+
prs_state: dict = state.setdefault("prs", {})
|
|
540
|
+
|
|
541
|
+
open_prs = _list_open_prs(github_token, REPO)
|
|
542
|
+
latest_open_prs = {pr["number"]: pr for pr in open_prs}
|
|
543
|
+
print(f"Found {len(open_prs)} open PR(s) in {REPO}")
|
|
544
|
+
|
|
545
|
+
last_conversation_id = None
|
|
546
|
+
|
|
547
|
+
for pr in open_prs:
|
|
548
|
+
number = pr["number"]
|
|
549
|
+
head_sha = _head_sha(pr)
|
|
550
|
+
label_present = _has_trigger_label(pr)
|
|
551
|
+
prs_state[str(number)] = {
|
|
552
|
+
"head_sha": head_sha,
|
|
553
|
+
"label_present": label_present,
|
|
554
|
+
"labels": _labels(pr),
|
|
555
|
+
"last_seen": time.time(),
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if not label_present:
|
|
559
|
+
continue
|
|
560
|
+
if not head_sha:
|
|
561
|
+
print(f" PR #{number} has no head SHA; skipping")
|
|
562
|
+
continue
|
|
563
|
+
|
|
564
|
+
fresh_pr = _get_pr(github_token, REPO, number)
|
|
565
|
+
fresh_head_sha = _head_sha(fresh_pr)
|
|
566
|
+
if fresh_head_sha != head_sha:
|
|
567
|
+
print(f" PR #{number} head changed during poll ({head_sha[:12]} → {fresh_head_sha[:12]}); using latest PR metadata")
|
|
568
|
+
if not _has_trigger_label(fresh_pr):
|
|
569
|
+
print(f" PR #{number} lost `{TRIGGER_LABEL}` during poll; skipping")
|
|
570
|
+
continue
|
|
571
|
+
|
|
572
|
+
label_event = _latest_trigger_label_event(github_token, REPO, number)
|
|
573
|
+
if not label_event:
|
|
574
|
+
print(f" PR #{number} has `{TRIGGER_LABEL}` but no matching labeled event; skipping")
|
|
575
|
+
continue
|
|
576
|
+
|
|
577
|
+
key = _review_key(number, label_event["id"])
|
|
578
|
+
if key in reviews:
|
|
579
|
+
print(f" PR #{number} label event {label_event['id']} already tracked ({reviews[key].get('status')})")
|
|
580
|
+
continue
|
|
581
|
+
|
|
582
|
+
conv_id = _process_review_request(github_token, agent_url, api_key, openhands_url, fresh_pr, label_event, reviews)
|
|
583
|
+
if conv_id:
|
|
584
|
+
last_conversation_id = conv_id
|
|
585
|
+
|
|
586
|
+
for rec in list(reviews.values()):
|
|
587
|
+
if rec.get("status") != "active":
|
|
588
|
+
continue
|
|
589
|
+
_check_conversation_completion(rec, latest_open_prs, github_token, agent_url, api_key)
|
|
590
|
+
|
|
591
|
+
state["repo"] = REPO
|
|
592
|
+
state["trigger_label"] = TRIGGER_LABEL
|
|
593
|
+
state["updated_at"] = time.time()
|
|
594
|
+
save_state(state_path, state)
|
|
595
|
+
print(f"State saved → {state_path}")
|
|
596
|
+
return last_conversation_id
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
if __name__ == "__main__":
|
|
600
|
+
try:
|
|
601
|
+
conversation_id = main()
|
|
602
|
+
fire_callback("COMPLETED", conversation_id=conversation_id)
|
|
603
|
+
except Exception as exc:
|
|
604
|
+
import traceback
|
|
605
|
+
|
|
606
|
+
traceback.print_exc()
|
|
607
|
+
fire_callback("FAILED", str(exc))
|
|
608
|
+
sys.exit(1)
|