diff --git a/git_mcp/mcp_server.py b/git_mcp/mcp_server.py index a97519b..a3f9a52 100644 --- a/git_mcp/mcp_server.py +++ b/git_mcp/mcp_server.py @@ -316,6 +316,56 @@ async def list_my_merge_requests( ) +@mcp.tool() +async def get_merge_request_diff( + platform: str, project_id: str, mr_id: str, **options +) -> Dict[str, Any]: + """Get diff/changes for a merge request + + Args: + platform: The platform name (e.g., 'gitlab', 'github') + project_id: The project identifier + mr_id: The merge request/pull request ID + **options: Optional parameters: + - format: Response format ('json', 'unified') - default: 'json' + - include_diff: Include actual diff content (bool) - default: True + + Returns: + Dict containing: + - mr_id: The merge request ID + - total_changes: Summary of additions, deletions, files changed + - files: List of changed files with details + - diff_format: Format of the response + - truncated: Whether response was truncated + """ + return await PlatformService.get_merge_request_diff( + platform, project_id, mr_id, **options + ) + + +@mcp.tool() +async def get_merge_request_commits( + platform: str, project_id: str, mr_id: str, **filters +) -> Dict[str, Any]: + """Get commits for a merge request + + Args: + platform: The platform name (e.g., 'gitlab', 'github') + project_id: The project identifier + mr_id: The merge request/pull request ID + **filters: Optional filters for commit selection + + Returns: + Dict containing: + - mr_id: The merge request ID + - total_commits: Number of commits + - commits: List of commit details with sha, message, author, dates, etc. + """ + return await PlatformService.get_merge_request_commits( + platform, project_id, mr_id, **filters + ) + + # Fork operations @mcp.tool() async def create_fork(platform: str, project_id: str, **kwargs) -> Dict[str, Any]: diff --git a/git_mcp/platforms/base.py b/git_mcp/platforms/base.py index ac1df12..e458666 100644 --- a/git_mcp/platforms/base.py +++ b/git_mcp/platforms/base.py @@ -236,6 +236,20 @@ async def merge_merge_request( """Merge a merge request.""" pass + @abstractmethod + async def get_merge_request_diff( + self, project_id: str, mr_id: str, **options + ) -> Dict[str, Any]: + """Get diff/changes for a merge request.""" + pass + + @abstractmethod + async def get_merge_request_commits( + self, project_id: str, mr_id: str, **filters + ) -> Dict[str, Any]: + """Get commits for a merge request.""" + pass + # Repository operations @abstractmethod async def list_branches(self, project_id: str, **filters) -> List[Dict[str, Any]]: diff --git a/git_mcp/platforms/github.py b/git_mcp/platforms/github.py index 630f4b0..ec6f852 100644 --- a/git_mcp/platforms/github.py +++ b/git_mcp/platforms/github.py @@ -731,6 +731,123 @@ async def get_fork_parent(self, project_id: str) -> Optional[str]: f"Failed to get fork parent for {project_id}: {e}", self.platform_name ) + async def get_merge_request_diff( + self, project_id: str, mr_id: str, **options + ) -> Dict[str, Any]: + """Get diff/changes for a GitHub pull request.""" + if not self.client: + await self.authenticate() + + try: + repo = self.client.get_repo(project_id) + pr = repo.get_pull(int(mr_id)) + + # Get diff format option (default: json) + diff_format = options.get("format", "json") + include_diff = options.get("include_diff", True) + + # Get files changed in the PR + files = list(pr.get_files()) + + # Initialize response structure + response = { + "mr_id": str(mr_id), + "total_changes": { + "additions": pr.additions, + "deletions": pr.deletions, + "files_changed": len(files), + }, + "files": [], + "diff_format": diff_format, + "truncated": False, + } + + # Process file changes + for file in files: + file_info = { + "path": file.filename, + "status": file.status, # GitHub provides: added, removed, modified, renamed + "additions": file.additions, + "deletions": file.deletions, + "binary": file.filename.endswith( + (".png", ".jpg", ".jpeg", ".gif", ".pdf", ".zip", ".exe") + ), + } + + # Include diff content if requested and file is not too large + if ( + include_diff + and not file_info["binary"] + and hasattr(file, "patch") + and file.patch + ): + file_info["diff"] = file.patch + + response["files"].append(file_info) + + return response + + except GithubException as e: + if e.status == 404: + raise ResourceNotFoundError("pull_request", mr_id, self.platform_name) + raise PlatformError( + f"Failed to get pull request diff {mr_id}: {e}", self.platform_name + ) + + async def get_merge_request_commits( + self, project_id: str, mr_id: str, **filters + ) -> Dict[str, Any]: + """Get commits for a GitHub pull request.""" + if not self.client: + await self.authenticate() + + try: + repo = self.client.get_repo(project_id) + pr = repo.get_pull(int(mr_id)) + + # Get commits from the pull request + commits = list(pr.get_commits()) + + response: Dict[str, Any] = { + "mr_id": str(mr_id), + "total_commits": len(commits), + "commits": [], + } + + # Process each commit + for commit in commits: + commit_info = { + "sha": commit.sha, + "message": commit.commit.message, + "author": commit.commit.author.name if commit.commit.author else "", + "authored_date": commit.commit.author.date.isoformat() + if commit.commit.author and commit.commit.author.date + else "", + "committer": commit.commit.committer.name + if commit.commit.committer + else "", + "committed_date": commit.commit.committer.date.isoformat() + if commit.commit.committer and commit.commit.committer.date + else "", + "url": commit.html_url, + } + + # Add stats if available (GitHub provides these) + if hasattr(commit, "stats") and commit.stats: + commit_info["additions"] = commit.stats.additions + commit_info["deletions"] = commit.stats.deletions + + response["commits"].append(commit_info) + + return response + + except GithubException as e: + if e.status == 404: + raise ResourceNotFoundError("pull_request", mr_id, self.platform_name) + raise PlatformError( + f"Failed to get pull request commits {mr_id}: {e}", self.platform_name + ) + def parse_branch_reference(self, branch_ref: str) -> Dict[str, Any]: """Parse GitHub branch reference into components. diff --git a/git_mcp/platforms/gitlab.py b/git_mcp/platforms/gitlab.py index 904551b..17083f1 100644 --- a/git_mcp/platforms/gitlab.py +++ b/git_mcp/platforms/gitlab.py @@ -628,6 +628,129 @@ async def get_fork_parent(self, project_id: str) -> Optional[str]: f"Failed to get fork parent for {project_id}: {e}", self.platform_name ) + async def get_merge_request_diff( + self, project_id: str, mr_id: str, **options + ) -> Dict[str, Any]: + """Get diff/changes for a GitLab merge request.""" + if not self.client: + await self.authenticate() + + try: + project = self.client.projects.get(project_id) + mr = project.mergerequests.get(mr_id) + + # Get diff format option (default: json) + diff_format = options.get("format", "json") + include_diff = options.get("include_diff", True) + + # Get changes using GitLab changes API + changes = mr.changes() + + # Initialize response structure + response = { + "mr_id": str(mr_id), + "total_changes": { + "additions": 0, + "deletions": 0, + "files_changed": len(changes.get("changes", [])), + }, + "files": [], + "diff_format": diff_format, + "truncated": False, + } + + # Process file changes + for change in changes.get("changes", []): + file_info = { + "path": change.get("new_path") or change.get("old_path", ""), + "status": self._get_file_status(change), + "additions": 0, # GitLab doesn't provide line counts in changes + "deletions": 0, + "binary": change.get("diff", "").startswith("Binary files"), + } + + # Include diff content if requested + if include_diff and not file_info["binary"]: + file_info["diff"] = change.get("diff", "") + + response["files"].append(file_info) + + # Try to get overall stats from MR + if hasattr(mr, "changes_count"): + response["total_changes"]["files_changed"] = mr.changes_count + + return response + + except GitlabError as e: + if e.response_code == 404: + raise ResourceNotFoundError("merge_request", mr_id, self.platform_name) + raise PlatformError( + f"Failed to get merge request diff {mr_id}: {e}", self.platform_name + ) + + async def get_merge_request_commits( + self, project_id: str, mr_id: str, **filters + ) -> Dict[str, Any]: + """Get commits for a GitLab merge request.""" + if not self.client: + await self.authenticate() + + try: + project = self.client.projects.get(project_id) + mr = project.mergerequests.get(mr_id) + + # Get commits from the merge request + commits = mr.commits() + + response: Dict[str, Any] = { + "mr_id": str(mr_id), + "total_commits": len(commits), + "commits": [], + } + + # Process each commit + for commit in commits: + commit_info = { + "sha": commit.id, + "message": commit.message, + "author": commit.author_name, + "authored_date": commit.authored_date, + "committer": commit.committer_name, + "committed_date": commit.committed_date, + "url": commit.web_url if hasattr(commit, "web_url") else "", + } + + # Add stats if available + if hasattr(commit, "stats"): + commit_info["additions"] = getattr(commit.stats, "additions", 0) + commit_info["deletions"] = getattr(commit.stats, "deletions", 0) + + response["commits"].append(commit_info) + + return response + + except GitlabError as e: + if e.response_code == 404: + raise ResourceNotFoundError("merge_request", mr_id, self.platform_name) + raise PlatformError( + f"Failed to get merge request commits {mr_id}: {e}", self.platform_name + ) + + def _get_file_status(self, change: Dict) -> str: + """Determine file status from GitLab change object.""" + new_file = change.get("new_file", False) + deleted_file = change.get("deleted_file", False) + renamed_file = change.get("renamed_file", False) + + if new_file: + return "added" + elif deleted_file: + return "deleted" + elif renamed_file: + return "renamed" + else: + return "modified" + def parse_branch_reference(self, branch_ref: str) -> Dict[str, Any]: """Parse GitLab branch reference into components. diff --git a/git_mcp/services/platform_service.py b/git_mcp/services/platform_service.py index f4ede4d..c14a851 100644 --- a/git_mcp/services/platform_service.py +++ b/git_mcp/services/platform_service.py @@ -737,3 +737,33 @@ async def list_forks( "note": "Use GitHub/GitLab web interface to view forks for now", } ] + + @staticmethod + async def get_merge_request_diff( + platform_name: str, project_id: str, mr_id: str, **options + ) -> Dict[str, Any]: + """Get diff/changes for a merge request.""" + adapter = PlatformService.get_adapter(platform_name) + diff_data = await adapter.get_merge_request_diff(project_id, mr_id, **options) + + diff_data.update( + {"platform": platform_name, "project_id": project_id, "mr_id": mr_id} + ) + + return diff_data + + @staticmethod + async def get_merge_request_commits( + platform_name: str, project_id: str, mr_id: str, **filters + ) -> Dict[str, Any]: + """Get commits for a merge request.""" + adapter = PlatformService.get_adapter(platform_name) + commits_data = await adapter.get_merge_request_commits( + project_id, mr_id, **filters + ) + + commits_data.update( + {"platform": platform_name, "project_id": project_id, "mr_id": mr_id} + ) + + return commits_data