summaryrefslogtreecommitdiff
path: root/servers/gitlab_python/src/mcp_server_gitlab_python/server.py
diff options
context:
space:
mode:
authorDawid Rycerz <dawid@rycerz.xyz>2025-07-24 10:45:29 +0300
committerDawid Rycerz <dawid@rycerz.xyz>2025-07-24 10:45:29 +0300
commitf7077b5c2f64f4d4a5d870f70e2f63caec220958 (patch)
tree1f82a9707e4f26e5e6f9135b8dea3abe9a86f93c /servers/gitlab_python/src/mcp_server_gitlab_python/server.py
parenta8e4df1743c78394269d922246a29e419069b01e (diff)
feat: add epic_id to issue parameters
Diffstat (limited to 'servers/gitlab_python/src/mcp_server_gitlab_python/server.py')
-rw-r--r--servers/gitlab_python/src/mcp_server_gitlab_python/server.py228
1 files changed, 181 insertions, 47 deletions
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()