@openhands/extensions 0.24.0 → 0.26.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 (133) hide show
  1. package/.claude-plugin/marketplace.json +30 -0
  2. package/.codex-plugin/marketplace.json +30 -0
  3. package/.github/pull_request_template.md +17 -2
  4. package/.github/scripts/check_pr_description.py +64 -5
  5. package/.github/scripts/markdown_sections.py +95 -0
  6. package/.github/workflows/build-vscode-app.yml +30 -0
  7. package/.github/workflows/check-extensions.yml +3 -0
  8. package/.github/workflows/tests.yml +26 -0
  9. package/.plugin/marketplace.json +30 -0
  10. package/.release-please-manifest.json +1 -1
  11. package/AGENTS.md +3 -2
  12. package/README.md +10 -3
  13. package/apps/README.md +20 -0
  14. package/apps/catalog.json +10 -0
  15. package/apps/index.d.ts +11 -0
  16. package/apps/index.js +5 -0
  17. package/apps/vscode/README.md +13 -0
  18. package/apps/vscode/canvas-extension.json +19 -0
  19. package/apps/vscode/extension.js +114 -0
  20. package/apps/vscode/scripts/package.mjs +84 -0
  21. package/automations/bundle-index.js +18 -14
  22. package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
  23. package/automations/catalog/github-pr-reviewer/manifest.json +19 -8
  24. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +71 -0
  25. package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
  26. package/automations/catalog/news-digest/manifest.json +2 -2
  27. package/automations/catalog-index.js +2 -0
  28. package/index.d.ts +2 -0
  29. package/index.js +1 -0
  30. package/marketplaces/openhands-extensions.json +30 -0
  31. package/package.json +9 -1
  32. package/plugins/openhands/skills/openhands-api/README.md +1 -1
  33. package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
  34. package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
  35. package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
  36. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
  37. package/plugins/openhands/skills/openhands-sdk/SKILL.md +1 -0
  38. package/plugins/pr-review/skills/github-pr-review/SKILL.md +12 -2
  39. package/plugins/qa-changes/skills/github-pr-review/SKILL.md +12 -2
  40. package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
  41. package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
  42. package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
  43. package/plugins/runtime-api-configs/README.md +59 -0
  44. package/plugins/runtime-api-configs/SKILL.md +240 -0
  45. package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
  46. package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
  47. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
  48. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +183 -15
  49. package/pyproject.toml +1 -1
  50. package/python/openhands_extensions/_version.py +1 -1
  51. package/scripts/check_plugin_resources.py +116 -0
  52. package/scripts/sync_extensions.py +32 -5
  53. package/skills/canvas-extension-api/SKILL.md +34 -3
  54. package/skills/canvas-extension-api/references/backend-safety.md +14 -0
  55. package/skills/canvas-extension-api/references/connections.md +24 -0
  56. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
  57. package/skills/canvas-extension-api/references/v1-contract.md +53 -3
  58. package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
  59. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
  60. package/skills/github/scripts/agent_conversation.py +223 -43
  61. package/skills/github/scripts/github_client.py +183 -15
  62. package/skills/github-agents-md-maintainer/README.md +7 -0
  63. package/skills/github-agents-md-maintainer/SKILL.md +13 -6
  64. package/skills/github-agents-md-maintainer/scripts/github_client.py +183 -15
  65. package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
  66. package/skills/github-delivery-watchdog/scripts/github_client.py +183 -15
  67. package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
  68. package/skills/github-issue-to-pr/scripts/github_client.py +183 -15
  69. package/skills/github-issue-to-pr/scripts/worker.py +10 -1
  70. package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
  71. package/skills/github-issue-triage/scripts/github_client.py +183 -15
  72. package/skills/github-pr-review/SKILL.md +12 -2
  73. package/skills/github-pr-reviewer/README.md +36 -0
  74. package/skills/github-pr-reviewer/SKILL.md +147 -4
  75. package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
  76. package/skills/github-pr-reviewer/scripts/github_client.py +183 -15
  77. package/skills/github-pr-reviewer/scripts/main.py +17 -1
  78. package/skills/github-pr-reviewer/scripts/worker.py +842 -57
  79. package/skills/github-repo-monitor/scripts/github_client.py +183 -15
  80. package/skills/github-repo-monitor/scripts/main.py +32 -1
  81. package/skills/github-stale-ci-pr-closer/.claude-plugin/plugin.json +9 -0
  82. package/skills/github-stale-ci-pr-closer/.codex-plugin/plugin.json +9 -0
  83. package/skills/github-stale-ci-pr-closer/.plugin/plugin.json +9 -0
  84. package/skills/github-stale-ci-pr-closer/README.md +9 -0
  85. package/skills/github-stale-ci-pr-closer/SKILL.md +24 -0
  86. package/skills/github-stale-ci-pr-closer/commands/github-stale-ci-pr-closer.md +8 -0
  87. package/skills/github-stale-ci-pr-closer/scripts/worker.py +508 -0
  88. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +353 -0
  89. package/skills/gitlab-issue-to-mr/README.md +12 -0
  90. package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
  91. package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
  92. package/skills/incident-retrospective/SKILL.md +5 -5
  93. package/skills/index.js +34 -16
  94. package/skills/jira-issue-to-pr/README.md +12 -4
  95. package/skills/jira-issue-to-pr/SKILL.md +129 -18
  96. package/skills/jira-issue-to-pr/references/setup.md +8 -0
  97. package/skills/jira-issue-to-pr/scripts/main.py +181 -54
  98. package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
  99. package/skills/linear-triage/SKILL.md +5 -5
  100. package/skills/news-digest/README.md +6 -0
  101. package/skills/news-digest/SKILL.md +19 -9
  102. package/skills/news-digest/commands/news-digest-setup.md +1 -1
  103. package/skills/news-digest/scripts/main.py +133 -15
  104. package/skills/openhands-api/README.md +1 -1
  105. package/skills/openhands-api/SKILL.md +3 -3
  106. package/skills/openhands-api/scripts/openhands_api.py +13 -0
  107. package/skills/openhands-automation/SKILL.md +66 -4
  108. package/skills/openhands-automation/references/custom-automation.md +21 -7
  109. package/skills/openhands-sdk/SKILL.md +1 -0
  110. package/skills/research-brief/SKILL.md +5 -5
  111. package/skills/slack-standup-digest/SKILL.md +5 -5
  112. package/skills/ticket-to-code-change/SKILL.md +6 -3
  113. package/skills/use-jev/.claude-plugin/plugin.json +8 -0
  114. package/skills/use-jev/.codex-plugin/plugin.json +8 -0
  115. package/skills/use-jev/.plugin/plugin.json +8 -0
  116. package/skills/use-jev/README.md +66 -0
  117. package/skills/use-jev/SKILL.md +66 -0
  118. package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
  119. package/tests/test_agent_conversation_dispatch.py +212 -0
  120. package/tests/test_apps_catalog.py +26 -0
  121. package/tests/test_automation_setup.py +44 -0
  122. package/tests/test_check_pr_description.py +89 -6
  123. package/tests/test_github_automation_foundation.py +158 -0
  124. package/tests/test_github_repo_monitor.py +107 -0
  125. package/tests/test_github_reviewer_delivery.py +2247 -6
  126. package/tests/test_github_stale_ci_pr_closer.py +56 -0
  127. package/tests/test_jira_issue_to_pr.py +312 -0
  128. package/tests/test_jira_setup_webhook.py +309 -0
  129. package/tests/test_openhands_api_python.py +37 -0
  130. package/tests/test_skill_plugin_loading.py +25 -15
  131. package/tests/test_sync_extensions.py +102 -0
  132. package/tests/test_vscode_app.py +33 -0
  133. package/tests/vscode-app.test.mjs +43 -0
