@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.
@@ -1,26 +1,9 @@
1
1
  """
2
- GitHub PR Reviewer - OpenHands Automation Script
2
+ GitHub PR Reviewer - OpenHands Automation Script
3
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)
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
- # ── 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
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"github_pr_reviewer_{automation_id}.json")
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": 1,
93
+ "version": 2,
133
94
  "repo": REPO,
134
- "conversations": {}, # pr_number (str) → ConversationRecord
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
- with open(path, "w") as f:
140
- json.dump(state, f, indent=2)
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
- """Low-level GitHub API call. Returns (parsed_body, response_headers)."""
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 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
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) -> 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
- "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}")
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
- username: str = user_data.get("login", "?")
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
- 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
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": "created", "direction": "asc"},
184
+ {"state": "open", "sort": "updated", "direction": "desc"},
239
185
  )
240
186
 
241
187
 
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
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, "POST",
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
- # ── 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:
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
- 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)
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
- agent_settings = data.get("agent_settings", {})
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
- secrets_list = _list_secret_names(agent_url, api_key)
322
- secrets: dict = {}
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": 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
- # ── Prompt building ────────────────────────────────────────────────────────────
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
- "cases. Reference specific files and line numbers where relevant."
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 the most important issues — "
382
- "bugs, security problems, or significant design flaws. Omit minor style feedback."
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. Be positive and supportive while still noting real issues."
333
+ "raising concerns while still noting real issues."
387
334
  ),
388
335
  }
389
336
 
390
337
 
391
- def _build_review_prompt(pr: dict, diff: str, diff_truncated: bool) -> str:
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
- labels = [lb["name"] for lb in (pr.get("labels") or [])]
400
- label_str = ", ".join(labels) if labels else "(none)"
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
- f"You are an AI code reviewer. Review the following GitHub pull request "
418
- f"and write a review comment.\n\n"
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
- f"{truncation_note}"
428
- f"\nDiff:\n```diff\n{diff}\n```\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"
429
406
  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`."
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
- conversations: dict,
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
- prompt = _build_review_prompt(pr, diff, diff_truncated)
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
- conversations[str(number)] = {
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, 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)._",
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 ("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)._"
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 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
- )
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
- print(f" Posted review for PR #{pr_number}")
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
- 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
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
- open_pr_numbers = {str(pr["number"]) for pr in open_prs}
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
- key = str(pr["number"])
592
- if key in conversations:
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
- conv_id = _process_new_pr(
595
- github_token, agent_url, api_key, openhands_url, pr, conversations,
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
- # Check active conversations for completion.
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
- 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)
589
+ _check_conversation_completion(rec, latest_open_prs, github_token, agent_url, api_key)
610
590
 
611
- state["conversations"] = conversations
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)