@openhands/extensions 0.15.0 → 0.17.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.
Files changed (45) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/AGENTS.md +1 -0
  3. package/automations/README.md +97 -18
  4. package/automations/bundle-index.js +9 -0
  5. package/automations/catalog/github-pr-reviewer/manifest.json +23 -7
  6. package/automations/catalog/github-repo-monitor/manifest.json +28 -34
  7. package/automations/catalog/jira-issue-to-pr/manifest.json +3 -3
  8. package/automations/catalog.schema.json +102 -1
  9. package/automations/index.d.ts +174 -1
  10. package/automations/index.js +14 -0
  11. package/automations/interface.json +93 -6
  12. package/automations/interface.schema.json +273 -9
  13. package/index.d.ts +1 -1
  14. package/index.js +1 -1
  15. package/integrations/catalog/posthog.json +41 -3
  16. package/integrations/catalog/sonarqube.json +78 -0
  17. package/integrations/catalog-index.js +65 -63
  18. package/marketplaces/openhands-extensions.json +11 -0
  19. package/package.json +1 -1
  20. package/plugins/qa-changes/action.yml +1 -1
  21. package/pyproject.toml +1 -1
  22. package/python/openhands_extensions/_version.py +1 -1
  23. package/scripts/build-automation-catalog.mjs +30 -0
  24. package/scripts/build-skills-catalog.mjs +24 -7
  25. package/skills/add-skill/SKILL.md +1 -1
  26. package/skills/github-pr-reviewer/README.md +12 -6
  27. package/skills/github-pr-reviewer/SKILL.md +93 -31
  28. package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
  29. package/skills/github-pr-reviewer/references/state-schema.md +68 -18
  30. package/skills/github-pr-reviewer/scripts/main.py +540 -103
  31. package/skills/github-pr-reviewer/tests/test_main.py +503 -0
  32. package/skills/index.d.ts +6 -0
  33. package/skills/index.js +29 -17
  34. package/skills/jira-issue-to-pr/scripts/main.py +4 -2
  35. package/skills/openhands-sdk/SKILL.md +2 -0
  36. package/skills/skill-creator/SKILL.md +1 -1
  37. package/skills/slack-channel-monitor/scripts/main.py +3 -2
  38. package/tests/fixtures/automations/capabilities.json +30 -0
  39. package/tests/fixtures/automations/github-pr-reviewer.json +319 -55
  40. package/tests/fixtures/automations/github-repo-monitor.json +153 -62
  41. package/tests/test_automation_setup.py +226 -31
  42. package/tests/test_catalogs.py +43 -0
  43. package/tests/test_interface_manifest.py +20 -0
  44. package/tests/test_skills_catalog.py +182 -36
  45. package/tests/test_slack_channel_monitor.py +1 -1
@@ -1,28 +1,157 @@
1
1
  """
2
2
  GitHub PR Reviewer - OpenHands Automation Script
3
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.
4
+ Cron-polls one or more GitHub repositories for open pull requests carrying the
5
+ configured trigger label. A review is queued only when the latest matching
6
+ GitHub `labeled` event has not already been processed by this automation.
7
+
8
+ Each repository is polled independently and keeps its own state document, so
9
+ pull-request numbers never collide across repositories.
10
+
11
+ The script owns the repository checkout: it downloads the pull request's head
12
+ commit as a tarball, hands the agent that directory as its workspace, and
13
+ removes it once the review has finished. The agent never clones, checks out, or
14
+ deletes anything.
7
15
  """
8
16
 
17
+ import io
9
18
  import json
10
19
  import os
20
+ import re
21
+ import shutil
11
22
  import sys
23
+ import tarfile
12
24
  import time
13
25
  import urllib.error
14
26
  import urllib.request
15
- from pathlib import Path
27
+ from collections.abc import Callable
28
+ from pathlib import Path, PurePosixPath
16
29
  from urllib.parse import urlencode
17
30
 
18
- REPO = "owner/repo"
31
+ # Configuration. Two setup paths write it, and both end up here:
32
+ #
33
+ # - the agent-driven path (SKILL.md) substitutes these constants directly
34
+ # into a copy of this file before packaging it;
35
+ # - the catalog path packs an unmodified copy and ships a rendered
36
+ # config.json beside it, which is loaded over these defaults below.
37
+ #
38
+ # A declarative host cannot rewrite Python - the catalog schema admits data,
39
+ # not code - so the constants stay as the defaults and config.json is the
40
+ # override, rather than one path being expressed in terms of the other.
41
+ REPOS = ["owner/repo"]
19
42
  TRIGGER_LABEL = "openhands-review"
20
43
  REVIEW_TONE = "thorough"
21
44
  REVIEW_STYLE_INSTRUCTIONS = ""
22
45
  DEFAULT_OPENHANDS_URL = "http://localhost:8000"
23
46
 
