@openhands/extensions 0.10.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 (54) 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 +12 -9
  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/atlassian-rovo.json +44 -0
  20. package/integrations/catalog/datadog.json +3 -3
  21. package/integrations/catalog/github.json +1 -1
  22. package/integrations/catalog/linear.json +1 -1
  23. package/integrations/catalog/slack.json +2 -2
  24. package/integrations/catalog-index.js +30 -28
  25. package/integrations/index.d.ts +1 -1
  26. package/marketplaces/openhands-extensions.json +9 -0
  27. package/package.json +6 -3
  28. package/pyproject.toml +1 -1
  29. package/python/openhands_extensions/__init__.py +0 -4
  30. package/python/openhands_extensions/_version.py +3 -2
  31. package/python/openhands_extensions/integrations.py +8 -30
  32. package/scripts/build-automation-catalog.mjs +51 -0
  33. package/skills/bitbucket/SKILL.md +0 -1
  34. package/skills/github/SKILL.md +0 -1
  35. package/skills/gitlab/SKILL.md +0 -1
  36. package/skills/index.js +11 -8
  37. package/skills/iterate/README.md +4 -1
  38. package/skills/iterate/SKILL.md +22 -2
  39. package/skills/openhands-sdk/SKILL.md +2 -0
  40. package/skills/technical-writing/.plugin/plugin.json +15 -0
  41. package/skills/technical-writing/README.md +14 -0
  42. package/skills/technical-writing/SKILL.md +106 -0
  43. package/tests/fixtures/automations/capabilities.json +92 -0
  44. package/tests/fixtures/automations/github-pr-reviewer.json +251 -0
  45. package/tests/fixtures/automations/github-repo-monitor.json +180 -0
  46. package/tests/fixtures/automations/incident-retrospective-drafter.json +91 -0
  47. package/tests/test_automation_setup.py +526 -0
  48. package/tests/test_catalogs.py +104 -12
  49. package/tests/test_deprecations.py +39 -4
  50. package/tests/test_integration_catalog_in_sync.py +50 -60
  51. package/tests/test_live_integration_smoke.py +366 -0
  52. package/automations/catalog/github-pr-reviewer.json +0 -13
  53. package/automations/catalog/github-repo-monitor.json +0 -11
  54. package/automations/catalog/incident-retrospective-drafter.json +0 -15
@@ -33,6 +33,28 @@ def _load_checker():
33
33
  return module
34
34
 
35
35
 
36
+ def _sample_package(checker, tmp_path: Path, *, version: str):
37
+ """A throwaway package holding one deprecation scheduled for removal in 0.12.0.
38
+
39
+ The deadline tests use this instead of the shipped source so they assert the
40
+ checker's behaviour rather than whichever deprecations happen to be pending.
41
+ """
42
+ pyproject = tmp_path / "pyproject.toml"
43
+ pyproject.write_text(f'[project]\nname = "sample"\nversion = "{version}"\n')
44
+
45
+ source_root = tmp_path / "sample"
46
+ source_root.mkdir()
47
+ (source_root / "legacy.py").write_text(
48
+ '@deprecated(deprecated_in="0.10.0", removed_in="0.12.0")\n'
49
+ "def legacy_helper() -> None:\n"
50
+ " pass\n"
51
+ )
52
+
53
+ return checker.PackageConfig(
54
+ name="sample", pyproject=pyproject, source_roots=(source_root,)
55
+ )
56
+
57
+
36
58
  def test_deprecated_decorator_warns_and_preserves_calls() -> None:
