@openhands/extensions 0.19.0 → 0.21.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 (64) hide show
  1. package/.github/workflows/check-extensions.yml +4 -0
  2. package/.github/workflows/pr-artifacts.yml +294 -0
  3. package/.github/workflows/pr-description-check.yml +4 -2
  4. package/.release-please-manifest.json +1 -1
  5. package/AGENTS.md +28 -4
  6. package/README.md +5 -2
  7. package/automations/README.md +52 -36
  8. package/automations/bundle-index.js +13 -3
  9. package/automations/catalog/custom-automation/manifest.json +229 -0
  10. package/automations/catalog/github-agents-md-maintainer/manifest.json +2 -1
  11. package/automations/catalog/github-issue-to-pr/manifest.json +3 -2
  12. package/automations/catalog/github-issue-triage/manifest.json +72 -0
  13. package/automations/catalog/github-pr-reviewer/manifest.json +3 -2
  14. package/automations/catalog/gitlab-issue-to-mr/manifest.json +124 -0
  15. package/automations/catalog/qa-changes/manifest.json +1 -1
  16. package/automations/catalog-index.js +23 -17
  17. package/automations/catalog.schema.json +81 -7
  18. package/automations/index.d.ts +52 -2
  19. package/automations/interface.json +2 -1
  20. package/integrations/catalog/gitlab.json +27 -5
  21. package/integrations/catalog/reportportal.json +76 -0
  22. package/integrations/catalog-index.js +6 -4
  23. package/marketplaces/openhands-extensions.json +36 -0
  24. package/package.json +1 -1
  25. package/pyproject.toml +2 -2
  26. package/python/openhands_extensions/_version.py +1 -1
  27. package/skills/github/scripts/github_client.py +204 -0
  28. package/skills/github-agents-md-maintainer/SKILL.md +7 -1
  29. package/skills/github-agents-md-maintainer/scripts/main.py +4 -40
  30. package/skills/github-issue-to-pr/SKILL.md +8 -1
  31. package/skills/github-issue-to-pr/scripts/main.py +3 -40
  32. package/skills/github-issue-triage/.plugin/plugin.json +9 -0
  33. package/skills/github-issue-triage/README.md +9 -0
  34. package/skills/github-issue-triage/SKILL.md +37 -0
  35. package/skills/github-issue-triage/commands/github-issue-triage.md +8 -0
  36. package/skills/github-issue-triage/scripts/worker.py +123 -0
  37. package/skills/github-pr-reviewer/SKILL.md +8 -1
  38. package/skills/github-pr-reviewer/scripts/main.py +3 -41
  39. package/skills/github-repo-monitor/SKILL.md +7 -1
  40. package/skills/github-repo-monitor/scripts/main.py +4 -46
  41. package/skills/gitlab-issue-to-mr/.plugin/plugin.json +19 -0
  42. package/skills/gitlab-issue-to-mr/README.md +92 -0
  43. package/skills/gitlab-issue-to-mr/SKILL.md +408 -0
  44. package/skills/gitlab-issue-to-mr/commands/issue-to-mr-setup.md +8 -0
  45. package/skills/gitlab-issue-to-mr/references/state-schema.md +140 -0
  46. package/skills/gitlab-issue-to-mr/scripts/main.py +1448 -0
  47. package/skills/index.js +43 -6
  48. package/skills/openhands-automation/README.md +1 -1
  49. package/skills/openhands-automation/SKILL.md +18 -8
  50. package/skills/openhands-enterprise-troubleshooting/.plugin/plugin.json +20 -0
  51. package/skills/openhands-enterprise-troubleshooting/README.md +79 -0
  52. package/skills/openhands-enterprise-troubleshooting/SKILL.md +339 -0
  53. package/skills/openhands-enterprise-troubleshooting/references/diagnostics.md +698 -0
  54. package/skills/openhands-enterprise-troubleshooting/references/support-bundle-analysis.md +570 -0
  55. package/skills/openhands-enterprise-troubleshooting/scripts/bundle_triage.py +532 -0
  56. package/skills/openhands-sdk/SKILL.md +2 -0
  57. package/tests/fixtures/automations/custom-automation.json +398 -0
  58. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  59. package/tests/github_automation_helpers.py +25 -0
  60. package/tests/test_automation_setup.py +340 -92
  61. package/tests/test_github_automation_foundation.py +73 -0
  62. package/tests/test_github_skill_installation.py +63 -0
  63. package/tests/test_github_triage_delivery.py +53 -0
  64. package/tests/test_gitlab_issue_to_mr.py +823 -0
