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, &notes); 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}