main
1package gitlab
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "net/http"
8 "os"
9 "path/filepath"
10 "strconv"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/xlgmokha/mcp/pkg/mcp"
16)
17
18// GitLabOperations provides GitLab API operations with caching
19type GitLabOperations struct {
20 mu sync.RWMutex
21 gitlabURL string
22 token string
23 client *http.Client
24 cache *Cache
25}
26
27type GitLabProject struct {
28 ID int `json:"id"`
29 Name string `json:"name"`
30 NameWithNamespace string `json:"name_with_namespace"`
31 Path string `json:"path"`
32 PathWithNamespace string `json:"path_with_namespace"`
33 WebURL string `json:"web_url"`
34 Description string `json:"description"`
35 Visibility string `json:"visibility"`
36 LastActivityAt time.Time `json:"last_activity_at"`
37 CreatedAt time.Time `json:"created_at"`
38 Namespace struct {
39 Name string `json:"name"`
40 Path string `json:"path"`
41 } `json:"namespace"`
42 OpenIssuesCount int `json:"open_issues_count"`
43 ForksCount int `json:"forks_count"`
44 StarCount int `json:"star_count"`
45}
46
47type GitLabIssue struct {
48 ID int `json:"id"`
49 IID int `json:"iid"`
50 ProjectID int `json:"project_id"`
51 Title string `json:"title"`
52 Description string `json:"description"`
53 State string `json:"state"`
54 CreatedAt time.Time `json:"created_at"`
55 UpdatedAt time.Time `json:"updated_at"`
56 WebURL string `json:"web_url"`
57 Author struct {
58 ID int `json:"id"`
59 Username string `json:"username"`
60 Name string `json:"name"`
61 } `json:"author"`
62 Assignees []struct {
63 ID int `json:"id"`
64 Username string `json:"username"`
65 Name string `json:"name"`
66 } `json:"assignees"`
67 Labels []string `json:"labels"`
68 UserNotesCount int `json:"user_notes_count"`
69 DueDate *string `json:"due_date"`
70 TimeStats struct {
71 TimeEstimate int `json:"time_estimate"`
72 TotalTimeSpent int `json:"total_time_spent"`
73 } `json:"time_stats"`
74}
75
76type GitLabNote struct {
77 ID int `json:"id"`
78 Body string `json:"body"`
79 CreatedAt time.Time `json:"created_at"`
80 UpdatedAt time.Time `json:"updated_at"`
81 Author struct {
82 ID int `json:"id"`
83 Username string `json:"username"`
84 Name string `json:"name"`
85 } `json:"author"`
86 System bool `json:"system"`
87 NoteableType string `json:"noteable_type"`
88 NoteableID int `json:"noteable_id"`
89}
90
91type GitLabUser struct {
92 ID int `json:"id"`
93 Username string `json:"username"`
94 Name string `json:"name"`
95 Email string `json:"email"`
96 State string `json:"state"`
97}
98
99// NewGitLabOperations creates a new GitLabOperations helper
100func NewGitLabOperations(gitlabURL, token string) (*GitLabOperations, error) {
101 // Initialize cache with default configuration
102 cache, err := NewCache(CacheConfig{
103 TTL: 5 * time.Minute,
104 MaxEntries: 1000,
105 EnableOffline: true,
106 CompressData: false,
107 })
108 if err != nil {
109 return nil, fmt.Errorf("failed to initialize cache: %w", err)
110 }
111
112 return &GitLabOperations{
113 gitlabURL: strings.TrimSuffix(gitlabURL, "/"),
114 token: token,
115 client: &http.Client{
116 Timeout: 30 * time.Second,
117 },
118 cache: cache,
119 }, nil
120}
121
122// New creates a new GitLab MCP server
123func New(gitlabURL, token string) (*mcp.Server, error) {
124 gitlab, err := NewGitLabOperations(gitlabURL, token)
125 if err != nil {
126 return nil, err
127 }
128
129 builder := mcp.NewServerBuilder("gitlab-server", "1.0.0")
130
131 // Add gitlab_list_my_projects tool
132 builder.AddTool(mcp.NewTool("gitlab_list_my_projects", "List projects where you are a member, with activity and access level info", map[string]interface{}{
133 "type": "object",
134 "properties": map[string]interface{}{
135 "limit": map[string]interface{}{
136 "type": "integer",
137 "description": "Maximum number of projects to return",
138 "minimum": 1,
139 "default": 20,
140 },
141 "archived": map[string]interface{}{
142 "type": "boolean",
143 "description": "Include archived projects",
144 "default": false,
145 },
146 },
147 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
148 return gitlab.handleListMyProjects(req)
149 }))
150
151 // Add gitlab_list_my_issues tool
152 builder.AddTool(mcp.NewTool("gitlab_list_my_issues", "List issues assigned to you, created by you, or where you're mentioned", map[string]interface{}{
153 "type": "object",
154 "properties": map[string]interface{}{
155 "scope": map[string]interface{}{
156 "type": "string",
157 "description": "Filter scope: assigned_to_me, authored, mentioned, all",
158 "default": "assigned_to_me",
159 "enum": []string{"assigned_to_me", "authored", "mentioned", "all"},
160 },
161 "state": map[string]interface{}{
162 "type": "string",
163 "description": "Issue state filter: opened, closed, all",
164 "default": "opened",
165 "enum": []string{"opened", "closed", "all"},
166 },
167 "limit": map[string]interface{}{
168 "type": "integer",
169 "description": "Maximum number of issues to return",
170 "minimum": 1,
171 "default": 50,
172 },
173 },
174 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
175 return gitlab.handleListMyIssues(req)
176 }))
177
178 // Add gitlab_get_issue_conversations tool
179 builder.AddTool(mcp.NewTool("gitlab_get_issue_conversations", "Get full conversation history for a specific issue including notes and system events", map[string]interface{}{
180 "type": "object",
181 "properties": map[string]interface{}{
182 "project_id": map[string]interface{}{
183 "type": "integer",
184 "description": "GitLab project ID",
185 "minimum": 1,
186 },
187 "issue_iid": map[string]interface{}{
188 "type": "integer",
189 "description": "Issue internal ID within the project",
190 "minimum": 1,
191 },
192 },
193 "required": []string{"project_id", "issue_iid"},
194 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
195 return gitlab.handleGetIssueConversations(req)
196 }))
197
198 // Add gitlab_find_similar_issues tool
199 builder.AddTool(mcp.NewTool("gitlab_find_similar_issues", "Find issues similar to a search query across your accessible projects", map[string]interface{}{
200 "type": "object",
201 "properties": map[string]interface{}{
202 "query": map[string]interface{}{
203 "type": "string",
204 "description": "Search query for finding similar issues",
205 },
206 "limit": map[string]interface{}{
207 "type": "integer",
208 "description": "Maximum number of results",
209 "minimum": 1,
210 "default": 20,
211 },
212 },
213 "required": []string{"query"},
214 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
215 return gitlab.handleFindSimilarIssues(req)
216 }))
217
218 // Add gitlab_get_my_activity tool
219 builder.AddTool(mcp.NewTool("gitlab_get_my_activity", "Get recent activity summary including commits, issues, merge requests", map[string]interface{}{
220 "type": "object",
221 "properties": map[string]interface{}{
222 "limit": map[string]interface{}{
223 "type": "integer",
224 "description": "Maximum number of activity events",
225 "minimum": 1,
226 "default": 50,
227 },
228 },
229 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
230 return gitlab.handleGetMyActivity(req)
231 }))
232
233 // Add gitlab_cache_stats tool
234 builder.AddTool(mcp.NewTool("gitlab_cache_stats", "Get cache performance statistics and storage information", map[string]interface{}{
235 "type": "object",
236 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
237 return gitlab.handleCacheStats(req)
238 }))
239
240 // Add gitlab_cache_clear tool
241 builder.AddTool(mcp.NewTool("gitlab_cache_clear", "Clear cached data for specific types or all cached data", map[string]interface{}{
242 "type": "object",
243 "properties": map[string]interface{}{
244 "cache_type": map[string]interface{}{
245 "type": "string",
246 "description": "Type of cache to clear: issues, projects, users, notes, events, search, or empty for all",
247 "enum": []string{"issues", "projects", "users", "notes", "events", "search"},
248 },
249 "confirm": map[string]interface{}{
250 "type": "string",
251 "description": "Confirmation string 'true' to proceed with cache clearing",
252 },
253 },
254 "required": []string{"confirm"},
255 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
256 return gitlab.handleCacheClear(req)
257 }))
258
259 // Add gitlab_offline_query tool
260 builder.AddTool(mcp.NewTool("gitlab_offline_query", "Query cached GitLab data when network connectivity is unavailable", map[string]interface{}{
261 "type": "object",
262 "properties": map[string]interface{}{
263 "query_type": map[string]interface{}{
264 "type": "string",
265 "description": "Type of cached data to query: issues, projects, users, notes, events",
266 "enum": []string{"issues", "projects", "users", "notes", "events"},
267 },
268 "search": map[string]interface{}{
269 "type": "string",
270 "description": "Optional search term to filter cached results",
271 },
272 "limit": map[string]interface{}{
273 "type": "integer",
274 "description": "Maximum number of results to return",
275 "minimum": 1,
276 "default": 50,
277 },
278 },
279 "required": []string{"query_type"},
280 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
281 return gitlab.handleOfflineQuery(req)
282 }))
283
284 return builder.Build(), nil
285}
286
287
288func (gitlab *GitLabOperations) makeRequest(method, endpoint string, params map[string]string) ([]byte, error) {
289 return gitlab.makeRequestWithCache(method, endpoint, params, "")
290}
291
292func (gitlab *GitLabOperations) makeRequestWithCache(method, endpoint string, params map[string]string, cacheType string) ([]byte, error) {
293 gitlab.mu.RLock()
294 defer gitlab.mu.RUnlock()
295
296 // Determine cache type from endpoint if not provided
297 if cacheType == "" {
298 cacheType = gitlab.determineCacheType(endpoint)
299 }
300
301 // Check cache first (only for GET requests)
302 if method == "GET" && cacheType != "" {
303 if cached, found := gitlab.cache.Get(cacheType, endpoint, params); found {
304 return cached, nil
305 }
306 }
307
308 url := fmt.Sprintf("%s/api/v4%s", gitlab.gitlabURL, endpoint)
309
310 req, err := http.NewRequest(method, url, nil)
311 if err != nil {
312 return nil, fmt.Errorf("failed to create request: %w", err)
313 }
314
315 req.Header.Set("Authorization", "Bearer "+gitlab.token)
316 req.Header.Set("Content-Type", "application/json")
317
318 // Add query parameters
319 if len(params) > 0 {
320 q := req.URL.Query()
321 for key, value := range params {
322 q.Add(key, value)
323 }
324 req.URL.RawQuery = q.Encode()
325 }
326
327 resp, err := gitlab.client.Do(req)
328 if err != nil {
329 // If request fails and we have cached data, try to return stale data
330 if method == "GET" && cacheType != "" && gitlab.cache.config.EnableOffline {
331 if cached, found := gitlab.cache.Get(cacheType, endpoint, params); found {
332 fmt.Fprintf(os.Stderr, "Network error, returning cached data: %v\n", err)
333 return cached, nil
334 }
335 }
336 return nil, fmt.Errorf("request failed: %w", err)
337 }
338 defer resp.Body.Close()
339
340 if resp.StatusCode >= 400 {
341 return nil, fmt.Errorf("GitLab API error: %s", resp.Status)
342 }
343
344 // Read response body
345 body, err := io.ReadAll(resp.Body)
346 if err != nil {
347 return nil, fmt.Errorf("failed to read response body: %w", err)
348 }
349
350 // Cache the response (only for GET requests)
351 if method == "GET" && cacheType != "" {
352 if err := gitlab.cache.Set(cacheType, endpoint, params, body, resp.StatusCode); err != nil {
353 // Log cache error but don't fail the request
354 fmt.Fprintf(os.Stderr, "Failed to cache response: %v\n", err)
355 }
356 }
357
358 return body, nil
359}
360
361// determineCacheType maps API endpoints to cache types
362func (gitlab *GitLabOperations) determineCacheType(endpoint string) string {
363 switch {
364 case strings.Contains(endpoint, "/issues"):
365 return "issues"
366 case strings.Contains(endpoint, "/projects"):
367 return "projects"
368 case strings.Contains(endpoint, "/users"):
369 return "users"
370 case strings.Contains(endpoint, "/notes"):
371 return "notes"
372 case strings.Contains(endpoint, "/events"):
373 return "events"
374 case strings.Contains(endpoint, "/search"):
375 return "search"
376 default:
377 return "misc"
378 }
379}
380
381func (gitlab *GitLabOperations) getCurrentUser() (*GitLabUser, error) {
382 body, err := gitlab.makeRequest("GET", "/user", nil)
383 if err != nil {
384 return nil, err
385 }
386
387 var user GitLabUser
388 if err := json.Unmarshal(body, &user); err != nil {
389 return nil, fmt.Errorf("failed to parse user response: %w", err)
390 }
391
392 return &user, nil
393}
394
395func (gitlab *GitLabOperations) handleListMyProjects(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
396 args := req.Arguments
397
398 // Parse optional parameters
399 limitStr, _ := args["limit"].(string)
400 limit := 20 // Default limit
401 if limitStr != "" {
402 if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
403 limit = l
404 }
405 }
406
407 searchTerm, _ := args["search"].(string)
408 membershipOnly, _ := args["membership"].(bool)
409 includeArchived, _ := args["archived"].(bool)
410
411 // Build API parameters
412 params := map[string]string{
413 "per_page": strconv.Itoa(limit),
414 "simple": "true",
415 "order_by": "last_activity_at",
416 "sort": "desc",
417 }
418
419 if searchTerm != "" {
420 params["search"] = searchTerm
421 }
422
423 if membershipOnly {
424 params["membership"] = "true"
425 }
426
427 if !includeArchived {
428 params["archived"] = "false"
429 }
430
431 // Make API request
432 body, err := gitlab.makeRequest("GET", "/projects", params)
433 if err != nil {
434 return mcp.NewToolError(fmt.Sprintf("Failed to fetch projects: %v", err)), nil
435 }
436
437 var projects []GitLabProject
438 if err := json.Unmarshal(body, &projects); err != nil {
439 return mcp.NewToolError(fmt.Sprintf("Failed to parse projects response: %v", err)), nil
440 }
441
442 // Format response for user
443 result := fmt.Sprintf("Your GitLab Projects (%d found):\n\n", len(projects))
444
445 if len(projects) == 0 {
446 result += "No projects found matching your criteria.\n"
447 } else {
448 for i, project := range projects {
449 lastActivity := project.LastActivityAt.Format("2006-01-02 15:04")
450 result += fmt.Sprintf("**%d. %s**\n", i+1, project.NameWithNamespace)
451 result += fmt.Sprintf(" 🔗 %s\n", project.WebURL)
452 result += fmt.Sprintf(" 📊 %d open issues | ⭐ %d stars | 🍴 %d forks\n",
453 project.OpenIssuesCount, project.StarCount, project.ForksCount)
454 result += fmt.Sprintf(" 📅 Last activity: %s\n", lastActivity)
455 if project.Description != "" {
456 // Truncate long descriptions
457 desc := project.Description
458 if len(desc) > 100 {
459 desc = desc[:97] + "..."
460 }
461 result += fmt.Sprintf(" 📝 %s\n", desc)
462 }
463 result += "\n"
464 }
465 }
466
467 return mcp.NewToolResult(mcp.NewTextContent(result)), nil
468}
469
470func (gitlab *GitLabOperations) handleListMyIssues(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
471 args := req.Arguments
472
473 // Parse optional parameters
474 limitStr, _ := args["limit"].(string)
475 limit := 20 // Default limit
476 if limitStr != "" {
477 if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
478 limit = l
479 }
480 }
481
482 scope, _ := args["scope"].(string)
483 if scope == "" {
484 scope = "assigned_to_me" // Default: issues assigned to user
485 }
486
487 state, _ := args["state"].(string)
488 if state == "" {
489 state = "opened" // Default: only open issues
490 }
491
492 searchTerm, _ := args["search"].(string)
493
494 // Get current user for filtering
495 user, err := gitlab.getCurrentUser()
496 if err != nil {
497 return mcp.NewToolError(fmt.Sprintf("Failed to get current user: %v", err)), nil
498 }
499
500 // Build API parameters based on scope
501 params := map[string]string{
502 "per_page": strconv.Itoa(limit),
503 "state": state,
504 "order_by": "updated_at",
505 "sort": "desc",
506 }
507
508 switch scope {
509 case "assigned_to_me":
510 params["assignee_username"] = user.Username
511 case "authored_by_me":
512 params["author_username"] = user.Username
513 case "all_involving_me":
514 // This will require multiple API calls or use a different endpoint
515 params["scope"] = "all"
516 default:
517 params["assignee_username"] = user.Username
518 }
519
520 if searchTerm != "" {
521 params["search"] = searchTerm
522 }
523
524 // Make API request
525 body, err := gitlab.makeRequest("GET", "/issues", params)
526 if err != nil {
527 return mcp.NewToolError(fmt.Sprintf("Failed to fetch issues: %v", err)), nil
528 }
529
530 var issues []GitLabIssue
531 if err := json.Unmarshal(body, &issues); err != nil {
532 return mcp.NewToolError(fmt.Sprintf("Failed to parse issues response: %v", err)), nil
533 }
534
535 // Format response for user
536 result := fmt.Sprintf("Your GitLab Issues (%s, %d found):\n\n", scope, len(issues))
537
538 if len(issues) == 0 {
539 result += "No issues found matching your criteria.\n"
540 } else {
541 for i, issue := range issues {
542 updatedAt := issue.UpdatedAt.Format("2006-01-02 15:04")
543
544 // Get project name from web URL (quick extraction)
545 projectName := extractProjectFromURL(issue.WebURL)
546
547 result += fmt.Sprintf("**%d. %s** 🎯\n", i+1, issue.Title)
548 result += fmt.Sprintf(" 📁 %s #%d\n", projectName, issue.IID)
549 result += fmt.Sprintf(" 🔗 %s\n", issue.WebURL)
550 result += fmt.Sprintf(" 👤 Author: %s", issue.Author.Name)
551
552 // Show assignees
553 if len(issue.Assignees) > 0 {
554 assigneeNames := make([]string, len(issue.Assignees))
555 for j, assignee := range issue.Assignees {
556 assigneeNames[j] = assignee.Name
557 }
558 result += fmt.Sprintf(" | 👥 Assigned: %s", strings.Join(assigneeNames, ", "))
559 }
560 result += "\n"
561
562 // Show labels if any
563 if len(issue.Labels) > 0 {
564 result += fmt.Sprintf(" 🏷️ %s\n", strings.Join(issue.Labels, ", "))
565 }
566
567 result += fmt.Sprintf(" 💬 %d comments | 📅 Updated: %s\n",
568 issue.UserNotesCount, updatedAt)
569
570 // Show due date if set
571 if issue.DueDate != nil && *issue.DueDate != "" {
572 result += fmt.Sprintf(" ⏰ Due: %s\n", *issue.DueDate)
573 }
574
575 result += "\n"
576 }
577 }
578
579 return mcp.NewToolResult(mcp.NewTextContent(result)), nil
580}
581
582// Helper function to extract project name from GitLab issue URL
583func extractProjectFromURL(webURL string) string {
584 // URL format: https://gitlab.com/namespace/project/-/issues/123
585 parts := strings.Split(webURL, "/")
586 if len(parts) >= 5 {
587 return fmt.Sprintf("%s/%s", parts[len(parts)-4], parts[len(parts)-3])
588 }
589 return "Unknown"
590}
591
592func (gitlab *GitLabOperations) handleGetIssueConversations(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
593 args := req.Arguments
594
595 projectIDStr, ok := args["project_id"].(string)
596 if !ok {
597 return mcp.NewToolError("project_id parameter is required"), nil
598 }
599
600 issueIIDStr, ok := args["issue_iid"].(string)
601 if !ok {
602 return mcp.NewToolError("issue_iid parameter is required"), nil
603 }
604
605 projectID, err := strconv.Atoi(projectIDStr)
606 if err != nil {
607 return mcp.NewToolError("project_id must be a valid integer"), nil
608 }
609
610 issueIID, err := strconv.Atoi(issueIIDStr)
611 if err != nil {
612 return mcp.NewToolError("issue_iid must be a valid integer"), nil
613 }
614
615 includeSystemNotes, _ := args["include_system_notes"].(bool)
616
617 // First, get the issue details
618 issueEndpoint := fmt.Sprintf("/projects/%d/issues/%d", projectID, issueIID)
619 issueBody, err := gitlab.makeRequest("GET", issueEndpoint, nil)
620 if err != nil {
621 return mcp.NewToolError(fmt.Sprintf("Failed to fetch issue: %v", err)), nil
622 }
623
624 var issue GitLabIssue
625 if err := json.Unmarshal(issueBody, &issue); err != nil {
626 return mcp.NewToolError(fmt.Sprintf("Failed to parse issue response: %v", err)), nil
627 }
628
629 // Get issue notes (comments)
630 notesEndpoint := fmt.Sprintf("/projects/%d/issues/%d/notes", projectID, issueIID)
631 notesParams := map[string]string{
632 "order_by": "created_at",
633 "sort": "asc",
634 }
635
636 notesBody, err := gitlab.makeRequest("GET", notesEndpoint, notesParams)
637 if err != nil {
638 return mcp.NewToolError(fmt.Sprintf("Failed to fetch issue notes: %v", err)), nil
639 }
640
641 var notes []GitLabNote
642 if err := json.Unmarshal(notesBody, ¬es); err != nil {
643 return mcp.NewToolError(fmt.Sprintf("Failed to parse notes response: %v", err)), nil
644 }
645
646 // Filter notes based on preferences
647 var filteredNotes []GitLabNote
648 for _, note := range notes {
649 if !includeSystemNotes && note.System {
650 continue
651 }
652 filteredNotes = append(filteredNotes, note)
653 }
654
655 // Format the conversation
656 projectName := extractProjectFromURL(issue.WebURL)
657 result := fmt.Sprintf("**Issue Conversation: %s**\n", issue.Title)
658 result += fmt.Sprintf("📁 %s #%d | 🔗 %s\n\n", projectName, issue.IID, issue.WebURL)
659
660 // Issue description
661 result += fmt.Sprintf("**Original Issue** - %s (%s)\n",
662 issue.Author.Name, issue.CreatedAt.Format("2006-01-02 15:04"))
663 result += "─────────────────────────────────────────────────\n"
664 if issue.Description != "" {
665 result += fmt.Sprintf("%s\n", issue.Description)
666 } else {
667 result += "_No description provided._\n"
668 }
669 result += "\n"
670
671 // Issue metadata
672 result += "**Issue Details:**\n"
673 result += fmt.Sprintf("• **State:** %s\n", issue.State)
674 if len(issue.Assignees) > 0 {
675 assigneeNames := make([]string, len(issue.Assignees))
676 for i, assignee := range issue.Assignees {
677 assigneeNames[i] = assignee.Name
678 }
679 result += fmt.Sprintf("• **Assignees:** %s\n", strings.Join(assigneeNames, ", "))
680 }
681 if len(issue.Labels) > 0 {
682 result += fmt.Sprintf("• **Labels:** %s\n", strings.Join(issue.Labels, ", "))
683 }
684 if issue.DueDate != nil && *issue.DueDate != "" {
685 result += fmt.Sprintf("• **Due Date:** %s\n", *issue.DueDate)
686 }
687 result += fmt.Sprintf("• **Total Comments:** %d\n", len(filteredNotes))
688 result += "\n"
689
690 // Conversation thread
691 if len(filteredNotes) == 0 {
692 result += "**No comments yet.**\n"
693 } else {
694 result += fmt.Sprintf("**Conversation Thread (%d comments):**\n\n", len(filteredNotes))
695
696 for i, note := range filteredNotes {
697 commentType := "💬"
698 if note.System {
699 commentType = "🔧"
700 }
701
702 result += fmt.Sprintf("%s **Comment %d** - %s (%s)\n",
703 commentType, i+1, note.Author.Name, note.CreatedAt.Format("2006-01-02 15:04"))
704 result += "─────────────────────────────────────────────────\n"
705 result += fmt.Sprintf("%s\n\n", note.Body)
706 }
707 }
708
709 return mcp.NewToolResult(mcp.NewTextContent(result)), nil
710}
711
712func (gitlab *GitLabOperations) handleFindSimilarIssues(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
713 args := req.Arguments
714
715 searchQuery, ok := args["query"].(string)
716 if !ok || searchQuery == "" {
717 return mcp.NewToolError("query parameter is required"), nil
718 }
719
720 limitStr, _ := args["limit"].(string)
721 limit := 10 // Default limit for similarity search
722 if limitStr != "" {
723 if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
724 limit = l
725 }
726 }
727
728 scope, _ := args["scope"].(string)
729 if scope == "" {
730 scope = "issues" // Default to issues
731 }
732
733 includeClosedStr, _ := args["include_closed"].(string)
734 includeClosed := false
735 if includeClosedStr == "true" {
736 includeClosed = true
737 }
738
739 // Build search parameters
740 params := map[string]string{
741 "search": searchQuery,
742 "scope": scope,
743 "per_page": strconv.Itoa(limit),
744 }
745
746 // Make search API request
747 body, err := gitlab.makeRequest("GET", "/search", params)
748 if err != nil {
749 return mcp.NewToolError(fmt.Sprintf("Failed to search issues: %v", err)), nil
750 }
751
752 var searchResults []GitLabIssue
753 if err := json.Unmarshal(body, &searchResults); err != nil {
754 return mcp.NewToolError(fmt.Sprintf("Failed to parse search results: %v", err)), nil
755 }
756
757 // Filter results based on state preferences
758 var filteredResults []GitLabIssue
759 for _, issue := range searchResults {
760 if !includeClosed && issue.State != "opened" {
761 continue
762 }
763 filteredResults = append(filteredResults, issue)
764 }
765
766 // Group results by project for better organization
767 projectGroups := make(map[string][]GitLabIssue)
768 for _, issue := range filteredResults {
769 projectName := extractProjectFromURL(issue.WebURL)
770 projectGroups[projectName] = append(projectGroups[projectName], issue)
771 }
772
773 // Format response
774 result := fmt.Sprintf("**Similar Issues Found for: \"%s\"**\n", searchQuery)
775 result += fmt.Sprintf("Found %d issues across %d projects:\n\n", len(filteredResults), len(projectGroups))
776
777 if len(filteredResults) == 0 {
778 result += "No similar issues found. Try:\n"
779 result += "• Different keywords or search terms\n"
780 result += "• Including closed issues with include_closed=true\n"
781 result += "• Broader search scope\n"
782 } else {
783 // Display results grouped by project
784 projectCount := 0
785 for projectName, issues := range projectGroups {
786 projectCount++
787 result += fmt.Sprintf("**🗂️ Project %d: %s** (%d issues)\n", projectCount, projectName, len(issues))
788 result += "─────────────────────────────────────────────────\n"
789
790 for i, issue := range issues {
791 stateIcon := "🟢"
792 if issue.State == "closed" {
793 stateIcon = "🔴"
794 }
795
796 result += fmt.Sprintf("%d. %s **%s** %s\n", i+1, stateIcon, issue.Title, issue.State)
797 result += fmt.Sprintf(" 📋 #%d | 👤 %s", issue.IID, issue.Author.Name)
798
799 if len(issue.Assignees) > 0 {
800 result += fmt.Sprintf(" | 👥 %s", issue.Assignees[0].Name)
801 }
802 result += "\n"
803
804 if len(issue.Labels) > 0 {
805 result += fmt.Sprintf(" 🏷️ %s\n", strings.Join(issue.Labels, ", "))
806 }
807
808 result += fmt.Sprintf(" 🔗 %s\n", issue.WebURL)
809 result += fmt.Sprintf(" 📅 Updated: %s\n", issue.UpdatedAt.Format("2006-01-02 15:04"))
810
811 // Show snippet of description if available
812 if issue.Description != "" {
813 desc := issue.Description
814 if len(desc) > 150 {
815 desc = desc[:147] + "..."
816 }
817 result += fmt.Sprintf(" 📝 %s\n", desc)
818 }
819 result += "\n"
820 }
821 result += "\n"
822 }
823
824 // Add similarity analysis tips
825 result += "**💡 Similarity Analysis Tips:**\n"
826 result += "• Look for common labels and patterns\n"
827 result += "• Check if issues are linked or reference each other\n"
828 result += "• Consider if these could be duplicate issues\n"
829 result += "• Review assignees for domain expertise\n"
830 }
831
832 return mcp.NewToolResult(mcp.NewTextContent(result)), nil
833}
834
835func (gitlab *GitLabOperations) handleGetMyActivity(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
836 args := req.Arguments
837
838 // Parse optional parameters
839 limitStr, _ := args["limit"].(string)
840 limit := 20 // Default limit
841 if limitStr != "" {
842 if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
843 limit = l
844 }
845 }
846
847 days, _ := args["days"].(string)
848 daysInt := 7 // Default: last 7 days
849 if days != "" {
850 if d, err := strconv.Atoi(days); err == nil && d > 0 && d <= 30 {
851 daysInt = d
852 }
853 }
854
855 // Get current user
856 user, err := gitlab.getCurrentUser()
857 if err != nil {
858 return mcp.NewToolError(fmt.Sprintf("Failed to get current user: %v", err)), nil
859 }
860
861 // Calculate date range
862 since := time.Now().AddDate(0, 0, -daysInt).Format("2006-01-02T15:04:05Z")
863
864 // Get recent issues assigned to user
865 assignedParams := map[string]string{
866 "assignee_username": user.Username,
867 "state": "opened",
868 "order_by": "updated_at",
869 "sort": "desc",
870 "per_page": strconv.Itoa(limit / 2),
871 "updated_after": since,
872 }
873
874 assignedBody, err := gitlab.makeRequest("GET", "/issues", assignedParams)
875 if err != nil {
876 return mcp.NewToolError(fmt.Sprintf("Failed to fetch assigned issues: %v", err)), nil
877 }
878
879 var assignedIssues []GitLabIssue
880 json.Unmarshal(assignedBody, &assignedIssues)
881
882 // Get recent issues authored by user
883 authoredParams := map[string]string{
884 "author_username": user.Username,
885 "state": "opened",
886 "order_by": "updated_at",
887 "sort": "desc",
888 "per_page": strconv.Itoa(limit / 2),
889 "updated_after": since,
890 }
891
892 authoredBody, err := gitlab.makeRequest("GET", "/issues", authoredParams)
893 if err != nil {
894 return mcp.NewToolError(fmt.Sprintf("Failed to fetch authored issues: %v", err)), nil
895 }
896
897 var authoredIssues []GitLabIssue
898 json.Unmarshal(authoredBody, &authoredIssues)
899
900 // Get user's recent activity events
901 activityParams := map[string]string{
902 "per_page": "20",
903 }
904
905 activityBody, err := gitlab.makeRequest("GET", fmt.Sprintf("/users/%d/events", user.ID), activityParams)
906 if err != nil {
907 // Activity endpoint might not be available, continue without it
908 activityBody = []byte("[]")
909 }
910
911 // Parse activity (basic structure)
912 var activities []map[string]interface{}
913 json.Unmarshal(activityBody, &activities)
914
915 // Format comprehensive activity summary
916 result := fmt.Sprintf("**GitLab Activity Summary for %s**\n", user.Name)
917 result += fmt.Sprintf("📅 Last %d days | 👤 @%s\n\n", daysInt, user.Username)
918
919 // Summary statistics
920 result += "**📊 Quick Summary:**\n"
921 result += fmt.Sprintf("• Assigned Issues: %d open\n", len(assignedIssues))
922 result += fmt.Sprintf("• Authored Issues: %d open\n", len(authoredIssues))
923 result += fmt.Sprintf("• Recent Activity Events: %d\n", len(activities))
924 result += "\n"
925
926 // Priority items that need attention
927 result += "**🎯 Items Needing Attention:**\n"
928 priorityCount := 0
929
930 // Check for overdue items
931 for _, issue := range assignedIssues {
932 if issue.DueDate != nil && *issue.DueDate != "" {
933 dueDate, err := time.Parse("2006-01-02", *issue.DueDate)
934 if err == nil && dueDate.Before(time.Now()) {
935 if priorityCount == 0 {
936 result += "⚠️ **Overdue Issues:**\n"
937 }
938 projectName := extractProjectFromURL(issue.WebURL)
939 result += fmt.Sprintf(" • %s #%d - %s (Due: %s)\n",
940 projectName, issue.IID, issue.Title, *issue.DueDate)
941 priorityCount++
942 }
943 }
944 }
945
946 // Check for issues with recent activity (comments)
947 recentActivityCount := 0
948 for _, issue := range assignedIssues {
949 if issue.UpdatedAt.After(time.Now().AddDate(0, 0, -2)) && issue.UserNotesCount > 0 {
950 if recentActivityCount == 0 && priorityCount > 0 {
951 result += "\n💬 **Issues with Recent Comments:**\n"
952 } else if recentActivityCount == 0 {
953 result += "💬 **Issues with Recent Comments:**\n"
954 }
955 projectName := extractProjectFromURL(issue.WebURL)
956 result += fmt.Sprintf(" • %s #%d - %s (%d comments)\n",
957 projectName, issue.IID, issue.Title, issue.UserNotesCount)
958 recentActivityCount++
959 if recentActivityCount >= 5 {
960 break
961 }
962 }
963 }
964
965 if priorityCount == 0 && recentActivityCount == 0 {
966 result += "✅ No urgent items requiring immediate attention\n"
967 }
968 result += "\n"
969
970 // Assigned issues section
971 if len(assignedIssues) > 0 {
972 result += fmt.Sprintf("**📋 Assigned Issues (%d):**\n", len(assignedIssues))
973 for i, issue := range assignedIssues {
974 if i >= 10 {
975 break // Limit display
976 }
977 projectName := extractProjectFromURL(issue.WebURL)
978 updatedAt := issue.UpdatedAt.Format("Jan 2")
979
980 result += fmt.Sprintf("%d. **%s** - %s #%d\n", i+1, issue.Title, projectName, issue.IID)
981 result += fmt.Sprintf(" 📅 %s | 💬 %d comments", updatedAt, issue.UserNotesCount)
982
983 if len(issue.Labels) > 0 {
984 result += fmt.Sprintf(" | 🏷️ %s", strings.Join(issue.Labels[:min(3, len(issue.Labels))], ", "))
985 }
986 result += "\n\n"
987 }
988 }
989
990 // Authored issues section
991 if len(authoredIssues) > 0 {
992 result += fmt.Sprintf("**✍️ Issues You Created (%d active):**\n", len(authoredIssues))
993 for i, issue := range authoredIssues {
994 if i >= 5 {
995 break // Limit display
996 }
997 projectName := extractProjectFromURL(issue.WebURL)
998 updatedAt := issue.UpdatedAt.Format("Jan 2")
999
1000 result += fmt.Sprintf("%d. **%s** - %s #%d\n", i+1, issue.Title, projectName, issue.IID)
1001 result += fmt.Sprintf(" 📅 %s | 💬 %d comments", updatedAt, issue.UserNotesCount)
1002
1003 if len(issue.Assignees) > 0 {
1004 result += fmt.Sprintf(" | 👥 %s", issue.Assignees[0].Name)
1005 }
1006 result += "\n\n"
1007 }
1008 }
1009
1010 // Productivity tips
1011 result += "**💡 Productivity Tips:**\n"
1012 result += "• Use `gitlab_get_issue_conversations` to catch up on specific discussions\n"
1013 result += "• Use `gitlab_find_similar_issues` to check for duplicates before creating new issues\n"
1014 result += "• Check labels and assignees to understand priority and ownership\n"
1015
1016 return mcp.NewToolResult(mcp.NewTextContent(result)), nil
1017}
1018
1019// Helper function for min of two integers
1020func min(a, b int) int {
1021 if a < b {
1022 return a
1023 }
1024 return b
1025}
1026
1027// Cache management tools
1028
1029func (gitlab *GitLabOperations) handleCacheStats(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
1030 stats := gitlab.cache.GetStats()
1031
1032 result := "**GitLab MCP Cache Statistics**\n\n"
1033
1034 totalHits := int64(0)
1035 totalMisses := int64(0)
1036 totalEntries := 0
1037 totalSize := int64(0)
1038
1039 for cacheType, meta := range stats {
1040 hitRate := float64(0)
1041 if meta.TotalHits+meta.TotalMisses > 0 {
1042 hitRate = float64(meta.TotalHits) / float64(meta.TotalHits+meta.TotalMisses) * 100
1043 }
1044
1045 result += fmt.Sprintf("**%s Cache:**\n", strings.Title(cacheType))
1046 result += fmt.Sprintf("• Entries: %d\n", meta.EntryCount)
1047 result += fmt.Sprintf("• Total Size: %.2f KB\n", float64(meta.TotalSize)/1024)
1048 result += fmt.Sprintf("• Hits: %d\n", meta.TotalHits)
1049 result += fmt.Sprintf("• Misses: %d\n", meta.TotalMisses)
1050 result += fmt.Sprintf("• Hit Rate: %.1f%%\n", hitRate)
1051 result += fmt.Sprintf("• Last Updated: %s\n", meta.LastUpdated.Format("2006-01-02 15:04:05"))
1052 result += "\n"
1053
1054 totalHits += meta.TotalHits
1055 totalMisses += meta.TotalMisses
1056 totalEntries += meta.EntryCount
1057 totalSize += meta.TotalSize
1058 }
1059
1060 if len(stats) > 1 {
1061 overallHitRate := float64(0)
1062 if totalHits+totalMisses > 0 {
1063 overallHitRate = float64(totalHits) / float64(totalHits+totalMisses) * 100
1064 }
1065
1066 result += "**Overall Statistics:**\n"
1067 result += fmt.Sprintf("• Total Entries: %d\n", totalEntries)
1068 result += fmt.Sprintf("• Total Size: %.2f KB\n", float64(totalSize)/1024)
1069 result += fmt.Sprintf("• Total Hits: %d\n", totalHits)
1070 result += fmt.Sprintf("• Total Misses: %d\n", totalMisses)
1071 result += fmt.Sprintf("• Overall Hit Rate: %.1f%%\n", overallHitRate)
1072 result += fmt.Sprintf("• Cache Directory: %s\n", gitlab.cache.config.CacheDir)
1073 }
1074
1075 if len(stats) == 0 {
1076 result += "No cache data available yet. Cache will populate as you use GitLab tools.\n"
1077 }
1078
1079 return mcp.NewToolResult(mcp.NewTextContent(result)), nil
1080}
1081
1082func (gitlab *GitLabOperations) handleCacheClear(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
1083 args := req.Arguments
1084
1085 cacheType, _ := args["cache_type"].(string)
1086 confirmStr, _ := args["confirm"].(string)
1087
1088 if confirmStr != "true" {
1089 result := "**Cache Clear Confirmation Required**\n\n"
1090 result += "This will permanently delete cached GitLab data.\n\n"
1091
1092 if cacheType == "" {
1093 result += "**Target:** All cache types (issues, projects, users, etc.)\n"
1094 } else {
1095 result += fmt.Sprintf("**Target:** %s cache only\n", cacheType)
1096 }
1097
1098 result += "\n**To proceed, call this tool again with:**\n"
1099 result += "```json\n"
1100 result += "{\n"
1101 if cacheType != "" {
1102 result += fmt.Sprintf(" \"cache_type\": \"%s\",\n", cacheType)
1103 }
1104 result += " \"confirm\": \"true\"\n"
1105 result += "}\n"
1106 result += "```\n"
1107
1108 return mcp.NewToolResult(mcp.NewTextContent(result)), nil
1109 }
1110
1111 // Perform the cache clear
1112 if err := gitlab.cache.Clear(cacheType); err != nil {
1113 return mcp.NewToolError(fmt.Sprintf("Failed to clear cache: %v", err)), nil
1114 }
1115
1116 result := "**Cache Cleared Successfully**\n\n"
1117 if cacheType == "" {
1118 result += "✅ All cached GitLab data has been deleted\n"
1119 result += "🔄 Fresh data will be fetched on next requests\n"
1120 } else {
1121 result += fmt.Sprintf("✅ %s cache has been cleared\n", strings.Title(cacheType))
1122 result += fmt.Sprintf("🔄 Fresh %s data will be fetched on next requests\n", cacheType)
1123 }
1124
1125 return mcp.NewToolResult(mcp.NewTextContent(result)), nil
1126}
1127
1128func (gitlab *GitLabOperations) handleOfflineQuery(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
1129 args := req.Arguments
1130
1131 queryType, ok := args["query_type"].(string)
1132 if !ok {
1133 return mcp.NewToolError("query_type parameter is required (issues, projects, users, etc.)"), nil
1134 }
1135
1136 searchTerm, _ := args["search"].(string)
1137 limitStr, _ := args["limit"].(string)
1138 limit := 20
1139 if limitStr != "" {
1140 if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
1141 limit = l
1142 }
1143 }
1144
1145 // This is a simplified offline query - in a full implementation,
1146 // you would scan cached files and perform local filtering
1147 cacheDir := filepath.Join(gitlab.cache.config.CacheDir, queryType)
1148
1149 if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
1150 result := fmt.Sprintf("**Offline Query: %s**\n\n", strings.Title(queryType))
1151 result += "❌ No cached data available for this query type.\n"
1152 result += "💡 Try running online queries first to populate the cache.\n"
1153 return mcp.NewToolResult(mcp.NewTextContent(result)), nil
1154 }
1155
1156 result := fmt.Sprintf("**Offline Query: %s**\n\n", strings.Title(queryType))
1157 result += "🔍 Searching cached data...\n"
1158 if searchTerm != "" {
1159 result += fmt.Sprintf("🎯 Search term: \"%s\"\n", searchTerm)
1160 }
1161 result += fmt.Sprintf("📊 Limit: %d results\n\n", limit)
1162
1163 // Scan cache directory for files
1164 files, err := filepath.Glob(filepath.Join(cacheDir, "*", "*.json"))
1165 if err != nil {
1166 files, _ = filepath.Glob(filepath.Join(cacheDir, "*.json"))
1167 }
1168
1169 result += fmt.Sprintf("📁 Found %d cached entries\n", len(files))
1170 result += "✅ Offline querying capability is available\n\n"
1171
1172 result += "**Note:** This is a basic offline query demonstration.\n"
1173 result += "Full implementation would parse cached JSON files and perform local filtering.\n"
1174 result += "Use online queries when network is available for latest data.\n"
1175
1176 return mcp.NewToolResult(mcp.NewTextContent(result)), nil
1177}