@@ -39,23 +39,28 @@ CAPABILITIES_PATH = FIXTURE_DIR / "capabilities.json"
39
39
 
40
40
  # The standardized parts of a direct setup, identical for every automation and
41
41
  # therefore not declared in any entry. Which create endpoint is used follows
42
- # from what the entry produces: a prompt is a preset, a bundle is a tarball the
43
- # host uploads and then creates from, which is the raw endpoint.
44
- CREATE_PATH = "/v1/preset/prompt"
42
+ # from the selected action: prompt and plugin actions use preset endpoints;
43
+ # uploaded tarballs and repository-shipped bundles use the raw endpoint.
44
+ PROMPT_CREATE_PATH = "/v1/preset/prompt"
45
+ PLUGIN_CREATE_PATH = "/v1/preset/plugin"
45
46
  BUNDLE_CREATE_PATH = "/v1"
46
47
  BUNDLE_UPLOAD_PATH = "/v1/uploads"
47
48
  PREFLIGHT_PATH = "/v1/validate"
48
49
 
49
50
  # Preflight runs while the form is being filled in and the upload happens once,
50
- # at submit, so a bundle draft has no tarball path to send yet. The service
51
- # checks that field's scheme at preflight and its ownership only at creation,
52
- # so a well-formed stand-in validates what preflight is for - the rest of the
53
- # body - without uploading an archive per keystroke.
51
+ # at submit, so an uploaded-tarball draft has no tarball path to send yet. The
52
+ # service checks that field's scheme at preflight and its ownership only at
53
+ # creation, so a well-formed stand-in validates what preflight is for.
54
54
  PREFLIGHT_TARBALL_PATH = "oh-internal://uploads/00000000-0000-0000-0000-000000000000"
55
55
 
56
56
  # The trigger properties the service accepts, per kind. A form field named
57
57
  # after one of them fills it; the rest are inputs to the declared filter.
58
- TRIGGER_PROPERTIES = {"cron": ("schedule", "timezone"), "event": ("on",)}
58
+ TRIGGER_PROPERTIES = {"cron": ("schedule", "timezone"), "event": ("source", "on")}
59
+
60
+ # Optional top-level fields accepted by every creation path represented here. A
61
+ # form field with one of these names fills the matching request property when it
62
+ # has a value.
63
+ OPTIONAL_CREATE_PROPERTIES = ("model", "timeout")
59
64
 
60
65
  _SCHEMA = json.loads(SCHEMA_PATH.read_text())
61
66
  VALIDATOR = Draft202012Validator(_SCHEMA)
@@ -152,13 +157,60 @@ def _interpolate(node, context: dict):
152
157
  )
153
158
 
154
159
 
160
+ def _has_value(value) -> bool:
161
+ return value is not None and value != ""
162
+
163
+
164
+ def _selected_trigger_kind(entry: dict, selected_trigger: str | None = None) -> str:
165
+ """The active trigger variant for this form submission.
166
+
167
+ A one-trigger entry gets its kind from the manifest as before. When an
168
+ entry declares multiple trigger variants, selection is host UI state rather
169
+ than a form field, so fixtures record it as `selectedTrigger`.
170
+ """
171
+ trigger_groups = entry["setup"]["form"].get("triggers", {})
172
+ if selected_trigger:
173
+ assert selected_trigger in trigger_groups, (
174
+ f"{entry['id']}: selectedTrigger {selected_trigger!r} is not declared"
175
+ )
176
+ return selected_trigger
177
+ assert len(trigger_groups) == 1, (
178
+ f"{entry['id']}: fixtures for multi-trigger setup must declare selectedTrigger"
179
+ )
180
+ return next(iter(trigger_groups))
181
+
182
+
183
+ def _actions(entry: dict) -> dict:
184
+ return entry.get("setup", {}).get("actions", {})
185
+
186
+
187
+ def _selected_action_kind(entry: dict, selected_action: str | None = None) -> str:
188
+ """The active creation-path variant for this form submission."""
189
+ actions = _actions(entry)
190
+ if actions:
191
+ assert selected_action in actions, (
192
+ f"{entry['id']}: selectedAction {selected_action!r} is not declared"
193
+ )
194
+ return selected_action
195
+ return "bundle" if _is_bundle(entry) else "prompt"
196
+
197
+
198
+ def _selected_action(entry: dict, selected_action: str | None = None) -> dict | None:
199
+ actions = _actions(entry)
200
+ if not actions:
201
+ return None
202
+ return actions[_selected_action_kind(entry, selected_action)]
203
+
204
+
155
205
  def _context(entry: dict, form_values: dict) -> dict:
156
206
  """`automation` resolves against the catalog entry the setup block sits in."""
157
207
  return {"form": form_values, "automation": entry}
158
208
 
159
209
 
160
- def _repo_picker(setup: dict) -> tuple[str | None, dict | None]:
161
- for name, field in _fields(setup).items():
210
+ def _repo_picker(
211
+ setup: dict, selected_action: str | None = None
212
+ ) -> tuple[str | None, dict | None]:
213
+ for name, field in _fields(setup, selected_action).items():
162
214
  if field["type"] == "repo-picker":
163
215
  return name, field
164
216
  return None, None
@@ -169,31 +221,38 @@ def _is_bundle(entry: dict) -> bool:
169
221
  return "bundle" in entry.get("setup", {})
170
222
 
171
223
 
172
- def _create_path(entry: dict) -> str:
173
- return BUNDLE_CREATE_PATH if _is_bundle(entry) else CREATE_PATH
224
+ def _create_path(entry: dict, selected_action: str | None = None) -> str:
225
+ kind = _selected_action_kind(entry, selected_action)
226
+ if kind == "prompt":
227
+ return PROMPT_CREATE_PATH
228
+ if kind == "plugin":
229
+ return PLUGIN_CREATE_PATH
230
+ return BUNDLE_CREATE_PATH
174
231
 
175
232
 
176
- def _repo_values(setup: dict, form_values: dict) -> list[str]:
177
- """Every repository the form collected, whether it collects one or many.
178
-
179
- A `multiple` picker holds a list; a single one holds a string. Reading both
180
- as a list is what keeps the rest of the derivation from branching.
181
- """
182
- name, field = _repo_picker(setup)
233
+ def _repo_values(
234
+ setup: dict, form_values: dict, selected_action: str | None = None
235
+ ) -> list[str]:
236
+ """Every repository the selected action collected."""
237
+ name, field = _repo_picker(setup, selected_action)
183
238
  if not name:
184
239
  return []
185
240
  value = form_values.get(name)
186
- if value is None or value == "":
241
+ if not _has_value(value):
187
242
  return []
188
243
  if field and field.get("multiple"):
189
244
  return list(value)
190
245
  return [value]
191
246
 
192
247
 
193
- def _derive_name(entry: dict, form_values: dict) -> str:
194
- """The created automation's name. One repository is worth naming; several
195
- are not, so the count stands in rather than a list that never fits."""
196
- repos = _repo_values(entry["setup"], form_values)
248
+ def _derive_name(
249
+ entry: dict, form_values: dict, selected_action: str | None = None
250
+ ) -> str:
251
+ """The created automation's name."""
252
+ if "name" in _fields(entry["setup"], selected_action) and "name" in form_values:
253
+ return form_values["name"]
254
+
255
+ repos = _repo_values(entry["setup"], form_values, selected_action)
197
256
  if not repos:
198
257
  return entry["name"]
199
258
  if len(repos) == 1:
@@ -201,35 +260,46 @@ def _derive_name(entry: dict, form_values: dict) -> str:
201
260
  return f"{entry['name']} - {len(repos)} repositories"
202
261
 
203
262
 
204
- def _derive_trigger(entry: dict, form_values: dict) -> dict:
205
- """The `trigger` object, read off the key and fields under form.triggers."""
263
+ def _derive_trigger(
264
+ entry: dict, form_values: dict, selected_trigger: str | None = None
265
+ ) -> dict:
266
+ """The `trigger` object, read off the selected trigger variant."""
206
267
  setup = entry["setup"]
