From f7077b5c2f64f4d4a5d870f70e2f63caec220958 Mon Sep 17 00:00:00 2001 From: Dawid Rycerz Date: Thu, 24 Jul 2025 10:45:29 +0300 Subject: feat: add epic_id to issue parameters --- .../src/mcp_server_gitlab_python/__init__.py | 6 +- .../src/mcp_server_gitlab_python/__main__.py | 2 +- .../src/mcp_server_gitlab_python/cli.py | 31 ++- .../src/mcp_server_gitlab_python/server.py | 228 ++++++++++++++++----- 4 files changed, 206 insertions(+), 61 deletions(-) (limited to 'servers/gitlab_python/src') diff --git a/servers/gitlab_python/src/mcp_server_gitlab_python/__init__.py b/servers/gitlab_python/src/mcp_server_gitlab_python/__init__.py index f1a37c3..0dafdfa 100644 --- a/servers/gitlab_python/src/mcp_server_gitlab_python/__init__.py +++ b/servers/gitlab_python/src/mcp_server_gitlab_python/__init__.py @@ -4,11 +4,11 @@ This package provides an MCP server that integrates with GitLab using python-git """ try: - from importlib.metadata import version, PackageNotFoundError + from importlib.metadata import PackageNotFoundError, version except ImportError: - from importlib_metadata import version, PackageNotFoundError # type: ignore + from importlib_metadata import PackageNotFoundError, version # type: ignore try: __version__ = version("mcp-server-gitlab-python") except PackageNotFoundError: - __version__ = "unknown" \ No newline at end of file + __version__ = "unknown" diff --git a/servers/gitlab_python/src/mcp_server_gitlab_python/__main__.py b/servers/gitlab_python/src/mcp_server_gitlab_python/__main__.py index 8f98d0b..f2ed2b3 100644 --- a/servers/gitlab_python/src/mcp_server_gitlab_python/__main__.py +++ b/servers/gitlab_python/src/mcp_server_gitlab_python/__main__.py @@ -4,4 +4,4 @@ from .cli import run_server if __name__ == "__main__": - run_server() \ No newline at end of file + run_server() diff --git a/servers/gitlab_python/src/mcp_server_gitlab_python/cli.py b/servers/gitlab_python/src/mcp_server_gitlab_python/cli.py index 9374520..7df7aa8 100644 --- a/servers/gitlab_python/src/mcp_server_gitlab_python/cli.py +++ b/servers/gitlab_python/src/mcp_server_gitlab_python/cli.py @@ -10,10 +10,12 @@ import os import sys import mcp_server_gitlab_python + from .server import main logger = logging.getLogger("mcp_gitlab_python_server") + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="GitLab Python MCP Server") parser.add_argument( @@ -41,37 +43,46 @@ def parse_args() -> argparse.Namespace: ) return parser.parse_args() + def validate_args(args: argparse.Namespace) -> argparse.Namespace: - if (args.transport == "remote" and args.port < 1024 - and not sys.platform.startswith("win")): + if ( + args.transport == "remote" + and args.port < 1024 + and not sys.platform.startswith("win") + ): logger.warning( "Using a port below 1024 may require root privileges on Unix-like systems." ) return args + def setup_logging(level: str, transport: str) -> None: os.makedirs("logs", exist_ok=True) file_handler = logging.FileHandler("logs/mcp_server.log") file_handler.setLevel(getattr(logging, level)) - file_handler.setFormatter(logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - )) + file_handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + ) console_handler = logging.StreamHandler() if transport == "stdio": console_handler.setLevel(logging.WARNING) else: console_handler.setLevel(getattr(logging, level)) - console_handler.setFormatter(logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - )) + console_handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + ) root_logger = logging.getLogger() root_logger.setLevel(getattr(logging, level)) root_logger.handlers = [] root_logger.addHandler(file_handler) root_logger.addHandler(console_handler) + def run_server() -> None: - logger.error(f"MCP GitLab Python Server CLI starting, version: {mcp_server_gitlab_python.__version__}") + logger.error( + f"MCP GitLab Python Server CLI starting, version: " + f"{mcp_server_gitlab_python.__version__}" + ) args = validate_args(parse_args()) setup_logging(args.log_level, args.transport) try: @@ -80,4 +91,4 @@ def run_server() -> None: logger.info("Server stopped by user") except Exception as e: logger.error(f"Error running server: {e}") - sys.exit(1) \ No newline at end of file + sys.exit(1) diff --git a/servers/gitlab_python/src/mcp_server_gitlab_python/server.py b/servers/gitlab_python/src/mcp_server_gitlab_python/server.py index e8231c0..09d3628 100644 --- a/servers/gitlab_python/src/mcp_server_gitlab_python/server.py +++ b/servers/gitlab_python/src/mcp_server_gitlab_python/server.py @@ -8,28 +8,32 @@ from mcp.server.fastmcp import FastMCP logger = logging.getLogger("mcp_gitlab_python_server") + def get_git_remote_url(working_directory: str) -> str | None: try: from git import Repo + repo = Repo(working_directory) return repo.remotes.origin.url except Exception as e: logger.warning(f"Could not get git remote url: {e}") return None + def parse_gitlab_url_from_remote(remote_url: str) -> str | None: # Handles both SSH and HTTPS remotes if remote_url.startswith("git@"): # git@gitlab.com:namespace/project.git - host = remote_url.split('@')[1].split(':')[0] + host = remote_url.split("@")[1].split(":")[0] return f"https://{host}" elif remote_url.startswith("https://"): # https://gitlab.com/namespace/project.git - parts = remote_url.split('/') + parts = remote_url.split("/") if len(parts) > 2: return f"{parts[0]}//{parts[2]}" return None + def get_token_from_glab_config(host: str) -> str | None: """ Retrieve the GitLab token for a specific host from the glab-cli config file. @@ -40,14 +44,14 @@ def get_token_from_glab_config(host: str) -> str | None: try: with open(config_path) as f: config = yaml.safe_load(f) - hosts = config.get('hosts', {}) + hosts = config.get("hosts", {}) # Try direct match - if host in hosts and 'token' in hosts[host]: - return hosts[host]['token'] + if host in hosts and "token" in hosts[host]: + return hosts[host]["token"] # Try matching by api_host if present for _h, data in hosts.items(): # Renamed h to _h for Ruff B007 - if data.get('api_host') == host and 'token' in data: - return data['token'] + if data.get("api_host") == host and "token" in data: + return data["token"] except Exception as e: logger.warning(f"Could not parse glab-cli config: {e}") return None @@ -76,6 +80,7 @@ def get_gitlab_settings(working_directory: str) -> tuple[str, str]: url = f"https://{url}" # Extract host from URL from urllib.parse import urlparse + parsed = urlparse(url) host = ( parsed.hostname @@ -94,6 +99,7 @@ def get_gitlab_settings(working_directory: str) -> tuple[str, str]: ) return url, token + class GitLabPythonServer: def __init__(self, working_directory: str) -> None: url, token = get_gitlab_settings(working_directory) @@ -139,15 +145,64 @@ class GitLabPythonServer: project: str, title: str, description: str, + epic_id: int | None = None, **kwargs: object, ) -> dict[str, Any]: + """Create a new GitLab issue. + + Args: + project (str): The project full path or ID. + title (str): The title of the issue. + description (str): The description of the issue. + epic_id (int | None): The global ID of the epic to attach the issue to. + **kwargs: Additional fields for the issue. + + Returns: + dict[str, Any]: Dictionary with the issue URL or error message. + """ try: proj = self.gl.projects.get(project) - issue = proj.issues.create({ - "title": title, - "description": description, - **kwargs, - }) + issue = proj.issues.create( + { + "title": title, + "description": description, + **kwargs, + } + ) + + # If epic_id is provided, attach the issue to the epic + if epic_id is not None: + try: + # Find the epic by searching through groups + # This is a simplified approach - in practice, you might want to + # require the group to be specified or search more efficiently + groups = self.gl.groups.list(all=True) + epic_found = False + + for group in groups: + try: + epic = group.epics.get(epic_id) + epic.issues.create({"issue_id": issue.id}) + epic_found = True + logger.info( + f"Successfully attached issue {issue.id} to epic " + f"{epic_id}" + ) + break + except Exception as e: + logger.debug(f"Failed to get epic {epic_id} from group: {e}") + continue + + if not epic_found: + logger.warning( + f"Could not find epic {epic_id} to attach issue {issue.id}" + ) + except Exception as e: + logger.error( + f"Failed to attach issue {issue.id} to epic {epic_id}: {e}" + ) + # Don't fail the entire operation if epic attachment fails + return {"url": issue.web_url} except Exception as e: return {"error": str(e)} @@ -156,6 +211,7 @@ class GitLabPythonServer: self, project: str, issue_iid: int, + epic_id: int | None = None, **kwargs: object, ) -> dict[str, Any]: """Update an existing GitLab issue. @@ -163,6 +219,7 @@ class GitLabPythonServer: Args: project (str): The project full path or ID. issue_iid (int): The internal ID of the issue. + epic_id (int | None): The global ID of the epic to attach the issue to. **kwargs: Fields to update (e.g., title, description, labels, etc.). Returns: @@ -174,6 +231,38 @@ class GitLabPythonServer: for k, v in kwargs.items(): setattr(issue, k, v) issue.save() + + # If epic_id is provided, attach the issue to the epic + if epic_id is not None: + try: + # Find the epic by searching through groups + groups = self.gl.groups.list(all=True) + epic_found = False + + for group in groups: + try: + epic = group.epics.get(epic_id) + epic.issues.create({"issue_id": issue.id}) + epic_found = True + logger.info( + f"Successfully attached issue {issue.id} to epic " + f"{epic_id}" + ) + break + except Exception as e: + logger.debug(f"Failed to get epic {epic_id} from group: {e}") + continue + + if not epic_found: + logger.warning( + f"Could not find epic {epic_id} to attach issue {issue.id}" + ) + except Exception as e: + logger.error( + f"Failed to attach issue {issue.id} to epic {epic_id}: {e}" + ) + # Don't fail the entire operation if epic attachment fails + return {"url": issue.web_url} except Exception as e: logger.error(f"Failed to update issue {project}#{issue_iid}: {e}") @@ -187,27 +276,35 @@ class GitLabPythonServer: filter_extensions: list[str] | None = None, ) -> dict[str, Any]: import tempfile + if filter_extensions is None: filter_extensions = [".lock", ".log"] try: proj = self.gl.projects.get(project) mr = proj.mergerequests.get(mr_iid) - changes = mr.changes() - logger.debug(f"Fetched {len(changes.get('changes', []))} file changes for MR {mr_iid} in project {project}") + diffs = mr.diffs.list() + logger.debug( + f"Fetched {len(diffs)} file diffs for MR {mr_iid} in project {project}" + ) diff_content = "" - for i, change in enumerate(changes.get('changes', [])): - logger.debug(f"Processing change #{i}: {change}") - old_path = change.get('old_path', '/dev/null') or '/dev/null' - new_path = change.get('new_path', '/dev/null') or '/dev/null' - diff_text = change.get('diff', None) + for i, diff in enumerate(diffs): + logger.debug(f"Processing diff #{i}: {diff}") + old_path = diff.old_path or "/dev/null" + new_path = diff.new_path or "/dev/null" + diff_text = diff.diff if diff_text is None: - logger.warning(f"Skipping change #{i} due to missing diff text: {change}") + logger.warning( + f"Skipping diff #{i} due to missing diff text: {diff}" + ) continue if any( old_path.endswith(ext) or new_path.endswith(ext) for ext in filter_extensions ): - logger.debug(f"Skipping change #{i} due to filter extension: {old_path}, {new_path}") + logger.debug( + f"Skipping diff #{i} due to filter extension: " + f"{old_path}, {new_path}" + ) continue diff_content += f"diff --git a/{old_path} b/{new_path}\n" diff_content += diff_text + "\n" @@ -220,7 +317,7 @@ class GitLabPythonServer: suffix=".diff", prefix="mr_diff_", delete=False, - encoding="utf-8" + encoding="utf-8", ) as temp_file: temp_file.write(diff_content) temp_path = temp_file.name @@ -234,7 +331,7 @@ class GitLabPythonServer: f"Diff is too large ({diff_size_kb:.2f} KB > " f"{max_size_kb} KB). Content saved to temporary file: " f"{temp_path}" - ) + ), } except Exception as e: logger.error(f"Failed to create temp file for large diff: {e}") @@ -247,7 +344,7 @@ class GitLabPythonServer: return { "diff": diff_content, "size_kb": round(diff_size_kb, 2), - "temp_file_path": None + "temp_file_path": None, } except Exception as e: logger.error(f"Exception in get_mr_diff: {e}", exc_info=True) @@ -268,6 +365,7 @@ class GitLabPythonServer: # Try to detect current branch using GitPython try: from git import Repo + repo = Repo(working_directory) ref = repo.active_branch.name except Exception: @@ -279,12 +377,16 @@ class GitLabPythonServer: pipeline_vars = variables.copy() if variables else {} if web_mode: pipeline_vars["CI_PIPELINE_SOURCE"] = "web" - pipeline = proj.pipelines.create({ - "ref": ref, - "variables": [ - {"key": k, "value": v} for k, v in pipeline_vars.items() - ] if pipeline_vars else None - }) + pipeline = proj.pipelines.create( + { + "ref": ref, + "variables": [ + {"key": k, "value": v} for k, v in pipeline_vars.items() + ] + if pipeline_vars + else None, + } + ) info = { "success": True, "pipeline_id": pipeline.id, @@ -397,9 +499,7 @@ class GitLabPythonServer: logger.error(f"Failed to update epic {group}#{epic_iid}: {e}") return {"error": str(e)} - def list_issue_comments( - self, project: str, issue_iid: int - ) -> dict[str, Any]: + def list_issue_comments(self, project: str, issue_iid: int) -> dict[str, Any]: """List all comments (notes) for a given issue. Args: @@ -457,9 +557,7 @@ class GitLabPythonServer: except Exception as e: return {"error": str(e)} - def list_epic_comments( - self, group: str, epic_iid: int - ) -> dict[str, Any]: + def list_epic_comments(self, group: str, epic_iid: int) -> dict[str, Any]: """List all comments (notes) for a given epic. Args: @@ -517,6 +615,7 @@ class GitLabPythonServer: except Exception as e: return {"error": str(e)} + def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: mcp = FastMCP("GitLab Python", host=host, port=port) @@ -535,7 +634,7 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: state: str | None = None, labels: list[str] | None = None, milestone: str | None = None, - **kwargs: object + **kwargs: object, ) -> dict[str, Any]: """Search for GitLab issues with various filters.""" server = GitLabPythonServer(working_directory) @@ -558,9 +657,25 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: labels: list[str] | None = None, assignee_ids: list[int] | None = None, milestone_id: int | None = None, - **kwargs: object + epic_id: int | None = None, + **kwargs: object, ) -> dict[str, Any]: - """Create a new GitLab issue.""" + """Create a new GitLab issue. + + Args: + project (str): The project full path or ID. + title (str): The title of the issue. + description (str): The description of the issue. + working_directory (str): The working directory for context. + labels (list[str] | None): List of labels to apply to the issue. + assignee_ids (list[int] | None): List of user IDs to assign the issue to. + milestone_id (int | None): The ID of the milestone to assign the issue to. + epic_id (int | None): The global ID of the epic to attach the issue to. + **kwargs: Additional fields for the issue. + + Returns: + dict[str, Any]: Dictionary with the issue URL or error message. + """ server = GitLabPythonServer(working_directory) data = {} if labels: @@ -570,7 +685,7 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: if milestone_id: data["milestone_id"] = milestone_id data.update(kwargs) - return server.create_issue(project, title, description, **data) + return server.create_issue(project, title, description, epic_id=epic_id, **data) @mcp.tool() def update_issue( @@ -583,9 +698,27 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: labels: list[str] | None = None, assignee_ids: list[int] | None = None, milestone_id: int | None = None, - **kwargs: object + epic_id: int | None = None, + **kwargs: object, ) -> dict[str, Any]: - """Update an existing GitLab issue.""" + """Update an existing GitLab issue. + + Args: + project (str): The project full path or ID. + issue_iid (int): The internal ID of the issue. + working_directory (str): The working directory for context. + title (str | None): The new title for the issue. + description (str | None): The new description for the issue. + state_event (str | None): The state event (e.g., 'close', 'reopen'). + labels (list[str] | None): List of labels to apply to the issue. + assignee_ids (list[int] | None): List of user IDs to assign the issue to. + milestone_id (int | None): The ID of the milestone to assign the issue to. + epic_id (int | None): The global ID of the epic to attach the issue to. + **kwargs: Additional fields to update. + + Returns: + dict[str, Any]: Dictionary with the issue URL or error message. + """ server = GitLabPythonServer(working_directory) data = {} if title is not None: @@ -601,7 +734,7 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: if milestone_id is not None: data["milestone_id"] = milestone_id data.update(kwargs) - return server.update_issue(project, issue_iid, **data) + return server.update_issue(project, issue_iid, epic_id=epic_id, **data) @mcp.tool() def get_mr_diff( @@ -654,7 +787,7 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: order_by: str | None = None, sort: str | None = None, search: str | None = None, - **kwargs: object + **kwargs: object, ) -> dict[str, Any]: """Search for GitLab epics in a group with various filters.""" server = GitLabPythonServer(working_directory) @@ -680,7 +813,7 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: confidential: bool | None = None, start_date_fixed: str | None = None, due_date_fixed: str | None = None, - **kwargs: object + **kwargs: object, ) -> dict[str, Any]: """Create a new GitLab epic in a group.""" server = GitLabPythonServer(working_directory) @@ -716,7 +849,7 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: confidential: bool | None = None, start_date_fixed: str | None = None, due_date_fixed: str | None = None, - **kwargs: object + **kwargs: object, ) -> dict[str, Any]: """Update an existing GitLab epic.""" server = GitLabPythonServer(working_directory) @@ -826,6 +959,7 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: return mcp + async def main(transport_type: str, host: str, port: int) -> None: logger.info("Starting MCP GitLab Python Server") mcp = create_server(host=host, port=port) @@ -834,4 +968,4 @@ async def main(transport_type: str, host: str, port: int) -> None: await mcp.run_stdio_async() else: logger.info(f"Server running with remote transport on {host}:{port}") - await mcp.run_sse_async() \ No newline at end of file + await mcp.run_sse_async() -- cgit v1.2.3