@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.
Files changed (40) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/README.md +3 -2
  3. package/automations/catalog/github-pr-reviewer.json +1 -1
  4. package/automations/catalog/github-repo-monitor.json +1 -1
  5. package/marketplaces/openhands-extensions.json +13 -0
  6. package/package.json +1 -1
  7. package/plugins/issue-duplicate-checker/action.yml +2 -2
  8. package/plugins/pr-review/README.md +1 -1
  9. package/plugins/qa-changes/action.yml +4 -2
  10. package/plugins/release-notes/action.yml +3 -2
  11. package/plugins/release-notes/workflows/release-notes.yml +10 -4
  12. package/plugins/vulnerability-remediation/README.md +4 -2
  13. package/plugins/vulnerability-remediation/action.yml +8 -4
  14. package/pyproject.toml +1 -1
  15. package/scripts/sync_extensions.py +2 -1
  16. package/skills/github-actions/.plugin/plugin.json +19 -0
  17. package/skills/github-actions/README.md +190 -0
  18. package/skills/github-actions/SKILL.md +44 -0
  19. package/skills/github-pr-reviewer/SKILL.md +183 -40
  20. package/skills/github-pr-reviewer/references/state-schema.md +98 -0
  21. package/skills/github-pr-reviewer/scripts/main.py +625 -0
  22. package/skills/github-repo-monitor/README.md +1 -1
  23. package/skills/github-repo-monitor/SKILL.md +9 -9
  24. package/skills/github-repo-monitor/references/github-api.md +3 -3
  25. package/skills/github-repo-monitor/scripts/main.py +11 -11
  26. package/skills/index.js +16 -4
  27. package/skills/openhands-sdk/SKILL.md +3 -0
  28. package/skills/slack-channel-monitor/README.md +2 -1
  29. package/skills/slack-channel-monitor/SKILL.md +22 -4
  30. package/tests/test_sync_extensions.py +14 -0
  31. /package/plugins/issue-duplicate-checker/scripts/{post_duplicate_notice.js → post_duplicate_notice.cjs} +0 -0
  32. /package/plugins/issue-duplicate-checker/scripts/{remove_duplicate_candidate_label.js → remove_duplicate_candidate_label.cjs} +0 -0
  33. /package/skills/github-pr-reviewer/commands/{pr-reviewer:setup.md → pr-reviewer-setup.md} +0 -0
  34. /package/skills/github-repo-monitor/commands/{github-monitor:poll.md → github-monitor-poll.md} +0 -0
  35. /package/skills/incident-retrospective/commands/{incident-retro:setup.md → incident-retro-setup.md} +0 -0
  36. /package/skills/linear-triage/commands/{linear-triage:setup.md → linear-triage-setup.md} +0 -0
  37. /package/skills/openhands-automation/commands/{automation:create.md → automation-create.md} +0 -0
  38. /package/skills/research-brief/commands/{research-brief:setup.md → research-brief-setup.md} +0 -0
  39. /package/skills/slack-channel-monitor/commands/{slack-monitor:poll.md → slack-monitor-poll.md} +0 -0
  40. /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
- - `GITHUB_TOKEN` secret set in OpenHands Settings → Secrets
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