207
- kind, trigger_fields = next(iter(setup["form"]["triggers"].items()))
268
+ kind = _selected_trigger_kind(entry, selected_trigger)
269
+ trigger_fields = setup["form"]["triggers"][kind]
208
270
  trigger = {"type": kind}
271
+
209
272
  # A field under a trigger kind fills the trigger property of the same name.
210
273
  # Anything else there, such as a phrase to match, is an input to `filter`.
211
274
  for name in trigger_fields:
212
- if name in TRIGGER_PROPERTIES[kind]:
275
+ if name in TRIGGER_PROPERTIES[kind] and _has_value(form_values.get(name)):
213
276
  trigger[name] = form_values[name]
277
+
214
278
  if kind == "event":
215
- _, repo_field = _repo_picker(setup)
216
- trigger["source"] = repo_field["provider"]
217
- trigger["filter"] = _interpolate(setup["filter"], _context(entry, form_values))
279
+ if "source" not in trigger:
280
+ _, repo_field = _repo_picker(setup)
281
+ if repo_field:
282
+ trigger["source"] = repo_field["provider"]
283
+ if "filter" in setup:
284
+ rendered_filter = _interpolate(
285
+ setup["filter"], _context(entry, form_values)
286
+ )
287
+ if _has_value(rendered_filter):
288
+ trigger["filter"] = rendered_filter
218
289
  return trigger
219
290
 
220
291
 
221
- def _render_bundle_payload(entry: dict, form_values: dict, tarball_path: str) -> dict:
222
- """The raw create body a bundle entry produces.
223
-
224
- `tarball_path` is the one value that is neither declared nor derived: the
225
- host uploads the packed bundle first and creates from what came back. The
226
- rest follows the same rule as the preset path - only `config` is declared,
227
- because only the entry knows which key of its own script each field fills.
228
- """
292
+ def _render_bundle_payload(
293
+ entry: dict,
294
+ form_values: dict,
295
+ tarball_path: str,
296
+ selected_trigger: str | None = None,
297
+ ) -> dict:
298
+ """The raw create body a repository-shipped bundle entry produces."""
229
299
  bundle = entry["setup"]["bundle"]
230
300
  body: dict = {
231
301
  "name": _derive_name(entry, form_values),
232
- "trigger": _derive_trigger(entry, form_values),
302
+ "trigger": _derive_trigger(entry, form_values, selected_trigger),
233
303
  "tarball_path": tarball_path,
234
304
  "entrypoint": bundle["entrypoint"],
235
305
  }
@@ -245,63 +315,121 @@ def _render_bundle_payload(entry: dict, form_values: dict, tarball_path: str) ->
245
315
  return body
246
316
 
247
317
 
248
- def _render_payload(entry: dict, form_values: dict, tarball_path: str = "") -> dict:
249
- """The create request body these form values produce.
318
+ def _add_optional_create_properties(
319
+ body: dict, setup: dict, form_values: dict, selected_action: str | None = None
320
+ ) -> None:
321
+ for name in OPTIONAL_CREATE_PROPERTIES:
322
+ if name in _fields(setup, selected_action) and _has_value(form_values.get(name)):
323
+ body[name] = form_values[name]
324
+
325
+
326
+ def _add_repo_property(
327
+ body: dict, setup: dict, form_values: dict, selected_action: str | None = None
328
+ ) -> None:
329
+ _, repo_field = _repo_picker(setup, selected_action)
330
+ repos = _repo_values(setup, form_values, selected_action)
331
+ if not repos:
332
+ return
333
+ ref = form_values.get("ref")
334
+ body["repos"] = [
335
+ {
336
+ "url": repo,
337
+ "provider": repo_field["provider"],
338
+ **({"ref": ref} if _has_value(ref) else {}),
339
+ }
340
+ for repo in repos
341
+ ]
250
342
 
251
- No entry declares this. `name` comes from the entry, `repos` from the
252
- repo-picker field and its provider, and `trigger` from the key and fields
253
- under `form.triggers`. Only `prompt` (or, for a bundle, `config`) and an
254
- event `filter` are declared, because only they cannot be read off the form.
255
- """
256
- if _is_bundle(entry):
257
- return _render_bundle_payload(entry, form_values, tarball_path)
258
343
 
