@openhands/extensions 0.28.0 → 0.30.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/.agents/skills/custom-codereview-guide.md +44 -1
- package/.claude-plugin/marketplace.json +7 -5
- package/.codex-plugin/marketplace.json +7 -5
- package/.github/workflows/build-vscode-app.yml +3 -3
- package/.github/workflows/check-extensions.yml +1 -1
- package/.github/workflows/tests.yml +1 -1
- package/.plugin/marketplace.json +7 -5
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +7 -2
- package/README.md +3 -3
- package/apps/vscode/README.md +8 -6
- package/apps/vscode/canvas-extension.json +11 -3
- package/apps/vscode/extension.js +60 -12
- package/apps/vscode/scripts/package.mjs +20 -74
- package/automations/bundle-index.js +9 -7
- package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
- package/automations/catalog/github-issue-triage/manifest.json +4 -3
- package/automations/catalog/github-pr-reviewer/manifest.json +5 -4
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
- package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
- package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
- package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
- package/integrations/catalog/atlassian-rovo.json +4 -3
- package/integrations/catalog/bitbucket.json +16 -1
- package/integrations/catalog/github.json +1 -0
- package/integrations/catalog/gitlab.json +1 -1
- package/marketplaces/openhands-extensions.json +7 -5
- package/package.json +1 -1
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +76 -8
- package/plugins/pr-review/README.md +1 -0
- package/plugins/pr-review/scripts/prompt.py +44 -11
- package/plugins/runtime-api-configs/SKILL.md +1 -1
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/canvas-extension-api/SKILL.md +4 -1
- package/skills/canvas-extension-api/references/backend-safety.md +3 -3
- package/skills/canvas-extension-api/references/native-design.md +250 -0
- package/skills/canvas-extension-api/references/v1-contract.md +7 -4
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
- package/skills/github/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-triage/README.md +73 -14
- package/skills/github-issue-triage/SKILL.md +26 -46
- package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
- package/skills/github-issue-triage/scripts/worker.py +153 -103
- package/skills/github-pr-reviewer/README.md +103 -1
- package/skills/github-pr-reviewer/SKILL.md +35 -8
- package/skills/github-pr-reviewer/references/state-schema.md +8 -0
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
- package/skills/github-pr-reviewer/scripts/main.py +163 -39
- package/skills/github-pr-reviewer/scripts/publication.py +193 -0
- package/skills/github-pr-reviewer/scripts/worker.py +297 -8
- package/skills/github-pr-reviewer/tests/test_main.py +73 -5
- package/skills/gitlab-issue-to-mr/README.md +10 -3
- package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
- package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
- package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
- package/skills/index.js +10 -10
- package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/README.md +18 -11
- package/skills/jira-issue-to-pr/SKILL.md +72 -23
- package/skills/jira-issue-to-pr/references/setup.md +43 -12
- package/skills/jira-issue-to-pr/scripts/main.py +136 -26
- package/skills/openhands-automation/references/custom-automation.md +76 -8
- package/skills/slack-channel-monitor/README.md +6 -1
- package/skills/slack-channel-monitor/SKILL.md +6 -3
- package/skills/slack-channel-monitor/scripts/main.py +36 -4
- package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/SKILL.md +5 -3
- package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/test_agent_conversation_dispatch.py +173 -6
- package/tests/test_automation_llm_provenance.py +1209 -0
- package/tests/test_github_review_publication.py +422 -0
- package/tests/test_github_reviewer_delivery.py +174 -21
- package/tests/test_github_triage_delivery.py +411 -238
- package/tests/test_gitlab_issue_to_mr.py +284 -0
- package/tests/test_jira_issue_to_pr.py +200 -6
- package/tests/test_pr_review_prompt_injection.py +353 -0
- package/tests/test_slack_channel_monitor.py +32 -1
- package/tests/test_vscode_app.py +8 -2
- package/tests/vscode-app.test.mjs +8 -2
|
@@ -35,6 +35,62 @@ def main(monkeypatch, tmp_path):
|
|
|
35
35
|
return _load_module(monkeypatch, tmp_path / "workspace")
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
CLOUD_URL = "https://app.example.test"
|
|
39
|
+
GITLAB_MCP_URL = "https://gitlab.com/api/v4/mcp"
|
|
40
|
+
CALL_TOOL_PATH = "/api/v1/mcp/servers/gitlab/call-tool"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def cloud_main(monkeypatch, tmp_path):
|
|
45
|
+
"""The script as a cloud run sees it: handed the OpenHands API, no agent server."""
|
|
46
|
+
monkeypatch.setenv("OPENHANDS_CLOUD_API_URL", CLOUD_URL)
|
|
47
|
+
monkeypatch.setenv("OPENHANDS_API_KEY", "openhands-key")
|
|
48
|
+
monkeypatch.setenv("SANDBOX_ID", "sandbox-1")
|
|
49
|
+
monkeypatch.setenv("SESSION_API_KEY", "session-key")
|
|
50
|
+
monkeypatch.delenv("AGENT_SERVER_URL", raising=False)
|
|
51
|
+
return _load_module(monkeypatch, tmp_path / "workspace")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CloudApi:
|
|
55
|
+
"""Stand-in for the OpenHands API: records every call and answers from a table."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, settings=None, tools=None):
|
|
58
|
+
self.calls = []
|
|
59
|
+
self.settings = settings or {
|
|
60
|
+
"agent_settings": {
|
|
61
|
+
"mcp_config": {
|
|
62
|
+
"gitlab": {"url": GITLAB_MCP_URL, "transport": "http"},
|
|
63
|
+
"rovo": {"url": "https://mcp.atlassian.com/v2/mcp"},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
# Tool name -> result dict (what the tool's JSON text decodes to), or a
|
|
68
|
+
# callable taking the arguments.
|
|
69
|
+
self.tools = tools or {}
|
|
70
|
+
|
|
71
|
+
def __call__(self, method, path, body=None):
|
|
72
|
+
self.calls.append({"method": method, "path": path, "body": body})
|
|
73
|
+
if path == "/api/v1/settings":
|
|
74
|
+
return self.settings
|
|
75
|
+
if path == CALL_TOOL_PATH:
|
|
76
|
+
answer = self.tools[body["name"]]
|
|
77
|
+
if callable(answer):
|
|
78
|
+
answer = answer(body["arguments"])
|
|
79
|
+
if isinstance(answer, str):
|
|
80
|
+
return {"is_error": True, "text": answer}
|
|
81
|
+
return {"is_error": False, "text": json.dumps(answer)}
|
|
82
|
+
raise AssertionError(f"unexpected call {method} {path}")
|
|
83
|
+
|
|
84
|
+
def tool_calls(self, name):
|
|
85
|
+
return [c["body"]["arguments"] for c in self.calls if c["path"] == CALL_TOOL_PATH and c["body"]["name"] == name]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _wire(cloud_main, monkeypatch, api):
|
|
89
|
+
monkeypatch.setattr(cloud_main, "_cloud_request", api)
|
|
90
|
+
monkeypatch.setattr(cloud_main, "GITLAB_MCP_SERVER", "gitlab")
|
|
91
|
+
return api
|
|
92
|
+
|
|
93
|
+
|
|
38
94
|
def _http_error(code: int, body: bytes = b"{}") -> urllib.error.HTTPError:
|
|
39
95
|
return urllib.error.HTTPError("https://gitlab.com/api/v4", code, "err", {}, None)
|
|
40
96
|
|
|
@@ -397,6 +453,234 @@ def test_the_conversation_carries_the_mcp_config_when_there_is_one(main, monkeyp
|
|
|
397
453
|
assert sent["body"]["mcp_config"] == config
|
|
398
454
|
|
|
399
455
|
|
|
456
|
+
# ── GitLab through a connected MCP server (cloud runs) ────────────────────────
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def test_a_cloud_run_uses_the_connected_gitlab_mcp_server_and_reads_no_token(cloud_main, monkeypatch):
|
|
460
|
+
"""The MCP connection replaces the token: no secret is read, no token verified."""
|
|
461
|
+
api = CloudApi()
|
|
462
|
+
monkeypatch.setattr(cloud_main, "_cloud_request", api)
|
|
463
|
+
read = []
|
|
464
|
+
monkeypatch.setattr(cloud_main, "get_secret", lambda name: read.append(name) or "token")
|
|
465
|
+
|
|
466
|
+
assert cloud_main._find_gitlab_mcp_server() == "gitlab"
|
|
467
|
+
assert read == []
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def test_a_configured_server_name_is_used_without_looking_at_settings(cloud_main, monkeypatch):
|
|
471
|
+
api = CloudApi()
|
|
472
|
+
monkeypatch.setattr(cloud_main, "_cloud_request", api)
|
|
473
|
+
monkeypatch.setattr(cloud_main, "GITLAB_MCP_SERVER_NAME", "my-gitlab")
|
|
474
|
+
|
|
475
|
+
assert cloud_main._find_gitlab_mcp_server() == "my-gitlab"
|
|
476
|
+
assert api.calls == []
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def test_a_cloud_run_without_a_gitlab_mcp_server_still_resolves_the_token(cloud_main, monkeypatch):
|
|
480
|
+
"""The fallback is unchanged: GITLAB_TOKEN first, then the connected integration's token."""
|
|
481
|
+
api = CloudApi(settings={"agent_settings": {"mcp_config": {
|
|
482
|
+
"rovo": {"url": "https://mcp.atlassian.com/v2/mcp"},
|
|
483
|
+
"other": {"url": "https://gitlab.example.com/api/v4/mcp"},
|
|
484
|
+
}}})
|
|
485
|
+
monkeypatch.setattr(cloud_main, "_cloud_request", api)
|
|
486
|
+
|
|
487
|
+
def get_secret(name):
|
|
488
|
+
if name == "gitlab_token":
|
|
489
|
+
return "integration-token"
|
|
490
|
+
raise RuntimeError("no such secret")
|
|
491
|
+
|
|
492
|
+
monkeypatch.setattr(cloud_main, "get_secret", get_secret)
|
|
493
|
+
|
|
494
|
+
assert cloud_main._find_gitlab_mcp_server() is None
|
|
495
|
+
assert cloud_main._resolve_gitlab_token() == "integration-token"
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def test_the_cloud_error_for_no_gitlab_access_names_every_option(cloud_main, monkeypatch):
|
|
499
|
+
def get_secret(name):
|
|
500
|
+
raise RuntimeError("no such secret")
|
|
501
|
+
|
|
502
|
+
monkeypatch.setattr(cloud_main, "get_secret", get_secret)
|
|
503
|
+
|
|
504
|
+
with pytest.raises(RuntimeError) as excinfo:
|
|
505
|
+
cloud_main._resolve_gitlab_token()
|
|
506
|
+
|
|
507
|
+
message = str(excinfo.value)
|
|
508
|
+
assert "GitLab MCP server" in message
|
|
509
|
+
assert "GitLab integration" in message
|
|
510
|
+
assert "GITLAB_TOKEN" in message
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def test_a_gitlab_token_secret_wins_over_a_connected_gitlab_mcp_server(cloud_main, monkeypatch):
|
|
514
|
+
"""The MCP tools need GitLab 19.4 or later, while a token works with any GitLab."""
|
|
515
|
+
api = CloudApi()
|
|
516
|
+
monkeypatch.setattr(cloud_main, "_cloud_request", api)
|
|
517
|
+
monkeypatch.setattr(
|
|
518
|
+
cloud_main, "get_secret", lambda name: "secret-token" if name == "GITLAB_TOKEN" else ""
|
|
519
|
+
)
|
|
520
|
+
verified = []
|
|
521
|
+
monkeypatch.setattr(cloud_main, "_verify_token", verified.append)
|
|
522
|
+
|
|
523
|
+
assert cloud_main._choose_gitlab_access() == "secret-token"
|
|
524
|
+
assert cloud_main.GITLAB_MCP_SERVER is None
|
|
525
|
+
assert verified == ["secret-token"]
|
|
526
|
+
assert api.calls == []
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def test_the_gitlab_sign_in_token_wins_over_a_connected_gitlab_mcp_server(cloud_main, monkeypatch):
|
|
530
|
+
"""A user who signed in with GitLab is not asked for a GITLAB_TOKEN secret on top."""
|
|
531
|
+
api = CloudApi()
|
|
532
|
+
monkeypatch.setattr(cloud_main, "_cloud_request", api)
|
|
533
|
+
|
|
534
|
+
def get_secret(name):
|
|
535
|
+
if name == "gitlab_token":
|
|
536
|
+
return "sign-in-token"
|
|
537
|
+
raise RuntimeError("no such secret")
|
|
538
|
+
|
|
539
|
+
monkeypatch.setattr(cloud_main, "get_secret", get_secret)
|
|
540
|
+
verified = []
|
|
541
|
+
monkeypatch.setattr(cloud_main, "_verify_token", verified.append)
|
|
542
|
+
|
|
543
|
+
assert cloud_main._choose_gitlab_access() == "sign-in-token"
|
|
544
|
+
assert cloud_main.GITLAB_MCP_SERVER is None
|
|
545
|
+
assert verified == ["sign-in-token"]
|
|
546
|
+
assert api.calls == []
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _no_secrets(name):
|
|
550
|
+
raise RuntimeError("no such secret")
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def test_without_any_gitlab_token_the_connected_gitlab_mcp_server_is_used(cloud_main, monkeypatch):
|
|
554
|
+
monkeypatch.setattr(cloud_main, "_cloud_request", CloudApi())
|
|
555
|
+
monkeypatch.setattr(cloud_main, "get_secret", _no_secrets)
|
|
556
|
+
verified = []
|
|
557
|
+
monkeypatch.setattr(cloud_main, "_verify_token", verified.append)
|
|
558
|
+
|
|
559
|
+
assert cloud_main._choose_gitlab_access() == ""
|
|
560
|
+
assert cloud_main.GITLAB_MCP_SERVER == "gitlab"
|
|
561
|
+
assert verified == []
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def test_without_a_token_or_a_gitlab_mcp_server_the_run_says_how_to_connect(cloud_main, monkeypatch):
|
|
565
|
+
monkeypatch.setattr(
|
|
566
|
+
cloud_main, "_cloud_request", CloudApi(settings={"agent_settings": {"mcp_config": {}}})
|
|
567
|
+
)
|
|
568
|
+
monkeypatch.setattr(cloud_main, "get_secret", _no_secrets)
|
|
569
|
+
|
|
570
|
+
with pytest.raises(RuntimeError) as excinfo:
|
|
571
|
+
cloud_main._choose_gitlab_access()
|
|
572
|
+
|
|
573
|
+
message = str(excinfo.value)
|
|
574
|
+
assert "GitLab integration" in message and "GITLAB_TOKEN" in message
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def test_labelled_issues_are_listed_through_the_mcp_server(cloud_main, monkeypatch):
|
|
578
|
+
pages = {
|
|
579
|
+
None: {"nodes": [
|
|
580
|
+
{"iid": "42", "title": "Retry uploads", "webUrl": "https://gitlab.com/group/project/-/issues/42",
|
|
581
|
+
"state": "OPEN", "labels": {"nodes": [{"title": "openhands"}]}},
|
|
582
|
+
], "pageInfo": {"hasNextPage": True, "endCursor": "c2"}},
|
|
583
|
+
"c2": {"nodes": [
|
|
584
|
+
{"iid": "7", "title": "Old one", "webUrl": "https://gitlab.com/group/project/-/issues/7",
|
|
585
|
+
"state": "OPEN", "labels": ["openhands", "bug"]},
|
|
586
|
+
], "pageInfo": {"hasNextPage": False}},
|
|
587
|
+
}
|
|
588
|
+
api = _wire(cloud_main, monkeypatch, CloudApi(tools={
|
|
589
|
+
"list_work_items": lambda args: pages[args.get("after")],
|
|
590
|
+
}))
|
|
591
|
+
|
|
592
|
+
issues = cloud_main._list_labeled_issues("", "group/project")
|
|
593
|
+
|
|
594
|
+
assert [issue["iid"] for issue in issues] == [42, 7]
|
|
595
|
+
assert issues[0]["web_url"] == "https://gitlab.com/group/project/-/issues/42"
|
|
596
|
+
assert cloud_main._has_trigger_label(issues[0]) and cloud_main._has_trigger_label(issues[1])
|
|
597
|
+
first, second = api.tool_calls("list_work_items")
|
|
598
|
+
assert first["project_id"] == "group/project"
|
|
599
|
+
assert first["state"] == "opened"
|
|
600
|
+
assert first["label_name"] == ["openhands"]
|
|
601
|
+
assert second["after"] == "c2"
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def test_the_label_event_is_the_newest_system_note_that_added_the_label(cloud_main, monkeypatch):
|
|
605
|
+
api = _wire(cloud_main, monkeypatch, CloudApi(tools={
|
|
606
|
+
"search_labels": {"nodes": [{"id": "gid://gitlab/ProjectLabel/99", "title": "openhands"}]},
|
|
607
|
+
"get_work_item": {"iid": "42", "notes": {"nodes": [
|
|
608
|
+
{"id": "gid://gitlab/Note/1", "system": True, "body": "added ~99 label", "createdAt": "2026-01-01T00:00:00Z"},
|
|
609
|
+
{"id": "gid://gitlab/Note/2", "system": False, "body": "added ~99 label", "createdAt": "2026-01-02T00:00:00Z"},
|
|
610
|
+
{"id": "gid://gitlab/Note/3", "system": True, "body": "added ~\"openhands\" label", "createdAt": "2026-01-03T00:00:00Z"},
|
|
611
|
+
{"id": "gid://gitlab/Note/4", "system": True, "body": "removed ~\"openhands\" label", "createdAt": "2026-01-04T00:00:00Z"},
|
|
612
|
+
], "pageInfo": {"hasNextPage": False}}},
|
|
613
|
+
}))
|
|
614
|
+
|
|
615
|
+
event = cloud_main._latest_trigger_label_event("", "group/project", 42)
|
|
616
|
+
|
|
617
|
+
assert event == {"id": "gid://gitlab/Note/3", "created_at": "2026-01-03T00:00:00Z"}
|
|
618
|
+
[call] = api.tool_calls("get_work_item")
|
|
619
|
+
assert call["work_item_iid"] == 42 and "notes" in call["include"]
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def test_an_issue_without_a_label_note_is_keyed_once(cloud_main, monkeypatch):
|
|
623
|
+
_wire(cloud_main, monkeypatch, CloudApi(tools={
|
|
624
|
+
"search_labels": {"nodes": []},
|
|
625
|
+
"get_work_item": {"iid": "42", "createdAt": "2026-01-01T00:00:00Z", "notes": {"nodes": []}},
|
|
626
|
+
}))
|
|
627
|
+
|
|
628
|
+
event = cloud_main._latest_trigger_label_event("", "group/project", 42)
|
|
629
|
+
|
|
630
|
+
assert event == {"id": "first", "created_at": "2026-01-01T00:00:00Z"}
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def test_branches_are_probed_and_merge_requests_found_through_the_mcp_server(cloud_main, monkeypatch):
|
|
634
|
+
api = _wire(cloud_main, monkeypatch, CloudApi(tools={
|
|
635
|
+
"list_branches": lambda args: [{"name": "openhands/issue-42"}, {"name": "openhands/issue-42-x"}]
|
|
636
|
+
if args["search"] == "openhands/issue-42" else [],
|
|
637
|
+
"get_work_item": {"iid": "42", "relatedMergeRequests": {"nodes": [
|
|
638
|
+
{"iid": "5", "sourceBranch": "other", "webUrl": "https://gitlab.com/group/project/-/merge_requests/5"},
|
|
639
|
+
{"iid": "6", "sourceBranch": "openhands/issue-42-2", "webUrl": "https://gitlab.com/group/project/-/merge_requests/6"},
|
|
640
|
+
]}},
|
|
641
|
+
}))
|
|
642
|
+
|
|
643
|
+
assert cloud_main._branch_name("", "group/project", 42) == "openhands/issue-42-2"
|
|
644
|
+
assert api.tool_calls("list_branches")[0] == {"id": "group/project", "search": "openhands/issue-42", "per_page": 100}
|
|
645
|
+
|
|
646
|
+
found = cloud_main._existing_merge_request("", "group/project", "openhands/issue-42-2", iid=42)
|
|
647
|
+
|
|
648
|
+
assert found["web_url"] == "https://gitlab.com/group/project/-/merge_requests/6"
|
|
649
|
+
assert found["iid"] == 6
|
|
650
|
+
assert cloud_main._existing_merge_request("", "group/project", "openhands/issue-42", iid=42) is None
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def test_comments_go_through_the_mcp_server(cloud_main, monkeypatch):
|
|
654
|
+
api = _wire(cloud_main, monkeypatch, CloudApi(tools={"save_note": {"id": "gid://gitlab/Note/9"}}))
|
|
655
|
+
|
|
656
|
+
cloud_main._post_gitlab_comment("", "group/project", 42, "hello")
|
|
657
|
+
|
|
658
|
+
assert api.tool_calls("save_note") == [{"project_id": "group/project", "work_item_iid": 42, "body": "hello"}]
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def test_the_cloud_prompt_in_mcp_mode_delivers_through_the_tools(cloud_main, monkeypatch):
|
|
662
|
+
monkeypatch.setattr(cloud_main, "GITLAB_MCP_SERVER", "gitlab")
|
|
663
|
+
prompt = _prompt(cloud_main)
|
|
664
|
+
|
|
665
|
+
for tool in ("get_work_item", "add_branch", "add_commit", "save_merge_request", "get_repository_file"):
|
|
666
|
+
assert f"`{tool}`" in prompt
|
|
667
|
+
assert "private" in prompt
|
|
668
|
+
assert "GITLAB_TOKEN" not in prompt
|
|
669
|
+
assert "git push" not in prompt
|
|
670
|
+
assert "Closes #42" in prompt and "GITLAB_MR_OPENED" in prompt
|
|
671
|
+
assert '"Draft: [#42] Retry uploads"' in prompt
|
|
672
|
+
assert "untrusted input" in prompt
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def test_the_cloud_prompt_in_token_mode_is_unchanged(cloud_main, monkeypatch):
|
|
676
|
+
monkeypatch.setattr(cloud_main, "GITLAB_MCP_SERVER", None)
|
|
677
|
+
prompt = _prompt(cloud_main)
|
|
678
|
+
|
|
679
|
+
assert "git push" in prompt and "$GITLAB_TOKEN" in prompt
|
|
680
|
+
assert "Authorization: Bearer $GITLAB_TOKEN" in prompt
|
|
681
|
+
assert "`add_commit`" not in prompt
|
|
682
|
+
|
|
683
|
+
|
|
400
684
|
# ── Issue discovery ───────────────────────────────────────────────────────────
|
|
401
685
|
|
|
402
686
|
|
|
@@ -30,6 +30,33 @@ ISSUE = {
|
|
|
30
30
|
}
|
|
31
31
|
# A baseline from before the issue was updated, so the issue counts as new.
|
|
32
32
|
STATE = {"processed_keys": [], "first_run_at": "2020-01-01T00:00:00+00:00"}
|
|
33
|
+
# A deployment that reaches Jira through the user's connected MCP server.
|
|
34
|
+
MCP_CONFIG = {"jira_mcp_server": "atlassian-rovo", "jira_cloud_id": "cloud-1"}
|
|
35
|
+
MCP_TOOL_PATH = "/api/v1/mcp/servers/atlassian-rovo/call-tool"
|
|
36
|
+
# What the conversation for ISSUE is asked when no git provider is configured. The
|
|
37
|
+
# Jira to GitHub PR template relies on this text staying as it is.
|
|
38
|
+
GITHUB_PROMPT = """Create a GitHub Pull Request for the following Jira issue.
|
|
39
|
+
|
|
40
|
+
Jira Issue : ENG-1
|
|
41
|
+
Summary : Add a README
|
|
42
|
+
Description: No description provided.
|
|
43
|
+
|
|
44
|
+
Steps:
|
|
45
|
+
1. Find the target GitHub repository in the Description above. Look for a reference in
|
|
46
|
+
"owner/repo" format (e.g. "acme-org/backend") or a full GitHub URL
|
|
47
|
+
(e.g. "https://github.com/acme-org/backend"). Use that repository.
|
|
48
|
+
If no repository is mentioned, create a file `jira/ENG-1/notes.md` with the issue
|
|
49
|
+
details and print a message explaining that no GitHub repo was found in the ticket.
|
|
50
|
+
2. Clone the repository (e.g. https://github.com/<owner>/<repo>).
|
|
51
|
+
3. Create branch `jira/eng-1` from the default branch.
|
|
52
|
+
4. Implement the changes described in the issue.
|
|
53
|
+
If the description is vague or missing, create `jira/ENG-1/notes.md`
|
|
54
|
+
with the issue key, summary, and description as a placeholder.
|
|
55
|
+
5. Commit, push the branch, and open a Pull Request:
|
|
56
|
+
- Title : [ENG-1] Add a README
|
|
57
|
+
- Body : Reference the Jira issue key and describe the changes made.
|
|
58
|
+
6. Print the PR URL when done.
|
|
59
|
+
"""
|
|
33
60
|
|
|
34
61
|
|
|
35
62
|
class StubServer:
|
|
@@ -41,19 +68,24 @@ class StubServer:
|
|
|
41
68
|
# reports for the conversations it was asked to start.
|
|
42
69
|
self.state = dict(STATE)
|
|
43
70
|
self.start_tasks = []
|
|
71
|
+
# Set to make the connected MCP server report a failed tool call.
|
|
72
|
+
self.mcp_error = None
|
|
73
|
+
# The name the connected MCP server offers its comment tool under.
|
|
74
|
+
self.mcp_comment_tool = "addOrEditJiraIssueComment"
|
|
44
75
|
stub = self
|
|
45
76
|
|
|
46
77
|
class Handler(BaseHTTPRequestHandler):
|
|
47
78
|
def _handle(self):
|
|
48
79
|
length = int(self.headers.get("Content-Length") or 0)
|
|
49
80
|
body = self.rfile.read(length) if length else b""
|
|
50
|
-
|
|
81
|
+
request = {
|
|
51
82
|
"method": self.command,
|
|
52
83
|
"path": self.path,
|
|
53
84
|
"headers": {k.lower(): v for k, v in self.headers.items()},
|
|
54
85
|
"body": json.loads(body) if body else None,
|
|
55
|
-
}
|
|
56
|
-
|
|
86
|
+
}
|
|
87
|
+
stub.requests.append(request)
|
|
88
|
+
status, payload = stub.respond(self.path, request["body"])
|
|
57
89
|
data = payload.encode() if isinstance(payload, str) else json.dumps(payload).encode()
|
|
58
90
|
self.send_response(status)
|
|
59
91
|
self.send_header("Content-Length", str(len(data)))
|
|
@@ -69,7 +101,14 @@ class StubServer:
|
|
|
69
101
|
self.url = f"http://127.0.0.1:{self.httpd.server_port}"
|
|
70
102
|
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
|
71
103
|
|
|
72
|
-
def respond(self, path):
|
|
104
|
+
def respond(self, path, body):
|
|
105
|
+
if path == MCP_TOOL_PATH:
|
|
106
|
+
if self.mcp_error:
|
|
107
|
+
return 200, {"is_error": True, "text": self.mcp_error}
|
|
108
|
+
if body["name"] not in ("searchJiraIssuesUsingJql", self.mcp_comment_tool):
|
|
109
|
+
return 200, {"is_error": True, "text": f"Tool {body['name']!r} not advertised by server"}
|
|
110
|
+
found = {"issues": [ISSUE]} if body["name"] == "searchJiraIssuesUsingJql" else {}
|
|
111
|
+
return 200, {"is_error": False, "text": json.dumps(found)}
|
|
73
112
|
if path == "/rest/api/3/search/jql":
|
|
74
113
|
return 200, {"issues": [ISSUE]}
|
|
75
114
|
if path.startswith("/rest/api/3/issue/"):
|
|
@@ -93,6 +132,9 @@ class StubServer:
|
|
|
93
132
|
def find(self, method, path):
|
|
94
133
|
return [r for r in self.requests if r["method"] == method and r["path"] == path]
|
|
95
134
|
|
|
135
|
+
def mcp_calls(self, tool):
|
|
136
|
+
return [r for r in self.find("POST", MCP_TOOL_PATH) if r["body"]["name"] == tool]
|
|
137
|
+
|
|
96
138
|
|
|
97
139
|
@pytest.fixture
|
|
98
140
|
def stub():
|
|
@@ -103,11 +145,11 @@ def stub():
|
|
|
103
145
|
|
|
104
146
|
@pytest.fixture
|
|
105
147
|
def run_script(stub, tmp_path):
|
|
106
|
-
def run(env):
|
|
148
|
+
def run(env, config=None):
|
|
107
149
|
workdir = tmp_path / "run"
|
|
108
150
|
workdir.mkdir()
|
|
109
151
|
shutil.copy(SCRIPT_PATH, workdir / "main.py")
|
|
110
|
-
(workdir / "config.json").write_text(json.dumps({
|
|
152
|
+
(workdir / "config.json").write_text(json.dumps(config or {
|
|
111
153
|
"jira_base_url": stub.url,
|
|
112
154
|
"jira_email": "alice@example.test",
|
|
113
155
|
"jira_token_secret": "JIRA_CLOUD_KEY",
|
|
@@ -154,6 +196,10 @@ def comment_text(comment):
|
|
|
154
196
|
return comment["body"]["body"]["content"][0]["content"][0]["text"]
|
|
155
197
|
|
|
156
198
|
|
|
199
|
+
def prompt_text(conversation):
|
|
200
|
+
return conversation["body"]["initial_message"]["content"][0]["text"]
|
|
201
|
+
|
|
202
|
+
|
|
157
203
|
def test_a_cloud_run_reads_the_jira_token_from_its_sandbox_secrets(stub, run_script):
|
|
158
204
|
# Arrange
|
|
159
205
|
env = cloud_env(stub)
|
|
@@ -283,6 +329,154 @@ def test_a_cloud_run_without_the_kv_store_fails_before_dispatching(stub, run_scr
|
|
|
283
329
|
assert callback["body"]["status"] == "FAILED"
|
|
284
330
|
|
|
285
331
|
|
|
332
|
+
def test_a_cloud_run_finds_issues_through_the_connected_mcp_server(stub, run_script):
|
|
333
|
+
# Arrange
|
|
334
|
+
env = cloud_env(stub)
|
|
335
|
+
|
|
336
|
+
# Act
|
|
337
|
+
result = run_script(env, MCP_CONFIG)
|
|
338
|
+
|
|
339
|
+
# Assert
|
|
340
|
+
assert result.returncode == 0, result.stderr
|
|
341
|
+
[search] = stub.mcp_calls("searchJiraIssuesUsingJql")
|
|
342
|
+
assert search["headers"]["authorization"] == "Bearer openhands-key"
|
|
343
|
+
assert search["body"]["arguments"]["cloudId"] == "cloud-1"
|
|
344
|
+
assert 'labels = "create-pr"' in search["body"]["arguments"]["jql"]
|
|
345
|
+
[conversation] = stub.find("POST", "/api/v1/app-conversations")
|
|
346
|
+
assert conversation["body"]["title"] == "[ENG-1] Add a README"
|
|
347
|
+
assert [r for r in stub.requests if "/settings/secrets/" in r["path"]] == []
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_a_cloud_run_links_the_jira_issue_through_the_connected_mcp_server(stub, run_script):
|
|
351
|
+
# Arrange
|
|
352
|
+
env = cloud_env(stub)
|
|
353
|
+
|
|
354
|
+
# Act
|
|
355
|
+
result = run_script(env, MCP_CONFIG)
|
|
356
|
+
|
|
357
|
+
# Assert
|
|
358
|
+
assert result.returncode == 0, result.stderr
|
|
359
|
+
[conversation] = stub.find("POST", "/api/v1/app-conversations")
|
|
360
|
+
[comment] = stub.mcp_calls("addOrEditJiraIssueComment")
|
|
361
|
+
conversation_id = conversation["body"]["conversation_id"]
|
|
362
|
+
assert comment["body"]["arguments"] == {
|
|
363
|
+
"cloudId": "cloud-1",
|
|
364
|
+
"issueIdOrKey": "ENG-1",
|
|
365
|
+
"commentBody": f"I'm on it: {stub.url}/canvas/conversations/{conversation_id}",
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_a_cloud_run_links_the_jira_issue_when_the_mcp_server_still_has_the_older_comment_tool(stub, run_script):
|
|
370
|
+
# Arrange
|
|
371
|
+
stub.mcp_comment_tool = "addCommentToJiraIssue"
|
|
372
|
+
|
|
373
|
+
# Act
|
|
374
|
+
result = run_script(cloud_env(stub), MCP_CONFIG)
|
|
375
|
+
|
|
376
|
+
# Assert
|
|
377
|
+
assert result.returncode == 0, result.stderr
|
|
378
|
+
[comment] = stub.mcp_calls("addCommentToJiraIssue")
|
|
379
|
+
assert comment["body"]["arguments"]["issueIdOrKey"] == "ENG-1"
|
|
380
|
+
assert comment["body"]["arguments"]["commentBody"].startswith("I'm on it: ")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def test_a_cloud_run_fails_when_the_connected_mcp_server_reports_an_error(stub, run_script):
|
|
384
|
+
# Arrange
|
|
385
|
+
stub.mcp_error = "The MCP server must be authorized again"
|
|
386
|
+
|
|
387
|
+
# Act
|
|
388
|
+
result = run_script(cloud_env(stub), MCP_CONFIG)
|
|
389
|
+
|
|
390
|
+
# Assert
|
|
391
|
+
assert result.returncode == 1
|
|
392
|
+
assert stub.find("POST", "/api/v1/app-conversations") == []
|
|
393
|
+
[callback] = stub.find("POST", "/callback")
|
|
394
|
+
assert callback["body"]["status"] == "FAILED"
|
|
395
|
+
assert "must be authorized again" in callback["body"]["error"]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def test_a_cloud_run_asks_for_a_github_pull_request_when_no_provider_is_configured(stub, run_script):
|
|
399
|
+
# Act
|
|
400
|
+
result = run_script(cloud_env(stub), MCP_CONFIG)
|
|
401
|
+
|
|
402
|
+
# Assert
|
|
403
|
+
assert result.returncode == 0, result.stderr
|
|
404
|
+
[conversation] = stub.find("POST", "/api/v1/app-conversations")
|
|
405
|
+
assert prompt_text(conversation) == GITHUB_PROMPT
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def test_a_cloud_run_asks_for_a_gitlab_merge_request_when_the_provider_is_gitlab(stub, run_script):
|
|
409
|
+
# Act
|
|
410
|
+
result = run_script(cloud_env(stub), {**MCP_CONFIG, "git_provider": "gitlab"})
|
|
411
|
+
|
|
412
|
+
# Assert
|
|
413
|
+
assert result.returncode == 0, result.stderr
|
|
414
|
+
[conversation] = stub.find("POST", "/api/v1/app-conversations")
|
|
415
|
+
prompt = prompt_text(conversation)
|
|
416
|
+
assert prompt.startswith("Create a GitLab Merge Request for the following Jira issue.")
|
|
417
|
+
assert '"group/project" format' in prompt
|
|
418
|
+
assert "self-managed" in prompt
|
|
419
|
+
assert "https://gitlab.com/<group>/<project>" in prompt
|
|
420
|
+
assert "Print the MR URL" in prompt
|
|
421
|
+
assert "github.com" not in prompt
|
|
422
|
+
# Without a git credential the conversation delivers through the GitLab MCP tools.
|
|
423
|
+
for tool in ("add_branch", "add_commit", "save_merge_request", "get_repository_file"):
|
|
424
|
+
assert f"`{tool}`" in prompt
|
|
425
|
+
assert "Create branch `jira/eng-1`" in prompt
|
|
426
|
+
assert conversation["body"]["title"] == "[ENG-1] Add a README"
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def test_a_cloud_run_asks_for_a_bitbucket_pull_request_when_the_provider_is_bitbucket(stub, run_script):
|
|
430
|
+
# Act
|
|
431
|
+
result = run_script(cloud_env(stub), {**MCP_CONFIG, "git_provider": "bitbucket"})
|
|
432
|
+
|
|
433
|
+
# Assert
|
|
434
|
+
assert result.returncode == 0, result.stderr
|
|
435
|
+
[conversation] = stub.find("POST", "/api/v1/app-conversations")
|
|
436
|
+
prompt = prompt_text(conversation)
|
|
437
|
+
assert prompt.startswith("Create a Bitbucket Pull Request for the following Jira issue.")
|
|
438
|
+
assert '"workspace/repo" format' in prompt
|
|
439
|
+
assert "https://bitbucket.org/<workspace>/<repo>" in prompt
|
|
440
|
+
assert "Print the PR URL" in prompt
|
|
441
|
+
assert "Merge Request" not in prompt
|
|
442
|
+
# Without a git credential the conversation delivers through Rovo's Bitbucket tools.
|
|
443
|
+
for tool in (
|
|
444
|
+
"createBitbucketRepoBranch",
|
|
445
|
+
"createBitbucketRepoCommit",
|
|
446
|
+
"createBitbucketRepoPullRequest",
|
|
447
|
+
"getBitbucketRepoFileContent",
|
|
448
|
+
):
|
|
449
|
+
assert f"`{tool}`" in prompt
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_a_run_with_an_unknown_git_provider_fails_before_dispatching(stub, run_script):
|
|
453
|
+
# Act
|
|
454
|
+
result = run_script(cloud_env(stub), {**MCP_CONFIG, "git_provider": "gitea"})
|
|
455
|
+
|
|
456
|
+
# Assert
|
|
457
|
+
assert result.returncode == 1
|
|
458
|
+
assert stub.find("POST", "/api/v1/app-conversations") == []
|
|
459
|
+
[callback] = stub.find("POST", "/callback")
|
|
460
|
+
assert callback["body"]["status"] == "FAILED"
|
|
461
|
+
assert "git_provider" in callback["body"]["error"]
|
|
462
|
+
assert "gitea" in callback["body"]["error"]
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def test_a_local_run_cannot_reach_jira_through_a_connected_mcp_server(stub, run_script):
|
|
466
|
+
# Arrange
|
|
467
|
+
env = local_env(stub)
|
|
468
|
+
|
|
469
|
+
# Act
|
|
470
|
+
result = run_script(env, MCP_CONFIG)
|
|
471
|
+
|
|
472
|
+
# Assert
|
|
473
|
+
assert result.returncode == 1
|
|
474
|
+
assert stub.find("POST", "/api/conversations") == []
|
|
475
|
+
[callback] = stub.find("POST", "/callback")
|
|
476
|
+
assert callback["body"]["status"] == "FAILED"
|
|
477
|
+
assert "OpenHands Cloud or Enterprise" in callback["body"]["error"]
|
|
478
|
+
|
|
479
|
+
|
|
286
480
|
def test_a_local_run_still_starts_conversations_on_the_agent_server(stub, run_script):
|
|
287
481
|
# Arrange
|
|
288
482
|
env = local_env(stub)
|