package providers import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "chorus/pkg/repository" ) // GitLabProvider implements TaskProvider for GitLab API type GitLabProvider struct { config *repository.Config httpClient *http.Client baseURL string token string projectID string // GitLab uses project ID or namespace/project-name } // NewGitLabProvider creates a new GitLab provider func NewGitLabProvider(config *repository.Config) (*GitLabProvider, error) { if config.AccessToken == "" { return nil, fmt.Errorf("access token is required for GitLab provider") } // Default to gitlab.com if no base URL provided baseURL := config.BaseURL if baseURL == "" { baseURL = "https://gitlab.com" } baseURL = strings.TrimSuffix(baseURL, "/") // Build project ID from owner/repo if provided, otherwise use settings var projectID string if config.Owner != "" && config.Repository != "" { projectID = url.QueryEscape(fmt.Sprintf("%s/%s", config.Owner, config.Repository)) } else if projectIDSetting, ok := config.Settings["project_id"].(string); ok { projectID = projectIDSetting } else { return nil, fmt.Errorf("either owner/repository or project_id in settings is required for GitLab provider") } return &GitLabProvider{ config: config, baseURL: baseURL, token: config.AccessToken, projectID: projectID, httpClient: &http.Client{ Timeout: 30 * time.Second, }, }, nil } // GitLabIssue represents a GitLab issue type GitLabIssue struct { ID int `json:"id"` IID int `json:"iid"` // Project-specific ID (what users see) Title string `json:"title"` Description string `json:"description"` State string `json:"state"` Labels []string `json:"labels"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` ProjectID int `json:"project_id"` Author *GitLabUser `json:"author"` Assignee *GitLabUser `json:"assignee"` Assignees []GitLabUser `json:"assignees"` WebURL string `json:"web_url"` TimeStats *GitLabTimeStats `json:"time_stats,omitempty"` } // GitLabUser represents a GitLab user type GitLabUser struct { ID int `json:"id"` Username string `json:"username"` Name string `json:"name"` Email string `json:"email"` AvatarURL string `json:"avatar_url"` } // GitLabTimeStats represents time tracking statistics type GitLabTimeStats struct { TimeEstimate int `json:"time_estimate"` TotalTimeSpent int `json:"total_time_spent"` HumanTimeEstimate string `json:"human_time_estimate"` HumanTotalTimeSpent string `json:"human_total_time_spent"` } // GitLabNote represents a GitLab issue note (comment) type GitLabNote struct { ID int `json:"id"` Body string `json:"body"` CreatedAt time.Time `json:"created_at"` Author *GitLabUser `json:"author"` System bool `json:"system"` } // GitLabProject represents a GitLab project type GitLabProject struct { ID int `json:"id"` Name string `json:"name"` NameWithNamespace string `json:"name_with_namespace"` PathWithNamespace string `json:"path_with_namespace"` WebURL string `json:"web_url"` } // makeRequest makes an HTTP request to the GitLab API func (g *GitLabProvider) makeRequest(method, endpoint string, body interface{}) (*http.Response, error) { var reqBody io.Reader if body != nil { jsonData, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("failed to marshal request body: %w", err) } reqBody = bytes.NewBuffer(jsonData) } url := fmt.Sprintf("%s/api/v4%s", g.baseURL, endpoint) req, err := http.NewRequest(method, url, reqBody) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Private-Token", g.token) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := g.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } return resp, nil } // GetTasks retrieves tasks (issues) from the GitLab project func (g *GitLabProvider) GetTasks(projectID int) ([]*repository.Task, error) { // Build query parameters params := url.Values{} params.Add("state", "opened") params.Add("sort", "created_desc") params.Add("per_page", "100") // GitLab default is 20 // Add task label filter if specified if g.config.TaskLabel != "" { params.Add("labels", g.config.TaskLabel) } endpoint := fmt.Sprintf("/projects/%s/issues?%s", g.projectID, params.Encode()) resp, err := g.makeRequest("GET", endpoint, nil) if err != nil { return nil, fmt.Errorf("failed to get issues: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) } var issues []GitLabIssue if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { return nil, fmt.Errorf("failed to decode issues: %w", err) } // Convert GitLab issues to repository tasks tasks := make([]*repository.Task, 0, len(issues)) for _, issue := range issues { task := g.issueToTask(&issue) tasks = append(tasks, task) } return tasks, nil } // ClaimTask claims a task by assigning it to the agent and adding in-progress label func (g *GitLabProvider) ClaimTask(taskNumber int, agentID string) (bool, error) { // First, get the current issue to check its state endpoint := fmt.Sprintf("/projects/%s/issues/%d", g.projectID, taskNumber) resp, err := g.makeRequest("GET", endpoint, nil) if err != nil { return false, fmt.Errorf("failed to get issue: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Errorf("issue not found or not accessible") } var issue GitLabIssue if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { return false, fmt.Errorf("failed to decode issue: %w", err) } // Check if issue is already assigned if issue.Assignee != nil || len(issue.Assignees) > 0 { assigneeName := "" if issue.Assignee != nil { assigneeName = issue.Assignee.Username } else if len(issue.Assignees) > 0 { assigneeName = issue.Assignees[0].Username } return false, fmt.Errorf("issue is already assigned to %s", assigneeName) } // Add in-progress label if specified if g.config.InProgressLabel != "" { err := g.addLabelToIssue(taskNumber, g.config.InProgressLabel) if err != nil { return false, fmt.Errorf("failed to add in-progress label: %w", err) } } // Add a note indicating the task has been claimed comment := fmt.Sprintf("šŸ¤– **Task Claimed by CHORUS Agent**\n\nAgent ID: `%s` \nStatus: Processing \n\nThis task is now being handled automatically by the CHORUS autonomous agent system.", agentID) err = g.addNoteToIssue(taskNumber, comment) if err != nil { // Don't fail the claim if note fails fmt.Printf("Warning: failed to add claim note: %v\n", err) } return true, nil } // UpdateTaskStatus updates the status of a task func (g *GitLabProvider) UpdateTaskStatus(task *repository.Task, status string, comment string) error { // Add a note with the status update statusComment := fmt.Sprintf("šŸ“Š **Status Update: %s**\n\n%s\n\n---\n*Updated by CHORUS Agent*", status, comment) err := g.addNoteToIssue(task.Number, statusComment) if err != nil { return fmt.Errorf("failed to add status note: %w", err) } return nil } // CompleteTask completes a task by updating status and adding completion comment func (g *GitLabProvider) CompleteTask(task *repository.Task, result *repository.TaskResult) error { // Create completion comment with results var commentBuffer strings.Builder commentBuffer.WriteString("āœ… **Task Completed Successfully**\n\n") commentBuffer.WriteString(fmt.Sprintf("**Result:** %s\n\n", result.Message)) // Add metadata if available if result.Metadata != nil { commentBuffer.WriteString("## Execution Details\n\n") for key, value := range result.Metadata { // Format the metadata nicely switch key { case "duration": commentBuffer.WriteString(fmt.Sprintf("- ā±ļø **Duration:** %v\n", value)) case "execution_type": commentBuffer.WriteString(fmt.Sprintf("- šŸ”§ **Execution Type:** %v\n", value)) case "commands_executed": commentBuffer.WriteString(fmt.Sprintf("- šŸ–„ļø **Commands Executed:** %v\n", value)) case "files_generated": commentBuffer.WriteString(fmt.Sprintf("- šŸ“„ **Files Generated:** %v\n", value)) case "ai_provider": commentBuffer.WriteString(fmt.Sprintf("- šŸ¤– **AI Provider:** %v\n", value)) case "ai_model": commentBuffer.WriteString(fmt.Sprintf("- 🧠 **AI Model:** %v\n", value)) default: commentBuffer.WriteString(fmt.Sprintf("- **%s:** %v\n", key, value)) } } commentBuffer.WriteString("\n") } commentBuffer.WriteString("---\nšŸ¤– *Completed by CHORUS Autonomous Agent System*") // Add completion note err := g.addNoteToIssue(task.Number, commentBuffer.String()) if err != nil { return fmt.Errorf("failed to add completion note: %w", err) } // Remove in-progress label and add completed label if g.config.InProgressLabel != "" { err := g.removeLabelFromIssue(task.Number, g.config.InProgressLabel) if err != nil { fmt.Printf("Warning: failed to remove in-progress label: %v\n", err) } } if g.config.CompletedLabel != "" { err := g.addLabelToIssue(task.Number, g.config.CompletedLabel) if err != nil { fmt.Printf("Warning: failed to add completed label: %v\n", err) } } // Close the issue if the task was successful if result.Success { err := g.closeIssue(task.Number) if err != nil { return fmt.Errorf("failed to close issue: %w", err) } } return nil } // GetTaskDetails retrieves detailed information about a specific task func (g *GitLabProvider) GetTaskDetails(projectID int, taskNumber int) (*repository.Task, error) { endpoint := fmt.Sprintf("/projects/%s/issues/%d", g.projectID, taskNumber) resp, err := g.makeRequest("GET", endpoint, nil) if err != nil { return nil, fmt.Errorf("failed to get issue: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("issue not found") } var issue GitLabIssue if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { return nil, fmt.Errorf("failed to decode issue: %w", err) } return g.issueToTask(&issue), nil } // ListAvailableTasks lists all available (unassigned) tasks func (g *GitLabProvider) ListAvailableTasks(projectID int) ([]*repository.Task, error) { // Get open issues without assignees params := url.Values{} params.Add("state", "opened") params.Add("assignee_id", "None") // GitLab filter for unassigned issues params.Add("sort", "created_desc") params.Add("per_page", "100") if g.config.TaskLabel != "" { params.Add("labels", g.config.TaskLabel) } endpoint := fmt.Sprintf("/projects/%s/issues?%s", g.projectID, params.Encode()) resp, err := g.makeRequest("GET", endpoint, nil) if err != nil { return nil, fmt.Errorf("failed to get available issues: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) } var issues []GitLabIssue if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { return nil, fmt.Errorf("failed to decode issues: %w", err) } // Convert to tasks tasks := make([]*repository.Task, 0, len(issues)) for _, issue := range issues { // Double-check that issue is truly unassigned if issue.Assignee != nil || len(issue.Assignees) > 0 { continue } task := g.issueToTask(&issue) tasks = append(tasks, task) } return tasks, nil } // Helper methods // issueToTask converts a GitLab issue to a repository Task func (g *GitLabProvider) issueToTask(issue *GitLabIssue) *repository.Task { // Calculate priority and complexity based on labels and content priority := g.calculatePriority(issue.Labels, issue.Title, issue.Description) complexity := g.calculateComplexity(issue.Labels, issue.Title, issue.Description) // Determine required role and expertise from labels requiredRole := g.determineRequiredRole(issue.Labels) requiredExpertise := g.determineRequiredExpertise(issue.Labels) // Extract project name from projectID repositoryName := strings.Replace(g.projectID, "%2F", "/", -1) // URL decode return &repository.Task{ Number: issue.IID, // Use IID (project-specific ID) not global ID Title: issue.Title, Body: issue.Description, Repository: repositoryName, Labels: issue.Labels, Priority: priority, Complexity: complexity, Status: issue.State, CreatedAt: issue.CreatedAt, UpdatedAt: issue.UpdatedAt, RequiredRole: requiredRole, RequiredExpertise: requiredExpertise, Metadata: map[string]interface{}{ "gitlab_id": issue.ID, "gitlab_iid": issue.IID, "provider": "gitlab", "project_id": issue.ProjectID, "web_url": issue.WebURL, "assignee": issue.Assignee, "assignees": issue.Assignees, "author": issue.Author, "time_stats": issue.TimeStats, }, } } // calculatePriority determines task priority from labels and content func (g *GitLabProvider) calculatePriority(labels []string, title, body string) int { priority := 5 // default for _, label := range labels { labelLower := strings.ToLower(label) switch { case strings.Contains(labelLower, "priority") && strings.Contains(labelLower, "critical"): priority = 10 case strings.Contains(labelLower, "priority") && strings.Contains(labelLower, "high"): priority = 8 case strings.Contains(labelLower, "priority") && strings.Contains(labelLower, "medium"): priority = 5 case strings.Contains(labelLower, "priority") && strings.Contains(labelLower, "low"): priority = 2 case labelLower == "critical" || labelLower == "urgent": priority = 10 case labelLower == "high": priority = 8 case labelLower == "bug" || labelLower == "security" || labelLower == "hotfix": priority = max(priority, 7) case labelLower == "enhancement" || labelLower == "feature": priority = max(priority, 5) case strings.Contains(labelLower, "milestone"): priority = max(priority, 6) } } // Boost priority for urgent keywords in title titleLower := strings.ToLower(title) urgentKeywords := []string{"urgent", "critical", "hotfix", "security", "broken", "crash", "blocker"} for _, keyword := range urgentKeywords { if strings.Contains(titleLower, keyword) { priority = max(priority, 8) break } } return priority } // calculateComplexity estimates task complexity from labels and content func (g *GitLabProvider) calculateComplexity(labels []string, title, body string) int { complexity := 3 // default for _, label := range labels { labelLower := strings.ToLower(label) switch { case strings.Contains(labelLower, "complexity") && strings.Contains(labelLower, "high"): complexity = 8 case strings.Contains(labelLower, "complexity") && strings.Contains(labelLower, "medium"): complexity = 5 case strings.Contains(labelLower, "complexity") && strings.Contains(labelLower, "low"): complexity = 2 case labelLower == "epic" || labelLower == "major": complexity = 8 case labelLower == "refactor" || labelLower == "architecture": complexity = max(complexity, 7) case labelLower == "bug" || labelLower == "hotfix": complexity = max(complexity, 4) case labelLower == "enhancement" || labelLower == "feature": complexity = max(complexity, 5) case strings.Contains(labelLower, "beginner") || strings.Contains(labelLower, "newcomer"): complexity = 2 case labelLower == "documentation" || labelLower == "docs": complexity = max(complexity, 3) } } // Estimate complexity from body length and content bodyLength := len(strings.Fields(body)) if bodyLength > 500 { complexity = max(complexity, 7) } else if bodyLength > 200 { complexity = max(complexity, 5) } else if bodyLength > 50 { complexity = max(complexity, 4) } // Look for complexity indicators in content bodyLower := strings.ToLower(body) complexityIndicators := []string{ "refactor", "architecture", "breaking change", "migration", "redesign", "database schema", "api changes", "infrastructure", } for _, indicator := range complexityIndicators { if strings.Contains(bodyLower, indicator) { complexity = max(complexity, 7) break } } return complexity } // determineRequiredRole determines what agent role is needed for this task func (g *GitLabProvider) determineRequiredRole(labels []string) string { roleKeywords := map[string]string{ // Frontend "frontend": "frontend-developer", "ui": "frontend-developer", "ux": "ui-ux-designer", "css": "frontend-developer", "html": "frontend-developer", "javascript": "frontend-developer", "react": "frontend-developer", "vue": "frontend-developer", "angular": "frontend-developer", // Backend "backend": "backend-developer", "api": "backend-developer", "server": "backend-developer", "database": "backend-developer", "sql": "backend-developer", // DevOps "devops": "devops-engineer", "infrastructure": "devops-engineer", "deployment": "devops-engineer", "docker": "devops-engineer", "kubernetes": "devops-engineer", "ci/cd": "devops-engineer", "pipeline": "devops-engineer", // Security "security": "security-engineer", "authentication": "security-engineer", "authorization": "security-engineer", "vulnerability": "security-engineer", // Testing "testing": "tester", "qa": "tester", "test": "tester", // Documentation "documentation": "technical-writer", "docs": "technical-writer", // Design "design": "ui-ux-designer", "mockup": "ui-ux-designer", "wireframe": "ui-ux-designer", } for _, label := range labels { labelLower := strings.ToLower(label) for keyword, role := range roleKeywords { if strings.Contains(labelLower, keyword) { return role } } } return "developer" // default role } // determineRequiredExpertise determines what expertise is needed func (g *GitLabProvider) determineRequiredExpertise(labels []string) []string { expertise := make([]string, 0) expertiseMap := make(map[string]bool) // prevent duplicates expertiseKeywords := map[string][]string{ // Programming languages "go": {"go", "golang"}, "python": {"python"}, "javascript": {"javascript", "js"}, "typescript": {"typescript", "ts"}, "java": {"java"}, "rust": {"rust"}, "c++": {"c++", "cpp"}, "c#": {"c#", "csharp"}, "php": {"php"}, "ruby": {"ruby"}, // Frontend technologies "react": {"react"}, "vue": {"vue", "vuejs"}, "angular": {"angular"}, "svelte": {"svelte"}, // Backend frameworks "nodejs": {"nodejs", "node.js", "node"}, "django": {"django"}, "flask": {"flask"}, "spring": {"spring"}, "express": {"express"}, // Databases "postgresql": {"postgresql", "postgres"}, "mysql": {"mysql"}, "mongodb": {"mongodb", "mongo"}, "redis": {"redis"}, // DevOps tools "docker": {"docker"}, "kubernetes": {"kubernetes", "k8s"}, "aws": {"aws"}, "azure": {"azure"}, "gcp": {"gcp", "google cloud"}, "gitlab-ci": {"gitlab-ci", "ci/cd"}, // Other technologies "graphql": {"graphql"}, "rest": {"rest", "restful"}, "grpc": {"grpc"}, } for _, label := range labels { labelLower := strings.ToLower(label) for expertiseArea, keywords := range expertiseKeywords { for _, keyword := range keywords { if strings.Contains(labelLower, keyword) && !expertiseMap[expertiseArea] { expertise = append(expertise, expertiseArea) expertiseMap[expertiseArea] = true break } } } } // Default expertise if none detected if len(expertise) == 0 { expertise = []string{"development", "programming"} } return expertise } // addLabelToIssue adds a label to an issue func (g *GitLabProvider) addLabelToIssue(issueNumber int, labelName string) error { // First get the current labels endpoint := fmt.Sprintf("/projects/%s/issues/%d", g.projectID, issueNumber) resp, err := g.makeRequest("GET", endpoint, nil) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("failed to get current issue labels") } var issue GitLabIssue if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { return fmt.Errorf("failed to decode issue: %w", err) } // Add new label to existing labels labels := append(issue.Labels, labelName) // Update the issue with new labels body := map[string]interface{}{ "labels": strings.Join(labels, ","), } resp, err = g.makeRequest("PUT", endpoint, body) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("failed to add label (status %d): %s", resp.StatusCode, string(respBody)) } return nil } // removeLabelFromIssue removes a label from an issue func (g *GitLabProvider) removeLabelFromIssue(issueNumber int, labelName string) error { // First get the current labels endpoint := fmt.Sprintf("/projects/%s/issues/%d", g.projectID, issueNumber) resp, err := g.makeRequest("GET", endpoint, nil) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("failed to get current issue labels") } var issue GitLabIssue if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { return fmt.Errorf("failed to decode issue: %w", err) } // Remove the specified label var newLabels []string for _, label := range issue.Labels { if label != labelName { newLabels = append(newLabels, label) } } // Update the issue with new labels body := map[string]interface{}{ "labels": strings.Join(newLabels, ","), } resp, err = g.makeRequest("PUT", endpoint, body) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("failed to remove label (status %d): %s", resp.StatusCode, string(respBody)) } return nil } // addNoteToIssue adds a note (comment) to an issue func (g *GitLabProvider) addNoteToIssue(issueNumber int, note string) error { endpoint := fmt.Sprintf("/projects/%s/issues/%d/notes", g.projectID, issueNumber) body := map[string]interface{}{ "body": note, } resp, err := g.makeRequest("POST", endpoint, body) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("failed to add note (status %d): %s", resp.StatusCode, string(respBody)) } return nil } // closeIssue closes an issue func (g *GitLabProvider) closeIssue(issueNumber int) error { endpoint := fmt.Sprintf("/projects/%s/issues/%d", g.projectID, issueNumber) body := map[string]interface{}{ "state_event": "close", } resp, err := g.makeRequest("PUT", endpoint, body) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("failed to close issue (status %d): %s", resp.StatusCode, string(respBody)) } return nil }