diff options
| author | Dawid Rycerz <dawid@rycerz.xyz> | 2025-05-28 15:44:40 +0200 |
|---|---|---|
| committer | Dawid Rycerz <dawid@rycerz.xyz> | 2025-05-28 15:44:40 +0200 |
| commit | 54b521f19e00bea52304f7379ca59de0c2e20962 (patch) | |
| tree | d28ecbe7dbd59a5d400f52cd7345ed0f5ec66476 | |
| parent | 0b819edceb307ce2f8ba6d58b37a86329b7d6ec0 (diff) | |
feat(gitlab_glab): add CI pipeline functionality with glab ci run
- Add new run_ci_pipeline method to GitLabServer class
- Support all glab ci run options including variables, branch, and web mode
- Auto-detect current branch using git branch --show-current when -b is missing
- Implement web mode that overrides CI_PIPELINE_SOURCE=web
- Add comprehensive test coverage with 9 new test cases
- Update README.md with complete documentation and usage examples
- Maintain 95% test coverage and pass all 53 tests
- Follow project standards with proper error handling and type hints
Closes: Add CI job runner functionality as requested
| -rw-r--r-- | servers/gitlab_glab/README.md | 35 | ||||
| -rw-r--r-- | servers/gitlab_glab/src/mcp_server_gitlab_glab/server.py | 144 | ||||
| -rw-r--r-- | servers/gitlab_glab/tests/test_integration.py | 3 | ||||
| -rw-r--r-- | servers/gitlab_glab/tests/test_server.py | 258 |
4 files changed, 439 insertions, 1 deletions
diff --git a/servers/gitlab_glab/README.md b/servers/gitlab_glab/README.md index a2bb677..19b6cd7 100644 --- a/servers/gitlab_glab/README.md +++ b/servers/gitlab_glab/README.md @@ -9,6 +9,7 @@ This MCP server provides integration with GitLab through the GitLab CLI (`glab`) - Search for GitLab issues with various filters - Create new GitLab issues - Get merge request diffs with automatic handling of large diffs +- Run CI/CD pipelines with support for variables and web mode - More features to be added in the future ## Prerequisites @@ -179,6 +180,40 @@ The function returns a dictionary containing: **Note on Large Diffs**: To prevent overwhelming LLMs with extremely large diffs, this tool automatically saves diffs larger than `max_size_kb` (default: 100KB) to a temporary file and returns the file path instead of the content. This allows you to process large merge request diffs without hitting token limits. +### run_ci_pipeline + +Run a CI/CD pipeline on GitLab using `glab ci run`. + +```python +result = use_mcp_tool( + server_name="gitlab_glab", + tool_name="run_ci_pipeline", + arguments={ + "working_directory": "/path/to/current/directory", + # Optional parameters + "branch": "main", # Branch/ref to run pipeline on (if None, uses current branch) + "variables": ["VAR1:value1", "VAR2:value2"], # Variables in key:value format + "variables_env": ["ENV1:envval1"], # Environment variables in key:value format + "variables_file": ["FILE1:file1.txt"], # File variables in key:filename format + "variables_from": "/path/to/vars.json", # JSON file containing variables + "web_mode": True, # Enable web mode (sets CI_PIPELINE_SOURCE=web) + "repo": "group/project" # Project path with namespace + } +) +``` + +The function returns a dictionary containing: +- `success`: Boolean indicating if the pipeline was created successfully +- `output`: The full command output from glab +- `branch`: The branch the pipeline was created on +- `web_mode`: Boolean indicating if web mode was used +- `pipeline_url`: The URL of the created pipeline (if found in output) +- `error`: Error message if the operation failed + +**Branch Detection**: If no `branch` is specified, the tool will automatically detect the current git branch using `git branch --show-current`. If branch detection fails, the pipeline will be created without specifying a branch (uses GitLab's default behavior). + +**Web Mode**: When `web_mode` is set to `True`, the tool adds `CI_PIPELINE_SOURCE:web` as an environment variable, which allows the pipeline to run with web-triggered behavior and access to manual pipeline features. + ## Working Directory Context All tools require a `working_directory` parameter that specifies the directory context in which the GitLab CLI commands should be executed. This ensures that commands are run in the same directory as the agent, maintaining proper context for repository operations. diff --git a/servers/gitlab_glab/src/mcp_server_gitlab_glab/server.py b/servers/gitlab_glab/src/mcp_server_gitlab_glab/server.py index 881228f..b661255 100644 --- a/servers/gitlab_glab/src/mcp_server_gitlab_glab/server.py +++ b/servers/gitlab_glab/src/mcp_server_gitlab_glab/server.py @@ -402,7 +402,115 @@ class GitLabServer: "temp_file_path": None } + def run_ci_pipeline( + self, + working_directory: str, + branch: str | None = None, + variables: list[str] | None = None, + variables_env: list[str] | None = None, + variables_file: list[str] | None = None, + variables_from: str | None = None, + web_mode: bool = False, + repo: str | None = None, + ) -> dict[str, Any]: + """Run a CI/CD pipeline on GitLab. + Args: + working_directory: The directory to execute the command in. + branch: Create pipeline on branch/ref. If None, uses current branch. + variables: Pass variables to pipeline in format key:value. + variables_env: Pass environment variables to pipeline in format key:value. + variables_file: Pass file contents as file variables in format key:filename. + variables_from: JSON file containing variables for pipeline execution. + web_mode: Run pipeline in web mode (overrides CI_PIPELINE_SOURCE to 'web'). + repo: Select another repository (OWNER/REPO or GROUP/NAMESPACE/REPO format). + + Returns: + A dictionary containing the pipeline information or an error message. + """ + # Build command arguments + args = ["ci", "run"] + + # Handle branch - if not provided, get current branch + if branch is None: + try: + # Get current branch using git command + 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(): + branch = result.stdout.strip() + logger.info(f"Using current branch: {branch}") + else: + logger.warning( + "Could not determine current branch, proceeding without -b flag" + ) + except Exception as e: + logger.warning(f"Could not determine current branch: {str(e)}") + + # Add branch if available + if branch: + args.extend(["-b", branch]) + + # Add variables + if variables: + for var in variables: + args.extend(["--variables", var]) + + # Add environment variables + if variables_env: + for var in variables_env: + args.extend(["--variables-env", var]) + + # Add file variables + if variables_file: + for var in variables_file: + args.extend(["--variables-file", var]) + + # Add variables from file + if variables_from: + args.extend(["-f", variables_from]) + + # Add web mode - override CI_PIPELINE_SOURCE to 'web' + if web_mode: + args.extend(["--variables-env", "CI_PIPELINE_SOURCE:web"]) + + # Add repo + if repo: + args.extend(["-R", repo]) + + # Execute the command + success, result = self.execute_glab_command(args, working_directory) + + if not success: + return result + + # Parse the output to extract pipeline information + # The output typically contains pipeline URL and ID + output_lines = result.strip().split('\n') if isinstance(result, str) else [] + + pipeline_info = { + "success": True, + "output": result, + "branch": branch, + "web_mode": web_mode + } + + # Try to extract pipeline URL if present + for line in output_lines: + if "https://" in line and "/pipelines/" in line: + # Extract just the URL from the line + import re + url_match = re.search(r'https://[^\s]+/pipelines/\d+', line) + if url_match: + pipeline_info["pipeline_url"] = url_match.group(0) + break + + return pipeline_info def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: """Create and configure the FastMCP server. @@ -581,6 +689,42 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: max_size_kb=max_size_kb, ) + @mcp.tool() + def run_ci_pipeline( + working_directory: str, + branch: str | None = None, + variables: list[str] | None = None, + variables_env: list[str] | None = None, + variables_file: list[str] | None = None, + variables_from: str | None = None, + web_mode: bool = False, + repo: str | None = None, + ) -> dict[str, Any]: + """Run a CI/CD pipeline on GitLab. + + Args: + working_directory: The directory to execute the command in. + branch: Create pipeline on branch/ref. If None, uses current branch. + variables: Pass variables to pipeline in format key:value. + variables_env: Pass environment variables to pipeline in format key:value. + variables_file: Pass file contents as file variables in format key:filename. + variables_from: JSON file containing variables for pipeline execution. + web_mode: Run pipeline in web mode (overrides CI_PIPELINE_SOURCE to 'web'). + repo: Select another repository (OWNER/REPO or GROUP/NAMESPACE/REPO format). + + Returns: + A dictionary containing the pipeline information or an error message. + """ + return gitlab.run_ci_pipeline( + working_directory=working_directory, + branch=branch, + variables=variables, + variables_env=variables_env, + variables_file=variables_file, + variables_from=variables_from, + web_mode=web_mode, + repo=repo, + ) return mcp diff --git a/servers/gitlab_glab/tests/test_integration.py b/servers/gitlab_glab/tests/test_integration.py index b7d1005..1e55b46 100644 --- a/servers/gitlab_glab/tests/test_integration.py +++ b/servers/gitlab_glab/tests/test_integration.py @@ -29,7 +29,8 @@ class TestIntegration: # - search_issues # - create_issue # - get_mr_diff - assert mock_server.tool.call_count == 5 + # - run_ci_pipeline + assert mock_server.tool.call_count == 6 # Verify that the tool decorator was called with functions that have # working_directory parameter. We can't directly access the decorated functions diff --git a/servers/gitlab_glab/tests/test_server.py b/servers/gitlab_glab/tests/test_server.py index a7e74a4..867b2c0 100644 --- a/servers/gitlab_glab/tests/test_server.py +++ b/servers/gitlab_glab/tests/test_server.py @@ -762,3 +762,261 @@ index 1234567..abcdefg 100644 ["mr", "diff", "123"], working_dir, ) + + # Tests for run_ci_pipeline method + + @patch.object(GitLabServer, "execute_glab_command") + @patch("subprocess.run") + def test_run_ci_pipeline_success_with_branch( + self, mock_subprocess: MagicMock, mock_execute: MagicMock + ) -> None: + """Test successful pipeline run with specified branch.""" + # Mock successful glab command execution + mock_execute.return_value = (True, "Pipeline created: https://gitlab.example.com/project/-/pipelines/123") + + server = GitLabServer() + working_dir = "/test/directory" + result = server.run_ci_pipeline( + working_directory=working_dir, + branch="main", + ) + + assert result["success"] is True + assert result["branch"] == "main" + assert result["web_mode"] is False + assert "pipeline_url" in result + assert result["pipeline_url"] == "https://gitlab.example.com/project/-/pipelines/123" + mock_execute.assert_called_once_with( + ["ci", "run", "-b", "main"], + working_dir, + ) + # subprocess.run should not be called when branch is specified + mock_subprocess.assert_not_called() + + @patch.object(GitLabServer, "execute_glab_command") + @patch("subprocess.run") + def test_run_ci_pipeline_success_current_branch( + self, mock_subprocess: MagicMock, mock_execute: MagicMock + ) -> None: + """Test successful pipeline run using current branch.""" + # Mock git branch --show-current command + mock_git_process = MagicMock() + mock_git_process.returncode = 0 + mock_git_process.stdout = "feature-branch" + mock_subprocess.return_value = mock_git_process + + # Mock successful glab command execution + mock_execute.return_value = (True, "Pipeline created: https://gitlab.example.com/project/-/pipelines/456") + + server = GitLabServer() + working_dir = "/test/directory" + result = server.run_ci_pipeline( + working_directory=working_dir, + ) + + assert result["success"] is True + assert result["branch"] == "feature-branch" + assert result["web_mode"] is False + assert "pipeline_url" in result + assert result["pipeline_url"] == "https://gitlab.example.com/project/-/pipelines/456" + + mock_subprocess.assert_called_once_with( + ["git", "branch", "--show-current"], + capture_output=True, + text=True, + check=False, + cwd=working_dir, + ) + mock_execute.assert_called_once_with( + ["ci", "run", "-b", "feature-branch"], + working_dir, + ) + + @patch.object(GitLabServer, "execute_glab_command") + @patch("subprocess.run") + def test_run_ci_pipeline_git_branch_failure( + self, mock_subprocess: MagicMock, mock_execute: MagicMock + ) -> None: + """Test pipeline run when git branch --show-current fails.""" + # Mock git branch --show-current command failure + mock_git_process = MagicMock() + mock_git_process.returncode = 1 + mock_git_process.stdout = "" + mock_subprocess.return_value = mock_git_process + + # Mock successful glab command execution (without branch) + mock_execute.return_value = (True, "Pipeline created: https://gitlab.example.com/project/-/pipelines/789") + + server = GitLabServer() + working_dir = "/test/directory" + result = server.run_ci_pipeline( + working_directory=working_dir, + ) + + assert result["success"] is True + assert result["branch"] is None + assert result["web_mode"] is False + + mock_subprocess.assert_called_once_with( + ["git", "branch", "--show-current"], + capture_output=True, + text=True, + check=False, + cwd=working_dir, + ) + # Should run without -b flag when branch detection fails + mock_execute.assert_called_once_with( + ["ci", "run"], + working_dir, + ) + + @patch.object(GitLabServer, "execute_glab_command") + def test_run_ci_pipeline_with_web_mode(self, mock_execute: MagicMock) -> None: + """Test pipeline run with web mode enabled.""" + # Mock successful glab command execution + mock_execute.return_value = (True, "Pipeline created in web mode") + + server = GitLabServer() + working_dir = "/test/directory" + result = server.run_ci_pipeline( + working_directory=working_dir, + branch="main", + web_mode=True, + ) + + assert result["success"] is True + assert result["branch"] == "main" + assert result["web_mode"] is True + + mock_execute.assert_called_once_with( + ["ci", "run", "-b", "main", "--variables-env", "CI_PIPELINE_SOURCE:web"], + working_dir, + ) + + @patch.object(GitLabServer, "execute_glab_command") + def test_run_ci_pipeline_with_variables(self, mock_execute: MagicMock) -> None: + """Test pipeline run with various variable types.""" + # Mock successful glab command execution + mock_execute.return_value = (True, "Pipeline created with variables") + + server = GitLabServer() + working_dir = "/test/directory" + result = server.run_ci_pipeline( + working_directory=working_dir, + branch="develop", + variables=["VAR1:value1", "VAR2:value2"], + variables_env=["ENV1:envvalue1"], + variables_file=["FILE1:file1.txt"], + variables_from="/path/to/vars.json", + repo="group/project", + ) + + assert result["success"] is True + assert result["branch"] == "develop" + + expected_args = [ + "ci", "run", + "-b", "develop", + "--variables", "VAR1:value1", + "--variables", "VAR2:value2", + "--variables-env", "ENV1:envvalue1", + "--variables-file", "FILE1:file1.txt", + "-f", "/path/to/vars.json", + "-R", "group/project" + ] + mock_execute.assert_called_once_with(expected_args, working_dir) + + @patch.object(GitLabServer, "execute_glab_command") + def test_run_ci_pipeline_with_web_mode_and_variables( + self, mock_execute: MagicMock + ) -> None: + """Test pipeline run with web mode and additional variables.""" + # Mock successful glab command execution + mock_execute.return_value = (True, "Pipeline created") + + server = GitLabServer() + working_dir = "/test/directory" + result = server.run_ci_pipeline( + working_directory=working_dir, + branch="main", + variables_env=["CUSTOM_VAR:value"], + web_mode=True, + ) + + assert result["success"] is True + assert result["web_mode"] is True + + expected_args = [ + "ci", "run", + "-b", "main", + "--variables-env", "CUSTOM_VAR:value", + "--variables-env", "CI_PIPELINE_SOURCE:web" + ] + mock_execute.assert_called_once_with(expected_args, working_dir) + + @patch.object(GitLabServer, "execute_glab_command") + def test_run_ci_pipeline_glab_failure(self, mock_execute: MagicMock) -> None: + """Test pipeline run when glab command fails.""" + # Mock glab command failure + mock_execute.return_value = (False, {"error": "Authentication required"}) + + server = GitLabServer() + working_dir = "/test/directory" + result = server.run_ci_pipeline( + working_directory=working_dir, + branch="main", + ) + + assert result == {"error": "Authentication required"} + mock_execute.assert_called_once_with( + ["ci", "run", "-b", "main"], + working_dir, + ) + + @patch.object(GitLabServer, "execute_glab_command") + def test_run_ci_pipeline_no_url_in_output(self, mock_execute: MagicMock) -> None: + """Test pipeline run when output doesn't contain pipeline URL.""" + # Mock successful glab command execution without URL + mock_execute.return_value = ( + True, + "Pipeline created successfully\nCheck status later" + ) + + server = GitLabServer() + working_dir = "/test/directory" + result = server.run_ci_pipeline( + working_directory=working_dir, + branch="main", + ) + + assert result["success"] is True + assert result["branch"] == "main" + assert "pipeline_url" not in result + assert "Pipeline created successfully" in result["output"] + + @patch.object(GitLabServer, "execute_glab_command") + @patch("subprocess.run") + def test_run_ci_pipeline_git_exception( + self, mock_subprocess: MagicMock, mock_execute: MagicMock + ) -> None: + """Test pipeline run when git command raises an exception.""" + # Mock git branch --show-current command exception + mock_subprocess.side_effect = Exception("Git command failed") + + # Mock successful glab command execution (without branch) + mock_execute.return_value = (True, "Pipeline created") + + server = GitLabServer() + working_dir = "/test/directory" + result = server.run_ci_pipeline( + working_directory=working_dir, + ) + + assert result["success"] is True + assert result["branch"] is None + + # Should run without -b flag when git command fails + mock_execute.assert_called_once_with( + ["ci", "run"], + working_dir, + ) |