344
+ def _render_upload_payload(
345
+ entry: dict,
346
+ form_values: dict,
347
+ tarball_path: str,
348
+ selected_trigger: str | None = None,
349
+ selected_action: str | None = None,
350
+ ) -> dict:
351
+ """The raw create body a user-uploaded tarball action produces."""
259
352
  setup = entry["setup"]
353
+ action = _selected_action(entry, selected_action)
354
+ assert action is not None
260
355
  context = _context(entry, form_values)
261
- _, repo_field = _repo_picker(setup)
262
- repos = _repo_values(setup, form_values)
356
+ body: dict = {
357
+ "name": _derive_name(entry, form_values, selected_action),
358
+ "trigger": _derive_trigger(entry, form_values, selected_trigger),
359
+ "tarball_path": tarball_path or _interpolate(action["tarballPath"], context),
360
+ "entrypoint": _interpolate(action["entrypoint"], context),
361
+ }
362
+ if "setupScript" in action:
363
+ setup_script = _interpolate(action["setupScript"], context)
364
+ if _has_value(setup_script):
365
+ body["setup_script_path"] = setup_script
366
+ _add_optional_create_properties(body, setup, form_values, selected_action)
367
+ return body
368
+
369
+
370
+ def _render_payload(
371
+ entry: dict,
372
+ form_values: dict,
373
+ tarball_path: str = "",
374
+ selected_trigger: str | None = None,
375
+ selected_action: str | None = None,
376
+ ) -> dict:
377
+ """The create request body these form values produce."""
378
+ selected_action_kind = _selected_action_kind(entry, selected_action)
379
+ if selected_action_kind == "bundle":
380
+ return _render_bundle_payload(entry, form_values, tarball_path, selected_trigger)
381
+ if selected_action_kind == "upload":
382
+ return _render_upload_payload(
383
+ entry, form_values, tarball_path, selected_trigger, selected_action
384
+ )
263
385
 
386
+ setup = entry["setup"]
387
+ action = _selected_action(entry, selected_action)
388
+ context = _context(entry, form_values)
389
+ prompt_template = action["prompt"] if action else setup["prompt"]
264
390
  body: dict = {
265
- "name": _derive_name(entry, form_values),
266
- "prompt": _interpolate(setup["prompt"], context),
391
+ "name": _derive_name(entry, form_values, selected_action),
392
+ "prompt": _interpolate(prompt_template, context),
267
393
  }
394
+ if selected_action_kind == "plugin":
395
+ assert action is not None
396
+ body["plugins"] = _interpolate(action["plugins"], context)
397
+
398
+ _add_optional_create_properties(body, setup, form_values, selected_action)
399
+ _add_repo_property(body, setup, form_values, selected_action)
400
+ body["trigger"] = _derive_trigger(entry, form_values, selected_trigger)
268
401
 
269
- if repos:
270
- body["repos"] = [
271
- {
272
- "url": repo,
273
- "provider": repo_field["provider"],
274
- **({"ref": form_values["ref"]} if "ref" in form_values else {}),
275
- }
276
- for repo in repos
277
- ]
278
-
279
- body["trigger"] = _derive_trigger(entry, form_values)
280
-
281
- # A versioned entry sends its provenance: the service stores it opaquely,
282
- # keyed by id for idempotent creation. The form values are non-secret by
283
- # design (credentials come only from connected integrations).
284
402
  if "version" in entry:
285
403
  body["template"] = {
286
404
  "id": entry["id"],
287
405
  "version": entry["version"],
288
406
  "config": dict(form_values),
289
407
  }
290
-
291
408
  return body
292
409
 
293
410
 
294
- def _derive_preflight_body(entry: dict, form_values: dict) -> dict:
411
+ def _derive_preflight_body(
412
+ entry: dict,
413
+ form_values: dict,
414
+ selected_trigger: str | None = None,
415
+ selected_action: str | None = None,
416
+ ) -> dict:
295
417
  """The preflight body the host sends. The same shape for every automation,
296
418
  so no entry declares it."""
