@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.
- package/.github/workflows/check-extensions.yml +4 -0
- package/.github/workflows/pr-artifacts.yml +294 -0
- package/.github/workflows/pr-description-check.yml +4 -2
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +28 -4
- package/README.md +5 -2
- package/automations/README.md +52 -36
- package/automations/bundle-index.js +13 -3
- package/automations/catalog/custom-automation/manifest.json +229 -0
- package/automations/catalog/github-agents-md-maintainer/manifest.json +2 -1
- package/automations/catalog/github-issue-to-pr/manifest.json +3 -2
- package/automations/catalog/github-issue-triage/manifest.json +72 -0
- package/automations/catalog/github-pr-reviewer/manifest.json +3 -2
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +124 -0
- package/automations/catalog/qa-changes/manifest.json +1 -1
- package/automations/catalog-index.js +23 -17
- package/automations/catalog.schema.json +81 -7
- package/automations/index.d.ts +52 -2
- package/automations/interface.json +2 -1
- package/integrations/catalog/gitlab.json +27 -5
- package/integrations/catalog/reportportal.json +76 -0
- package/integrations/catalog-index.js +6 -4
- package/marketplaces/openhands-extensions.json +36 -0
- package/package.json +1 -1
- package/pyproject.toml +2 -2
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/github/scripts/github_client.py +204 -0
- package/skills/github-agents-md-maintainer/SKILL.md +7 -1
- package/skills/github-agents-md-maintainer/scripts/main.py +4 -40
- package/skills/github-issue-to-pr/SKILL.md +8 -1
- package/skills/github-issue-to-pr/scripts/main.py +3 -40
- package/skills/github-issue-triage/.plugin/plugin.json +9 -0
- package/skills/github-issue-triage/README.md +9 -0
- package/skills/github-issue-triage/SKILL.md +37 -0
- package/skills/github-issue-triage/commands/github-issue-triage.md +8 -0
- package/skills/github-issue-triage/scripts/worker.py +123 -0
- package/skills/github-pr-reviewer/SKILL.md +8 -1
- package/skills/github-pr-reviewer/scripts/main.py +3 -41
- package/skills/github-repo-monitor/SKILL.md +7 -1
- package/skills/github-repo-monitor/scripts/main.py +4 -46
- package/skills/gitlab-issue-to-mr/.plugin/plugin.json +19 -0
- package/skills/gitlab-issue-to-mr/README.md +92 -0
- package/skills/gitlab-issue-to-mr/SKILL.md +408 -0
- package/skills/gitlab-issue-to-mr/commands/issue-to-mr-setup.md +8 -0
- package/skills/gitlab-issue-to-mr/references/state-schema.md +140 -0
- package/skills/gitlab-issue-to-mr/scripts/main.py +1448 -0
- package/skills/index.js +43 -6
- package/skills/openhands-automation/README.md +1 -1
- package/skills/openhands-automation/SKILL.md +18 -8
- package/skills/openhands-enterprise-troubleshooting/.plugin/plugin.json +20 -0
- package/skills/openhands-enterprise-troubleshooting/README.md +79 -0
- package/skills/openhands-enterprise-troubleshooting/SKILL.md +339 -0
- package/skills/openhands-enterprise-troubleshooting/references/diagnostics.md +698 -0
- package/skills/openhands-enterprise-troubleshooting/references/support-bundle-analysis.md +570 -0
- package/skills/openhands-enterprise-troubleshooting/scripts/bundle_triage.py +532 -0
- package/skills/openhands-sdk/SKILL.md +2 -0
- package/tests/fixtures/automations/custom-automation.json +398 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/github_automation_helpers.py +25 -0
- package/tests/test_automation_setup.py +340 -92
- package/tests/test_github_automation_foundation.py +73 -0
- package/tests/test_github_skill_installation.py +63 -0
- package/tests/test_github_triage_delivery.py +53 -0
- 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
|
|
43
|
-
#
|
|
44
|
-
|
|
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
|
|
51
|
-
# checks that field's scheme at preflight and its ownership only at
|
|
52
|
-
# so a well-formed stand-in validates what preflight is for
|
|
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(
|
|
161
|
-
|
|
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
|
-
|
|
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(
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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(
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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(
|
|
205
|
-
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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(
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
249
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 = {
|
|
317
|
-
|
|
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(
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
403
|
-
|
|
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
|
-
"""
|
|
430
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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:
|