From fdfb3abd3c595e4c5c42b4d854b152262c3b4614 Mon Sep 17 00:00:00 2001 From: Dawid Rycerz Date: Sat, 5 Apr 2025 21:30:25 +0200 Subject: feat: add listing gitlab issues to glab mcp server --- servers/gitlab_glab/README.md | 41 +++++ .../src/mcp_server_gitlab_glab/server.py | 170 ++++++++++++++++++++- servers/gitlab_glab/tests/test_integration.py | 7 +- servers/gitlab_glab/tests/test_server.py | 154 ++++++++++++++++++- 4 files changed, 369 insertions(+), 3 deletions(-) diff --git a/servers/gitlab_glab/README.md b/servers/gitlab_glab/README.md index 55ac6bb..50926fb 100644 --- a/servers/gitlab_glab/README.md +++ b/servers/gitlab_glab/README.md @@ -78,6 +78,47 @@ The function returns a list of matching projects, each containing the following - `web_url`: The project web URL - `description`: The project description +### search_issues + +Search for GitLab issues with various filters. + +```python +result = use_mcp_tool( + server_name="gitlab_glab", + tool_name="search_issues", + arguments={ + "working_directory": "/path/to/current/directory", + # Optional filters + "author": "username", # Filter by author + "assignee": "username", # Filter by assignee + "closed": True, # Get only closed issues + "confidential": True, # Filter by confidential issues + "group": "group-name", # Select a group or subgroup + "issue_type": "issue", # Filter by type: issue, incident, test_case + "iteration": 123, # Filter by iteration ID + "label": ["bug", "critical"], # Filter by labels + "milestone": "v1.0", # Filter by milestone + "not_assignee": "username", # Filter by not being assigned to + "not_author": "username", # Filter by not being authored by + "not_label": ["wontfix"], # Filter by lack of labels + "page": 1, # Page number (default: 1) + "per_page": 30, # Items per page (default: 30) + "project": "group/project" # Project path with namespace + } +) +``` + +The function returns a dictionary containing: +- `issues`: A list of issues, each with: + - `id`: The issue ID + - `iid`: The internal issue ID + - `title`: The issue title + - `web_url`: The issue URL + - `state`: The issue state (opened/closed) + - `created_at`: Creation timestamp + - `updated_at`: Last update timestamp +- `error`: Error message if the operation failed + ### create_issue Creates a new GitLab issue and returns its URL. 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 958e0a7..d3fd074 100644 --- a/servers/gitlab_glab/src/mcp_server_gitlab_glab/server.py +++ b/servers/gitlab_glab/src/mcp_server_gitlab_glab/server.py @@ -120,7 +120,11 @@ class GitLabServer: or an error message. """ success, result = self.execute_glab_command( - ["api", f"/projects?search_namespaces=true&search={project_name}"], working_directory + [ + "api", + f"/projects?search_namespaces=true&search={project_name}", + ], + working_directory ) if not success: @@ -144,6 +148,109 @@ class GitLabServer: else: return {"error": f"Project '{project_name}' not found"} + def search_issues( + self, + working_directory: str, + author: str | None = None, + assignee: str | None = None, + closed: bool = False, + confidential: bool = False, + group: str | None = None, + issue_type: str | None = None, + iteration: int | None = None, + label: list[str] | None = None, + milestone: str | None = None, + not_assignee: str | None = None, + not_author: str | None = None, + not_label: list[str] | None = None, + page: int = 1, + per_page: int = 30, + project: str | None = None, + ) -> dict[str, Any]: + """Search for GitLab issues with various filters. + + Args: + working_directory: The directory to execute the command in. + author: Filter issue by author username. + assignee: Filter issue by assignee username. + closed: Get only closed issues. + confidential: Filter by confidential issues. + group: Select a group or subgroup. + issue_type: Filter issue by its type (issue, incident, test_case). + iteration: Filter issue by iteration ID. + label: Filter issue by label names. + milestone: Filter issue by milestone ID or title. + not_assignee: Filter issue by not being assigned to username. + not_author: Filter issue by not being by author username. + not_label: Filter issue by lack of label names. + page: Page number. + per_page: Number of items to list per page. + project: Select another repository. + + Returns: + A dictionary containing a list of issues with their IDs, titles, and links. + """ + # Build command arguments + args = ["issue", "list", "-O", "json"] + + # Add filter arguments + if author: + args.extend(["--author", author]) + if assignee: + args.extend(["-a", assignee]) + if closed: + args.append("-c") + if confidential: + args.append("-C") + if group: + args.extend(["-g", group]) + if issue_type: + args.extend(["-t", issue_type]) + if iteration: + args.extend(["-i", str(iteration)]) + if label: + for lbl in label: + args.extend(["-l", lbl]) + if milestone: + args.extend(["-m", milestone]) + if not_assignee: + args.extend(["--not-assignee", not_assignee]) + if not_author: + args.extend(["--not-author", not_author]) + if not_label: + for lbl in not_label: + args.extend(["--not-label", lbl]) + if page != 1: + args.extend(["-p", str(page)]) + if per_page != 30: + args.extend(["-P", str(per_page)]) + if project: + args.extend(["-R", project]) + + # Execute the command + success, result = self.execute_glab_command(args, working_directory) + + if not success: + return result + + # If the result is already a list (JSON parsed), process it + if isinstance(result, list): + issues = [] + for issue in result: + issues.append({ + "id": issue.get("id"), + "iid": issue.get("iid"), + "title": issue.get("title"), + "web_url": issue.get("web_url"), + "state": issue.get("state"), + "created_at": issue.get("created_at"), + "updated_at": issue.get("updated_at"), + }) + return {"issues": issues} + else: + # This shouldn't happen with -O json, but handle it just in case + return {"error": "Failed to parse issues list"} + def create_issue( self, title: str, @@ -250,6 +357,67 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: return gitlab.find_project(project_name, working_directory) # Add create_issue tool + @mcp.tool() + def search_issues( + working_directory: str, + author: str | None = None, + assignee: str | None = None, + closed: bool = False, + confidential: bool = False, + group: str | None = None, + issue_type: str | None = None, + iteration: int | None = None, + label: list[str] | None = None, + milestone: str | None = None, + not_assignee: str | None = None, + not_author: str | None = None, + not_label: list[str] | None = None, + page: int = 1, + per_page: int = 30, + project: str | None = None, + ) -> dict[str, Any]: + """Search for GitLab issues with various filters. + + Args: + working_directory: The directory to execute the command in. + author: Filter issue by author username. + assignee: Filter issue by assignee username. + closed: Get only closed issues. + confidential: Filter by confidential issues. + group: Select a group or subgroup. + issue_type: Filter issue by its type (issue, incident, test_case). + iteration: Filter issue by iteration ID. + label: Filter issue by label names. + milestone: Filter issue by milestone ID or title. + not_assignee: Filter issue by not being assigned to username. + not_author: Filter issue by not being by author username. + not_label: Filter issue by lack of label names. + page: Page number. + per_page: Number of items to list per page. + project: Select another repository. + + Returns: + A dictionary containing a list of issues with their IDs, titles, and links. + """ + return gitlab.search_issues( + working_directory=working_directory, + author=author, + assignee=assignee, + closed=closed, + confidential=confidential, + group=group, + issue_type=issue_type, + iteration=iteration, + label=label, + milestone=milestone, + not_assignee=not_assignee, + not_author=not_author, + not_label=not_label, + page=page, + per_page=per_page, + project=project, + ) + @mcp.tool() def create_issue( title: str, diff --git a/servers/gitlab_glab/tests/test_integration.py b/servers/gitlab_glab/tests/test_integration.py index 6915348..5d7eed5 100644 --- a/servers/gitlab_glab/tests/test_integration.py +++ b/servers/gitlab_glab/tests/test_integration.py @@ -23,7 +23,12 @@ class TestIntegration: mock_fastmcp.assert_called_once_with("GitLab CLI", host="127.0.0.1", port=8080) # Verify tools were registered - assert mock_server.tool.call_count == 3 # check_availability, find_project, create_issue + # Verify all tools are registered: + # - check_availability + # - find_project + # - search_issues + # - create_issue + assert mock_server.tool.call_count == 4 # 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 0c7ce64..ab93026 100644 --- a/servers/gitlab_glab/tests/test_server.py +++ b/servers/gitlab_glab/tests/test_server.py @@ -278,7 +278,11 @@ class TestGitLabServer: assert "error" in result assert "not found" in result["error"] mock_execute.assert_called_once_with( - ["api", "/projects?search_namespaces=true&search=nonexistent-project"], working_dir + [ + "api", + "/projects?search_namespaces=true&search=nonexistent-project", + ], + working_dir ) @patch.object(GitLabServer, "execute_glab_command") @@ -297,6 +301,154 @@ class TestGitLabServer: ["api", "/projects?search_namespaces=true&search=test-project"], working_dir ) + @patch.object(GitLabServer, "execute_glab_command") + def test_search_issues_success(self, mock_execute: MagicMock) -> None: + """Test successful issue search with default parameters.""" + # Mock successful command execution with JSON output + mock_execute.return_value = ( + True, + [ + { + "id": 1, + "iid": 101, + "title": "Test Issue 1", + "web_url": "https://gitlab.com/group/project/issues/101", + "state": "opened", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-02T00:00:00Z", + }, + { + "id": 2, + "iid": 102, + "title": "Test Issue 2", + "web_url": "https://gitlab.com/group/project/issues/102", + "state": "closed", + "created_at": "2025-01-03T00:00:00Z", + "updated_at": "2025-01-04T00:00:00Z", + }, + ], + ) + + server = GitLabServer() + working_dir = "/test/directory" + result = server.search_issues(working_directory=working_dir) + + assert "issues" in result + assert len(result["issues"]) == 2 + assert result["issues"][0]["id"] == 1 + assert result["issues"][0]["title"] == "Test Issue 1" + assert result["issues"][1]["id"] == 2 + assert result["issues"][1]["title"] == "Test Issue 2" + + # Verify command was called with correct arguments + mock_execute.assert_called_once_with( + ["issue", "list", "-O", "json"], + working_dir, + ) + + @patch.object(GitLabServer, "execute_glab_command") + def test_search_issues_with_filters(self, mock_execute: MagicMock) -> None: + """Test issue search with various filters.""" + # Mock successful command execution with JSON output + mock_execute.return_value = ( + True, + [ + { + "id": 1, + "iid": 101, + "title": "Test Issue 1", + "web_url": "https://gitlab.com/group/project/issues/101", + "state": "opened", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-02T00:00:00Z", + }, + ], + ) + + server = GitLabServer() + working_dir = "/test/directory" + result = server.search_issues( + working_directory=working_dir, + author="user1", + assignee="user2", + closed=True, + confidential=True, + group="test-group", + issue_type="incident", + iteration=123, + label=["bug", "critical"], + milestone="v1.0", + not_assignee="user3", + not_author="user4", + not_label=["wontfix"], + page=2, + per_page=10, + project="group/project", + ) + + assert "issues" in result + assert len(result["issues"]) == 1 + assert result["issues"][0]["id"] == 1 + assert result["issues"][0]["title"] == "Test Issue 1" + + # Verify command was called with correct arguments + mock_execute.assert_called_once_with( + [ + "issue", "list", "-O", "json", + "--author", "user1", + "-a", "user2", + "-c", + "-C", + "-g", "test-group", + "-t", "incident", + "-i", "123", + "-l", "bug", + "-l", "critical", + "-m", "v1.0", + "--not-assignee", "user3", + "--not-author", "user4", + "--not-label", "wontfix", + "-p", "2", + "-P", "10", + "-R", "group/project", + ], + working_dir, + ) + + @patch.object(GitLabServer, "execute_glab_command") + def test_search_issues_failure(self, mock_execute: MagicMock) -> None: + """Test failed issue search.""" + # Mock failed command execution + mock_execute.return_value = (False, {"error": "Failed to list issues"}) + + server = GitLabServer() + working_dir = "/test/directory" + result = server.search_issues(working_directory=working_dir) + + assert "error" in result + assert result["error"] == "Failed to list issues" + mock_execute.assert_called_once_with( + ["issue", "list", "-O", "json"], + working_dir, + ) + + @patch.object(GitLabServer, "execute_glab_command") + def test_search_issues_invalid_json(self, mock_execute: MagicMock) -> None: + """Test issue search with invalid JSON response.""" + # Mock successful command execution but with invalid JSON + mock_execute.return_value = (True, "invalid json") + + server = GitLabServer() + working_dir = "/test/directory" + result = server.search_issues(working_directory=working_dir) + + assert "error" in result + assert result["error"] == "Failed to parse issues list" + mock_execute.assert_called_once_with( + ["issue", "list", "-O", "json"], + working_dir, + ) + @patch.object(GitLabServer, "execute_glab_command") def test_create_issue_success(self, mock_execute: MagicMock) -> None: """Test successful issue creation with required parameters.""" -- cgit v1.2.3