37
59
  @deprecated(
38
60
  deprecated_in="0.10.0",
@@ -107,9 +129,13 @@ warn_cleanup(\"temporary workaround\", cleanup_by=date(2030, 1, 1))
107
129
 
108
130
  def test_release_please_deadline_passes_before_removal_version(
109
131
  monkeypatch: pytest.MonkeyPatch,
132
+ tmp_path: Path,
110
133
  ) -> None:
111
134
  checker = _load_checker()
112
- monkeypatch.setattr(checker, "_load_current_version", lambda _: "0.10.0")
135
+ monkeypatch.setattr(checker, "REPO_ROOT", tmp_path)
136
+ monkeypatch.setattr(
137
+ checker, "PACKAGES", (_sample_package(checker, tmp_path, version="0.11.0"),)
138
+ )
113
139
 
114
140
  assert checker.main() == 0
115
141
 
@@ -117,14 +143,23 @@ def test_release_please_deadline_passes_before_removal_version(
117
143
  def test_release_please_deadline_fails_at_removal_version(
118
144
  monkeypatch: pytest.MonkeyPatch,
119
145
  capsys: pytest.CaptureFixture[str],
146
+ tmp_path: Path,
120
147
  ) -> None:
121
148
  checker = _load_checker()
122
- monkeypatch.setattr(checker, "_load_current_version", lambda _: "0.12.0")
149
+ monkeypatch.setattr(checker, "REPO_ROOT", tmp_path)
150
+ monkeypatch.setattr(
151
+ checker, "PACKAGES", (_sample_package(checker, tmp_path, version="0.12.0"),)
152
+ )
123
153
 
124
154
  assert checker.main() == 1
125
155
  output = capsys.readouterr().out
126
- assert "list_integration_catalog" in output
127
- assert "get_integration_catalog_entry" in output
156
+ assert "legacy_helper" in output
157
+ assert "removed in: 0.12.0" in output
158
+
159
+
160
+ def test_shipped_source_has_no_overdue_deprecations() -> None:
161
+ """The gate the release PR runs: nothing shipped is past its deadline."""
162
+ assert _load_checker().main() == 0
128
163
 
129
164
 
130
165
  def test_date_based_deadlines_use_the_sdk_semantics() -> None:
@@ -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
@@ -0,0 +1,366 @@
1
+ """Live smoke tests for the daily-workflow integrations (OSS-5193).
2
+
3
+ These tests exercise the published catalog entries for GitHub, Linear, and
4
+ Slack against the real services, through the same client stack
5
+ (``openhands.sdk.mcp.create_mcp_tools``) that backs the agent-server's
6
+ ``POST /api/mcp/test`` probe behind Agent Canvas's Test connection.
7
+
8
+ Every test is skipped unless real credentials are present in the environment,
9
+ so credential-less CI runs them as skips with zero network access. To run
10
+ live:
11
+
12
+ GITHUB_PERSONAL_ACCESS_TOKEN=... \
13
+ LINEAR_API_KEY=... \
14
+ SLACK_BOT_TOKEN=... SLACK_TEAM_ID=... \
15
+ uv run --group test pytest tests/test_live_integration_smoke.py -rA -s
16
+
17
+ Preconditions:
18
+ - The GitHub token needs the scopes stated in the catalog entry (classic:
19
+ ``repo`` and ``read:user``).
20
+ - The Linear API key needs at least read access.
21
+ - The Slack bot must be a member of at least one channel with message history
22
+ (``/invite @your-bot``), and its token needs the scopes stated in the
23
+ catalog entry.
24
+
25
+ Evidence lines are printed as ``[OSS-5193] ...`` and contain only tool names,
26
+ counts, and provider error codes — never credentials or message content.
27
+ """
28
+
29
+ import json
30
+ import os
31
+ from contextlib import contextmanager
32
+
33
+ import mcp.types
34
+ import pytest
35
+ from openhands.sdk.mcp import MCPError, MCPServer, MCPTimeoutError, create_mcp_tools
36
+
37
+ from openhands_extensions import get_integration_catalog_entry_model
38
+
39
+
40
+ GITHUB_TOKEN_ENV = "GITHUB_PERSONAL_ACCESS_TOKEN"
41
+ LINEAR_TOKEN_ENV = "LINEAR_API_KEY"
42
+ SLACK_ENV_KEYS = ("SLACK_TEAM_ID", "SLACK_BOT_TOKEN")
43
+
44
+ HTTP_TIMEOUT = 30.0
45
+ STDIO_TIMEOUT = 90.0 # a cold `npx -y` run downloads the Slack server first
46
+
47
+ # Auth failure codes the Agent Canvas interpreter reclassifies as credential
48
+ # failures (agent-canvas src/utils/mcp-credential-validation.ts).
49
+ SLACK_AUTH_FAILURES = {
50
+ "invalid_auth",
51
+ "not_authed",
52
+ "account_inactive",
53
+ "token_revoked",
54
+ "token_expired",
55
+ }
56
+
57
+ requires_github = pytest.mark.skipif(
58
+ not os.environ.get(GITHUB_TOKEN_ENV),
59
+ reason=f"set {GITHUB_TOKEN_ENV} to run the live GitHub MCP smoke test",
60
+ )
61
+ requires_linear = pytest.mark.skipif(
62
+ not os.environ.get(LINEAR_TOKEN_ENV),
63
+ reason=f"set {LINEAR_TOKEN_ENV} to run the live Linear MCP smoke test",
64
+ )
65
+ requires_slack = pytest.mark.skipif(
66
+ not all(os.environ.get(key) for key in SLACK_ENV_KEYS),
67
+ reason=f"set {' and '.join(SLACK_ENV_KEYS)} to run the live Slack MCP smoke test",
68
+ )
69
+
70
+
71
+ def _evidence(line):
72
+ print(f"[OSS-5193] {line}")
73
+
74
+
75
+ def _installable_option(entry_id):
76
+ """The option a local Agent Canvas install uses — the GUI filters out
77
+ provider-OAuth options (isLocallyInstallableMcpOption in agent-canvas)."""
78
+ entry = get_integration_catalog_entry_model(entry_id)
79
+ assert entry is not None, f"catalog entry {entry_id!r} missing"
80
+ for option in entry.connectionOptions:
81
+ if option.auth.strategy != "oauth2":
82
+ return option
83
+ raise AssertionError(f"{entry_id}: no locally installable connection option")
84
+
85
+
86
+ def _remote_server(option, token):
87
+ return MCPServer.model_validate(
88
+ {
89
+ "url": option.transport.url,
90
+ "transport": "http",
91
+ "auth": {"strategy": option.auth.strategy, "value": token},
92
+ }
93
+ )
94
+
95
+
96
+ def _stdio_server(option, env):
97
+ return MCPServer.model_validate(
98
+ {
99
+ "transport": "stdio",
100
+ "command": option.transport.command,
101
+ "args": list(option.transport.args),
102
+ "env": env,
103
+ }
104
+ )
105
+
106
+
107
+ @contextmanager
108
+ def _connect(server_id, server, timeout):
109
+ """Yield ``(tool_names, call)`` through the same code path as the
110
+ agent-server probe (mcp_router._probe_mcp_server): connect, list tools,
111
+ invoke read-only tools, join TextContent blocks."""
112
+ with create_mcp_tools({server_id: server}, timeout=timeout) as client:
113
+ tool_names = [tool.name for tool in client.tools]
114
+
115
+ def call(name, arguments):
116
+ result = client.call_async_from_sync(
117
+ client.call_tool_mcp,
118
+ name=name,
119
+ arguments=arguments,
120
+ timeout=timeout,
121
+ )
122
+ text = "\n".join(
123
+ block.text
124
+ for block in result.content
125
+ if isinstance(block, mcp.types.TextContent)
126
+ )
127
+ return bool(result.isError), text
128
+
129
+ yield tool_names, call
130
+
131
+
132
+ def _first_advertised(tool_names, candidates):
133
+ for name, arguments in candidates:
134
+ if name in tool_names:
135
+ return name, arguments
136
+ pytest.fail(
137
+ "none of the candidate tools "
138
+ f"{sorted({name for name, _ in candidates})} are advertised; "
139
+ f"server offers: {sorted(tool_names)}"
140
+ )
141
+
142
+
143
+ @requires_github
144
+ def test_github_identity_and_pr_read():
145
+ option = _installable_option("github")
146
+ # The env var doubles as the catalog's secret name; fail loudly on drift.
147
+ assert option.auth.credentialSecretName == GITHUB_TOKEN_ENV
148
+ server = _remote_server(option, os.environ[GITHUB_TOKEN_ENV])
149
+
150
+ with _connect("github", server, HTTP_TIMEOUT) as (tool_names, call):
151
+ assert "get_me" in tool_names, (
152
+ "Agent Canvas's credential probe calls get_me; "
153
+ f"server offers: {sorted(tool_names)}"
154
+ )
155
+ is_error, text = call("get_me", {})
156
+ assert not is_error, f"get_me failed: {text}"
157
+ assert "login" in text
158
+ _evidence(f"github: {len(tool_names)} tools; get_me ok (login present)")
159
+
160
+ # Representative PR lookup against a merged, permanently public PR in
161
+ # this very repo (OpenHands/extensions#408).
162
+ name, arguments = _first_advertised(
163
+ tool_names,
164
+ [
165
+ (
166
+ "pull_request_read",
167
+ {
168
+ "method": "get",
169
+ "owner": "OpenHands",
170
+ "repo": "extensions",
171
+ "pullNumber": 408,
172
+ },
173
+ ),
174
+ (
175
+ "get_pull_request",
176
+ {"owner": "OpenHands", "repo": "extensions", "pullNumber": 408},
177
+ ),
178
+ (
179
+ "search_pull_requests",
180
+ {"query": "repo:OpenHands/extensions is:pr 408"},
181
+ ),
182
+ ("list_pull_requests", {"owner": "OpenHands", "repo": "extensions"}),
183
+ ],
184
+ )
185
+ is_error, text = call(name, arguments)
186
+ if is_error and "pullNumber" in arguments:
187
+ # Tolerate snake_case argument naming on the hosted server.
188
+ arguments = {
189
+ ("pull_number" if key == "pullNumber" else key): value
190
+ for key, value in arguments.items()
191
+ }
192
+ is_error, text = call(name, arguments)
193
+ assert not is_error, f"{name} failed: {text}"
194
+ assert text.strip()
195
+ if "408" in json.dumps(arguments):
196
+ assert "408" in text, f"{name} response does not reference PR 408"
197
+ _evidence(f"github: PR lookup ok via {name}")
198
+
199
+
200
+ @requires_github
201
+ def test_github_invalid_token_yields_structured_failure():
202
+ option = _installable_option("github")
203
+ bogus = "github_pat_invalid_oss5193"
204
+ server = _remote_server(option, bogus)
205
+
206
+ try:
207
+ with _connect("github-invalid", server, HTTP_TIMEOUT) as (tool_names, call):
208
+ # Some deployments accept the connection and fail on invocation.
209
+ if "get_me" in tool_names:
210
+ is_error, text = call("get_me", {})
211
+ else:
212
+ is_error, text = True, "get_me not advertised"
213
+ assert is_error, "expected an auth failure with an invalid token"
214
+ assert bogus not in text, "failure text must not echo the credential"
215
+ _evidence("github: invalid token -> tool-level error (is_error=true)")
216
+ except MCPTimeoutError:
217
+ pytest.fail("invalid GitHub token should fail fast, not time out")
218
+ except MCPError as exc:
219
+ detail = str(exc.__cause__ or exc.__context__ or exc)
220
+ assert bogus not in detail, "failure text must not echo the credential"
221
+ _evidence(
222
+ "github: invalid token -> connection-stage failure "
223
+ "(Canvas error_kind=connection)"
224
+ )
225
+
226
+
227
+ @requires_linear
228
+ def test_linear_teams_and_issue_read():
229
+ option = _installable_option("linear")
230
+ server = _remote_server(option, os.environ[LINEAR_TOKEN_ENV])
231
+
232
+ with _connect("linear", server, HTTP_TIMEOUT) as (tool_names, call):
233
+ assert "list_teams" in tool_names, (
234
+ "Agent Canvas's credential probe calls list_teams; "
235
+ f"server offers: {sorted(tool_names)}"
236
+ )
237
+ is_error, text = call("list_teams", {})
238
+ assert not is_error, f"list_teams failed: {text}"
239
+ _evidence(f"linear: {len(tool_names)} tools; list_teams ok")
240
+
241
+ name, arguments = _first_advertised(
242
+ tool_names,
243
+ [("list_issues", {"limit": 1}), ("list_my_issues", {"limit": 1})],
244
+ )
245
+ is_error, text = call(name, arguments)
246
+ assert not is_error, f"{name} failed: {text}"
247
+ assert text.strip()
248
+ _evidence(f"linear: issue read ok via {name}")
249
+
250
+
251
+ @requires_linear
252
+ def test_linear_invalid_token_yields_structured_failure():
253
+ option = _installable_option("linear")
254
+ bogus = "lin_api_invalid_oss5193"
255
+ server = _remote_server(option, bogus)
256
+
257
+ try:
258
+ with _connect("linear-invalid", server, HTTP_TIMEOUT) as (tool_names, call):
259
+ if "list_teams" in tool_names:
260
+ is_error, text = call("list_teams", {})
261
+ else:
262
+ is_error, text = True, "list_teams not advertised"
263
+ assert is_error, "expected an auth failure with an invalid token"
264
+ assert bogus not in text, "failure text must not echo the credential"
265
+ _evidence("linear: invalid token -> tool-level error (is_error=true)")
266
+ except MCPTimeoutError:
267
+ pytest.fail("invalid Linear token should fail fast, not time out")
268
+ except MCPError as exc:
269
+ detail = str(exc.__cause__ or exc.__context__ or exc)
270
+ assert bogus not in detail, "failure text must not echo the credential"
271
+ _evidence(
272
+ "linear: invalid token -> connection-stage failure "
273
+ "(Canvas error_kind=connection)"
274
+ )
275
+
276
+
277
+ @requires_slack
278
+ def test_slack_channel_and_thread_read():
279
+ option = _installable_option("slack")
280
+ env_keys = [field.key for field in option.transport.envFields or []]
281
+ assert set(env_keys) == set(SLACK_ENV_KEYS)
282
+ server = _stdio_server(option, {key: os.environ[key] for key in env_keys})
283
+
284
+ with _connect("slack", server, STDIO_TIMEOUT) as (tool_names, call):
285
+ for required in (
286
+ "slack_list_channels",
287
+ "slack_get_channel_history",
288
+ "slack_get_thread_replies",
289
+ ):
290
+ assert required in tool_names, (
291
+ f"{required} not advertised; server offers: {sorted(tool_names)}"
292
+ )
293
+
294
+ is_error, text = call("slack_list_channels", {"limit": 5})
295
+ assert not is_error, f"slack_list_channels failed: {text}"
296
+ payload = json.loads(text)
297
+ assert payload.get("ok") is True, (
298
+ f"slack_list_channels not ok: {payload.get('error')}"
299
+ )
300
+ channels = payload.get("channels") or []
301
+ assert channels, "no channels visible to the bot"
302
+ _evidence(
303
+ f"slack: {len(tool_names)} tools; list_channels ok "
304
+ f"({len(channels)} channel(s))"
305
+ )
306
+
307
+ for channel in channels:
308
+ is_error, text = call(
309
+ "slack_get_channel_history",
310
+ {"channel_id": channel["id"], "limit": 10},
311
+ )
312
+ if is_error:
313
+ continue
314
+ history = json.loads(text)
315
+ if history.get("ok") is not True:
316
+ continue # e.g. not_in_channel — try the next channel
317
+ for message in history.get("messages") or []:
318
+ ts = message.get("thread_ts") or message.get("ts")
319
+ if not ts:
320
+ continue
321
+ is_error, text = call(
322
+ "slack_get_thread_replies",
323
+ {"channel_id": channel["id"], "thread_ts": ts},
324
+ )
325
+ assert not is_error, f"slack_get_thread_replies failed: {text}"
326
+ replies = json.loads(text)
327
+ assert replies.get("ok") is True, (
328
+ f"slack_get_thread_replies not ok: {replies.get('error')}"
329
+ )
330
+ _evidence(
331
+ "slack: channel history + thread read ok "
332
+ f"({len(replies.get('messages') or [])} message(s) in thread)"
333
+ )
334
+ return
335
+ pytest.fail(
336
+ "no channel with readable message history: invite the bot to a "
337
+ "public channel that has messages (/invite @your-bot) and rerun"
338
+ )
339
+
340
+
341
+ @requires_slack
342
+ def test_slack_invalid_token_yields_in_band_error():
343
+ option = _installable_option("slack")
344
+ bogus = "xoxb-invalid-oss5193"
345
+ server = _stdio_server(
346
+ option,
347
+ {"SLACK_TEAM_ID": os.environ["SLACK_TEAM_ID"], "SLACK_BOT_TOKEN": bogus},
348
+ )
349
+
350
+ with _connect("slack-invalid", server, STDIO_TIMEOUT) as (tool_names, call):
351
+ # The stdio server starts and lists tools with any token — the exact
352
+ # reason Agent Canvas interprets the in-band payload of a read-only
353
+ # tool call instead of trusting tools/list.
354
+ assert "slack_list_channels" in tool_names
355
+ is_error, text = call("slack_list_channels", {"limit": 1})
356
+ assert bogus not in text, "failure text must not echo the credential"
357
+ payload = json.loads(text)
358
+ assert payload.get("ok") is False
359
+ assert payload.get("error") in SLACK_AUTH_FAILURES, (
360
+ f"unexpected error code {payload.get('error')!r}; Canvas reclassifies "
361
+ f"only {sorted(SLACK_AUTH_FAILURES)} as credential failures"
362
+ )
363
+ _evidence(
364
+ f"slack: invalid token -> in-band ok=false error={payload.get('error')} "
365
+ f"(is_error={is_error})"
366
+ )
@@ -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
- }