47
+ CONFIG_FILENAME = "config.json"
48
+
49
+ # Config keys, paired with the type each must have. A wrong type is a hard
50
+ # error at import: the alternative is polling the string "owner/repo" one
51
+ # character at a time, or matching a label that is silently a list.
52
+ _CONFIG_TYPES: dict[str, type] = {
53
+ "repos": list,
54
+ "trigger_label": str,
55
+ "review_tone": str,
56
+ "review_style_instructions": str,
57
+ "openhands_url": str,
58
+ }
59
+
60
+
61
+ def load_config(directory: Path | None = None) -> dict:
62
+ """Return the rendered config shipped beside this script, or {} if absent.
63
+
64
+ Only the keys above are read; anything else in the file is ignored, so a
65
+ host may ship provenance there without this script caring.
66
+ """
67
+ path = (directory or Path(__file__).resolve().parent) / CONFIG_FILENAME
68
+ if not path.is_file():
69
+ return {}
70
+
71
+ try:
72
+ raw = json.loads(path.read_text())
73
+ except json.JSONDecodeError as e:
74
+ raise SystemExit(f"{CONFIG_FILENAME} is not valid JSON: {e}") from e
75
+ if not isinstance(raw, dict):
76
+ raise SystemExit(f"{CONFIG_FILENAME} must contain a JSON object")
77
+
78
+ config = {}
79
+ for key, expected in _CONFIG_TYPES.items():
80
+ if key not in raw:
81
+ continue
82
+ value = raw[key]
83
+ if not isinstance(value, expected):
84
+ raise SystemExit(
85
+ f"{CONFIG_FILENAME}: {key} must be {expected.__name__}, "
86
+ f"got {type(value).__name__}"
87
+ )
88
+ if key == "repos" and not (
89
+ value and all(isinstance(item, str) and item for item in value)
90
+ ):
91
+ raise SystemExit(
92
+ f'{CONFIG_FILENAME}: repos must be a non-empty list of "owner/repo" strings'
93
+ )
94
+ config[key] = value
95
+ return config
96
+
97
+
98
+ # owner/repo, which is what every GitHub API path in this script is built from.
99
+ _REPO_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
100
+
101
+
102
+ def normalize_repo(value: str) -> str:
103
+ """Return ``owner/repo`` for the ways a repository gets written down.
104
+
105
+ A clone URL is what a repository page offers to copy, so it is what ends up
106
+ pasted into a setup form. Left alone it becomes
107
+ ``/repos/https://github.com/owner/repo``, which GitHub answers with a 404 -
108
+ indistinguishable, from here, from a repository the token cannot see.
109
+
110
+ Raises ValueError for anything that is not a repository name, so the run
111
+ says which value it could not read instead of blaming the token.
112
+ """
113
+ repo = value.strip()
114
+ if repo.startswith("git@"):
115
+ # git@github.com:owner/repo.git
116
+ repo = repo.partition(":")[2]
117
+ elif "://" in repo:
118
+ # https://github.com/owner/repo, and anything else with a host
119
+ repo = repo.split("://", 1)[1].partition("/")[2]
120
+ repo = repo.strip("/")
121
+ if repo.endswith(".git"):
122
+ repo = repo[: -len(".git")]
123
+
124
+ if not _REPO_NAME_RE.match(repo):
125
+ raise ValueError(
126
+ f"{value!r} is not a repository. Use owner/repo, for example "
127
+ "OpenHands/automation."
128
+ )
129
+ return repo
130
+
131
+
132
+ _CONFIG = load_config()
133
+ REPOS = _CONFIG.get("repos", REPOS)
134
+ TRIGGER_LABEL = _CONFIG.get("trigger_label", TRIGGER_LABEL)
135
+ REVIEW_TONE = _CONFIG.get("review_tone", REVIEW_TONE)
136
+ REVIEW_STYLE_INSTRUCTIONS = _CONFIG.get("review_style_instructions", REVIEW_STYLE_INSTRUCTIONS)
137
+ DEFAULT_OPENHANDS_URL = _CONFIG.get("openhands_url", DEFAULT_OPENHANDS_URL)
138
+
24
139
  DONE_DEBOUNCE = 15
25
140
  TERMINAL_STATUSES = {"idle", "finished", "error", "stuck"}
141
+ # A conversation that never reaches a terminal status would hold its checkout
142
+ # forever. After this long the review is abandoned so the disk can be reclaimed.
143
+ MAX_ACTIVE_AGE = 2 * 60 * 60
144
+ # A label event is claimed in the state document before its review starts, so an
145
+ # overlapping poll skips it. If the claiming poll dies before the conversation
146
+ # exists, the claim is released after this long - comfortably longer than
147
+ # fetching an archive and opening a conversation, short enough that a crash does
148
+ # not park the review until someone notices.
149
+ STALLED_CLAIM_SECONDS = 15 * 60
150
+
151
+ # Login of the token owner, filled in by _verify_token. Reviews are matched
152
+ # against it to answer "did we already publish a review for this commit", which
153
+ # is checked on GitHub rather than trusted from the agent.
154
+ _AUTH_LOGIN = ""
26
155
 
27
156
 
28
157
  def _get_env_key() -> str:
@@ -71,7 +200,18 @@ def fire_callback(
71
200
 
72
201
  _KV_TOKEN = os.environ.get("AUTOMATION_KV_TOKEN", "")
73
202
  _KV_BASE = os.environ.get("AUTOMATION_API_URL", "").rstrip("/")
74
- _STATE_KEY = "state"
203
+ # Single-repository deployments of this script kept their state under a bare
204
+ # "state" key. It is adopted once, on first poll after an upgrade, so the
205
+ # switch to per-repository keys does not re-review every open labelled PR.
206
+ _LEGACY_STATE_KEY = "state"
207
+
208
+
209
+ def _repo_slug(repo: str) -> str:
210
+ return repo.replace("/", "__")
211
+
212
+
213
+ def _state_key(repo: str) -> str:
214
+ return f"state:{_repo_slug(repo)}"
75
215
 
76
216
 
77
217
  def _kv_available() -> bool:
@@ -106,59 +246,86 @@ def _kv_set(key: str, value: dict) -> None:
106
246
  r.read()
107
247
 
108
248
 
109
- def _state_file_path() -> str:
249
+ def _state_dir() -> Path:
110
250
  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
251
  if workspace_base:
115
252
  root = Path(workspace_base).resolve().parent.parent
116
253
  else:
117
254
  root = Path.home() / ".openhands" / "workspaces"
118
-
119
255
  state_dir = root / "automation-state"
120
256
  state_dir.mkdir(parents=True, exist_ok=True)
121
- return str(state_dir / f"github_pr_reviewer_label_event_{automation_id}.json")
257
+ return state_dir
258
+
259
+
260
+ def _automation_id() -> str:
261
+ event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}"))
262
+ return event_payload.get("automation_id", "default")
263
+
264
+
265
+ def _state_file_path(repo: str) -> str:
266
+ name = f"github_pr_reviewer_label_event_{_automation_id()}_{_repo_slug(repo)}.json"
267
+ return str(_state_dir() / name)
268
+
122
269
 
270
+ def _legacy_state_file_path() -> str:
271
+ return str(_state_dir() / f"github_pr_reviewer_label_event_{_automation_id()}.json")
123
272
 
124
- def _default_state() -> dict:
273
+
274
+ def _read_state_file(path: str) -> dict | None:
275
+ if not os.path.exists(path):
276
+ return None
277
+ try:
278
+ with open(path) as f:
279
+ return json.load(f)
280
+ except (json.JSONDecodeError, OSError) as exc:
281
+ print(f" Warning: state file {path} unreadable ({exc}); starting fresh")
282
+ return None
283
+
284
+
285
+ def _default_state(repo: str) -> dict:
125
286
  return {
126
- "version": 2,
127
- "repo": REPO,
287
+ "version": 3,
288
+ "repo": repo,
128
289
  "trigger_label": TRIGGER_LABEL,
129
290
  "reviews": {},
130
291
  "prs": {},
131
292
  }
132
293
 
133
294
 
134
- def load_state() -> dict:
295
+ def load_state(repo: str) -> dict:
296
+ """Load this repository's state, adopting a pre-multi-repo document once."""
135
297
  if _kv_available():
136
- data = _kv_get(_STATE_KEY)
298
+ data = _kv_get(_state_key(repo))
137
299
  if data is not None:
138
- print("State loaded from KV store")
300
+ print(f" State loaded from KV store ({_state_key(repo)})")
139
301
  return data
140
- return _default_state()
141
- path = _state_file_path()
142
- if os.path.exists(path):
143
- try:
144
- with open(path) as f:
145
- return json.load(f)
146
- except (json.JSONDecodeError, OSError) as exc:
147
- print(f"Warning: state file {path} unreadable ({exc}); starting fresh")
148
- return _default_state()
149
-
150
-
151
- def save_state(state: dict) -> None:
302
+ legacy = _kv_get(_LEGACY_STATE_KEY)
303
+ if legacy is not None and legacy.get("repo") == repo:
304
+ print(f" Adopted legacy KV state for {repo}")
305
+ return legacy
306
+ return _default_state(repo)
307
+
308
+ data = _read_state_file(_state_file_path(repo))
309
+ if data is not None:
310
+ return data
311
+ legacy = _read_state_file(_legacy_state_file_path())
312
+ if legacy is not None and legacy.get("repo") == repo:
313
+ print(f" Adopted legacy state file for {repo}")
314
+ return legacy
315
+ return _default_state(repo)
316
+
317
+
318
+ def save_state(repo: str, state: dict) -> None:
152
319
  if _kv_available():
153
- _kv_set(_STATE_KEY, state)
154
- print("State saved to KV store")
320
+ _kv_set(_state_key(repo), state)
321
+ print(f" State saved to KV store ({_state_key(repo)})")
155
322
  return
156
- path = _state_file_path()
323
+ path = _state_file_path(repo)
157
324
  tmp_path = f"{path}.tmp"
158
325
  with open(tmp_path, "w") as f:
159
326
  json.dump(state, f, indent=2, sort_keys=True)
160
327
  os.replace(tmp_path, path)
161
- print(f"State saved to {path}")
328
+ print(f" State saved to {path}")
162
329
 
163
330
 
164
331
  def _github_request(
@@ -215,7 +382,9 @@ def _resolve_github_token() -> str:
215
382
  )
216
383
 
217
384
 
218
- def _verify_token_and_repo(token: str, repo: str) -> None:
385
+ def _verify_token(token: str) -> None:
386
+ """Check the token once per run and remember who it belongs to."""
387
+ global _AUTH_LOGIN
219
388
  try:
220
389
  user_data, _ = _github_request(token, "GET", "/user")
221
390
  except urllib.error.HTTPError as exc:
@@ -223,8 +392,11 @@ def _verify_token_and_repo(token: str, repo: str) -> None:
223
392
  raise RuntimeError("GITHUB_PERSONAL_ACCESS_TOKEN is invalid or expired.") from exc
224
393
  raise RuntimeError(f"GitHub /user check failed: {exc.code}") from exc
225
394
 
226
- print(f"Authenticated as GitHub user: {user_data.get('login', '?')}")
395
+ _AUTH_LOGIN = user_data.get("login", "")
396
+ print(f"Authenticated as GitHub user: {_AUTH_LOGIN or '?'}")
397
+
227
398
 
