Add get_git_parent_changes tool with structured output Adds a new MCP tool that queries immediate git-parent changes of a given change using Gerrit's `parentof:` query operator. Useful when navigating a stack: given a change ID, it surfaces which open Gerrit changes have a commit that is a direct parent of the queried change. Only immediate parents are returned; grandparents and higher ancestors are excluded by design. Returns a typed _GitParentChangesResult so the MCP SDK auto-generates an output_schema and produces both structured and unstructured text content. Error paths raise exceptions rather than returning text error content blocks. This follows the structured-output pattern documented in AGENTS.md and the writing-extensions skill. Includes tests/unit/test_get_git_parent_changes.py covering success, WIP flag, multiple parents, empty result, JSON decode error, and curl exception cases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Change-Id: I61e8703d6dfb2f5aee2135b3a2d6c0a3bea7bf19
diff --git a/docs/available_tools.md b/docs/available_tools.md index 5d10e6a..a92c8ed 100644 --- a/docs/available_tools.md +++ b/docs/available_tools.md
@@ -37,6 +37,10 @@ revision — commit SHA, parent SHA(s), author, committer, subject, and the verbatim message — so an agent can retrieve parent SHAs (e.g. to check whether each parent is merged via `query_changes("commit:<sha>")`). +- **get_git_parent_changes**: Returns the immediate git-parent changes of a CL, + using the `parentof:` query operator to find changes whose commit is a direct + parent of the given change's commit. Only immediate parents are returned, not + grandparents or higher ancestors. - **suggest_reviewers**: Suggests reviewers for a change based on a query. - **abandon_change**: Abandons a change. - **get_most_recent_cl**: Gets the most recent CL for a user.
diff --git a/docs/use_cases.md b/docs/use_cases.md index b8d6ff1..bbfeb18 100644 --- a/docs/use_cases.md +++ b/docs/use_cases.md
@@ -25,6 +25,7 @@ | | "What other changes would be submitted with CL 67890?" | `changes_submitted_together` | | | "Show me the relation chain for CL 67890 — are its ancestors merged?" | `get_related_changes` | | | "Get the parent commit SHAs for CL 67890." | `get_revision_commit` | +| | "Which open changes are immediate git-parents of CL 67890?" | `get_git_parent_changes` | | | "Create a new change in project 'test-project', branch 'dev', with subject 'Test new feature'." | `create_change` | ## Data Analysis Use Cases
diff --git a/gerrit_mcp_server/main.py b/gerrit_mcp_server/main.py index aba1c40..2673f04 100644 --- a/gerrit_mcp_server/main.py +++ b/gerrit_mcp_server/main.py
@@ -1086,6 +1086,76 @@ ] +class _ParentChange(TypedDict): + change_number: int + subject: str + work_in_progress: bool + + +class _GitParentChangesResult(TypedDict): + change_id: str + parent_changes: List[_ParentChange] + note: Optional[str] + + +@mcp.tool() +async def get_git_parent_changes( + change_id: str, + gerrit_base_url: Optional[str] = None, +) -> _GitParentChangesResult: + """ + Returns the immediate git-parent changes of a given CL. + + Uses the parentof: query operator to find changes whose commit is a direct + parent of the given change's commit. Returns only immediate parents, not + grandparents or higher ancestors. + + change_id can be a numeric change number or a Change-Id (I... hash from the + commit footer). + """ + 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/?q={quote(f'parentof:{change_id}')}" + + try: + result_json_str = await run_curl([url], base_url) + changes = json.loads(result_json_str) + except json.JSONDecodeError as e: + raise ValueError( + "Failed to parse JSON response from Gerrit." + f" Raw response: '{result_json_str}'" + ) from e + except Exception as e: + raise RuntimeError( + f"Error fetching git-parent changes for {change_id}: {e}" + ) from e + + parent_changes: List[_ParentChange] = [ + { + "change_number": change["_number"], + "subject": change["subject"], + "work_in_progress": change.get("work_in_progress", False), + } + for change in changes + ] + + result: _GitParentChangesResult = { + "change_id": change_id, + "parent_changes": parent_changes, + "note": None, + } + if not parent_changes: + result["note"] = ( + "No git-parent changes found." + " The parent commit is not an open Gerrit change." + ) + + return result + + @mcp.tool() async def changes_submitted_together( change_id: str,
diff --git a/tests/unit/test_get_git_parent_changes.py b/tests/unit/test_get_git_parent_changes.py new file mode 100644 index 0000000..b52ab49 --- /dev/null +++ b/tests/unit/test_get_git_parent_changes.py
@@ -0,0 +1,114 @@ +import asyncio +import json +import unittest +from unittest.mock import AsyncMock, patch + +from gerrit_mcp_server import main + +GERRIT_BASE_URL = "https://gerrit.example.com" + +CHANGE_PARENT = { + "_number": 1001, + "subject": "parent: some work", + "work_in_progress": False, +} + +CHANGE_PARENT_WIP = { + "_number": 1002, + "subject": "parent: draft work", + "work_in_progress": True, +} + + +class TestGetGitParentChanges(unittest.TestCase): + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_returns_structured_result(self, mock_run_curl): + async def run_test(): + mock_run_curl.return_value = json.dumps([CHANGE_PARENT]) + + result = await main.get_git_parent_changes( + "2000", gerrit_base_url=GERRIT_BASE_URL + ) + + self.assertEqual(result["change_id"], "2000") + self.assertEqual(len(result["parent_changes"]), 1) + self.assertEqual(result["parent_changes"][0]["change_number"], 1001) + self.assertEqual( + result["parent_changes"][0]["subject"], "parent: some work" + ) + self.assertFalse(result["parent_changes"][0]["work_in_progress"]) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_wip_flag_preserved(self, mock_run_curl): + async def run_test(): + mock_run_curl.return_value = json.dumps([CHANGE_PARENT_WIP]) + + result = await main.get_git_parent_changes( + "2000", gerrit_base_url=GERRIT_BASE_URL + ) + + self.assertTrue(result["parent_changes"][0]["work_in_progress"]) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_multiple_parents(self, mock_run_curl): + async def run_test(): + mock_run_curl.return_value = json.dumps([CHANGE_PARENT, CHANGE_PARENT_WIP]) + + result = await main.get_git_parent_changes( + "2000", gerrit_base_url=GERRIT_BASE_URL + ) + + self.assertEqual(len(result["parent_changes"]), 2) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_empty_returns_structured_response(self, mock_run_curl): + async def run_test(): + mock_run_curl.return_value = json.dumps([]) + + result = await main.get_git_parent_changes( + "2000", gerrit_base_url=GERRIT_BASE_URL + ) + + self.assertEqual(result["change_id"], "2000") + self.assertEqual(result["parent_changes"], []) + self.assertIn("note", result) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_json_decode_error_raises(self, mock_run_curl): + async def run_test(): + mock_run_curl.return_value = "not valid json" + + with self.assertRaises(Exception) as ctx: + await main.get_git_parent_changes( + "2000", gerrit_base_url=GERRIT_BASE_URL + ) + + self.assertIn("Failed to parse", str(ctx.exception)) + + asyncio.run(run_test()) + + @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) + def test_curl_exception_raises(self, mock_run_curl): + async def run_test(): + mock_run_curl.side_effect = Exception("connection refused") + + with self.assertRaises(Exception) as ctx: + await main.get_git_parent_changes( + "2000", gerrit_base_url=GERRIT_BASE_URL + ) + + self.assertIn("connection refused", str(ctx.exception)) + + asyncio.run(run_test()) + + +if __name__ == "__main__": + unittest.main()