@openhands/extensions 0.29.0 → 0.31.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 (97) hide show
  1. package/.agents/skills/custom-codereview-guide.md +47 -5
  2. package/.claude-plugin/marketplace.json +20 -5
  3. package/.codex-plugin/marketplace.json +20 -5
  4. package/.github/workflows/build-vscode-app.yml +3 -3
  5. package/.github/workflows/check-extensions.yml +1 -1
  6. package/.github/workflows/pr-artifacts.yml +4 -83
  7. package/.github/workflows/tests.yml +14 -1
  8. package/.plugin/marketplace.json +20 -5
  9. package/.release-please-manifest.json +1 -1
  10. package/AGENTS.md +4 -3
  11. package/README.md +6 -5
  12. package/apps/vscode/README.md +8 -6
  13. package/apps/vscode/canvas-extension.json +11 -3
  14. package/apps/vscode/extension.js +60 -12
  15. package/apps/vscode/scripts/package.mjs +20 -74
  16. package/automations/bundle-index.js +10 -8
  17. package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
  18. package/automations/catalog/github-issue-triage/manifest.json +4 -3
  19. package/automations/catalog/github-pr-reviewer/manifest.json +7 -6
  20. package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
  21. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
  22. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
  23. package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
  24. package/integrations/catalog/atlassian-rovo.json +4 -3
  25. package/integrations/catalog/bitbucket.json +16 -1
  26. package/integrations/catalog/github.json +1 -0
  27. package/integrations/catalog/gitlab.json +1 -1
  28. package/marketplaces/openhands-extensions.json +20 -5
  29. package/package.json +1 -1
  30. package/plugins/pr-review/README.md +1 -0
  31. package/plugins/pr-review/scripts/prompt.py +44 -11
  32. package/plugins/runtime-api-configs/SKILL.md +1 -1
  33. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
  34. package/pyproject.toml +1 -1
  35. package/python/openhands_extensions/_version.py +1 -1
  36. package/skills/canvas-extension-api/references/backend-safety.md +3 -3
  37. package/skills/canvas-extension-api/references/v1-contract.md +7 -4
  38. package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
  39. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
  40. package/skills/github/scripts/agent_conversation.py +224 -8
  41. package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
  42. package/skills/github-issue-triage/README.md +73 -14
  43. package/skills/github-issue-triage/SKILL.md +26 -46
  44. package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
  45. package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
  46. package/skills/github-issue-triage/scripts/worker.py +153 -103
  47. package/skills/github-pr-reviewer/README.md +109 -1
  48. package/skills/github-pr-reviewer/SKILL.md +42 -8
  49. package/skills/github-pr-reviewer/references/state-schema.md +8 -0
  50. package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
  51. package/skills/github-pr-reviewer/scripts/main.py +163 -39
  52. package/skills/github-pr-reviewer/scripts/publication.py +193 -0
  53. package/skills/github-pr-reviewer/scripts/worker.py +297 -8
  54. package/skills/github-pr-reviewer/tests/test_main.py +73 -5
  55. package/skills/github-stale-ci-pr-closer/scripts/worker.py +8 -2
  56. package/skills/gitlab-issue-to-mr/README.md +10 -3
  57. package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
  58. package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
  59. package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
  60. package/skills/index.js +18 -8
  61. package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
  62. package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
  63. package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
  64. package/skills/jira-issue-to-pr/README.md +18 -11
  65. package/skills/jira-issue-to-pr/SKILL.md +72 -23
  66. package/skills/jira-issue-to-pr/references/setup.md +43 -12
  67. package/skills/jira-issue-to-pr/scripts/main.py +136 -26
  68. package/skills/slack-channel-monitor/README.md +12 -3
  69. package/skills/slack-channel-monitor/SKILL.md +35 -20
  70. package/skills/slack-channel-monitor/references/windows.md +6 -2
  71. package/skills/slack-channel-monitor/scripts/main.py +189 -12
  72. package/skills/software-factory/.claude-plugin/plugin.json +19 -0
  73. package/skills/software-factory/.codex-plugin/plugin.json +19 -0
  74. package/skills/software-factory/.plugin/plugin.json +19 -0
  75. package/skills/software-factory/README.md +64 -0
  76. package/skills/software-factory/SKILL.md +229 -0
  77. package/skills/software-factory/commands/software-factory.md +8 -0
  78. package/skills/software-factory/scripts/factory.py +630 -0
  79. package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
  80. package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
  81. package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
  82. package/skills/ticket-to-code-change/SKILL.md +5 -3
  83. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
  84. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  85. package/tests/test_agent_conversation_dispatch.py +173 -6
  86. package/tests/test_automation_llm_provenance.py +1209 -0
  87. package/tests/test_github_review_publication.py +422 -0
  88. package/tests/test_github_reviewer_delivery.py +174 -21
  89. package/tests/test_github_stale_ci_pr_closer.py +52 -1
  90. package/tests/test_github_triage_delivery.py +411 -238
  91. package/tests/test_gitlab_issue_to_mr.py +284 -0
  92. package/tests/test_jira_issue_to_pr.py +200 -6
  93. package/tests/test_pr_review_prompt_injection.py +353 -0
  94. package/tests/test_slack_channel_monitor.py +32 -1
  95. package/tests/test_software_factory_script.py +231 -0
  96. package/tests/test_vscode_app.py +8 -2
  97. package/tests/vscode-app.test.mjs +8 -2
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "ticket-to-code-change",
3
3
  "version": "1.0.0",
4
- "description": "Create pull or merge requests from Jira or Linear tickets across GitHub, GitLab, and Bitbucket.",
4
+ "description": "Create pull or merge requests from Linear tickets across GitHub, GitLab, and Bitbucket.",
5
5
  "author": {
6
6
  "name": "OpenHands",
7
7
  "email": "contact@all-hands.dev"
@@ -10,7 +10,6 @@
10
10
  "repository": "https://github.com/OpenHands/extensions",
11
11
  "license": "MIT",
12
12
  "keywords": [
13
- "jira",
14
13
  "linear",
15
14
  "github",
16
15
  "gitlab",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ticket-to-code-change",
3
3
  "version": "1.0.0",
4
- "description": "Create pull or merge requests from Jira or Linear tickets across GitHub, GitLab, and Bitbucket.",
4
+ "description": "Create pull or merge requests from Linear tickets across GitHub, GitLab, and Bitbucket.",
5
5
  "author": {
6
6
  "name": "OpenHands",
7
7
  "email": "contact@all-hands.dev"
@@ -10,7 +10,6 @@
10
10
  "repository": "https://github.com/OpenHands/extensions",
11
11
  "license": "MIT",
12
12
  "keywords": [
13
- "jira",
14
13
  "linear",
15
14
  "github",
16
15
  "gitlab",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ticket-to-code-change",
3
3
  "version": "1.0.0",
4
- "description": "Create pull or merge requests from Jira or Linear tickets across GitHub, GitLab, and Bitbucket.",
4
+ "description": "Create pull or merge requests from Linear tickets across GitHub, GitLab, and Bitbucket.",
5
5
  "author": {
6
6
  "name": "OpenHands",
7
7
  "email": "contact@all-hands.dev"
@@ -10,7 +10,6 @@
10
10
  "repository": "https://github.com/OpenHands/extensions",
11
11
  "license": "MIT",
12
12
  "keywords": [
13
- "jira",
14
13
  "linear",
15
14
  "github",
16
15
  "gitlab",