@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.
@@ -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)