@openhands/extensions 0.30.0 → 0.32.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/.agents/skills/custom-codereview-guide.md +3 -4
- package/.claude-plugin/marketplace.json +14 -0
- package/.codex-plugin/marketplace.json +14 -0
- package/.github/workflows/pr-artifacts.yml +4 -83
- package/.github/workflows/tests.yml +13 -0
- package/.plugin/marketplace.json +14 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +4 -3
- package/README.md +3 -2
- package/automations/README.md +9 -2
- package/automations/bundle-index.js +1 -1
- package/automations/catalog/github-pr-reviewer/manifest.json +2 -2
- package/automations/catalog/incident-retrospective-drafter/manifest.json +4 -0
- package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +3 -0
- package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +3 -0
- package/automations/catalog/jira-issue-to-pr/manifest.json +3 -0
- package/automations/catalog/linear-issue-to-bitbucket-pr/manifest.json +4 -0
- package/automations/catalog/linear-issue-to-github-pr/manifest.json +4 -0
- package/automations/catalog/linear-issue-to-gitlab-mr/manifest.json +4 -0
- package/automations/catalog/linear-triage-assistant/manifest.json +4 -0
- package/automations/catalog/research-brief-writer/manifest.json +3 -0
- package/automations/catalog/slack-channel-monitor/manifest.json +3 -0
- package/automations/catalog/slack-standup-digest/manifest.json +3 -0
- package/automations/catalog.schema.json +18 -1
- package/automations/index.d.ts +8 -0
- package/marketplaces/openhands-extensions.json +14 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/bitbucket-data-center/SKILL.md +26 -9
- package/skills/github-pr-reviewer/README.md +6 -0
- package/skills/github-pr-reviewer/SKILL.md +7 -0
- package/skills/github-stale-ci-pr-closer/scripts/worker.py +8 -2
- package/skills/index.js +15 -4
- package/skills/slack-channel-monitor/README.md +6 -2
- package/skills/slack-channel-monitor/SKILL.md +29 -17
- package/skills/slack-channel-monitor/references/windows.md +6 -2
- package/skills/slack-channel-monitor/scripts/main.py +156 -11
- package/skills/software-factory/.claude-plugin/plugin.json +19 -0
- package/skills/software-factory/.codex-plugin/plugin.json +19 -0
- package/skills/software-factory/.plugin/plugin.json +19 -0
- package/skills/software-factory/README.md +64 -0
- package/skills/software-factory/SKILL.md +229 -0
- package/skills/software-factory/commands/software-factory.md +8 -0
- package/skills/software-factory/scripts/factory.py +630 -0
- package/tests/test_github_stale_ci_pr_closer.py +52 -1
- package/tests/test_software_factory_script.py +231 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic helpers for the software-factory skill (stdlib only, no LLM).
|
|
3
|
+
|
|
4
|
+
Each command prints one JSON document on stdout and exits non-zero on failure.
|
|
5
|
+
|
|
6
|
+
factory.py preflight --repos owner/a,owner/b [--profile NAME]
|
|
7
|
+
factory.py create --repos owner/a --ids ID[,ID...] [--set [ID.]FIELD=VALUE ...] [--profile NAME]
|
|
8
|
+
factory.py tune --repos owner/a --ids ID[,ID...] [--set [ID.]FIELD=VALUE ...] [--profile NAME]
|
|
9
|
+
factory.py verify ID [ID ...] [--no-wait ID ...] [--timeout SECONDS]
|
|
10
|
+
factory.py status ID [ID ...]
|
|
11
|
+
factory.py prs --repos owner/a [--branch-prefix openhands/agents-md]
|
|
12
|
+
factory.py reset-token
|
|
13
|
+
|
|
14
|
+
Environment:
|
|
15
|
+
OPENHANDS_HOST automation host, as the openhands-automation skill determines it
|
|
16
|
+
OPENHANDS_API_KEY Bearer auth (OpenHands Cloud)
|
|
17
|
+
OPENHANDS_AUTOMATION_API_KEY X-Session-API-Key auth (local Agent Canvas; SESSION_API_KEY also works)
|
|
18
|
+
GITHUB_PERSONAL_ACCESS_TOKEN GitHub token; read, never printed
|
|
19
|
+
FACTORY_EXTENSIONS catalog checkout; defaults to the package this script ships in
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import io
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import tarfile
|
|
32
|
+
import time
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.parse
|
|
35
|
+
import urllib.request
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
TOKEN_SECRET = "GITHUB_PERSONAL_ACCESS_TOKEN"
|
|
39
|
+
EXTENSIONS_URL = "https://github.com/OpenHands/extensions"
|
|
40
|
+
PLACEHOLDER_RE = re.compile(r"\{\{([a-z]+)\.([A-Za-z0-9_.]+)\}\}")
|
|
41
|
+
STATIONS = (
|
|
42
|
+
"github-issue-to-pr",
|
|
43
|
+
"github-pr-reviewer",
|
|
44
|
+
"github-agents-md-maintainer",
|
|
45
|
+
"github-issue-triage",
|
|
46
|
+
"github-delivery-watchdog",
|
|
47
|
+
"github-stale-ci-pr-closer",
|
|
48
|
+
"qa-changes",
|
|
49
|
+
)
|
|
50
|
+
TERMINAL = {"COMPLETED", "FAILED", "CANCELLED", "SKIPPED"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FactoryError(Exception):
|
|
54
|
+
def __init__(self, message: str, **extra):
|
|
55
|
+
super().__init__(message)
|
|
56
|
+
self.extra = extra
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- HTTP -------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _host() -> str:
|
|
63
|
+
host = os.environ.get("OPENHANDS_HOST", "").rstrip("/")
|
|
64
|
+
if not host:
|
|
65
|
+
raise FactoryError("set OPENHANDS_HOST (see the openhands-automation skill)")
|
|
66
|
+
return host
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _auth() -> dict:
|
|
70
|
+
local = urllib.parse.urlparse(_host()).hostname in ("localhost", "127.0.0.1", "::1")
|
|
71
|
+
session = os.environ.get("OPENHANDS_AUTOMATION_API_KEY") or os.environ.get("SESSION_API_KEY")
|
|
72
|
+
bearer = os.environ.get("OPENHANDS_API_KEY")
|
|
73
|
+
if session and (local or not bearer):
|
|
74
|
+
return {"X-Session-API-Key": session}
|
|
75
|
+
if bearer:
|
|
76
|
+
return {"Authorization": f"Bearer {bearer}"}
|
|
77
|
+
raise FactoryError(
|
|
78
|
+
"no automation API key: name OPENHANDS_API_KEY or OPENHANDS_AUTOMATION_API_KEY"
|
|
79
|
+
" in the command so the agent server passes the saved secret in"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _request(
|
|
84
|
+
method: str,
|
|
85
|
+
url: str,
|
|
86
|
+
body=None,
|
|
87
|
+
headers=None,
|
|
88
|
+
raw: bytes | None = None,
|
|
89
|
+
return_status: bool = False,
|
|
90
|
+
):
|
|
91
|
+
headers = dict(headers or {})
|
|
92
|
+
data = raw
|
|
93
|
+
if body is not None:
|
|
94
|
+
data = json.dumps(body).encode()
|
|
95
|
+
headers["Content-Type"] = "application/json"
|
|
96
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
97
|
+
try:
|
|
98
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
99
|
+
status_code = resp.status
|
|
100
|
+
text = resp.read().decode()
|
|
101
|
+
except urllib.error.HTTPError as err:
|
|
102
|
+
detail = err.read().decode()[:2000]
|
|
103
|
+
raise FactoryError(f"{method} {url} -> {err.code}: {detail}") from None
|
|
104
|
+
except urllib.error.URLError as err:
|
|
105
|
+
raise FactoryError(f"{method} {url} -> {err.reason}") from None
|
|
106
|
+
payload = json.loads(text) if text.strip() else None
|
|
107
|
+
# The status distinguishes a fresh create (201) from the 200 the service
|
|
108
|
+
# returns when the same template already exists in the organization.
|
|
109
|
+
return (status_code, payload) if return_status else payload
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _api(
|
|
113
|
+
method: str,
|
|
114
|
+
path: str,
|
|
115
|
+
body=None,
|
|
116
|
+
raw: bytes | None = None,
|
|
117
|
+
headers=None,
|
|
118
|
+
return_status: bool = False,
|
|
119
|
+
):
|
|
120
|
+
return _request(
|
|
121
|
+
method,
|
|
122
|
+
f"{_host()}/api/automation/v1{path}",
|
|
123
|
+
body,
|
|
124
|
+
{**_auth(), **(headers or {})},
|
|
125
|
+
raw,
|
|
126
|
+
return_status,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _github(path: str):
|
|
131
|
+
token = os.environ.get(TOKEN_SECRET)
|
|
132
|
+
if not token:
|
|
133
|
+
raise FactoryError(f"{TOKEN_SECRET} is not set")
|
|
134
|
+
return _request(
|
|
135
|
+
"GET",
|
|
136
|
+
f"https://api.github.com{path}",
|
|
137
|
+
headers={
|
|
138
|
+
"Authorization": f"Bearer {token}",
|
|
139
|
+
"Accept": "application/vnd.github+json",
|
|
140
|
+
"User-Agent": "openhands-software-factory",
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --- Catalog ----------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _extensions() -> Path:
|
|
149
|
+
"""The checkout that holds `automations/catalog`.
|
|
150
|
+
|
|
151
|
+
Prefer the installed package or working copy this script lives in, so a
|
|
152
|
+
factory installs the bundles that shipped with the release the user has,
|
|
153
|
+
rather than whatever `main` happens to be at run time. A bare copy of this
|
|
154
|
+
script falls back to a shallow clone, pinned to the release tag when the
|
|
155
|
+
installed version is known.
|
|
156
|
+
"""
|
|
157
|
+
override = os.environ.get("FACTORY_EXTENSIONS")
|
|
158
|
+
if override:
|
|
159
|
+
return Path(override)
|
|
160
|
+
installed = Path(__file__).resolve().parents[3]
|
|
161
|
+
if (installed / "automations" / "catalog").is_dir():
|
|
162
|
+
return installed
|
|
163
|
+
root = Path("/tmp/software-factory/extensions")
|
|
164
|
+
if not (root / "automations" / "catalog").is_dir():
|
|
165
|
+
root.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
ref = _release_ref()
|
|
167
|
+
cmd = ["git", "clone", "-q", "--depth", "1"]
|
|
168
|
+
if ref:
|
|
169
|
+
cmd += ["--branch", ref]
|
|
170
|
+
subprocess.run([*cmd, EXTENSIONS_URL, str(root)], check=True)
|
|
171
|
+
return root
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _release_ref() -> str | None:
|
|
175
|
+
"""The `vX.Y.Z` tag matching the installed package, if it is known."""
|
|
176
|
+
try:
|
|
177
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
178
|
+
|
|
179
|
+
return f"v{version('openhands-extensions')}"
|
|
180
|
+
except Exception:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _manifest(station: str) -> dict:
|
|
185
|
+
path = _extensions() / "automations" / "catalog" / station / "manifest.json"
|
|
186
|
+
if not path.is_file():
|
|
187
|
+
raise FactoryError(f"unknown station: {station}")
|
|
188
|
+
return json.loads(path.read_text())
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _needs_profile(entry: dict) -> bool:
|
|
192
|
+
return "agentProfiles" in entry.get("requires", {}).get("features", [])
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _resolve(namespace: str, key: str, context: dict):
|
|
196
|
+
value = context[namespace]
|
|
197
|
+
for part in key.split("."):
|
|
198
|
+
if not isinstance(value, dict) or part not in value:
|
|
199
|
+
raise FactoryError(f"unresolved placeholder {{{{{namespace}.{key}}}}}")
|
|
200
|
+
value = value[part]
|
|
201
|
+
return value
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _interpolate(node, context: dict):
|
|
205
|
+
if isinstance(node, dict):
|
|
206
|
+
return {k: _interpolate(v, context) for k, v in node.items()}
|
|
207
|
+
if isinstance(node, list):
|
|
208
|
+
return [_interpolate(v, context) for v in node]
|
|
209
|
+
if not isinstance(node, str):
|
|
210
|
+
return node
|
|
211
|
+
whole = PLACEHOLDER_RE.fullmatch(node)
|
|
212
|
+
if whole:
|
|
213
|
+
return _resolve(whole.group(1), whole.group(2), context)
|
|
214
|
+
return PLACEHOLDER_RE.sub(lambda m: str(_resolve(m.group(1), m.group(2), context)), node)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _parse_value(text: str):
|
|
218
|
+
try:
|
|
219
|
+
return json.loads(text)
|
|
220
|
+
except json.JSONDecodeError:
|
|
221
|
+
return text
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _fields(entry: dict) -> dict:
|
|
225
|
+
form = entry["setup"]["form"]
|
|
226
|
+
# Only the cron trigger group carries form fields; an entry whose only
|
|
227
|
+
# trigger is an event declares no cron fields, so this must not index it.
|
|
228
|
+
return {**form.get("args", {}), **form.get("triggers", {}).get("cron", {})}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _form_values(entry: dict, repos: list[str], overrides: dict) -> dict:
|
|
232
|
+
"""Answers, else derived values, else manifest defaults."""
|
|
233
|
+
fields = _fields(entry)
|
|
234
|
+
values = {name: spec["default"] for name, spec in fields.items() if "default" in spec}
|
|
235
|
+
for name, spec in fields.items():
|
|
236
|
+
if spec.get("type") == "repo-picker":
|
|
237
|
+
values[name] = repos if spec.get("multiple") else repos[0]
|
|
238
|
+
if "githubTokenSecret" in fields:
|
|
239
|
+
values["githubTokenSecret"] = TOKEN_SECRET
|
|
240
|
+
for name, value in overrides.items():
|
|
241
|
+
if name not in fields:
|
|
242
|
+
raise FactoryError(f"{entry['id']} has no field {name}")
|
|
243
|
+
values[name] = value
|
|
244
|
+
missing = [n for n, s in fields.items() if s.get("required") and values.get(n) in (None, "", [])]
|
|
245
|
+
if missing:
|
|
246
|
+
raise FactoryError(f"{entry['id']} is missing required fields: {missing}")
|
|
247
|
+
return values
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _create_body(entry: dict, values: dict, repos: list[str]) -> dict:
|
|
251
|
+
"""The create request, as Agent Canvas builds it for a catalog bundle."""
|
|
252
|
+
bundle = entry["setup"]["bundle"]
|
|
253
|
+
name = entry["name"] + (f" - {repos[0]}" if len(repos) == 1 else f" - {len(repos)} repositories")
|
|
254
|
+
body = {
|
|
255
|
+
"name": name,
|
|
256
|
+
"trigger": {"type": "cron", "schedule": values["schedule"], "timezone": values["timezone"]},
|
|
257
|
+
"tarball_path": "",
|
|
258
|
+
"entrypoint": bundle["entrypoint"],
|
|
259
|
+
}
|
|
260
|
+
if "setupScript" in bundle:
|
|
261
|
+
body["setup_script_path"] = bundle["setupScript"]
|
|
262
|
+
if "timeout" in bundle:
|
|
263
|
+
body["timeout"] = bundle["timeout"]
|
|
264
|
+
body["template"] = {
|
|
265
|
+
"id": entry["id"],
|
|
266
|
+
"version": bundle["version"],
|
|
267
|
+
"config": _interpolate(bundle["config"], {"form": values, "automation": entry}),
|
|
268
|
+
}
|
|
269
|
+
return body
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _tarball(root: Path, files: dict, config: dict) -> bytes:
|
|
273
|
+
buf = io.BytesIO()
|
|
274
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
275
|
+
for archive_name, repo_path in files.items():
|
|
276
|
+
tar.add(str((root / repo_path).resolve()), arcname=archive_name)
|
|
277
|
+
data = json.dumps(config, indent=2).encode()
|
|
278
|
+
info = tarfile.TarInfo("config.json")
|
|
279
|
+
info.size = len(data)
|
|
280
|
+
tar.addfile(info, io.BytesIO(data))
|
|
281
|
+
return buf.getvalue()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _upload(name: str, data: bytes) -> str:
|
|
285
|
+
upload = _api(
|
|
286
|
+
"POST",
|
|
287
|
+
f"/uploads?name={urllib.parse.quote(name)}",
|
|
288
|
+
raw=data,
|
|
289
|
+
headers={"Content-Type": "application/gzip"},
|
|
290
|
+
)
|
|
291
|
+
deadline = time.time() + 120
|
|
292
|
+
while upload.get("status") == "UPLOADING" and time.time() < deadline:
|
|
293
|
+
time.sleep(2)
|
|
294
|
+
upload = _api("GET", f"/uploads/{upload['id']}")
|
|
295
|
+
if upload.get("status") not in (None, "COMPLETED") or not upload.get("tarball_path"):
|
|
296
|
+
raise FactoryError(f"upload of {name} failed: {upload.get('error_message') or upload}")
|
|
297
|
+
return upload["tarball_path"]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# --- Agent profile ----------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _profile(name: str | None) -> dict:
|
|
304
|
+
try:
|
|
305
|
+
listing = _request("GET", f"{_host()}/api/agent-profiles", headers=_auth())
|
|
306
|
+
except FactoryError as err:
|
|
307
|
+
if " -> 404" in str(err):
|
|
308
|
+
raise FactoryError(
|
|
309
|
+
"OPENHANDS_HOST serves no /api/agent-profiles: on a local Agent Canvas use the"
|
|
310
|
+
" Ingress URL from <RUNTIME_SERVICES>, not the automation backend"
|
|
311
|
+
) from None
|
|
312
|
+
raise
|
|
313
|
+
profiles = listing.get("profiles", [])
|
|
314
|
+
wanted = name or listing.get("active_agent_profile_id")
|
|
315
|
+
match = next((p for p in profiles if wanted in (p.get("id"), p.get("name"))), None)
|
|
316
|
+
if not match:
|
|
317
|
+
raise FactoryError(f"agent profile not found: {wanted}")
|
|
318
|
+
if match.get("agent_kind") == "acp":
|
|
319
|
+
raise FactoryError(f"agent profile {match['name']} is an ACP profile")
|
|
320
|
+
detail = _request(
|
|
321
|
+
"GET",
|
|
322
|
+
f"{_host()}/api/agent-profiles/{urllib.parse.quote(match['name'])}",
|
|
323
|
+
headers=_auth(),
|
|
324
|
+
)
|
|
325
|
+
refs = (detail or {}).get("profile", {}).get("secret_refs")
|
|
326
|
+
if refs is not None and TOKEN_SECRET not in json.dumps(refs):
|
|
327
|
+
raise FactoryError(f"agent profile {match['name']} does not allow {TOKEN_SECRET}")
|
|
328
|
+
return {"id": match["id"], "name": match["name"]}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# --- Commands ---------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def preflight(args) -> dict:
|
|
335
|
+
out: dict = {}
|
|
336
|
+
features = set(_api("GET", "/capabilities").get("features", []))
|
|
337
|
+
out["stations"] = {}
|
|
338
|
+
for station in STATIONS:
|
|
339
|
+
entry = _manifest(station)
|
|
340
|
+
missing = sorted(set(entry.get("requires", {}).get("features", [])) - features)
|
|
341
|
+
out["stations"][station] = "ok" if not missing else f"missing {missing}"
|
|
342
|
+
try:
|
|
343
|
+
out["profile"] = _profile(args.profile)
|
|
344
|
+
except FactoryError as err:
|
|
345
|
+
if "OPENHANDS_HOST" in str(err):
|
|
346
|
+
raise
|
|
347
|
+
out["profile"] = {"error": str(err)}
|
|
348
|
+
out["github_login"] = None
|
|
349
|
+
if not os.environ.get(TOKEN_SECRET):
|
|
350
|
+
out["token_error"] = (
|
|
351
|
+
f"not_set: name {TOKEN_SECRET} in the command so the agent server passes the"
|
|
352
|
+
" saved secret in; if it is still not set, it is not saved"
|
|
353
|
+
)
|
|
354
|
+
return out
|
|
355
|
+
try:
|
|
356
|
+
out["github_login"] = _github("/user")["login"]
|
|
357
|
+
except FactoryError as err:
|
|
358
|
+
rejected = " -> 401" in str(err) or " -> 403" in str(err)
|
|
359
|
+
out["token_error"] = ("rejected: " if rejected else "") + str(err)[:300]
|
|
360
|
+
return out
|
|
361
|
+
out["repos"] = {}
|
|
362
|
+
for repo in _split(args.repos):
|
|
363
|
+
try:
|
|
364
|
+
out["repos"][repo] = "push" if _github(f"/repos/{repo}")["permissions"]["push"] else "no push"
|
|
365
|
+
except (FactoryError, KeyError):
|
|
366
|
+
out["repos"][repo] = "not found or no access"
|
|
367
|
+
return out
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def create(args) -> dict:
|
|
371
|
+
repos = _split(args.repos)
|
|
372
|
+
stations = _split(args.ids)
|
|
373
|
+
entries = {station: _manifest(station) for station in stations}
|
|
374
|
+
overrides = _overrides(entries, args.set or [], stations)
|
|
375
|
+
root = _extensions()
|
|
376
|
+
profile = None
|
|
377
|
+
created = []
|
|
378
|
+
existing = []
|
|
379
|
+
try:
|
|
380
|
+
for station in stations:
|
|
381
|
+
entry = entries[station]
|
|
382
|
+
bundle = entry.get("setup", {}).get("bundle")
|
|
383
|
+
if not bundle:
|
|
384
|
+
raise FactoryError(f"{station} has no bundle; create it as the skill describes")
|
|
385
|
+
values = _form_values(entry, repos, overrides[station])
|
|
386
|
+
body = _create_body(entry, values, repos)
|
|
387
|
+
body["tarball_path"] = _upload(station, _tarball(root, bundle["files"], body["template"]["config"]))
|
|
388
|
+
body["enabled"] = False
|
|
389
|
+
if _needs_profile(entry):
|
|
390
|
+
profile = profile or _profile(args.profile)
|
|
391
|
+
body["agent_profile_id"] = profile["id"]
|
|
392
|
+
status_code, automation = _api("POST", "", body, return_status=True)
|
|
393
|
+
if status_code == 200:
|
|
394
|
+
# The service returns the org's existing automation for the same
|
|
395
|
+
# template id (see find_existing_template_automation). Report it
|
|
396
|
+
# as pre-existing so a repeat run does not claim it created it.
|
|
397
|
+
existing.append(_summary(station, body["name"], body["trigger"], automation))
|
|
398
|
+
continue
|
|
399
|
+
if automation.get("enabled") or automation.get("state") == "ACTIVE":
|
|
400
|
+
_api("PATCH", f"/{automation['id']}", {"enabled": False})
|
|
401
|
+
created.append(_summary(station, body["name"], body["trigger"], automation))
|
|
402
|
+
except FactoryError as err:
|
|
403
|
+
raise FactoryError(str(err), created=created, existing=existing) from None
|
|
404
|
+
return {"created": created, "existing": existing}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def tune(args) -> dict:
|
|
408
|
+
"""Apply new settings to an existing station by updating it in place.
|
|
409
|
+
|
|
410
|
+
`create` cannot do this: it stamps `template.id`, and the service dedupes on
|
|
411
|
+
that id, so a second `create` returns the original automation with its old
|
|
412
|
+
config instead of a replacement. Here the same values are rendered, the
|
|
413
|
+
bundle is re-uploaded, and the automation is PATCHed, so the new values take
|
|
414
|
+
effect without a delete and without a template-id collision.
|
|
415
|
+
"""
|
|
416
|
+
repos = _split(args.repos)
|
|
417
|
+
stations = _split(args.ids)
|
|
418
|
+
entries = {station: _manifest(station) for station in stations}
|
|
419
|
+
overrides = _overrides(entries, args.set or [], stations)
|
|
420
|
+
root = _extensions()
|
|
421
|
+
profile = None
|
|
422
|
+
tuned = []
|
|
423
|
+
for station in stations:
|
|
424
|
+
entry = entries[station]
|
|
425
|
+
bundle = entry.get("setup", {}).get("bundle")
|
|
426
|
+
if not bundle:
|
|
427
|
+
raise FactoryError(f"{station} has no bundle; tune it as the skill describes")
|
|
428
|
+
automation = _find_existing(station)
|
|
429
|
+
if automation is None:
|
|
430
|
+
raise FactoryError(f"{station} is not set up yet; create it before tuning it")
|
|
431
|
+
values = _form_values(entry, repos, overrides[station])
|
|
432
|
+
body = _create_body(entry, values, repos)
|
|
433
|
+
patch = {
|
|
434
|
+
"name": body["name"],
|
|
435
|
+
"trigger": body["trigger"],
|
|
436
|
+
"tarball_path": _upload(station, _tarball(root, bundle["files"], body["template"]["config"])),
|
|
437
|
+
"entrypoint": body["entrypoint"],
|
|
438
|
+
}
|
|
439
|
+
if "timeout" in body:
|
|
440
|
+
patch["timeout"] = body["timeout"]
|
|
441
|
+
if "setup_script_path" in body:
|
|
442
|
+
patch["setup_script_path"] = body["setup_script_path"]
|
|
443
|
+
if _needs_profile(entry):
|
|
444
|
+
profile = profile or _profile(args.profile)
|
|
445
|
+
patch["agent_profile_id"] = profile["id"]
|
|
446
|
+
updated = _api("PATCH", f"/{automation['id']}", patch)
|
|
447
|
+
tuned.append(_summary(station, body["name"], body["trigger"], updated))
|
|
448
|
+
return {"tuned": tuned}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _overrides(entries: dict, items: list[str], stations: list[str]) -> dict[str, dict]:
|
|
452
|
+
"""Parse `--set` into per-station overrides.
|
|
453
|
+
|
|
454
|
+
FIELD=VALUE applies to every listed station with that field; ID.FIELD=VALUE
|
|
455
|
+
to one. Station lookup uses the parsed manifest, so a field on a station
|
|
456
|
+
that declares no such field is reported instead of applied blindly.
|
|
457
|
+
"""
|
|
458
|
+
overrides: dict[str, dict] = {station: {} for station in stations}
|
|
459
|
+
for item in items:
|
|
460
|
+
key, _, value = item.partition("=")
|
|
461
|
+
target, _, field = key.rpartition(".")
|
|
462
|
+
if target and target not in entries:
|
|
463
|
+
raise FactoryError(f"--set names a station not in --ids: {target}")
|
|
464
|
+
targets = [target] if target else [s for s in stations if field in _fields(entries[s])]
|
|
465
|
+
if not targets:
|
|
466
|
+
raise FactoryError(f"no listed station has field {field}")
|
|
467
|
+
for station in targets:
|
|
468
|
+
overrides[station][field] = _parse_value(value)
|
|
469
|
+
return overrides
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _find_existing(station: str) -> dict | None:
|
|
473
|
+
"""The org's automation created from this station's template, if any."""
|
|
474
|
+
offset = 0
|
|
475
|
+
while True:
|
|
476
|
+
page = _api("GET", f"?limit=100&offset={offset}")
|
|
477
|
+
automations = page.get("automations", [])
|
|
478
|
+
for automation in automations:
|
|
479
|
+
template = (automation.get("preset_metadata") or {}).get("template") or {}
|
|
480
|
+
if template.get("id") == station:
|
|
481
|
+
return automation
|
|
482
|
+
if len(automations) < 100:
|
|
483
|
+
return None
|
|
484
|
+
offset += 100
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _summary(station: str, name: str, trigger: dict, automation: dict) -> dict:
|
|
488
|
+
return {
|
|
489
|
+
"station": station,
|
|
490
|
+
"id": automation["id"],
|
|
491
|
+
"name": name,
|
|
492
|
+
"trigger": trigger,
|
|
493
|
+
"url": _automation_url(automation["id"]),
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _automation_url(automation_id: str) -> str:
|
|
498
|
+
"""The automation's page in Agent Canvas."""
|
|
499
|
+
return f"{_host()}/automations/{automation_id}"
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _latest_run(automation_id: str) -> dict | None:
|
|
503
|
+
runs = _api("GET", f"/{automation_id}/runs?limit=1").get("runs", [])
|
|
504
|
+
return runs[0] if runs else None
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def verify(args) -> dict:
|
|
508
|
+
results = {}
|
|
509
|
+
waiting = {}
|
|
510
|
+
for automation_id in args.ids + (args.no_wait or []):
|
|
511
|
+
run = _api("POST", f"/{automation_id}/dispatch")
|
|
512
|
+
if automation_id in (args.no_wait or []):
|
|
513
|
+
_api("PATCH", f"/{automation_id}", {"enabled": True})
|
|
514
|
+
results[automation_id] = {
|
|
515
|
+
"status": run.get("status"),
|
|
516
|
+
"enabled": True,
|
|
517
|
+
"url": _automation_url(automation_id),
|
|
518
|
+
}
|
|
519
|
+
else:
|
|
520
|
+
waiting[automation_id] = run.get("id")
|
|
521
|
+
deadline = time.time() + args.timeout
|
|
522
|
+
while waiting and time.time() < deadline:
|
|
523
|
+
time.sleep(5)
|
|
524
|
+
for automation_id in list(waiting):
|
|
525
|
+
run = _latest_run(automation_id) or {}
|
|
526
|
+
if run.get("status") not in TERMINAL:
|
|
527
|
+
continue
|
|
528
|
+
passed = run["status"] == "COMPLETED"
|
|
529
|
+
_api("PATCH", f"/{automation_id}", {"enabled": passed})
|
|
530
|
+
results[automation_id] = {
|
|
531
|
+
"status": run["status"],
|
|
532
|
+
"enabled": passed,
|
|
533
|
+
"error_detail": run.get("error_detail"),
|
|
534
|
+
"url": _automation_url(automation_id),
|
|
535
|
+
}
|
|
536
|
+
del waiting[automation_id]
|
|
537
|
+
for automation_id in waiting:
|
|
538
|
+
_api("PATCH", f"/{automation_id}", {"enabled": False})
|
|
539
|
+
results[automation_id] = {
|
|
540
|
+
"status": "TIMEOUT",
|
|
541
|
+
"enabled": False,
|
|
542
|
+
"url": _automation_url(automation_id),
|
|
543
|
+
}
|
|
544
|
+
return results
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def status(args) -> dict:
|
|
548
|
+
out = {}
|
|
549
|
+
for automation_id in args.ids:
|
|
550
|
+
run = _latest_run(automation_id) or {}
|
|
551
|
+
out[automation_id] = {k: run.get(k) for k in ("status", "error_detail", "conversation_id", "completed_at")}
|
|
552
|
+
return out
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def prs(args) -> dict:
|
|
556
|
+
"""Open pull requests whose branch starts with the prefix, per repository."""
|
|
557
|
+
out = {}
|
|
558
|
+
for repo in _split(args.repos):
|
|
559
|
+
pulls = _github(f"/repos/{repo}/pulls?state=open&per_page=100")
|
|
560
|
+
out[repo] = [
|
|
561
|
+
{
|
|
562
|
+
"number": p["number"],
|
|
563
|
+
"title": p["title"],
|
|
564
|
+
"url": p["html_url"],
|
|
565
|
+
"draft": p.get("draft"),
|
|
566
|
+
"created_at": p.get("created_at"),
|
|
567
|
+
}
|
|
568
|
+
for p in pulls
|
|
569
|
+
if p["head"]["ref"].startswith(args.branch_prefix)
|
|
570
|
+
]
|
|
571
|
+
return out
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def reset_token(args) -> dict:
|
|
575
|
+
"""Delete the saved token so request_secret opens its form (local Agent Canvas)."""
|
|
576
|
+
try:
|
|
577
|
+
_request("DELETE", f"{_host()}/api/settings/secrets/{TOKEN_SECRET}", headers=_auth())
|
|
578
|
+
except FactoryError as err:
|
|
579
|
+
raise FactoryError(f"could not delete {TOKEN_SECRET}; replace it in Settings -> Secrets: {err}") from None
|
|
580
|
+
return {"deleted": TOKEN_SECRET}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _split(text: str) -> list[str]:
|
|
584
|
+
return [part.strip() for part in text.split(",") if part.strip()]
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def main() -> int:
|
|
588
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
589
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
590
|
+
p = sub.add_parser("preflight")
|
|
591
|
+
p.add_argument("--repos", required=True)
|
|
592
|
+
p.add_argument("--profile")
|
|
593
|
+
p.set_defaults(func=preflight)
|
|
594
|
+
p = sub.add_parser("create")
|
|
595
|
+
p.add_argument("--repos", required=True)
|
|
596
|
+
p.add_argument("--ids", required=True)
|
|
597
|
+
p.add_argument("--set", action="append", metavar="[ID.]FIELD=VALUE")
|
|
598
|
+
p.add_argument("--profile")
|
|
599
|
+
p.set_defaults(func=create)
|
|
600
|
+
p = sub.add_parser("tune")
|
|
601
|
+
p.add_argument("--repos", required=True)
|
|
602
|
+
p.add_argument("--ids", required=True)
|
|
603
|
+
p.add_argument("--set", action="append", metavar="[ID.]FIELD=VALUE")
|
|
604
|
+
p.add_argument("--profile")
|
|
605
|
+
p.set_defaults(func=tune)
|
|
606
|
+
p = sub.add_parser("verify")
|
|
607
|
+
p.add_argument("ids", nargs="*")
|
|
608
|
+
p.add_argument("--no-wait", nargs="+", metavar="ID")
|
|
609
|
+
p.add_argument("--timeout", type=int, default=600)
|
|
610
|
+
p.set_defaults(func=verify)
|
|
611
|
+
p = sub.add_parser("status")
|
|
612
|
+
p.add_argument("ids", nargs="+")
|
|
613
|
+
p.set_defaults(func=status)
|
|
614
|
+
p = sub.add_parser("prs")
|
|
615
|
+
p.add_argument("--repos", required=True)
|
|
616
|
+
p.add_argument("--branch-prefix", default="openhands/agents-md")
|
|
617
|
+
p.set_defaults(func=prs)
|
|
618
|
+
p = sub.add_parser("reset-token")
|
|
619
|
+
p.set_defaults(func=reset_token)
|
|
620
|
+
args = parser.parse_args()
|
|
621
|
+
try:
|
|
622
|
+
print(json.dumps(args.func(args), indent=2))
|
|
623
|
+
except FactoryError as err:
|
|
624
|
+
print(json.dumps({"error": str(err), **err.extra}, indent=2))
|
|
625
|
+
return 1
|
|
626
|
+
return 0
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
if __name__ == "__main__":
|
|
630
|
+
sys.exit(main())
|
|
@@ -1,10 +1,61 @@
|
|
|
1
|
-
"""Tests for how the stale-CI pull request closer reports its run."""
|
|
1
|
+
"""Tests for how the stale-CI pull request closer keeps state and reports its run."""
|
|
2
2
|
|
|
3
|
+
import io
|
|
3
4
|
import json
|
|
5
|
+
from urllib.error import HTTPError
|
|
4
6
|
|
|
5
7
|
import pytest
|
|
6
8
|
from github_automation_helpers import worker
|
|
7
9
|
|
|
10
|
+
RECORDS = {"7": {"head_sha": "abc", "first_failed_at": 0}}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def kv(tmp_path, monkeypatch):
|
|
15
|
+
"""A KV store that, like the real one, keeps a PUT body verbatim."""
|
|
16
|
+
module = worker("github-stale-ci-pr-closer", tmp_path, monkeypatch)
|
|
17
|
+
monkeypatch.setattr(module, "KV_TOKEN", "kv-token")
|
|
18
|
+
monkeypatch.setattr(module, "KV_BASE", "https://automation.test")
|
|
19
|
+
store = {}
|
|
20
|
+
|
|
21
|
+
def urlopen(request):
|
|
22
|
+
if request.get_method() == "PUT":
|
|
23
|
+
store["value"] = json.loads(request.data)
|
|
24
|
+
elif "value" not in store:
|
|
25
|
+
raise HTTPError(request.full_url, 404, "Not Found", {}, None)
|
|
26
|
+
return io.BytesIO(json.dumps({"value": store["value"]}).encode())
|
|
27
|
+
|
|
28
|
+
monkeypatch.setattr(module, "urlopen", urlopen)
|
|
29
|
+
return module, store
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_state_round_trips_through_the_kv_store_unchanged(kv):
|
|
33
|
+
# Arrange
|
|
34
|
+
module, store = kv
|
|
35
|
+
state = {"prs": RECORDS}
|
|
36
|
+
|
|
37
|
+
# Act
|
|
38
|
+
for _ in range(3):
|
|
39
|
+
module.save_state("OpenHands/extensions", state)
|
|
40
|
+
state = module.load_state("OpenHands/extensions")
|
|
41
|
+
|
|
42
|
+
# Assert
|
|
43
|
+
assert store["value"] == {"version": 1, "prs": RECORDS}
|
|
44
|
+
assert state == store["value"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_state_nested_by_earlier_versions_is_recovered(kv):
|
|
48
|
+
# Arrange
|
|
49
|
+
module, store = kv
|
|
50
|
+
oldest = {"value": {"version": 1, "prs": {}}}
|
|
51
|
+
store["value"] = {"value": {"value": oldest, "version": 1, "prs": RECORDS}}
|
|
52
|
+
|
|
53
|
+
# Act
|
|
54
|
+
state = module.load_state("OpenHands/extensions")
|
|
55
|
+
|
|
56
|
+
# Assert
|
|
57
|
+
assert state == {"version": 1, "prs": RECORDS}
|
|
58
|
+
|
|
8
59
|
|
|
9
60
|
@pytest.mark.parametrize(
|
|
10
61
|
("environment", "expected"),
|