@openhands/extensions 0.16.0 → 0.18.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 +1 -0
- package/README.md +5 -2
- package/automations/README.md +78 -11
- package/automations/bundle-index.js +18 -0
- package/automations/catalog/github-agents-md-maintainer/manifest.json +96 -0
- package/automations/catalog/github-issue-to-pr/manifest.json +107 -0
- package/automations/catalog/github-pr-reviewer/manifest.json +24 -7
- package/automations/catalog/github-repo-monitor/manifest.json +29 -34
- package/automations/catalog/jira-issue-to-pr/manifest.json +3 -3
- package/automations/catalog/news-digest/manifest.json +76 -0
- package/automations/catalog-index.js +13 -7
- package/automations/catalog.schema.json +112 -3
- package/automations/index.d.ts +66 -1
- package/automations/index.js +14 -0
- package/automations/interface.json +6 -2
- package/automations/interface.schema.json +6 -2
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/integrations/catalog/posthog.json +41 -3
- package/integrations/catalog/sonarqube.json +78 -0
- package/integrations/catalog-index.js +65 -63
- package/marketplaces/openhands-extensions.json +49 -0
- package/package.json +1 -1
- package/plugins/qa-changes/action.yml +1 -1
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/scripts/build-automation-catalog.mjs +30 -0
- package/scripts/build-skills-catalog.mjs +24 -7
- package/skills/add-skill/SKILL.md +1 -1
- package/skills/github-agents-md-maintainer/.plugin/plugin.json +19 -0
- package/skills/github-agents-md-maintainer/README.md +59 -0
- package/skills/github-agents-md-maintainer/SKILL.md +271 -0
- package/skills/github-agents-md-maintainer/commands/agents-md-setup.md +8 -0
- package/skills/github-agents-md-maintainer/references/state-schema.md +113 -0
- package/skills/github-agents-md-maintainer/scripts/main.py +1187 -0
- package/skills/github-issue-to-pr/.plugin/plugin.json +19 -0
- package/skills/github-issue-to-pr/README.md +84 -0
- package/skills/github-issue-to-pr/SKILL.md +373 -0
- package/skills/github-issue-to-pr/commands/issue-to-pr-setup.md +8 -0
- package/skills/github-issue-to-pr/references/state-schema.md +139 -0
- package/skills/github-issue-to-pr/scripts/main.py +1305 -0
- package/skills/github-pr-reviewer/README.md +12 -6
- package/skills/github-pr-reviewer/SKILL.md +93 -31
- package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
- package/skills/github-pr-reviewer/references/state-schema.md +68 -18
- package/skills/github-pr-reviewer/scripts/main.py +540 -103
- package/skills/github-pr-reviewer/tests/test_main.py +503 -0
- package/skills/index.d.ts +6 -0
- package/skills/index.js +56 -17
- package/skills/jira-issue-to-pr/scripts/main.py +4 -2
- package/skills/news-digest/.plugin/plugin.json +19 -0
- package/skills/news-digest/README.md +71 -0
- package/skills/news-digest/SKILL.md +284 -0
- package/skills/news-digest/commands/news-digest-setup.md +8 -0
- package/skills/news-digest/references/state-schema.md +178 -0
- package/skills/news-digest/scripts/main.py +1250 -0
- package/skills/openhands-sdk/SKILL.md +2 -0
- package/skills/skill-creator/SKILL.md +1 -1
- package/skills/slack-channel-monitor/scripts/main.py +3 -2
- package/tests/fixtures/automations/capabilities.json +30 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +319 -55
- package/tests/fixtures/automations/github-repo-monitor.json +230 -62
- package/tests/test_automation_setup.py +260 -31
- package/tests/test_catalogs.py +46 -1
- package/tests/test_github_agents_md_maintainer.py +352 -0
- package/tests/test_github_issue_to_pr.py +539 -0
- package/tests/test_news_digest.py +646 -0
- package/tests/test_skills_catalog.py +182 -36
- package/tests/test_slack_channel_monitor.py +1 -1
|
@@ -32,15 +32,27 @@ ROOT = Path(__file__).resolve().parents[1]
|
|
|
32
32
|
SCHEMA_PATH = ROOT / "automations" / "catalog.schema.json"
|
|
33
33
|
CATALOG_DIR = ROOT / "automations" / "catalog"
|
|
34
34
|
CATALOG_INDEX = ROOT / "automations" / "catalog-index.js"
|
|
35
|
+
BUNDLE_INDEX = ROOT / "automations" / "bundle-index.js"
|
|
35
36
|
BUILD_SCRIPT = ROOT / "scripts" / "build-automation-catalog.mjs"
|
|
36
37
|
FIXTURE_DIR = ROOT / "tests" / "fixtures" / "automations"
|
|
37
38
|
CAPABILITIES_PATH = FIXTURE_DIR / "capabilities.json"
|
|
38
39
|
|
|
39
40
|
# The standardized parts of a direct setup, identical for every automation and
|
|
40
|
-
# therefore not declared in any entry.
|
|
41
|
+
# therefore not declared in any entry. Which create endpoint is used follows
|
|
42
|
+
# from what the entry produces: a prompt is a preset, a bundle is a tarball the
|
|
43
|
+
# host uploads and then creates from, which is the raw endpoint.
|
|
41
44
|
CREATE_PATH = "/v1/preset/prompt"
|
|
45
|
+
BUNDLE_CREATE_PATH = "/v1"
|
|
46
|
+
BUNDLE_UPLOAD_PATH = "/v1/uploads"
|
|
42
47
|
PREFLIGHT_PATH = "/v1/validate"
|
|
43
48
|
|
|
49
|
+
# Preflight runs while the form is being filled in and the upload happens once,
|
|
50
|
+
# at submit, so a bundle draft has no tarball path to send yet. The service
|
|
51
|
+
# checks that field's scheme at preflight and its ownership only at creation,
|
|
52
|
+
# so a well-formed stand-in validates what preflight is for - the rest of the
|
|
53
|
+
# body - without uploading an archive per keystroke.
|
|
54
|
+
PREFLIGHT_TARBALL_PATH = "oh-internal://uploads/00000000-0000-0000-0000-000000000000"
|
|
55
|
+
|
|
44
56
|
# The trigger properties the service accepts, per kind. A form field named
|
|
45
57
|
# after one of them fills it; the rest are inputs to the declared filter.
|
|
46
58
|
TRIGGER_PROPERTIES = {"cron": ("schedule", "timezone"), "event": ("on",)}
|
|
@@ -83,10 +95,30 @@ def _fixture_bundles():
|
|
|
83
95
|
yield pytest.param(path, id=path.stem)
|
|
84
96
|
|
|
85
97
|
|
|
98
|
+
def _inlined_bundles() -> dict:
|
|
99
|
+
"""The generated bundle index, read as data rather than executed."""
|
|
100
|
+
body = BUNDLE_INDEX.read_text()
|
|
101
|
+
marker = "export const AUTOMATION_BUNDLE_FILES = "
|
|
102
|
+
return json.loads(body[body.index(marker) + len(marker) :].rstrip().rstrip(";"))
|
|
103
|
+
|
|
104
|
+
|
|
86
105
|
def _entry_for(bundle: dict) -> dict:
|
|
87
106
|
return _load(CATALOG_DIR / bundle["automationId"] / "manifest.json")
|
|
88
107
|
|
|
89
108
|
|
|
109
|
+
def _uploaded_path(scenario: dict) -> str:
|
|
110
|
+
"""Where a bundle scenario's tarball landed, as the upload step reported it.
|
|
111
|
+
|
|
112
|
+
A preset scenario has no upload step and never reads this.
|
|
113
|
+
"""
|
|
114
|
+
return (
|
|
115
|
+
scenario.get("upload", {})
|
|
116
|
+
.get("response", {})
|
|
117
|
+
.get("body", {})
|
|
118
|
+
.get("tarball_path", "")
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
90
122
|
def _integration_catalog_ids() -> set[str]:
|
|
91
123
|
return {path.stem for path in (ROOT / "integrations" / "catalog").glob("*.json")}
|
|
92
124
|
|
|
@@ -132,30 +164,46 @@ def _repo_picker(setup: dict) -> tuple[str | None, dict | None]:
|
|
|
132
164
|
return None, None
|
|
133
165
|
|
|
134
166
|
|
|
135
|
-
def
|
|
136
|
-
"""
|
|
167
|
+
def _is_bundle(entry: dict) -> bool:
|
|
168
|
+
"""Whether this entry ships a script tarball instead of a prompt."""
|
|
169
|
+
return "bundle" in entry.get("setup", {})
|
|
137
170
|
|
|
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
171
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
"prompt": _interpolate(setup["prompt"], context),
|
|
151
|
-
}
|
|
172
|
+
def _create_path(entry: dict) -> str:
|
|
173
|
+
return BUNDLE_CREATE_PATH if _is_bundle(entry) else CREATE_PATH
|
|
152
174
|
|
|
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
175
|
|
|
176
|
+
def _repo_values(setup: dict, form_values: dict) -> list[str]:
|
|
177
|
+
"""Every repository the form collected, whether it collects one or many.
|
|
178
|
+
|
|
179
|
+
A `multiple` picker holds a list; a single one holds a string. Reading both
|
|
180
|
+
as a list is what keeps the rest of the derivation from branching.
|
|
181
|
+
"""
|
|
182
|
+
name, field = _repo_picker(setup)
|
|
183
|
+
if not name:
|
|
184
|
+
return []
|
|
185
|
+
value = form_values.get(name)
|
|
186
|
+
if value is None or value == "":
|
|
187
|
+
return []
|
|
188
|
+
if field and field.get("multiple"):
|
|
189
|
+
return list(value)
|
|
190
|
+
return [value]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _derive_name(entry: dict, form_values: dict) -> str:
|
|
194
|
+
"""The created automation's name. One repository is worth naming; several
|
|
195
|
+
are not, so the count stands in rather than a list that never fits."""
|
|
196
|
+
repos = _repo_values(entry["setup"], form_values)
|
|
197
|
+
if not repos:
|
|
198
|
+
return entry["name"]
|
|
199
|
+
if len(repos) == 1:
|
|
200
|
+
return f"{entry['name']} - {repos[0]}"
|
|
201
|
+
return f"{entry['name']} - {len(repos)} repositories"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _derive_trigger(entry: dict, form_values: dict) -> dict:
|
|
205
|
+
"""The `trigger` object, read off the key and fields under form.triggers."""
|
|
206
|
+
setup = entry["setup"]
|
|
159
207
|
kind, trigger_fields = next(iter(setup["form"]["triggers"].items()))
|
|
160
208
|
trigger = {"type": kind}
|
|
161
209
|
# A field under a trigger kind fills the trigger property of the same name.
|
|
@@ -164,9 +212,81 @@ def _render_payload(entry: dict, form_values: dict) -> dict:
|
|
|
164
212
|
if name in TRIGGER_PROPERTIES[kind]:
|
|
165
213
|
trigger[name] = form_values[name]
|
|
166
214
|
if kind == "event":
|
|
215
|
+
_, repo_field = _repo_picker(setup)
|
|
167
216
|
trigger["source"] = repo_field["provider"]
|
|
168
|
-
trigger["filter"] = _interpolate(setup["filter"],
|
|
169
|
-
|
|
217
|
+
trigger["filter"] = _interpolate(setup["filter"], _context(entry, form_values))
|
|
218
|
+
return trigger
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _render_bundle_payload(entry: dict, form_values: dict, tarball_path: str) -> dict:
|
|
222
|
+
"""The raw create body a bundle entry produces.
|
|
223
|
+
|
|
224
|
+
`tarball_path` is the one value that is neither declared nor derived: the
|
|
225
|
+
host uploads the packed bundle first and creates from what came back. The
|
|
226
|
+
rest follows the same rule as the preset path - only `config` is declared,
|
|
227
|
+
because only the entry knows which key of its own script each field fills.
|
|
228
|
+
"""
|
|
229
|
+
bundle = entry["setup"]["bundle"]
|
|
230
|
+
body: dict = {
|
|
231
|
+
"name": _derive_name(entry, form_values),
|
|
232
|
+
"trigger": _derive_trigger(entry, form_values),
|
|
233
|
+
"tarball_path": tarball_path,
|
|
234
|
+
"entrypoint": bundle["entrypoint"],
|
|
235
|
+
}
|
|
236
|
+
if "setupScript" in bundle:
|
|
237
|
+
body["setup_script_path"] = bundle["setupScript"]
|
|
238
|
+
if "timeout" in bundle:
|
|
239
|
+
body["timeout"] = bundle["timeout"]
|
|
240
|
+
body["template"] = {
|
|
241
|
+
"id": entry["id"],
|
|
242
|
+
"version": bundle["version"],
|
|
243
|
+
"config": _interpolate(bundle["config"], _context(entry, form_values)),
|
|
244
|
+
}
|
|
245
|
+
return body
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _render_payload(entry: dict, form_values: dict, tarball_path: str = "") -> dict:
|
|
249
|
+
"""The create request body these form values produce.
|
|
250
|
+
|
|
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
|
+
|
|
259
|
+
setup = entry["setup"]
|
|
260
|
+
context = _context(entry, form_values)
|
|
261
|
+
_, repo_field = _repo_picker(setup)
|
|
262
|
+
repos = _repo_values(setup, form_values)
|
|
263
|
+
|
|
264
|
+
body: dict = {
|
|
265
|
+
"name": _derive_name(entry, form_values),
|
|
266
|
+
"prompt": _interpolate(setup["prompt"], context),
|
|
267
|
+
}
|
|
268
|
+
|
|
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
|
+
if "version" in entry:
|
|
285
|
+
body["template"] = {
|
|
286
|
+
"id": entry["id"],
|
|
287
|
+
"version": entry["version"],
|
|
288
|
+
"config": dict(form_values),
|
|
289
|
+
}
|
|
170
290
|
|
|
171
291
|
return body
|
|
172
292
|
|
|
@@ -176,8 +296,8 @@ def _derive_preflight_body(entry: dict, form_values: dict) -> dict:
|
|
|
176
296
|
so no entry declares it."""
|
|
177
297
|
return {
|
|
178
298
|
"automationId": entry["id"],
|
|
179
|
-
"endpoint":
|
|
180
|
-
"draft": _render_payload(entry, form_values),
|
|
299
|
+
"endpoint": _create_path(entry),
|
|
300
|
+
"draft": _render_payload(entry, form_values, PREFLIGHT_TARBALL_PATH),
|
|
181
301
|
}
|
|
182
302
|
|
|
183
303
|
|
|
@@ -190,7 +310,7 @@ def _derive_error_map(entry: dict) -> dict[str, list[str]]:
|
|
|
190
310
|
an entry does not declare it.
|
|
191
311
|
"""
|
|
192
312
|
mapping: dict[str, list[str]] = {}
|
|
193
|
-
if "prompt" not in entry["setup"]:
|
|
313
|
+
if "prompt" not in entry["setup"] and not _is_bundle(entry):
|
|
194
314
|
return mapping
|
|
195
315
|
|
|
196
316
|
stand_ins = {name: f"{{{{form.{name}}}}}" for name in _field_names(entry["setup"])}
|
|
@@ -279,8 +399,8 @@ def _reported_fields(entry: dict, scenario: dict) -> dict[str, str]:
|
|
|
279
399
|
setup = entry["setup"]
|
|
280
400
|
error_map = _derive_error_map(entry)
|
|
281
401
|
payload = (
|
|
282
|
-
_render_payload(entry, scenario["formValues"])
|
|
283
|
-
if "prompt" in setup and "formValues" in scenario
|
|
402
|
+
_render_payload(entry, scenario["formValues"], _uploaded_path(scenario))
|
|
403
|
+
if ("prompt" in setup or _is_bundle(entry)) and "formValues" in scenario
|
|
284
404
|
else {}
|
|
285
405
|
)
|
|
286
406
|
|
|
@@ -368,7 +488,7 @@ def test_schema_rejects_content_a_setup_block_must_never_carry() -> None:
|
|
|
368
488
|
rejected: list[tuple[str, dict]] = []
|
|
369
489
|
|
|
370
490
|
with_markup = deepcopy(entry)
|
|
371
|
-
with_markup["setup"]["form"]["args"]["
|
|
491
|
+
with_markup["setup"]["form"]["args"]["repositories"]["label"] = (
|
|
372
492
|
"Repository <script>steal()</script>"
|
|
373
493
|
)
|
|
374
494
|
rejected.append(("<script>steal()</script>", with_markup))
|
|
@@ -387,6 +507,10 @@ def test_schema_rejects_content_a_setup_block_must_never_carry() -> None:
|
|
|
387
507
|
with_repeated_identity["setup"]["description"] = "a second description"
|
|
388
508
|
rejected.append(("description", with_repeated_identity))
|
|
389
509
|
|
|
510
|
+
with_bad_version = deepcopy(entry)
|
|
511
|
+
with_bad_version["version"] = "1.0"
|
|
512
|
+
rejected.append(("'1.0' does not match", with_bad_version))
|
|
513
|
+
|
|
390
514
|
for expected_fragment, invalid in rejected:
|
|
391
515
|
errors = list(VALIDATOR.iter_errors(invalid))
|
|
392
516
|
assert any(expected_fragment in error.message for error in errors), (
|
|
@@ -410,6 +534,26 @@ def test_form_placeholders_reference_declared_fields(entry_path: Path) -> None:
|
|
|
410
534
|
assert referenced - fields == set()
|
|
411
535
|
|
|
412
536
|
|
|
537
|
+
@pytest.mark.parametrize("entry_path", list(_setup_paths()))
|
|
538
|
+
def test_the_declared_features_match_the_archetype(entry_path: Path) -> None:
|
|
539
|
+
"""A bundle needs a deployment that runs a client-supplied tarball; a prompt
|
|
540
|
+
needs the preset endpoint. Declaring the other one's features is a check the
|
|
541
|
+
host runs against the wrong capability, so the entry is either offered where
|
|
542
|
+
it cannot run or withheld where it can."""
|
|
543
|
+
entry = _load(entry_path)
|
|
544
|
+
features = set(entry["requires"].get("features", []))
|
|
545
|
+
|
|
546
|
+
if _is_bundle(entry):
|
|
547
|
+
assert "customTarball" in features, f"{entry['id']}: a bundle must declare customTarball"
|
|
548
|
+
assert "presetPrompt" not in features, (
|
|
549
|
+
f"{entry['id']}: a bundle creates through {BUNDLE_CREATE_PATH}, not the preset endpoint"
|
|
550
|
+
)
|
|
551
|
+
else:
|
|
552
|
+
assert "customTarball" not in features, (
|
|
553
|
+
f"{entry['id']}: only a bundle uploads a tarball"
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
|
|
413
557
|
@pytest.mark.parametrize("entry_path", list(_setup_paths()))
|
|
414
558
|
def test_select_fields_offer_options(entry_path: Path) -> None:
|
|
415
559
|
"""A select without options is an empty dropdown the user cannot get past.
|
|
@@ -435,10 +579,10 @@ def test_derived_body_reproduces_the_create_request(
|
|
|
435
579
|
where that reconstruction is pinned to a body the service accepts."""
|
|
436
580
|
entry = _entry_for(bundle)
|
|
437
581
|
|
|
438
|
-
derived = _render_payload(entry, scenario["formValues"])
|
|
582
|
+
derived = _render_payload(entry, scenario["formValues"], _uploaded_path(scenario))
|
|
439
583
|
|
|
440
584
|
assert derived == scenario["create"]["request"]["body"]
|
|
441
|
-
assert scenario["create"]["request"]["path"] ==
|
|
585
|
+
assert scenario["create"]["request"]["path"] == _create_path(entry)
|
|
442
586
|
|
|
443
587
|
|
|
444
588
|
@pytest.mark.parametrize(("bundle", "scenario"), list(_scenarios("preflight")))
|
|
@@ -506,12 +650,97 @@ def test_blocked_by_lists_exactly_the_unsatisfiable_deployments(
|
|
|
506
650
|
def test_generated_catalog_index_is_up_to_date() -> None:
|
|
507
651
|
"""Re-running the codegen script should produce identical output."""
|
|
508
652
|
before = CATALOG_INDEX.read_text()
|
|
653
|
+
bundles_before = BUNDLE_INDEX.read_text()
|
|
509
654
|
subprocess.run(
|
|
510
655
|
["node", str(BUILD_SCRIPT)], cwd=str(ROOT), check=True, capture_output=True
|
|
511
656
|
)
|
|
512
657
|
assert CATALOG_INDEX.read_text() == before, (
|
|
513
658
|
"automations/catalog-index.js is out of date - run: npm run build:automations"
|
|
514
659
|
)
|
|
660
|
+
assert BUNDLE_INDEX.read_text() == bundles_before, (
|
|
661
|
+
"automations/bundle-index.js is out of date - run: npm run build:automations"
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@pytest.mark.parametrize("entry_path", list(_setup_paths()))
|
|
666
|
+
def test_bundle_files_exist_and_are_inlined(entry_path: Path) -> None:
|
|
667
|
+
"""A bundle names repository paths; the package ships their contents.
|
|
668
|
+
|
|
669
|
+
The host packing the tarball has the published package but not this
|
|
670
|
+
repository, so a path that resolves here and nowhere else would produce an
|
|
671
|
+
entry that installs in CI and fails for every user.
|
|
672
|
+
"""
|
|
673
|
+
entry = _load(entry_path)
|
|
674
|
+
if not _is_bundle(entry):
|
|
675
|
+
pytest.skip("not a bundle entry")
|
|
676
|
+
|
|
677
|
+
bundle = entry["setup"]["bundle"]
|
|
678
|
+
inlined = _inlined_bundles()
|
|
679
|
+
|
|
680
|
+
assert set(inlined) >= {entry["id"]}
|
|
681
|
+
for packed_path, source in bundle["files"].items():
|
|
682
|
+
assert (ROOT / source).is_file(), f"{source} does not exist"
|
|
683
|
+
assert inlined[entry["id"]][packed_path] == (ROOT / source).read_text()
|
|
684
|
+
|
|
685
|
+
# The entrypoint has to name something the tarball actually contains.
|
|
686
|
+
packed = set(bundle["files"]) | {"config.json"}
|
|
687
|
+
assert any(word in packed for word in bundle["entrypoint"].split()), (
|
|
688
|
+
f"entrypoint {bundle['entrypoint']!r} names no packed file"
|
|
689
|
+
)
|
|
690
|
+
if "setupScript" in bundle:
|
|
691
|
+
assert bundle["setupScript"] in packed
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
# A bundle is the one part of a manifest naming files and a command the host
|
|
695
|
+
# acts on, so the schema is what stands between an entry and the host doing it.
|
|
696
|
+
BUNDLE_REJECTIONS = [
|
|
697
|
+
("a packed path that climbs out of the archive", {"files": {"../main.py": "skills/github-pr-reviewer/scripts/main.py"}}),
|
|
698
|
+
("a packed path that is a bare dot segment", {"files": {"./main.py": "skills/github-pr-reviewer/scripts/main.py"}}),
|
|
699
|
+
("a source that traverses out of the repository", {"files": {"main.py": "skills/../../etc/passwd"}}),
|
|
700
|
+
("a source outside skills/ and automations/", {"files": {"main.py": "python/main.py"}}),
|
|
701
|
+
("an entrypoint carrying a shell metacharacter", {"entrypoint": "python3 main.py && curl evil.sh"}),
|
|
702
|
+
("a setup script that climbs out of the archive", {"setupScript": "../setup.sh"}),
|
|
703
|
+
("a version that is not a semantic version", {"version": "latest"}),
|
|
704
|
+
("no files at all", {"files": {}}),
|
|
705
|
+
]
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@pytest.mark.parametrize(
|
|
709
|
+
("case", "override"),
|
|
710
|
+
[pytest.param(case, override, id=case) for case, override in BUNDLE_REJECTIONS],
|
|
711
|
+
)
|
|
712
|
+
def test_schema_refuses_an_unsafe_bundle(case: str, override: dict) -> None:
|
|
713
|
+
entry = deepcopy(_load(CATALOG_DIR / "github-pr-reviewer" / "manifest.json"))
|
|
714
|
+
entry["setup"]["bundle"].update(override)
|
|
715
|
+
|
|
716
|
+
assert list(VALIDATOR.iter_errors(entry)), f"schema admitted {case}"
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def test_a_direct_entry_declares_a_prompt_or_a_bundle_but_not_both() -> None:
|
|
720
|
+
entry = deepcopy(_load(CATALOG_DIR / "github-pr-reviewer" / "manifest.json"))
|
|
721
|
+
entry["setup"]["prompt"] = "Review pull requests."
|
|
722
|
+
|
|
723
|
+
assert list(VALIDATOR.iter_errors(entry))
|
|
724
|
+
|
|
725
|
+
del entry["setup"]["prompt"]
|
|
726
|
+
del entry["setup"]["bundle"]
|
|
727
|
+
|
|
728
|
+
assert list(VALIDATOR.iter_errors(entry))
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
@pytest.mark.parametrize("entry_path", list(_setup_paths()))
|
|
732
|
+
def test_bundle_config_only_reads_declared_form_fields(entry_path: Path) -> None:
|
|
733
|
+
"""config.json is the bundle's analogue of the prompt, and the same rule
|
|
734
|
+
applies: a placeholder must name an input the form actually collects."""
|
|
735
|
+
entry = _load(entry_path)
|
|
736
|
+
if not _is_bundle(entry):
|
|
737
|
+
pytest.skip("not a bundle entry")
|
|
738
|
+
|
|
739
|
+
names = _field_names(entry["setup"])
|
|
740
|
+
for value in _iter_strings(entry["setup"]["bundle"]["config"]):
|
|
741
|
+
for namespace, key in PLACEHOLDER_RE.findall(value):
|
|
742
|
+
if namespace == "form":
|
|
743
|
+
assert key in names, f"config references unknown field: {key}"
|
|
515
744
|
|
|
516
745
|
|
|
517
746
|
def test_no_catalog_entry_or_fixture_carries_a_credential_value() -> None:
|
package/tests/test_catalogs.py
CHANGED
|
@@ -34,8 +34,10 @@ def test_catalog_ids_are_unique_and_automations_reference_things_that_exist():
|
|
|
34
34
|
|
|
35
35
|
known_integration_ids = set(integration_ids)
|
|
36
36
|
for automation in automations:
|
|
37
|
+
# May be empty: an automation that needs nothing connected says so with
|
|
38
|
+
# an empty object rather than by leaving the key out. What it may not do
|
|
39
|
+
# is name an integration that does not exist.
|
|
37
40
|
required = automation["requires"]["integrations"]
|
|
38
|
-
assert required
|
|
39
41
|
assert set(required) - known_integration_ids == set()
|
|
40
42
|
|
|
41
43
|
# The launch command is looked up from this skill rather than stored on
|
|
@@ -146,6 +148,49 @@ def test_credential_fields_have_helper_text_and_link():
|
|
|
146
148
|
)
|
|
147
149
|
|
|
148
150
|
|
|
151
|
+
def test_posthog_catalog_prefers_oauth_with_safe_mcp_defaults():
|
|
152
|
+
posthog = next(
|
|
153
|
+
entry for entry in load_catalog_entries("integrations/catalog")
|
|
154
|
+
if entry["id"] == "posthog"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
assert [option["id"] for option in posthog["connectionOptions"]] == [
|
|
158
|
+
"oauth",
|
|
159
|
+
"api-key",
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
oauth = posthog["connectionOptions"][0]
|
|
163
|
+
assert oauth["provider"] == "mcp"
|
|
164
|
+
assert oauth["transport"] == {
|
|
165
|
+
"kind": "shttp",
|
|
166
|
+
"url": "https://mcp.posthog.com/mcp?readonly=true",
|
|
167
|
+
"urlEditable": True,
|
|
168
|
+
}
|
|
169
|
+
oauth_config = oauth["auth"]["oauth"]
|
|
170
|
+
assert oauth_config["authorizationUrl"] == (
|
|
171
|
+
"https://oauth.posthog.com/oauth/authorize/"
|
|
172
|
+
)
|
|
173
|
+
assert oauth_config["tokenUrl"] == "https://oauth.posthog.com/oauth/token/"
|
|
174
|
+
assert oauth_config["registrationUrl"] == (
|
|
175
|
+
"https://oauth.posthog.com/oauth/register/"
|
|
176
|
+
)
|
|
177
|
+
assert oauth_config["pkce"] is True
|
|
178
|
+
assert oauth_config["clientAuthentication"] == "none"
|
|
179
|
+
assert oauth_config["additionalAuthorizationParams"] == {
|
|
180
|
+
"resource": "https://mcp.posthog.com/mcp"
|
|
181
|
+
}
|
|
182
|
+
assert oauth_config["additionalTokenParams"] == {
|
|
183
|
+
"resource": "https://mcp.posthog.com/mcp"
|
|
184
|
+
}
|
|
185
|
+
assert "query:read" in oauth_config["scopes"]
|
|
186
|
+
assert all(not scope.endswith(":write") for scope in oauth_config["scopes"])
|
|
187
|
+
|
|
188
|
+
api_key = posthog["connectionOptions"][1]
|
|
189
|
+
assert api_key["auth"]["strategy"] == "bearer"
|
|
190
|
+
assert api_key["auth"]["credentialSecretName"] == "POSTHOG_PERSONAL_API_KEY"
|
|
191
|
+
assert api_key["auth"]["saveCredentialAsSecretByDefault"] is True
|
|
192
|
+
|
|
193
|
+
|
|
149
194
|
def test_node_package_exports_catalogs():
|
|
150
195
|
script = """
|
|
151
196
|
import { INTEGRATION_CATALOG, AUTOMATION_CATALOG } from './index.js';
|