summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDawid Rycerz <dawid@rycerz.xyz>2025-07-15 16:05:19 +0300
committerDawid Rycerz <dawid@rycerz.xyz>2025-07-15 16:05:19 +0300
commit7a5eb0b5fb9106dd43377231d4ee03c65420bf85 (patch)
tree4fbb72d2c4fba2db854c0d49325ece225b43a0a8
parentb9967d37d7c80d56b9f7e1367708be9b066882fe (diff)
feat: update gitlab-python and cursor rules
-rw-r--r--.cursorrules83
-rw-r--r--.gitignore1
-rw-r--r--servers/gitlab_python/src/mcp_server_gitlab_python/server.py149
-rw-r--r--servers/gitlab_python/tests/test_server.py207
4 files changed, 437 insertions, 3 deletions
diff --git a/.cursorrules b/.cursorrules
new file mode 100644
index 0000000..afd9955
--- /dev/null
+++ b/.cursorrules
@@ -0,0 +1,83 @@
+# Role Definition
+
+- You are a **Python master**, a highly experienced **tutor**, a **world-renowned ML engineer**, and a **talented data scientist**.
+- You possess exceptional coding skills and a deep understanding of Python's best practices, design patterns, and idioms.
+- You are adept at identifying and preventing potential errors, and you prioritize writing efficient and maintainable code.
+- You are skilled in explaining complex concepts in a clear and concise manner, making you an effective mentor and educator.
+- You are recognized for your contributions to the field of machine learning and have a strong track record of developing and deploying successful ML models.
+
+# Technology Stack
+
+- **Python Version:** Python 3.11+
+- **Dependency Management:** uv
+- **Code Formatting:** Ruff (replaces `black`, `isort`, `flake8`)
+- **Type Hinting:** Strictly use the `typing` module. All functions, methods, and class members must have type annotations.
+- **Testing Framework:** `pytest`
+- **Documentation:** Google style docstring
+- **Environment Management:** `uv`
+- **Containerization:** `docker`, `docker-compose`
+- **Asynchronous Programming:** `asyncio`
+- **LLM Framework:** `mcp`
+- **Web Framework:** `FastMCP`
+- **Version Control:** `git`
+
+# Coding Guidelines
+
+## 1. Pythonic Practices
+
+- **Elegance and Readability:** Strive for elegant and Pythonic code that is easy to understand and maintain.
+- **PEP 8 Compliance:** Adhere to PEP 8 guidelines for code style, with Ruff as the primary linter and formatter.
+- **Explicit over Implicit:** Favor explicit code that clearly communicates its intent over implicit, overly concise code.
+- **Zen of Python:** Keep the Zen of Python in mind when making design decisions.
+
+## 2. Modular Design
+
+- **Single Responsibility Principle:** Each module/file should have a well-defined, single responsibility.
+- **Reusable Components:** Develop reusable functions and classes, favoring composition over inheritance.
+- **Package Structure:** Organize code into logical packages and modules.
+
+
+## 3. Code Quality
+
+- **Comprehensive Type Annotations:** All functions, methods, and class members must have type annotations, using the most specific types possible.
+- **Detailed Docstrings:** All functions, methods, and classes must have Google-style docstrings, thoroughly explaining their purpose, parameters, return values, and any exceptions raised. Include usage examples where helpful.
+- **Thorough Unit Testing:** Aim for high test coverage (80% or higher) using `pytest`. Test both common cases and edge cases.
+- **Robust Exception Handling:** Use specific exception types, provide informative error messages, and handle exceptions gracefully. Implement custom exception classes when needed. Avoid bare `except` clauses.
+- **Logging:** Employ the `logging` module judiciously to log important events, warnings, and errors.
+
+
+## 4. Performance Optimization
+
+- **Asynchronous Programming:** Leverage `async` and `await` for I/O-bound operations to maximize concurrency.
+- **Caching:** Apply `functools.lru_cache`, `@cache` (Python 3.9+), or `fastapi.Depends` caching where appropriate.
+- **Resource Monitoring:** Use `psutil` or similar to monitor resource usage and identify bottlenecks.
+- **Memory Efficiency:** Ensure proper release of unused resources to prevent memory leaks.
+- **Concurrency:** Employ `concurrent.futures` or `asyncio` to manage concurrent tasks effectively.
+- **Database Best Practices:** Design database schemas efficiently, optimize queries, and use indexes wisely.
+
+## 5. API Development with FastMCP
+
+- **Data Validation:** Use Pydantic models for rigorous request and response data validation.
+
+# Code Example Requirements
+
+- All functions must include type annotations.
+- Must provide clear, Google-style docstrings.
+- Key logic should be annotated with comments.
+- Provide usage examples (e.g., in the `tests/` directory or as a `__main__` section).
+- Include error handling.
+- Use `ruff` for code formatting.
+
+# Others
+
+- **Prioritize new features in Python 3.11+.**
+- **When explaining code, provide clear logical explanations and code comments.**
+- **When making suggestions, explain the rationale and potential trade-offs.**
+- **If code examples span multiple files, clearly indicate the file name.**
+- **Do not over-engineer solutions. Strive for simplicity and maintainability while still being efficient.**
+- **Favor modularity, but avoid over-modularization.**
+- **Use the most modern and efficient libraries when appropriate, but justify their use and ensure they don't add unnecessary complexity.**
+- **When providing solutions or examples, ensure they are self-contained and executable without requiring extensive modifications.**
+- **If a request is unclear or lacks sufficient information, ask clarifying questions before proceeding.**
+- **Always consider the security implications of your code, especially when dealing with user inputs and external data.**
+- **Actively use and promote best practices for the specific tasks at hand (LLM app development, data cleaning, demo creation, etc.).** \ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 1dc2ba6..cbe3e98 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,6 +42,7 @@ coverage.xml
# IDE
.idea/
.vscode/
+.cursor/
*.swp
*.swo
*~
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 3005d30..9173df1 100644
--- a/servers/gitlab_python/src/mcp_server_gitlab_python/server.py
+++ b/servers/gitlab_python/src/mcp_server_gitlab_python/server.py
@@ -51,7 +51,7 @@ def get_token_from_glab_config() -> Optional[str]:
def get_gitlab_settings(working_directory: str) -> tuple[str, str]:
# URL
- url = os.environ.get("GITLAB_URL")
+ url = os.environ.get("GITLAB_HOST")
if not url:
remote_url = get_git_remote_url(working_directory)
if remote_url:
@@ -114,6 +114,118 @@ class GitLabPythonServer:
except Exception as e:
return {"error": str(e)}
+ def get_mr_diff(
+ self,
+ project: str,
+ mr_iid: int,
+ max_size_kb: int = 100,
+ filter_extensions: Optional[list[str]] = 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)
+ # Get the diff as a list of dicts (one per file)
+ diffs = mr.diffs.list(get_all=True)
+ # Build unified diff string
+ diff_content = ""
+ for diff in diffs:
+ old_path = diff.old_path
+ new_path = diff.new_path
+ # Filter by extension
+ if any(
+ old_path.endswith(ext) or new_path.endswith(ext)
+ for ext in filter_extensions
+ ):
+ continue
+ diff_content += f"diff --git a/{old_path} b/{new_path}\n"
+ diff_content += diff.diff + "\n"
+ diff_size_kb = len(diff_content.encode("utf-8")) / 1024
+ if diff_size_kb > max_size_kb:
+ try:
+ with tempfile.NamedTemporaryFile(
+ mode="w",
+ suffix=".diff",
+ prefix="mr_diff_",
+ delete=False,
+ encoding="utf-8"
+ ) as temp_file:
+ temp_file.write(diff_content)
+ temp_path = temp_file.name
+ return {
+ "diff_too_large": True,
+ "size_kb": round(diff_size_kb, 2),
+ "max_size_kb": max_size_kb,
+ "temp_file_path": temp_path,
+ "message": (
+ 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:
+ return {
+ "error": (
+ f"Diff is too large ({diff_size_kb:.2f} KB) and failed to "
+ f"create temporary file: {str(e)}"
+ )
+ }
+ return {
+ "diff": diff_content,
+ "size_kb": round(diff_size_kb, 2),
+ "temp_file_path": None
+ }
+ except Exception as e:
+ return {"error": str(e)}
+
+ def run_ci_pipeline(
+ self,
+ project: str,
+ branch: str = None,
+ variables: Optional[dict] = None,
+ web_mode: bool = False,
+ working_directory: str = None,
+ ) -> dict[str, Any]:
+ import subprocess
+ try:
+ proj = self.gl.projects.get(project)
+ ref = branch
+ if not ref:
+ # Try to detect current branch
+ try:
+ result = subprocess.run([
+ "git", "branch", "--show-current"
+ ], capture_output=True, text=True, check=False, cwd=working_directory)
+ if result.returncode == 0 and result.stdout.strip():
+ ref = result.stdout.strip()
+ except Exception:
+ ref = None
+ if not ref:
+ # If still no branch, let GitLab use default
+ ref = proj.default_branch
+ # Prepare variables
+ 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
+ })
+ info = {
+ "success": True,
+ "pipeline_id": pipeline.id,
+ "pipeline_url": pipeline.web_url,
+ "branch": ref,
+ "web_mode": web_mode,
+ }
+ return info
+ 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)
@@ -165,6 +277,41 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP:
data.update(kwargs)
return server.create_issue(project, title, description, **data)
+ @mcp.tool()
+ def get_mr_diff(
+ project: str,
+ mr_iid: int,
+ working_directory: str,
+ max_size_kb: int = 100,
+ filter_extensions: Optional[list[str]] = None,
+ ) -> dict[str, Any]:
+ """Get the diff for a merge request."""
+ server = GitLabPythonServer(working_directory)
+ return server.get_mr_diff(
+ project=project,
+ mr_iid=mr_iid,
+ max_size_kb=max_size_kb,
+ filter_extensions=filter_extensions,
+ )
+
+ @mcp.tool()
+ def run_ci_pipeline(
+ project: str,
+ working_directory: str,
+ branch: str = None,
+ variables: Optional[dict] = None,
+ web_mode: bool = False,
+ ) -> dict[str, Any]:
+ """Run a CI/CD pipeline on GitLab."""
+ server = GitLabPythonServer(working_directory)
+ return server.run_ci_pipeline(
+ project=project,
+ branch=branch,
+ variables=variables,
+ web_mode=web_mode,
+ working_directory=working_directory,
+ )
+
return mcp
async def main(transport_type: str, host: str, port: int) -> None:
diff --git a/servers/gitlab_python/tests/test_server.py b/servers/gitlab_python/tests/test_server.py
index ab5a0bb..99cb788 100644
--- a/servers/gitlab_python/tests/test_server.py
+++ b/servers/gitlab_python/tests/test_server.py
@@ -1,6 +1,209 @@
import pytest
-from mcp_server_gitlab_python.server import create_server
+from mcp_server_gitlab_python.server import create_server, GitLabPythonServer
+from unittest.mock import MagicMock, patch
def test_create_server():
server = create_server()
- assert server is not None \ No newline at end of file
+ assert server is not None
+
+def make_mock_diff(old_path, new_path, diff):
+ m = MagicMock()
+ m.old_path = old_path
+ m.new_path = new_path
+ m.diff = diff
+ return m
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+def test_get_mr_diff_small_diff(mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ mr = MagicMock()
+ mock_gitlab.return_value.projects.get.return_value = proj
+ proj.mergerequests.get.return_value = mr
+ mr.diffs.list.return_value = [
+ make_mock_diff("file1.txt", "file1.txt", "diff content 1"),
+ make_mock_diff("file2.py", "file2.py", "diff content 2"),
+ ]
+ result = server.get_mr_diff("project/path", 1, max_size_kb=100)
+ assert "diff" in result
+ assert "file1.txt" in result["diff"]
+ assert result["temp_file_path"] is None
+ assert result["size_kb"] < 100
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+@patch("tempfile.NamedTemporaryFile")
+def test_get_mr_diff_large_diff_temp_file(mock_tempfile, mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ mr = MagicMock()
+ mock_gitlab.return_value.projects.get.return_value = proj
+ proj.mergerequests.get.return_value = mr
+ # Create a large diff
+ large_diff = "x" * (101 * 1024)
+ mock_diff = make_mock_diff("bigfile.txt", "bigfile.txt", large_diff)
+ mr.diffs.list.return_value = [mock_diff]
+ mock_file = MagicMock()
+ mock_file.name = "/tmp/mr_diff_12345.diff"
+ mock_tempfile.return_value.__enter__.return_value = mock_file
+ result = server.get_mr_diff("project/path", 1, max_size_kb=100)
+ assert result["diff_too_large"] is True
+ assert result["temp_file_path"] == "/tmp/mr_diff_12345.diff"
+ assert result["size_kb"] > 100
+ assert "message" in result
+ mock_file.write.assert_called_once()
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+def test_get_mr_diff_filter_extensions(mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ mr = MagicMock()
+ mock_gitlab.return_value.projects.get.return_value = proj
+ proj.mergerequests.get.return_value = mr
+ # One file should be filtered out
+ mr.diffs.list.return_value = [
+ make_mock_diff("file.lock", "file.lock", "should be filtered"),
+ make_mock_diff("file.txt", "file.txt", "should be present"),
+ ]
+ result = server.get_mr_diff("project/path", 1, max_size_kb=100, filter_extensions=[".lock"])
+ assert "file.lock" not in result["diff"]
+ assert "file.txt" in result["diff"]
+ assert "should be present" in result["diff"]
+ assert "should be filtered" not in result["diff"]
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+def test_get_mr_diff_error_handling(mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ mock_gitlab.return_value.projects.get.side_effect = Exception("Not found")
+ result = server.get_mr_diff("project/path", 1)
+ assert "error" in result
+ assert "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_run_ci_pipeline_success_with_branch(mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ pipeline = MagicMock()
+ pipeline.id = 123
+ pipeline.web_url = "https://gitlab.com/project/-/pipelines/123"
+ proj.pipelines.create.return_value = pipeline
+ mock_gitlab.return_value.projects.get.return_value = proj
+ result = server.run_ci_pipeline(
+ project="project/path",
+ branch="main",
+ variables={"FOO": "bar"},
+ web_mode=False,
+ working_directory="/tmp"
+ )
+ assert result["success"] is True
+ assert result["pipeline_id"] == 123
+ assert result["pipeline_url"] == "https://gitlab.com/project/-/pipelines/123"
+ assert result["branch"] == "main"
+ assert result["web_mode"] is False
+ proj.pipelines.create.assert_called_once()
+ args = proj.pipelines.create.call_args[0][0]
+ assert args["ref"] == "main"
+ assert {"key": "FOO", "value": "bar"} in args["variables"]
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+@patch("subprocess.run")
+def test_run_ci_pipeline_success_current_branch(mock_subprocess, mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ pipeline = MagicMock()
+ pipeline.id = 456
+ pipeline.web_url = "https://gitlab.com/project/-/pipelines/456"
+ proj.pipelines.create.return_value = pipeline
+ proj.default_branch = "main"
+ mock_gitlab.return_value.projects.get.return_value = proj
+ # Simulate git branch detection
+ mock_git = MagicMock()
+ mock_git.returncode = 0
+ mock_git.stdout = "feature-branch"
+ mock_subprocess.return_value = mock_git
+ result = server.run_ci_pipeline(
+ project="project/path",
+ branch=None,
+ variables=None,
+ web_mode=False,
+ working_directory="/tmp"
+ )
+ assert result["success"] is True
+ assert result["pipeline_id"] == 456
+ assert result["pipeline_url"] == "https://gitlab.com/project/-/pipelines/456"
+ assert result["branch"] == "feature-branch"
+ mock_subprocess.assert_called_once_with([
+ "git", "branch", "--show-current"
+ ], capture_output=True, text=True, check=False, cwd="/tmp")
+ proj.pipelines.create.assert_called_once()
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+@patch("subprocess.run")
+def test_run_ci_pipeline_fallback_to_default_branch(mock_subprocess, mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ pipeline = MagicMock()
+ pipeline.id = 789
+ pipeline.web_url = "https://gitlab.com/project/-/pipelines/789"
+ proj.pipelines.create.return_value = pipeline
+ proj.default_branch = "main"
+ mock_gitlab.return_value.projects.get.return_value = proj
+ # Simulate git branch detection failure
+ mock_git = MagicMock()
+ mock_git.returncode = 1
+ mock_git.stdout = ""
+ mock_subprocess.return_value = mock_git
+ result = server.run_ci_pipeline(
+ project="project/path",
+ branch=None,
+ variables=None,
+ web_mode=False,
+ working_directory="/tmp"
+ )
+ assert result["success"] is True
+ assert result["branch"] == "main"
+ proj.pipelines.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_run_ci_pipeline_web_mode(mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ pipeline = MagicMock()
+ pipeline.id = 321
+ pipeline.web_url = "https://gitlab.com/project/-/pipelines/321"
+ proj.pipelines.create.return_value = pipeline
+ mock_gitlab.return_value.projects.get.return_value = proj
+ result = server.run_ci_pipeline(
+ project="project/path",
+ branch="main",
+ variables={"FOO": "bar"},
+ web_mode=True,
+ working_directory="/tmp"
+ )
+ assert result["success"] is True
+ assert result["web_mode"] is True
+ args = proj.pipelines.create.call_args[0][0]
+ # Should include CI_PIPELINE_SOURCE=web
+ assert {"key": "CI_PIPELINE_SOURCE", "value": "web"} in args["variables"]
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+def test_run_ci_pipeline_error_handling(mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ mock_gitlab.return_value.projects.get.side_effect = Exception("Not found")
+ result = server.run_ci_pipeline(
+ project="project/path",
+ branch="main",
+ variables=None,
+ web_mode=False,
+ working_directory="/tmp"
+ )
+ assert "error" in result
+ assert "Not found" in result["error"] \ No newline at end of file