399
+ def _verify_repo(token: str, repo: str) -> None:
228
400
  try:
229
401
  _github_request(token, "GET", f"/repos/{repo}")
230
402
  except urllib.error.HTTPError as exc:
@@ -275,6 +447,156 @@ def _post_github_comment(token: str, repo: str, pr_number: int, body: str) -> No
275
447
  print(f" Warning: failed to post comment on PR #{pr_number}: {exc}")
276
448
 
277
449
 
450
+ def _matching_review_exists(token: str, repo: str, pr_number: int, head_sha: str) -> bool:
451
+ """Has this token's user already published a review for this exact commit?
452
+
453
+ The agent is asked to report success, but a report is not evidence: reviews
454
+ have been reported as posted when none existed. GitHub is the source of
455
+ truth for whether the review landed.
456
+ """
457
+ if not head_sha or not _AUTH_LOGIN:
458
+ return False
459
+ try:
460
+ reviews = _github_paginate(token, f"/repos/{repo}/pulls/{pr_number}/reviews")
461
+ except Exception as exc:
462
+ print(f" Warning: could not list reviews for PR #{pr_number}: {exc}")
463
+ return False
464
+ for review in reviews:
465
+ if (review.get("user") or {}).get("login", "").lower() != _AUTH_LOGIN.lower():
466
+ continue
467
+ if review.get("commit_id") == head_sha:
468
+ return True
469
+ return False
470
+
471
+
472
+ # ── Repository checkout ───────────────────────────────────────────────────────
473
+
474
+
475
+ def _checkouts_root() -> Path:
476
+ return Path(os.environ.get("WORKSPACE_BASE", "/workspace")).resolve() / "repositories"
477
+
478
+
479
+ def _checkout_path(repo: str, pr_number: int, head_sha: str) -> Path:
480
+ return _checkouts_root() / _repo_slug(repo) / f"pr-{pr_number}-{head_sha[:12]}"
481
+
482
+
483
+ def _prepare_repository(token: str, repo: str, pr_number: int, head_sha: str) -> Path:
484
+ """Materialise the pull request's head commit as the agent's workspace.
485
+
486
+ The commit is fetched as a tarball rather than cloned, so the directory
487
+ holds exactly the reviewed tree with no history and no git remote for the
488
+ agent to push to.
489
+ """
490
+ checkout = _checkout_path(repo, pr_number, head_sha)
491
+ if checkout.exists():
492
+ shutil.rmtree(checkout)
493
+ checkout.mkdir(parents=True)
494
+
495
+ req = urllib.request.Request(
496
+ f"https://api.github.com/repos/{repo}/tarball/{head_sha}",
497
+ headers={
498
+ "Authorization": f"Bearer {token}",
499
+ "Accept": "application/vnd.github+json",
500
+ "X-GitHub-Api-Version": "2022-11-28",
501
+ },
502
+ )
503
+ skipped_links = 0
504
+ try:
505
+ with urllib.request.urlopen(req) as response:
506
+ archive = tarfile.open(fileobj=io.BytesIO(response.read()), mode="r:gz")
507
+ with archive:
508
+ members = archive.getmembers()
509
+ roots = {
510
+ PurePosixPath(member.name).parts[0]
511
+ for member in members
512
+ if PurePosixPath(member.name).parts
513
+ }
514
+ if len(roots) != 1:
515
+ raise RuntimeError("Repository archive has an unexpected layout")
516
+ root = next(iter(roots))
517
+ for member in members:
518
+ path = PurePosixPath(member.name)
519
+ if not path.parts or path.parts[0] != root:
520
+ raise RuntimeError("Repository archive contains an invalid path")
521
+ relative = PurePosixPath(*path.parts[1:])
522
+ if not relative.parts:
523
+ continue
524
+ if relative.is_absolute() or ".." in relative.parts:
525
+ raise RuntimeError("Repository archive contains path traversal")
526
+ if member.issym() or member.islnk() or member.isdev():
527
+ # Repositories legitimately contain symlinks. Reviewing does
528
+ # not need them, and materialising them risks escaping the
529
+ # checkout, so skip rather than reject the whole archive.
530
+ skipped_links += 1
531
+ continue
532
+ destination = checkout.joinpath(*relative.parts)
533
+ if member.isdir():
534
+ destination.mkdir(parents=True, exist_ok=True)
535
+ continue
536
+ if not member.isfile():
537
+ continue
538
+ destination.parent.mkdir(parents=True, exist_ok=True)
539
+ source = archive.extractfile(member)
540
+ if source is None:
541
+ raise RuntimeError(f"Could not read archive member {member.name}")
542
+ with source, destination.open("wb") as target:
543
+ shutil.copyfileobj(source, target)
544
+ destination.chmod(member.mode & 0o777)
545
+ except Exception:
546
+ shutil.rmtree(checkout, ignore_errors=True)
547
+ raise
548
+
549
+ if skipped_links:
550
+ print(f" Skipped {skipped_links} link/device entries while extracting")
551
+ return checkout
552
+
553
+
554
+ def _release_checkout(rec: dict, agent_url: str, api_key: str) -> bool:
555
+ """Remove a finished review's checkout. Returns True when nothing is left.
556
+
557
+ The checkout is the conversation's working directory, so it is only removed
558
+ once the conversation has stopped - deleting it under a running agent would
559
+ pull the ground out from under it. When the status cannot be confirmed the
560
+ directory is left alone and the next poll tries again.
561
+ """
562
+ workspace_dir = rec.get("workspace_dir")
563
+ if not workspace_dir:
564
+ return True
565
+
566
+ conversation_id = rec.get("conversation_id")
567
+ if conversation_id:
568
+ try:
569
+ status = conversation_status(agent_url, api_key, conversation_id)
570
+ except urllib.error.HTTPError as exc:
571
+ status = "finished" if exc.code == 404 else None
572
+ except Exception:
573
+ status = None
574
+ if status is None:
575
+ print(f" Could not confirm conversation {conversation_id} has stopped; keeping {workspace_dir}")
576
+ return False
577
+ if status not in TERMINAL_STATUSES:
578
+ print(f" Conversation {conversation_id} is still '{status}'; keeping its checkout")
579
+ return False
580
+
581
+ path = Path(workspace_dir)
582
+ root = _checkouts_root()
583
+ try:
584
+ resolved = path.resolve()
585
+ except OSError:
586
+ resolved = path
587
+ if resolved == root or not resolved.is_relative_to(root):
588
+ # Never delete anything the script did not create under the checkout
589
+ # root, whatever ended up recorded in state.
590
+ print(f" Refusing to remove {resolved}: outside {root}")
591
+ rec.pop("workspace_dir", None)
592
+ return True
593
+
594
+ shutil.rmtree(resolved, ignore_errors=True)
595
+ rec.pop("workspace_dir", None)
596
+ print(f" Removed checkout {resolved}")
597
+ return True
598
+
599
+
278
600
  def _oh_request(agent_url: str, api_key: str, method: str, path: str, body: dict | None = None) -> dict:
279
601
  url = f"{agent_url}{path}"
280
602
  headers = {"X-Session-API-Key": api_key, "Content-Type": "application/json"}
@@ -347,10 +669,14 @@ def _build_secrets_payload(agent_url: str, api_key: str) -> dict:
347
669
  return secrets
348
670
 
349
671
 
350
- def create_conversation(agent_url: str, api_key: str, initial_message: str) -> str:
351
- workspace_dir = os.environ.get("WORKSPACE_BASE", "/workspace")
672
+ def create_conversation(
673
+ agent_url: str,
674
+ api_key: str,
675
+ initial_message: str,
676
+ workspace_dir: Path,
677
+ ) -> str:
352
678
  payload: dict = {
353
- "workspace": {"working_dir": workspace_dir},
679
+ "workspace": {"working_dir": str(workspace_dir)},
354
680
  "agent": _get_agent_dict(agent_url, api_key),
355
681
  "initial_message": {"content": [{"text": initial_message}]},
356
682
  }
@@ -415,7 +741,7 @@ def _with_ai_disclosure(body: str) -> str:
415
741
  return f"{body}\n\n{disclosure}" if body else disclosure
416
742
 
417
743
 
418
- def _build_review_prompt(pr: dict, head_sha: str, label_event: dict) -> str:
744
+ def _build_review_prompt(repo: str, pr: dict, head_sha: str, label_event: dict) -> str:
419
745
  number = pr.get("number", "?")
420
746
  title = pr.get("title", "(no title)")
421
747
  body = (pr.get("body") or "").strip() or "(no description)"
@@ -429,16 +755,14 @@ def _build_review_prompt(pr: dict, head_sha: str, label_event: dict) -> str:
429
755
  changed_files = pr.get("changed_files", "?")
430
756
  additions = pr.get("additions", "?")
431
757
  deletions = pr.get("deletions", "?")
432
- clone_url = f"https://github.com/{REPO}.git"
433
758
  tone = _TONE_INSTRUCTIONS.get(REVIEW_TONE, _TONE_INSTRUCTIONS["thorough"])
434
759
  extra = f"\n\nAdditional style instructions:\n{REVIEW_STYLE_INSTRUCTIONS}" if REVIEW_STYLE_INSTRUCTIONS.strip() else ""
435
760
 
