@openhands/extensions 0.3.0 → 0.4.1
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/README.md +3 -2
- package/automations/catalog/github-pr-reviewer.json +1 -1
- package/automations/catalog/github-repo-monitor.json +1 -1
- package/marketplaces/openhands-extensions.json +13 -0
- package/package.json +1 -1
- package/plugins/issue-duplicate-checker/action.yml +2 -2
- package/plugins/pr-review/README.md +1 -1
- package/plugins/qa-changes/action.yml +4 -2
- package/plugins/release-notes/action.yml +3 -2
- package/plugins/release-notes/workflows/release-notes.yml +10 -4
- package/plugins/vulnerability-remediation/README.md +4 -2
- package/plugins/vulnerability-remediation/action.yml +8 -4
- package/pyproject.toml +1 -1
- package/scripts/sync_extensions.py +2 -1
- package/skills/github-actions/.plugin/plugin.json +19 -0
- package/skills/github-actions/README.md +190 -0
- package/skills/github-actions/SKILL.md +44 -0
- package/skills/github-pr-reviewer/SKILL.md +183 -40
- package/skills/github-pr-reviewer/references/state-schema.md +98 -0
- package/skills/github-pr-reviewer/scripts/main.py +625 -0
- package/skills/github-repo-monitor/README.md +1 -1
- package/skills/github-repo-monitor/SKILL.md +9 -9
- package/skills/github-repo-monitor/references/github-api.md +3 -3
- package/skills/github-repo-monitor/scripts/main.py +11 -11
- package/skills/index.js +16 -4
- package/skills/openhands-sdk/SKILL.md +3 -0
- package/skills/slack-channel-monitor/README.md +2 -1
- package/skills/slack-channel-monitor/SKILL.md +22 -4
- package/tests/test_sync_extensions.py +14 -0
- /package/plugins/issue-duplicate-checker/scripts/{post_duplicate_notice.js → post_duplicate_notice.cjs} +0 -0
- /package/plugins/issue-duplicate-checker/scripts/{remove_duplicate_candidate_label.js → remove_duplicate_candidate_label.cjs} +0 -0
- /package/skills/github-pr-reviewer/commands/{pr-reviewer:setup.md → pr-reviewer-setup.md} +0 -0
- /package/skills/github-repo-monitor/commands/{github-monitor:poll.md → github-monitor-poll.md} +0 -0
- /package/skills/incident-retrospective/commands/{incident-retro:setup.md → incident-retro-setup.md} +0 -0
- /package/skills/linear-triage/commands/{linear-triage:setup.md → linear-triage-setup.md} +0 -0
- /package/skills/openhands-automation/commands/{automation:create.md → automation-create.md} +0 -0
- /package/skills/research-brief/commands/{research-brief:setup.md → research-brief-setup.md} +0 -0
- /package/skills/slack-channel-monitor/commands/{slack-monitor:poll.md → slack-monitor-poll.md} +0 -0
- /package/skills/slack-standup-digest/commands/{standup-digest:setup.md → standup-digest-setup.md} +0 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GitHub PR Reviewer - OpenHands Automation Script
|
|
3
|
+
|
|
4
|
+
Polls a GitHub repository on a cron schedule. For each open pull request
|
|
5
|
+
that has not yet been reviewed it:
|
|
6
|
+
1. Fetches the PR metadata and diff.
|
|
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)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.request
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from urllib.parse import urlencode
|
|
35
|
+
|
|
36
|
+
# ── Embedded configuration (filled in by the skill at creation time) ──────────
|
|
37
|
+
REPO = "owner/repo" # e.g. "myorg/backend"
|
|
38
|
+
REVIEW_TONE = "thorough" # "thorough" | "concise" | "friendly"
|
|
39
|
+
REVIEW_STYLE_INSTRUCTIONS = "" # extra free-form persona / style notes
|
|
40
|
+
DEFAULT_OPENHANDS_URL = "http://localhost:8000"
|
|
41
|
+
|
|
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
|
+
DONE_DEBOUNCE = 15
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── Stdlib helpers ─────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
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
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_secret(name: str) -> str:
|
|
63
|
+
"""Fetch a named secret from the agent server."""
|
|
64
|
+
url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
|
|
65
|
+
key = _get_env_key()
|
|
66
|
+
req = urllib.request.Request(
|
|
67
|
+
f"{url}/api/settings/secrets/{name}",
|
|
68
|
+
headers={"X-Session-API-Key": key},
|
|
69
|
+
)
|
|
70
|
+
with urllib.request.urlopen(req) as r:
|
|
71
|
+
return r.read().decode().strip()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fire_callback(
|
|
75
|
+
status: str = "COMPLETED",
|
|
76
|
+
error: str | None = None,
|
|
77
|
+
conversation_id: str | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Signal run completion to the automation service."""
|
|
80
|
+
url = os.environ.get("AUTOMATION_CALLBACK_URL", "")
|
|
81
|
+
if not url:
|
|
82
|
+
return
|
|
83
|
+
body: dict = {"status": status, "run_id": os.environ.get("AUTOMATION_RUN_ID", "")}
|
|
84
|
+
if error:
|
|
85
|
+
body["error"] = error
|
|
86
|
+
if conversation_id:
|
|
87
|
+
body["conversation_id"] = conversation_id
|
|
88
|
+
req = urllib.request.Request(
|
|
89
|
+
url,
|
|
90
|
+
data=json.dumps(body).encode(),
|
|
91
|
+
headers={
|
|
92
|
+
"Content-Type": "application/json",
|
|
93
|
+
"Authorization": f"Bearer {os.environ.get('AUTOMATION_CALLBACK_API_KEY', '')}",
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
try:
|
|
97
|
+
urllib.request.urlopen(req)
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
print(f"Callback error (non-fatal): {exc}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── State management ───────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
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
|
+
workspace_base = os.environ.get("WORKSPACE_BASE", "")
|
|
111
|
+
event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}"))
|
|
112
|
+
automation_id = event_payload.get("automation_id", "default")
|
|
113
|
+
|
|
114
|
+
if workspace_base:
|
|
115
|
+
root = Path(workspace_base).resolve().parent.parent
|
|
116
|
+
else:
|
|
117
|
+
root = Path.home() / ".openhands" / "workspaces"
|
|
118
|
+
|
|
119
|
+
state_dir = root / "automation-state"
|
|
120
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
return str(state_dir / f"github_pr_reviewer_{automation_id}.json")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load_state(path: str) -> dict:
|
|
125
|
+
if os.path.exists(path):
|
|
126
|
+
try:
|
|
127
|
+
with open(path) as f:
|
|
128
|
+
return json.load(f)
|
|
129
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
130
|
+
print(f"Warning: state file {path} unreadable ({exc}); starting fresh")
|
|
131
|
+
return {
|
|
132
|
+
"version": 1,
|
|
133
|
+
"repo": REPO,
|
|
134
|
+
"conversations": {}, # pr_number (str) → ConversationRecord
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def save_state(path: str, state: dict) -> None:
|
|
139
|
+
with open(path, "w") as f:
|
|
140
|
+
json.dump(state, f, indent=2)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ── GitHub API helpers ─────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
def _github_request(
|
|
146
|
+
token: str,
|
|
147
|
+
method: str,
|
|
148
|
+
path: str,
|
|
149
|
+
params: dict | None = None,
|
|
150
|
+
body: dict | None = None,
|
|
151
|
+
accept: str = "application/vnd.github+json",
|
|
152
|
+
) -> tuple:
|
|
153
|
+
"""Low-level GitHub API call. Returns (parsed_body, response_headers)."""
|
|
154
|
+
base = "https://api.github.com"
|
|
155
|
+
url = f"{base}{path}"
|
|
156
|
+
if params:
|
|
157
|
+
url = f"{url}?{urlencode(params)}"
|
|
158
|
+
headers = {
|
|
159
|
+
"Authorization": f"Bearer {token}",
|
|
160
|
+
"Accept": accept,
|
|
161
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
162
|
+
"Content-Type": "application/json",
|
|
163
|
+
}
|
|
164
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
165
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
166
|
+
with urllib.request.urlopen(req) as r:
|
|
167
|
+
resp_headers = dict(r.headers)
|
|
168
|
+
raw = r.read()
|
|
169
|
+
if accept == "application/vnd.github.diff":
|
|
170
|
+
return raw.decode("utf-8", errors="replace"), resp_headers
|
|
171
|
+
return (json.loads(raw) if raw.strip() else {}), resp_headers
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _github_paginate(token: str, path: str, params: dict | None = None) -> list:
|
|
175
|
+
"""Fetch all pages from a GitHub list endpoint."""
|
|
176
|
+
results = []
|
|
177
|
+
page = 1
|
|
178
|
+
base_params = dict(params or {})
|
|
179
|
+
base_params.setdefault("per_page", 100)
|
|
180
|
+
while True:
|
|
181
|
+
base_params["page"] = page
|
|
182
|
+
data, _ = _github_request(token, "GET", path, params=base_params)
|
|
183
|
+
if not isinstance(data, list):
|
|
184
|
+
break
|
|
185
|
+
results.extend(data)
|
|
186
|
+
if len(data) < base_params["per_page"]:
|
|
187
|
+
break
|
|
188
|
+
page += 1
|
|
189
|
+
return results
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _resolve_github_token() -> str:
|
|
193
|
+
try:
|
|
194
|
+
token = get_secret("GITHUB_PERSONAL_ACCESS_TOKEN")
|
|
195
|
+
if token:
|
|
196
|
+
return token
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
raise RuntimeError(
|
|
200
|
+
"GITHUB_PERSONAL_ACCESS_TOKEN secret is not set. "
|
|
201
|
+
"Go to OpenHands Settings → Secrets and add your GitHub Personal Access Token."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _verify_token_and_repo(token: str, repo: str) -> str:
|
|
206
|
+
"""Verify token validity and repo access. Returns the authenticated GitHub username."""
|
|
207
|
+
try:
|
|
208
|
+
user_data, _ = _github_request(token, "GET", "/user")
|
|
209
|
+
except urllib.error.HTTPError as exc:
|
|
210
|
+
if exc.code == 401:
|
|
211
|
+
raise RuntimeError(
|
|
212
|
+
"GITHUB_PERSONAL_ACCESS_TOKEN is invalid or expired. "
|
|
213
|
+
"Update it in OpenHands Settings → Secrets."
|
|
214
|
+
)
|
|
215
|
+
raise RuntimeError(f"GitHub /user check failed: {exc.code}")
|
|
216
|
+
|
|
217
|
+
username: str = user_data.get("login", "?")
|
|
218
|
+
print(f"Authenticated as GitHub user: {username}")
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
_github_request(token, "GET", f"/repos/{repo}")
|
|
222
|
+
except urllib.error.HTTPError as exc:
|
|
223
|
+
if exc.code == 404:
|
|
224
|
+
raise RuntimeError(
|
|
225
|
+
f"Repository '{repo}' not found or not accessible with the current token."
|
|
226
|
+
)
|
|
227
|
+
raise RuntimeError(f"GitHub /repos/{repo} check failed: {exc.code}")
|
|
228
|
+
|
|
229
|
+
print(f"Repository '{repo}' accessible.")
|
|
230
|
+
return username
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _list_open_prs(token: str, repo: str) -> list[dict]:
|
|
234
|
+
"""Fetch all open pull requests, oldest first."""
|
|
235
|
+
return _github_paginate(
|
|
236
|
+
token,
|
|
237
|
+
f"/repos/{repo}/pulls",
|
|
238
|
+
{"state": "open", "sort": "created", "direction": "asc"},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _get_pr_diff(token: str, repo: str, pr_number: int) -> str:
|
|
243
|
+
"""Fetch the unified diff for a pull request."""
|
|
244
|
+
diff, _ = _github_request(
|
|
245
|
+
token, "GET", f"/repos/{repo}/pulls/{pr_number}",
|
|
246
|
+
accept="application/vnd.github.diff",
|
|
247
|
+
)
|
|
248
|
+
return diff
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _post_github_comment(token: str, repo: str, pr_number: int, body: str) -> None:
|
|
252
|
+
"""Post a comment on a pull request."""
|
|
253
|
+
try:
|
|
254
|
+
_github_request(
|
|
255
|
+
token, "POST",
|
|
256
|
+
f"/repos/{repo}/issues/{pr_number}/comments",
|
|
257
|
+
body={"body": body},
|
|
258
|
+
)
|
|
259
|
+
except Exception as exc:
|
|
260
|
+
print(f" Warning: failed to post comment on PR #{pr_number}: {exc}")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ── OpenHands conversation helpers ────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
def _oh_request(
|
|
266
|
+
agent_url: str, api_key: str, method: str, path: str, body: dict | None = None
|
|
267
|
+
) -> dict:
|
|
268
|
+
url = f"{agent_url}{path}"
|
|
269
|
+
headers = {"X-Session-API-Key": api_key, "Content-Type": "application/json"}
|
|
270
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
271
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
272
|
+
try:
|
|
273
|
+
with urllib.request.urlopen(req) as r:
|
|
274
|
+
raw = r.read()
|
|
275
|
+
return json.loads(raw) if raw.strip() else {}
|
|
276
|
+
except urllib.error.HTTPError as exc:
|
|
277
|
+
body_text = exc.read().decode()
|
|
278
|
+
raise RuntimeError(f"Agent API {method} {path} → {exc.code}: {body_text}") from exc
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _fetch_settings(agent_url: str, api_key: str) -> dict:
|
|
282
|
+
url = f"{agent_url}/api/settings"
|
|
283
|
+
headers = {"X-Session-API-Key": api_key, "X-Expose-Secrets": "plaintext"}
|
|
284
|
+
req = urllib.request.Request(url, headers=headers)
|
|
285
|
+
with urllib.request.urlopen(req) as r:
|
|
286
|
+
return json.loads(r.read())
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _get_agent_dict(agent_url: str, api_key: str) -> dict:
|
|
290
|
+
data = _fetch_settings(agent_url, api_key)
|
|
291
|
+
agent_settings = data.get("agent_settings", {})
|
|
292
|
+
llm = agent_settings.get("llm", {})
|
|
293
|
+
return {
|
|
294
|
+
"kind": "Agent",
|
|
295
|
+
"llm": llm,
|
|
296
|
+
"tools": [{"name": "terminal"}, {"name": "file_editor"}],
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _get_mcp_config(agent_url: str, api_key: str) -> dict | None:
|
|
301
|
+
try:
|
|
302
|
+
data = _fetch_settings(agent_url, api_key)
|
|
303
|
+
mcp_config = data.get("agent_settings", {}).get("mcp_config")
|
|
304
|
+
if isinstance(mcp_config, dict) and mcp_config.get("mcpServers"):
|
|
305
|
+
return mcp_config
|
|
306
|
+
except Exception as exc:
|
|
307
|
+
print(f"Warning: could not fetch MCP config: {exc}")
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _list_secret_names(agent_url: str, api_key: str) -> list[dict]:
|
|
312
|
+
try:
|
|
313
|
+
result = _oh_request(agent_url, api_key, "GET", "/api/settings/secrets")
|
|
314
|
+
return result.get("secrets", [])
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
print(f"Warning: could not list secrets: {exc}")
|
|
317
|
+
return []
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _build_secrets_payload(agent_url: str, api_key: str) -> dict:
|
|
321
|
+
secrets_list = _list_secret_names(agent_url, api_key)
|
|
322
|
+
secrets: dict = {}
|
|
323
|
+
for secret in secrets_list:
|
|
324
|
+
name = secret.get("name", "")
|
|
325
|
+
if not name:
|
|
326
|
+
continue
|
|
327
|
+
lookup: dict = {
|
|
328
|
+
"kind": "LookupSecret",
|
|
329
|
+
"url": f"/api/settings/secrets/{name}",
|
|
330
|
+
}
|
|
331
|
+
if api_key:
|
|
332
|
+
lookup["headers"] = {"X-Session-API-Key": api_key}
|
|
333
|
+
desc = secret.get("description")
|
|
334
|
+
if desc:
|
|
335
|
+
lookup["description"] = desc
|
|
336
|
+
secrets[name] = lookup
|
|
337
|
+
return secrets
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def create_conversation(agent_url: str, api_key: str, initial_message: str) -> str:
|
|
341
|
+
"""Create an OpenHands conversation and return its ID."""
|
|
342
|
+
workspace_dir = os.environ.get("WORKSPACE_BASE", "/workspace")
|
|
343
|
+
agent = _get_agent_dict(agent_url, api_key)
|
|
344
|
+
payload: dict = {
|
|
345
|
+
"workspace": {"working_dir": workspace_dir},
|
|
346
|
+
"agent": agent,
|
|
347
|
+
"initial_message": {"content": [{"text": initial_message}]},
|
|
348
|
+
}
|
|
349
|
+
secrets = _build_secrets_payload(agent_url, api_key)
|
|
350
|
+
if secrets:
|
|
351
|
+
payload["secrets"] = secrets
|
|
352
|
+
mcp_config = _get_mcp_config(agent_url, api_key)
|
|
353
|
+
if mcp_config:
|
|
354
|
+
payload["mcp_config"] = mcp_config
|
|
355
|
+
result = _oh_request(agent_url, api_key, "POST", "/api/conversations", payload)
|
|
356
|
+
return result["id"]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def conversation_status(agent_url: str, api_key: str, conv_id: str) -> str:
|
|
360
|
+
result = _oh_request(agent_url, api_key, "GET", f"/api/conversations/{conv_id}")
|
|
361
|
+
return result.get("execution_status", "unknown")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
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
|
+
)
|
|
369
|
+
return result.get("response", "")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ── Prompt building ────────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
_TONE_INSTRUCTIONS: dict[str, str] = {
|
|
375
|
+
"thorough": (
|
|
376
|
+
"Provide a comprehensive review. Cover correctness, security vulnerabilities, "
|
|
377
|
+
"missing or inadequate tests, code style, maintainability, and potential edge "
|
|
378
|
+
"cases. Reference specific files and line numbers where relevant."
|
|
379
|
+
),
|
|
380
|
+
"concise": (
|
|
381
|
+
"Provide a brief, high-signal review. Focus only on the most important issues — "
|
|
382
|
+
"bugs, security problems, or significant design flaws. Omit minor style feedback."
|
|
383
|
+
),
|
|
384
|
+
"friendly": (
|
|
385
|
+
"Provide a constructive, encouraging review. Acknowledge what is done well before "
|
|
386
|
+
"raising concerns. Be positive and supportive while still noting real issues."
|
|
387
|
+
),
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _build_review_prompt(pr: dict, diff: str, diff_truncated: bool) -> str:
|
|
392
|
+
number = pr.get("number", "?")
|
|
393
|
+
title = pr.get("title", "(no title)")
|
|
394
|
+
body = (pr.get("body") or "").strip() or "(no description)"
|
|
395
|
+
html_url = pr.get("html_url", "")
|
|
396
|
+
author = (pr.get("user") or {}).get("login", "?")
|
|
397
|
+
base_branch = (pr.get("base") or {}).get("ref", "?")
|
|
398
|
+
head_branch = (pr.get("head") or {}).get("ref", "?")
|
|
399
|
+
labels = [lb["name"] for lb in (pr.get("labels") or [])]
|
|
400
|
+
label_str = ", ".join(labels) if labels else "(none)"
|
|
401
|
+
changed_files = pr.get("changed_files", "?")
|
|
402
|
+
additions = pr.get("additions", "?")
|
|
403
|
+
deletions = pr.get("deletions", "?")
|
|
404
|
+
|
|
405
|
+
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 ""
|
|
415
|
+
|
|
416
|
+
return (
|
|
417
|
+
f"You are an AI code reviewer. Review the following GitHub pull request "
|
|
418
|
+
f"and write a review comment.\n\n"
|
|
419
|
+
f"Repository : {REPO}\n"
|
|
420
|
+
f"PR #{number}: \"{title}\"\n"
|
|
421
|
+
f"Author : @{author}\n"
|
|
422
|
+
f"Base → Head: {base_branch} ← {head_branch}\n"
|
|
423
|
+
f"Labels : {label_str}\n"
|
|
424
|
+
f"Changes : +{additions} -{deletions} across {changed_files} file(s)\n"
|
|
425
|
+
f"URL : {html_url}\n"
|
|
426
|
+
f"\nPR Description:\n---\n{body}\n---\n"
|
|
427
|
+
f"{truncation_note}"
|
|
428
|
+
f"\nDiff:\n```diff\n{diff}\n```\n"
|
|
429
|
+
f"\nReview instructions:\n{tone}{extra}\n\n"
|
|
430
|
+
f"Output ONLY the review text — no preamble, no meta-commentary. "
|
|
431
|
+
f"This text will be posted verbatim as a comment on the pull request.\n"
|
|
432
|
+
f"End your review with a clear verdict on its own line: "
|
|
433
|
+
f"either `✅ APPROVED` or `🔄 CHANGES REQUESTED`."
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ── Core logic ─────────────────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
def _process_new_pr(
|
|
440
|
+
github_token: str,
|
|
441
|
+
agent_url: str,
|
|
442
|
+
api_key: str,
|
|
443
|
+
openhands_url: str,
|
|
444
|
+
pr: dict,
|
|
445
|
+
conversations: dict,
|
|
446
|
+
) -> str | None:
|
|
447
|
+
"""Start a review conversation for a PR not yet seen. Returns the conversation ID."""
|
|
448
|
+
number = pr["number"]
|
|
449
|
+
title = pr.get("title", "(no title)")
|
|
450
|
+
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
|
+
|
|
482
|
+
prompt = _build_review_prompt(pr, diff, diff_truncated)
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
conv_id = create_conversation(agent_url, api_key, prompt)
|
|
486
|
+
except Exception as exc:
|
|
487
|
+
print(f" Error creating conversation for PR #{number}: {exc}")
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
conversations[str(number)] = {
|
|
491
|
+
"pr_number": number,
|
|
492
|
+
"html_url": html_url,
|
|
493
|
+
"status": "active",
|
|
494
|
+
"conversation_id": conv_id,
|
|
495
|
+
"last_activity": time.time(),
|
|
496
|
+
}
|
|
497
|
+
print(f" Created review conversation {conv_id}")
|
|
498
|
+
|
|
499
|
+
conv_url = f"{openhands_url}/conversations/{conv_id}"
|
|
500
|
+
_post_github_comment(
|
|
501
|
+
github_token, REPO, number,
|
|
502
|
+
f"🤖 **OpenHands is reviewing this PR.**\n\n"
|
|
503
|
+
f"View the conversation: {conv_url}\n\n"
|
|
504
|
+
f"_This comment was posted by an AI agent (OpenHands)._",
|
|
505
|
+
)
|
|
506
|
+
return conv_id
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _check_conversation_completion(
|
|
510
|
+
rec: dict,
|
|
511
|
+
github_token: str,
|
|
512
|
+
agent_url: str,
|
|
513
|
+
api_key: str,
|
|
514
|
+
) -> None:
|
|
515
|
+
"""Post the review result and close the conversation record once it finishes."""
|
|
516
|
+
if (time.time() - rec.get("last_activity", 0.0)) < DONE_DEBOUNCE:
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
conv_id = rec["conversation_id"]
|
|
520
|
+
pr_number = rec["pr_number"]
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
status = conversation_status(agent_url, api_key, conv_id)
|
|
524
|
+
except Exception as exc:
|
|
525
|
+
print(f" Warning: could not get status for {conv_id}: {exc}")
|
|
526
|
+
return
|
|
527
|
+
|
|
528
|
+
print(f" PR #{pr_number} conversation {conv_id} → status={status}")
|
|
529
|
+
|
|
530
|
+
if status not in ("idle", "finished", "error", "stuck"):
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
final = conversation_final_response(agent_url, api_key, conv_id)
|
|
535
|
+
except Exception:
|
|
536
|
+
final = ""
|
|
537
|
+
|
|
538
|
+
if status in ("error", "stuck"):
|
|
539
|
+
comment_body = (
|
|
540
|
+
f"⚠️ **OpenHands PR Reviewer encountered a problem** (status: `{status}`).\n\n"
|
|
541
|
+
+ (f"{final}\n\n" if final else "")
|
|
542
|
+
+ "_This message was posted by an AI agent (OpenHands)._"
|
|
543
|
+
)
|
|
544
|
+
else:
|
|
545
|
+
comment_body = (
|
|
546
|
+
final if final
|
|
547
|
+
else (
|
|
548
|
+
"✅ **OpenHands completed the review.** (No review text was produced.)\n\n"
|
|
549
|
+
"_This message was posted by an AI agent (OpenHands)._"
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
_post_github_comment(github_token, REPO, pr_number, comment_body)
|
|
554
|
+
rec["status"] = "closed"
|
|
555
|
+
print(f" Posted review for PR #{pr_number}")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# ── Main ───────────────────────────────────────────────────────────────────────
|
|
559
|
+
|
|
560
|
+
def main() -> str | None:
|
|
561
|
+
"""Run one polling cycle. Returns the last conversation ID created, if any."""
|
|
562
|
+
state_path = _state_file_path()
|
|
563
|
+
state = load_state(state_path)
|
|
564
|
+
|
|
565
|
+
agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
|
|
566
|
+
api_key = _get_env_key()
|
|
567
|
+
|
|
568
|
+
github_token = _resolve_github_token()
|
|
569
|
+
_verify_token_and_repo(github_token, REPO)
|
|
570
|
+
|
|
571
|
+
try:
|
|
572
|
+
openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
|
|
573
|
+
except Exception:
|
|
574
|
+
openhands_url = DEFAULT_OPENHANDS_URL
|
|
575
|
+
|
|
576
|
+
conversations: dict = state.get("conversations", {})
|
|
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
|
|
582
|
+
|
|
583
|
+
print(f"Found {len(open_prs)} open PR(s) in {REPO}")
|
|
584
|
+
|
|
585
|
+
open_pr_numbers = {str(pr["number"]) for pr in open_prs}
|
|
586
|
+
|
|
587
|
+
last_conversation_id: str | None = None
|
|
588
|
+
|
|
589
|
+
# Queue reviews for PRs not yet in state.
|
|
590
|
+
for pr in open_prs:
|
|
591
|
+
key = str(pr["number"])
|
|
592
|
+
if key in conversations:
|
|
593
|
+
continue
|
|
594
|
+
conv_id = _process_new_pr(
|
|
595
|
+
github_token, agent_url, api_key, openhands_url, pr, conversations,
|
|
596
|
+
)
|
|
597
|
+
if conv_id:
|
|
598
|
+
last_conversation_id = conv_id
|
|
599
|
+
|
|
600
|
+
# Check active conversations for completion.
|
|
601
|
+
for key, rec in list(conversations.items()):
|
|
602
|
+
if rec.get("status") != "active":
|
|
603
|
+
continue
|
|
604
|
+
if key not in open_pr_numbers:
|
|
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)
|
|
610
|
+
|
|
611
|
+
state["conversations"] = conversations
|
|
612
|
+
save_state(state_path, state)
|
|
613
|
+
print(f"State saved → {state_path}")
|
|
614
|
+
return last_conversation_id
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
if __name__ == "__main__":
|
|
618
|
+
try:
|
|
619
|
+
conversation_id = main()
|
|
620
|
+
fire_callback("COMPLETED", conversation_id=conversation_id)
|
|
621
|
+
except Exception as exc:
|
|
622
|
+
import traceback
|
|
623
|
+
traceback.print_exc()
|
|
624
|
+
fire_callback("FAILED", str(exc))
|
|
625
|
+
sys.exit(1)
|
|
@@ -41,7 +41,7 @@ event-type selection, cron schedule, and automation creation automatically.
|
|
|
41
41
|
|
|
42
42
|
## Requirements
|
|
43
43
|
|
|
44
|
-
- `
|
|
44
|
+
- `GITHUB_PERSONAL_ACCESS_TOKEN` secret set in OpenHands Settings → Secrets
|
|
45
45
|
- Classic PAT: `repo` (private repos) or `public_repo` (public repos)
|
|
46
46
|
- Fine-grained PAT: Issues — Read and Write
|
|
47
47
|
- The monitored repository must be accessible with that token
|