From 8f08f9f8257126a6d277265bc4410a3e707f033d 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 --- gerrit_mcp_server/main.py | 54 ++++++++++++++++- tests/unit/test_submit_change.py | 101 +++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_submit_change.py 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()