297
419
  return {
298
420
  "automationId": entry["id"],
299
- "endpoint": _create_path(entry),
300
- "draft": _render_payload(entry, form_values, PREFLIGHT_TARBALL_PATH),
421
+ "endpoint": _create_path(entry, selected_action),
422
+ "draft": _render_payload(
423
+ entry, form_values, PREFLIGHT_TARBALL_PATH, selected_trigger, selected_action
424
+ ),
301
425
  }
302
426
 
303
427
 
304
- def _derive_error_map(entry: dict) -> dict[str, list[str]]:
428
+ def _derive_error_map(
429
+ entry: dict,
430
+ selected_trigger: str | None = None,
431
+ selected_action: str | None = None,
432
+ ) -> dict[str, list[str]]:
305
433
  """Which form fields built each payload path.
306
434
 
307
435
  Preflight and the create endpoint reject a draft by payload path, and the
@@ -310,11 +438,21 @@ def _derive_error_map(entry: dict) -> dict[str, list[str]]:
310
438
  an entry does not declare it.
311
439
  """
312
440
  mapping: dict[str, list[str]] = {}
313
- if "prompt" not in entry["setup"] and not _is_bundle(entry):
441
+ if "prompt" not in entry["setup"] and not _is_bundle(entry) and not _actions(entry):
314
442
  return mapping
315
443
 
316
- stand_ins = {name: f"{{{{form.{name}}}}}" for name in _field_names(entry["setup"])}
317
- template = _render_payload(entry, stand_ins)
444
+ stand_ins = {
445
+ name: f"{{{{form.{name}}}}}" for name in _field_names(entry["setup"])
446
+ }
447
+ trigger_groups = entry["setup"]["form"].get("triggers", {})
448
+ trigger_variants = [selected_trigger if selected_trigger else None]
449
+ if not selected_trigger and len(trigger_groups) > 1:
450
+ trigger_variants = list(trigger_groups)
451
+
452
+ action_groups = _actions(entry)
453
+ action_variants = [selected_action if selected_action else None]
454
+ if action_groups and selected_action is None:
455
+ action_variants = list(action_groups)
318
456
 
319
457
  def walk(node, path: str) -> None:
320
458
  if isinstance(node, dict):
@@ -330,9 +468,20 @@ def _derive_error_map(entry: dict) -> dict[str, list[str]]:
330
468
  if namespace == "form"
331
469
  ]
332
470
  if names:
333
- mapping[path] = list(dict.fromkeys(names))
334
-
335
- walk(template, "")
471
+ mapping[path] = list(
472
+ dict.fromkeys([*mapping.get(path, []), *names])
473
+ )
474
+
475
+ for action_variant in action_variants:
476
+ for trigger_variant in trigger_variants:
477
+ template = _render_payload(
478
+ entry,
479
+ stand_ins,
480
+ "" if action_variant == "upload" else PREFLIGHT_TARBALL_PATH,
481
+ selected_trigger=trigger_variant,
482
+ selected_action=action_variant,
483
+ )
484
+ walk(template, "")
336
485
  return mapping
337
486
 
338
487
 
@@ -351,12 +500,22 @@ def _payload_path_exists(payload, path: str) -> bool:
351
500
  return True
352
501
 
353
502
 
354
- def _fields(setup: dict) -> dict[str, dict]:
355
- """Every input the form declares, keyed by name, whichever half it is in."""
503
+ def _fields(setup: dict, selected_action: str | None = None) -> dict[str, dict]:
504
+ """Every input the form declares, keyed by name.
505
+
506
+ Common args are always present. Action args are conditional in the UI; when
507
+ no action is selected, include all of them for placeholder and safety tests.
508
+ """
356
509
  fields = {}
357
510
  for group in setup["form"].get("triggers", {}).values():
358
511
  fields.update(group)
359
512
  fields.update(setup["form"]["args"])
513
+ actions = setup.get("actions", {})
514
+ if selected_action:
515
+ fields.update(actions[selected_action].get("args", {}))
516
+ else:
517
+ for action in actions.values():
518
+ fields.update(action.get("args", {}))
360
519
  return fields
361
520
 
362
521
 
@@ -397,10 +556,19 @@ def _loc_to_payload_path(loc: list, payload) -> str:
397
556
  def _reported_fields(entry: dict, scenario: dict) -> dict[str, str]:
398
557
  """Apply the derived error map to whatever rejected this scenario."""
399
558
  setup = entry["setup"]
400
- error_map = _derive_error_map(entry)
559
+ selected_trigger = scenario.get("selectedTrigger")
560
+ selected_action = scenario.get("selectedAction")
561
+ error_map = _derive_error_map(entry, selected_trigger, selected_action)
401
562
  payload = (
402
- _render_payload(entry, scenario["formValues"], _uploaded_path(scenario))
403
- if ("prompt" in setup or _is_bundle(entry)) and "formValues" in scenario
563
+ _render_payload(
564
+ entry,
565
+ scenario["formValues"],
566
+ _uploaded_path(scenario),
567
+ selected_trigger,
568
+ selected_action,
569
+ )
570
+ if ("prompt" in setup or _is_bundle(entry) or _actions(entry))
571
+ and "formValues" in scenario
404
572
  else {}
405
573
  )
406
574
 
@@ -426,13 +594,23 @@ def _reported_fields(entry: dict, scenario: dict) -> dict[str, str]:
426
594
 
427
595
 
428
596
  def _capabilities_satisfied(entry: dict, deployment: dict) -> bool:
429
- """A deployment can run this automation when it offers every feature the
430
- entry requires and every trigger kind the form configures."""
597
+ """Whether at least one trigger and action variant can run here."""
598
+ deployment_features = set(deployment.get("features", []))
431
599
  needed_features = set(entry["requires"].get("features", []))
432
- if not needed_features.issubset(set(deployment.get("features", []))):
600
+ if not needed_features.issubset(deployment_features):
433
601
  return False
602
+
434
603
  needed_kinds = set(entry.get("setup", {}).get("form", {}).get("triggers", {}))
435
- return needed_kinds.issubset(set(deployment.get("triggerKinds", [])))
604
+ if needed_kinds and needed_kinds.isdisjoint(set(deployment.get("triggerKinds", []))):
605
+ return False
606
+
607
+ actions = _actions(entry)
608
+ if not actions:
609
+ return True
610
+ return any(
611
+ set(action.get("features", [])).issubset(deployment_features)
612
+ for action in actions.values()
613
+ )
436
614
 
437
615
 
438
616
  def _iter_strings(node):
@@ -556,6 +734,44 @@ def test_form_placeholders_reference_declared_fields(entry_path: Path) -> None:
556
734
  assert referenced - fields == set()
557
735
 
558
736
 
737
+ @pytest.mark.parametrize("fixture_path", list(_fixture_bundles()))
738
+ def test_multi_trigger_fixture_scenarios_name_the_selected_variant(
739
+ fixture_path: Path,
740
+ ) -> None:
741
+ """The active trigger is UI state, not another form field.
742
+
743
+ Fixtures for entries that declare multiple trigger variants record that state
744
+ explicitly so the expected create and preflight payloads are unambiguous.
745
+ """
746
+ bundle = _load(fixture_path)
747
+ entry = _entry_for(bundle)
748
+ trigger_groups = entry.get("setup", {}).get("form", {}).get("triggers", {})
749
+ if len(trigger_groups) <= 1:
750
+ pytest.skip("entry has only one trigger variant")
751
+
752
+ for scenario in bundle["scenarios"]:
753
+ if not ({"preflight", "create"} & set(scenario)):
754
+ continue
755
+ assert scenario.get("selectedTrigger") in trigger_groups, scenario["id"]
756
+
757
+
758
+ @pytest.mark.parametrize("fixture_path", list(_fixture_bundles()))
759
+ def test_multi_action_fixture_scenarios_name_the_selected_variant(
760
+ fixture_path: Path,
761
+ ) -> None:
762
+ """The active creation path is UI state, not another form field."""
763
+ bundle = _load(fixture_path)
764
+ entry = _entry_for(bundle)
765
+ actions = _actions(entry)
766
+ if len(actions) <= 1:
767
+ pytest.skip("entry has one or no action variants")
768
+
769
+ for scenario in bundle["scenarios"]:
770
+ if not ({"preflight", "create"} & set(scenario)):
771
+ continue
772
+ assert scenario.get("selectedAction") in actions, scenario["id"]
773
+
774
+
559
775
  @pytest.mark.parametrize("entry_path", list(_setup_paths()))