@@ -0,0 +1,56 @@
1
+ """Tests for how the stale-CI pull request closer reports its run."""
2
+
3
+ import json
4
+
5
+ import pytest
6
+ from github_automation_helpers import worker
7
+
8
+
9
+ @pytest.mark.parametrize(
10
+ ("environment", "expected"),
11
+ [
12
+ pytest.param(
13
+ {"AUTOMATION_CALLBACK_API_KEY": "callback-key"},
14
+ "Bearer callback-key",
15
+ id="local-run-uses-its-callback-key",
16
+ ),
17
+ pytest.param(
18
+ {"OPENHANDS_API_KEY": "openhands-key"},
19
+ "Bearer openhands-key",
20
+ id="cloud-run-has-no-callback-key-and-uses-its-api-key",
21
+ ),
22
+ ],
23
+ )
24
+ def test_completion_is_reported_with_the_key_the_run_was_given(
25
+ tmp_path, monkeypatch, environment, expected
26
+ ):
27
+ # Arrange
28
+ module = worker("github-stale-ci-pr-closer", tmp_path, monkeypatch)
29
+ for name in ("AUTOMATION_CALLBACK_API_KEY", "OPENHANDS_API_KEY"):
30
+ monkeypatch.delenv(name, raising=False)
31
+ for name, value in environment.items():
32
+ monkeypatch.setenv(name, value)
33
+ monkeypatch.setenv("AUTOMATION_CALLBACK_URL", "https://automation.test/callback")
34
+ monkeypatch.setenv("AUTOMATION_RUN_ID", "run-1")
35
+ sent = []
36
+
37
+ class _Response:
38
+ def __enter__(self):
39
+ return self
40
+
41
+ def __exit__(self, *args):
42
+ return False
43
+
44
+ def urlopen(request):
45
+ sent.append(request)
46
+ return _Response()
47
+
48
+ monkeypatch.setattr(module, "urlopen", urlopen)
49
+
50
+ # Act
51
+ module.complete_run()
52
+
53
+ # Assert
54
+ [request] = sent
55
+ assert request.get_header("Authorization") == expected
56
+ assert json.loads(request.data) == {"status": "COMPLETED", "run_id": "run-1"}
@@ -0,0 +1,312 @@
1
+ """Tests for where the jira-issue-to-pr automation script starts its conversations.
2
+
3
+ The script runs top to bottom as the automation's entrypoint, so each test runs it
4
+ as a subprocess against one stub HTTP server that plays Jira, the automation
5
+ service (KV store and completion callback), and either the local agent server or
6
+ OpenHands Cloud - whichever the run's environment points at.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import threading
15
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
16
+ from pathlib import Path
17
+
18
+ import pytest
19
+
20
+ ROOT = Path(__file__).parent.parent
21
+ SCRIPT_PATH = ROOT / "skills" / "jira-issue-to-pr" / "scripts" / "main.py"
22
+
23
+ ISSUE = {
24
+ "key": "ENG-1",
25
+ "fields": {
26
+ "summary": "Add a README",
27
+ "description": None,
28
+ "updated": "2030-01-01T00:00:00.000+0000",
29
+ },
30
+ }
31
+ # A baseline from before the issue was updated, so the issue counts as new.
32
+ STATE = {"processed_keys": [], "first_run_at": "2020-01-01T00:00:00+00:00"}
33
+
34
+
35
+ class StubServer:
36
+ """Records every request and answers it the way the real service would."""
37
+
38
+ def __init__(self):
39
+ self.requests = []
40
+ # What the automation's KV store holds, and the tasks OpenHands Cloud
41
+ # reports for the conversations it was asked to start.
42
+ self.state = dict(STATE)
43
+ self.start_tasks = []
44
+ stub = self
45
+
46
+ class Handler(BaseHTTPRequestHandler):
47
+ def _handle(self):
48
+ length = int(self.headers.get("Content-Length") or 0)
49
+ body = self.rfile.read(length) if length else b""
50
+ stub.requests.append({
51
+ "method": self.command,
52
+ "path": self.path,
53
+ "headers": {k.lower(): v for k, v in self.headers.items()},
54
+ "body": json.loads(body) if body else None,
55
+ })
56
+ status, payload = stub.respond(self.path)
57
+ data = payload.encode() if isinstance(payload, str) else json.dumps(payload).encode()
58
+ self.send_response(status)
59
+ self.send_header("Content-Length", str(len(data)))
60
+ self.end_headers()
61
+ self.wfile.write(data)
62
+
63
+ do_GET = do_POST = do_PUT = _handle
64
+
65
+ def log_message(self, *args):
66
+ pass
67
+
68
+ self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
69
+ self.url = f"http://127.0.0.1:{self.httpd.server_port}"
70
+ threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
71
+
72
+ def respond(self, path):
73
+ if path == "/rest/api/3/search/jql":
74
+ return 200, {"issues": [ISSUE]}
75
+ if path.startswith("/rest/api/3/issue/"):
76
+ return 201, {}
77
+ if path == "/automation/v1/kv/state":
78
+ return 200, {"value": self.state}
79
+ if path.endswith("/settings/secrets/JIRA_CLOUD_KEY"):
80
+ return 200, "jira-token"
81
+ if path == "/api/settings":
82
+ return 200, {"agent_settings": {"llm": {"model": "test-model"}}}
83
+ if path == "/api/conversations":
84
+ return 201, {"id": "local-conversation"}
85
+ if path == "/api/v1/app-conversations":
86
+ return 200, {"id": "start-task", "status": "WORKING"}
87
+ if path.startswith("/api/v1/app-conversations/start-tasks?"):
88
+ return 200, self.start_tasks
89
+ if path == "/callback":
90
+ return 200, {}
91
+ return 404, {}
92
+
93
+ def find(self, method, path):
94
+ return [r for r in self.requests if r["method"] == method and r["path"] == path]
95
+
96
+
97
+ @pytest.fixture
98
+ def stub():
99
+ server = StubServer()
100
+ yield server
101
+ server.httpd.shutdown()
102
+
103
+
104
+ @pytest.fixture
105
+ def run_script(stub, tmp_path):
106
+ def run(env):
107
+ workdir = tmp_path / "run"
108
+ workdir.mkdir()
109
+ shutil.copy(SCRIPT_PATH, workdir / "main.py")
110
+ (workdir / "config.json").write_text(json.dumps({
111
+ "jira_base_url": stub.url,
112
+ "jira_email": "alice@example.test",
113
+ "jira_token_secret": "JIRA_CLOUD_KEY",
114
+ }))
115
+ return subprocess.run(
116
+ [sys.executable, str(workdir / "main.py")],
117
+ env={
118
+ "PATH": os.environ["PATH"],
119
+ "HOME": str(tmp_path),
120
+ "AUTOMATION_CALLBACK_URL": f"{stub.url}/callback",
121
+ "AUTOMATION_RUN_ID": "run-1",
122
+ **env,
123
+ },
124
+ capture_output=True,
125
+ text=True,
126
+ timeout=60,
127
+ )
128
+
129
+ return run
130
+
131
+
132
+ def cloud_env(stub):
133
+ return {
134
+ "OPENHANDS_CLOUD_API_URL": stub.url,
135
+ "OPENHANDS_API_KEY": "openhands-key",
136
+ "SANDBOX_ID": "sandbox-1",
137
+ "SESSION_API_KEY": "session-key",
138
+ "AUTOMATION_API_URL": f"{stub.url}/automation",
139
+ "AUTOMATION_KV_TOKEN": "kv-token",
140
+ }
141
+
142
+
143
+ def local_env(stub):
144
+ return {
145
+ "AGENT_SERVER_URL": stub.url,
146
+ "SESSION_API_KEY": "session-key",
147
+ "AUTOMATION_CALLBACK_API_KEY": "callback-key",
148
+ "AUTOMATION_API_URL": f"{stub.url}/automation",
149
+ "AUTOMATION_KV_TOKEN": "kv-token",
150
+ }
151
+
152
+
153
+ def comment_text(comment):
154
+ return comment["body"]["body"]["content"][0]["content"][0]["text"]
155
+
156
+
157
+ def test_a_cloud_run_reads_the_jira_token_from_its_sandbox_secrets(stub, run_script):
158
+ # Arrange
159
+ env = cloud_env(stub)
160
+
161
+ # Act
162
+ result = run_script(env)
163
+
164
+ # Assert
165
+ assert result.returncode == 0, result.stderr
166
+ [secret] = stub.find("GET", "/api/v1/sandboxes/sandbox-1/settings/secrets/JIRA_CLOUD_KEY")
167
+ assert secret["headers"]["x-session-api-key"] == "session-key"
168
+
169
+
170
+ def test_a_cloud_run_starts_each_conversation_through_openhands_cloud(stub, run_script):
171
+ # Arrange
172
+ env = cloud_env(stub)
173
+
174
+ # Act
175
+ result = run_script(env)
176
+
177
+ # Assert
178
+ assert result.returncode == 0, result.stderr
179
+ [conversation] = stub.find("POST", "/api/v1/app-conversations")
180
+ assert conversation["headers"]["authorization"] == "Bearer openhands-key"
181
+ assert conversation["body"]["title"] == "[ENG-1] Add a README"
182
+ assert "ENG-1" in conversation["body"]["initial_message"]["content"][0]["text"]
183
+ assert stub.find("POST", "/api/conversations") == []
184
+
185
+
186
+ def test_a_cloud_run_links_the_jira_issue_to_its_conversation(stub, run_script):
187
+ # Arrange
188
+ env = cloud_env(stub)
189
+
190
+ # Act
191
+ result = run_script(env)
192
+
193
+ # Assert
194
+ assert result.returncode == 0, result.stderr
195
+ [conversation] = stub.find("POST", "/api/v1/app-conversations")
196
+ [comment] = stub.find("POST", "/rest/api/3/issue/ENG-1/comment")
197
+ conversation_id = conversation["body"]["conversation_id"]
198
+ assert comment_text(comment) == f"I'm on it: {stub.url}/canvas/conversations/{conversation_id}"
199
+
200
+
201
+ def test_a_cloud_run_starts_an_issue_again_when_its_conversation_failed_to_start(stub, run_script):
202
+ # Arrange - an earlier run asked for the conversation and marked the issue done
203
+ stub.state = {
204
+ **STATE,
205
+ "processed_keys": ["ENG-1"],
206
+ "pending_starts": {"ENG-1": {"task_id": "task-1", "attempts": 1}},
207
+ }
208
+ stub.start_tasks = [{"id": "task-1", "status": "ERROR", "detail": "sandbox did not start"}]
209
+
210
+ # Act
211
+ result = run_script(cloud_env(stub))
212
+
213
+ # Assert
214
+ assert result.returncode == 0, result.stderr
215
+ [conversation] = stub.find("POST", "/api/v1/app-conversations")
216
+ assert conversation["body"]["title"] == "[ENG-1] Add a README"
217
+
218
+
219
+ def test_a_cloud_run_leaves_an_issue_alone_once_its_conversation_is_up(stub, run_script):
220
+ # Arrange
221
+ stub.state = {
222
+ **STATE,
223
+ "processed_keys": ["ENG-1"],
224
+ "pending_starts": {"ENG-1": {"task_id": "task-1", "attempts": 1}},
225
+ }
226
+ stub.start_tasks = [{"id": "task-1", "status": "READY"}]
227
+
228
+ # Act
229
+ result = run_script(cloud_env(stub))
230
+
231
+ # Assert
232
+ assert result.returncode == 0, result.stderr
233
+ assert stub.find("POST", "/api/v1/app-conversations") == []
234
+ assert stub.find("POST", "/rest/api/3/issue/ENG-1/comment") == []
235
+
236
+
237
+ def test_a_cloud_run_reports_a_start_that_keeps_failing_on_the_issue(stub, run_script):
238
+ # Arrange - the last allowed attempt has failed as well
239
+ stub.state = {
240
+ **STATE,
241
+ "processed_keys": ["ENG-1"],
242
+ "pending_starts": {"ENG-1": {"task_id": "task-1", "attempts": 3}},
243
+ }
244
+ stub.start_tasks = [{"id": "task-1", "status": "ERROR", "detail": "sandbox did not start"}]
245
+
246
+ # Act
247
+ result = run_script(cloud_env(stub))
248
+
249
+ # Assert
250
+ assert result.returncode == 0, result.stderr
251
+ assert stub.find("POST", "/api/v1/app-conversations") == []
252
+ [comment] = stub.find("POST", "/rest/api/3/issue/ENG-1/comment")
253
+ assert "could not start a conversation" in comment_text(comment)
254
+ assert "sandbox did not start" in comment_text(comment)
255
+
256
+
257
+ def test_a_cloud_run_reports_completion_with_its_api_key(stub, run_script):
258
+ # Arrange
259
+ env = cloud_env(stub)
260
+
261
+ # Act
262
+ result = run_script(env)
263
+
264
+ # Assert
265
+ assert result.returncode == 0, result.stderr
266
+ [callback] = stub.find("POST", "/callback")
267
+ assert callback["headers"]["authorization"] == "Bearer openhands-key"
268
+ assert callback["body"]["status"] == "COMPLETED"
269
+
270
+
271
+ def test_a_cloud_run_without_the_kv_store_fails_before_dispatching(stub, run_script):
272
+ # Arrange
273
+ env = cloud_env(stub)
274
+ del env["AUTOMATION_KV_TOKEN"]
275
+
276
+ # Act
277
+ result = run_script(env)
278
+
279
+ # Assert
280
+ assert result.returncode == 1
281
+ assert stub.find("POST", "/api/v1/app-conversations") == []
282
+ [callback] = stub.find("POST", "/callback")
283
+ assert callback["body"]["status"] == "FAILED"
284
+
285
+
286
+ def test_a_local_run_still_starts_conversations_on_the_agent_server(stub, run_script):
287
+ # Arrange
288
+ env = local_env(stub)
289
+
290
+ # Act
291
+ result = run_script(env)
292
+
293
+ # Assert
294
+ assert result.returncode == 0, result.stderr
295
+ [conversation] = stub.find("POST", "/api/conversations")
296
+ assert conversation["headers"]["x-session-api-key"] == "session-key"
297
+ [comment] = stub.find("POST", "/rest/api/3/issue/ENG-1/comment")
298
+ assert comment_text(comment) == f"I'm on it: {stub.url}/conversations/local-conversation"
299
+ assert stub.find("POST", "/api/v1/app-conversations") == []
300
+
301
+
302
+ def test_a_local_run_still_reports_completion_with_its_callback_key(stub, run_script):
303
+ # Arrange
304
+ env = {**local_env(stub), "OPENHANDS_API_KEY": "openhands-key"}
305
+
306
+ # Act
307
+ result = run_script(env)
308
+
309
+ # Assert
310
+ assert result.returncode == 0, result.stderr
311
+ [callback] = stub.find("POST", "/callback")
312
+ assert callback["headers"]["authorization"] == "Bearer callback-key"
@@ -0,0 +1,309 @@
1
+ """Tests for the script that sets up the Jira webhook of an event-based automation.
2
+
3
+ The script talks to two services over HTTPS: the OpenHands automation API and
4
+ Jira Cloud. Each test runs it as the entrypoint it is, with `urlopen` replaced by
5
+ one in-process stand-in for both services that keeps what they would store.
6
+ """
7
+
8
+ import hashlib
9
+ import hmac
10
+ import io
11
+ import json
12
+ import runpy
13
+ import sys
14
+ import urllib.error
15
+ import urllib.request
16
+ from pathlib import Path
17
+
18
+ import pytest
19
+
20
+ SCRIPT_PATH = (
21
+ Path(__file__).parent.parent
22
+ / "skills"
23
+ / "jira-issue-to-pr"
24
+ / "scripts"
25
+ / "setup_webhook.py"
26
+ )
27
+ OPENHANDS = "https://openhands.example.test"
28
+ JIRA = "https://acme.atlassian.net"
29
+ WEBHOOK_URL = f"{OPENHANDS}/api/automation/v1/events/org-1/jira"
30
+
31
+
32
+ class Services:
33
+ """What the OpenHands automation API and Jira Cloud hold, and how they answer."""
34
+
35
+ def __init__(self):
36
+ self.jira_admin = True
37
+ self.jira_rejects_writes = False
38
+ self.openhands_webhook = None # includes its "secret"
39
+ self.jira_webhooks = {} # id -> webhook, including its "secret"
40
+ self.writes = []
41
+ self.delivered = []
42
+
43
+ def urlopen(self, request, timeout=None):
44
+ method, url = request.get_method(), request.full_url
45
+ body = json.loads(request.data) if request.data else None
46
+ if method != "GET" and "/events/" not in url:
47
+ self.writes.append((method, url))
48
+ status, payload = self._answer(method, url, body, request)
49
+ if status >= 400:
50
+ raise urllib.error.HTTPError(
51
+ url, status, "error", {}, io.BytesIO(json.dumps(payload).encode())
52
+ )
53
+ return _Response(status, payload)
54
+
55
+ def _answer(self, method, url, body, request):
56
+ if url == WEBHOOK_URL:
57
+ signature = "sha256=" + hmac.new(
58
+ self.openhands_webhook["secret"].encode(), request.data, hashlib.sha256
59
+ ).hexdigest()
60
+ if request.get_header("X-hub-signature") != signature:
61
+ return 401, {"detail": "Invalid signature"}
62
+ self.delivered.append(body)
63
+ return 200, {"received": True, "matched": 0}
64
+ if url.startswith(OPENHANDS):
65
+ return self._openhands(method, url, body)
66
+ return self._jira(method, url, body)
67
+
68
+ def _openhands(self, method, url, body):
69
+ def public():
70
+ return {k: v for k, v in self.openhands_webhook.items() if k != "secret"}
71
+
72
+ if method == "GET":
73
+ return 200, {"webhooks": [public()] if self.openhands_webhook else []}
74
+ if url.endswith("/rotate-secret"):
75
+ self.openhands_webhook["secret"] = "rotated-" + "r" * 32
76
+ return 200, {"webhook_secret": self.openhands_webhook["secret"]}
77
+ if method == "PATCH":
78
+ self.openhands_webhook.update(body)
79
+ return 200, public()
80
+ self.openhands_webhook = {
81
+ "id": "webhook-1",
82
+ "enabled": True,
83
+ "webhook_url": WEBHOOK_URL,
84
+ "secret": body.pop("webhook_secret"),
85
+ **body,
86
+ }
87
+ return 201, {**public(), "webhook_secret": None}
88
+
89
+ def _jira(self, method, url, body):
90
+ if "/mypermissions" in url:
91
+ return 200, {
92
+ "permissions": {"ADMINISTER": {"havePermission": self.jira_admin}}
93
+ }
94
+ if method != "GET" and self.jira_rejects_writes:
95
+ return 400, {"messages": ["rejected", (body or {}).get("secret")]}
96
+ hook_id = url.rsplit("/", 1)[-1]
97
+ if method == "GET":
98
+ return 200, [
99
+ {**hook, "self": f"{JIRA}/rest/webhooks/1.0/webhook/{i}"}
100
+ for i, hook in self.jira_webhooks.items()
101
+ ]
102
+ if method == "DELETE":
103
+ del self.jira_webhooks[hook_id]
104
+ return 204, {}
105
+ if method == "PUT":
106
+ # Jira keeps the secret of a webhook updated without one.
107
+ kept = self.jira_webhooks[hook_id].get("secret")
108
+ self.jira_webhooks[hook_id] = {"secret": kept, **body}
109
+ else:
110
+ hook_id = "71"
111
+ self.jira_webhooks[hook_id] = body
112
+ return 200, {**body, "self": f"{JIRA}/rest/webhooks/1.0/webhook/{hook_id}"}
113
+
114
+
115
+ class _Response:
116
+ def __init__(self, status, payload):
117
+ self.status = status
118
+ self._raw = json.dumps(payload).encode()
119
+
120
+ def read(self):
121
+ return self._raw
122
+
123
+ def __enter__(self):
124
+ return self
125
+
126
+ def __exit__(self, *args):
127
+ return False
128
+
129
+
130
+ @pytest.fixture
131
+ def services(monkeypatch):
132
+ services = Services()
133
+ monkeypatch.setattr(urllib.request, "urlopen", services.urlopen)
134
+ monkeypatch.setenv("OPENHANDS_API_KEY", "openhands-key")
135
+ monkeypatch.setenv("JIRA_ADMIN_TOKEN", "jira-token")
136
+ return services
137
+
138
+
139
+ @pytest.fixture
140
+ def run_script(monkeypatch, capsys):
141
+ """Run the script with the given action and flags; return (exit code, output)."""
142
+
143
+ def run(*args):
144
+ monkeypatch.setattr(
145
+ sys,
146
+ "argv",
147
+ [
148
+ str(SCRIPT_PATH),
149
+ *args,
150
+ *("--jira-base-url", JIRA, "--jira-email", "alice@acme.com"),
151
+ *("--jira-token-env", "JIRA_ADMIN_TOKEN"),
152
+ *("--openhands-host", OPENHANDS),
153
+ ],
154
+ )
155
+ code = 0
156
+ try:
157
+ runpy.run_path(str(SCRIPT_PATH), run_name="__main__")
158
+ except SystemExit as exit_:
159
+ code = exit_.code or 0
160
+ captured = capsys.readouterr()
161
+ return code, captured.out + captured.err
162
+
163
+ return run
164
+
165
+
166
+ def test_apply_registers_the_webhook_on_both_sides_with_one_secret(
167
+ services, run_script
168
+ ):
169
+ # Act
170
+ code, _ = run_script("apply")
171
+
172
+ # Assert
173
+ assert code == 0
174
+ [jira_webhook] = services.jira_webhooks.values()
175
+ assert jira_webhook["url"] == WEBHOOK_URL
176
+ assert jira_webhook["events"] == ["jira:issue_created", "jira:issue_updated"]
177
+ assert jira_webhook["filters"] == {
178
+ "issue-related-events-section": 'labels = "create-pr"'
179
+ }
180
+ assert jira_webhook["secret"] == services.openhands_webhook["secret"]
181
+
182
+
183
+ def test_apply_registers_the_openhands_webhook_the_way_jira_signs(
184
+ services, run_script
185
+ ):
186
+ # Act
187
+ run_script("apply")
188
+
189
+ # Assert
190
+ assert services.openhands_webhook["signature_header"] == "X-Hub-Signature"
191
+ assert services.openhands_webhook["event_key_expr"] == "webhookEvent"
192
+
193
+
194
+ def test_apply_proves_the_pair_with_a_signed_delivery_that_starts_nothing(
195
+ services, run_script
196
+ ):
197
+ # Act
198
+ _, output = run_script("apply")
199
+
200
+ # Assert
201
+ assert json.loads(output)["signed_delivery_check"] == "ok"
202
+ assert services.delivered == [{"webhookEvent": "openhands:setup_check"}]
203
+
204
+
205
+ def test_apply_never_prints_the_signing_secret(services, run_script):
206
+ # Act
207
+ _, output = run_script("apply")
208
+
209
+ # Assert
210
+ assert services.openhands_webhook["secret"] not in output
211
+
212
+
213
+ def test_applying_again_updates_jira_and_keeps_the_secret(services, run_script):
214
+ # Arrange
215
+ run_script("apply")
216
+ secret = services.openhands_webhook["secret"]
217
+
218
+ # Act
219
+ code, _ = run_script("apply", "--label", "needs-pr")
220
+
221
+ # Assert
222
+ assert code == 0
223
+ [jira_webhook] = services.jira_webhooks.values()
224
+ assert jira_webhook["filters"] == {
225
+ "issue-related-events-section": 'labels = "needs-pr"'
226
+ }
227
+ assert jira_webhook["secret"] == secret == services.openhands_webhook["secret"]
228
+
229
+
230
+ def test_a_failed_jira_update_leaves_the_existing_secret_working(services, run_script):
231
+ # Arrange
232
+ run_script("apply")
233
+ secret = services.openhands_webhook["secret"]
234
+ services.jira_rejects_writes = True
235
+
236
+ # Act
237
+ code, _ = run_script("apply", "--label", "needs-pr")
238
+
239
+ # Assert
240
+ assert code == 1
241
+ [jira_webhook] = services.jira_webhooks.values()
242
+ assert jira_webhook["secret"] == secret == services.openhands_webhook["secret"]
243
+
244
+
245
+ def test_rotate_secret_gives_both_sides_a_new_secret(services, run_script):
246
+ # Arrange
247
+ run_script("apply")
248
+ secret = services.openhands_webhook["secret"]
249
+
250
+ # Act
251
+ code, _ = run_script("apply", "--rotate-secret")
252
+
253
+ # Assert
254
+ assert code == 0
255
+ [jira_webhook] = services.jira_webhooks.values()
256
+ assert services.openhands_webhook["secret"] != secret
257
+ assert jira_webhook["secret"] == services.openhands_webhook["secret"]
258
+
259
+
260
+ def test_a_jira_error_that_echoes_the_secret_is_redacted(services, run_script):
261
+ # Arrange
262
+ services.jira_rejects_writes = True
263
+
264
+ # Act
265
+ code, output = run_script("apply")
266
+
267
+ # Assert
268
+ assert code == 1
269
+ assert services.openhands_webhook["secret"] not in output
270
+
271
+
272
+ def test_an_account_that_is_not_a_jira_administrator_changes_nothing(
273
+ services, run_script
274
+ ):
275
+ # Arrange
276
+ services.jira_admin = False
277
+
278
+ # Act
279
+ code, output = run_script("apply")
280
+
281
+ # Assert - exit code 3 is what tells the skill to fall back to manual setup
282
+ assert code == 3
283
+ assert "Administer Jira" in output
284
+ assert services.writes == []
285
+
286
+
287
+ def test_a_dry_run_changes_nothing(services, run_script):
288
+ # Act
289
+ code, output = run_script("apply", "--dry-run")
290
+
291
+ # Assert
292
+ assert code == 0
293
+ assert json.loads(output)["jira_webhook"] == "create"
294
+ assert services.writes == []
295
+
296
+
297
+ def test_delete_removes_the_jira_webhook_and_keeps_the_openhands_one(
298
+ services, run_script
299
+ ):
300
+ # Arrange
301
+ run_script("apply")
302
+
303
+ # Act
304
+ code, _ = run_script("delete")
305
+
306
+ # Assert
307
+ assert code == 0
308
+ assert services.jira_webhooks == {}
309
+ assert services.openhands_webhook is not None