@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.
- package/.claude-plugin/marketplace.json +30 -0
- package/.codex-plugin/marketplace.json +30 -0
- package/.github/pull_request_template.md +17 -2
- package/.github/scripts/check_pr_description.py +64 -5
- package/.github/scripts/markdown_sections.py +95 -0
- package/.github/workflows/build-vscode-app.yml +30 -0
- package/.github/workflows/check-extensions.yml +3 -0
- package/.github/workflows/tests.yml +26 -0
- package/.plugin/marketplace.json +30 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +3 -2
- package/README.md +10 -3
- package/apps/README.md +20 -0
- package/apps/catalog.json +10 -0
- package/apps/index.d.ts +11 -0
- package/apps/index.js +5 -0
- package/apps/vscode/README.md +13 -0
- package/apps/vscode/canvas-extension.json +19 -0
- package/apps/vscode/extension.js +114 -0
- package/apps/vscode/scripts/package.mjs +84 -0
- package/automations/bundle-index.js +18 -14
- package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
- package/automations/catalog/github-pr-reviewer/manifest.json +19 -8
- package/automations/catalog/github-stale-ci-pr-closer/manifest.json +71 -0
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
- package/automations/catalog/news-digest/manifest.json +2 -2
- package/automations/catalog-index.js +2 -0
- package/index.d.ts +2 -0
- package/index.js +1 -0
- package/marketplaces/openhands-extensions.json +30 -0
- package/package.json +9 -1
- package/plugins/openhands/skills/openhands-api/README.md +1 -1
- package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
- package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
- package/plugins/openhands/skills/openhands-sdk/SKILL.md +1 -0
- package/plugins/pr-review/skills/github-pr-review/SKILL.md +12 -2
- package/plugins/qa-changes/skills/github-pr-review/SKILL.md +12 -2
- package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/README.md +59 -0
- package/plugins/runtime-api-configs/SKILL.md +240 -0
- package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
- package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
- package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +183 -15
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/scripts/check_plugin_resources.py +116 -0
- package/scripts/sync_extensions.py +32 -5
- package/skills/canvas-extension-api/SKILL.md +34 -3
- package/skills/canvas-extension-api/references/backend-safety.md +14 -0
- package/skills/canvas-extension-api/references/connections.md +24 -0
- package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
- package/skills/canvas-extension-api/references/v1-contract.md +53 -3
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
- package/skills/github/scripts/agent_conversation.py +223 -43
- package/skills/github/scripts/github_client.py +183 -15
- package/skills/github-agents-md-maintainer/README.md +7 -0
- package/skills/github-agents-md-maintainer/SKILL.md +13 -6
- package/skills/github-agents-md-maintainer/scripts/github_client.py +183 -15
- package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
- package/skills/github-delivery-watchdog/scripts/github_client.py +183 -15
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-to-pr/scripts/github_client.py +183 -15
- package/skills/github-issue-to-pr/scripts/worker.py +10 -1
- package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-triage/scripts/github_client.py +183 -15
- package/skills/github-pr-review/SKILL.md +12 -2
- package/skills/github-pr-reviewer/README.md +36 -0
- package/skills/github-pr-reviewer/SKILL.md +147 -4
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
- package/skills/github-pr-reviewer/scripts/github_client.py +183 -15
- package/skills/github-pr-reviewer/scripts/main.py +17 -1
- package/skills/github-pr-reviewer/scripts/worker.py +842 -57
- package/skills/github-repo-monitor/scripts/github_client.py +183 -15
- package/skills/github-repo-monitor/scripts/main.py +32 -1
- package/skills/github-stale-ci-pr-closer/.claude-plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/.codex-plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/.plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/README.md +9 -0
- package/skills/github-stale-ci-pr-closer/SKILL.md +24 -0
- package/skills/github-stale-ci-pr-closer/commands/github-stale-ci-pr-closer.md +8 -0
- package/skills/github-stale-ci-pr-closer/scripts/worker.py +508 -0
- package/skills/github-stale-ci-pr-closer/tests/test_worker.py +353 -0
- package/skills/gitlab-issue-to-mr/README.md +12 -0
- package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
- package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
- package/skills/incident-retrospective/SKILL.md +5 -5
- package/skills/index.js +34 -16
- package/skills/jira-issue-to-pr/README.md +12 -4
- package/skills/jira-issue-to-pr/SKILL.md +129 -18
- package/skills/jira-issue-to-pr/references/setup.md +8 -0
- package/skills/jira-issue-to-pr/scripts/main.py +181 -54
- package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
- package/skills/linear-triage/SKILL.md +5 -5
- package/skills/news-digest/README.md +6 -0
- package/skills/news-digest/SKILL.md +19 -9
- package/skills/news-digest/commands/news-digest-setup.md +1 -1
- package/skills/news-digest/scripts/main.py +133 -15
- package/skills/openhands-api/README.md +1 -1
- package/skills/openhands-api/SKILL.md +3 -3
- package/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/skills/openhands-automation/SKILL.md +66 -4
- package/skills/openhands-automation/references/custom-automation.md +21 -7
- package/skills/openhands-sdk/SKILL.md +1 -0
- package/skills/research-brief/SKILL.md +5 -5
- package/skills/slack-standup-digest/SKILL.md +5 -5
- package/skills/ticket-to-code-change/SKILL.md +6 -3
- package/skills/use-jev/.claude-plugin/plugin.json +8 -0
- package/skills/use-jev/.codex-plugin/plugin.json +8 -0
- package/skills/use-jev/.plugin/plugin.json +8 -0
- package/skills/use-jev/README.md +66 -0
- package/skills/use-jev/SKILL.md +66 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
- package/tests/test_agent_conversation_dispatch.py +212 -0
- package/tests/test_apps_catalog.py +26 -0
- package/tests/test_automation_setup.py +44 -0
- package/tests/test_check_pr_description.py +89 -6
- package/tests/test_github_automation_foundation.py +158 -0
- package/tests/test_github_repo_monitor.py +107 -0
- package/tests/test_github_reviewer_delivery.py +2247 -6
- package/tests/test_github_stale_ci_pr_closer.py +56 -0
- package/tests/test_jira_issue_to_pr.py +312 -0
- package/tests/test_jira_setup_webhook.py +309 -0
- package/tests/test_openhands_api_python.py +37 -0
- package/tests/test_skill_plugin_loading.py +25 -15
- package/tests/test_sync_extensions.py +102 -0
- package/tests/test_vscode_app.py +33 -0
- 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
|