package gitlab import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" "github.com/xlgmokha/mcp/pkg/mcp" ) // GitLabOperations provides GitLab API operations with caching type GitLabOperations struct { mu sync.RWMutex gitlabURL string token string client *http.Client cache *Cache } type GitLabProject struct { ID int `json:"id"` Name string `json:"name"` NameWithNamespace string `json:"name_with_namespace"` Path string `json:"path"` PathWithNamespace string `json:"path_with_namespace"` WebURL string `json:"web_url"` Description string `json:"description"` Visibility string `json:"visibility"` LastActivityAt time.Time `json:"last_activity_at"` CreatedAt time.Time `json:"created_at"` Namespace struct { Name string `json:"name"` Path string `json:"path"` } `json:"namespace"` OpenIssuesCount int `json:"open_issues_count"` ForksCount int `json:"forks_count"` StarCount int `json:"star_count"` } type GitLabIssue struct { ID int `json:"id"` IID int `json:"iid"` ProjectID int `json:"project_id"` Title string `json:"title"` Description string `json:"description"` State string `json:"state"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` WebURL string `json:"web_url"` Author struct { ID int `json:"id"` Username string `json:"username"` Name string `json:"name"` } `json:"author"` Assignees []struct { ID int `json:"id"` Username string `json:"username"` Name string `json:"name"` } `json:"assignees"` Labels []string `json:"labels"` UserNotesCount int `json:"user_notes_count"` DueDate *string `json:"due_date"` TimeStats struct { TimeEstimate int `json:"time_estimate"` TotalTimeSpent int `json:"total_time_spent"` } `json:"time_stats"` } type GitLabNote struct { ID int `json:"id"` Body string `json:"body"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Author struct { ID int `json:"id"` Username string `json:"username"` Name string `json:"name"` } `json:"author"` System bool `json:"system"` NoteableType string `json:"noteable_type"` NoteableID int `json:"noteable_id"` } type GitLabUser struct { ID int `json:"id"` Username string `json:"username"` Name string `json:"name"` Email string `json:"email"` State string `json:"state"` } // NewGitLabOperations creates a new GitLabOperations helper func NewGitLabOperations(gitlabURL, token string) (*GitLabOperations, error) { // Initialize cache with default configuration cache, err := NewCache(CacheConfig{ TTL: 5 * time.Minute, MaxEntries: 1000, EnableOffline: true, CompressData: false, }) if err != nil { return nil, fmt.Errorf("failed to initialize cache: %w", err) } return &GitLabOperations{ gitlabURL: strings.TrimSuffix(gitlabURL, "/"), token: token, client: &http.Client{ Timeout: 30 * time.Second, }, cache: cache, }, nil } // New creates a new GitLab MCP server func New(gitlabURL, token string) (*mcp.Server, error) { gitlab, err := NewGitLabOperations(gitlabURL, token) if err != nil { return nil, err } builder := mcp.NewServerBuilder("gitlab-server", "1.0.0") // Add gitlab_list_my_projects tool builder.AddTool(mcp.NewTool("gitlab_list_my_projects", "List projects where you are a member, with activity and access level info", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "limit": map[string]interface{}{ "type": "integer", "description": "Maximum number of projects to return", "minimum": 1, "default": 20, }, "archived": map[string]interface{}{ "type": "boolean", "description": "Include archived projects", "default": false, }, }, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { return gitlab.handleListMyProjects(req) })) // Add gitlab_list_my_issues tool builder.AddTool(mcp.NewTool("gitlab_list_my_issues", "List issues assigned to you, created by you, or where you're mentioned", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "scope": map[string]interface{}{ "type": "string", "description": "Filter scope: assigned_to_me, authored, mentioned, all", "default": "assigned_to_me", "enum": []string{"assigned_to_me", "authored", "mentioned", "all"}, }, "state": map[string]interface{}{ "type": "string", "description": "Issue state filter: opened, closed, all", "default": "opened", "enum": []string{"opened", "closed", "all"}, }, "limit": map[string]interface{}{ "type": "integer", "description": "Maximum number of issues to return", "minimum": 1, "default": 50, }, }, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { return gitlab.handleListMyIssues(req) })) // Add gitlab_get_issue_conversations tool builder.AddTool(mcp.NewTool("gitlab_get_issue_conversations", "Get full conversation history for a specific issue including notes and system events", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "project_id": map[string]interface{}{ "type": "integer", "description": "GitLab project ID", "minimum": 1, }, "issue_iid": map[string]interface{}{ "type": "integer", "description": "Issue internal ID within the project", "minimum": 1, }, }, "required": []string{"project_id", "issue_iid"}, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { return gitlab.handleGetIssueConversations(req) })) // Add gitlab_find_similar_issues tool builder.AddTool(mcp.NewTool("gitlab_find_similar_issues", "Find issues similar to a search query across your accessible projects", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "query": map[string]interface{}{ "type": "string", "description": "Search query for finding similar issues", }, "limit": map[string]interface{}{ "type": "integer", "description": "Maximum number of results", "minimum": 1, "default": 20, }, }, "required": []string{"query"}, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { return gitlab.handleFindSimilarIssues(req) })) // Add gitlab_get_my_activity tool builder.AddTool(mcp.NewTool("gitlab_get_my_activity", "Get recent activity summary including commits, issues, merge requests", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "limit": map[string]interface{}{ "type": "integer", "description": "Maximum number of activity events", "minimum": 1, "default": 50, }, }, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { return gitlab.handleGetMyActivity(req) })) // Add gitlab_cache_stats tool builder.AddTool(mcp.NewTool("gitlab_cache_stats", "Get cache performance statistics and storage information", map[string]interface{}{ "type": "object", }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { return gitlab.handleCacheStats(req) })) // Add gitlab_cache_clear tool builder.AddTool(mcp.NewTool("gitlab_cache_clear", "Clear cached data for specific types or all cached data", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "cache_type": map[string]interface{}{ "type": "string", "description": "Type of cache to clear: issues, projects, users, notes, events, search, or empty for all", "enum": []string{"issues", "projects", "users", "notes", "events", "search"}, }, "confirm": map[string]interface{}{ "type": "string", "description": "Confirmation string 'true' to proceed with cache clearing", }, }, "required": []string{"confirm"}, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { return gitlab.handleCacheClear(req) })) // Add gitlab_offline_query tool builder.AddTool(mcp.NewTool("gitlab_offline_query", "Query cached GitLab data when network connectivity is unavailable", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "query_type": map[string]interface{}{ "type": "string", "description": "Type of cached data to query: issues, projects, users, notes, events", "enum": []string{"issues", "projects", "users", "notes", "events"}, }, "search": map[string]interface{}{ "type": "string", "description": "Optional search term to filter cached results", }, "limit": map[string]interface{}{ "type": "integer", "description": "Maximum number of results to return", "minimum": 1, "default": 50, }, }, "required": []string{"query_type"}, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { return gitlab.handleOfflineQuery(req) })) return builder.Build(), nil } func (gitlab *GitLabOperations) makeRequest(method, endpoint string, params map[string]string) ([]byte, error) { return gitlab.makeRequestWithCache(method, endpoint, params, "") } func (gitlab *GitLabOperations) makeRequestWithCache(method, endpoint string, params map[string]string, cacheType string) ([]byte, error) { gitlab.mu.RLock() defer gitlab.mu.RUnlock() // Determine cache type from endpoint if not provided if cacheType == "" { cacheType = gitlab.determineCacheType(endpoint) } // Check cache first (only for GET requests) if method == "GET" && cacheType != "" { if cached, found := gitlab.cache.Get(cacheType, endpoint, params); found { return cached, nil } } url := fmt.Sprintf("%s/api/v4%s", gitlab.gitlabURL, endpoint) req, err := http.NewRequest(method, url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+gitlab.token) req.Header.Set("Content-Type", "application/json") // Add query parameters if len(params) > 0 { q := req.URL.Query() for key, value := range params { q.Add(key, value) } req.URL.RawQuery = q.Encode() } resp, err := gitlab.client.Do(req) if err != nil { // If request fails and we have cached data, try to return stale data if method == "GET" && cacheType != "" && gitlab.cache.config.EnableOffline { if cached, found := gitlab.cache.Get(cacheType, endpoint, params); found { fmt.Fprintf(os.Stderr, "Network error, returning cached data: %v\n", err) return cached, nil } } return nil, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return nil, fmt.Errorf("GitLab API error: %s", resp.Status) } // Read response body body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } // Cache the response (only for GET requests) if method == "GET" && cacheType != "" { if err := gitlab.cache.Set(cacheType, endpoint, params, body, resp.StatusCode); err != nil { // Log cache error but don't fail the request fmt.Fprintf(os.Stderr, "Failed to cache response: %v\n", err) } } return body, nil } // determineCacheType maps API endpoints to cache types func (gitlab *GitLabOperations) determineCacheType(endpoint string) string { switch { case strings.Contains(endpoint, "/issues"): return "issues" case strings.Contains(endpoint, "/projects"): return "projects" case strings.Contains(endpoint, "/users"): return "users" case strings.Contains(endpoint, "/notes"): return "notes" case strings.Contains(endpoint, "/events"): return "events" case strings.Contains(endpoint, "/search"): return "search" default: return "misc" } } func (gitlab *GitLabOperations) getCurrentUser() (*GitLabUser, error) { body, err := gitlab.makeRequest("GET", "/user", nil) if err != nil { return nil, err } var user GitLabUser if err := json.Unmarshal(body, &user); err != nil { return nil, fmt.Errorf("failed to parse user response: %w", err) } return &user, nil } func (gitlab *GitLabOperations) handleListMyProjects(req mcp.CallToolRequest) (mcp.CallToolResult, error) { args := req.Arguments // Parse optional parameters limitStr, _ := args["limit"].(string) limit := 20 // Default limit if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { limit = l } } searchTerm, _ := args["search"].(string) membershipOnly, _ := args["membership"].(bool) includeArchived, _ := args["archived"].(bool) // Build API parameters params := map[string]string{ "per_page": strconv.Itoa(limit), "simple": "true", "order_by": "last_activity_at", "sort": "desc", } if searchTerm != "" { params["search"] = searchTerm } if membershipOnly { params["membership"] = "true" } if !includeArchived { params["archived"] = "false" } // Make API request body, err := gitlab.makeRequest("GET", "/projects", params) if err != nil { return mcp.NewToolError(fmt.Sprintf("Failed to fetch projects: %v", err)), nil } var projects []GitLabProject if err := json.Unmarshal(body, &projects); err != nil { return mcp.NewToolError(fmt.Sprintf("Failed to parse projects response: %v", err)), nil } // Format response for user result := fmt.Sprintf("Your GitLab Projects (%d found):\n\n", len(projects)) if len(projects) == 0 { result += "No projects found matching your criteria.\n" } else { for i, project := range projects { lastActivity := project.LastActivityAt.Format("2006-01-02 15:04") result += fmt.Sprintf("**%d. %s**\n", i+1, project.NameWithNamespace) result += fmt.Sprintf(" šŸ”— %s\n", project.WebURL) result += fmt.Sprintf(" šŸ“Š %d open issues | ⭐ %d stars | šŸ“ %d forks\n", project.OpenIssuesCount, project.StarCount, project.ForksCount) result += fmt.Sprintf(" šŸ“… Last activity: %s\n", lastActivity) if project.Description != "" { // Truncate long descriptions desc := project.Description if len(desc) > 100 { desc = desc[:97] + "..." } result += fmt.Sprintf(" šŸ“ %s\n", desc) } result += "\n" } } return mcp.NewToolResult(mcp.NewTextContent(result)), nil } func (gitlab *GitLabOperations) handleListMyIssues(req mcp.CallToolRequest) (mcp.CallToolResult, error) { args := req.Arguments // Parse optional parameters limitStr, _ := args["limit"].(string) limit := 20 // Default limit if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { limit = l } } scope, _ := args["scope"].(string) if scope == "" { scope = "assigned_to_me" // Default: issues assigned to user } state, _ := args["state"].(string) if state == "" { state = "opened" // Default: only open issues } searchTerm, _ := args["search"].(string) // Get current user for filtering user, err := gitlab.getCurrentUser() if err != nil { return mcp.NewToolError(fmt.Sprintf("Failed to get current user: %v", err)), nil } // Build API parameters based on scope params := map[string]string{ "per_page": strconv.Itoa(limit), "state": state, "order_by": "updated_at", "sort": "desc", } switch scope { case "assigned_to_me": params["assignee_username"] = user.Username case "authored_by_me": params["author_username"] = user.Username case "all_involving_me": // This will require multiple API calls or use a different endpoint params["scope"] = "all" default: params["assignee_username"] = user.Username } if searchTerm != "" { params["search"] = searchTerm } // Make API request body, err := gitlab.makeRequest("GET", "/issues", params) if err != nil { return mcp.NewToolError(fmt.Sprintf("Failed to fetch issues: %v", err)), nil } var issues []GitLabIssue if err := json.Unmarshal(body, &issues); err != nil { return mcp.NewToolError(fmt.Sprintf("Failed to parse issues response: %v", err)), nil } // Format response for user result := fmt.Sprintf("Your GitLab Issues (%s, %d found):\n\n", scope, len(issues)) if len(issues) == 0 { result += "No issues found matching your criteria.\n" } else { for i, issue := range issues { updatedAt := issue.UpdatedAt.Format("2006-01-02 15:04") // Get project name from web URL (quick extraction) projectName := extractProjectFromURL(issue.WebURL) result += fmt.Sprintf("**%d. %s** šŸŽÆ\n", i+1, issue.Title) result += fmt.Sprintf(" šŸ“ %s #%d\n", projectName, issue.IID) result += fmt.Sprintf(" šŸ”— %s\n", issue.WebURL) result += fmt.Sprintf(" šŸ‘¤ Author: %s", issue.Author.Name) // Show assignees if len(issue.Assignees) > 0 { assigneeNames := make([]string, len(issue.Assignees)) for j, assignee := range issue.Assignees { assigneeNames[j] = assignee.Name } result += fmt.Sprintf(" | šŸ‘„ Assigned: %s", strings.Join(assigneeNames, ", ")) } result += "\n" // Show labels if any if len(issue.Labels) > 0 { result += fmt.Sprintf(" šŸ·ļø %s\n", strings.Join(issue.Labels, ", ")) } result += fmt.Sprintf(" šŸ’¬ %d comments | šŸ“… Updated: %s\n", issue.UserNotesCount, updatedAt) // Show due date if set if issue.DueDate != nil && *issue.DueDate != "" { result += fmt.Sprintf(" ā° Due: %s\n", *issue.DueDate) } result += "\n" } } return mcp.NewToolResult(mcp.NewTextContent(result)), nil } // Helper function to extract project name from GitLab issue URL func extractProjectFromURL(webURL string) string { // URL format: https://gitlab.com/namespace/project/-/issues/123 parts := strings.Split(webURL, "/") if len(parts) >= 5 { return fmt.Sprintf("%s/%s", parts[len(parts)-4], parts[len(parts)-3]) } return "Unknown" } func (gitlab *GitLabOperations) handleGetIssueConversations(req mcp.CallToolRequest) (mcp.CallToolResult, error) { args := req.Arguments projectIDStr, ok := args["project_id"].(string) if !ok { return mcp.NewToolError("project_id parameter is required"), nil } issueIIDStr, ok := args["issue_iid"].(string) if !ok { return mcp.NewToolError("issue_iid parameter is required"), nil } projectID, err := strconv.Atoi(projectIDStr) if err != nil { return mcp.NewToolError("project_id must be a valid integer"), nil } issueIID, err := strconv.Atoi(issueIIDStr) if err != nil { return mcp.NewToolError("issue_iid must be a valid integer"), nil } includeSystemNotes, _ := args["include_system_notes"].(bool) // First, get the issue details issueEndpoint := fmt.Sprintf("/projects/%d/issues/%d", projectID, issueIID) issueBody, err := gitlab.makeRequest("GET", issueEndpoint, nil) if err != nil { return mcp.NewToolError(fmt.Sprintf("Failed to fetch issue: %v", err)), nil } var issue GitLabIssue if err := json.Unmarshal(issueBody, &issue); err != nil { return mcp.NewToolError(fmt.Sprintf("Failed to parse issue response: %v", err)), nil } // Get issue notes (comments) notesEndpoint := fmt.Sprintf("/projects/%d/issues/%d/notes", projectID, issueIID) notesParams := map[string]string{ "order_by": "created_at", "sort": "asc", } notesBody, err := gitlab.makeRequest("GET", notesEndpoint, notesParams) if err != nil { return mcp.NewToolError(fmt.Sprintf("Failed to fetch issue notes: %v", err)), nil } var notes []GitLabNote if err := json.Unmarshal(notesBody, ¬es); err != nil { return mcp.NewToolError(fmt.Sprintf("Failed to parse notes response: %v", err)), nil } // Filter notes based on preferences var filteredNotes []GitLabNote for _, note := range notes { if !includeSystemNotes && note.System { continue } filteredNotes = append(filteredNotes, note) } // Format the conversation projectName := extractProjectFromURL(issue.WebURL) result := fmt.Sprintf("**Issue Conversation: %s**\n", issue.Title) result += fmt.Sprintf("šŸ“ %s #%d | šŸ”— %s\n\n", projectName, issue.IID, issue.WebURL) // Issue description result += fmt.Sprintf("**Original Issue** - %s (%s)\n", issue.Author.Name, issue.CreatedAt.Format("2006-01-02 15:04")) result += "─────────────────────────────────────────────────\n" if issue.Description != "" { result += fmt.Sprintf("%s\n", issue.Description) } else { result += "_No description provided._\n" } result += "\n" // Issue metadata result += "**Issue Details:**\n" result += fmt.Sprintf("• **State:** %s\n", issue.State) if len(issue.Assignees) > 0 { assigneeNames := make([]string, len(issue.Assignees)) for i, assignee := range issue.Assignees { assigneeNames[i] = assignee.Name } result += fmt.Sprintf("• **Assignees:** %s\n", strings.Join(assigneeNames, ", ")) } if len(issue.Labels) > 0 { result += fmt.Sprintf("• **Labels:** %s\n", strings.Join(issue.Labels, ", ")) } if issue.DueDate != nil && *issue.DueDate != "" { result += fmt.Sprintf("• **Due Date:** %s\n", *issue.DueDate) } result += fmt.Sprintf("• **Total Comments:** %d\n", len(filteredNotes)) result += "\n" // Conversation thread if len(filteredNotes) == 0 { result += "**No comments yet.**\n" } else { result += fmt.Sprintf("**Conversation Thread (%d comments):**\n\n", len(filteredNotes)) for i, note := range filteredNotes { commentType := "šŸ’¬" if note.System { commentType = "šŸ”§" } result += fmt.Sprintf("%s **Comment %d** - %s (%s)\n", commentType, i+1, note.Author.Name, note.CreatedAt.Format("2006-01-02 15:04")) result += "─────────────────────────────────────────────────\n" result += fmt.Sprintf("%s\n\n", note.Body) } } return mcp.NewToolResult(mcp.NewTextContent(result)), nil } func (gitlab *GitLabOperations) handleFindSimilarIssues(req mcp.CallToolRequest) (mcp.CallToolResult, error) { args := req.Arguments searchQuery, ok := args["query"].(string) if !ok || searchQuery == "" { return mcp.NewToolError("query parameter is required"), nil } limitStr, _ := args["limit"].(string) limit := 10 // Default limit for similarity search if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 { limit = l } } scope, _ := args["scope"].(string) if scope == "" { scope = "issues" // Default to issues } includeClosedStr, _ := args["include_closed"].(string) includeClosed := false if includeClosedStr == "true" { includeClosed = true } // Build search parameters params := map[string]string{ "search": searchQuery, "scope": scope, "per_page": strconv.Itoa(limit), } // Make search API request body, err := gitlab.makeRequest("GET", "/search", params) if err != nil { return mcp.NewToolError(fmt.Sprintf("Failed to search issues: %v", err)), nil } var searchResults []GitLabIssue if err := json.Unmarshal(body, &searchResults); err != nil { return mcp.NewToolError(fmt.Sprintf("Failed to parse search results: %v", err)), nil } // Filter results based on state preferences var filteredResults []GitLabIssue for _, issue := range searchResults { if !includeClosed && issue.State != "opened" { continue } filteredResults = append(filteredResults, issue) } // Group results by project for better organization projectGroups := make(map[string][]GitLabIssue) for _, issue := range filteredResults { projectName := extractProjectFromURL(issue.WebURL) projectGroups[projectName] = append(projectGroups[projectName], issue) } // Format response result := fmt.Sprintf("**Similar Issues Found for: \"%s\"**\n", searchQuery) result += fmt.Sprintf("Found %d issues across %d projects:\n\n", len(filteredResults), len(projectGroups)) if len(filteredResults) == 0 { result += "No similar issues found. Try:\n" result += "• Different keywords or search terms\n" result += "• Including closed issues with include_closed=true\n" result += "• Broader search scope\n" } else { // Display results grouped by project projectCount := 0 for projectName, issues := range projectGroups { projectCount++ result += fmt.Sprintf("**šŸ—‚ļø Project %d: %s** (%d issues)\n", projectCount, projectName, len(issues)) result += "─────────────────────────────────────────────────\n" for i, issue := range issues { stateIcon := "🟢" if issue.State == "closed" { stateIcon = "šŸ”“" } result += fmt.Sprintf("%d. %s **%s** %s\n", i+1, stateIcon, issue.Title, issue.State) result += fmt.Sprintf(" šŸ“‹ #%d | šŸ‘¤ %s", issue.IID, issue.Author.Name) if len(issue.Assignees) > 0 { result += fmt.Sprintf(" | šŸ‘„ %s", issue.Assignees[0].Name) } result += "\n" if len(issue.Labels) > 0 { result += fmt.Sprintf(" šŸ·ļø %s\n", strings.Join(issue.Labels, ", ")) } result += fmt.Sprintf(" šŸ”— %s\n", issue.WebURL) result += fmt.Sprintf(" šŸ“… Updated: %s\n", issue.UpdatedAt.Format("2006-01-02 15:04")) // Show snippet of description if available if issue.Description != "" { desc := issue.Description if len(desc) > 150 { desc = desc[:147] + "..." } result += fmt.Sprintf(" šŸ“ %s\n", desc) } result += "\n" } result += "\n" } // Add similarity analysis tips result += "**šŸ’” Similarity Analysis Tips:**\n" result += "• Look for common labels and patterns\n" result += "• Check if issues are linked or reference each other\n" result += "• Consider if these could be duplicate issues\n" result += "• Review assignees for domain expertise\n" } return mcp.NewToolResult(mcp.NewTextContent(result)), nil } func (gitlab *GitLabOperations) handleGetMyActivity(req mcp.CallToolRequest) (mcp.CallToolResult, error) { args := req.Arguments // Parse optional parameters limitStr, _ := args["limit"].(string) limit := 20 // Default limit if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { limit = l } } days, _ := args["days"].(string) daysInt := 7 // Default: last 7 days if days != "" { if d, err := strconv.Atoi(days); err == nil && d > 0 && d <= 30 { daysInt = d } } // Get current user user, err := gitlab.getCurrentUser() if err != nil { return mcp.NewToolError(fmt.Sprintf("Failed to get current user: %v", err)), nil } // Calculate date range since := time.Now().AddDate(0, 0, -daysInt).Format("2006-01-02T15:04:05Z") // Get recent issues assigned to user assignedParams := map[string]string{ "assignee_username": user.Username, "state": "opened", "order_by": "updated_at", "sort": "desc", "per_page": strconv.Itoa(limit / 2), "updated_after": since, } assignedBody, err := gitlab.makeRequest("GET", "/issues", assignedParams) if err != nil { return mcp.NewToolError(fmt.Sprintf("Failed to fetch assigned issues: %v", err)), nil } var assignedIssues []GitLabIssue json.Unmarshal(assignedBody, &assignedIssues) // Get recent issues authored by user authoredParams := map[string]string{ "author_username": user.Username, "state": "opened", "order_by": "updated_at", "sort": "desc", "per_page": strconv.Itoa(limit / 2), "updated_after": since, } authoredBody, err := gitlab.makeRequest("GET", "/issues", authoredParams) if err != nil { return mcp.NewToolError(fmt.Sprintf("Failed to fetch authored issues: %v", err)), nil } var authoredIssues []GitLabIssue json.Unmarshal(authoredBody, &authoredIssues) // Get user's recent activity events activityParams := map[string]string{ "per_page": "20", } activityBody, err := gitlab.makeRequest("GET", fmt.Sprintf("/users/%d/events", user.ID), activityParams) if err != nil { // Activity endpoint might not be available, continue without it activityBody = []byte("[]") } // Parse activity (basic structure) var activities []map[string]interface{} json.Unmarshal(activityBody, &activities) // Format comprehensive activity summary result := fmt.Sprintf("**GitLab Activity Summary for %s**\n", user.Name) result += fmt.Sprintf("šŸ“… Last %d days | šŸ‘¤ @%s\n\n", daysInt, user.Username) // Summary statistics result += "**šŸ“Š Quick Summary:**\n" result += fmt.Sprintf("• Assigned Issues: %d open\n", len(assignedIssues)) result += fmt.Sprintf("• Authored Issues: %d open\n", len(authoredIssues)) result += fmt.Sprintf("• Recent Activity Events: %d\n", len(activities)) result += "\n" // Priority items that need attention result += "**šŸŽÆ Items Needing Attention:**\n" priorityCount := 0 // Check for overdue items for _, issue := range assignedIssues { if issue.DueDate != nil && *issue.DueDate != "" { dueDate, err := time.Parse("2006-01-02", *issue.DueDate) if err == nil && dueDate.Before(time.Now()) { if priorityCount == 0 { result += "āš ļø **Overdue Issues:**\n" } projectName := extractProjectFromURL(issue.WebURL) result += fmt.Sprintf(" • %s #%d - %s (Due: %s)\n", projectName, issue.IID, issue.Title, *issue.DueDate) priorityCount++ } } } // Check for issues with recent activity (comments) recentActivityCount := 0 for _, issue := range assignedIssues { if issue.UpdatedAt.After(time.Now().AddDate(0, 0, -2)) && issue.UserNotesCount > 0 { if recentActivityCount == 0 && priorityCount > 0 { result += "\nšŸ’¬ **Issues with Recent Comments:**\n" } else if recentActivityCount == 0 { result += "šŸ’¬ **Issues with Recent Comments:**\n" } projectName := extractProjectFromURL(issue.WebURL) result += fmt.Sprintf(" • %s #%d - %s (%d comments)\n", projectName, issue.IID, issue.Title, issue.UserNotesCount) recentActivityCount++ if recentActivityCount >= 5 { break } } } if priorityCount == 0 && recentActivityCount == 0 { result += "āœ… No urgent items requiring immediate attention\n" } result += "\n" // Assigned issues section if len(assignedIssues) > 0 { result += fmt.Sprintf("**šŸ“‹ Assigned Issues (%d):**\n", len(assignedIssues)) for i, issue := range assignedIssues { if i >= 10 { break // Limit display } projectName := extractProjectFromURL(issue.WebURL) updatedAt := issue.UpdatedAt.Format("Jan 2") result += fmt.Sprintf("%d. **%s** - %s #%d\n", i+1, issue.Title, projectName, issue.IID) result += fmt.Sprintf(" šŸ“… %s | šŸ’¬ %d comments", updatedAt, issue.UserNotesCount) if len(issue.Labels) > 0 { result += fmt.Sprintf(" | šŸ·ļø %s", strings.Join(issue.Labels[:min(3, len(issue.Labels))], ", ")) } result += "\n\n" } } // Authored issues section if len(authoredIssues) > 0 { result += fmt.Sprintf("**āœļø Issues You Created (%d active):**\n", len(authoredIssues)) for i, issue := range authoredIssues { if i >= 5 { break // Limit display } projectName := extractProjectFromURL(issue.WebURL) updatedAt := issue.UpdatedAt.Format("Jan 2") result += fmt.Sprintf("%d. **%s** - %s #%d\n", i+1, issue.Title, projectName, issue.IID) result += fmt.Sprintf(" šŸ“… %s | šŸ’¬ %d comments", updatedAt, issue.UserNotesCount) if len(issue.Assignees) > 0 { result += fmt.Sprintf(" | šŸ‘„ %s", issue.Assignees[0].Name) } result += "\n\n" } } // Productivity tips result += "**šŸ’” Productivity Tips:**\n" result += "• Use `gitlab_get_issue_conversations` to catch up on specific discussions\n" result += "• Use `gitlab_find_similar_issues` to check for duplicates before creating new issues\n" result += "• Check labels and assignees to understand priority and ownership\n" return mcp.NewToolResult(mcp.NewTextContent(result)), nil } // Helper function for min of two integers func min(a, b int) int { if a < b { return a } return b } // Cache management tools func (gitlab *GitLabOperations) handleCacheStats(req mcp.CallToolRequest) (mcp.CallToolResult, error) { stats := gitlab.cache.GetStats() result := "**GitLab MCP Cache Statistics**\n\n" totalHits := int64(0) totalMisses := int64(0) totalEntries := 0 totalSize := int64(0) for cacheType, meta := range stats { hitRate := float64(0) if meta.TotalHits+meta.TotalMisses > 0 { hitRate = float64(meta.TotalHits) / float64(meta.TotalHits+meta.TotalMisses) * 100 } result += fmt.Sprintf("**%s Cache:**\n", strings.Title(cacheType)) result += fmt.Sprintf("• Entries: %d\n", meta.EntryCount) result += fmt.Sprintf("• Total Size: %.2f KB\n", float64(meta.TotalSize)/1024) result += fmt.Sprintf("• Hits: %d\n", meta.TotalHits) result += fmt.Sprintf("• Misses: %d\n", meta.TotalMisses) result += fmt.Sprintf("• Hit Rate: %.1f%%\n", hitRate) result += fmt.Sprintf("• Last Updated: %s\n", meta.LastUpdated.Format("2006-01-02 15:04:05")) result += "\n" totalHits += meta.TotalHits totalMisses += meta.TotalMisses totalEntries += meta.EntryCount totalSize += meta.TotalSize } if len(stats) > 1 { overallHitRate := float64(0) if totalHits+totalMisses > 0 { overallHitRate = float64(totalHits) / float64(totalHits+totalMisses) * 100 } result += "**Overall Statistics:**\n" result += fmt.Sprintf("• Total Entries: %d\n", totalEntries) result += fmt.Sprintf("• Total Size: %.2f KB\n", float64(totalSize)/1024) result += fmt.Sprintf("• Total Hits: %d\n", totalHits) result += fmt.Sprintf("• Total Misses: %d\n", totalMisses) result += fmt.Sprintf("• Overall Hit Rate: %.1f%%\n", overallHitRate) result += fmt.Sprintf("• Cache Directory: %s\n", gitlab.cache.config.CacheDir) } if len(stats) == 0 { result += "No cache data available yet. Cache will populate as you use GitLab tools.\n" } return mcp.NewToolResult(mcp.NewTextContent(result)), nil } func (gitlab *GitLabOperations) handleCacheClear(req mcp.CallToolRequest) (mcp.CallToolResult, error) { args := req.Arguments cacheType, _ := args["cache_type"].(string) confirmStr, _ := args["confirm"].(string) if confirmStr != "true" { result := "**Cache Clear Confirmation Required**\n\n" result += "This will permanently delete cached GitLab data.\n\n" if cacheType == "" { result += "**Target:** All cache types (issues, projects, users, etc.)\n" } else { result += fmt.Sprintf("**Target:** %s cache only\n", cacheType) } result += "\n**To proceed, call this tool again with:**\n" result += "```json\n" result += "{\n" if cacheType != "" { result += fmt.Sprintf(" \"cache_type\": \"%s\",\n", cacheType) } result += " \"confirm\": \"true\"\n" result += "}\n" result += "```\n" return mcp.NewToolResult(mcp.NewTextContent(result)), nil } // Perform the cache clear if err := gitlab.cache.Clear(cacheType); err != nil { return mcp.NewToolError(fmt.Sprintf("Failed to clear cache: %v", err)), nil } result := "**Cache Cleared Successfully**\n\n" if cacheType == "" { result += "āœ… All cached GitLab data has been deleted\n" result += "šŸ”„ Fresh data will be fetched on next requests\n" } else { result += fmt.Sprintf("āœ… %s cache has been cleared\n", strings.Title(cacheType)) result += fmt.Sprintf("šŸ”„ Fresh %s data will be fetched on next requests\n", cacheType) } return mcp.NewToolResult(mcp.NewTextContent(result)), nil } func (gitlab *GitLabOperations) handleOfflineQuery(req mcp.CallToolRequest) (mcp.CallToolResult, error) { args := req.Arguments queryType, ok := args["query_type"].(string) if !ok { return mcp.NewToolError("query_type parameter is required (issues, projects, users, etc.)"), nil } searchTerm, _ := args["search"].(string) limitStr, _ := args["limit"].(string) limit := 20 if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { limit = l } } // This is a simplified offline query - in a full implementation, // you would scan cached files and perform local filtering cacheDir := filepath.Join(gitlab.cache.config.CacheDir, queryType) if _, err := os.Stat(cacheDir); os.IsNotExist(err) { result := fmt.Sprintf("**Offline Query: %s**\n\n", strings.Title(queryType)) result += "āŒ No cached data available for this query type.\n" result += "šŸ’” Try running online queries first to populate the cache.\n" return mcp.NewToolResult(mcp.NewTextContent(result)), nil } result := fmt.Sprintf("**Offline Query: %s**\n\n", strings.Title(queryType)) result += "šŸ” Searching cached data...\n" if searchTerm != "" { result += fmt.Sprintf("šŸŽÆ Search term: \"%s\"\n", searchTerm) } result += fmt.Sprintf("šŸ“Š Limit: %d results\n\n", limit) // Scan cache directory for files files, err := filepath.Glob(filepath.Join(cacheDir, "*", "*.json")) if err != nil { files, _ = filepath.Glob(filepath.Join(cacheDir, "*.json")) } result += fmt.Sprintf("šŸ“ Found %d cached entries\n", len(files)) result += "āœ… Offline querying capability is available\n\n" result += "**Note:** This is a basic offline query demonstration.\n" result += "Full implementation would parse cached JSON files and perform local filtering.\n" result += "Use online queries when network is available for latest data.\n" return mcp.NewToolResult(mcp.NewTextContent(result)), nil }