@openhands/extensions 0.29.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.
Files changed (84) hide show
  1. package/.agents/skills/custom-codereview-guide.md +44 -1
  2. package/.claude-plugin/marketplace.json +7 -5
  3. package/.codex-plugin/marketplace.json +7 -5
  4. package/.github/workflows/build-vscode-app.yml +3 -3
  5. package/.github/workflows/check-extensions.yml +1 -1
  6. package/.github/workflows/tests.yml +1 -1
  7. package/.plugin/marketplace.json +7 -5
  8. package/.release-please-manifest.json +1 -1
  9. package/README.md +3 -3
  10. package/apps/vscode/README.md +8 -6
  11. package/apps/vscode/canvas-extension.json +11 -3
  12. package/apps/vscode/extension.js +60 -12
  13. package/apps/vscode/scripts/package.mjs +20 -74
  14. package/automations/bundle-index.js +9 -7
  15. package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
  16. package/automations/catalog/github-issue-triage/manifest.json +4 -3
  17. package/automations/catalog/github-pr-reviewer/manifest.json +5 -4
  18. package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
  19. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
  20. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
  21. package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
  22. package/integrations/catalog/atlassian-rovo.json +4 -3
  23. package/integrations/catalog/bitbucket.json +16 -1
  24. package/integrations/catalog/github.json +1 -0
  25. package/integrations/catalog/gitlab.json +1 -1
  26. package/marketplaces/openhands-extensions.json +7 -5
  27. package/package.json +1 -1
  28. package/plugins/pr-review/README.md +1 -0
  29. package/plugins/pr-review/scripts/prompt.py +44 -11
  30. package/plugins/runtime-api-configs/SKILL.md +1 -1
  31. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
  32. package/pyproject.toml +1 -1
  33. package/python/openhands_extensions/_version.py +1 -1
  34. package/skills/canvas-extension-api/references/backend-safety.md +3 -3
  35. package/skills/canvas-extension-api/references/v1-contract.md +7 -4
  36. package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
  37. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
  38. package/skills/github/scripts/agent_conversation.py +224 -8
  39. package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
  40. package/skills/github-issue-triage/README.md +73 -14
  41. package/skills/github-issue-triage/SKILL.md +26 -46
  42. package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
  43. package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
  44. package/skills/github-issue-triage/scripts/worker.py +153 -103
  45. package/skills/github-pr-reviewer/README.md +103 -1
  46. package/skills/github-pr-reviewer/SKILL.md +35 -8
  47. package/skills/github-pr-reviewer/references/state-schema.md +8 -0
  48. package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
  49. package/skills/github-pr-reviewer/scripts/main.py +163 -39
  50. package/skills/github-pr-reviewer/scripts/publication.py +193 -0
  51. package/skills/github-pr-reviewer/scripts/worker.py +297 -8
  52. package/skills/github-pr-reviewer/tests/test_main.py +73 -5
  53. package/skills/gitlab-issue-to-mr/README.md +10 -3
  54. package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
  55. package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
  56. package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
  57. package/skills/index.js +8 -8
  58. package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
  59. package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
  60. package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
  61. package/skills/jira-issue-to-pr/README.md +18 -11
  62. package/skills/jira-issue-to-pr/SKILL.md +72 -23
  63. package/skills/jira-issue-to-pr/references/setup.md +43 -12
  64. package/skills/jira-issue-to-pr/scripts/main.py +136 -26
  65. package/skills/slack-channel-monitor/README.md +6 -1
  66. package/skills/slack-channel-monitor/SKILL.md +6 -3
  67. package/skills/slack-channel-monitor/scripts/main.py +36 -4
  68. package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
  69. package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
  70. package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
  71. package/skills/ticket-to-code-change/SKILL.md +5 -3
  72. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
  73. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  74. package/tests/test_agent_conversation_dispatch.py +173 -6
  75. package/tests/test_automation_llm_provenance.py +1209 -0
  76. package/tests/test_github_review_publication.py +422 -0
  77. package/tests/test_github_reviewer_delivery.py +174 -21
  78. package/tests/test_github_triage_delivery.py +411 -238
  79. package/tests/test_gitlab_issue_to_mr.py +284 -0
  80. package/tests/test_jira_issue_to_pr.py +200 -6
  81. package/tests/test_pr_review_prompt_injection.py +353 -0
  82. package/tests/test_slack_channel_monitor.py +32 -1
  83. package/tests/test_vscode_app.py +8 -2
  84. 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
- stub.requests.append({
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
- status, payload = stub.respond(self.path)
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)