diff options
| author | Dawid Rycerz <dawid@rycerz.xyz> | 2025-07-15 16:05:19 +0300 |
|---|---|---|
| committer | Dawid Rycerz <dawid@rycerz.xyz> | 2025-07-15 16:05:19 +0300 |
| commit | 7a5eb0b5fb9106dd43377231d4ee03c65420bf85 (patch) | |
| tree | 4fbb72d2c4fba2db854c0d49325ece225b43a0a8 | |
| parent | b9967d37d7c80d56b9f7e1367708be9b066882fe (diff) | |
feat: update gitlab-python and cursor rules
| -rw-r--r-- | .cursorrules | 83 | ||||
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | servers/gitlab_python/src/mcp_server_gitlab_python/server.py | 149 | ||||
| -rw-r--r-- | servers/gitlab_python/tests/test_server.py | 207 |
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 @@ -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 |
