@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.
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +1 -0
- package/automations/README.md +75 -9
- package/automations/bundle-index.js +9 -0
- package/automations/catalog/github-pr-reviewer/manifest.json +23 -7
- package/automations/catalog/github-repo-monitor/manifest.json +28 -34
- package/automations/catalog/jira-issue-to-pr/manifest.json +3 -3
- package/automations/catalog.schema.json +102 -1
- package/automations/index.d.ts +48 -0
- package/automations/index.js +14 -0
- package/automations/interface.json +3 -1
- 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 +11 -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-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 +29 -17
- package/skills/jira-issue-to-pr/scripts/main.py +4 -2
- 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 +153 -62
- package/tests/test_automation_setup.py +226 -31
- package/tests/test_catalogs.py +43 -0
- package/tests/test_skills_catalog.py +182 -36
- package/tests/test_slack_channel_monitor.py +1 -1
package/tests/test_catalogs.py
CHANGED
|
@@ -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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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 = "
|
|
34
|
+
markdown_summary = "- **Bold:** [link](https://example.com)"
|
|
35
35
|
|
|
36
36
|
ts = helpers["post_message"](
|
|
37
37
|
"xoxb-test",
|