From a66587f961d7ca09ddb2393fc3736b15a5558db2 Mon Sep 17 00:00:00 2001 From: Maksym Dzhosan Date: Mon, 30 Mar 2026 12:25:56 +0300 Subject: [PATCH] feat: Add submit_change tool Changes: - Add submit_change tool to submit a single Gerrit change - Add unit tests for submit_change - Document submit_change in available_tools.md and use_cases.md Change-Id: I3ce0a9e60b7c4f6018a8e9f0e12ff7e7649e1b8a --- docs/available_tools.md | 3 + docs/use_cases.md | 2 + gerrit_mcp_server/main.py | 54 ++++++++++++++++- tests/unit/test_submit_change.py | 101 +++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_submit_change.py diff --git a/docs/available_tools.md b/docs/available_tools.md index 2da4ca7..31f579b 100644 --- a/docs/available_tools.md +++ b/docs/available_tools.md @@ -35,5 +35,8 @@ This document lists the tools available in the Gerrit MCP Server, extracted from - **abandon_change**: Abandons a change. - **get_most_recent_cl**: Gets the most recent CL for a user. - **get_bugs_from_cl**: Extracts bug IDs from the commit message of a CL. +- **submit_change**: Submits (merges) a single Gerrit change into its target + branch. The change must be submittable (approved, no unresolved comments, + etc.). - **post_review_comment**: Posts a review comment on a specific line of a file in a CL. diff --git a/docs/use_cases.md b/docs/use_cases.md index 8ab6fbd..3131cfe 100644 --- a/docs/use_cases.md +++ b/docs/use_cases.md @@ -20,6 +20,8 @@ Here are a few examples of how you can use the Gerrit MCP Server with a language | | "Suggest reviewers for CL 12345 who know about the 'auth' module." | `suggest_reviewers` | | **Review Helper** | "List the comments on CL 12345." | `list_change_comments` | | | "Post a comment on CL 12345, file 'utils.py', line 20: 'Add a test for this case.'" | `post_review_comment` | +| **Submit** | "Submit CL 12345." | `submit_change` | +| | "Merge CL 12345 and wait for it to be merged." | `submit_change` | | **Advanced** | "Revert CL 12345 with the message 'Broke the build'." | `revert_change` | | | "What other changes would be submitted with CL 67890?" | `changes_submitted_together` | | | "Create a new change in project 'test-project', branch 'dev', with subject 'Test new feature'." | `create_change` | diff --git a/gerrit_mcp_server/main.py b/gerrit_mcp_server/main.py index b9383e5..01c7072 100644 --- a/gerrit_mcp_server/main.py +++ b/gerrit_mcp_server/main.py @@ -782,6 +782,58 @@ async def revert_submission( raise e +@mcp.tool() +async def submit_change( + change_id: str, + wait_for_merge: bool = False, + gerrit_base_url: Optional[str] = None, +): + """ + Submits (merges) a single Gerrit change into its target branch. + The change must be submittable (approved, no unresolved comments, etc.). + """ + config = load_gerrit_config() + gerrit_hosts = config.get("gerrit_hosts", []) + base_url = _normalize_gerrit_url( + _get_gerrit_base_url(gerrit_base_url), gerrit_hosts + ) + url = f"{base_url}/changes/{change_id}/submit" + payload = {} + if wait_for_merge: + payload["wait_for_merge"] = True + args = _create_post_args(url, payload) + + try: + result_str = await run_curl(args, base_url) + submit_info = json.loads(result_str) + if "id" in submit_info and "_number" in submit_info: + output = ( + f"Successfully submitted CL {submit_info['_number']}.\n" + f"Subject: {submit_info.get('subject', 'N/A')}\n" + f"Status: {submit_info.get('status', 'N/A')}" + ) + return [{"type": "text", "text": output}] + else: + return [ + { + "type": "text", + "text": f"Failed to submit CL {change_id}. Response: {result_str}", + } + ] + except json.JSONDecodeError: + return [ + { + "type": "text", + "text": f"Failed to submit CL {change_id}. Response: {result_str}", + } + ] + except Exception as e: + with open(LOG_FILE_PATH, "a") as log_file: + log_file.write( + f"[gerrit-mcp-server] Error submitting CL {change_id}: {e}\n" + ) + raise e + @mcp.tool() async def create_change( project: str, @@ -1257,4 +1309,4 @@ def cli_main(argv: List[str]): if __name__ == "__main__": cli_main(sys.argv) -app = mcp.streamable_http_app() \ No newline at end of file +app = mcp.streamable_http_app() diff --git a/tests/unit/test_submit_change.py b/tests/unit/test_submit_change.py new file mode 100644 index 0000000..f4790c2 --- /dev/null +++ b/tests/unit/test_submit_change.py @@ -0,0 +1,101 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from unittest.mock import patch, AsyncMock +import asyncio +import json + +from gerrit_mcp_server import main + + +class TestSubmitChange(unittest.TestCase): + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_submit_change_success(self, mock_run_curl): + async def run_test(): + mock_response = { + "id": "myproject~master~I1234", + "_number": 12345, + "subject": "Fix the bug", + "status": "MERGED", + } + mock_run_curl.return_value = json.dumps(mock_response) + gerrit_base_url = "https://my-gerrit.com" + + result = await main.submit_change( + "12345", gerrit_base_url=gerrit_base_url + ) + + self.assertIn("Successfully submitted CL 12345", result[0]["text"]) + self.assertIn("Fix the bug", result[0]["text"]) + self.assertIn("MERGED", result[0]["text"]) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_submit_change_not_submittable(self, mock_run_curl): + async def run_test(): + mock_run_curl.return_value = "change is new" + gerrit_base_url = "https://my-gerrit.com" + + result = await main.submit_change( + "12345", gerrit_base_url=gerrit_base_url + ) + + self.assertIn("Failed to submit CL 12345", result[0]["text"]) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_submit_change_with_wait_for_merge(self, mock_run_curl): + async def run_test(): + mock_response = { + "id": "myproject~master~I1234", + "_number": 12345, + "subject": "Fix the bug", + "status": "MERGED", + } + mock_run_curl.return_value = json.dumps(mock_response) + gerrit_base_url = "https://my-gerrit.com" + + result = await main.submit_change( + "12345", + wait_for_merge=True, + gerrit_base_url=gerrit_base_url, + ) + + self.assertIn("Successfully submitted CL 12345", result[0]["text"]) + # Verify the payload included wait_for_merge + call_args = mock_run_curl.call_args[0][0] + self.assertIn("wait_for_merge", str(call_args)) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_submit_change_exception(self, mock_run_curl): + async def run_test(): + mock_run_curl.side_effect = Exception("Connection refused") + gerrit_base_url = "https://my-gerrit.com" + + with self.assertRaises(Exception) as ctx: + await main.submit_change( + "12345", gerrit_base_url=gerrit_base_url + ) + self.assertIn("Connection refused", str(ctx.exception)) + + asyncio.run(run_test()) + + +if __name__ == "__main__": + unittest.main()