From f3dec2b03cd137ce92ddf0c19308db5662a8964c Mon Sep 17 00:00:00 2001 From: Maksym Dzhosan Date: Mon, 30 Mar 2026 12:25:27 +0300 Subject: [PATCH] feat: Add cherry-pick tools Changes: - Add cherry_pick_change tool for cherry-picking a single change to a destination branch - Add cherry_pick_chain tool for cherry-picking an entire relation chain preserving dependency order - Update docs with new cherry-pick tools and use cases Change-Id: I66fe8602b24fd12f05d2138d5fcf634646c785d3 --- docs/available_tools.md | 3 + docs/use_cases.md | 3 + gerrit_mcp_server/main.py | 207 ++++++++++++++++- tests/unit/test_cherry_pick_chain.py | 317 ++++++++++++++++++++++++++ tests/unit/test_cherry_pick_change.py | 207 +++++++++++++++++ 5 files changed, 736 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_cherry_pick_chain.py create mode 100644 tests/unit/test_cherry_pick_change.py diff --git a/docs/available_tools.md b/docs/available_tools.md index 2da4ca7..eb4ea03 100644 --- a/docs/available_tools.md +++ b/docs/available_tools.md @@ -37,3 +37,6 @@ This document lists the tools available in the Gerrit MCP Server, extracted from - **get_bugs_from_cl**: Extracts bug IDs from the commit message of a CL. - **post_review_comment**: Posts a review comment on a specific line of a file in a CL. +- **cherry_pick_change**: Cherry-picks a single change to a destination branch. +- **cherry_pick_chain**: Cherry-picks an entire relation chain (series of + dependent changes) to a destination branch, maintaining dependency order. diff --git a/docs/use_cases.md b/docs/use_cases.md index 8ab6fbd..9b41550 100644 --- a/docs/use_cases.md +++ b/docs/use_cases.md @@ -23,6 +23,9 @@ Here are a few examples of how you can use the Gerrit MCP Server with a language | **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` | +| **Cherry-pick** | "Cherry-pick CL 12345 to the 'release-1.0' branch." | `cherry_pick_change` | +| | "Cherry-pick the entire chain of CL 67890 to the 'stable' branch." | `cherry_pick_chain` | +| | "Where has CL 12345 been cherry-picked to?" | `get_change_details` + `query_changes` | ## Data Analysis Use Cases diff --git a/gerrit_mcp_server/main.py b/gerrit_mcp_server/main.py index b9383e5..0c666b7 100644 --- a/gerrit_mcp_server/main.py +++ b/gerrit_mcp_server/main.py @@ -782,6 +782,211 @@ async def revert_submission( raise e +@mcp.tool() +async def cherry_pick_change( + change_id: str, + destination: str, + revision_id: str = "current", + message: Optional[str] = None, + keep_reviewers: bool = False, + allow_conflicts: bool = True, + allow_empty: bool = False, + gerrit_base_url: Optional[str] = None, +): + """ + Cherry-picks a single change to a destination branch. + """ + 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}/revisions/{revision_id}/cherrypick" + payload = {"destination": destination} + if message: + payload["message"] = message + if keep_reviewers: + payload["keep_reviewers"] = True + if allow_conflicts: + payload["allow_conflicts"] = True + if allow_empty: + payload["allow_empty"] = True + args = _create_post_args(url, payload) + + try: + result_str = await run_curl(args, base_url) + cherry_info = json.loads(result_str) + if "id" in cherry_info and "_number" in cherry_info: + output = ( + f"Successfully cherry-picked CL {change_id} to branch {destination}.\n" + f"New CL created: {cherry_info['_number']}\n" + f"Subject: {cherry_info['subject']}" + ) + return [{"type": "text", "text": output}] + else: + return [ + { + "type": "text", + "text": f"Failed to cherry-pick CL {change_id}. Response: {result_str}", + } + ] + except json.JSONDecodeError: + return [ + { + "type": "text", + "text": f"Failed to cherry-pick 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 cherry-picking CL {change_id}: {e}\n" + ) + raise e + + +@mcp.tool() +async def cherry_pick_chain( + change_id: str, + destination: str, + revision_id: str = "current", + keep_reviewers: bool = False, + allow_conflicts: bool = True, + allow_empty: bool = False, + gerrit_base_url: Optional[str] = None, +): + """ + Cherry-picks an entire relation chain (series of dependent changes) to a + destination branch, maintaining dependency order. Fetches the related changes + for the given change, then cherry-picks each one sequentially from parent to + child so the chain structure is preserved on the destination branch. + """ + config = load_gerrit_config() + gerrit_hosts = config.get("gerrit_hosts", []) + base_url = _normalize_gerrit_url( + _get_gerrit_base_url(gerrit_base_url), gerrit_hosts + ) + + # Step 1: Fetch the relation chain + related_url = ( + f"{base_url}/changes/{change_id}/revisions/{revision_id}/related" + ) + try: + result_str = await run_curl([related_url], base_url) + related_info = json.loads(result_str) + except (json.JSONDecodeError, Exception) as e: + return [ + { + "type": "text", + "text": f"Failed to fetch related changes for CL {change_id}: {e}", + } + ] + + changes = related_info.get("changes", []) + if not changes: + return [ + { + "type": "text", + "text": ( + f"No related changes found for CL {change_id}. " + "Use cherry_pick_change for a single change." + ), + } + ] + + # Step 2: Reverse so we cherry-pick parent-to-child + # (the /related API returns child-first, ancestors last) + changes.reverse() + + results = [] + parent_commit = None + + for i, related_change in enumerate(changes): + cid = str(related_change["_change_number"]) + rid = str(related_change.get("_revision_number", "current")) + + payload = {"destination": destination} + if keep_reviewers: + payload["keep_reviewers"] = True + if allow_conflicts: + payload["allow_conflicts"] = True + if allow_empty: + payload["allow_empty"] = True + if parent_commit: + payload["base"] = parent_commit + + cherry_url = ( + f"{base_url}/changes/{cid}/revisions/{rid}/cherrypick" + ) + args = _create_post_args(cherry_url, payload) + + try: + result_str = await run_curl(args, base_url) + cherry_info = json.loads(result_str) + + if "id" not in cherry_info or "_number" not in cherry_info: + error_output = ( + f"Cherry-pick chain failed at CL {cid} " + f"({i + 1}/{len(changes)}).\n" + f"Response: {result_str}\n" + ) + if results: + error_output += "Successfully cherry-picked before failure:\n" + for r in results: + error_output += ( + f"- CL {r['original']} -> new CL {r['new_number']}: " + f"{r['subject']}\n" + ) + return [{"type": "text", "text": error_output}] + + # The cherry-pick response doesn't include current_revision + # by default. Fetch the new change with CURRENT_REVISION to + # get the commit SHA needed as 'base' for the next cherry-pick. + new_cl = cherry_info["_number"] + detail_url = ( + f"{base_url}/changes/{new_cl}?o=CURRENT_REVISION" + ) + detail_str = await run_curl([detail_url], base_url) + detail_info = json.loads(detail_str) + parent_commit = detail_info.get("current_revision") + + results.append( + { + "original": cid, + "new_number": new_cl, + "subject": cherry_info.get("subject", ""), + } + ) + except Exception as e: + error_output = ( + f"Cherry-pick chain failed at CL {cid} " + f"({i + 1}/{len(changes)}): {e}\n" + ) + if results: + error_output += "Successfully cherry-picked before failure:\n" + for r in results: + error_output += ( + f"- CL {r['original']} -> new CL {r['new_number']}: " + f"{r['subject']}\n" + ) + with open(LOG_FILE_PATH, "a") as log_file: + log_file.write( + f"[gerrit-mcp-server] Error cherry-picking chain at CL {cid}: {e}\n" + ) + return [{"type": "text", "text": error_output}] + + # Step 3: Report success + output = ( + f"Successfully cherry-picked chain of {len(results)} changes " + f"to branch {destination}:\n" + ) + for r in results: + output += ( + f"- CL {r['original']} -> new CL {r['new_number']}: {r['subject']}\n" + ) + return [{"type": "text", "text": output}] + + @mcp.tool() async def create_change( project: str, @@ -1257,4 +1462,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_cherry_pick_chain.py b/tests/unit/test_cherry_pick_chain.py new file mode 100644 index 0000000..28cf4d2 --- /dev/null +++ b/tests/unit/test_cherry_pick_chain.py @@ -0,0 +1,317 @@ +# 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 + + +GERRIT_BASE_URL = "https://my-gerrit.com" + + +def _related_response(changes): + """Build a related changes API response.""" + return json.dumps({"changes": changes}) + + +def _cherry_pick_response(change_number, subject): + """Build a cherry-pick API success response (no current_revision).""" + return json.dumps( + { + "id": f"myProject~release~I{change_number}", + "_number": change_number, + "subject": subject, + } + ) + + +def _detail_response(change_number, current_revision): + """Build a change detail response with CURRENT_REVISION.""" + return json.dumps( + { + "_number": change_number, + "current_revision": current_revision, + } + ) + + +class TestCherryPickChain(unittest.TestCase): + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_chain_success(self, mock_run_curl): + async def run_test(): + # Arrange — 3 related changes in child-first order (as Gerrit returns) + # The tool should reverse them and cherry-pick 100 -> 200 -> 300 + related = [ + {"_change_number": 300, "_revision_number": 3}, + {"_change_number": 200, "_revision_number": 2}, + {"_change_number": 100, "_revision_number": 1}, + ] + mock_run_curl.side_effect = [ + _related_response(related), + _cherry_pick_response(1001, "CP of 100"), + _detail_response(1001, "sha_a"), + _cherry_pick_response(1002, "CP of 200"), + _detail_response(1002, "sha_b"), + _cherry_pick_response(1003, "CP of 300"), + _detail_response(1003, "sha_c"), + ] + + # Act + result = await main.cherry_pick_chain( + "300", "release-branch", gerrit_base_url=GERRIT_BASE_URL + ) + + # Assert + text = result[0]["text"] + self.assertIn("Successfully cherry-picked chain of 3 changes", text) + self.assertIn("CL 100 -> new CL 1001", text) + self.assertIn("CL 200 -> new CL 1002", text) + self.assertIn("CL 300 -> new CL 1003", text) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_chain_reverses_related_order(self, mock_run_curl): + async def run_test(): + # Arrange — /related returns child-first: 200, 100 + # Tool must reverse to cherry-pick 100 first, then 200 + related = [ + {"_change_number": 200, "_revision_number": 2}, + {"_change_number": 100, "_revision_number": 1}, + ] + mock_run_curl.side_effect = [ + _related_response(related), + _cherry_pick_response(1001, "CP of 100"), + _detail_response(1001, "sha_first"), + _cherry_pick_response(1002, "CP of 200"), + _detail_response(1002, "sha_second"), + ] + + # Act + await main.cherry_pick_chain( + "200", "release-branch", gerrit_base_url=GERRIT_BASE_URL + ) + + # Assert — verify cherry-pick order via curl call URLs + calls = mock_run_curl.call_args_list + # Call 0: GET related + # Call 1: POST cherry-pick CL 100 (parent first) + first_cp_url = calls[1][0][0][-1] + self.assertIn("/changes/100/", first_cp_url) + # Call 3: POST cherry-pick CL 200 (child second) + second_cp_url = calls[3][0][0][-1] + self.assertIn("/changes/200/", second_cp_url) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_chain_passes_base_commit(self, mock_run_curl): + async def run_test(): + # Arrange — child-first from API + related = [ + {"_change_number": 200, "_revision_number": 2}, + {"_change_number": 100, "_revision_number": 1}, + ] + mock_run_curl.side_effect = [ + _related_response(related), + # CL 100 (parent): cherry-pick + detail + _cherry_pick_response(1001, "CP of 100"), + _detail_response(1001, "sha_first"), + # CL 200 (child): cherry-pick + detail + _cherry_pick_response(1002, "CP of 200"), + _detail_response(1002, "sha_second"), + ] + + # Act + await main.cherry_pick_chain( + "200", "release-branch", gerrit_base_url=GERRIT_BASE_URL + ) + + # Assert — first cherry-pick should NOT have base, + # second should have base=sha_first + calls = mock_run_curl.call_args_list + + # Call 1: POST cherry-pick CL 100 + first_cp_args = calls[1][0][0] + first_payload = json.loads( + first_cp_args[first_cp_args.index("--data") + 1] + ) + self.assertNotIn("base", first_payload) + + # Call 2: GET detail for new CL 1001 + detail_url = calls[2][0][0][0] + self.assertIn("/changes/1001", detail_url) + self.assertIn("o=CURRENT_REVISION", detail_url) + + # Call 3: POST cherry-pick CL 200 + second_cp_args = calls[3][0][0] + second_payload = json.loads( + second_cp_args[second_cp_args.index("--data") + 1] + ) + self.assertEqual(second_payload["base"], "sha_first") + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_chain_no_related_changes(self, mock_run_curl): + async def run_test(): + # Arrange — empty relation chain + mock_run_curl.return_value = json.dumps({"changes": []}) + + # Act + result = await main.cherry_pick_chain( + "12345", "release-branch", gerrit_base_url=GERRIT_BASE_URL + ) + + # Assert + text = result[0]["text"] + self.assertIn("No related changes found", text) + self.assertIn("cherry_pick_change", text) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_chain_failure_mid_chain_with_partial_success(self, mock_run_curl): + async def run_test(): + # Arrange — 3 changes (child-first), second cherry-pick fails + related = [ + {"_change_number": 300, "_revision_number": 3}, + {"_change_number": 200, "_revision_number": 2}, + {"_change_number": 100, "_revision_number": 1}, + ] + # After reversing: 100, 200, 300. CL 100 succeeds, CL 200 fails. + mock_run_curl.side_effect = [ + _related_response(related), + _cherry_pick_response(1001, "CP of 100"), + _detail_response(1001, "sha_a"), + Exception("merge conflict"), # CL 200 cherry-pick fails + ] + + # Act + result = await main.cherry_pick_chain( + "300", "release-branch", gerrit_base_url=GERRIT_BASE_URL + ) + + # Assert + text = result[0]["text"] + self.assertIn("Cherry-pick chain failed at CL 200", text) + self.assertIn("(2/3)", text) + self.assertIn("Successfully cherry-picked before failure", text) + self.assertIn("CL 100 -> new CL 1001", text) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_chain_failure_first_change(self, mock_run_curl): + async def run_test(): + # Arrange — first cherry-pick fails (after reversing, CL 100 is first) + related = [ + {"_change_number": 200, "_revision_number": 2}, + {"_change_number": 100, "_revision_number": 1}, + ] + mock_run_curl.side_effect = [ + _related_response(related), + Exception("permission denied"), + ] + + # Act + result = await main.cherry_pick_chain( + "200", "release-branch", gerrit_base_url=GERRIT_BASE_URL + ) + + # Assert + text = result[0]["text"] + self.assertIn("Cherry-pick chain failed at CL 100", text) + self.assertIn("(1/2)", text) + self.assertNotIn("Successfully cherry-picked before failure", text) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_chain_bad_response_mid_chain(self, mock_run_curl): + async def run_test(): + # Arrange — second cherry-pick returns invalid response (no _number) + related = [ + {"_change_number": 200, "_revision_number": 2}, + {"_change_number": 100, "_revision_number": 1}, + ] + # After reversing: 100, 200. CL 100 succeeds, CL 200 bad response. + mock_run_curl.side_effect = [ + _related_response(related), + _cherry_pick_response(1001, "CP of 100"), + _detail_response(1001, "sha_a"), + json.dumps({"status": "error"}), # CL 200 bad response + ] + + # Act + result = await main.cherry_pick_chain( + "200", "release-branch", gerrit_base_url=GERRIT_BASE_URL + ) + + # Assert + text = result[0]["text"] + self.assertIn("Cherry-pick chain failed at CL 200", text) + self.assertIn("Successfully cherry-picked before failure", text) + self.assertIn("CL 100 -> new CL 1001", text) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_chain_fetch_related_fails(self, mock_run_curl): + async def run_test(): + # Arrange — fetching related changes itself fails + mock_run_curl.side_effect = Exception("network error") + + # Act + result = await main.cherry_pick_chain( + "12345", "release-branch", gerrit_base_url=GERRIT_BASE_URL + ) + + # Assert + text = result[0]["text"] + self.assertIn("Failed to fetch related changes", text) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_chain_allow_conflicts_default_true(self, mock_run_curl): + async def run_test(): + # Arrange + related = [{"_change_number": 100, "_revision_number": 1}] + mock_run_curl.side_effect = [ + _related_response(related), + _cherry_pick_response(1001, "CP of 100"), + _detail_response(1001, "sha_a"), + ] + + # Act + await main.cherry_pick_chain( + "100", "release-branch", gerrit_base_url=GERRIT_BASE_URL + ) + + # Assert — allow_conflicts should be in the payload by default + cp_call_args = mock_run_curl.call_args_list[1][0][0] + payload = json.loads( + cp_call_args[cp_call_args.index("--data") + 1] + ) + self.assertTrue(payload.get("allow_conflicts")) + + asyncio.run(run_test()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_cherry_pick_change.py b/tests/unit/test_cherry_pick_change.py new file mode 100644 index 0000000..be9f364 --- /dev/null +++ b/tests/unit/test_cherry_pick_change.py @@ -0,0 +1,207 @@ +# 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 TestCherryPickChange(unittest.TestCase): + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_cherry_pick_change_success(self, mock_run_curl): + async def run_test(): + # Arrange + change_id = "12345" + destination = "release-branch" + new_cl_number = 67890 + new_subject = "Cherry-picked change" + mock_run_curl.return_value = json.dumps( + { + "id": f"myProject~{destination}~Iabc123", + "_number": new_cl_number, + "subject": new_subject, + } + ) + gerrit_base_url = "https://my-gerrit.com" + + # Act + result = await main.cherry_pick_change( + change_id, destination, gerrit_base_url=gerrit_base_url + ) + + # Assert + self.assertIn( + f"Successfully cherry-picked CL {change_id} to branch {destination}", + result[0]["text"], + ) + self.assertIn( + f"New CL created: {new_cl_number}", result[0]["text"] + ) + self.assertIn(f"Subject: {new_subject}", result[0]["text"]) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_cherry_pick_change_with_message(self, mock_run_curl): + async def run_test(): + # Arrange + change_id = "12345" + destination = "release-branch" + custom_message = "Custom cherry-pick message" + mock_run_curl.return_value = json.dumps( + { + "id": "myProject~release-branch~Iabc123", + "_number": 67890, + "subject": custom_message, + } + ) + gerrit_base_url = "https://my-gerrit.com" + + # Act + result = await main.cherry_pick_change( + change_id, + destination, + message=custom_message, + gerrit_base_url=gerrit_base_url, + ) + + # Assert + self.assertIn("Successfully cherry-picked", result[0]["text"]) + # Verify the payload included the message + call_args = mock_run_curl.call_args[0][0] + payload = json.loads(call_args[call_args.index("--data") + 1]) + self.assertEqual(payload["message"], custom_message) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_cherry_pick_change_with_specific_revision(self, mock_run_curl): + async def run_test(): + # Arrange + change_id = "12345" + destination = "release-branch" + revision_id = "3" + mock_run_curl.return_value = json.dumps( + { + "id": "myProject~release-branch~Iabc123", + "_number": 67890, + "subject": "Cherry-picked", + } + ) + gerrit_base_url = "https://my-gerrit.com" + + # Act + result = await main.cherry_pick_change( + change_id, + destination, + revision_id=revision_id, + gerrit_base_url=gerrit_base_url, + ) + + # Assert + self.assertIn("Successfully cherry-picked", result[0]["text"]) + call_args = mock_run_curl.call_args[0][0] + url = call_args[-1] + self.assertIn(f"/revisions/{revision_id}/cherrypick", url) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_cherry_pick_change_allow_conflicts_default_true(self, mock_run_curl): + async def run_test(): + # Arrange + mock_run_curl.return_value = json.dumps( + { + "id": "myProject~main~Iabc", + "_number": 67890, + "subject": "Cherry-picked", + } + ) + gerrit_base_url = "https://my-gerrit.com" + + # Act + await main.cherry_pick_change( + "12345", "main", gerrit_base_url=gerrit_base_url + ) + + # Assert — allow_conflicts should be in the payload by default + call_args = mock_run_curl.call_args[0][0] + payload = json.loads(call_args[call_args.index("--data") + 1]) + self.assertTrue(payload.get("allow_conflicts")) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_cherry_pick_change_failure_response(self, mock_run_curl): + async def run_test(): + # Arrange + change_id = "12345" + error_message = "change is new" + mock_run_curl.return_value = error_message + gerrit_base_url = "https://my-gerrit.com" + + # Act + result = await main.cherry_pick_change( + change_id, "release-branch", gerrit_base_url=gerrit_base_url + ) + + # Assert + self.assertIn( + f"Failed to cherry-pick CL {change_id}", result[0]["text"] + ) + self.assertIn(error_message, result[0]["text"]) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_cherry_pick_change_missing_fields(self, mock_run_curl): + async def run_test(): + # Arrange — response lacks _number + change_id = "12345" + mock_run_curl.return_value = json.dumps({"status": "error"}) + gerrit_base_url = "https://my-gerrit.com" + + # Act + result = await main.cherry_pick_change( + change_id, "release-branch", gerrit_base_url=gerrit_base_url + ) + + # Assert + self.assertIn( + f"Failed to cherry-pick CL {change_id}", result[0]["text"] + ) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_cherry_pick_change_exception(self, mock_run_curl): + async def run_test(): + change_id = "12345" + gerrit_base_url = "https://my-gerrit.com" + error_message = "Internal server error" + mock_run_curl.side_effect = Exception(error_message) + + with self.assertRaisesRegex(Exception, error_message): + await main.cherry_pick_change( + change_id, "release-branch", gerrit_base_url=gerrit_base_url + ) + + asyncio.run(run_test()) + + +if __name__ == "__main__": + unittest.main()