From 0ce2162d1d95f796483dc6f258c1ff691d345bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=A0=D1=8F=D0=B1=D0=BE?= =?UTF-8?q?=D0=B2?= Date: Wed, 25 Mar 2026 11:46:18 +0600 Subject: [PATCH] Added support for multiline inline comments --- src/code_review_mcp/providers.py | 57 +++++++++++++++++++++++++++----- src/code_review_mcp/server.py | 29 ++++++++++++++-- 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/src/code_review_mcp/providers.py b/src/code_review_mcp/providers.py index 22b48f3..f511845 100644 --- a/src/code_review_mcp/providers.py +++ b/src/code_review_mcp/providers.py @@ -38,8 +38,10 @@ async def add_inline_comment( line: int, line_type: str, comment: str, + start_line: int | None = None, + start_line_type: str | None = None, ) -> dict[str, Any]: - """Add inline comment to specific line.""" + """Add inline comment to specific line or range of lines.""" pass @abstractmethod @@ -162,8 +164,14 @@ async def get_pr_changes( "total_files": len(filtered_changes), } - def _find_line_code(self, diff: str, target_line: int, line_type: str, head_sha: str) -> str: - """Find line_code from diff for GitLab API.""" + def _find_line_info( + self, diff: str, target_line: int, line_type: str, head_sha: str + ) -> tuple[str, int | None, int | None]: + """Find line_code and line numbers from diff for GitLab API. + + Returns (line_code, old_line_num, new_line_num). + For 'old' lines: new_line_num is None; for 'new' lines: old_line_num is the context value. + """ lines = diff.split("\n") old_line = 0 new_line = 0 @@ -177,16 +185,16 @@ def _find_line_code(self, diff: str, target_line: int, line_type: str, head_sha: elif line.startswith("-"): old_line += 1 if line_type == "old" and old_line == target_line: - return f"{head_sha}_{old_line}_" + return f"{head_sha}_{old_line}_", old_line, None elif line.startswith("+"): new_line += 1 if line_type == "new" and new_line == target_line: - return f"{head_sha}_{old_line}_{new_line}" + return f"{head_sha}_{old_line}_{new_line}", None, new_line else: old_line += 1 new_line += 1 - return "" + return "", None, None async def add_inline_comment( self, @@ -196,6 +204,8 @@ async def add_inline_comment( line: int, line_type: str, comment: str, + start_line: int | None = None, + start_line_type: str | None = None, ) -> dict[str, Any]: """Add inline comment.""" project_id = repo.replace("/", "%2F") @@ -217,8 +227,9 @@ async def add_inline_comment( if not target_diff: return {"success": False, "error": f"File not found: {file_path}"} - line_code = self._find_line_code( - target_diff, line, line_type, mr_info.get("diff_refs", {}).get("head_sha", "") + head_sha = mr_info.get("diff_refs", {}).get("head_sha", "") + line_code, end_old_line, end_new_line = self._find_line_info( + target_diff, line, line_type, head_sha ) if not line_code: return {"success": False, "error": f"Cannot locate line {line}"} @@ -239,6 +250,28 @@ async def add_inline_comment( else: position["new_line"] = line + if start_line is not None: + effective_start_type = start_line_type or line_type + start_code, start_old_line, start_new_line = self._find_line_info( + target_diff, start_line, effective_start_type, head_sha + ) + if not start_code: + return {"success": False, "error": f"Cannot locate start_line {start_line}"} + position["line_range"] = { + "start": { + "line_code": start_code, + "type": effective_start_type, + "old_line": start_old_line, + "new_line": start_new_line, + }, + "end": { + "line_code": line_code, + "type": line_type, + "old_line": end_old_line, + "new_line": end_new_line, + }, + } + data = {"body": comment, "position": position} result = await self._call_api( project_id, f"merge_requests/{pr_id}/discussions", method="POST", data=data @@ -394,6 +427,8 @@ async def add_inline_comment( line: int, line_type: str, comment: str, + start_line: int | None = None, + start_line_type: str | None = None, ) -> dict[str, Any]: """Add inline comment using PR review comments API.""" pr_info = await self._call_api(f"/repos/{repo}/pulls/{pr_id}") @@ -402,7 +437,7 @@ async def add_inline_comment( commit_sha = pr_info.get("head", {}).get("sha") - data = { + data: dict[str, Any] = { "body": comment, "commit_id": commit_sha, "path": file_path, @@ -410,6 +445,10 @@ async def add_inline_comment( "side": "RIGHT" if line_type == "new" else "LEFT", } + if start_line is not None: + data["start_line"] = start_line + data["start_side"] = "RIGHT" if (start_line_type or line_type) == "new" else "LEFT" + result = await self._call_api( f"/repos/{repo}/pulls/{pr_id}/comments", method="POST", data=data ) diff --git a/src/code_review_mcp/server.py b/src/code_review_mcp/server.py index 2a5f574..81bc3d0 100644 --- a/src/code_review_mcp/server.py +++ b/src/code_review_mcp/server.py @@ -162,7 +162,7 @@ def extract_related_prs( }, "line": { "type": "integer", - "description": "Line number to comment on", + "description": "Line number to comment on (end line for multi-line comments)", }, "line_type": { "type": "string", @@ -173,6 +173,15 @@ def extract_related_prs( "type": "string", "description": "Comment content", }, + "start_line": { + "type": "integer", + "description": "Start line for a multi-line comment (omit for single-line)", + }, + "start_line_type": { + "type": "string", + "enum": ["old", "new"], + "description": "Line type for start_line; defaults to line_type if omitted", + }, "host": { "type": "string", "description": "GitLab host for self-hosted instances", @@ -255,9 +264,21 @@ def extract_related_prs( "type": "object", "properties": { "file_path": {"type": "string"}, - "line": {"type": "integer"}, + "line": { + "type": "integer", + "description": "Line number to comment on (end line for multi-line comments)", + }, "line_type": {"type": "string", "enum": ["old", "new"]}, "comment": {"type": "string"}, + "start_line": { + "type": "integer", + "description": "Start line for a multi-line comment (omit for single-line)", + }, + "start_line_type": { + "type": "string", + "enum": ["old", "new"], + "description": "Line type for start_line; defaults to line_type if omitted", + }, }, "required": ["file_path", "line", "line_type", "comment"], }, @@ -362,6 +383,8 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: arguments["line"], arguments["line_type"], arguments["comment"], + arguments.get("start_line"), + arguments.get("start_line_type"), ) elif name == "add_pr_comment": @@ -388,6 +411,8 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: comment_data["line"], comment_data["line_type"], comment_data["comment"], + comment_data.get("start_line"), + comment_data.get("start_line_type"), ) if res.get("success"): batch_results["inline_success"] += 1