@openhands/extensions 0.11.0 → 0.12.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 (42) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/AGENTS.md +2 -1
  3. package/MIGRATION.md +31 -0
  4. package/README.md +9 -7
  5. package/automations/README.md +251 -8
  6. package/automations/catalog/github-pr-reviewer/manifest.json +82 -0
  7. package/automations/catalog/github-repo-monitor/manifest.json +82 -0
  8. package/automations/catalog/incident-retrospective-drafter/manifest.json +100 -0
  9. package/automations/catalog/{jira-issue-to-pr.json → jira-issue-to-pr/manifest.json} +10 -5
  10. package/automations/catalog/{linear-triage-assistant.json → linear-triage-assistant/manifest.json} +8 -4
  11. package/automations/catalog/{research-brief-writer.json → research-brief-writer/manifest.json} +11 -5
  12. package/automations/catalog/{slack-channel-monitor.json → slack-channel-monitor/manifest.json} +7 -2
  13. package/automations/catalog/{slack-standup-digest.json → slack-standup-digest/manifest.json} +8 -5
  14. package/automations/catalog-index.js +23 -0
  15. package/automations/catalog.schema.json +273 -0
  16. package/automations/index.d.ts +111 -3
  17. package/automations/index.js +21 -18
  18. package/index.d.ts +16 -1
  19. package/integrations/catalog/datadog.json +3 -3
  20. package/integrations/index.d.ts +1 -1
  21. package/package.json +6 -3
  22. package/pyproject.toml +1 -1
  23. package/python/openhands_extensions/__init__.py +0 -4
  24. package/python/openhands_extensions/_version.py +3 -2
  25. package/python/openhands_extensions/integrations.py +8 -30
  26. package/scripts/build-automation-catalog.mjs +51 -0
  27. package/skills/bitbucket/SKILL.md +0 -1
  28. package/skills/github/SKILL.md +0 -1
  29. package/skills/gitlab/SKILL.md +0 -1
  30. package/skills/index.js +4 -7
  31. package/skills/openhands-sdk/SKILL.md +2 -0
  32. package/tests/fixtures/automations/capabilities.json +92 -0
  33. package/tests/fixtures/automations/github-pr-reviewer.json +251 -0
  34. package/tests/fixtures/automations/github-repo-monitor.json +180 -0
  35. package/tests/fixtures/automations/incident-retrospective-drafter.json +91 -0
  36. package/tests/test_automation_setup.py +526 -0
  37. package/tests/test_catalogs.py +37 -12
  38. package/tests/test_deprecations.py +39 -4
  39. package/tests/test_integration_catalog_in_sync.py +50 -60
  40. package/automations/catalog/github-pr-reviewer.json +0 -13
  41. package/automations/catalog/github-repo-monitor.json +0 -11
  42. package/automations/catalog/incident-retrospective-drafter.json +0 -15
@@ -39,6 +39,19 @@ def _catalog_entries() -> list[dict]:
39
39
  )
40
40
 
41
41
 
42
+ def _python_entries(
43
+ mcp: bool | None = None,
44
+ oauth: bool | None = None,
45
+ ) -> list[dict]:
46
+ """Catalog entries as seen through the supported Python model API."""
47
+ return [
48
+ model.model_dump(exclude_none=True)
49
+ for model in openhands_extensions.list_integration_catalog_models(
50
+ mcp=mcp, oauth=oauth
51
+ )
52
+ ]
53
+
54
+
42
55
  def _supports_mcp(entry: dict) -> bool:
43
56
  return any(option.get("provider") == "mcp" for option in entry["connectionOptions"])
44
57
 
@@ -84,8 +97,8 @@ def test_python_snapshot_is_built_from_catalog_directory() -> None:
84
97
  }
85
98
 
86
99
 
87
- def test_python_list_integration_catalog_returns_raw_entries() -> None:
88
- entries = openhands_extensions.list_integration_catalog()
100
+ def test_python_catalog_models_expose_no_derived_fields() -> None:
101
+ entries = _python_entries()
89
102
  assert entries == _catalog_entries()
90
103
  for entry in entries:
91
104
  assert "supportsMcp" not in entry
@@ -99,8 +112,14 @@ def test_python_list_integration_catalog_returns_raw_entries() -> None:
99
112
  assert "runtimeAvailability" not in entry
100
113
 
101
114
 
115
+ def test_raw_dict_accessors_were_removed_in_0_12_0() -> None:
116
+ # Deprecated in 0.10.0, removed in 0.12.0. The typed model API replaces them.
117
+ assert not hasattr(openhands_extensions, "list_integration_catalog")
118
+ assert not hasattr(openhands_extensions, "get_integration_catalog_entry")
119
+
120
+
102
121
  def test_python_list_integration_catalog_models_returns_typed_entries() -> None:
103
- entries = openhands_extensions.list_integration_catalog()
122
+ entries = _catalog_entries()
104
123
  models = openhands_extensions.list_integration_catalog_models()
105
124
 
106
125
  assert [model.model_dump(exclude_none=True) for model in models] == entries
@@ -189,7 +208,7 @@ def test_catalog_models_require_connectability_and_install_metadata() -> None:
189
208
 
190
209
 
191
210
  def test_logo_metadata_is_serializable_and_language_agnostic() -> None:
192
- entries = openhands_extensions.list_integration_catalog()
211
+ entries = _python_entries()
193
212
  with_logo = [entry for entry in entries if entry.get("logoUrl")]
194
213
  assert with_logo
195
214
  assert any(entry["id"].startswith("cloudflare-") for entry in with_logo)
@@ -199,16 +218,14 @@ def test_logo_metadata_is_serializable_and_language_agnostic() -> None:
199
218
  assert "react" not in entry["logoUrl"].lower()
200
219
 
201
220
 
202
- def test_get_integration_catalog_entry_round_trip() -> None:
203
- sample_id = openhands_extensions.list_integration_catalog()[0]["id"]
204
- sample = openhands_extensions.get_integration_catalog_entry(sample_id)
221
+ def test_get_integration_catalog_entry_model_round_trip() -> None:
222
+ sample_id = _python_entries()[0]["id"]
223
+ sample = openhands_extensions.get_integration_catalog_entry_model(sample_id)
205
224
  assert sample is not None
206
- assert sample == next(
207
- entry
208
- for entry in openhands_extensions.list_integration_catalog()
209
- if entry["id"] == sample_id
225
+ assert sample.model_dump(exclude_none=True) == next(
226
+ entry for entry in _python_entries() if entry["id"] == sample_id
210
227
  )
211
- assert openhands_extensions.get_integration_catalog_entry("nope") is None
228
+ assert openhands_extensions.get_integration_catalog_entry_model("nope") is None
212
229
 
213
230
 
214
231
  def _js_call(expr: str) -> str:
@@ -227,16 +244,16 @@ def test_js_reads_the_same_catalog_as_python() -> None:
227
244
  "import { listIntegrationCatalog } from './integrations/index.js';\n"
228
245
  "process.stdout.write(JSON.stringify(listIntegrationCatalog()));"
229
246
  )
230
- assert json.loads(integrations) == openhands_extensions.list_integration_catalog()
247
+ assert json.loads(integrations) == _python_entries()
231
248
 
232
- sample_id = openhands_extensions.list_integration_catalog()[0]["id"]
249
+ sample_id = _python_entries()[0]["id"]
233
250
  sample = _js_call(
234
251
  "import { getIntegrationCatalogEntry } from './integrations/index.js';\n"
235
252
  f"process.stdout.write(JSON.stringify(getIntegrationCatalogEntry({json.dumps(sample_id)})));"
236
253
  )
237
- assert json.loads(sample) == openhands_extensions.get_integration_catalog_entry(
238
- sample_id
239
- )
254
+ entry_model = openhands_extensions.get_integration_catalog_entry_model(sample_id)
255
+ assert entry_model is not None
256
+ assert json.loads(sample) == entry_model.model_dump(exclude_none=True)
240
257
 
241
258
 
242
259
  def _js_filter(mcp, oauth) -> list[str]:
@@ -249,74 +266,51 @@ def _js_filter(mcp, oauth) -> list[str]:
249
266
 
