@openhands/extensions 0.4.1 → 0.5.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/.release-please-manifest.json +1 -1
- package/automations/catalog/github-pr-reviewer.json +2 -2
- package/automations/catalog/slack-channel-monitor.json +2 -2
- package/integrations/catalog/github.json +8 -24
- package/integrations/index.d.ts +2 -0
- 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 +85 -66
- package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
- package/skills/github-pr-reviewer/references/state-schema.md +74 -46
- package/skills/github-pr-reviewer/scripts/main.py +210 -227
- package/skills/index.js +3 -3
- package/skills/slack-channel-monitor/README.md +8 -4
- package/skills/slack-channel-monitor/SKILL.md +15 -6
- package/skills/slack-channel-monitor/references/slack-api.md +5 -3
- package/skills/slack-channel-monitor/references/state-schema.md +59 -32
- package/skills/slack-channel-monitor/scripts/main.py +242 -40
|
@@ -1,26 +1,9 @@
|
|
|
1
1
|
"""
|
|
2
|
-
GitHub PR Reviewer
|
|
2
|
+
GitHub PR Reviewer - OpenHands Automation Script
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
2. Creates an OpenHands conversation with a targeted review prompt.
|
|
8
|
-
3. When the conversation completes, posts the AI review as a GitHub comment.
|
|
9
|
-
|
|
10
|
-
On subsequent runs:
|
|
11
|
-
- Already-reviewed PRs are skipped (tracked in the state file).
|
|
12
|
-
- Active conversations are checked for completion and results posted.
|
|
13
|
-
|
|
14
|
-
Configuration constants are embedded at automation-creation time by the skill.
|
|
15
|
-
See SKILL.md for the full setup workflow.
|
|
16
|
-
|
|
17
|
-
Required secret (set in OpenHands Settings → Secrets):
|
|
18
|
-
GITHUB_PERSONAL_ACCESS_TOKEN - Personal Access Token
|
|
19
|
-
Classic PAT: 'repo' scope (private) or 'public_repo' (public)
|
|
20
|
-
Fine-grained PAT: Pull requests: Read and Write
|
|
21
|
-
|
|
22
|
-
Optional secret:
|
|
23
|
-
OPENHANDS_URL - base URL for conversation links (default: http://localhost:8000)
|
|
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.
|
|
24
7
|
"""
|
|
25
8
|
|
|
26
9
|
import json
|
|
@@ -29,38 +12,24 @@ import sys
|
|
|
29
12
|
import time
|
|
30
13
|
import urllib.error
|
|
31
14
|
import urllib.request
|
|
32
|
-
from datetime import datetime, timezone
|
|
33
15
|
from pathlib import Path
|
|
34
16
|
from urllib.parse import urlencode
|
|
35
17
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
REVIEW_TONE = "thorough"
|
|
39
|
-
REVIEW_STYLE_INSTRUCTIONS = ""
|
|
18
|
+
REPO = "owner/repo"
|
|
19
|
+
TRIGGER_LABEL = "openhands-review"
|
|
20
|
+
REVIEW_TONE = "thorough"
|
|
21
|
+
REVIEW_STYLE_INSTRUCTIONS = ""
|
|
40
22
|
DEFAULT_OPENHANDS_URL = "http://localhost:8000"
|
|
41
23
|
|
|
42
|
-
# Max diff lines to include in the review prompt (avoids token overrun).
|
|
43
|
-
MAX_DIFF_LINES = 500
|
|
44
|
-
|
|
45
|
-
# PRs whose diff exceeds this many lines are skipped with an explanatory comment.
|
|
46
|
-
MAX_DIFF_LINES_SKIP = 5000
|
|
47
|
-
|
|
48
|
-
# Prevent posting summaries in the same run that created the conversation.
|
|
49
24
|
DONE_DEBOUNCE = 15
|
|
25
|
+
TERMINAL_STATUSES = {"idle", "finished", "error", "stuck"}
|
|
50
26
|
|
|
51
27
|
|
|
52
|
-
# ── Stdlib helpers ─────────────────────────────────────────────────────────────
|
|
53
|
-
|
|
54
28
|
def _get_env_key() -> str:
|
|
55
|
-
return (
|
|
56
|
-
os.environ.get("SESSION_API_KEY")
|
|
57
|
-
or os.environ.get("OH_SESSION_API_KEYS_0")
|
|
58
|
-
or ""
|
|
59
|
-
)
|
|
29
|
+
return os.environ.get("SESSION_API_KEY") or os.environ.get("OH_SESSION_API_KEYS_0") or ""
|
|
60
30
|
|
|
61
31
|
|
|
62
32
|
def get_secret(name: str) -> str:
|
|
63
|
-
"""Fetch a named secret from the agent server."""
|
|
64
33
|
url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
|
|
65
34
|
key = _get_env_key()
|
|
66
35
|
req = urllib.request.Request(
|
|
@@ -76,7 +45,6 @@ def fire_callback(
|
|
|
76
45
|
error: str | None = None,
|
|
77
46
|
conversation_id: str | None = None,
|
|
78
47
|
) -> None:
|
|
79
|
-
"""Signal run completion to the automation service."""
|
|
80
48
|
url = os.environ.get("AUTOMATION_CALLBACK_URL", "")
|
|
81
49
|
if not url:
|
|
82
50
|
return
|
|
@@ -99,14 +67,7 @@ def fire_callback(
|
|
|
99
67
|
print(f"Callback error (non-fatal): {exc}")
|
|
100
68
|
|
|
101
69
|
|
|
102
|
-
# ── State management ───────────────────────────────────────────────────────────
|
|
103
|
-
|
|
104
70
|
def _state_file_path() -> str:
|
|
105
|
-
"""Derive a persistent storage path from WORKSPACE_BASE.
|
|
106
|
-
|
|
107
|
-
WORKSPACE_BASE = {root}/automation-runs/{run_id}
|
|
108
|
-
State lives two levels up at {root}/automation-state/.
|
|
109
|
-
"""
|
|
110
71
|
workspace_base = os.environ.get("WORKSPACE_BASE", "")
|
|
111
72
|
event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}"))
|
|
112
73
|
automation_id = event_payload.get("automation_id", "default")
|
|
@@ -118,7 +79,7 @@ def _state_file_path() -> str:
|
|
|
118
79
|
|
|
119
80
|
state_dir = root / "automation-state"
|
|
120
81
|
state_dir.mkdir(parents=True, exist_ok=True)
|
|
121
|
-
return str(state_dir / f"
|
|
82
|
+
return str(state_dir / f"github_pr_reviewer_label_event_{automation_id}.json")
|
|
122
83
|
|
|
123
84
|
|
|
124
85
|
def load_state(path: str) -> dict:
|
|
@@ -129,18 +90,20 @@ def load_state(path: str) -> dict:
|
|
|
129
90
|
except (json.JSONDecodeError, OSError) as exc:
|
|
130
91
|
print(f"Warning: state file {path} unreadable ({exc}); starting fresh")
|
|
131
92
|
return {
|
|
132
|
-
"version":
|
|
93
|
+
"version": 2,
|
|
133
94
|
"repo": REPO,
|
|
134
|
-
"
|
|
95
|
+
"trigger_label": TRIGGER_LABEL,
|
|
96
|
+
"reviews": {},
|
|
97
|
+
"prs": {},
|
|
135
98
|
}
|
|
136
99
|
|
|
137
100
|
|
|
138
101
|
def save_state(path: str, state: dict) -> None:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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)
|
|
142
106
|
|
|
143
|
-
# ── GitHub API helpers ─────────────────────────────────────────────────────────
|
|
144
107
|
|
|
145
108
|
def _github_request(
|
|
146
109
|
token: str,
|
|
@@ -150,9 +113,7 @@ def _github_request(
|
|
|
150
113
|
body: dict | None = None,
|
|
151
114
|
accept: str = "application/vnd.github+json",
|
|
152
115
|
) -> tuple:
|
|
153
|
-
|
|
154
|
-
base = "https://api.github.com"
|
|
155
|
-
url = f"{base}{path}"
|
|
116
|
+
url = f"https://api.github.com{path}"
|
|
156
117
|
if params:
|
|
157
118
|
url = f"{url}?{urlencode(params)}"
|
|
158
119
|
headers = {
|
|
@@ -164,15 +125,11 @@ def _github_request(
|
|
|
164
125
|
data = json.dumps(body).encode() if body is not None else None
|
|
165
126
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
166
127
|
with urllib.request.urlopen(req) as r:
|
|
167
|
-
resp_headers = dict(r.headers)
|
|
168
128
|
raw = r.read()
|
|
169
|
-
if
|
|
170
|
-
return raw.decode("utf-8", errors="replace"), resp_headers
|
|
171
|
-
return (json.loads(raw) if raw.strip() else {}), resp_headers
|
|
129
|
+
return (json.loads(raw) if raw.strip() else {}), dict(r.headers)
|
|
172
130
|
|
|
173
131
|
|
|
174
132
|
def _github_paginate(token: str, path: str, params: dict | None = None) -> list:
|
|
175
|
-
"""Fetch all pages from a GitHub list endpoint."""
|
|
176
133
|
results = []
|
|
177
134
|
page = 1
|
|
178
135
|
base_params = dict(params or {})
|
|
@@ -202,57 +159,59 @@ def _resolve_github_token() -> str:
|
|
|
202
159
|
)
|
|
203
160
|
|
|
204
161
|
|
|
205
|
-
def _verify_token_and_repo(token: str, repo: str) ->
|
|
206
|
-
"""Verify token validity and repo access. Returns the authenticated GitHub username."""
|
|
162
|
+
def _verify_token_and_repo(token: str, repo: str) -> None:
|
|
207
163
|
try:
|
|
208
164
|
user_data, _ = _github_request(token, "GET", "/user")
|
|
209
165
|
except urllib.error.HTTPError as exc:
|
|
210
166
|
if exc.code == 401:
|
|
211
|
-
raise RuntimeError(
|
|
212
|
-
|
|
213
|
-
"Update it in OpenHands Settings → Secrets."
|
|
214
|
-
)
|
|
215
|
-
raise RuntimeError(f"GitHub /user check failed: {exc.code}")
|
|
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
|
|
216
169
|
|
|
217
|
-
|
|
218
|
-
print(f"Authenticated as GitHub user: {username}")
|
|
170
|
+
print(f"Authenticated as GitHub user: {user_data.get('login', '?')}")
|
|
219
171
|
|
|
220
172
|
try:
|
|
221
173
|
_github_request(token, "GET", f"/repos/{repo}")
|
|
222
174
|
except urllib.error.HTTPError as exc:
|
|
223
175
|
if exc.code == 404:
|
|
224
|
-
raise RuntimeError(
|
|
225
|
-
|
|
226
|
-
)
|
|
227
|
-
raise RuntimeError(f"GitHub /repos/{repo} check failed: {exc.code}")
|
|
228
|
-
|
|
229
|
-
print(f"Repository '{repo}' accessible.")
|
|
230
|
-
return username
|
|
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
|
|
231
178
|
|
|
232
179
|
|
|
233
180
|
def _list_open_prs(token: str, repo: str) -> list[dict]:
|
|
234
|
-
"""Fetch all open pull requests, oldest first."""
|
|
235
181
|
return _github_paginate(
|
|
236
182
|
token,
|
|
237
183
|
f"/repos/{repo}/pulls",
|
|
238
|
-
{"state": "open", "sort": "
|
|
184
|
+
{"state": "open", "sort": "updated", "direction": "desc"},
|
|
239
185
|
)
|
|
240
186
|
|
|
241
187
|
|
|
242
|
-
def
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
return
|
|
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)))
|
|
249
208
|
|
|
250
209
|
|
|
251
210
|
def _post_github_comment(token: str, repo: str, pr_number: int, body: str) -> None:
|
|
252
|
-
"""Post a comment on a pull request."""
|
|
253
211
|
try:
|
|
254
212
|
_github_request(
|
|
255
|
-
token,
|
|
213
|
+
token,
|
|
214
|
+
"POST",
|
|
256
215
|
f"/repos/{repo}/issues/{pr_number}/comments",
|
|
257
216
|
body={"body": body},
|
|
258
217
|
)
|
|
@@ -260,11 +219,7 @@ def _post_github_comment(token: str, repo: str, pr_number: int, body: str) -> No
|
|
|
260
219
|
print(f" Warning: failed to post comment on PR #{pr_number}: {exc}")
|
|
261
220
|
|
|
262
221
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def _oh_request(
|
|
266
|
-
agent_url: str, api_key: str, method: str, path: str, body: dict | None = None
|
|
267
|
-
) -> dict:
|
|
222
|
+
def _oh_request(agent_url: str, api_key: str, method: str, path: str, body: dict | None = None) -> dict:
|
|
268
223
|
url = f"{agent_url}{path}"
|
|
269
224
|
headers = {"X-Session-API-Key": api_key, "Content-Type": "application/json"}
|
|
270
225
|
data = json.dumps(body).encode() if body is not None else None
|
|
@@ -279,17 +234,17 @@ def _oh_request(
|
|
|
279
234
|
|
|
280
235
|
|
|
281
236
|
def _fetch_settings(agent_url: str, api_key: str) -> dict:
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
237
|
+
req = urllib.request.Request(
|
|
238
|
+
f"{agent_url}/api/settings",
|
|
239
|
+
headers={"X-Session-API-Key": api_key, "X-Expose-Secrets": "plaintext"},
|
|
240
|
+
)
|
|
285
241
|
with urllib.request.urlopen(req) as r:
|
|
286
242
|
return json.loads(r.read())
|
|
287
243
|
|
|
288
244
|
|
|
289
245
|
def _get_agent_dict(agent_url: str, api_key: str) -> dict:
|
|
290
246
|
data = _fetch_settings(agent_url, api_key)
|
|
291
|
-
|
|
292
|
-
llm = agent_settings.get("llm", {})
|
|
247
|
+
llm = data.get("agent_settings", {}).get("llm", {})
|
|
293
248
|
return {
|
|
294
249
|
"kind": "Agent",
|
|
295
250
|
"llm": llm,
|
|
@@ -318,9 +273,8 @@ def _list_secret_names(agent_url: str, api_key: str) -> list[dict]:
|
|
|
318
273
|
|
|
319
274
|
|
|
320
275
|
def _build_secrets_payload(agent_url: str, api_key: str) -> dict:
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
for secret in secrets_list:
|
|
276
|
+
secrets = {}
|
|
277
|
+
for secret in _list_secret_names(agent_url, api_key):
|
|
324
278
|
name = secret.get("name", "")
|
|
325
279
|
if not name:
|
|
326
280
|
continue
|
|
@@ -338,12 +292,10 @@ def _build_secrets_payload(agent_url: str, api_key: str) -> dict:
|
|
|
338
292
|
|
|
339
293
|
|
|
340
294
|
def create_conversation(agent_url: str, api_key: str, initial_message: str) -> str:
|
|
341
|
-
"""Create an OpenHands conversation and return its ID."""
|
|
342
295
|
workspace_dir = os.environ.get("WORKSPACE_BASE", "/workspace")
|
|
343
|
-
agent = _get_agent_dict(agent_url, api_key)
|
|
344
296
|
payload: dict = {
|
|
345
297
|
"workspace": {"working_dir": workspace_dir},
|
|
346
|
-
"agent":
|
|
298
|
+
"agent": _get_agent_dict(agent_url, api_key),
|
|
347
299
|
"initial_message": {"content": [{"text": initial_message}]},
|
|
348
300
|
}
|
|
349
301
|
secrets = _build_secrets_payload(agent_url, api_key)
|
|
@@ -362,33 +314,52 @@ def conversation_status(agent_url: str, api_key: str, conv_id: str) -> str:
|
|
|
362
314
|
|
|
363
315
|
|
|
364
316
|
def conversation_final_response(agent_url: str, api_key: str, conv_id: str) -> str:
|
|
365
|
-
result = _oh_request(
|
|
366
|
-
agent_url, api_key, "GET",
|
|
367
|
-
f"/api/conversations/{conv_id}/agent_final_response",
|
|
368
|
-
)
|
|
317
|
+
result = _oh_request(agent_url, api_key, "GET", f"/api/conversations/{conv_id}/agent_final_response")
|
|
369
318
|
return result.get("response", "")
|
|
370
319
|
|
|
371
320
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
_TONE_INSTRUCTIONS: dict[str, str] = {
|
|
321
|
+
_TONE_INSTRUCTIONS = {
|
|
375
322
|
"thorough": (
|
|
376
323
|
"Provide a comprehensive review. Cover correctness, security vulnerabilities, "
|
|
377
|
-
"missing or inadequate tests, code style, maintainability, and potential edge "
|
|
378
|
-
"
|
|
324
|
+
"missing or inadequate tests, code style, maintainability, and potential edge cases. "
|
|
325
|
+
"Reference specific files and line numbers where relevant."
|
|
379
326
|
),
|
|
380
327
|
"concise": (
|
|
381
|
-
"Provide a brief, high-signal review. Focus only on
|
|
382
|
-
"
|
|
328
|
+
"Provide a brief, high-signal review. Focus only on important bugs, security problems, "
|
|
329
|
+
"or significant design flaws. Omit minor style feedback."
|
|
383
330
|
),
|
|
384
331
|
"friendly": (
|
|
385
332
|
"Provide a constructive, encouraging review. Acknowledge what is done well before "
|
|
386
|
-
"raising concerns
|
|
333
|
+
"raising concerns while still noting real issues."
|
|
387
334
|
),
|
|
388
335
|
}
|
|
389
336
|
|
|
390
337
|
|
|
391
|
-
def
|
|
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:
|
|
392
363
|
number = pr.get("number", "?")
|
|
393
364
|
title = pr.get("title", "(no title)")
|
|
394
365
|
body = (pr.get("body") or "").strip() or "(no description)"
|
|
@@ -396,90 +367,67 @@ def _build_review_prompt(pr: dict, diff: str, diff_truncated: bool) -> str:
|
|
|
396
367
|
author = (pr.get("user") or {}).get("login", "?")
|
|
397
368
|
base_branch = (pr.get("base") or {}).get("ref", "?")
|
|
398
369
|
head_branch = (pr.get("head") or {}).get("ref", "?")
|
|
399
|
-
|
|
400
|
-
|
|
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", "?")
|
|
401
373
|
changed_files = pr.get("changed_files", "?")
|
|
402
374
|
additions = pr.get("additions", "?")
|
|
403
375
|
deletions = pr.get("deletions", "?")
|
|
404
|
-
|
|
376
|
+
clone_url = f"https://github.com/{REPO}.git"
|
|
405
377
|
tone = _TONE_INSTRUCTIONS.get(REVIEW_TONE, _TONE_INSTRUCTIONS["thorough"])
|
|
406
|
-
extra = (
|
|
407
|
-
f"\n\nAdditional style instructions:\n{REVIEW_STYLE_INSTRUCTIONS}"
|
|
408
|
-
if REVIEW_STYLE_INSTRUCTIONS.strip()
|
|
409
|
-
else ""
|
|
410
|
-
)
|
|
411
|
-
truncation_note = (
|
|
412
|
-
f"\n\n⚠️ The diff below has been truncated to the first {MAX_DIFF_LINES} lines. "
|
|
413
|
-
"Review what is available and note that the full diff is larger."
|
|
414
|
-
) if diff_truncated else ""
|
|
378
|
+
extra = f"\n\nAdditional style instructions:\n{REVIEW_STYLE_INSTRUCTIONS}" if REVIEW_STYLE_INSTRUCTIONS.strip() else ""
|
|
415
379
|
|
|
416
380
|
return (
|
|
417
|
-
|
|
418
|
-
|
|
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"
|
|
419
384
|
f"Repository : {REPO}\n"
|
|
385
|
+
f"Clone URL : {clone_url}\n"
|
|
420
386
|
f"PR #{number}: \"{title}\"\n"
|
|
421
387
|
f"Author : @{author}\n"
|
|
422
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"
|
|
423
391
|
f"Labels : {label_str}\n"
|
|
424
392
|
f"Changes : +{additions} -{deletions} across {changed_files} file(s)\n"
|
|
425
393
|
f"URL : {html_url}\n"
|
|
426
|
-
f"\nPR Description:\n---\n{body}\n---\n"
|
|
427
|
-
|
|
428
|
-
|
|
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"
|
|
429
406
|
f"\nReview instructions:\n{tone}{extra}\n\n"
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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`."
|
|
434
411
|
)
|
|
435
412
|
|
|
436
|
-
|
|
437
|
-
# ── Core logic ─────────────────────────────────────────────────────────────────
|
|
438
|
-
|
|
439
|
-
def _process_new_pr(
|
|
413
|
+
def _process_review_request(
|
|
440
414
|
github_token: str,
|
|
441
415
|
agent_url: str,
|
|
442
416
|
api_key: str,
|
|
443
417
|
openhands_url: str,
|
|
444
418
|
pr: dict,
|
|
445
|
-
|
|
419
|
+
label_event: dict,
|
|
420
|
+
reviews: dict,
|
|
446
421
|
) -> str | None:
|
|
447
|
-
"""Start a review conversation for a PR not yet seen. Returns the conversation ID."""
|
|
448
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)
|
|
449
426
|
title = pr.get("title", "(no title)")
|
|
450
427
|
html_url = pr.get("html_url", "")
|
|
451
|
-
print(f" New PR #{number}: \"{title}\"")
|
|
452
|
-
|
|
453
|
-
try:
|
|
454
|
-
diff = _get_pr_diff(github_token, REPO, number)
|
|
455
|
-
except Exception as exc:
|
|
456
|
-
print(f" Warning: could not fetch diff for PR #{number}: {exc}")
|
|
457
|
-
return None
|
|
458
|
-
|
|
459
|
-
diff_lines = diff.splitlines()
|
|
460
|
-
|
|
461
|
-
if len(diff_lines) > MAX_DIFF_LINES_SKIP:
|
|
462
|
-
print(f" Skipping PR #{number}: diff too large ({len(diff_lines)} lines)")
|
|
463
|
-
conversations[str(number)] = {
|
|
464
|
-
"pr_number": number,
|
|
465
|
-
"html_url": html_url,
|
|
466
|
-
"status": "skipped",
|
|
467
|
-
"reason": f"diff too large ({len(diff_lines)} lines)",
|
|
468
|
-
"last_activity": time.time(),
|
|
469
|
-
}
|
|
470
|
-
_post_github_comment(
|
|
471
|
-
github_token, REPO, number,
|
|
472
|
-
f"⚠️ **OpenHands PR Reviewer**: This PR's diff is too large to review "
|
|
473
|
-
f"automatically ({len(diff_lines):,} lines). Consider splitting it into "
|
|
474
|
-
f"smaller PRs.\n\n_This message was posted by an AI agent (OpenHands)._",
|
|
475
|
-
)
|
|
476
|
-
return None
|
|
477
|
-
|
|
478
|
-
diff_truncated = len(diff_lines) > MAX_DIFF_LINES
|
|
479
|
-
if diff_truncated:
|
|
480
|
-
diff = "\n".join(diff_lines[:MAX_DIFF_LINES])
|
|
481
428
|
|
|
482
|
-
|
|
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)
|
|
483
431
|
|
|
484
432
|
try:
|
|
485
433
|
conv_id = create_conversation(agent_url, api_key, prompt)
|
|
@@ -487,8 +435,11 @@ def _process_new_pr(
|
|
|
487
435
|
print(f" Error creating conversation for PR #{number}: {exc}")
|
|
488
436
|
return None
|
|
489
437
|
|
|
490
|
-
|
|
438
|
+
reviews[key] = {
|
|
491
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"),
|
|
492
443
|
"html_url": html_url,
|
|
493
444
|
"status": "active",
|
|
494
445
|
"conversation_id": conv_id,
|
|
@@ -498,26 +449,45 @@ def _process_new_pr(
|
|
|
498
449
|
|
|
499
450
|
conv_url = f"{openhands_url}/conversations/{conv_id}"
|
|
500
451
|
_post_github_comment(
|
|
501
|
-
github_token,
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
+
),
|
|
505
462
|
)
|
|
506
463
|
return conv_id
|
|
507
464
|
|
|
508
|
-
|
|
509
465
|
def _check_conversation_completion(
|
|
510
466
|
rec: dict,
|
|
467
|
+
latest_open_prs: dict[int, dict],
|
|
511
468
|
github_token: str,
|
|
512
469
|
agent_url: str,
|
|
513
470
|
api_key: str,
|
|
514
471
|
) -> None:
|
|
515
|
-
"""Post the review result and close the conversation record once it finishes."""
|
|
516
472
|
if (time.time() - rec.get("last_activity", 0.0)) < DONE_DEBOUNCE:
|
|
517
473
|
return
|
|
518
474
|
|
|
519
475
|
conv_id = rec["conversation_id"]
|
|
520
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
|
|
521
491
|
|
|
522
492
|
try:
|
|
523
493
|
status = conversation_status(agent_url, api_key, conv_id)
|
|
@@ -526,8 +496,7 @@ def _check_conversation_completion(
|
|
|
526
496
|
return
|
|
527
497
|
|
|
528
498
|
print(f" PR #{pr_number} conversation {conv_id} → status={status}")
|
|
529
|
-
|
|
530
|
-
if status not in ("idle", "finished", "error", "stuck"):
|
|
499
|
+
if status not in TERMINAL_STATUSES:
|
|
531
500
|
return
|
|
532
501
|
|
|
533
502
|
try:
|
|
@@ -535,33 +504,26 @@ def _check_conversation_completion(
|
|
|
535
504
|
except Exception:
|
|
536
505
|
final = ""
|
|
537
506
|
|
|
538
|
-
if status in
|
|
539
|
-
comment_body = (
|
|
540
|
-
f"⚠️ **OpenHands PR Reviewer encountered a problem**
|
|
541
|
-
|
|
542
|
-
+ "_This message was posted by an AI agent (OpenHands)._"
|
|
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()
|
|
543
511
|
)
|
|
544
512
|
else:
|
|
545
|
-
comment_body = (
|
|
546
|
-
final
|
|
547
|
-
|
|
548
|
-
"✅ **OpenHands completed the review.** (No review text was produced.)\n\n"
|
|
549
|
-
"_This message was posted by an AI agent (OpenHands)._"
|
|
550
|
-
)
|
|
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."
|
|
551
516
|
)
|
|
552
517
|
|
|
553
518
|
_post_github_comment(github_token, REPO, pr_number, comment_body)
|
|
554
519
|
rec["status"] = "closed"
|
|
555
|
-
|
|
556
|
-
|
|
520
|
+
rec["completed_at"] = time.time()
|
|
521
|
+
print(f" Posted review for PR #{pr_number} at {reviewed_sha[:12]}")
|
|
557
522
|
|
|
558
|
-
# ── Main ───────────────────────────────────────────────────────────────────────
|
|
559
523
|
|
|
560
524
|
def main() -> str | None:
|
|
561
|
-
"""Run one polling cycle. Returns the last conversation ID created, if any."""
|
|
562
525
|
state_path = _state_file_path()
|
|
563
526
|
state = load_state(state_path)
|
|
564
|
-
|
|
565
527
|
agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
|
|
566
528
|
api_key = _get_env_key()
|
|
567
529
|
|
|
@@ -573,42 +535,62 @@ def main() -> str | None:
|
|
|
573
535
|
except Exception:
|
|
574
536
|
openhands_url = DEFAULT_OPENHANDS_URL
|
|
575
537
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
try:
|
|
579
|
-
open_prs = _list_open_prs(github_token, REPO)
|
|
580
|
-
except Exception as exc:
|
|
581
|
-
raise RuntimeError(f"Failed to list open PRs for {REPO}: {exc}") from exc
|
|
538
|
+
reviews: dict = state.setdefault("reviews", {})
|
|
539
|
+
prs_state: dict = state.setdefault("prs", {})
|
|
582
540
|
|
|
541
|
+
open_prs = _list_open_prs(github_token, REPO)
|
|
542
|
+
latest_open_prs = {pr["number"]: pr for pr in open_prs}
|
|
583
543
|
print(f"Found {len(open_prs)} open PR(s) in {REPO}")
|
|
584
544
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
last_conversation_id: str | None = None
|
|
545
|
+
last_conversation_id = None
|
|
588
546
|
|
|
589
|
-
# Queue reviews for PRs not yet in state.
|
|
590
547
|
for pr in open_prs:
|
|
591
|
-
|
|
592
|
-
|
|
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:
|
|
593
559
|
continue
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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)
|
|
597
583
|
if conv_id:
|
|
598
584
|
last_conversation_id = conv_id
|
|
599
585
|
|
|
600
|
-
|
|
601
|
-
for key, rec in list(conversations.items()):
|
|
586
|
+
for rec in list(reviews.values()):
|
|
602
587
|
if rec.get("status") != "active":
|
|
603
588
|
continue
|
|
604
|
-
|
|
605
|
-
# PR was closed/merged before review completed — mark closed silently.
|
|
606
|
-
rec["status"] = "closed"
|
|
607
|
-
print(f" PR #{rec.get('pr_number')} closed/merged — skipping result post")
|
|
608
|
-
continue
|
|
609
|
-
_check_conversation_completion(rec, github_token, agent_url, api_key)
|
|
589
|
+
_check_conversation_completion(rec, latest_open_prs, github_token, agent_url, api_key)
|
|
610
590
|
|
|
611
|
-
state["
|
|
591
|
+
state["repo"] = REPO
|
|
592
|
+
state["trigger_label"] = TRIGGER_LABEL
|
|
593
|
+
state["updated_at"] = time.time()
|
|
612
594
|
save_state(state_path, state)
|
|
613
595
|
print(f"State saved → {state_path}")
|
|
614
596
|
return last_conversation_id
|
|
@@ -620,6 +602,7 @@ if __name__ == "__main__":
|
|
|
620
602
|
fire_callback("COMPLETED", conversation_id=conversation_id)
|
|
621
603
|
except Exception as exc:
|
|
622
604
|
import traceback
|
|
605
|
+
|
|
623
606
|
traceback.print_exc()
|
|
624
607
|
fire_callback("FAILED", str(exc))
|
|
625
608
|
sys.exit(1)
|