From c87c2b0c207d63fa8ea42b7674d66842c5ddf6f8 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 - Add get_cherry_picks_of_change tool to find cherry-picks of a change across branches --- gerrit_mcp_server/main.py | 307 ++++++++++++++++- tests/unit/test_cherry_pick_chain.py | 317 ++++++++++++++++++ tests/unit/test_cherry_pick_change.py | 207 ++++++++++++ tests/unit/test_get_cherry_picks_of_change.py | 168 ++++++++++ 4 files changed, 998 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_cherry_pick_chain.py create mode 100644 tests/unit/test_cherry_pick_change.py create mode 100644 tests/unit/test_get_cherry_picks_of_change.py diff --git a/gerrit_mcp_server/main.py b/gerrit_mcp_server/main.py index b9383e5..6be3ea9 100644 --- a/gerrit_mcp_server/main.py +++ b/gerrit_mcp_server/main.py @@ -782,6 +782,311 @@ 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 get_cherry_picks_of_change( + change_id: str, + gerrit_base_url: Optional[str] = None, +): + """ + Finds all cherry-picks of a given change across different branches. + Retrieves the Change-Id from the change's commit message, then queries + for all changes sharing that Change-Id. Useful for tracking where a + change has been cherry-picked and whether those cherry-picks need to + be submitted. + """ + 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 change details to get the Change-Id + detail_url = ( + f"{base_url}/changes/{change_id}/detail" + f"?o=CURRENT_REVISION&o=CURRENT_COMMIT" + ) + try: + result_str = await run_curl([detail_url], base_url) + details = json.loads(result_str) + except (json.JSONDecodeError, Exception) as e: + return [ + { + "type": "text", + "text": f"Failed to fetch details for CL {change_id}: {e}", + } + ] + + # Step 2: Extract Change-Id from commit message + change_id_value = None + current_rev = details.get("current_revision") + if current_rev and current_rev in details.get("revisions", {}): + commit_msg = ( + details["revisions"][current_rev] + .get("commit", {}) + .get("message", "") + ) + for line in commit_msg.splitlines(): + stripped = line.strip() + if stripped.startswith("Change-Id: "): + change_id_value = stripped.split("Change-Id: ", 1)[1].strip() + break + + if not change_id_value: + return [ + { + "type": "text", + "text": f"Could not find Change-Id in commit message for CL {change_id}.", + } + ] + + # Step 3: Query for all changes with the same Change-Id + query_url = f"{base_url}/changes/?q=change:{change_id_value}" + try: + result_str = await run_curl([query_url], base_url) + all_changes = json.loads(result_str) + except (json.JSONDecodeError, Exception) as e: + return [ + { + "type": "text", + "text": f"Failed to query cherry-picks for Change-Id {change_id_value}: {e}", + } + ] + + # Step 4: Filter out the original change + original_number = details.get("_number") + cherry_picks = [ + c for c in all_changes if c.get("_number") != original_number + ] + + if not cherry_picks: + return [ + { + "type": "text", + "text": f"No cherry-picks found for CL {change_id} (Change-Id: {change_id_value}).", + } + ] + + # Step 5: Format output + output = ( + f"Found {len(cherry_picks)} cherry-pick(s) of CL {change_id} " + f"(Change-Id: {change_id_value}):\n" + ) + for cp in cherry_picks: + output += ( + f"- CL {cp['_number']}: branch={cp.get('branch', 'N/A')}, " + f"project={cp.get('project', 'N/A')}, " + f"status={cp.get('status', 'N/A')}, " + f"subject={cp.get('subject', 'N/A')}\n" + ) + + return [{"type": "text", "text": output}] + + @mcp.tool() async def create_change( project: str, @@ -1257,4 +1562,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() diff --git a/tests/unit/test_get_cherry_picks_of_change.py b/tests/unit/test_get_cherry_picks_of_change.py new file mode 100644 index 0000000..37d4e10 --- /dev/null +++ b/tests/unit/test_get_cherry_picks_of_change.py @@ -0,0 +1,168 @@ +# 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 TestGetCherryPicksOfChange(unittest.TestCase): + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_cherry_picks_found(self, mock_run_curl): + async def run_test(): + detail_response = { + "_number": 12345, + "current_revision": "abc123", + "revisions": { + "abc123": { + "commit": { + "message": ( + "Fix the bug\n\n" + "Change-Id: I1234567890abcdef1234567890abcdef12345678\n" + ) + } + } + }, + } + query_response = [ + { + "_number": 12345, + "branch": "master", + "project": "myproject", + "status": "MERGED", + "subject": "Fix the bug", + }, + { + "_number": 12400, + "branch": "release-1.0", + "project": "myproject", + "status": "NEW", + "subject": "Fix the bug", + }, + { + "_number": 12500, + "branch": "release-2.0", + "project": "myproject", + "status": "MERGED", + "subject": "Fix the bug", + }, + ] + + mock_run_curl.side_effect = [ + json.dumps(detail_response), + json.dumps(query_response), + ] + gerrit_base_url = "https://my-gerrit.com" + + result = await main.get_cherry_picks_of_change( + "12345", gerrit_base_url=gerrit_base_url + ) + + self.assertIn("Found 2 cherry-pick(s)", result[0]["text"]) + self.assertIn("CL 12400", result[0]["text"]) + self.assertIn("release-1.0", result[0]["text"]) + self.assertIn("CL 12500", result[0]["text"]) + self.assertIn("release-2.0", result[0]["text"]) + # Original should not appear + self.assertNotIn("CL 12345", result[0]["text"].split(":\n", 1)[1]) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_no_cherry_picks(self, mock_run_curl): + async def run_test(): + detail_response = { + "_number": 12345, + "current_revision": "abc123", + "revisions": { + "abc123": { + "commit": { + "message": ( + "Fix the bug\n\n" + "Change-Id: I1234567890abcdef1234567890abcdef12345678\n" + ) + } + } + }, + } + query_response = [ + { + "_number": 12345, + "branch": "master", + "project": "myproject", + "status": "MERGED", + "subject": "Fix the bug", + }, + ] + + mock_run_curl.side_effect = [ + json.dumps(detail_response), + json.dumps(query_response), + ] + gerrit_base_url = "https://my-gerrit.com" + + result = await main.get_cherry_picks_of_change( + "12345", gerrit_base_url=gerrit_base_url + ) + + self.assertIn("No cherry-picks found", result[0]["text"]) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_no_change_id_in_commit(self, mock_run_curl): + async def run_test(): + detail_response = { + "_number": 12345, + "current_revision": "abc123", + "revisions": { + "abc123": { + "commit": { + "message": "Fix the bug without Change-Id footer" + } + } + }, + } + + mock_run_curl.return_value = json.dumps(detail_response) + gerrit_base_url = "https://my-gerrit.com" + + result = await main.get_cherry_picks_of_change( + "12345", gerrit_base_url=gerrit_base_url + ) + + self.assertIn("Could not find Change-Id", result[0]["text"]) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_fetch_details_failure(self, mock_run_curl): + async def run_test(): + mock_run_curl.side_effect = Exception("Connection refused") + gerrit_base_url = "https://my-gerrit.com" + + result = await main.get_cherry_picks_of_change( + "12345", gerrit_base_url=gerrit_base_url + ) + + self.assertIn("Failed to fetch details", result[0]["text"]) + + asyncio.run(run_test()) + + +if __name__ == "__main__": + unittest.main()