@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
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
],
|
|
21
21
|
"githubTokenSecret": "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
22
22
|
"maintainers": "",
|
|
23
|
-
"triggerReviewer": "all-hands-bot"
|
|
23
|
+
"triggerReviewer": "all-hands-bot",
|
|
24
|
+
"maxNewPerRun": 2
|
|
24
25
|
},
|
|
25
26
|
"upload": {
|
|
26
27
|
"request": {
|
|
@@ -67,7 +68,7 @@
|
|
|
67
68
|
"timeout": 300,
|
|
68
69
|
"template": {
|
|
69
70
|
"id": "github-pr-reviewer",
|
|
70
|
-
"version": "1.
|
|
71
|
+
"version": "1.8.1",
|
|
71
72
|
"config": {
|
|
72
73
|
"repos": [
|
|
73
74
|
"OpenHands/agent-server-gui"
|
|
@@ -76,7 +77,8 @@
|
|
|
76
77
|
"review_tone": "thorough",
|
|
77
78
|
"github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
78
79
|
"maintainers": "",
|
|
79
|
-
"trigger_reviewer": "all-hands-bot"
|
|
80
|
+
"trigger_reviewer": "all-hands-bot",
|
|
81
|
+
"max_new_per_run": 2
|
|
80
82
|
}
|
|
81
83
|
}
|
|
82
84
|
}
|
|
@@ -106,7 +108,7 @@
|
|
|
106
108
|
"timeout": 300,
|
|
107
109
|
"template": {
|
|
108
110
|
"id": "github-pr-reviewer",
|
|
109
|
-
"version": "1.
|
|
111
|
+
"version": "1.8.1",
|
|
110
112
|
"config": {
|
|
111
113
|
"repos": [
|
|
112
114
|
"OpenHands/agent-server-gui"
|
|
@@ -115,7 +117,8 @@
|
|
|
115
117
|
"review_tone": "thorough",
|
|
116
118
|
"github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
117
119
|
"maintainers": "",
|
|
118
|
-
"trigger_reviewer": "all-hands-bot"
|
|
120
|
+
"trigger_reviewer": "all-hands-bot",
|
|
121
|
+
"max_new_per_run": 2
|
|
119
122
|
}
|
|
120
123
|
}
|
|
121
124
|
}
|
|
@@ -162,7 +165,8 @@
|
|
|
162
165
|
],
|
|
163
166
|
"githubTokenSecret": "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
164
167
|
"maintainers": "",
|
|
165
|
-
"triggerReviewer": "all-hands-bot"
|
|
168
|
+
"triggerReviewer": "all-hands-bot",
|
|
169
|
+
"maxNewPerRun": 2
|
|
166
170
|
},
|
|
167
171
|
"upload": {
|
|
168
172
|
"request": {
|
|
@@ -209,7 +213,7 @@
|
|
|
209
213
|
"timeout": 300,
|
|
210
214
|
"template": {
|
|
211
215
|
"id": "github-pr-reviewer",
|
|
212
|
-
"version": "1.
|
|
216
|
+
"version": "1.8.1",
|
|
213
217
|
"config": {
|
|
214
218
|
"repos": [
|
|
215
219
|
"OpenHands/agent-server-gui",
|
|
@@ -219,7 +223,8 @@
|
|
|
219
223
|
"review_tone": "thorough",
|
|
220
224
|
"github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
221
225
|
"maintainers": "",
|
|
222
|
-
"trigger_reviewer": "all-hands-bot"
|
|
226
|
+
"trigger_reviewer": "all-hands-bot",
|
|
227
|
+
"max_new_per_run": 2
|
|
223
228
|
}
|
|
224
229
|
}
|
|
225
230
|
}
|
|
@@ -249,7 +254,7 @@
|
|
|
249
254
|
"timeout": 300,
|
|
250
255
|
"template": {
|
|
251
256
|
"id": "github-pr-reviewer",
|
|
252
|
-
"version": "1.
|
|
257
|
+
"version": "1.8.1",
|
|
253
258
|
"config": {
|
|
254
259
|
"repos": [
|
|
255
260
|
"OpenHands/agent-server-gui",
|
|
@@ -259,7 +264,8 @@
|
|
|
259
264
|
"review_tone": "thorough",
|
|
260
265
|
"github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
261
266
|
"maintainers": "",
|
|
262
|
-
"trigger_reviewer": "all-hands-bot"
|
|
267
|
+
"trigger_reviewer": "all-hands-bot",
|
|
268
|
+
"max_new_per_run": 2
|
|
263
269
|
}
|
|
264
270
|
}
|
|
265
271
|
}
|
|
@@ -290,7 +296,7 @@
|
|
|
290
296
|
"preset_metadata": {
|
|
291
297
|
"template": {
|
|
292
298
|
"id": "github-pr-reviewer",
|
|
293
|
-
"version": "1.
|
|
299
|
+
"version": "1.8.1",
|
|
294
300
|
"config": {
|
|
295
301
|
"repos": [
|
|
296
302
|
"OpenHands/agent-server-gui",
|
|
@@ -300,7 +306,8 @@
|
|
|
300
306
|
"review_tone": "thorough",
|
|
301
307
|
"github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
302
308
|
"maintainers": "",
|
|
303
|
-
"trigger_reviewer": "all-hands-bot"
|
|
309
|
+
"trigger_reviewer": "all-hands-bot",
|
|
310
|
+
"max_new_per_run": 2
|
|
304
311
|
}
|
|
305
312
|
}
|
|
306
313
|
}
|
|
@@ -322,7 +329,8 @@
|
|
|
322
329
|
],
|
|
323
330
|
"githubTokenSecret": "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
324
331
|
"maintainers": "",
|
|
325
|
-
"triggerReviewer": "all-hands-bot"
|
|
332
|
+
"triggerReviewer": "all-hands-bot",
|
|
333
|
+
"maxNewPerRun": 2
|
|
326
334
|
},
|
|
327
335
|
"preflight": {
|
|
328
336
|
"request": {
|
|
@@ -343,7 +351,7 @@
|
|
|
343
351
|
"timeout": 300,
|
|
344
352
|
"template": {
|
|
345
353
|
"id": "github-pr-reviewer",
|
|
346
|
-
"version": "1.
|
|
354
|
+
"version": "1.8.1",
|
|
347
355
|
"config": {
|
|
348
356
|
"repos": [
|
|
349
357
|
"OpenHands/agent-server-gui"
|
|
@@ -352,7 +360,8 @@
|
|
|
352
360
|
"review_tone": "concise",
|
|
353
361
|
"github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
354
362
|
"maintainers": "",
|
|
355
|
-
"trigger_reviewer": "all-hands-bot"
|
|
363
|
+
"trigger_reviewer": "all-hands-bot",
|
|
364
|
+
"max_new_per_run": 2
|
|
356
365
|
}
|
|
357
366
|
}
|
|
358
367
|
}
|
|
@@ -390,7 +399,8 @@
|
|
|
390
399
|
],
|
|
391
400
|
"githubTokenSecret": "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
392
401
|
"maintainers": "",
|
|
393
|
-
"triggerReviewer": "all-hands-bot"
|
|
402
|
+
"triggerReviewer": "all-hands-bot",
|
|
403
|
+
"maxNewPerRun": 2
|
|
394
404
|
},
|
|
395
405
|
"upload": {
|
|
396
406
|
"request": {
|
|
@@ -434,7 +444,7 @@
|
|
|
434
444
|
"timeout": 300,
|
|
435
445
|
"template": {
|
|
436
446
|
"id": "github-pr-reviewer",
|
|
437
|
-
"version": "1.
|
|
447
|
+
"version": "1.8.1",
|
|
438
448
|
"config": {
|
|
439
449
|
"repos": [
|
|
440
450
|
"OpenHands/agent-server-gui"
|
|
@@ -443,7 +453,8 @@
|
|
|
443
453
|
"review_tone": "concise",
|
|
444
454
|
"github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
445
455
|
"maintainers": "",
|
|
446
|
-
"trigger_reviewer": "all-hands-bot"
|
|
456
|
+
"trigger_reviewer": "all-hands-bot",
|
|
457
|
+
"max_new_per_run": 2
|
|
447
458
|
}
|
|
448
459
|
}
|
|
449
460
|
}
|
|
@@ -484,7 +495,8 @@
|
|
|
484
495
|
],
|
|
485
496
|
"githubTokenSecret": "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
486
497
|
"maintainers": "",
|
|
487
|
-
"triggerReviewer": "all-hands-bot"
|
|
498
|
+
"triggerReviewer": "all-hands-bot",
|
|
499
|
+
"maxNewPerRun": 2
|
|
488
500
|
},
|
|
489
501
|
"upload": {
|
|
490
502
|
"request": {
|
|
@@ -528,7 +540,7 @@
|
|
|
528
540
|
"timeout": 300,
|
|
529
541
|
"template": {
|
|
530
542
|
"id": "github-pr-reviewer",
|
|
531
|
-
"version": "1.
|
|
543
|
+
"version": "1.8.1",
|
|
532
544
|
"config": {
|
|
533
545
|
"repos": [
|
|
534
546
|
"OpenHands/agent-server-gui"
|
|
@@ -537,7 +549,8 @@
|
|
|
537
549
|
"review_tone": "concise",
|
|
538
550
|
"github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
539
551
|
"maintainers": "",
|
|
540
|
-
"trigger_reviewer": "all-hands-bot"
|
|
552
|
+
"trigger_reviewer": "all-hands-bot",
|
|
553
|
+
"max_new_per_run": 2
|
|
541
554
|
}
|
|
542
555
|
},
|
|
543
556
|
"repos": [
|
|
@@ -580,7 +593,8 @@
|
|
|
580
593
|
"OpenHands/automation"
|
|
581
594
|
],
|
|
582
595
|
"githubTokenSecret": "GITHUB_TOKEN_REVIEW",
|
|
583
|
-
"maintainers": "neubig,malhotra5"
|
|
596
|
+
"maintainers": "neubig,malhotra5",
|
|
597
|
+
"maxNewPerRun": 2
|
|
584
598
|
},
|
|
585
599
|
"preflight": {
|
|
586
600
|
"request": {
|
|
@@ -602,7 +616,7 @@
|
|
|
602
616
|
"timeout": 300,
|
|
603
617
|
"template": {
|
|
604
618
|
"id": "github-pr-reviewer",
|
|
605
|
-
"version": "1.
|
|
619
|
+
"version": "1.8.1",
|
|
606
620
|
"config": {
|
|
607
621
|
"repos": [
|
|
608
622
|
"OpenHands/automation"
|
|
@@ -611,7 +625,8 @@
|
|
|
611
625
|
"trigger_reviewer": "all-hands-bot",
|
|
612
626
|
"review_tone": "concise",
|
|
613
627
|
"maintainers": "neubig,malhotra5",
|
|
614
|
-
"github_token_secret": "GITHUB_TOKEN_REVIEW"
|
|
628
|
+
"github_token_secret": "GITHUB_TOKEN_REVIEW",
|
|
629
|
+
"max_new_per_run": 2
|
|
615
630
|
}
|
|
616
631
|
}
|
|
617
632
|
}
|
|
@@ -94,6 +94,9 @@ def test_new_subject_uses_selected_profile_and_persists_mapping(monkeypatch):
|
|
|
94
94
|
"subject": "repo:issue:7",
|
|
95
95
|
"conversation_id": result["conversation_id"],
|
|
96
96
|
"delivery": "revision-1",
|
|
97
|
+
# A caller with no revision identity records an empty head, which keeps
|
|
98
|
+
# the dedupe keyed on `delivery` alone.
|
|
99
|
+
"head": "",
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
|
|
@@ -177,6 +180,89 @@ def test_same_delivery_resumes_only_inactive_conversation(
|
|
|
177
180
|
conversation.run.assert_not_called()
|
|
178
181
|
|
|
179
182
|
|
|
183
|
+
def test_new_delivery_on_a_running_same_head_is_not_a_second_review(monkeypatch):
|
|
184
|
+
"""A fresh trigger for a head already under review must not start a review.
|
|
185
|
+
|
|
186
|
+
This is the duplicate the trigger-keyed delivery alone allowed: a second
|
|
187
|
+
review request (or a label re-applied after the bot's own handoff) produces a
|
|
188
|
+
new delivery key, which would send a new turn and publish a second review of
|
|
189
|
+
the head an in-flight conversation is already reviewing.
|
|
190
|
+
"""
|
|
191
|
+
state = {
|
|
192
|
+
_state_key("repo:pr:9"): {
|
|
193
|
+
"subject": "repo:pr:9",
|
|
194
|
+
"conversation_id": "22222222-2222-4222-8222-222222222222",
|
|
195
|
+
"delivery": "42:head-2",
|
|
196
|
+
"head": "head-2",
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
_fake_kv(monkeypatch, state)
|
|
200
|
+
monkeypatch.setattr(agent_conversation, "_register_tools", lambda: None)
|
|
201
|
+
workspace = MagicMock()
|
|
202
|
+
workspace.__enter__.return_value = workspace
|
|
203
|
+
workspace.get_secrets.return_value = {}
|
|
204
|
+
monkeypatch.setattr(
|
|
205
|
+
agent_conversation, "RemoteWorkspace", lambda **kwargs: workspace
|
|
206
|
+
)
|
|
207
|
+
conversation = MagicMock()
|
|
208
|
+
conversation.state.execution_status = ConversationExecutionStatus.RUNNING
|
|
209
|
+
monkeypatch.setattr(
|
|
210
|
+
agent_conversation.RemoteConversation,
|
|
211
|
+
"attach",
|
|
212
|
+
MagicMock(return_value=conversation),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
with _dispatcher(monkeypatch) as dispatcher:
|
|
216
|
+
result = dispatcher.deliver("repo:pr:9", "43:head-2", "again", head="head-2")
|
|
217
|
+
|
|
218
|
+
assert result["disposition"] == "in_progress"
|
|
219
|
+
conversation.send_message.assert_not_called()
|
|
220
|
+
conversation.run.assert_not_called()
|
|
221
|
+
# The revision the live turn is working on is left recorded, not overwritten
|
|
222
|
+
# by the trigger that arrived beside it.
|
|
223
|
+
assert state[_state_key("repo:pr:9")]["delivery"] == "42:head-2"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_new_delivery_on_a_running_new_head_still_sends_a_turn(monkeypatch):
|
|
227
|
+
"""A moved head is new work, so the running conversation gets the new turn."""
|
|
228
|
+
state = {
|
|
229
|
+
_state_key("repo:pr:9"): {
|
|
230
|
+
"subject": "repo:pr:9",
|
|
231
|
+
"conversation_id": "22222222-2222-4222-8222-222222222222",
|
|
232
|
+
"delivery": "42:head-1",
|
|
233
|
+
"head": "head-1",
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
_fake_kv(monkeypatch, state)
|
|
237
|
+
monkeypatch.setattr(agent_conversation, "_register_tools", lambda: None)
|
|
238
|
+
workspace = MagicMock()
|
|
239
|
+
workspace.__enter__.return_value = workspace
|
|
240
|
+
workspace.get_secrets.return_value = {}
|
|
241
|
+
monkeypatch.setattr(
|
|
242
|
+
agent_conversation, "RemoteWorkspace", lambda **kwargs: workspace
|
|
243
|
+
)
|
|
244
|
+
conversation = MagicMock()
|
|
245
|
+
conversation.state.execution_status = ConversationExecutionStatus.RUNNING
|
|
246
|
+
monkeypatch.setattr(
|
|
247
|
+
agent_conversation.RemoteConversation,
|
|
248
|
+
"attach",
|
|
249
|
+
MagicMock(return_value=conversation),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
with _dispatcher(monkeypatch) as dispatcher:
|
|
253
|
+
result = dispatcher.deliver("repo:pr:9", "43:head-2", "next", head="head-2")
|
|
254
|
+
|
|
255
|
+
assert result["disposition"] == "resumed"
|
|
256
|
+
conversation.send_message.assert_called_once_with("next")
|
|
257
|
+
conversation.run.assert_called_once_with(blocking=False)
|
|
258
|
+
assert state[_state_key("repo:pr:9")] == {
|
|
259
|
+
"subject": "repo:pr:9",
|
|
260
|
+
"conversation_id": result["conversation_id"],
|
|
261
|
+
"delivery": "43:head-2",
|
|
262
|
+
"head": "head-2",
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
180
266
|
def test_subjects_use_independent_kv_records(monkeypatch):
|
|
181
267
|
state = {}
|
|
182
268
|
_fake_kv(monkeypatch, state)
|
|
@@ -209,3 +295,129 @@ def test_subjects_use_independent_kv_records(monkeypatch):
|
|
|
209
295
|
_state_key("repo:issue:7"),
|
|
210
296
|
_state_key("repo:issue:8"),
|
|
211
297
|
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# --- OpenHands Cloud and Enterprise: conversations go through the OpenHands API
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class _FakeCloud:
|
|
304
|
+
"""The OpenHands API's conversations, as the dispatcher uses them."""
|
|
305
|
+
|
|
306
|
+
def __init__(self, conversation=None):
|
|
307
|
+
self.conversation = conversation
|
|
308
|
+
self.started = []
|
|
309
|
+
self.sent = []
|
|
310
|
+
|
|
311
|
+
def get(self, conversation_id):
|
|
312
|
+
return self.conversation
|
|
313
|
+
|
|
314
|
+
def start(self, conversation_id, profile_id, title, prompt):
|
|
315
|
+
self.started.append({"id": str(conversation_id), "profile": str(profile_id)})
|
|
316
|
+
|
|
317
|
+
def send(self, conversation, prompt):
|
|
318
|
+
self.sent.append(prompt)
|
|
319
|
+
|
|
320
|
+
def run(self, conversation):
|
|
321
|
+
pass
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _cloud_dispatcher(monkeypatch, cloud):
|
|
325
|
+
monkeypatch.delenv("AGENT_SERVER_URL", raising=False)
|
|
326
|
+
monkeypatch.setenv("SESSION_API_KEY", "session")
|
|
327
|
+
monkeypatch.setenv(
|
|
328
|
+
"AUTOMATION_AGENT_PROFILE_ID", "11111111-1111-4111-8111-111111111111"
|
|
329
|
+
)
|
|
330
|
+
monkeypatch.setenv(
|
|
331
|
+
"AUTOMATION_EVENT_PAYLOAD", json.dumps({"automation_id": "automation-1"})
|
|
332
|
+
)
|
|
333
|
+
monkeypatch.setattr(agent_conversation, "CloudConversations", lambda: cloud)
|
|
334
|
+
return agent_conversation.AgentConversationDispatcher()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_cloud_run_starts_a_new_subject_with_the_selected_profile(monkeypatch):
|
|
338
|
+
# Arrange
|
|
339
|
+
state = {}
|
|
340
|
+
_fake_kv(monkeypatch, state)
|
|
341
|
+
cloud = _FakeCloud()
|
|
342
|
+
|
|
343
|
+
# Act
|
|
344
|
+
with _cloud_dispatcher(monkeypatch, cloud) as dispatcher:
|
|
345
|
+
result = dispatcher.deliver("repo:pr:7", "revision-1", "review it")
|
|
346
|
+
|
|
347
|
+
# Assert
|
|
348
|
+
assert result["disposition"] == "created"
|
|
349
|
+
[started] = cloud.started
|
|
350
|
+
assert started["profile"] == "11111111-1111-4111-8111-111111111111"
|
|
351
|
+
assert state[_state_key("repo:pr:7")]["conversation_id"] == started["id"]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def test_cloud_run_does_not_start_a_second_conversation_while_one_is_starting(
|
|
355
|
+
monkeypatch,
|
|
356
|
+
):
|
|
357
|
+
# Arrange - the first delivery's conversation is not listed by the API yet
|
|
358
|
+
state = {}
|
|
359
|
+
_fake_kv(monkeypatch, state)
|
|
360
|
+
cloud = _FakeCloud()
|
|
361
|
+
with _cloud_dispatcher(monkeypatch, cloud) as dispatcher:
|
|
362
|
+
dispatcher.deliver("repo:pr:7", "revision-1", "review it", head="sha-1")
|
|
363
|
+
recorded = dict(state[_state_key("repo:pr:7")])
|
|
364
|
+
|
|
365
|
+
# Act - a new revision arrives before it is
|
|
366
|
+
result = dispatcher.deliver(
|
|
367
|
+
"repo:pr:7", "revision-2", "review again", head="sha-2"
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# Assert - nothing is started, and the record is left for the next trigger
|
|
371
|
+
assert result["disposition"] == "in_progress"
|
|
372
|
+
assert len(cloud.started) == 1
|
|
373
|
+
assert state[_state_key("repo:pr:7")] == recorded
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def test_cloud_run_sends_a_new_revision_to_the_subjects_conversation(monkeypatch):
|
|
377
|
+
# Arrange
|
|
378
|
+
state = {
|
|
379
|
+
_state_key("repo:pr:7"): {
|
|
380
|
+
"conversation_id": "22222222-2222-4222-8222-222222222222",
|
|
381
|
+
"delivery": "revision-1",
|
|
382
|
+
"head": "sha-1",
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
_fake_kv(monkeypatch, state)
|
|
386
|
+
cloud = _FakeCloud({"sandbox_status": "RUNNING", "execution_status": "idle"})
|
|
387
|
+
|
|
388
|
+
# Act
|
|
389
|
+
with _cloud_dispatcher(monkeypatch, cloud) as dispatcher:
|
|
390
|
+
result = dispatcher.deliver(
|
|
391
|
+
"repo:pr:7", "revision-2", "review again", head="sha-2"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# Assert
|
|
395
|
+
assert result["disposition"] == "resumed"
|
|
396
|
+
assert cloud.sent == ["review again"]
|
|
397
|
+
assert cloud.started == []
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_cloud_run_replaces_a_conversation_whose_sandbox_is_gone(monkeypatch):
|
|
401
|
+
# Arrange
|
|
402
|
+
gone = "22222222-2222-4222-8222-222222222222"
|
|
403
|
+
state = {
|
|
404
|
+
_state_key("repo:pr:7"): {
|
|
405
|
+
"conversation_id": gone,
|
|
406
|
+
"delivery": "revision-1",
|
|
407
|
+
"head": "sha-1",
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
_fake_kv(monkeypatch, state)
|
|
411
|
+
cloud = _FakeCloud({"sandbox_status": "MISSING", "execution_status": None})
|
|
412
|
+
|
|
413
|
+
# Act
|
|
414
|
+
with _cloud_dispatcher(monkeypatch, cloud) as dispatcher:
|
|
415
|
+
result = dispatcher.deliver(
|
|
416
|
+
"repo:pr:7", "revision-2", "review again", head="sha-2"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# Assert - the API does not start an id twice, so the subject gets a new one
|
|
420
|
+
assert result["disposition"] == "created"
|
|
421
|
+
[started] = cloud.started
|
|
422
|
+
assert started["id"] != gone
|
|
423
|
+
assert state[_state_key("repo:pr:7")]["conversation_id"] == started["id"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_apps_catalog_is_valid_and_package_export_matches():
|
|
9
|
+
entries = json.loads((ROOT / "apps/catalog.json").read_text())
|
|
10
|
+
assert isinstance(entries, list)
|
|
11
|
+
assert len({entry["id"] for entry in entries}) == len(entries)
|
|
12
|
+
for entry in entries:
|
|
13
|
+
assert set(entry) == {"id", "name", "description", "source", "manifest", "beta"}
|
|
14
|
+
assert entry["id"] and entry["source"].startswith("./apps/")
|
|
15
|
+
assert entry["manifest"].endswith("/canvas-extension.json")
|
|
16
|
+
assert entry["beta"] is True
|
|
17
|
+
subprocess.run(
|
|
18
|
+
[
|
|
19
|
+
"node",
|
|
20
|
+
"--input-type=module",
|
|
21
|
+
"-e",
|
|
22
|
+
"import { APPS_CATALOG } from './index.js'; if (!Array.isArray(APPS_CATALOG)) process.exit(1)",
|
|
23
|
+
],
|
|
24
|
+
cwd=ROOT,
|
|
25
|
+
check=True,
|
|
26
|
+
)
|
|
@@ -625,6 +625,25 @@ def _iter_strings(node):
|
|
|
625
625
|
yield node
|
|
626
626
|
|
|
627
627
|
|
|
628
|
+
def _scenario_template_versions(bundle: dict) -> set[str]:
|
|
629
|
+
"""Every template version a fixture pins, wherever it appears."""
|
|
630
|
+
versions = set()
|
|
631
|
+
|
|
632
|
+
def walk(node) -> None:
|
|
633
|
+
if isinstance(node, dict):
|
|
634
|
+
template = node.get("template")
|
|
635
|
+
if isinstance(template, dict) and "version" in template:
|
|
636
|
+
versions.add(template["version"])
|
|
637
|
+
for value in node.values():
|
|
638
|
+
walk(value)
|
|
639
|
+
elif isinstance(node, list):
|
|
640
|
+
for item in node:
|
|
641
|
+
walk(item)
|
|
642
|
+
|
|
643
|
+
walk(bundle["scenarios"])
|
|
644
|
+
return versions
|
|
645
|
+
|
|
646
|
+
|
|
628
647
|
def _scenarios(kind: str):
|
|
629
648
|
"""Every fixture scenario carrying the given block, as test params."""
|
|
630
649
|
for path in sorted(FIXTURE_DIR.glob("*.json")):
|
|
@@ -787,6 +806,31 @@ def test_multi_action_fixture_scenarios_name_the_selected_variant(
|
|
|
787
806
|
assert scenario.get("selectedAction") in actions, scenario["id"]
|
|
788
807
|
|
|
789
808
|
|
|
809
|
+
@pytest.mark.parametrize("fixture_path", list(_fixture_bundles()))
|
|
810
|
+
def test_fixture_template_versions_match_the_entry_to_pin_the_bump(
|
|
811
|
+
fixture_path: Path,
|
|
812
|
+
) -> None:
|
|
813
|
+
"""A fixture pins the template version the host will send, so it moves with
|
|
814
|
+
the entry. Pinning a stale version passes until the version is bumped, and
|
|
815
|
+
then every scenario in the fixture fails at once - the bump, not the
|
|
816
|
+
behavior, is what changed."""
|
|
817
|
+
bundle = _load(fixture_path)
|
|
818
|
+
entry = _entry_for(bundle)
|
|
819
|
+
|
|
820
|
+
expected = set()
|
|
821
|
+
if "version" in entry:
|
|
822
|
+
expected.add(entry["version"])
|
|
823
|
+
if "version" in entry.get("setup", {}).get("bundle", {}):
|
|
824
|
+
expected.add(entry["setup"]["bundle"]["version"])
|
|
825
|
+
|
|
826
|
+
pinned = _scenario_template_versions(bundle)
|
|
827
|
+
|
|
828
|
+
assert pinned <= expected, (
|
|
829
|
+
f"{fixture_path.stem}: fixture pins template version(s) {sorted(pinned)} "
|
|
830
|
+
f"but {entry['id']} declares {sorted(expected)}"
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
|
|
790
834
|
@pytest.mark.parametrize("entry_path", list(_setup_paths()))
|
|
791
835
|
def test_the_declared_features_match_the_archetype(entry_path: Path) -> None:
|
|
792
836
|
"""A bundle needs a deployment that runs a client-supplied tarball; a prompt
|
|
@@ -6,10 +6,9 @@ import sys
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
def
|
|
9
|
+
def _load(name: str, script_name: str):
|
|
10
10
|
repo_root = Path(__file__).resolve().parents[1]
|
|
11
|
-
script_path = repo_root / ".github" / "scripts" /
|
|
12
|
-
name = "check_pr_description"
|
|
11
|
+
script_path = repo_root / ".github" / "scripts" / script_name
|
|
13
12
|
spec = importlib.util.spec_from_file_location(name, script_path)
|
|
14
13
|
assert spec and spec.loader
|
|
15
14
|
mod = importlib.util.module_from_spec(spec)
|
|
@@ -18,9 +17,12 @@ def _load_prod_module():
|
|
|
18
17
|
return mod
|
|
19
18
|
|
|
20
19
|
|
|
21
|
-
|
|
20
|
+
# `check_pr_description` resolves its sibling `markdown_sections` itself, so no
|
|
21
|
+
# preloading is needed here and this file genuinely exercises that contract.
|
|
22
|
+
_prod = _load("check_pr_description", "check_pr_description.py")
|
|
22
23
|
validate_pr_body = _prod.validate_pr_body
|
|
23
24
|
body_from_event = _prod.body_from_event
|
|
25
|
+
extract_human_note = _prod.extract_human_note
|
|
24
26
|
extract_linked_issue_numbers = _prod.extract_linked_issue_numbers
|
|
25
27
|
validate_linked_issue_ready = _prod.validate_linked_issue_ready
|
|
26
28
|
fetch_issue_details = _prod.fetch_issue_details
|
|
@@ -28,9 +30,11 @@ fetch_issue_details = _prod.fetch_issue_details
|
|
|
28
30
|
|
|
29
31
|
VALID_BODY = """<!-- Keep this PR as draft until it is ready for review. -->
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
HUMAN:
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
I checked this change and it does what the PR says.
|
|
36
|
+
|
|
37
|
+
AGENT:
|
|
34
38
|
|
|
35
39
|
---
|
|
36
40
|
|
|
@@ -64,6 +68,85 @@ def test_valid_pr_body_passes():
|
|
|
64
68
|
assert validate_pr_body(VALID_BODY) == []
|
|
65
69
|
|
|
66
70
|
|
|
71
|
+
def test_first_visible_line_must_be_human_marker():
|
|
72
|
+
body = VALID_BODY.replace("HUMAN:", "## Why\n\nSomething.\n\nHUMAN:", 1)
|
|
73
|
+
|
|
74
|
+
errors = validate_pr_body(body)
|
|
75
|
+
|
|
76
|
+
assert any("first visible line" in e for e in errors)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_human_note_must_clear_the_minimum_length():
|
|
80
|
+
body = VALID_BODY.replace(
|
|
81
|
+
"I checked this change and it does what the PR says.", "Looks good."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
errors = validate_pr_body(body)
|
|
85
|
+
|
|
86
|
+
assert any("human-written note" in e for e in errors)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_missing_human_section_is_reported():
|
|
90
|
+
body = VALID_BODY.replace("HUMAN:", "", 1)
|
|
91
|
+
|
|
92
|
+
errors = validate_pr_body(body)
|
|
93
|
+
|
|
94
|
+
assert any("first visible line" in e for e in errors)
|
|
95
|
+
assert any("human-written note" in e for e in errors)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_agent_marker_must_be_kept():
|
|
99
|
+
body = VALID_BODY.replace("AGENT:", "", 1)
|
|
100
|
+
|
|
101
|
+
errors = validate_pr_body(body)
|
|
102
|
+
|
|
103
|
+
assert any("AGENT:" in e for e in errors)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_quoting_the_template_markers_in_a_fence_does_not_satisfy_the_gate():
|
|
107
|
+
# A body whose only HUMAN:/AGENT: markers are quoted inside a code fence has
|
|
108
|
+
# no real human attestation: the markers must appear outside fenced code.
|
|
109
|
+
body = VALID_BODY.replace(
|
|
110
|
+
"HUMAN:\n\nI checked this change and it does what the PR says.\n\nAGENT:",
|
|
111
|
+
"```text\nHUMAN:\n\nfill this in yourself\n\nAGENT:\n```",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
errors = validate_pr_body(body)
|
|
115
|
+
|
|
116
|
+
assert any("first visible line" in e for e in errors)
|
|
117
|
+
assert any("human-written note" in e for e in errors)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_human_note_stops_at_the_agent_marker():
|
|
121
|
+
# Text after AGENT: must not count toward the human note.
|
|
122
|
+
body = VALID_BODY.replace(
|
|
123
|
+
"I checked this change and it does what the PR says.",
|
|
124
|
+
"ok\n\nAGENT:\n\n" + "agent padding " * 5,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
assert extract_human_note(body) == "ok"
|
|
128
|
+
assert any("human-written note" in e for e in validate_pr_body(body))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_human_note_and_sections_survive_crlf_bodies():
|
|
132
|
+
body = VALID_BODY.replace("\n", "\r\n")
|
|
133
|
+
|
|
134
|
+
assert validate_pr_body(body) == []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_template_fields_must_be_outside_fenced_code_blocks():
|
|
138
|
+
# Quoting the template's headings in a fence must not satisfy the sections.
|
|
139
|
+
body = (
|
|
140
|
+
"HUMAN:\n\nI reviewed this change carefully and it works as described.\n\n"
|
|
141
|
+
"AGENT:\n\n```markdown\n## Why\n\nx\n\n## Summary\n\n- y\n\n"
|
|
142
|
+
"## How to Test\n\nz\n```\n"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
errors = validate_pr_body(body)
|
|
146
|
+
|
|
147
|
+
assert any("Keep the `## Why`" in e for e in errors)
|
|
148
|
+
|
|
149
|
+
|
|
67
150
|
def test_required_template_fields_must_be_present_and_filled():
|
|
68
151
|
how_to_test = "## How to Test\n\nRun `uv run --group test pytest tests/`."
|
|
69
152
|
body = VALID_BODY.replace(how_to_test, "## How to Test\n\n<!-- TODO -->")
|