@openhands/extensions 0.5.0 → 0.7.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/.github/workflows/pypi-publish.yml +104 -0
- package/.github/workflows/tests.yml +6 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +1 -1
- package/README.md +34 -4
- package/integrations/README.md +25 -9
- package/integrations/catalog/airtable.json +16 -10
- package/integrations/catalog/apify.json +0 -2
- package/integrations/catalog/asana.json +33 -0
- package/integrations/catalog/atlassian.json +5 -8
- package/integrations/catalog/bitbucket.json +34 -0
- package/integrations/catalog/box.json +34 -0
- package/integrations/catalog/brave-search.json +1 -2
- package/integrations/catalog/browser-mcp.json +0 -3
- package/integrations/catalog/canva.json +31 -0
- package/integrations/catalog/clickhouse.json +1 -2
- package/integrations/catalog/cloudflare-bindings.json +5 -8
- package/integrations/catalog/cloudflare-browser-rendering.json +5 -8
- package/integrations/catalog/cloudflare-builds.json +6 -9
- package/integrations/catalog/cloudflare-docs.json +4 -7
- package/integrations/catalog/cloudflare-observability.json +5 -8
- package/integrations/catalog/confluence.json +33 -0
- package/integrations/catalog/datadog.json +34 -0
- package/integrations/catalog/deepwiki.json +3 -7
- package/integrations/catalog/discord.json +34 -0
- package/integrations/catalog/elevenlabs.json +27 -28
- package/integrations/catalog/everything.json +0 -2
- package/integrations/catalog/exa.json +0 -2
- package/integrations/catalog/fetch.json +0 -2
- package/integrations/catalog/figma.json +35 -9
- package/integrations/catalog/filesystem.json +0 -3
- package/integrations/catalog/firecrawl.json +0 -2
- package/integrations/catalog/git.json +0 -3
- package/integrations/catalog/github.json +34 -10
- package/integrations/catalog/gmail.json +33 -0
- package/integrations/catalog/google-calendar.json +33 -0
- package/integrations/catalog/google-docs.json +33 -0
- package/integrations/catalog/google-drive.json +33 -0
- package/integrations/catalog/google-sheets.json +33 -0
- package/integrations/catalog/hubspot.json +34 -0
- package/integrations/catalog/huggingface.json +1 -2
- package/integrations/catalog/intercom.json +34 -0
- package/integrations/catalog/jira.json +33 -0
- package/integrations/catalog/kagi.json +2 -3
- package/integrations/catalog/linear.json +24 -16
- package/integrations/catalog/memory.json +0 -2
- package/integrations/catalog/microsoft-outlook.json +33 -0
- package/integrations/catalog/microsoft-teams.json +33 -0
- package/integrations/catalog/miro.json +42 -0
- package/integrations/catalog/monday.json +42 -0
- package/integrations/catalog/mongodb.json +1 -2
- package/integrations/catalog/neon.json +0 -2
- package/integrations/catalog/netlify.json +33 -0
- package/integrations/catalog/notion.json +30 -10
- package/integrations/catalog/obsidian.json +1 -3
- package/integrations/catalog/okta.json +33 -0
- package/integrations/catalog/onedrive.json +33 -0
- package/integrations/catalog/ordinal.json +33 -0
- package/integrations/catalog/paypal.json +5 -8
- package/integrations/catalog/playwright.json +0 -2
- package/integrations/catalog/posthog.json +38 -0
- package/integrations/catalog/quickbooks.json +86 -0
- package/integrations/catalog/redis.json +1 -3
- package/integrations/catalog/resend.json +1 -2
- package/integrations/catalog/salesforce.json +44 -0
- package/integrations/catalog/sentry.json +47 -14
- package/integrations/catalog/sequential-thinking.json +0 -2
- package/integrations/catalog/sharepoint.json +33 -0
- package/integrations/catalog/slack.json +49 -10
- package/integrations/catalog/stripe.json +39 -14
- package/integrations/catalog/supabase.json +31 -9
- package/integrations/catalog/tavily.json +0 -2
- package/integrations/catalog/time.json +0 -2
- package/integrations/catalog/trello.json +34 -0
- package/integrations/catalog/vercel.json +33 -0
- package/integrations/catalog/webflow.json +34 -0
- package/integrations/catalog/xero.json +33 -0
- package/integrations/catalog/zoom.json +33 -0
- package/integrations/catalog-index.js +151 -0
- package/integrations/catalog.schema.json +255 -0
- package/integrations/index.d.ts +49 -69
- package/integrations/index.js +36 -206
- package/marketplaces/openhands-extensions.json +40 -2
- package/package.json +4 -7
- package/plugins/cobol-modernization/README.md +3 -3
- package/plugins/cobol-modernization/skills/{cobol-modernization-overview → cobol-modernization}/SKILL.md +1 -1
- package/plugins/magic-test/skills/magic-word/SKILL.md +1 -0
- package/plugins/migration-scoring/README.md +1 -1
- package/plugins/pr-review/scripts/prompt.py +1 -1
- package/pyproject.toml +18 -3
- package/python/openhands_extensions/__init__.py +24 -0
- package/python/openhands_extensions/_version.py +25 -0
- package/python/openhands_extensions/integrations.py +92 -0
- package/release-please-config.json +2 -1
- package/scripts/build-integration-catalog.mjs +45 -0
- package/skills/agent-canvas-environment/.plugin/plugin.json +19 -0
- package/skills/agent-canvas-environment/README.md +5 -0
- package/skills/agent-canvas-environment/SKILL.md +202 -0
- package/skills/bitbucket/README.md +23 -40
- package/skills/bitbucket/SKILL.md +43 -31
- package/skills/bitbucket-cloud/.plugin/plugin.json +18 -0
- package/skills/bitbucket-cloud/README.md +15 -0
- package/skills/bitbucket-cloud/SKILL.md +46 -0
- package/skills/bitbucket-data-center/.plugin/plugin.json +19 -0
- package/skills/bitbucket-data-center/README.md +20 -0
- package/skills/bitbucket-data-center/SKILL.md +53 -0
- package/skills/code-review/SKILL.md +1 -1
- package/skills/code-review/references/supply-chain-security.md +2 -2
- package/skills/github-pr-reviewer/references/state-schema.md +18 -4
- package/skills/github-pr-reviewer/scripts/main.py +66 -12
- package/skills/github-repo-monitor/references/state-schema.md +11 -4
- package/skills/github-repo-monitor/scripts/main.py +67 -19
- package/skills/github-repo-monitor/tests/test_main.py +67 -8
- package/skills/index.js +34 -8
- package/skills/openhands-api/README.md +5 -1
- package/skills/openhands-api/SKILL.md +74 -5
- package/skills/openhands-automation/SKILL.md +3 -1
- package/skills/openhands-automation/references/custom-automation.md +166 -0
- package/skills/openhands-sdk/SKILL.md +8 -3
- package/skills/slack-channel-monitor/README.md +3 -3
- package/skills/slack-channel-monitor/SKILL.md +3 -2
- package/skills/slack-channel-monitor/references/state-schema.md +11 -4
- package/skills/slack-channel-monitor/scripts/main.py +111 -41
- package/tests/test_catalog_schema.py +47 -0
- package/tests/test_catalogs.py +47 -3
- package/tests/test_integration_catalog_in_sync.py +252 -0
- package/tests/test_slack_channel_monitor.py +142 -0
- package/tests/test_version_alignment.py +64 -0
- package/integrations/logos.d.ts +0 -5
- package/integrations/logos.js +0 -94
- package/integrations/oauth-provider-catalog.js +0 -553
- package/integrations/oauth-provider-registration-defaults.js +0 -610
- /package/plugins/cobol-modernization/skills/{mainfraime-removal → mainframe-removal}/SKILL.md +0 -0
- /package/plugins/cobol-modernization/skills/{mainfraime-removal → mainframe-removal}/references/cics-transformation-examples.md +0 -0
- /package/plugins/migration-scoring/skills/{migration-scoring-overview → migration-scoring}/SKILL.md +0 -0
|
@@ -40,11 +40,10 @@ import urllib.request
|
|
|
40
40
|
from datetime import datetime, timezone
|
|
41
41
|
from urllib.parse import urlencode
|
|
42
42
|
|
|
43
|
-
# ── Debug logging to a
|
|
43
|
+
# ── Debug logging to a per-run file ───────────────────────────────────────────
|
|
44
44
|
_DEBUG_LOG_PATH = os.path.join(
|
|
45
|
-
os.
|
|
46
|
-
|
|
47
|
-
"automation-state", "slack_poller_debug.log",
|
|
45
|
+
os.environ.get("WORKSPACE_BASE", "/tmp"),
|
|
46
|
+
"slack_poller_debug.log",
|
|
48
47
|
)
|
|
49
48
|
os.makedirs(os.path.dirname(_DEBUG_LOG_PATH), exist_ok=True)
|
|
50
49
|
_debug_log_fh = open(_DEBUG_LOG_PATH, "a")
|
|
@@ -198,14 +197,47 @@ def fire_callback(
|
|
|
198
197
|
print(f"Callback error (non-fatal): {exc}")
|
|
199
198
|
|
|
200
199
|
|
|
201
|
-
# ── State
|
|
200
|
+
# ── State persistence (KV store with local-file fallback) ─────────────────────
|
|
202
201
|
|
|
203
|
-
|
|
204
|
-
|
|
202
|
+
_KV_TOKEN = os.environ.get("AUTOMATION_KV_TOKEN", "")
|
|
203
|
+
_KV_BASE = os.environ.get("AUTOMATION_API_URL", "").rstrip("/")
|
|
204
|
+
_STATE_KEY = "state"
|
|
205
205
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
206
|
+
|
|
207
|
+
def _kv_available() -> bool:
|
|
208
|
+
return bool(_KV_TOKEN and _KV_BASE)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _kv_get(key: str) -> dict | None:
|
|
212
|
+
req = urllib.request.Request(
|
|
213
|
+
f"{_KV_BASE}/v1/kv/{key}",
|
|
214
|
+
headers={"Authorization": f"Bearer {_KV_TOKEN}"},
|
|
215
|
+
)
|
|
216
|
+
try:
|
|
217
|
+
with urllib.request.urlopen(req) as r:
|
|
218
|
+
return json.loads(r.read())["value"]
|
|
219
|
+
except urllib.error.HTTPError as exc:
|
|
220
|
+
if exc.code == 404:
|
|
221
|
+
return None
|
|
222
|
+
raise
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _kv_set(key: str, value: dict) -> None:
|
|
226
|
+
req = urllib.request.Request(
|
|
227
|
+
f"{_KV_BASE}/v1/kv/{key}",
|
|
228
|
+
data=json.dumps(value).encode(),
|
|
229
|
+
headers={
|
|
230
|
+
"Authorization": f"Bearer {_KV_TOKEN}",
|
|
231
|
+
"Content-Type": "application/json",
|
|
232
|
+
},
|
|
233
|
+
method="PUT",
|
|
234
|
+
)
|
|
235
|
+
with urllib.request.urlopen(req) as r:
|
|
236
|
+
r.read()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _state_file_path() -> str:
|
|
240
|
+
"""Derive a persistent storage path from WORKSPACE_BASE (file fallback only)."""
|
|
209
241
|
workspace_base = os.environ.get("WORKSPACE_BASE", "")
|
|
210
242
|
event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}"))
|
|
211
243
|
automation_id = event_payload.get("automation_id", "default")
|
|
@@ -220,22 +252,42 @@ def _state_file_path() -> str:
|
|
|
220
252
|
return os.path.join(state_dir, f"slack_poller_{automation_id}.json")
|
|
221
253
|
|
|
222
254
|
|
|
223
|
-
def
|
|
224
|
-
if os.path.exists(path):
|
|
225
|
-
return json.load(open(path))
|
|
255
|
+
def _default_state() -> dict:
|
|
226
256
|
return {
|
|
227
257
|
"version": 1,
|
|
228
258
|
"bot_user_id": None,
|
|
229
|
-
"last_poll": {},
|
|
230
|
-
"conversations": {},
|
|
231
|
-
"bot_message_ts": [],
|
|
232
|
-
"processed_ts": [],
|
|
259
|
+
"last_poll": {},
|
|
260
|
+
"conversations": {},
|
|
261
|
+
"bot_message_ts": [],
|
|
262
|
+
"processed_ts": [],
|
|
233
263
|
}
|
|
234
264
|
|
|
235
265
|
|
|
236
|
-
def
|
|
266
|
+
def load_state() -> dict:
|
|
267
|
+
if _kv_available():
|
|
268
|
+
data = _kv_get(_STATE_KEY)
|
|
269
|
+
if data is not None:
|
|
270
|
+
print("State loaded from KV store")
|
|
271
|
+
return data
|
|
272
|
+
return _default_state()
|
|
273
|
+
path = _state_file_path()
|
|
274
|
+
if os.path.exists(path):
|
|
275
|
+
try:
|
|
276
|
+
return json.load(open(path))
|
|
277
|
+
except Exception as exc:
|
|
278
|
+
print(f"Warning: state file {path} unreadable ({exc}); starting fresh")
|
|
279
|
+
return _default_state()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def save_state(state: dict) -> None:
|
|
283
|
+
if _kv_available():
|
|
284
|
+
_kv_set(_STATE_KEY, state)
|
|
285
|
+
print("State saved to KV store")
|
|
286
|
+
return
|
|
287
|
+
path = _state_file_path()
|
|
237
288
|
with open(path, "w") as f:
|
|
238
289
|
json.dump(state, f, indent=2)
|
|
290
|
+
print(f"State saved to {path}")
|
|
239
291
|
|
|
240
292
|
|
|
241
293
|
# ── Slack API helpers ──────────────────────────────────────────────────────────
|
|
@@ -311,7 +363,12 @@ def add_reaction(token: str, channel: str, ts: str, emoji: str = "eyes") -> None
|
|
|
311
363
|
|
|
312
364
|
def post_message(token: str, channel: str, text: str, thread_ts: str | None = None) -> str:
|
|
313
365
|
"""Post a Slack message and return its timestamp."""
|
|
314
|
-
body: dict = {
|
|
366
|
+
body: dict = {
|
|
367
|
+
"channel": channel,
|
|
368
|
+
"markdown_text": text,
|
|
369
|
+
"unfurl_links": False,
|
|
370
|
+
"unfurl_media": False,
|
|
371
|
+
}
|
|
315
372
|
if thread_ts:
|
|
316
373
|
body["thread_ts"] = thread_ts
|
|
317
374
|
return slack_post(token, "chat.postMessage", body).get("ts", "")
|
|
@@ -665,16 +722,19 @@ def _gather_channel_context(
|
|
|
665
722
|
return context_lines
|
|
666
723
|
|
|
667
724
|
|
|
668
|
-
def
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
725
|
+
def _close_thread_watch(rec: dict, conv_key: str, now: float) -> None:
|
|
726
|
+
rec["status"] = "closed"
|
|
727
|
+
rec["closed_reason"] = "followup_watch_expired"
|
|
728
|
+
rec["closed_at"] = now
|
|
729
|
+
print(f" Follow-up watch expired for {conv_key}")
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _next_reply_poll_at(rec: dict, now: float, delay: int) -> float:
|
|
733
|
+
next_poll_at = now + delay
|
|
734
|
+
watch_until = float(rec.get("watch_until") or 0)
|
|
735
|
+
if rec.get("status") == "watching" and watch_until:
|
|
736
|
+
next_poll_at = min(next_poll_at, watch_until)
|
|
737
|
+
return next_poll_at
|
|
678
738
|
|
|
679
739
|
|
|
680
740
|
def _poll_due_thread_replies(
|
|
@@ -684,12 +744,14 @@ def _poll_due_thread_replies(
|
|
|
684
744
|
bot_message_ts: list[str],
|
|
685
745
|
) -> list[tuple[str, dict]]:
|
|
686
746
|
now = time.time()
|
|
687
|
-
_expire_inactive_thread_watches(active_convs, now)
|
|
688
747
|
due: list[tuple[float, str, dict]] = []
|
|
689
748
|
for conv_key, rec in active_convs.items():
|
|
690
749
|
if rec.get("status") not in {"active", "watching"}:
|
|
691
750
|
continue
|
|
692
751
|
next_poll = float(rec.get("next_reply_poll_at") or 0)
|
|
752
|
+
watch_until = float(rec.get("watch_until") or 0)
|
|
753
|
+
if rec.get("status") == "watching" and watch_until and now >= watch_until:
|
|
754
|
+
next_poll = min(next_poll or watch_until, watch_until)
|
|
693
755
|
if next_poll <= now:
|
|
694
756
|
due.append((next_poll, conv_key, rec))
|
|
695
757
|
|
|
@@ -698,6 +760,8 @@ def _poll_due_thread_replies(
|
|
|
698
760
|
cid = rec["channel_id"]
|
|
699
761
|
thread_ts = rec["thread_ts"]
|
|
700
762
|
oldest = rec.get("last_seen_reply_ts") or thread_ts
|
|
763
|
+
watch_until = float(rec.get("watch_until") or 0)
|
|
764
|
+
watch_expired = rec.get("status") == "watching" and watch_until and now >= watch_until
|
|
701
765
|
try:
|
|
702
766
|
replies = thread_replies(slack_token, cid, thread_ts, oldest)
|
|
703
767
|
except Exception as exc:
|
|
@@ -710,11 +774,14 @@ def _poll_due_thread_replies(
|
|
|
710
774
|
retry_after = max(THREAD_REPLY_INITIAL_BACKOFF_SECONDS, int(raw))
|
|
711
775
|
except ValueError:
|
|
712
776
|
pass
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
777
|
+
if watch_expired:
|
|
778
|
+
_close_thread_watch(rec, conv_key, now)
|
|
779
|
+
else:
|
|
780
|
+
rec["next_reply_poll_at"] = _next_reply_poll_at(rec, now, retry_after)
|
|
781
|
+
rec["reply_poll_backoff_seconds"] = min(
|
|
782
|
+
THREAD_REPLY_MAX_BACKOFF_SECONDS,
|
|
783
|
+
max(retry_after, int(rec.get("reply_poll_backoff_seconds") or THREAD_REPLY_INITIAL_BACKOFF_SECONDS)),
|
|
784
|
+
)
|
|
718
785
|
print(f" Warning: could not fetch replies for thread {thread_ts}: {exc}")
|
|
719
786
|
continue
|
|
720
787
|
|
|
@@ -733,6 +800,10 @@ def _poll_due_thread_replies(
|
|
|
733
800
|
for r in triggered_replies:
|
|
734
801
|
reply_messages.append((cid, r))
|
|
735
802
|
print(f" {conv_key}: {len(triggered_replies)} triggered follow-up reply/replies")
|
|
803
|
+
elif watch_expired:
|
|
804
|
+
if human_replies:
|
|
805
|
+
print(f" {conv_key}: ignored {len(human_replies)} follow-up reply/replies without trigger")
|
|
806
|
+
_close_thread_watch(rec, conv_key, now)
|
|
736
807
|
else:
|
|
737
808
|
if human_replies:
|
|
738
809
|
print(f" {conv_key}: ignored {len(human_replies)} follow-up reply/replies without trigger")
|
|
@@ -742,8 +813,9 @@ def _poll_due_thread_replies(
|
|
|
742
813
|
max(THREAD_REPLY_INITIAL_BACKOFF_SECONDS, current * THREAD_REPLY_BACKOFF_MULTIPLIER),
|
|
743
814
|
)
|
|
744
815
|
rec["reply_poll_backoff_seconds"] = next_backoff
|
|
745
|
-
rec["next_reply_poll_at"] = now
|
|
746
|
-
|
|
816
|
+
rec["next_reply_poll_at"] = _next_reply_poll_at(rec, now, next_backoff)
|
|
817
|
+
next_delay = max(0, int(rec["next_reply_poll_at"] - now))
|
|
818
|
+
print(f" {conv_key}: no follow-ups; next reply poll in {next_delay}s")
|
|
747
819
|
|
|
748
820
|
return reply_messages
|
|
749
821
|
|
|
@@ -951,8 +1023,7 @@ def _check_conversation_completion(
|
|
|
951
1023
|
|
|
952
1024
|
def main() -> str | None:
|
|
953
1025
|
"""Run one polling cycle. Returns the last conversation ID created, if any."""
|
|
954
|
-
|
|
955
|
-
state = load_state(state_path)
|
|
1026
|
+
state = load_state()
|
|
956
1027
|
|
|
957
1028
|
agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
|
|
958
1029
|
api_key = _get_env_key()
|
|
@@ -1139,8 +1210,7 @@ def main() -> str | None:
|
|
|
1139
1210
|
state["processed_ts"] = processed_list[-MAX_PROCESSED_TS:]
|
|
1140
1211
|
|
|
1141
1212
|
state["conversations"] = active_convs
|
|
1142
|
-
save_state(
|
|
1143
|
-
print(f"State saved to {state_path}")
|
|
1213
|
+
save_state(state)
|
|
1144
1214
|
return last_conversation_id
|
|
1145
1215
|
|
|
1146
1216
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Strict JSON-Schema validation for integrations/catalog/*.json.
|
|
2
|
+
|
|
3
|
+
Every catalog entry must validate against integrations/catalog.schema.json.
|
|
4
|
+
The schema encodes the catalog contract from integrations/index.d.ts plus the
|
|
5
|
+
cross-field connectability rules (e.g. an oauth2 HTTP option must carry an
|
|
6
|
+
authorizationUrl + tokenUrl; an HTTP option must ship an openApiUrl) so the
|
|
7
|
+
class of "entry looks shaped but cannot actually be connected" regressions the
|
|
8
|
+
AI reviewer kept catching is now caught in CI.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from jsonschema import Draft202012Validator
|
|
16
|
+
|
|
17
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
18
|
+
SCHEMA_PATH = ROOT / "integrations" / "catalog.schema.json"
|
|
19
|
+
CATALOG_DIR = ROOT / "integrations" / "catalog"
|
|
20
|
+
|
|
21
|
+
_SCHEMA = json.loads(SCHEMA_PATH.read_text())
|
|
22
|
+
VALIDATOR = Draft202012Validator(_SCHEMA)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _catalog_entries():
|
|
26
|
+
for entry_path in sorted(CATALOG_DIR.glob("*.json")):
|
|
27
|
+
yield pytest.param(
|
|
28
|
+
entry_path,
|
|
29
|
+
id=entry_path.stem,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.parametrize("entry_path", list(_catalog_entries()))
|
|
34
|
+
def test_catalog_entry_validates_against_schema(entry_path: Path) -> None:
|
|
35
|
+
entry = json.loads(entry_path.read_text())
|
|
36
|
+
errors = sorted(VALIDATOR.iter_errors(entry), key=lambda e: list(e.path))
|
|
37
|
+
if errors:
|
|
38
|
+
rendered = "\n".join(
|
|
39
|
+
f" - at {'/'.join(map(str, e.path)) or '<root>'}: {e.message}"
|
|
40
|
+
for e in errors
|
|
41
|
+
)
|
|
42
|
+
pytest.fail(f"{entry_path.stem} failed schema validation:\n{rendered}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_schema_file_is_valid_draft_2020_12() -> None:
|
|
46
|
+
"""The schema itself must be a valid Draft 2020-12 document."""
|
|
47
|
+
Draft202012Validator.check_schema(_SCHEMA)
|
package/tests/test_catalogs.py
CHANGED
|
@@ -40,10 +40,10 @@ def test_catalog_entries_have_required_fields():
|
|
|
40
40
|
assert entry["id"]
|
|
41
41
|
assert entry["name"]
|
|
42
42
|
assert entry["description"]
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
# iconBg/iconColor are optional UI styling hints (OAuth-only entries may
|
|
44
|
+
# ship without a bespoke icon background).
|
|
45
|
+
assert "iconBg" not in entry or entry["iconBg"]
|
|
45
46
|
assert entry["connectionOptions"]
|
|
46
|
-
assert entry["defaultConnectionOptionId"]
|
|
47
47
|
for option in entry["connectionOptions"]:
|
|
48
48
|
assert option["id"]
|
|
49
49
|
assert option["provider"] in {"mcp", "http"}
|
|
@@ -72,6 +72,24 @@ def test_catalog_entries_have_required_fields():
|
|
|
72
72
|
assert isinstance(entry["estimatedSetupMinutes"], int)
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def test_remote_no_auth_mcp_entries_are_intentionally_public():
|
|
76
|
+
public_remote_mcp_ids = {"cloudflare-docs", "deepwiki", "huggingface"}
|
|
77
|
+
|
|
78
|
+
actual = set()
|
|
79
|
+
for entry in load_catalog_entries("integrations/catalog"):
|
|
80
|
+
for option in entry["connectionOptions"]:
|
|
81
|
+
transport = option.get("transport", {})
|
|
82
|
+
if (
|
|
83
|
+
option["provider"] == "mcp"
|
|
84
|
+
and option["auth"]["strategy"] == "none"
|
|
85
|
+
and transport.get("url", "").startswith("https://")
|
|
86
|
+
):
|
|
87
|
+
actual.add(entry["id"])
|
|
88
|
+
assert transport["kind"] == "shttp"
|
|
89
|
+
|
|
90
|
+
assert actual == public_remote_mcp_ids
|
|
91
|
+
|
|
92
|
+
|
|
75
93
|
def test_credential_fields_have_helper_text_and_link():
|
|
76
94
|
"""All password fields must have helperText plus a link (either a helperLink field or a
|
|
77
95
|
markdown link embedded in helperText) so users know how to get credentials."""
|
|
@@ -107,3 +125,29 @@ def test_node_package_exports_catalogs():
|
|
|
107
125
|
if (!AUTOMATION_CATALOG.some((entry) => entry.id === 'github-pr-reviewer')) process.exit(1);
|
|
108
126
|
"""
|
|
109
127
|
subprocess.run(["node", "--input-type=module", "-e", script], cwd=ROOT, check=True)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_no_default_tool_in_catalog():
|
|
131
|
+
"""The defaultTool field was removed; tools now come from MCP tools/list or
|
|
132
|
+
HTTP OpenAPI regeneration. No connection option may carry it."""
|
|
133
|
+
offenders = []
|
|
134
|
+
for entry in load_catalog_entries("integrations/catalog"):
|
|
135
|
+
for option in entry["connectionOptions"]:
|
|
136
|
+
http_cfg = option.get("http", {})
|
|
137
|
+
if "defaultTool" in http_cfg:
|
|
138
|
+
offenders.append(f"{entry['id']}.{option['id']}")
|
|
139
|
+
assert not offenders, f"defaultTool still present in: {offenders}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_http_connectors_have_openapi_url():
|
|
143
|
+
"""An HTTP connector without an openApiUrl has no tool-discovery path once
|
|
144
|
+
defaultTool is gone, so every HTTP option must ship an openApiUrl."""
|
|
145
|
+
offenders = []
|
|
146
|
+
for entry in load_catalog_entries("integrations/catalog"):
|
|
147
|
+
for option in entry["connectionOptions"]:
|
|
148
|
+
if option["provider"] == "http":
|
|
149
|
+
http_cfg = option.get("http", {})
|
|
150
|
+
url = http_cfg.get("openApiUrl")
|
|
151
|
+
if not url or not url.startswith("https://"):
|
|
152
|
+
offenders.append(f"{entry['id']}.{option['id']}")
|
|
153
|
+
assert not offenders, f"HTTP options missing openApiUrl: {offenders}"
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Assert integration catalog files drive the generated JS/Python assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import openhands_extensions
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
12
|
+
CATALOG_DIR = ROOT / "integrations" / "catalog"
|
|
13
|
+
CATALOG_INDEX = ROOT / "integrations" / "catalog-index.js"
|
|
14
|
+
AGGREGATE_ASSETS = [
|
|
15
|
+
ROOT / "integrations" / "integration-catalog.json",
|
|
16
|
+
ROOT / "python" / "openhands_extensions" / "integration-catalog.json",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _catalog_files() -> dict[str, dict]:
|
|
21
|
+
return {p.stem: json.loads(p.read_text()) for p in CATALOG_DIR.glob("*.json")}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _catalog_entries() -> list[dict]:
|
|
25
|
+
entries = list(_catalog_files().values())
|
|
26
|
+
return sorted(entries, key=lambda entry: (-(entry.get("popularityRank") if entry.get("popularityRank") is not None else -1), entry["id"]))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _supports_mcp(entry: dict) -> bool:
|
|
30
|
+
return any(option.get("provider") == "mcp" for option in entry["connectionOptions"])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _supports_oauth(entry: dict) -> bool:
|
|
34
|
+
return any(option.get("auth", {}).get("strategy") == "oauth2" for option in entry["connectionOptions"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_catalog_directory_is_hand_authored_source_of_truth() -> None:
|
|
39
|
+
for integration_id, entry in _catalog_files().items():
|
|
40
|
+
assert entry["id"] == integration_id
|
|
41
|
+
assert "supportsMcp" not in entry
|
|
42
|
+
assert "supportsOauth" not in entry
|
|
43
|
+
assert "oauthProvider" not in entry
|
|
44
|
+
assert "registrationDefaults" not in entry
|
|
45
|
+
assert "managedConnectorSlug" not in entry
|
|
46
|
+
assert "authStrategy" not in entry
|
|
47
|
+
assert "defaultConnectionOptionId" not in entry
|
|
48
|
+
assert "kind" not in entry
|
|
49
|
+
assert "catalogStatus" not in entry
|
|
50
|
+
assert "availability" not in entry
|
|
51
|
+
assert "runtimeAvailability" not in entry
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_no_aggregate_catalog_json_exists() -> None:
|
|
55
|
+
for asset in AGGREGATE_ASSETS:
|
|
56
|
+
assert not asset.exists()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_generated_js_index_references_catalog_directory() -> None:
|
|
60
|
+
body = CATALOG_INDEX.read_text()
|
|
61
|
+
for file in sorted(path.name for path in CATALOG_DIR.glob("*.json")):
|
|
62
|
+
assert f"./catalog/{file}" in body
|
|
63
|
+
assert "integration-catalog.json" not in body
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_python_snapshot_is_built_from_catalog_directory() -> None:
|
|
67
|
+
assert openhands_extensions.INTEGRATION_CATALOG_SNAPSHOT == {
|
|
68
|
+
"integrations": _catalog_entries()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_python_list_integration_catalog_returns_raw_entries() -> None:
|
|
73
|
+
entries = openhands_extensions.list_integration_catalog()
|
|
74
|
+
assert entries == _catalog_entries()
|
|
75
|
+
for entry in entries:
|
|
76
|
+
assert "supportsMcp" not in entry
|
|
77
|
+
assert "supportsOauth" not in entry
|
|
78
|
+
assert "registrationDefaults" not in entry
|
|
79
|
+
assert "managedConnectorSlug" not in entry
|
|
80
|
+
assert "authStrategy" not in entry
|
|
81
|
+
assert "defaultConnectionOptionId" not in entry
|
|
82
|
+
assert "catalogStatus" not in entry
|
|
83
|
+
assert "availability" not in entry
|
|
84
|
+
assert "runtimeAvailability" not in entry
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_logo_metadata_is_serializable_and_language_agnostic() -> None:
|
|
88
|
+
entries = openhands_extensions.list_integration_catalog()
|
|
89
|
+
with_logo = [entry for entry in entries if entry.get("logoUrl")]
|
|
90
|
+
assert with_logo
|
|
91
|
+
assert any(entry["id"].startswith("cloudflare-") for entry in with_logo)
|
|
92
|
+
for entry in with_logo:
|
|
93
|
+
assert isinstance(entry["logoUrl"], str)
|
|
94
|
+
assert entry["logoUrl"].startswith("https://")
|
|
95
|
+
assert "react" not in entry["logoUrl"].lower()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_get_integration_catalog_entry_round_trip() -> None:
|
|
99
|
+
sample_id = openhands_extensions.list_integration_catalog()[0]["id"]
|
|
100
|
+
sample = openhands_extensions.get_integration_catalog_entry(sample_id)
|
|
101
|
+
assert sample is not None
|
|
102
|
+
assert sample == next(
|
|
103
|
+
entry
|
|
104
|
+
for entry in openhands_extensions.list_integration_catalog()
|
|
105
|
+
if entry["id"] == sample_id
|
|
106
|
+
)
|
|
107
|
+
assert openhands_extensions.get_integration_catalog_entry("nope") is None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _js_call(expr: str) -> str:
|
|
111
|
+
result = subprocess.run(
|
|
112
|
+
["node", "--input-type=module", "-e", expr],
|
|
113
|
+
capture_output=True,
|
|
114
|
+
text=True,
|
|
115
|
+
check=True,
|
|
116
|
+
cwd=ROOT,
|
|
117
|
+
)
|
|
118
|
+
return result.stdout
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_js_reads_the_same_catalog_as_python() -> None:
|
|
122
|
+
integrations = _js_call(
|
|
123
|
+
"import { listIntegrationCatalog } from './integrations/index.js';\n"
|
|
124
|
+
"process.stdout.write(JSON.stringify(listIntegrationCatalog()));"
|
|
125
|
+
)
|
|
126
|
+
assert json.loads(integrations) == openhands_extensions.list_integration_catalog()
|
|
127
|
+
|
|
128
|
+
sample_id = openhands_extensions.list_integration_catalog()[0]["id"]
|
|
129
|
+
sample = _js_call(
|
|
130
|
+
"import { getIntegrationCatalogEntry } from './integrations/index.js';\n"
|
|
131
|
+
f"process.stdout.write(JSON.stringify(getIntegrationCatalogEntry({json.dumps(sample_id)})));"
|
|
132
|
+
)
|
|
133
|
+
assert json.loads(sample) == openhands_extensions.get_integration_catalog_entry(sample_id)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _js_filter(mcp, oauth) -> list[str]:
|
|
137
|
+
expr = (
|
|
138
|
+
"import { listIntegrationCatalog } from './integrations/index.js';\n"
|
|
139
|
+
f"const f = listIntegrationCatalog({{ mcp: {mcp}, oauth: {oauth} }});\n"
|
|
140
|
+
"process.stdout.write(JSON.stringify(f.map((e) => e.id)));"
|
|
141
|
+
)
|
|
142
|
+
return json.loads(_js_call(expr))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_filter_mcp_only() -> None:
|
|
146
|
+
py_ids = {e["id"] for e in openhands_extensions.list_integration_catalog(mcp=True)}
|
|
147
|
+
js_ids = set(_js_filter("true", "undefined"))
|
|
148
|
+
all_mcp = {
|
|
149
|
+
e["id"]
|
|
150
|
+
for e in openhands_extensions.list_integration_catalog()
|
|
151
|
+
if _supports_mcp(e)
|
|
152
|
+
}
|
|
153
|
+
assert py_ids == js_ids == all_mcp
|
|
154
|
+
assert "filesystem" in py_ids
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_filter_oauth_only() -> None:
|
|
158
|
+
py_ids = {e["id"] for e in openhands_extensions.list_integration_catalog(oauth=True)}
|
|
159
|
+
js_ids = set(_js_filter("undefined", "true"))
|
|
160
|
+
all_oauth = {
|
|
161
|
+
e["id"]
|
|
162
|
+
for e in openhands_extensions.list_integration_catalog()
|
|
163
|
+
if _supports_oauth(e)
|
|
164
|
+
}
|
|
165
|
+
assert py_ids == js_ids == all_oauth
|
|
166
|
+
assert py_ids
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_filter_oauth_not_mcp() -> None:
|
|
170
|
+
py_ids = {
|
|
171
|
+
e["id"] for e in openhands_extensions.list_integration_catalog(oauth=True, mcp=False)
|
|
172
|
+
}
|
|
173
|
+
js_ids = set(_js_filter("false", "true"))
|
|
174
|
+
expected = {
|
|
175
|
+
e["id"]
|
|
176
|
+
for e in openhands_extensions.list_integration_catalog()
|
|
177
|
+
if _supports_oauth(e) and not _supports_mcp(e)
|
|
178
|
+
}
|
|
179
|
+
assert py_ids == js_ids == expected
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_filter_none_returns_all() -> None:
|
|
183
|
+
assert len(openhands_extensions.list_integration_catalog()) == len(
|
|
184
|
+
openhands_extensions.list_integration_catalog(mcp=None, oauth=None)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_accessors_return_independent_copies() -> None:
|
|
189
|
+
catalog_a = openhands_extensions.list_integration_catalog()
|
|
190
|
+
catalog_b = openhands_extensions.list_integration_catalog()
|
|
191
|
+
assert catalog_a == catalog_b
|
|
192
|
+
assert catalog_a is not catalog_b
|
|
193
|
+
catalog_a[0]["__mutated"] = True
|
|
194
|
+
assert "__mutated" not in openhands_extensions.list_integration_catalog()[0]
|
|
195
|
+
|
|
196
|
+
sample_id = openhands_extensions.list_integration_catalog()[0]["id"]
|
|
197
|
+
sample = openhands_extensions.get_integration_catalog_entry(sample_id)
|
|
198
|
+
assert sample is not None
|
|
199
|
+
sample["__mutated"] = True
|
|
200
|
+
assert "__mutated" not in openhands_extensions.get_integration_catalog_entry(sample_id)
|
|
201
|
+
|
|
202
|
+
snapshot = openhands_extensions.INTEGRATION_CATALOG_SNAPSHOT
|
|
203
|
+
snapshot["integrations"][0]["__mutated"] = True
|
|
204
|
+
assert "__mutated" not in openhands_extensions.list_integration_catalog()[0]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_js_accessors_return_independent_copies() -> None:
|
|
208
|
+
sample_id = openhands_extensions.list_integration_catalog()[0]["id"]
|
|
209
|
+
expr = f"""
|
|
210
|
+
import {{
|
|
211
|
+
INTEGRATION_CATALOG,
|
|
212
|
+
getIntegrationCatalogEntry,
|
|
213
|
+
listIntegrationCatalog,
|
|
214
|
+
}} from './integrations/index.js';
|
|
215
|
+
const sampleId = {json.dumps(sample_id)};
|
|
216
|
+
const first = listIntegrationCatalog();
|
|
217
|
+
const second = listIntegrationCatalog();
|
|
218
|
+
first[0].__mutated = true;
|
|
219
|
+
const entry = getIntegrationCatalogEntry(sampleId);
|
|
220
|
+
entry.__mutated = true;
|
|
221
|
+
INTEGRATION_CATALOG[0].__mutated = true;
|
|
222
|
+
process.stdout.write(JSON.stringify({{
|
|
223
|
+
listsAreIndependent:
|
|
224
|
+
!('__mutated' in second[0]) &&
|
|
225
|
+
!('__mutated' in listIntegrationCatalog()[0]),
|
|
226
|
+
entryIsIndependent: !('__mutated' in getIntegrationCatalogEntry(sampleId)),
|
|
227
|
+
}}));
|
|
228
|
+
"""
|
|
229
|
+
result = json.loads(_js_call(expr))
|
|
230
|
+
assert result == {"listsAreIndependent": True, "entryIsIndependent": True}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_oauth_config_lives_on_connection_options() -> None:
|
|
234
|
+
oauth_entries = [
|
|
235
|
+
entry
|
|
236
|
+
for entry in openhands_extensions.list_integration_catalog()
|
|
237
|
+
if _supports_oauth(entry)
|
|
238
|
+
]
|
|
239
|
+
assert oauth_entries
|
|
240
|
+
for entry in oauth_entries:
|
|
241
|
+
assert "registrationDefaults" not in entry
|
|
242
|
+
oauth_options = [
|
|
243
|
+
option
|
|
244
|
+
for option in entry["connectionOptions"]
|
|
245
|
+
if option.get("auth", {}).get("strategy") == "oauth2"
|
|
246
|
+
]
|
|
247
|
+
assert oauth_options
|
|
248
|
+
for option in oauth_options:
|
|
249
|
+
assert "registrationDefaults" not in option
|
|
250
|
+
oauth = option["auth"].get("oauth")
|
|
251
|
+
if oauth is not None:
|
|
252
|
+
assert isinstance(oauth, dict)
|