250
267
 
251
268
  def test_filter_mcp_only() -> None:
252
- py_ids = {e["id"] for e in openhands_extensions.list_integration_catalog(mcp=True)}
269
+ py_ids = {e["id"] for e in _python_entries(mcp=True)}
253
270
  js_ids = set(_js_filter("true", "undefined"))
254
- all_mcp = {
255
- e["id"]
256
- for e in openhands_extensions.list_integration_catalog()
257
- if _supports_mcp(e)
258
- }
271
+ all_mcp = {e["id"] for e in _python_entries() if _supports_mcp(e)}
259
272
  assert py_ids == js_ids == all_mcp
260
273
  assert "filesystem" in py_ids
261
274
 
262
275
 
263
276
  def test_filter_oauth_only() -> None:
264
- py_ids = {
265
- e["id"] for e in openhands_extensions.list_integration_catalog(oauth=True)
266
- }
277
+ py_ids = {e["id"] for e in _python_entries(oauth=True)}
267
278
  js_ids = set(_js_filter("undefined", "true"))
268
- all_oauth = {
269
- e["id"]
270
- for e in openhands_extensions.list_integration_catalog()
271
- if _supports_oauth(e)
272
- }
279
+ all_oauth = {e["id"] for e in _python_entries() if _supports_oauth(e)}
273
280
  assert py_ids == js_ids == all_oauth
274
281
  assert py_ids
275
282
 
276
283
 
277
284
  def test_filter_oauth_not_mcp() -> None:
278
- py_ids = {
279
- e["id"]
280
- for e in openhands_extensions.list_integration_catalog(oauth=True, mcp=False)
281
- }
285
+ py_ids = {e["id"] for e in _python_entries(oauth=True, mcp=False)}
282
286
  js_ids = set(_js_filter("false", "true"))
283
287
  expected = {
284
288
  e["id"]
285
- for e in openhands_extensions.list_integration_catalog()
289
+ for e in _python_entries()
286
290
  if _supports_oauth(e) and not _supports_mcp(e)
287
291
  }
288
292
  assert py_ids == js_ids == expected
289
293
 
290
294
 
291
295
  def test_filter_none_returns_all() -> None:
292
- assert len(openhands_extensions.list_integration_catalog()) == len(
293
- openhands_extensions.list_integration_catalog(mcp=None, oauth=None)
294
- )
296
+ assert len(_python_entries()) == len(_python_entries(mcp=None, oauth=None))
295
297
 
296
298
 
297
299
  def test_accessors_return_independent_copies() -> None:
298
- catalog_a = openhands_extensions.list_integration_catalog()
299
- catalog_b = openhands_extensions.list_integration_catalog()
300
- assert catalog_a == catalog_b
301
- assert catalog_a is not catalog_b
302
- catalog_a[0]["__mutated"] = True
303
- assert "__mutated" not in openhands_extensions.list_integration_catalog()[0]
304
-
305
- sample_id = openhands_extensions.list_integration_catalog()[0]["id"]
306
- sample = openhands_extensions.get_integration_catalog_entry(sample_id)
307
- assert sample is not None
308
- sample["__mutated"] = True
309
- assert "__mutated" not in openhands_extensions.get_integration_catalog_entry(
310
- sample_id
311
- )
300
+ models_a = openhands_extensions.list_integration_catalog_models()
301
+ models_b = openhands_extensions.list_integration_catalog_models()
302
+ assert models_a == models_b
303
+ assert models_a[0] is not models_b[0]
312
304
 
305
+ # Mutating the exported snapshot must not leak into the cached catalog.
306
+ # The models forbid extra fields, so a leak surfaces as a validation error.
313
307
  snapshot = openhands_extensions.INTEGRATION_CATALOG_SNAPSHOT
314
308
  snapshot["integrations"][0]["__mutated"] = True
315
- assert "__mutated" not in openhands_extensions.list_integration_catalog()[0]
309
+ assert "__mutated" not in _python_entries()[0]
316
310
 
317
311
 
318
312
  def test_js_accessors_return_independent_copies() -> None:
