@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.
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +1 -0
- package/automations/README.md +97 -18
- package/automations/bundle-index.js +9 -0
- package/automations/catalog/github-pr-reviewer/manifest.json +23 -7
- package/automations/catalog/github-repo-monitor/manifest.json +28 -34
- package/automations/catalog/jira-issue-to-pr/manifest.json +3 -3
- package/automations/catalog.schema.json +102 -1
- package/automations/index.d.ts +174 -1
- package/automations/index.js +14 -0
- package/automations/interface.json +93 -6
- package/automations/interface.schema.json +273 -9
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/integrations/catalog/posthog.json +41 -3
- package/integrations/catalog/sonarqube.json +78 -0
- package/integrations/catalog-index.js +65 -63
- package/marketplaces/openhands-extensions.json +11 -0
- package/package.json +1 -1
- package/plugins/qa-changes/action.yml +1 -1
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/scripts/build-automation-catalog.mjs +30 -0
- package/scripts/build-skills-catalog.mjs +24 -7
- package/skills/add-skill/SKILL.md +1 -1
- package/skills/github-pr-reviewer/README.md +12 -6
- package/skills/github-pr-reviewer/SKILL.md +93 -31
- package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
- package/skills/github-pr-reviewer/references/state-schema.md +68 -18
- package/skills/github-pr-reviewer/scripts/main.py +540 -103
- package/skills/github-pr-reviewer/tests/test_main.py +503 -0
- package/skills/index.d.ts +6 -0
- package/skills/index.js +29 -17
- package/skills/jira-issue-to-pr/scripts/main.py +4 -2
- package/skills/openhands-sdk/SKILL.md +2 -0
- package/skills/skill-creator/SKILL.md +1 -1
- package/skills/slack-channel-monitor/scripts/main.py +3 -2
- package/tests/fixtures/automations/capabilities.json +30 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +319 -55
- package/tests/fixtures/automations/github-repo-monitor.json +153 -62
- package/tests/test_automation_setup.py +226 -31
- package/tests/test_catalogs.py +43 -0
- package/tests/test_interface_manifest.py +20 -0
- package/tests/test_skills_catalog.py +182 -36
- 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
|
|
5
|
-
trigger label. A review is queued only when the latest matching
|
|
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
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from pathlib import Path, PurePosixPath
|
|
16
29
|
from urllib.parse import urlencode
|
|
17
30
|
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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":
|
|
127
|
-
"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(
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
351
|
-
|
|
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
|
|
438
|
-
"
|
|
439
|
-
"
|
|
440
|
-
f"Repository : {
|
|
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.
|
|
453
|
-
|
|
454
|
-
"2.
|
|
455
|
-
|
|
456
|
-
"
|
|
457
|
-
"
|
|
458
|
-
"
|
|
459
|
-
"
|
|
460
|
-
|
|
461
|
-
"
|
|
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
|
-
"
|
|
464
|
-
"
|
|
465
|
-
"
|
|
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": "
|
|
501
|
-
"conversation_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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
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)
|
|
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,
|
|
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,
|
|
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(
|
|
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.
|
|
642
|
-
if rec.get("status")
|
|
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
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
|