summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDawid Rycerz <dawid@rycerz.xyz>2025-04-05 21:30:25 +0200
committerDawid Rycerz <dawid@rycerz.xyz>2025-04-05 21:30:25 +0200
commitfdfb3abd3c595e4c5c42b4d854b152262c3b4614 (patch)
tree209a4f23eb83cc3c6197f7097dd068c2ad024783
parentfa91e3fcbdd3b1d70f01de256e1c48fe7726ebd4 (diff)
feat: add listing gitlab issues to glab mcp server
-rw-r--r--servers/gitlab_glab/README.md41
-rw-r--r--servers/gitlab_glab/src/mcp_server_gitlab_glab/server.py170
-rw-r--r--servers/gitlab_glab/tests/test_integration.py7
-rw-r--r--servers/gitlab_glab/tests/test_server.py154
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,
@@ -251,6 +358,67 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP:
# 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,
description: 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")
@@ -298,6 +302,154 @@ class TestGitLabServer:
)
@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."""
# Mock successful command execution with actual glab output format