@openhands/extensions 0.16.0 → 0.17.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 (44) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/AGENTS.md +1 -0
  3. package/automations/README.md +75 -9
  4. package/automations/bundle-index.js +9 -0
  5. package/automations/catalog/github-pr-reviewer/manifest.json +23 -7
  6. package/automations/catalog/github-repo-monitor/manifest.json +28 -34
  7. package/automations/catalog/jira-issue-to-pr/manifest.json +3 -3
  8. package/automations/catalog.schema.json +102 -1
  9. package/automations/index.d.ts +48 -0
  10. package/automations/index.js +14 -0
  11. package/automations/interface.json +3 -1
  12. package/automations/interface.schema.json +6 -2
  13. package/index.d.ts +1 -1
  14. package/index.js +1 -1
  15. package/integrations/catalog/posthog.json +41 -3
  16. package/integrations/catalog/sonarqube.json +78 -0
  17. package/integrations/catalog-index.js +65 -63
  18. package/marketplaces/openhands-extensions.json +11 -0
  19. package/package.json +1 -1
  20. package/plugins/qa-changes/action.yml +1 -1
  21. package/pyproject.toml +1 -1
  22. package/python/openhands_extensions/_version.py +1 -1
  23. package/scripts/build-automation-catalog.mjs +30 -0
  24. package/scripts/build-skills-catalog.mjs +24 -7
  25. package/skills/add-skill/SKILL.md +1 -1
  26. package/skills/github-pr-reviewer/README.md +12 -6
  27. package/skills/github-pr-reviewer/SKILL.md +93 -31
  28. package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
  29. package/skills/github-pr-reviewer/references/state-schema.md +68 -18
  30. package/skills/github-pr-reviewer/scripts/main.py +540 -103
  31. package/skills/github-pr-reviewer/tests/test_main.py +503 -0
  32. package/skills/index.d.ts +6 -0
  33. package/skills/index.js +29 -17
  34. package/skills/jira-issue-to-pr/scripts/main.py +4 -2
  35. package/skills/openhands-sdk/SKILL.md +2 -0
  36. package/skills/skill-creator/SKILL.md +1 -1
  37. package/skills/slack-channel-monitor/scripts/main.py +3 -2
  38. package/tests/fixtures/automations/capabilities.json +30 -0
  39. package/tests/fixtures/automations/github-pr-reviewer.json +319 -55
  40. package/tests/fixtures/automations/github-repo-monitor.json +153 -62
  41. package/tests/test_automation_setup.py +226 -31
  42. package/tests/test_catalogs.py +43 -0
  43. package/tests/test_skills_catalog.py +182 -36
  44. package/tests/test_slack_channel_monitor.py +1 -1
@@ -146,6 +146,49 @@ def test_credential_fields_have_helper_text_and_link():
146
146
  )
147
147
 
148
148
 
149
+ def test_posthog_catalog_prefers_oauth_with_safe_mcp_defaults():
150
+ posthog = next(
151
+ entry for entry in load_catalog_entries("integrations/catalog")
152
+ if entry["id"] == "posthog"
153
+ )
154
+
155
+ assert [option["id"] for option in posthog["connectionOptions"]] == [
156
+ "oauth",
157
+ "api-key",
158
+ ]
159
+
160
+ oauth = posthog["connectionOptions"][0]
161
+ assert oauth["provider"] == "mcp"
162
+ assert oauth["transport"] == {
163
+ "kind": "shttp",
164
+ "url": "https://mcp.posthog.com/mcp?readonly=true",
165
+ "urlEditable": True,
166
+ }
167
+ oauth_config = oauth["auth"]["oauth"]
168
+ assert oauth_config["authorizationUrl"] == (
169
+ "https://oauth.posthog.com/oauth/authorize/"
170
+ )
171
+ assert oauth_config["tokenUrl"] == "https://oauth.posthog.com/oauth/token/"
172
+ assert oauth_config["registrationUrl"] == (
173
+ "https://oauth.posthog.com/oauth/register/"
174
+ )
175
+ assert oauth_config["pkce"] is True
176
+ assert oauth_config["clientAuthentication"] == "none"
177
+ assert oauth_config["additionalAuthorizationParams"] == {
178
+ "resource": "https://mcp.posthog.com/mcp"
179
+ }
180
+ assert oauth_config["additionalTokenParams"] == {
181
+ "resource": "https://mcp.posthog.com/mcp"
182
+ }
183
+ assert "query:read" in oauth_config["scopes"]
184
+ assert all(not scope.endswith(":write") for scope in oauth_config["scopes"])
185
+
186
+ api_key = posthog["connectionOptions"][1]
187
+ assert api_key["auth"]["strategy"] == "bearer"
188
+ assert api_key["auth"]["credentialSecretName"] == "POSTHOG_PERSONAL_API_KEY"
189
+ assert api_key["auth"]["saveCredentialAsSecretByDefault"] is True
190
+
191
+
149
192
  def test_node_package_exports_catalogs():