560
776
  def test_the_declared_features_match_the_archetype(entry_path: Path) -> None:
561
777
  """A bundle needs a deployment that runs a client-supplied tarball; a prompt
@@ -565,6 +781,20 @@ def test_the_declared_features_match_the_archetype(entry_path: Path) -> None:
565
781
  entry = _load(entry_path)
566
782
  features = set(entry["requires"].get("features", []))
567
783
 
784
+ actions = _actions(entry)
785
+ if actions:
786
+ assert not ({"presetPrompt", "presetPlugin", "customTarball"} & features), (
787
+ f"{entry['id']}: creation-path features belong on action variants"
788
+ )
789
+ expected = {
790
+ "prompt": "presetPrompt",
791
+ "plugin": "presetPlugin",
792
+ "upload": "customTarball",
793
+ }
794
+ for action_name, action in actions.items():
795
+ assert expected[action_name] in set(action.get("features", []))
796
+ return
797
+
568
798
  if _is_bundle(entry):
569
799
  assert "customTarball" in features, f"{entry['id']}: a bundle must declare customTarball"
570
800
  assert "presetPrompt" not in features, (
@@ -601,10 +831,18 @@ def test_derived_body_reproduces_the_create_request(
601
831
  where that reconstruction is pinned to a body the service accepts."""
602
832
  entry = _entry_for(bundle)
603
833
 
604
- derived = _render_payload(entry, scenario["formValues"], _uploaded_path(scenario))
834
+ derived = _render_payload(
835
+ entry,
836
+ scenario["formValues"],
837
+ _uploaded_path(scenario),
838
+ scenario.get("selectedTrigger"),
839
+ scenario.get("selectedAction"),
840
+ )
605
841
 
606
842
  assert derived == scenario["create"]["request"]["body"]
607
- assert scenario["create"]["request"]["path"] == _create_path(entry)
843
+ assert scenario["create"]["request"]["path"] == _create_path(
844
+ entry, scenario.get("selectedAction")
845
+ )
608
846
 
609
847
 
610
848
  @pytest.mark.parametrize(("bundle", "scenario"), list(_scenarios("preflight")))
@@ -615,7 +853,12 @@ def test_derived_preflight_body_reproduces_the_preflight_request(
615
853
  entry id and the payload, and this is where that is pinned."""
616
854
  entry = _entry_for(bundle)
617
855
 
618
- derived = _derive_preflight_body(entry, scenario["formValues"])
856
+ derived = _derive_preflight_body(
857
+ entry,
858
+ scenario["formValues"],
859
+ scenario.get("selectedTrigger"),
860
+ scenario.get("selectedAction"),
861
+ )
619
862
 
620
863
  assert derived == scenario["preflight"]["request"]["body"]
621
864
  assert scenario["preflight"]["request"]["path"] == PREFLIGHT_PATH
@@ -738,7 +981,7 @@ def test_schema_refuses_an_unsafe_bundle(case: str, override: dict) -> None:
738
981
  assert list(VALIDATOR.iter_errors(entry)), f"schema admitted {case}"
739
982
 
740
983
 
741
- def test_a_direct_entry_declares_a_prompt_or_a_bundle_but_not_both() -> None:
984
+ def test_a_direct_entry_declares_exactly_one_creation_archetype() -> None:
742
985
  entry = deepcopy(_load(CATALOG_DIR / "github-pr-reviewer" / "manifest.json"))
743
986
  entry["setup"]["prompt"] = "Review pull requests."
744
987
 
@@ -749,6 +992,11 @@ def test_a_direct_entry_declares_a_prompt_or_a_bundle_but_not_both() -> None:
749
992
 
750
993
  assert list(VALIDATOR.iter_errors(entry))
751
994
 
995
+ entry = deepcopy(_load(CATALOG_DIR / "custom-automation" / "manifest.json"))
996
+ entry["setup"]["prompt"] = "Run this too."
997
+
998
+ assert list(VALIDATOR.iter_errors(entry))
999
+
752
1000
 
753
1001
  @pytest.mark.parametrize("entry_path", list(_setup_paths()))
754
1002
  def test_bundle_config_only_reads_declared_form_fields(entry_path: Path) -> None: