@openhands/extensions 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/custom-codereview-guide.md +7 -0
- package/.github/dependabot.yml +24 -0
- package/.github/scripts/check_issue_readiness.py +272 -0
- package/.github/scripts/check_pr_description.py +226 -0
- package/.github/scripts/post-readiness-comment.mjs +184 -0
- package/.github/scripts/refresh_linked_pr_checks.py +171 -0
- package/.github/workflows/issue-readiness-check.yml +142 -0
- package/.github/workflows/pr-description-check.yml +37 -0
- package/.pr/custom-automation-event-trigger.png +0 -0
- package/.pr/custom-automation-plugin-fields.png +0 -0
- package/.pr/custom-automation-prompt-review.png +0 -0
- package/.pr/custom-automation-prompt-scheduled.png +0 -0
- package/.pr/custom-automation-upload-fields.png +0 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +17 -1
- package/README.md +10 -2
- package/automations/README.md +52 -36
- package/automations/bundle-index.js +1 -1
- package/automations/catalog/custom-automation/manifest.json +229 -0
- package/automations/catalog/github-agents-md-maintainer/manifest.json +5 -0
- package/automations/catalog/github-issue-to-pr/manifest.json +6 -1
- package/automations/catalog/github-pr-reviewer/manifest.json +6 -1
- package/automations/catalog/github-repo-monitor/manifest.json +5 -0
- package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +23 -0
- package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +23 -0
- package/automations/catalog/linear-issue-to-bitbucket-pr/manifest.json +23 -0
- package/automations/catalog/linear-issue-to-github-pr/manifest.json +23 -0
- package/automations/catalog/linear-issue-to-gitlab-mr/manifest.json +23 -0
- package/automations/catalog/news-digest/manifest.json +5 -0
- package/automations/catalog/qa-changes/manifest.json +72 -0
- package/automations/catalog/slack-github-linear-daily-organization.json +27 -0
- package/automations/catalog-index.js +25 -11
- package/automations/catalog.schema.json +106 -7
- package/automations/index.d.ts +68 -2
- package/automations/interface.json +2 -1
- package/integrations/catalog/datadog.json +2 -2
- package/integrations/catalog/gitlab.json +63 -0
- package/integrations/catalog/notion.json +1 -1
- package/integrations/catalog-index.js +71 -69
- package/integrations/index.d.ts +1 -1
- package/marketplaces/openhands-extensions.json +21 -6
- package/package.json +1 -1
- package/plugins/pr-review/scripts/prompt.py +2 -0
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/github-pr-reviewer/SKILL.md +2 -1
- package/skills/github-pr-reviewer/scripts/main.py +45 -4
- package/skills/github-pr-reviewer/tests/test_main.py +74 -0
- package/skills/index.js +11 -2
- package/skills/openhands-sdk/SKILL.md +1 -0
- package/skills/ticket-to-code-change/.plugin/plugin.json +21 -0
- package/skills/ticket-to-code-change/README.md +5 -0
- package/skills/ticket-to-code-change/SKILL.md +50 -0
- package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +8 -0
- package/tests/fixtures/automations/custom-automation.json +398 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/test_automation_setup.py +362 -92
- package/tests/test_catalogs.py +2 -2
- package/tests/test_check_issue_readiness.py +135 -0
- package/tests/test_check_pr_description.py +228 -0
- package/tests/test_pr_review_prompt.py +10 -0
- package/tests/test_refresh_linked_pr_checks.py +130 -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
|
-
|
|
174
|
-
|
|
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
|
|
175
231
|
|
|
176
|
-
def _repo_values(setup: dict, form_values: dict) -> list[str]:
|
|
177
|
-
"""Every repository the form collected, whether it collects one or many.
|
|
178
232
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
+
|
|
263
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
|
+
)
|
|
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):
|
|
@@ -518,6 +696,28 @@ def test_schema_rejects_content_a_setup_block_must_never_carry() -> None:
|
|
|
518
696
|
)
|
|
519
697
|
|
|
520
698
|
|
|
699
|
+
IMPACT_REJECTIONS: list[tuple[str, dict]] = [
|
|
700
|
+
("a basis the host does not know how to compute", {"basis": "run-counter"}),
|
|
701
|
+
("markup in a phrase", {"one": "<b>1 sweep</b>"}),
|
|
702
|
+
("a placeholder from another namespace", {"other": "{{count}} sweeps for {{form.repo}}"}),
|
|
703
|
+
("a plural phrase that hides the count", {"other": "many sweeps completed"}),
|
|
704
|
+
("an extra key beside the declared three", {"detail": "and saved hours"}),
|
|
705
|
+
]
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@pytest.mark.parametrize(
|
|
709
|
+
("case", "override"),
|
|
710
|
+
[pytest.param(case, override, id=case) for case, override in IMPACT_REJECTIONS],
|
|
711
|
+
)
|
|
712
|
+
def test_schema_refuses_an_impact_statement_the_host_must_never_render(
|
|
713
|
+
case: str, override: dict
|
|
714
|
+
) -> None:
|
|
715
|
+
entry = deepcopy(_load(CATALOG_DIR / "github-pr-reviewer" / "manifest.json"))
|
|
716
|
+
entry["impact"].update(override)
|
|
717
|
+
|
|
718
|
+
assert list(VALIDATOR.iter_errors(entry)), f"schema admitted {case}"
|
|
719
|
+
|
|
720
|
+
|
|
521
721
|
@pytest.mark.parametrize("entry_path", list(_setup_paths()))
|
|
522
722
|
def test_form_placeholders_reference_declared_fields(entry_path: Path) -> None:
|
|
523
723
|
"""A {{form.x}} that names no field renders as an empty value at runtime."""
|
|
@@ -534,6 +734,44 @@ def test_form_placeholders_reference_declared_fields(entry_path: Path) -> None:
|
|
|
534
734
|
assert referenced - fields == set()
|
|
535
735
|
|
|
536
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
|
+
|
|
537
775
|
@pytest.mark.parametrize("entry_path", list(_setup_paths()))
|
|
538
776
|
def test_the_declared_features_match_the_archetype(entry_path: Path) -> None:
|
|
539
777
|
"""A bundle needs a deployment that runs a client-supplied tarball; a prompt
|
|
@@ -543,6 +781,20 @@ def test_the_declared_features_match_the_archetype(entry_path: Path) -> None:
|
|
|
543
781
|
entry = _load(entry_path)
|
|
544
782
|
features = set(entry["requires"].get("features", []))
|
|
545
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
|
+
|
|
546
798
|
if _is_bundle(entry):
|
|
547
799
|
assert "customTarball" in features, f"{entry['id']}: a bundle must declare customTarball"
|
|
548
800
|
assert "presetPrompt" not in features, (
|
|
@@ -579,10 +831,18 @@ def test_derived_body_reproduces_the_create_request(
|
|
|
579
831
|
where that reconstruction is pinned to a body the service accepts."""
|
|
580
832
|
entry = _entry_for(bundle)
|
|
581
833
|
|
|
582
|
-
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
|
+
)
|
|
583
841
|
|
|
584
842
|
assert derived == scenario["create"]["request"]["body"]
|
|
585
|
-
assert scenario["create"]["request"]["path"] == _create_path(
|
|
843
|
+
assert scenario["create"]["request"]["path"] == _create_path(
|
|
844
|
+
entry, scenario.get("selectedAction")
|
|
845
|
+
)
|
|
586
846
|
|
|
587
847
|
|
|
588
848
|
@pytest.mark.parametrize(("bundle", "scenario"), list(_scenarios("preflight")))
|
|
@@ -593,7 +853,12 @@ def test_derived_preflight_body_reproduces_the_preflight_request(
|
|
|
593
853
|
entry id and the payload, and this is where that is pinned."""
|
|
594
854
|
entry = _entry_for(bundle)
|
|
595
855
|
|
|
596
|
-
derived = _derive_preflight_body(
|
|
856
|
+
derived = _derive_preflight_body(
|
|
857
|
+
entry,
|
|
858
|
+
scenario["formValues"],
|
|
859
|
+
scenario.get("selectedTrigger"),
|
|
860
|
+
scenario.get("selectedAction"),
|
|
861
|
+
)
|
|
597
862
|
|
|
598
863
|
assert derived == scenario["preflight"]["request"]["body"]
|
|
599
864
|
assert scenario["preflight"]["request"]["path"] == PREFLIGHT_PATH
|
|
@@ -716,7 +981,7 @@ def test_schema_refuses_an_unsafe_bundle(case: str, override: dict) -> None:
|
|
|
716
981
|
assert list(VALIDATOR.iter_errors(entry)), f"schema admitted {case}"
|
|
717
982
|
|
|
718
983
|
|
|
719
|
-
def
|
|
984
|
+
def test_a_direct_entry_declares_exactly_one_creation_archetype() -> None:
|
|
720
985
|
entry = deepcopy(_load(CATALOG_DIR / "github-pr-reviewer" / "manifest.json"))
|
|
721
986
|
entry["setup"]["prompt"] = "Review pull requests."
|
|
722
987
|
|
|
@@ -727,6 +992,11 @@ def test_a_direct_entry_declares_a_prompt_or_a_bundle_but_not_both() -> None:
|
|
|
727
992
|
|
|
728
993
|
assert list(VALIDATOR.iter_errors(entry))
|
|
729
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
|
+
|
|
730
1000
|
|
|
731
1001
|
@pytest.mark.parametrize("entry_path", list(_setup_paths()))
|
|
732
1002
|
def test_bundle_config_only_reads_declared_form_fields(entry_path: Path) -> None:
|
package/tests/test_catalogs.py
CHANGED
|
@@ -93,7 +93,7 @@ def test_remote_no_auth_mcp_entries_are_intentionally_public():
|
|
|
93
93
|
transport = option.get("transport", {})
|
|
94
94
|
# An option with `headerFields` still requires the user to supply
|
|
95
95
|
# credentials (just via named headers, e.g. Datadog's
|
|
96
|
-
#
|
|
96
|
+
# DD-API-KEY/DD-APPLICATION-KEY), so it is NOT "public/no-auth".
|
|
97
97
|
has_header_credentials = bool(transport.get("headerFields"))
|
|
98
98
|
if (
|
|
99
99
|
option["provider"] == "mcp"
|
|
@@ -119,7 +119,7 @@ def test_datadog_mcp_uses_documented_api_key_headers():
|
|
|
119
119
|
|
|
120
120
|
assert [
|
|
121
121
|
field["key"] for field in api_option["transport"]["headerFields"]
|
|
122
|
-
] == ["
|
|
122
|
+
] == ["DD-API-KEY", "DD-APPLICATION-KEY"]
|
|
123
123
|
|
|
124
124
|
|
|
125
125
|
def test_credential_fields_have_helper_text_and_link():
|