150
193
  script = """
151
194
  import { INTEGRATION_CATALOG, AUTOMATION_CATALOG } from './index.js';
@@ -373,19 +373,6 @@ SKILL_CATEGORY_IDS = {
373
373
  # Adding entries would mean creating .plugin/plugin.json and vendor symlinks (see test_skill_plugin_loading.py), which publishes them as Codex/Claude Code plugins.
374
374
  SKILLS_WITHOUT_MARKETPLACE_ENTRY = {"qa-changes", "release-notes"}
375
375
 
376
- EXPECTED_CATEGORY_COUNTS = {
377
- "environment": 10,
378
- "automations": 9,
379
- "code-hosting": 8,
380
- "agent-authoring": 8,
381
- "code-quality": 6,
382
- "integrations": 6,
383
- "writing": 4,
384
- "design": 2,
385
- "other": 1,
386
- }
387
-
388
-
389
376
  def _marketplace_skill_categories() -> dict[str, str]:
390
377
  """Map skill directory name -> category, across every marketplace manifest."""
391
378
  result: dict[str, str] = {}
@@ -417,12 +404,6 @@ class TestMarketplaceSkillCategories:
417
404
  uncovered = dirs - set(_marketplace_skill_categories())
418
405
  assert uncovered == SKILLS_WITHOUT_MARKETPLACE_ENTRY
419
406
 
420
- def test_category_distribution_is_balanced(self):
421
- from collections import Counter
422
-
423
- counts = dict(Counter(_marketplace_skill_categories().values()))
424
- assert counts == EXPECTED_CATEGORY_COUNTS
425
-
426
407
  def test_plugin_entries_keep_their_own_taxonomy(self):
427
408
  """Plugin entries are for Claude Code browsing and must not be rewritten."""
428
409
  categories = set()
@@ -437,25 +418,35 @@ class TestMarketplaceSkillCategories:
437
418
  )
438
419
 
439
420
 
440
- class TestCategoryJoin:
441
- def _build(self, tmp_path, skills: dict[str, str], manifests: dict[str, dict], check: bool = True):
442
- skills_dir = tmp_path / "skills"
443
- skills_dir.mkdir()
444
- for name, content in skills.items():
445
- (skills_dir / name).mkdir()
446
- (skills_dir / name / "SKILL.md").write_text(content)
421
+ def build_from_fixtures(
422
+ tmp_path: Path,
423
+ skills: dict[str, str],
424
+ manifests: dict[str, dict],
425
+ check: bool = True,
426
+ ) -> subprocess.CompletedProcess:
427
+ """Run buildCatalog over temp SKILL.md dirs joined against temp manifests."""
428
+ skills_dir = tmp_path / "skills"
429
+ skills_dir.mkdir()
430
+ for name, content in skills.items():
431
+ (skills_dir / name).mkdir()
432
+ (skills_dir / name / "SKILL.md").write_text(content)
433
+
434
+ markets_dir = tmp_path / "marketplaces"
435
+ markets_dir.mkdir()
436
+ for filename, manifest in manifests.items():
437
+ (markets_dir / filename).write_text(json.dumps(manifest))
438
+
439
+ script = textwrap.dedent(f"""\
440
+ import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
441
+ const entries = buildCatalog({json.dumps(str(skills_dir))}, {json.dumps(str(markets_dir))});
442
+ process.stdout.write(JSON.stringify(entries));
443
+ """)
444
+ return run_node(script, check=check)
447
445
 
448
- markets_dir = tmp_path / "marketplaces"
449
- markets_dir.mkdir()
450
- for filename, manifest in manifests.items():
451
- (markets_dir / filename).write_text(json.dumps(manifest))
452
446
 
453
- script = textwrap.dedent(f"""\
454
- import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
455
- const entries = buildCatalog({json.dumps(str(skills_dir))}, {json.dumps(str(markets_dir))});
456
- process.stdout.write(JSON.stringify(entries));
457
- """)
458
- return run_node(script, check=check)
447
+ class TestCategoryJoin:
448
+ def _build(self, tmp_path, skills: dict[str, str], manifests: dict[str, dict], check: bool = True):
449
+ return build_from_fixtures(tmp_path, skills, manifests, check=check)
459
450
 
460
451
  def test_category_is_joined_from_the_manifest(self, tmp_path):
461
452
  result = self._build(
@@ -544,3 +535,158 @@ class TestGeneratedCategories:
544
535
  }}
545
536
  """)
546
537
  run_node(script)
538
+
539
+
540
+ # ---------------------------------------------------------------------------
541
+ # defaultEnabled: the marketplace flag that seeds a new workspace's skill set
542
+ # ---------------------------------------------------------------------------
543
+
544
+ SKILL_MD = "---\nname: docker\ndescription: d\n---\nBody"
545
+
546
+
547
+ def _entry(**overrides) -> dict:
548
+ return {"name": "docker", "source": "./skills/docker", "category": "environment", **overrides}
549
+
550
+
551
+ class TestDefaultEnabledJoin:
552
+ def test_flag_is_joined_from_the_manifest(self, tmp_path):
553
+ result = build_from_fixtures(
554
+ tmp_path,
555
+ {"docker": SKILL_MD},
556
+ {"m.json": {"plugins": [_entry(defaultEnabled=True)]}},
557
+ )
558
+ assert json.loads(result.stdout)[0]["defaultEnabled"] is True
559
+
560
+ def test_absent_flag_is_omitted_rather_than_false(self, tmp_path):
561
+ """So consumers can test for absence."""
562
+ result = build_from_fixtures(
563
+ tmp_path,
564
+ {"docker": SKILL_MD},
565
+ {"m.json": {"plugins": [_entry()]}},
566
+ )
567
+ assert "defaultEnabled" not in json.loads(result.stdout)[0]
568
+
569
+ def test_explicit_false_is_also_omitted(self, tmp_path):
570
+ result = build_from_fixtures(
571
+ tmp_path,
572
+ {"docker": SKILL_MD},
573
+ {"m.json": {"plugins": [_entry(defaultEnabled=False)]}},
574
+ )
575
+ assert "defaultEnabled" not in json.loads(result.stdout)[0]
576
+
577
+ def test_skill_without_a_manifest_entry_is_not_default_enabled(self, tmp_path):
578
+ result = build_from_fixtures(
579
+ tmp_path,
580
+ {"lonely": "---\nname: lonely\ndescription: d\n---\nBody"},
581
+ {"m.json": {"plugins": []}},
582
+ )
583
+ assert "defaultEnabled" not in json.loads(result.stdout)[0]
584
+
585
+ def test_non_boolean_throws_naming_the_skill(self, tmp_path):
586
+ """A truthy string like "true" must not silently enable a skill for every new user."""
587
+ result = build_from_fixtures(
588
+ tmp_path,
589
+ {"docker": SKILL_MD},
590
+ {"m.json": {"plugins": [_entry(defaultEnabled="true")]}},
591
+ check=False,
592
+ )
593
+ assert result.returncode != 0
594
+ assert "docker" in result.stderr
595
+ assert "defaultEnabled" in result.stderr
596
+ assert "boolean" in result.stderr
597
+
598
+ def test_conflicting_values_across_manifests_throws(self, tmp_path):
599
+ result = build_from_fixtures(
600
+ tmp_path,
601
+ {"docker": SKILL_MD},
602
+ {
603
+ "a.json": {"plugins": [_entry(defaultEnabled=True)]},
604
+ "b.json": {"plugins": [_entry()]},
605
+ },
606
+ check=False,
607
+ )
608
+ assert result.returncode != 0
609
+ assert "docker" in result.stderr
610
+ assert "Conflicting defaultEnabled" in result.stderr
611
+
612
+ def test_plugin_entries_cannot_set_the_flag(self, tmp_path):
613
+ """Only `./skills/*` sources feed the catalog."""
614
+ result = build_from_fixtures(
615
+ tmp_path,
616
+ {"docker": SKILL_MD},
617
+ {"m.json": {"plugins": [
618
+ {"name": "docker", "source": "./plugins/docker", "category": "utilities", "defaultEnabled": True},
619
+ _entry(),
620
+ ]}},
621
+ )
622
+ assert "defaultEnabled" not in json.loads(result.stdout)[0]
623
+
624
+
625
+ class TestManifestDefaultEnabled:
626
+ """The hand-authored side: what the real marketplaces/*.json are allowed to say."""
627
+
628
+ def _skill_entries(self):
629
+ for path in sorted(MARKETPLACES_DIR.glob("*.json")):
630
+ for entry in json.loads(path.read_text()).get("plugins", []):
631
+ if entry.get("source", "").startswith("./skills/"):
632
+ yield path.name, entry
633
+
634
+ def test_values_are_boolean(self):
635
+ bad = {
636
+ f"{filename}:{entry['name']}": entry["defaultEnabled"]
637
+ for filename, entry in self._skill_entries()
638
+ if "defaultEnabled" in entry and not isinstance(entry["defaultEnabled"], bool)
639
+ }
640
+ assert bad == {}, f"Non-boolean defaultEnabled: {bad}"
641
+
642
+ def test_off_by_default_is_expressed_by_omission(self):
643
+ redundant = [
644
+ f"{filename}:{entry['name']}"
645
+ for filename, entry in self._skill_entries()
646
+ if entry.get("defaultEnabled") is False
647
+ ]
648
+ assert redundant == [], f"Drop the field instead: {redundant}"
649
+
650
+ def test_default_set_stays_a_curated_minority(self):
651
+ """The bug this flag fixes is "everything is on"."""
652
+ entries = list(self._skill_entries())
653
+ enabled = [entry["name"] for _, entry in entries if entry.get("defaultEnabled")]
654
+ assert enabled, "No skill is default-enabled; a new workspace would start empty"
655
+ assert len(enabled) < len(entries) / 2, (
656
+ f"{len(enabled)}/{len(entries)} skills are default-enabled, which is back to all-on"
657
+ )
658
+
659
+
660
+ class TestGeneratedDefaultEnabled:
661
+ def test_names_export_matches_the_flagged_entries(self):
662
+ script = textwrap.dedent("""\
663
+ import { SKILLS_CATALOG, DEFAULT_ENABLED_SKILL_NAMES } from './skills/index.js';
664
+ const fromEntries = SKILLS_CATALOG.filter(e => e.defaultEnabled).map(e => e.name);
665
+ const exported = [...DEFAULT_ENABLED_SKILL_NAMES];
666
+ if (JSON.stringify(fromEntries) !== JSON.stringify(exported)) {
667
+ console.error('Mismatch: ' + JSON.stringify(fromEntries) + ' vs ' + JSON.stringify(exported));
668
+ process.exit(1);
669
+ }
670
+ if (exported.length === 0) process.exit(1);
671
+ """)
672
+ run_node(script)
673
+
674
+ def test_flag_is_only_ever_true_in_the_generated_catalog(self):
675
+ script = textwrap.dedent("""\
676
+ import { SKILLS_CATALOG } from './skills/index.js';
677
+ for (const entry of SKILLS_CATALOG) {
678
+ if ('defaultEnabled' in entry && entry.defaultEnabled !== true) {
679
+ console.error('Non-true defaultEnabled for ' + entry.name);
680
+ process.exit(1);
681
+ }
682
+ }
683
+ """)
684
+ run_node(script)
685
+
686
+ def test_names_are_re_exported_from_the_package_root(self):
687
+ script = textwrap.dedent("""\
688
+ import { DEFAULT_ENABLED_SKILL_NAMES } from './index.js';
689
+ if (!Array.isArray(DEFAULT_ENABLED_SKILL_NAMES)) process.exit(1);
690
+ if (DEFAULT_ENABLED_SKILL_NAMES.length === 0) process.exit(1);
691
+ """)
692
+ run_node(script)
@@ -31,7 +31,7 @@ def test_post_message_sends_markdown_text(monkeypatch):
31
31
  return {"ts": "123.456"}
32
32
 
33
33
  monkeypatch.setitem(helpers, "slack_post", fake_slack_post)
34
- markdown_summary = "✅ Done!\n\n- **Bold:** [link](https://example.com)"
34
+ markdown_summary = "- **Bold:** [link](https://example.com)"
35
35
 
36
36
  ts = helpers["post_message"](
37
37
  "xoxb-test",