@openhands/extensions 0.11.0 → 0.12.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/.release-please-manifest.json +1 -1
- package/AGENTS.md +2 -1
- package/MIGRATION.md +31 -0
- package/README.md +9 -7
- package/automations/README.md +251 -8
- package/automations/catalog/github-pr-reviewer/manifest.json +82 -0
- package/automations/catalog/github-repo-monitor/manifest.json +82 -0
- package/automations/catalog/incident-retrospective-drafter/manifest.json +100 -0
- package/automations/catalog/{jira-issue-to-pr.json → jira-issue-to-pr/manifest.json} +10 -5
- package/automations/catalog/{linear-triage-assistant.json → linear-triage-assistant/manifest.json} +8 -4
- package/automations/catalog/{research-brief-writer.json → research-brief-writer/manifest.json} +11 -5
- package/automations/catalog/{slack-channel-monitor.json → slack-channel-monitor/manifest.json} +7 -2
- package/automations/catalog/{slack-standup-digest.json → slack-standup-digest/manifest.json} +8 -5
- package/automations/catalog-index.js +23 -0
- package/automations/catalog.schema.json +273 -0
- package/automations/index.d.ts +111 -3
- package/automations/index.js +21 -18
- package/index.d.ts +16 -1
- package/integrations/catalog/datadog.json +3 -3
- package/integrations/index.d.ts +1 -1
- package/package.json +6 -3
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/__init__.py +0 -4
- package/python/openhands_extensions/_version.py +3 -2
- package/python/openhands_extensions/integrations.py +8 -30
- package/scripts/build-automation-catalog.mjs +51 -0
- package/skills/bitbucket/SKILL.md +0 -1
- package/skills/github/SKILL.md +0 -1
- package/skills/gitlab/SKILL.md +0 -1
- package/skills/index.js +4 -7
- package/skills/openhands-sdk/SKILL.md +2 -0
- package/tests/fixtures/automations/capabilities.json +92 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +251 -0
- package/tests/fixtures/automations/github-repo-monitor.json +180 -0
- package/tests/fixtures/automations/incident-retrospective-drafter.json +91 -0
- package/tests/test_automation_setup.py +526 -0
- package/tests/test_catalogs.py +37 -12
- package/tests/test_deprecations.py +39 -4
- package/tests/test_integration_catalog_in_sync.py +50 -60
- package/automations/catalog/github-pr-reviewer.json +0 -13
- package/automations/catalog/github-repo-monitor.json +0 -11
- package/automations/catalog/incident-retrospective-drafter.json +0 -15
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""Contract tests for the `setup` block in automations/catalog/*/manifest.json.
|
|
2
|
+
|
|
3
|
+
Three things are checked here that nothing else can catch:
|
|
4
|
+
|
|
5
|
+
1. Every catalog entry validates against automations/catalog.schema.json, the way
|
|
6
|
+
integration catalog entries validate against integrations/catalog.schema.json.
|
|
7
|
+
2. Running a fixture's form values through an entry's declared mapping reproduces
|
|
8
|
+
the fixture's request body, byte for byte.
|
|
9
|
+
3. The parts of that request an entry no longer declares - the preflight body and
|
|
10
|
+
the payload-path-to-field mapping - still come out right when derived. An entry
|
|
11
|
+
states only what varies between automations; everything else is the same code
|
|
12
|
+
for every automation, and these tests are where that code is pinned.
|
|
13
|
+
|
|
14
|
+
(2) is the point of the fixtures. Form shape and API shape genuinely differ, the
|
|
15
|
+
create endpoint is declared extra="forbid", and a mapping mistake is a 422 that
|
|
16
|
+
only shows up at creation time. Pinning the mapping to a worked example is what
|
|
17
|
+
keeps OpenHands/agent-canvas and OpenHands/automation building against the same
|
|
18
|
+
contract.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
from copy import deepcopy
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import pytest
|
|
28
|
+
from jsonschema import Draft202012Validator
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
32
|
+
SCHEMA_PATH = ROOT / "automations" / "catalog.schema.json"
|
|
33
|
+
CATALOG_DIR = ROOT / "automations" / "catalog"
|
|
34
|
+
CATALOG_INDEX = ROOT / "automations" / "catalog-index.js"
|
|
35
|
+
BUILD_SCRIPT = ROOT / "scripts" / "build-automation-catalog.mjs"
|
|
36
|
+
FIXTURE_DIR = ROOT / "tests" / "fixtures" / "automations"
|
|
37
|
+
CAPABILITIES_PATH = FIXTURE_DIR / "capabilities.json"
|
|
38
|
+
|
|
39
|
+
# The standardized parts of a direct setup, identical for every automation and
|
|
40
|
+
# therefore not declared in any entry.
|
|
41
|
+
CREATE_PATH = "/v1/preset/prompt"
|
|
42
|
+
PREFLIGHT_PATH = "/v1/validate"
|
|
43
|
+
|
|
44
|
+
# The trigger properties the service accepts, per kind. A form field named
|
|
45
|
+
# after one of them fills it; the rest are inputs to the declared filter.
|
|
46
|
+
TRIGGER_PROPERTIES = {"cron": ("schedule", "timezone"), "event": ("on",)}
|
|
47
|
+
|
|
48
|
+
_SCHEMA = json.loads(SCHEMA_PATH.read_text())
|
|
49
|
+
VALIDATOR = Draft202012Validator(_SCHEMA)
|
|
50
|
+
|
|
51
|
+
PLACEHOLDER_RE = re.compile(r"\{\{([a-z]+)\.([A-Za-z0-9_.]+)\}\}")
|
|
52
|
+
|
|
53
|
+
# Anything that looks like a real credential rather than a credential's name.
|
|
54
|
+
CREDENTIAL_VALUE_RE = re.compile(
|
|
55
|
+
r"(gh[pousr]_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|sk-[A-Za-z0-9]{20,})"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load(path: Path) -> dict:
|
|
60
|
+
return json.loads(path.read_text())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _manifests():
|
|
64
|
+
return sorted(CATALOG_DIR.glob("*/manifest.json"))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _catalog_paths():
|
|
68
|
+
for path in _manifests():
|
|
69
|
+
yield pytest.param(path, id=path.parent.name)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _setup_paths():
|
|
73
|
+
"""Only the entries that ship a setup block. It is optional by design."""
|
|
74
|
+
for path in _manifests():
|
|
75
|
+
if "setup" in _load(path):
|
|
76
|
+
yield pytest.param(path, id=path.parent.name)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _fixture_bundles():
|
|
80
|
+
for path in sorted(FIXTURE_DIR.glob("*.json")):
|
|
81
|
+
if path == CAPABILITIES_PATH:
|
|
82
|
+
continue
|
|
83
|
+
yield pytest.param(path, id=path.stem)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _entry_for(bundle: dict) -> dict:
|
|
87
|
+
return _load(CATALOG_DIR / bundle["automationId"] / "manifest.json")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _integration_catalog_ids() -> set[str]:
|
|
91
|
+
return {path.stem for path in (ROOT / "integrations" / "catalog").glob("*.json")}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resolve(namespace: str, key: str, context: dict):
|
|
95
|
+
"""Resolve one {{namespace.key}} placeholder against the render context."""
|
|
96
|
+
if namespace not in context:
|
|
97
|
+
raise KeyError(f"unknown placeholder namespace: {namespace}")
|
|
98
|
+
value = context[namespace]
|
|
99
|
+
for part in key.split("."):
|
|
100
|
+
if not isinstance(value, dict) or part not in value:
|
|
101
|
+
raise KeyError(f"unresolved placeholder: {{{{{namespace}.{key}}}}}")
|
|
102
|
+
value = value[part]
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _interpolate(node, context: dict):
|
|
107
|
+
"""Apply placeholder substitution to a setup fragment."""
|
|
108
|
+
if isinstance(node, dict):
|
|
109
|
+
return {key: _interpolate(value, context) for key, value in node.items()}
|
|
110
|
+
if isinstance(node, list):
|
|
111
|
+
return [_interpolate(item, context) for item in node]
|
|
112
|
+
if not isinstance(node, str):
|
|
113
|
+
return node
|
|
114
|
+
|
|
115
|
+
whole = PLACEHOLDER_RE.fullmatch(node)
|
|
116
|
+
if whole:
|
|
117
|
+
return _resolve(whole.group(1), whole.group(2), context)
|
|
118
|
+
return PLACEHOLDER_RE.sub(
|
|
119
|
+
lambda match: str(_resolve(match.group(1), match.group(2), context)), node
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _context(entry: dict, form_values: dict) -> dict:
|
|
124
|
+
"""`automation` resolves against the catalog entry the setup block sits in."""
|
|
125
|
+
return {"form": form_values, "automation": entry}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _repo_picker(setup: dict) -> tuple[str | None, dict | None]:
|
|
129
|
+
for name, field in _fields(setup).items():
|
|
130
|
+
if field["type"] == "repo-picker":
|
|
131
|
+
return name, field
|
|
132
|
+
return None, None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _render_payload(entry: dict, form_values: dict) -> dict:
|
|
136
|
+
"""The create request body these form values produce.
|
|
137
|
+
|
|
138
|
+
No entry declares this. `name` comes from the entry, `repos` from the
|
|
139
|
+
repo-picker field and its provider, and `trigger` from the key and fields
|
|
140
|
+
under `form.triggers`. Only `prompt` and an event `filter` are declared,
|
|
141
|
+
because only they cannot be read off the form.
|
|
142
|
+
"""
|
|
143
|
+
setup = entry["setup"]
|
|
144
|
+
context = _context(entry, form_values)
|
|
145
|
+
repo_name, repo_field = _repo_picker(setup)
|
|
146
|
+
repo = form_values.get(repo_name) if repo_name else None
|
|
147
|
+
|
|
148
|
+
body: dict = {
|
|
149
|
+
"name": f"{entry['name']} - {repo}" if repo else entry["name"],
|
|
150
|
+
"prompt": _interpolate(setup["prompt"], context),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if repo:
|
|
154
|
+
source = {"url": repo, "provider": repo_field["provider"]}
|
|
155
|
+
if "ref" in form_values:
|
|
156
|
+
source["ref"] = form_values["ref"]
|
|
157
|
+
body["repos"] = [source]
|
|
158
|
+
|
|
159
|
+
kind, trigger_fields = next(iter(setup["form"]["triggers"].items()))
|
|
160
|
+
trigger = {"type": kind}
|
|
161
|
+
# A field under a trigger kind fills the trigger property of the same name.
|
|
162
|
+
# Anything else there, such as a phrase to match, is an input to `filter`.
|
|
163
|
+
for name in trigger_fields:
|
|
164
|
+
if name in TRIGGER_PROPERTIES[kind]:
|
|
165
|
+
trigger[name] = form_values[name]
|
|
166
|
+
if kind == "event":
|
|
167
|
+
trigger["source"] = repo_field["provider"]
|
|
168
|
+
trigger["filter"] = _interpolate(setup["filter"], context)
|
|
169
|
+
body["trigger"] = trigger
|
|
170
|
+
|
|
171
|
+
return body
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _derive_preflight_body(entry: dict, form_values: dict) -> dict:
|
|
175
|
+
"""The preflight body the host sends. The same shape for every automation,
|
|
176
|
+
so no entry declares it."""
|
|
177
|
+
return {
|
|
178
|
+
"automationId": entry["id"],
|
|
179
|
+
"endpoint": CREATE_PATH,
|
|
180
|
+
"draft": _render_payload(entry, form_values),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _derive_error_map(entry: dict) -> dict[str, list[str]]:
|
|
185
|
+
"""Which form fields built each payload path.
|
|
186
|
+
|
|
187
|
+
Preflight and the create endpoint reject a draft by payload path, and the
|
|
188
|
+
host has to turn that back into a highlighted input. Building the body with
|
|
189
|
+
each field standing in for its own value recovers the mapping exactly, so
|
|
190
|
+
an entry does not declare it.
|
|
191
|
+
"""
|
|
192
|
+
mapping: dict[str, list[str]] = {}
|
|
193
|
+
if "prompt" not in entry["setup"]:
|
|
194
|
+
return mapping
|
|
195
|
+
|
|
196
|
+
stand_ins = {name: f"{{{{form.{name}}}}}" for name in _field_names(entry["setup"])}
|
|
197
|
+
template = _render_payload(entry, stand_ins)
|
|
198
|
+
|
|
199
|
+
def walk(node, path: str) -> None:
|
|
200
|
+
if isinstance(node, dict):
|
|
201
|
+
for key, value in node.items():
|
|
202
|
+
walk(value, f"{path}.{key}" if path else key)
|
|
203
|
+
elif isinstance(node, list):
|
|
204
|
+
for index, item in enumerate(node):
|
|
205
|
+
walk(item, f"{path}[{index}]")
|
|
206
|
+
elif isinstance(node, str):
|
|
207
|
+
names = [
|
|
208
|
+
key
|
|
209
|
+
for namespace, key in PLACEHOLDER_RE.findall(node)
|
|
210
|
+
if namespace == "form"
|
|
211
|
+
]
|
|
212
|
+
if names:
|
|
213
|
+
mapping[path] = list(dict.fromkeys(names))
|
|
214
|
+
|
|
215
|
+
walk(template, "")
|
|
216
|
+
return mapping
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _payload_path_exists(payload, path: str) -> bool:
|
|
220
|
+
"""Whether an error path such as `repos[0].ref` addresses the payload."""
|
|
221
|
+
node = payload
|
|
222
|
+
for segment in path.split("."):
|
|
223
|
+
name, _, indexes = segment.partition("[")
|
|
224
|
+
if not isinstance(node, dict) or name not in node:
|
|
225
|
+
return False
|
|
226
|
+
node = node[name]
|
|
227
|
+
for index in re.findall(r"\d+", indexes):
|
|
228
|
+
if not isinstance(node, list) or int(index) >= len(node):
|
|
229
|
+
return False
|
|
230
|
+
node = node[int(index)]
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _fields(setup: dict) -> dict[str, dict]:
|
|
235
|
+
"""Every input the form declares, keyed by name, whichever half it is in."""
|
|
236
|
+
fields = {}
|
|
237
|
+
for group in setup["form"].get("triggers", {}).values():
|
|
238
|
+
fields.update(group)
|
|
239
|
+
fields.update(setup["form"]["args"])
|
|
240
|
+
return fields
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _field_names(setup: dict) -> set[str]:
|
|
244
|
+
return set(_fields(setup))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _join_loc(parts: list[str]) -> str:
|
|
248
|
+
path = ""
|
|
249
|
+
for part in parts:
|
|
250
|
+
if part.isdigit():
|
|
251
|
+
path += f"[{part}]"
|
|
252
|
+
else:
|
|
253
|
+
path += f".{part}" if path else part
|
|
254
|
+
return path
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _loc_to_payload_path(loc: list, payload) -> str:
|
|
258
|
+
"""Turn a 422 `loc` into the payload path an error is keyed by.
|
|
259
|
+
|
|
260
|
+
FastAPI prefixes `body`, and Pydantic inserts the discriminated-union tag,
|
|
261
|
+
so an invalid cron arrives as ["body", "trigger", "cron", "schedule"] while
|
|
262
|
+
the payload path is `trigger.schedule`. Dropping the segment that does not
|
|
263
|
+
address the payload is what makes the error field-addressable.
|
|
264
|
+
"""
|
|
265
|
+
parts = [str(part) for part in loc]
|
|
266
|
+
if parts and parts[0] == "body":
|
|
267
|
+
parts = parts[1:]
|
|
268
|
+
|
|
269
|
+
candidates = [parts] + [parts[:i] + parts[i + 1 :] for i in range(len(parts))]
|
|
270
|
+
for candidate in candidates:
|
|
271
|
+
path = _join_loc(candidate)
|
|
272
|
+
if _payload_path_exists(payload, path):
|
|
273
|
+
return path
|
|
274
|
+
return _join_loc(parts)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _reported_fields(entry: dict, scenario: dict) -> dict[str, str]:
|
|
278
|
+
"""Apply the derived error map to whatever rejected this scenario."""
|
|
279
|
+
setup = entry["setup"]
|
|
280
|
+
error_map = _derive_error_map(entry)
|
|
281
|
+
payload = (
|
|
282
|
+
_render_payload(entry, scenario["formValues"])
|
|
283
|
+
if "prompt" in setup and "formValues" in scenario
|
|
284
|
+
else {}
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
reported: dict[str, str] = {}
|
|
288
|
+
|
|
289
|
+
for error in scenario.get("localValidation", {}).get("errors", []):
|
|
290
|
+
reported[error["field"]] = error["message"]
|
|
291
|
+
|
|
292
|
+
for error in scenario.get("preflight", {}).get("response", {}).get("body", {}).get(
|
|
293
|
+
"errors", []
|
|
294
|
+
):
|
|
295
|
+
for name in error_map.get(error["field"], [error["field"]]):
|
|
296
|
+
reported[name] = error["message"]
|
|
297
|
+
|
|
298
|
+
response = scenario.get("create", {}).get("response", {})
|
|
299
|
+
if response.get("status") == 422:
|
|
300
|
+
for detail in response["body"]["detail"]:
|
|
301
|
+
path = _loc_to_payload_path(detail["loc"], payload)
|
|
302
|
+
for name in error_map.get(path, [path]):
|
|
303
|
+
reported[name] = detail["msg"]
|
|
304
|
+
|
|
305
|
+
return reported
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _capabilities_satisfied(entry: dict, deployment: dict) -> bool:
|
|
309
|
+
"""A deployment can run this automation when it offers every feature the
|
|
310
|
+
entry requires and every trigger kind the form configures."""
|
|
311
|
+
needed_features = set(entry["requires"].get("features", []))
|
|
312
|
+
if not needed_features.issubset(set(deployment.get("features", []))):
|
|
313
|
+
return False
|
|
314
|
+
needed_kinds = set(entry.get("setup", {}).get("form", {}).get("triggers", {}))
|
|
315
|
+
return needed_kinds.issubset(set(deployment.get("triggerKinds", [])))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _iter_strings(node):
|
|
319
|
+
if isinstance(node, dict):
|
|
320
|
+
for key, value in node.items():
|
|
321
|
+
yield key
|
|
322
|
+
yield from _iter_strings(value)
|
|
323
|
+
elif isinstance(node, list):
|
|
324
|
+
for item in node:
|
|
325
|
+
yield from _iter_strings(item)
|
|
326
|
+
elif isinstance(node, str):
|
|
327
|
+
yield node
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _scenarios(kind: str):
|
|
331
|
+
"""Every fixture scenario carrying the given block, as test params."""
|
|
332
|
+
for path in sorted(FIXTURE_DIR.glob("*.json")):
|
|
333
|
+
if path == CAPABILITIES_PATH:
|
|
334
|
+
continue
|
|
335
|
+
bundle = _load(path)
|
|
336
|
+
for scenario in bundle["scenarios"]:
|
|
337
|
+
if kind in scenario and scenario.get("matchesSetupPayload", True):
|
|
338
|
+
yield pytest.param(bundle, scenario, id=f"{path.stem}-{scenario['id']}")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@pytest.mark.parametrize("entry_path", list(_catalog_paths()))
|
|
342
|
+
def test_catalog_entry_validates_against_schema(entry_path: Path) -> None:
|
|
343
|
+
entry = _load(entry_path)
|
|
344
|
+
|
|
345
|
+
errors = sorted(VALIDATOR.iter_errors(entry), key=lambda e: list(e.path))
|
|
346
|
+
|
|
347
|
+
if errors:
|
|
348
|
+
rendered = "\n".join(
|
|
349
|
+
f" - at {'/'.join(map(str, error.path)) or '<root>'}: {error.message}"
|
|
350
|
+
for error in errors
|
|
351
|
+
)
|
|
352
|
+
pytest.fail(f"{entry_path.parent.name} failed schema validation:\n{rendered}")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_schema_file_is_valid_draft_2020_12() -> None:
|
|
356
|
+
Draft202012Validator.check_schema(_SCHEMA)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def test_schema_rejects_content_a_setup_block_must_never_carry() -> None:
|
|
360
|
+
"""The format constraints are the trust boundary, so they are asserted here.
|
|
361
|
+
|
|
362
|
+
A setup block is data that tells the host what to render and what request to
|
|
363
|
+
build. These are the mutations that would turn it into code, an arbitrary
|
|
364
|
+
request, or a credential leak.
|
|
365
|
+
"""
|
|
366
|
+
entry = _load(CATALOG_DIR / "github-pr-reviewer" / "manifest.json")
|
|
367
|
+
|
|
368
|
+
rejected: list[tuple[str, dict]] = []
|
|
369
|
+
|
|
370
|
+
with_markup = deepcopy(entry)
|
|
371
|
+
with_markup["setup"]["form"]["args"]["repository"]["label"] = (
|
|
372
|
+
"Repository <script>steal()</script>"
|
|
373
|
+
)
|
|
374
|
+
rejected.append(("<script>steal()</script>", with_markup))
|
|
375
|
+
|
|
376
|
+
with_unknown_placeholder = deepcopy(entry)
|
|
377
|
+
with_unknown_placeholder["setup"]["prompt"] = "Use {{env.GITHUB_TOKEN}}"
|
|
378
|
+
rejected.append(("{{env.GITHUB_TOKEN}}", with_unknown_placeholder))
|
|
379
|
+
|
|
380
|
+
with_secret_value = deepcopy(entry)
|
|
381
|
+
with_secret_value["requires"]["integrations"]["github"]["value"] = (
|
|
382
|
+
"ghp_notarealtokenvalue00"
|
|
383
|
+
)
|
|
384
|
+
rejected.append(("value", with_secret_value))
|
|
385
|
+
|
|
386
|
+
with_repeated_identity = deepcopy(entry)
|
|
387
|
+
with_repeated_identity["setup"]["description"] = "a second description"
|
|
388
|
+
rejected.append(("description", with_repeated_identity))
|
|
389
|
+
|
|
390
|
+
for expected_fragment, invalid in rejected:
|
|
391
|
+
errors = list(VALIDATOR.iter_errors(invalid))
|
|
392
|
+
assert any(expected_fragment in error.message for error in errors), (
|
|
393
|
+
f"schema accepted an entry it must reject ({expected_fragment}): {errors}"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@pytest.mark.parametrize("entry_path", list(_setup_paths()))
|
|
398
|
+
def test_form_placeholders_reference_declared_fields(entry_path: Path) -> None:
|
|
399
|
+
"""A {{form.x}} that names no field renders as an empty value at runtime."""
|
|
400
|
+
setup = _load(entry_path)["setup"]
|
|
401
|
+
fields = _field_names(setup)
|
|
402
|
+
|
|
403
|
+
referenced = {
|
|
404
|
+
key
|
|
405
|
+
for value in _iter_strings(setup)
|
|
406
|
+
for namespace, key in PLACEHOLDER_RE.findall(value)
|
|
407
|
+
if namespace == "form"
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
assert referenced - fields == set()
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@pytest.mark.parametrize("entry_path", list(_setup_paths()))
|
|
414
|
+
def test_select_fields_offer_options(entry_path: Path) -> None:
|
|
415
|
+
"""A select without options is an empty dropdown the user cannot get past.
|
|
416
|
+
A field whose options come from the deployment declares a semantic type
|
|
417
|
+
instead, so the host knows to fill it."""
|
|
418
|
+
setup = _load(entry_path)["setup"]
|
|
419
|
+
|
|
420
|
+
unusable = [
|
|
421
|
+
name
|
|
422
|
+
for name, field in _fields(setup).items()
|
|
423
|
+
if field["type"] == "select" and "options" not in field
|
|
424
|
+
]
|
|
425
|
+
|
|
426
|
+
assert unusable == []
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@pytest.mark.parametrize(("bundle", "scenario"), list(_scenarios("create")))
|
|
430
|
+
def test_derived_body_reproduces_the_create_request(
|
|
431
|
+
bundle: dict, scenario: dict
|
|
432
|
+
) -> None:
|
|
433
|
+
"""An entry declares the prompt and, for an event trigger, the filter. The
|
|
434
|
+
name, the repository and the trigger come out of the form, and this is
|
|
435
|
+
where that reconstruction is pinned to a body the service accepts."""
|
|
436
|
+
entry = _entry_for(bundle)
|
|
437
|
+
|
|
438
|
+
derived = _render_payload(entry, scenario["formValues"])
|
|
439
|
+
|
|
440
|
+
assert derived == scenario["create"]["request"]["body"]
|
|
441
|
+
assert scenario["create"]["request"]["path"] == CREATE_PATH
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@pytest.mark.parametrize(("bundle", "scenario"), list(_scenarios("preflight")))
|
|
445
|
+
def test_derived_preflight_body_reproduces_the_preflight_request(
|
|
446
|
+
bundle: dict, scenario: dict
|
|
447
|
+
) -> None:
|
|
448
|
+
"""No entry declares the preflight call any more. It has to come out of the
|
|
449
|
+
entry id and the payload, and this is where that is pinned."""
|
|
450
|
+
entry = _entry_for(bundle)
|
|
451
|
+
|
|
452
|
+
derived = _derive_preflight_body(entry, scenario["formValues"])
|
|
453
|
+
|
|
454
|
+
assert derived == scenario["preflight"]["request"]["body"]
|
|
455
|
+
assert scenario["preflight"]["request"]["path"] == PREFLIGHT_PATH
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@pytest.mark.parametrize(("bundle", "scenario"), list(_scenarios("conversation")))
|
|
459
|
+
def test_seed_message_reproduces_the_conversation_request(
|
|
460
|
+
bundle: dict, scenario: dict
|
|
461
|
+
) -> None:
|
|
462
|
+
entry = _entry_for(bundle)
|
|
463
|
+
|
|
464
|
+
rendered = _interpolate(
|
|
465
|
+
entry["setup"]["message"], _context(entry, scenario["formValues"])
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
assert rendered == scenario["conversation"]["request"]["message"]
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@pytest.mark.parametrize(("bundle", "scenario"), list(_scenarios("expectedFieldErrors")))
|
|
472
|
+
def test_derived_error_map_turns_rejections_into_highlighted_inputs(
|
|
473
|
+
bundle: dict, scenario: dict
|
|
474
|
+
) -> None:
|
|
475
|
+
"""The whole two-tier validation design rests on this translation: whoever
|
|
476
|
+
rejects the draft, the user must end up looking at the input at fault. The
|
|
477
|
+
mapping is derived from the payload, so it cannot drift from it."""
|
|
478
|
+
entry = _entry_for(bundle)
|
|
479
|
+
|
|
480
|
+
reported = _reported_fields(entry, scenario)
|
|
481
|
+
|
|
482
|
+
assert set(reported) <= _field_names(entry["setup"])
|
|
483
|
+
assert reported == scenario["expectedFieldErrors"]
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
@pytest.mark.parametrize("fixture_path", list(_fixture_bundles()))
|
|
487
|
+
def test_blocked_by_lists_exactly_the_unsatisfiable_deployments(
|
|
488
|
+
fixture_path: Path,
|
|
489
|
+
) -> None:
|
|
490
|
+
"""Keeps requires honest: an entry that claims to work everywhere would
|
|
491
|
+
silently offer a card the deployment cannot run."""
|
|
492
|
+
bundle = _load(fixture_path)
|
|
493
|
+
entry = _entry_for(bundle)
|
|
494
|
+
responses = _load(CAPABILITIES_PATH)["responses"]
|
|
495
|
+
|
|
496
|
+
unsatisfiable = {
|
|
497
|
+
name
|
|
498
|
+
for name, response in responses.items()
|
|
499
|
+
if not _capabilities_satisfied(entry, response["body"])
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
assert unsatisfiable == set(bundle["blockedBy"])
|
|
503
|
+
assert _capabilities_satisfied(entry, responses[bundle["capabilities"]]["body"])
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def test_generated_catalog_index_is_up_to_date() -> None:
|
|
507
|
+
"""Re-running the codegen script should produce identical output."""
|
|
508
|
+
before = CATALOG_INDEX.read_text()
|
|
509
|
+
subprocess.run(
|
|
510
|
+
["node", str(BUILD_SCRIPT)], cwd=str(ROOT), check=True, capture_output=True
|
|
511
|
+
)
|
|
512
|
+
assert CATALOG_INDEX.read_text() == before, (
|
|
513
|
+
"automations/catalog-index.js is out of date - run: npm run build:automations"
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def test_no_catalog_entry_or_fixture_carries_a_credential_value() -> None:
|
|
518
|
+
"""Credentials come from a connected integration, so no entry or fixture
|
|
519
|
+
has any reason to carry one."""
|
|
520
|
+
offenders = []
|
|
521
|
+
for path in _manifests() + sorted(FIXTURE_DIR.glob("*.json")):
|
|
522
|
+
for value in _iter_strings(_load(path)):
|
|
523
|
+
if CREDENTIAL_VALUE_RE.search(value):
|
|
524
|
+
offenders.append(f"{path.name}: {value[:40]}")
|
|
525
|
+
|
|
526
|
+
assert offenders == []
|
package/tests/test_catalogs.py
CHANGED
|
@@ -7,18 +7,24 @@ from pathlib import Path
|
|
|
7
7
|
ROOT = Path(__file__).resolve().parents[1]
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
def load_catalog_entries(relative_path: str):
|
|
10
|
+
def load_catalog_entries(relative_path: str, pattern: str = "*.json"):
|
|
11
|
+
"""Integrations are `<id>.json`; automations are `<id>/manifest.json`."""
|
|
11
12
|
entries = []
|
|
12
|
-
for entry_path in sorted((ROOT / relative_path).glob(
|
|
13
|
+
for entry_path in sorted((ROOT / relative_path).glob(pattern)):
|
|
13
14
|
entry = json.loads(entry_path.read_text())
|
|
14
|
-
|
|
15
|
+
expected_id = (
|
|
16
|
+
entry_path.parent.name
|
|
17
|
+
if entry_path.name == "manifest.json"
|
|
18
|
+
else entry_path.stem
|
|
19
|
+
)
|
|
20
|
+
assert entry["id"] == expected_id
|
|
15
21
|
entries.append(entry)
|
|
16
22
|
return entries
|
|
17
23
|
|
|
18
24
|
|
|
19
|
-
def
|
|
25
|
+
def test_catalog_ids_are_unique_and_automations_reference_things_that_exist():
|
|
20
26
|
integrations = load_catalog_entries("integrations/catalog")
|
|
21
|
-
automations = load_catalog_entries("automations/catalog")
|
|
27
|
+
automations = load_catalog_entries("automations/catalog", "*/manifest.json")
|
|
22
28
|
|
|
23
29
|
integration_ids = [entry["id"] for entry in integrations]
|
|
24
30
|
automation_ids = [entry["id"] for entry in automations]
|
|
@@ -28,11 +34,16 @@ def test_catalog_ids_are_unique_and_automations_reference_existing_integrations(
|
|
|
28
34
|
|
|
29
35
|
known_integration_ids = set(integration_ids)
|
|
30
36
|
for automation in automations:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
required = automation["requires"]["integrations"]
|
|
38
|
+
assert required
|
|
39
|
+
assert set(required) - known_integration_ids == set()
|
|
40
|
+
|
|
41
|
+
# The launch command is looked up from this skill rather than stored on
|
|
42
|
+
# the entry, so a broken link is a card that launches nothing.
|
|
43
|
+
skill = automation.get("skill", automation["id"])
|
|
44
|
+
assert (ROOT / "skills" / skill / "SKILL.md").is_file(), (
|
|
45
|
+
f"{automation['id']}: skill '{skill}' has no SKILL.md"
|
|
34
46
|
)
|
|
35
|
-
assert missing_ids == set()
|
|
36
47
|
|
|
37
48
|
|
|
38
49
|
def test_catalog_entries_have_required_fields():
|
|
@@ -63,10 +74,9 @@ def test_catalog_entries_have_required_fields():
|
|
|
63
74
|
else:
|
|
64
75
|
assert option["transport"]["url"].startswith("https://")
|
|
65
76
|
|
|
66
|
-
for entry in load_catalog_entries("automations/catalog"):
|
|
77
|
+
for entry in load_catalog_entries("automations/catalog", "*/manifest.json"):
|
|
67
78
|
assert entry["id"]
|
|
68
79
|
assert entry["name"]
|
|
69
|
-
assert entry["prompt"]
|
|
70
80
|
assert entry["exampleImplementation"]
|
|
71
81
|
assert isinstance(entry["popularityRank"], int)
|
|
72
82
|
assert isinstance(entry["estimatedSetupMinutes"], int)
|
|
@@ -81,7 +91,7 @@ def test_remote_no_auth_mcp_entries_are_intentionally_public():
|
|
|
81
91
|
transport = option.get("transport", {})
|
|
82
92
|
# An option with `headerFields` still requires the user to supply
|
|
83
93
|
# credentials (just via named headers, e.g. Datadog's
|
|
84
|
-
#
|
|
94
|
+
# DD_API_KEY/DD_APPLICATION_KEY), so it is NOT "public/no-auth".
|
|
85
95
|
has_header_credentials = bool(transport.get("headerFields"))
|
|
86
96
|
if (
|
|
87
97
|
option["provider"] == "mcp"
|
|
@@ -95,6 +105,21 @@ def test_remote_no_auth_mcp_entries_are_intentionally_public():
|
|
|
95
105
|
assert actual == public_remote_mcp_ids
|
|
96
106
|
|
|
97
107
|
|
|
108
|
+
def test_datadog_mcp_uses_documented_api_key_headers():
|
|
109
|
+
datadog = next(
|
|
110
|
+
entry
|
|
111
|
+
for entry in load_catalog_entries("integrations/catalog")
|
|
112
|
+
if entry["id"] == "datadog"
|
|
113
|
+
)
|
|
114
|
+
api_option = next(
|
|
115
|
+
option for option in datadog["connectionOptions"] if option["id"] == "api"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
assert [
|
|
119
|
+
field["key"] for field in api_option["transport"]["headerFields"]
|
|
120
|
+
] == ["DD_API_KEY", "DD_APPLICATION_KEY"]
|
|
121
|
+
|
|
122
|
+
|
|
98
123
|
def test_credential_fields_have_helper_text_and_link():
|
|
99
124
|
"""All password fields must have helperText plus a link (either a helperLink field or a
|
|
100
125
|
markdown link embedded in helperText) so users know how to get credentials."""
|
|
@@ -33,6 +33,28 @@ def _load_checker():
|
|
|
33
33
|
return module
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
def _sample_package(checker, tmp_path: Path, *, version: str):
|
|
37
|
+
"""A throwaway package holding one deprecation scheduled for removal in 0.12.0.
|
|
38
|
+
|
|
39
|
+
The deadline tests use this instead of the shipped source so they assert the
|
|
40
|
+
checker's behaviour rather than whichever deprecations happen to be pending.
|
|
41
|
+
"""
|
|
42
|
+
pyproject = tmp_path / "pyproject.toml"
|
|
43
|
+
pyproject.write_text(f'[project]\nname = "sample"\nversion = "{version}"\n')
|
|
44
|
+
|
|
45
|
+
source_root = tmp_path / "sample"
|
|
46
|
+
source_root.mkdir()
|
|
47
|
+
(source_root / "legacy.py").write_text(
|
|
48
|
+
'@deprecated(deprecated_in="0.10.0", removed_in="0.12.0")\n'
|
|
49
|
+
"def legacy_helper() -> None:\n"
|
|
50
|
+
" pass\n"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return checker.PackageConfig(
|
|
54
|
+
name="sample", pyproject=pyproject, source_roots=(source_root,)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
36
58
|
def test_deprecated_decorator_warns_and_preserves_calls() -> None:
|
|
37
59
|
@deprecated(
|
|
38
60
|
deprecated_in="0.10.0",
|
|
@@ -107,9 +129,13 @@ warn_cleanup(\"temporary workaround\", cleanup_by=date(2030, 1, 1))
|
|
|
107
129
|
|
|
108
130
|
def test_release_please_deadline_passes_before_removal_version(
|
|
109
131
|
monkeypatch: pytest.MonkeyPatch,
|
|
132
|
+
tmp_path: Path,
|
|
110
133
|
) -> None:
|
|
111
134
|
checker = _load_checker()
|
|
112
|
-
monkeypatch.setattr(checker, "
|
|
135
|
+
monkeypatch.setattr(checker, "REPO_ROOT", tmp_path)
|
|
136
|
+
monkeypatch.setattr(
|
|
137
|
+
checker, "PACKAGES", (_sample_package(checker, tmp_path, version="0.11.0"),)
|
|
138
|
+
)
|
|
113
139
|
|
|
114
140
|
assert checker.main() == 0
|
|
115
141
|
|
|
@@ -117,14 +143,23 @@ def test_release_please_deadline_passes_before_removal_version(
|
|
|
117
143
|
def test_release_please_deadline_fails_at_removal_version(
|
|
118
144
|
monkeypatch: pytest.MonkeyPatch,
|
|
119
145
|
capsys: pytest.CaptureFixture[str],
|
|
146
|
+
tmp_path: Path,
|
|
120
147
|
) -> None:
|
|
121
148
|
checker = _load_checker()
|
|
122
|
-
monkeypatch.setattr(checker, "
|
|
149
|
+
monkeypatch.setattr(checker, "REPO_ROOT", tmp_path)
|
|
150
|
+
monkeypatch.setattr(
|
|
151
|
+
checker, "PACKAGES", (_sample_package(checker, tmp_path, version="0.12.0"),)
|
|
152
|
+
)
|
|
123
153
|
|
|
124
154
|
assert checker.main() == 1
|
|
125
155
|
output = capsys.readouterr().out
|
|
126
|
-
assert "
|
|
127
|
-
assert "
|
|
156
|
+
assert "legacy_helper" in output
|
|
157
|
+
assert "removed in: 0.12.0" in output
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_shipped_source_has_no_overdue_deprecations() -> None:
|
|
161
|
+
"""The gate the release PR runs: nothing shipped is past its deadline."""
|
|
162
|
+
assert _load_checker().main() == 0
|
|
128
163
|
|
|
129
164
|
|
|
130
165
|
def test_date_based_deadlines_use_the_sdk_semantics() -> None:
|