@openhands/extensions 0.25.0 → 0.27.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 +24 -0
- package/.codex-plugin/marketplace.json +24 -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 +24 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +3 -2
- package/README.md +9 -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/README.md +7 -6
- package/automations/bundle-index.js +15 -15
- package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
- package/automations/catalog/github-pr-reviewer/manifest.json +5 -5
- package/automations/catalog/github-stale-ci-pr-closer/manifest.json +3 -3
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
- package/automations/catalog/news-digest/manifest.json +2 -2
- package/automations/index.d.ts +9 -1
- package/automations/interface.json +9 -0
- package/automations/interface.schema.json +37 -2
- package/index.d.ts +2 -0
- package/index.js +1 -0
- package/marketplaces/openhands-extensions.json +24 -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/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 +67 -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 +67 -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 +67 -15
- package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
- package/skills/github-delivery-watchdog/scripts/github_client.py +67 -15
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-to-pr/scripts/github_client.py +67 -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 +67 -15
- package/skills/github-pr-reviewer/README.md +5 -4
- package/skills/github-pr-reviewer/SKILL.md +8 -17
- package/skills/github-pr-reviewer/references/state-schema.md +0 -15
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
- package/skills/github-pr-reviewer/scripts/github_client.py +67 -15
- package/skills/github-pr-reviewer/scripts/worker.py +131 -108
- package/skills/github-repo-monitor/scripts/github_client.py +67 -15
- package/skills/github-stale-ci-pr-closer/scripts/worker.py +223 -50
- package/skills/github-stale-ci-pr-closer/tests/test_worker.py +155 -15
- 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 +23 -14
- 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/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 +9 -9
- 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_reviewer_delivery.py +254 -118
- package/tests/test_github_stale_ci_pr_closer.py +56 -0
- package/tests/test_interface_manifest.py +23 -1
- 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
|
@@ -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 -->")
|