summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--servers/gitlab_python/src/mcp_server_gitlab_python/__init__.py6
-rw-r--r--servers/gitlab_python/src/mcp_server_gitlab_python/__main__.py2
-rw-r--r--servers/gitlab_python/src/mcp_server_gitlab_python/cli.py31
-rw-r--r--servers/gitlab_python/src/mcp_server_gitlab_python/server.py228
-rw-r--r--servers/gitlab_python/tests/conftest.py1
-rw-r--r--servers/gitlab_python/tests/test_server.py124
6 files changed, 329 insertions, 63 deletions
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()
diff --git a/servers/gitlab_python/tests/conftest.py b/servers/gitlab_python/tests/conftest.py
index 0519ecb..e69de29 100644
--- a/servers/gitlab_python/tests/conftest.py
+++ b/servers/gitlab_python/tests/conftest.py
@@ -1 +0,0 @@
- \ No newline at end of file
diff --git a/servers/gitlab_python/tests/test_server.py b/servers/gitlab_python/tests/test_server.py
index 94820aa..64d27b1 100644
--- a/servers/gitlab_python/tests/test_server.py
+++ b/servers/gitlab_python/tests/test_server.py
@@ -477,4 +477,126 @@ def test_create_epic_comment_error(mock_gitlab, mock_settings):
mock_gitlab.return_value.groups.get.side_effect = Exception("Group not found")
result = server.create_epic_comment("bad-group", 101, "A new epic comment")
assert "error" in result
- assert "Group not found" in result["error"] \ No newline at end of file
+ assert "Group not found" in result["error"]
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+def test_create_issue_with_epic_id_success(mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ issue = MagicMock()
+ issue.id = 123
+ issue.web_url = "https://gitlab.com/project/-/issues/123"
+ proj.issues.create.return_value = issue
+ mock_gitlab.return_value.projects.get.return_value = proj
+
+ # Mock groups and epic
+ group = MagicMock()
+ epic = MagicMock()
+ epic_issue = MagicMock()
+ epic.issues.create.return_value = epic_issue
+ group.epics.get.return_value = epic
+ mock_gitlab.return_value.groups.list.return_value = [group]
+
+ result = server.create_issue(
+ project="project/path",
+ title="Test Issue",
+ description="Test Description",
+ epic_id=456
+ )
+
+ assert "url" in result
+ assert result["url"] == "https://gitlab.com/project/-/issues/123"
+ proj.issues.create.assert_called_once()
+ group.epics.get.assert_called_once_with(456)
+ epic.issues.create.assert_called_once_with({"issue_id": 123})
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+def test_create_issue_with_epic_id_not_found(mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ issue = MagicMock()
+ issue.id = 123
+ issue.web_url = "https://gitlab.com/project/-/issues/123"
+ proj.issues.create.return_value = issue
+ mock_gitlab.return_value.projects.get.return_value = proj
+
+ # Mock groups but no epic found
+ group = MagicMock()
+ group.epics.get.side_effect = Exception("Epic not found")
+ mock_gitlab.return_value.groups.list.return_value = [group]
+
+ result = server.create_issue(
+ project="project/path",
+ title="Test Issue",
+ description="Test Description",
+ epic_id=456
+ )
+
+ # Issue should still be created even if epic attachment fails
+ assert "url" in result
+ assert result["url"] == "https://gitlab.com/project/-/issues/123"
+ proj.issues.create.assert_called_once()
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+def test_update_issue_with_epic_id_success(mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ issue = MagicMock()
+ issue.id = 123
+ issue.web_url = "https://gitlab.com/project/-/issues/123"
+ proj.issues.get.return_value = issue
+ mock_gitlab.return_value.projects.get.return_value = proj
+
+ # Mock groups and epic
+ group = MagicMock()
+ epic = MagicMock()
+ epic_issue = MagicMock()
+ epic.issues.create.return_value = epic_issue
+ group.epics.get.return_value = epic
+ mock_gitlab.return_value.groups.list.return_value = [group]
+
+ result = server.update_issue(
+ project="project/path",
+ issue_iid=123,
+ title="Updated Title",
+ epic_id=456
+ )
+
+ assert "url" in result
+ assert result["url"] == "https://gitlab.com/project/-/issues/123"
+ proj.issues.get.assert_called_once_with(123)
+ issue.save.assert_called_once()
+ group.epics.get.assert_called_once_with(456)
+ epic.issues.create.assert_called_once_with({"issue_id": 123})
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+def test_update_issue_with_epic_id_not_found(mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ issue = MagicMock()
+ issue.id = 123
+ issue.web_url = "https://gitlab.com/project/-/issues/123"
+ proj.issues.get.return_value = issue
+ mock_gitlab.return_value.projects.get.return_value = proj
+
+ # Mock groups but no epic found
+ group = MagicMock()
+ group.epics.get.side_effect = Exception("Epic not found")
+ mock_gitlab.return_value.groups.list.return_value = [group]
+
+ result = server.update_issue(
+ project="project/path",
+ issue_iid=123,
+ title="Updated Title",
+ epic_id=456
+ )
+
+ # Issue should still be updated even if epic attachment fails
+ assert "url" in result
+ assert result["url"] == "https://gitlab.com/project/-/issues/123"
+ proj.issues.get.assert_called_once_with(123)
+ issue.save.assert_called_once() \ No newline at end of file