436
761
  return (
437
- "You are an AI code reviewer. Review the GitHub pull request below and write "
438
- "a single review comment. Do not modify files, push commits, approve via the GitHub "
439
- "API, or request changes via the review API; only produce the final comment text.\n\n"
440
- f"Repository : {REPO}\n"
441
- f"Clone URL : {clone_url}\n"
762
+ "You are an AI code reviewer. Review the GitHub pull request below and publish "
763
+ "the review directly to GitHub. Do not modify files, push commits, or approve "
764
+ "the pull request.\n\n"
765
+ f"Repository : {repo}\n"
442
766
  f"PR #{number}: \"{title}\"\n"
443
767
  f"Author : @{author}\n"
444
768
  f"Base → Head: {base_branch} ← {head_branch}\n"
@@ -449,31 +773,43 @@ def _build_review_prompt(pr: dict, head_sha: str, label_event: dict) -> str:
449
773
  f"URL : {html_url}\n"
450
774
  f"\nPR Description:\n---\n{body}\n---\n\n"
451
775
  "Required workflow:\n"
452
- "1. Clone the repository into a fresh working directory inside the workspace.\n"
453
- f" Example: `git clone {clone_url} pr-review-{number}`.\n"
454
- "2. Check out the exact pull request branch by PR number, then verify HEAD matches the SHA above.\n"
455
- f" Example: `git fetch origin pull/{number}/head:openhands-pr-{number}` followed by `git checkout openhands-pr-{number}`.\n"
456
- "3. Inspect the existing PR context before reviewing, including PR description, issue comments, review comments, changed files, and the diff.\n"
457
- " 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"
458
- "4. Use the checked-out repository to inspect relevant files and surrounding code, not just the patch.\n"
459
- "5. Before producing the final review text, delete only the cloned repository directory created in step 1.\n"
460
- f" Example: `rm -rf pr-review-{number}`. Do not delete any other files or directories.\n"
461
- "6. Write a high-signal review comment with specific findings. If there are no material issues, say so.\n"
776
+ "1. The workspace is already the repository root at the exact Head SHA above. "
777
+ "Do not clone, fetch, check out, or delete the repository.\n"
778
+ "2. Inspect the PR discussion, existing review comments, changed files, and the diff, "
779
+ "together with the surrounding code in the workspace.\n"
780
+ " Use `gh` or GitHub REST API calls with `GITHUB_PERSONAL_ACCESS_TOKEN`; never print secret values.\n"
781
+ "3. Ground every finding in the workspace code. Before using an inline location, verify that "
782
+ "the path and line are part of this pull request's diff.\n"
783
+ f"4. Publish one review with `POST /repos/{repo}/pulls/{number}/reviews`, using "
784
+ "`commit_id` equal to the Head SHA above and `event: COMMENT`.\n"
785
+ " Put the overall assessment in `body`, and each line-specific finding in the `comments` "
786
+ "array with `path`, `line`, `side: RIGHT`, and `body`.\n"
787
+ " Only create inline comments for actionable findings; do not open praise or nitpick threads.\n"
788
+ "5. If a finding cannot be attached to a changed line, put it in the review body instead. "
789
+ "If the API rejects the inline positions, retry with every finding in the body and no `comments` array.\n"
790
+ "6. Begin the review body with this disclosure: "
791
+ "`_This review was posted by an AI agent (OpenHands)._`\n"
792
+ "7. End the review body with a verdict on its own line: either `✅ APPROVED` "
793
+ "or `🔄 CHANGES REQUESTED`.\n"
794
+ "8. If there are no material issues, still publish a review saying so, with the "
795
+ "disclosure and the verdict.\n"
462
796
  f"\nReview instructions:\n{tone}{extra}\n\n"
463
- "Output ONLY the review text — no preamble, no meta-commentary. "
464
- "This text will be posted verbatim as a comment on the pull request. "
465
- "End your review with a clear verdict on its own line: either `✅ APPROVED` "
466
- "or `🔄 CHANGES REQUESTED`."
797
+ "After GitHub accepts the review, output exactly `GITHUB_REVIEW_POSTED`. "
798
+ "If publishing still fails after the fallback in step 5, output the complete review text "
799
+ "so it can be posted as a comment instead."
467
800
  )
468
801
 
802
+
469
803
  def _process_review_request(
470
804
  github_token: str,
471
805
  agent_url: str,
472
806
  api_key: str,
473
807
  openhands_url: str,
808
+ repo: str,
474
809
  pr: dict,
475
810
  label_event: dict,
476
811
  reviews: dict,
812
+ persist: Callable[[], None],
477
813
  ) -> str | None:
478
814
  number = pr["number"]
479
815
  head_sha = _head_sha(pr)
