package providers import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "chorus/pkg/repository" ) // GiteaProvider implements TaskProvider for Gitea API type GiteaProvider struct { config *repository.Config httpClient *http.Client baseURL string token string owner string repo string } // NewGiteaProvider creates a new Gitea provider func NewGiteaProvider(config *repository.Config) (*GiteaProvider, error) { if config.BaseURL == "" { return nil, fmt.Errorf("base URL is required for Gitea provider") } if config.AccessToken == "" { return nil, fmt.Errorf("access token is required for Gitea provider") } if config.Owner == "" { return nil, fmt.Errorf("owner is required for Gitea provider") } if config.Repository == "" { return nil, fmt.Errorf("repository name is required for Gitea provider") } // Ensure base URL has proper format baseURL := strings.TrimSuffix(config.BaseURL, "/") if !strings.HasPrefix(baseURL, "http") { baseURL = "https://" + baseURL } return &GiteaProvider{ config: config, baseURL: baseURL, token: config.AccessToken, owner: config.Owner, repo: config.Repository, httpClient: &http.Client{ Timeout: 30 * time.Second, }, }, nil } // GiteaIssue represents a Gitea issue type GiteaIssue struct { ID int64 `json:"id"` Number int `json:"number"` Title string `json:"title"` Body string `json:"body"` State string `json:"state"` Labels []GiteaLabel `json:"labels"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Repository *GiteaRepository `json:"repository"` Assignee *GiteaUser `json:"assignee"` Assignees []GiteaUser `json:"assignees"` } // GiteaLabel represents a Gitea label type GiteaLabel struct { ID int64 `json:"id"` Name string `json:"name"` Color string `json:"color"` } // GiteaRepository represents a Gitea repository type GiteaRepository struct { ID int64 `json:"id"` Name string `json:"name"` FullName string `json:"full_name"` Owner *GiteaUser `json:"owner"` } // GiteaUser represents a Gitea user type GiteaUser struct { ID int64 `json:"id"` Username string `json:"username"` FullName string `json:"full_name"` Email string `json:"email"` } // GiteaComment represents a Gitea issue comment type GiteaComment struct { ID int64 `json:"id"` Body string `json:"body"` CreatedAt time.Time `json:"created_at"` User *GiteaUser `json:"user"` } // makeRequest makes an HTTP request to the Gitea API func (g *GiteaProvider) 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/v1%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("Authorization", "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 Gitea repository func (g *GiteaProvider) GetTasks(projectID int) ([]*repository.Task, error) { // Build query parameters params := url.Values{} params.Add("state", "open") params.Add("type", "issues") params.Add("sort", "created") params.Add("order", "desc") // Add task label filter if specified if g.config.TaskLabel != "" { params.Add("labels", g.config.TaskLabel) } endpoint := fmt.Sprintf("/repos/%s/%s/issues?%s", g.owner, g.repo, 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 []GiteaIssue if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { return nil, fmt.Errorf("failed to decode issues: %w", err) } // Convert Gitea 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 *GiteaProvider) ClaimTask(taskNumber int, agentID string) (bool, error) { // First, get the current issue to check its state endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d", g.owner, g.repo, 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 GiteaIssue 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 { return false, fmt.Errorf("issue is already assigned to %s", issue.Assignee.Username) } // 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 comment indicating the task has been claimed comment := fmt.Sprintf("🤖 Task claimed by CHORUS agent `%s`\n\nThis task is now being processed automatically.", agentID) err = g.addCommentToIssue(taskNumber, comment) if err != nil { // Don't fail the claim if comment fails fmt.Printf("Warning: failed to add claim comment: %v\n", err) } return true, nil } // UpdateTaskStatus updates the status of a task func (g *GiteaProvider) UpdateTaskStatus(task *repository.Task, status string, comment string) error { // Add a comment with the status update statusComment := fmt.Sprintf("**Status Update:** %s\n\n%s", status, comment) err := g.addCommentToIssue(task.Number, statusComment) if err != nil { return fmt.Errorf("failed to add status comment: %w", err) } return nil } // CompleteTask completes a task by updating status and adding completion comment func (g *GiteaProvider) CompleteTask(task *repository.Task, result *repository.TaskResult) error { // Create completion comment with results var commentBuffer strings.Builder commentBuffer.WriteString(fmt.Sprintf("✅ **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") for key, value := range result.Metadata { commentBuffer.WriteString(fmt.Sprintf("- **%s:** %v\n", key, value)) } commentBuffer.WriteString("\n") } commentBuffer.WriteString("🤖 Completed by CHORUS autonomous agent") // Add completion comment err := g.addCommentToIssue(task.Number, commentBuffer.String()) if err != nil { return fmt.Errorf("failed to add completion comment: %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 *GiteaProvider) GetTaskDetails(projectID int, taskNumber int) (*repository.Task, error) { endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d", g.owner, g.repo, 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 GiteaIssue 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 *GiteaProvider) ListAvailableTasks(projectID int) ([]*repository.Task, error) { // Get all open issues without assignees params := url.Values{} params.Add("state", "open") params.Add("type", "issues") params.Add("assigned", "false") // Only unassigned issues if g.config.TaskLabel != "" { params.Add("labels", g.config.TaskLabel) } endpoint := fmt.Sprintf("/repos/%s/%s/issues?%s", g.owner, g.repo, 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 []GiteaIssue if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { return nil, fmt.Errorf("failed to decode issues: %w", err) } // Convert to tasks and filter out assigned ones tasks := make([]*repository.Task, 0, len(issues)) for _, issue := range issues { // Skip assigned issues 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 Gitea issue to a repository Task func (g *GiteaProvider) issueToTask(issue *GiteaIssue) *repository.Task { // Extract labels labels := make([]string, len(issue.Labels)) for i, label := range issue.Labels { labels[i] = label.Name } // Calculate priority and complexity based on labels and content priority := g.calculatePriority(labels, issue.Title, issue.Body) complexity := g.calculateComplexity(labels, issue.Title, issue.Body) // Determine required role and expertise from labels requiredRole := g.determineRequiredRole(labels) requiredExpertise := g.determineRequiredExpertise(labels) return &repository.Task{ Number: issue.Number, Title: issue.Title, Body: issue.Body, Repository: fmt.Sprintf("%s/%s", g.owner, g.repo), Labels: labels, Priority: priority, Complexity: complexity, Status: issue.State, CreatedAt: issue.CreatedAt, UpdatedAt: issue.UpdatedAt, RequiredRole: requiredRole, RequiredExpertise: requiredExpertise, Metadata: map[string]interface{}{ "gitea_id": issue.ID, "provider": "gitea", "repository": issue.Repository, "assignee": issue.Assignee, "assignees": issue.Assignees, }, } } // calculatePriority determines task priority from labels and content func (g *GiteaProvider) calculatePriority(labels []string, title, body string) int { priority := 5 // default for _, label := range labels { switch strings.ToLower(label) { case "priority:critical", "critical", "urgent": priority = 10 case "priority:high", "high": priority = 8 case "priority:medium", "medium": priority = 5 case "priority:low", "low": priority = 2 case "bug", "security", "hotfix": priority = max(priority, 7) } } // Boost priority for urgent keywords in title titleLower := strings.ToLower(title) if strings.Contains(titleLower, "urgent") || strings.Contains(titleLower, "critical") || strings.Contains(titleLower, "hotfix") || strings.Contains(titleLower, "security") { priority = max(priority, 8) } return priority } // calculateComplexity estimates task complexity from labels and content func (g *GiteaProvider) calculateComplexity(labels []string, title, body string) int { complexity := 3 // default for _, label := range labels { switch strings.ToLower(label) { case "complexity:high", "epic", "major": complexity = 8 case "complexity:medium": complexity = 5 case "complexity:low", "simple", "trivial": complexity = 2 case "refactor", "architecture": complexity = max(complexity, 7) case "bug", "hotfix": complexity = max(complexity, 4) case "enhancement", "feature": complexity = max(complexity, 5) } } // Estimate complexity from body length bodyLength := len(strings.Fields(body)) if bodyLength > 200 { complexity = max(complexity, 6) } else if bodyLength > 50 { complexity = max(complexity, 4) } return complexity } // determineRequiredRole determines what agent role is needed for this task func (g *GiteaProvider) determineRequiredRole(labels []string) string { for _, label := range labels { switch strings.ToLower(label) { case "frontend", "ui", "ux", "css", "html", "javascript", "react", "vue": return "frontend-developer" case "backend", "api", "server", "database", "sql": return "backend-developer" case "devops", "infrastructure", "deployment", "docker", "kubernetes": return "devops-engineer" case "security", "authentication", "authorization": return "security-engineer" case "testing", "qa", "quality": return "tester" case "documentation", "docs": return "technical-writer" case "design", "mockup", "wireframe": return "designer" } } return "developer" // default role } // determineRequiredExpertise determines what expertise is needed func (g *GiteaProvider) determineRequiredExpertise(labels []string) []string { expertise := make([]string, 0) expertiseMap := make(map[string]bool) // prevent duplicates for _, label := range labels { labelLower := strings.ToLower(label) // Programming languages languages := []string{"go", "python", "javascript", "typescript", "java", "rust", "c++", "php"} for _, lang := range languages { if strings.Contains(labelLower, lang) { if !expertiseMap[lang] { expertise = append(expertise, lang) expertiseMap[lang] = true } } } // Technologies and frameworks technologies := []string{"docker", "kubernetes", "react", "vue", "angular", "nodejs", "django", "flask", "spring"} for _, tech := range technologies { if strings.Contains(labelLower, tech) { if !expertiseMap[tech] { expertise = append(expertise, tech) expertiseMap[tech] = true } } } // Domain areas domains := []string{"frontend", "backend", "database", "security", "testing", "devops", "api"} for _, domain := range domains { if strings.Contains(labelLower, domain) { if !expertiseMap[domain] { expertise = append(expertise, domain) expertiseMap[domain] = true } } } } // Default expertise if none detected if len(expertise) == 0 { expertise = []string{"development", "programming"} } return expertise } // addLabelToIssue adds a label to an issue func (g *GiteaProvider) addLabelToIssue(issueNumber int, labelName string) error { endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d/labels", g.owner, g.repo, issueNumber) body := map[string]interface{}{ "labels": []string{labelName}, } resp, err := g.makeRequest("POST", endpoint, body) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 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 *GiteaProvider) removeLabelFromIssue(issueNumber int, labelName string) error { endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d/labels/%s", g.owner, g.repo, issueNumber, url.QueryEscape(labelName)) resp, err := g.makeRequest("DELETE", endpoint, nil) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent && 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 } // addCommentToIssue adds a comment to an issue func (g *GiteaProvider) addCommentToIssue(issueNumber int, comment string) error { endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", g.owner, g.repo, issueNumber) body := map[string]interface{}{ "body": comment, } 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 comment (status %d): %s", resp.StatusCode, string(respBody)) } return nil } // closeIssue closes an issue func (g *GiteaProvider) closeIssue(issueNumber int) error { endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d", g.owner, g.repo, issueNumber) body := map[string]interface{}{ "state": "closed", } resp, err := g.makeRequest("PATCH", endpoint, body) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated && 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 } // max returns the maximum of two integers func max(a, b int) int { if a > b { return a } return b }