319
- sample_id = openhands_extensions.list_integration_catalog()[0]["id"]
313
+ sample_id = _python_entries()[0]["id"]
320
314
  expr = f"""
321
315
  import {{
322
316
  INTEGRATION_CATALOG,
@@ -342,11 +336,7 @@ def test_js_accessors_return_independent_copies() -> None:
342
336
 
343
337
 
344
338
  def test_oauth_config_lives_on_connection_options() -> None:
345
- oauth_entries = [
346
- entry
347
- for entry in openhands_extensions.list_integration_catalog()
348
- if _supports_oauth(entry)
349
- ]
339
+ oauth_entries = [entry for entry in _python_entries() if _supports_oauth(entry)]
350
340
  assert oauth_entries
351
341
  for entry in oauth_entries:
352
342
  assert "registrationDefaults" not in entry
@@ -1,13 +0,0 @@
1
- {
2
- "id": "github-pr-reviewer",
3
- "name": "GitHub Code Review Agent",
4
- "category": "Code review",
5
- "description": "Watch for a configurable label on GitHub pull requests, inspect full PR and repository context, and post an AI review comment once per label event.",
6
- "requiredIntegrationIds": [
7
- "github"
8
- ],
9
- "popularityRank": 100,
10
- "estimatedSetupMinutes": 4,
11
- "prompt": "/pr-reviewer:setup",
12
- "exampleImplementation": "Trigger: cron polling for open GitHub PRs with a configured label such as openhands-review\nRequired secret: GITHUB_PERSONAL_ACCESS_TOKEN\n\n1. Read repository, trigger label, review tone, and polling schedule from setup.\n2. List open PRs and find the latest matching GitHub labeled issue event for each labeled PR.\n3. Deduplicate on the label event ID so every label application queues exactly one review.\n4. Start an OpenHands conversation that clones the repo, checks out the exact PR head SHA, and inspects PR discussion, review comments, changed files, diff, and surrounding code.\n5. Post an acknowledgement with the conversation link, then post the final AI review comment only if the PR is still open and the head SHA has not changed."
13
- }
@@ -1,11 +0,0 @@
1
- {
2
- "id": "github-repo-monitor",
3
- "name": "GitHub repository monitor",
4
- "category": "Developer tools",
5
- "description": "Watch a repository for @OpenHands mentions in issues and PR comments, start a conversation, and post the agent's reply back to GitHub.",
6
- "requiredIntegrationIds": ["github"],
7
- "popularityRank": 98,
8
- "estimatedSetupMinutes": 5,
9
- "prompt": "/github-monitor:poll",
10
- "exampleImplementation": "Trigger: cron, every minute (configurable)\nRequired secret: GITHUB_PERSONAL_ACCESS_TOKEN\n\n1. Poll GitHub for new issue and PR comments since the last run.\n2. Match comments containing the trigger phrase (case-insensitive, default: @OpenHands).\n3. Post an acknowledgment comment with a link to the new OpenHands conversation.\n4. Forward follow-up replies in the same thread to the running conversation.\n5. Post the agent's final response back to GitHub when the conversation completes."
11
- }
@@ -1,15 +0,0 @@
1
- {
2
- "id": "incident-retrospective-drafter",
3
- "name": "Incident retrospective drafter",
4
- "category": "Reliability",
5
- "description": "Collect incident chatter and issue updates, then draft a timeline and follow-up checklist.",
6
- "requiredIntegrationIds": [
7
- "slack",
8
- "linear",
9
- "notion"
10
- ],
11
- "popularityRank": 78,
12
- "estimatedSetupMinutes": 8,
13
- "prompt": "/incident-retro:setup",
14
- "exampleImplementation": "Trigger: manual, cron, or incident label added\nRequired MCPs: Slack, Linear, Notion\n\n1. Gather incident messages, timestamps, and linked issue references.\n2. Pull Linear follow-up tickets, owners, and status.\n3. TODO: define the incident identifier format and approved Notion template.\n4. Build a timeline, impact summary, root-cause hypotheses, and action items.\n5. Publish a draft retrospective and notify the incident owner."
15
- }