@@ -483,30 +819,55 @@ def _process_review_request(
483
819
  html_url = pr.get("html_url", "")
484
820
 
485
821
  print(f" Queuing review for PR #{number} from `{TRIGGER_LABEL}` event {label_event_id} at {head_sha[:12]}: {title}")
486
- prompt = _build_review_prompt(pr, head_sha, label_event)
487
-
488
- try:
489
- conv_id = create_conversation(agent_url, api_key, prompt)
490
- except Exception as exc:
491
- print(f" Error creating conversation for PR #{number}: {exc}")
492
- return None
493
822
 
823
+ # Claim the label event and persist it *before* the slow work below. State
824
+ # is otherwise only written when the repository finishes polling, so a poll
825
+ # starting while this one downloads an archive or spins up a conversation
826
+ # would read no record for this event and review the same commit a second
827
+ # time - two conversations, two "reviewing" comments, two reviews.
494
828
  reviews[key] = {
495
829
  "pr_number": number,
496
830
  "head_sha": head_sha,
497
831
  "trigger_label_event_id": label_event_id,
498
832
  "trigger_label_event_created_at": label_event.get("created_at"),
499
833
  "html_url": html_url,
500
- "status": "active",
501
- "conversation_id": conv_id,
834
+ "status": "starting",
835
+ "conversation_id": None,
836
+ "workspace_dir": None,
502
837
  "last_activity": time.time(),
503
838
  }
839
+ persist()
840
+
841
+ workspace_dir = None
842
+ try:
843
+ workspace_dir = _prepare_repository(github_token, repo, number, head_sha)
844
+ prompt = _build_review_prompt(repo, pr, head_sha, label_event)
845
+ conv_id = create_conversation(agent_url, api_key, prompt, workspace_dir)
846
+ except Exception as exc:
847
+ # The claim is dropped so the next poll retries this label event. The
848
+ # checkout goes with it rather than being left behind.
849
+ if workspace_dir:
850
+ shutil.rmtree(workspace_dir, ignore_errors=True)
851
+ reviews.pop(key, None)
852
+ persist()
853
+ print(f" Error starting review for PR #{number}: {exc}")
854
+ return None
855
+
856
+ reviews[key].update(
857
+ {
858
+ "status": "active",
859
+ "conversation_id": conv_id,
860
+ "workspace_dir": str(workspace_dir),
861
+ "last_activity": time.time(),
862
+ }
863
+ )
864
+ persist()
504
865
  print(f" Created review conversation {conv_id}")
505
866
 
506
867
  conv_url = f"{openhands_url}/conversations/{conv_id}"
507
868
  _post_github_comment(
508
869
  github_token,
509
- REPO,
870
+ repo,
510
871
  number,
511
872
  _with_ai_disclosure(
512
873
  "🤖 **OpenHands is reviewing this PR.**\n\n"
@@ -518,14 +879,17 @@ def _process_review_request(
518
879
  )
519
880
  return conv_id
520
881
 
882
+
521
883
  def _check_conversation_completion(
522
884
  rec: dict,
523
885
  latest_open_prs: dict[int, dict],
524
886
  github_token: str,
525
887
  agent_url: str,
526
888
  api_key: str,
889
+ repo: str,
527
890
  ) -> None:
528
- if (time.time() - rec.get("last_activity", 0.0)) < DONE_DEBOUNCE:
891
+ age = time.time() - rec.get("last_activity", 0.0)
892
+ if age < DONE_DEBOUNCE:
529
893
  return
530
894
 
531
895
  conv_id = rec["conversation_id"]
@@ -536,6 +900,7 @@ def _check_conversation_completion(
536
900
  if not current_pr:
537
901
  rec["status"] = "closed"
538
902
  print(f" PR #{pr_number} closed/merged — skipping result post")
903
+ _release_checkout(rec, agent_url, api_key)
539
904
  return
540
905
 
541
906
  current_sha = _head_sha(current_pr)
@@ -543,6 +908,7 @@ def _check_conversation_completion(
543
908
  rec["status"] = "stale"
544
909
  rec["stale_reason"] = f"head changed from {reviewed_sha} to {current_sha}"
545
910
  print(f" PR #{pr_number} advanced to {current_sha[:12]} — suppressing stale review {conv_id}")
911
+ _release_checkout(rec, agent_url, api_key)
546
912
  return
547
913
 
548
914
  try:
@@ -553,6 +919,11 @@ def _check_conversation_completion(
553
919
 
554
920
  print(f" PR #{pr_number} conversation {conv_id} → status={status}")
555
921
  if status not in TERMINAL_STATUSES:
922
+ if age > MAX_ACTIVE_AGE:
923
+ rec["status"] = "expired"
924
+ rec["expired_after"] = age
925
+ print(f" Review for PR #{pr_number} still '{status}' after {int(age)}s; abandoning it")
926
+ _release_checkout(rec, agent_url, api_key)
556
927
  return
557
928
 
558
929
  try:
@@ -561,41 +932,62 @@ def _check_conversation_completion(
561
932
  final = ""
562
933
 
563
934
  if status in {"error", "stuck"}:
564
- comment_body = _with_ai_disclosure(
565
- f"⚠️ **OpenHands PR Reviewer encountered a problem** at commit `{reviewed_sha[:12]}` "
566
- f"(status: `{status}`).\n\n{final}".strip()
935
+ _post_github_comment(
936
+ github_token,
937
+ repo,
938
+ pr_number,
939
+ _with_ai_disclosure(
940
+ f"⚠️ **OpenHands PR Reviewer encountered a problem** at commit `{reviewed_sha[:12]}` "
941
+ f"(status: `{status}`).\n\n{final}".strip()
942
+ ),
567
943
  )
944
+ elif _matching_review_exists(github_token, repo, pr_number, reviewed_sha):
945
+ print(f" PR #{pr_number}: review confirmed on GitHub at {reviewed_sha[:12]}")
568
946
  else:
569
- comment_body = _with_ai_disclosure(
570
- final
571
- or f"✅ **OpenHands completed the review for commit `{reviewed_sha[:12]}`.** No review text was produced."
947
+ # The agent was asked to publish the review itself; it did not, so the
948
+ # work is not lost - post whatever it produced as a comment.
949
+ _post_github_comment(
950
+ github_token,
951
+ repo,
952
+ pr_number,
953
+ _with_ai_disclosure(
954
+ final
955
+ or f"✅ **OpenHands completed the review for commit `{reviewed_sha[:12]}`.** No review text was produced."
956
+ ),
572
957
  )
958
+ print(f" PR #{pr_number}: no review found on GitHub; posted the result as a comment")
573
959
 
574
- _post_github_comment(github_token, REPO, pr_number, comment_body)
575
960
  rec["status"] = "closed"
576
961
  rec["completed_at"] = time.time()
577
- print(f" Posted review for PR #{pr_number} at {reviewed_sha[:12]}")
578
-
962
+ _release_checkout(rec, agent_url, api_key)
579
963
 
580
- def main() -> str | None:
581
- state = load_state()
582
- agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
583
- api_key = _get_env_key()
584
964
 
585
- github_token = _resolve_github_token()
586
- _verify_token_and_repo(github_token, REPO)
587
-
588
- try:
589
- openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
590
- except Exception:
591
- openhands_url = DEFAULT_OPENHANDS_URL
965
+ def _process_repo(
966
+ repo: str,
967
+ github_token: str,
968
+ agent_url: str,
969
+ api_key: str,
970
+ openhands_url: str,
971
+ ) -> str | None:
972
+ """Poll one repository end to end. Its state is loaded and saved here, so a
973
+ failure in another repository cannot discard this one's progress."""
974
+ print(f"\n=== {repo} ===")
975
+ _verify_repo(github_token, repo)
592
976
 
977
+ state = load_state(repo)
593
978
  reviews: dict = state.setdefault("reviews", {})
594
979
  prs_state: dict = state.setdefault("prs", {})
595
980
 
596
- open_prs = _list_open_prs(github_token, REPO)
981
+ def persist() -> None:
982
+ state["version"] = 3
983
+ state["repo"] = repo
984
+ state["trigger_label"] = TRIGGER_LABEL
985
+ state["updated_at"] = time.time()
986
+ save_state(repo, state)
987
+
988
+ open_prs = _list_open_prs(github_token, repo)
597
989
  latest_open_prs = {pr["number"]: pr for pr in open_prs}
598
- print(f"Found {len(open_prs)} open PR(s) in {REPO}")
990
+ print(f" Found {len(open_prs)} open PR(s)")
599
991
 
600
992
  last_conversation_id = None
601
993
 
@@ -616,7 +1008,7 @@ def main() -> str | None:
616
1008
  print(f" PR #{number} has no head SHA; skipping")
617
1009
  continue
618
1010
 
619
- fresh_pr = _get_pr(github_token, REPO, number)
1011
+ fresh_pr = _get_pr(github_token, repo, number)
620
1012
  fresh_head_sha = _head_sha(fresh_pr)
621
1013
  if fresh_head_sha != head_sha:
622
1014
  print(f" PR #{number} head changed during poll ({head_sha[:12]} → {fresh_head_sha[:12]}); using latest PR metadata")
@@ -624,7 +1016,7 @@ def main() -> str | None:
624
1016
  print(f" PR #{number} lost `{TRIGGER_LABEL}` during poll; skipping")
625
1017
  continue
626
1018
 
627
- label_event = _latest_trigger_label_event(github_token, REPO, number)
1019
+ label_event = _latest_trigger_label_event(github_token, repo, number)
628
1020
  if not label_event:
629
1021
  print(f" PR #{number} has `{TRIGGER_LABEL}` but no matching labeled event; skipping")
630
1022
  continue
@@ -634,19 +1026,64 @@ def main() -> str | None:
634
1026
  print(f" PR #{number} label event {label_event['id']} already tracked ({reviews[key].get('status')})")
635
1027
  continue
636
1028
 
637
- conv_id = _process_review_request(github_token, agent_url, api_key, openhands_url, fresh_pr, label_event, reviews)
1029
+ conv_id = _process_review_request(
1030
+ github_token, agent_url, api_key, openhands_url, repo, fresh_pr, label_event, reviews, persist
1031
+ )
638
1032
  if conv_id:
639
1033
  last_conversation_id = conv_id
640
1034
 
641
- for rec in list(reviews.values()):
642
- if rec.get("status") != "active":
1035
+ for rev_key, rec in list(reviews.items()):
1036
+ if rec.get("status") == "starting":
1037
+ # A claim this poll made has already moved to "active" or been
1038
+ # dropped, so one still sitting here belongs to a poll that died
1039
+ # between claiming and creating its conversation. Release it once it
1040
+ # is old enough that no live poll could still be working on it,
1041
+ # otherwise the label event would never be reviewed.
1042
+ age = time.time() - float(rec.get("last_activity") or 0)
1043
+ if age > STALLED_CLAIM_SECONDS:
1044
+ print(f" Releasing a claim stalled for {int(age)}s: {rev_key}")
1045
+ reviews.pop(rev_key, None)
643
1046
  continue
644
- _check_conversation_completion(rec, latest_open_prs, github_token, agent_url, api_key)
1047
+ if rec.get("status") == "active":
1048
+ _check_conversation_completion(rec, latest_open_prs, github_token, agent_url, api_key, repo)
1049
+ elif rec.get("workspace_dir"):
1050
+ # A checkout whose removal could not be confirmed on an earlier
1051
+ # poll, e.g. the agent was still running when its PR was closed.
1052
+ _release_checkout(rec, agent_url, api_key)
1053
+
1054
+ persist()
1055
+ return last_conversation_id
645
1056
 
646
- state["repo"] = REPO
647
- state["trigger_label"] = TRIGGER_LABEL
648
- state["updated_at"] = time.time()
649
- save_state(state)
1057
+
1058
+ def main() -> str | None:
1059
+ agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
1060
+ api_key = _get_env_key()
1061
+
1062
+ github_token = _resolve_github_token()
1063
+ _verify_token(github_token)
1064
+
1065
+ try:
1066
+ openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
1067
+ except Exception:
1068
+ openhands_url = DEFAULT_OPENHANDS_URL
1069
+
1070
+ last_conversation_id = None
1071
+ failures = []
1072
+ for configured in REPOS:
1073
+ # One repository failing must not stop the others from being polled.
1074
+ try:
1075
+ repo = normalize_repo(configured)
1076
+ conv_id = _process_repo(repo, github_token, agent_url, api_key, openhands_url)
1077
+ if conv_id:
1078
+ last_conversation_id = conv_id
1079
+ except Exception as exc:
1080
+ print(f"Error processing {configured}: {exc}")
1081
+ failures.append(f"{configured}: {exc}")
1082
+
1083
+ if failures and len(failures) == len(REPOS):
1084
+ # Every repository failed, so the run achieved nothing - report it as a
1085
+ # failed run rather than a successful no-op.
1086
+ raise RuntimeError("; ".join(failures))
650
1087
  return last_conversation_id
651
1088
 
652
1089