diff --git a/Makefile b/Makefile index 72cca9a..abdf727 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ BINARY_NAME_AGENT = chorus-agent BINARY_NAME_HAP = chorus-hap BINARY_NAME_COMPAT = chorus -VERSION ?= 0.4.0 +VERSION ?= 0.5.0 COMMIT_HASH ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") BUILD_DATE ?= $(shell date -u '+%Y-%m-%d_%H:%M:%S') diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go new file mode 100644 index 0000000..7b64945 --- /dev/null +++ b/pkg/providers/factory.go @@ -0,0 +1,261 @@ +package providers + +import ( + "fmt" + "strings" + + "chorus/pkg/repository" +) + +// ProviderFactory creates task providers for different repository types +type ProviderFactory struct { + supportedProviders map[string]ProviderCreator +} + +// ProviderCreator is a function that creates a provider from config +type ProviderCreator func(config *repository.Config) (repository.TaskProvider, error) + +// NewProviderFactory creates a new provider factory with all supported providers +func NewProviderFactory() *ProviderFactory { + factory := &ProviderFactory{ + supportedProviders: make(map[string]ProviderCreator), + } + + // Register all supported providers + factory.RegisterProvider("gitea", func(config *repository.Config) (repository.TaskProvider, error) { + return NewGiteaProvider(config) + }) + + factory.RegisterProvider("github", func(config *repository.Config) (repository.TaskProvider, error) { + return NewGitHubProvider(config) + }) + + factory.RegisterProvider("gitlab", func(config *repository.Config) (repository.TaskProvider, error) { + return NewGitLabProvider(config) + }) + + factory.RegisterProvider("mock", func(config *repository.Config) (repository.TaskProvider, error) { + return &repository.MockTaskProvider{}, nil + }) + + return factory +} + +// RegisterProvider registers a new provider creator +func (f *ProviderFactory) RegisterProvider(providerType string, creator ProviderCreator) { + f.supportedProviders[strings.ToLower(providerType)] = creator +} + +// CreateProvider creates a task provider based on the configuration +func (f *ProviderFactory) CreateProvider(ctx interface{}, config *repository.Config) (repository.TaskProvider, error) { + if config == nil { + return nil, fmt.Errorf("configuration cannot be nil") + } + + providerType := strings.ToLower(config.Provider) + if providerType == "" { + // Fall back to Type field if Provider is not set + providerType = strings.ToLower(config.Type) + } + + if providerType == "" { + return nil, fmt.Errorf("provider type must be specified in config.Provider or config.Type") + } + + creator, exists := f.supportedProviders[providerType] + if !exists { + return nil, fmt.Errorf("unsupported provider type: %s. Supported types: %v", + providerType, f.GetSupportedTypes()) + } + + provider, err := creator(config) + if err != nil { + return nil, fmt.Errorf("failed to create %s provider: %w", providerType, err) + } + + return provider, nil +} + +// GetSupportedTypes returns a list of all supported provider types +func (f *ProviderFactory) GetSupportedTypes() []string { + types := make([]string, 0, len(f.supportedProviders)) + for providerType := range f.supportedProviders { + types = append(types, providerType) + } + return types +} + +// SupportedProviders returns list of supported providers (alias for GetSupportedTypes) +func (f *ProviderFactory) SupportedProviders() []string { + return f.GetSupportedTypes() +} + +// ValidateConfig validates a provider configuration +func (f *ProviderFactory) ValidateConfig(config *repository.Config) error { + if config == nil { + return fmt.Errorf("configuration cannot be nil") + } + + providerType := strings.ToLower(config.Provider) + if providerType == "" { + providerType = strings.ToLower(config.Type) + } + + if providerType == "" { + return fmt.Errorf("provider type must be specified") + } + + // Check if provider type is supported + if _, exists := f.supportedProviders[providerType]; !exists { + return fmt.Errorf("unsupported provider type: %s", providerType) + } + + // Provider-specific validation + switch providerType { + case "gitea": + return f.validateGiteaConfig(config) + case "github": + return f.validateGitHubConfig(config) + case "gitlab": + return f.validateGitLabConfig(config) + case "mock": + return nil // Mock provider doesn't need validation + default: + return fmt.Errorf("validation not implemented for provider type: %s", providerType) + } +} + +// validateGiteaConfig validates Gitea-specific configuration +func (f *ProviderFactory) validateGiteaConfig(config *repository.Config) error { + if config.BaseURL == "" { + return fmt.Errorf("baseURL is required for Gitea provider") + } + if config.AccessToken == "" { + return fmt.Errorf("accessToken is required for Gitea provider") + } + if config.Owner == "" { + return fmt.Errorf("owner is required for Gitea provider") + } + if config.Repository == "" { + return fmt.Errorf("repository is required for Gitea provider") + } + return nil +} + +// validateGitHubConfig validates GitHub-specific configuration +func (f *ProviderFactory) validateGitHubConfig(config *repository.Config) error { + if config.AccessToken == "" { + return fmt.Errorf("accessToken is required for GitHub provider") + } + if config.Owner == "" { + return fmt.Errorf("owner is required for GitHub provider") + } + if config.Repository == "" { + return fmt.Errorf("repository is required for GitHub provider") + } + return nil +} + +// validateGitLabConfig validates GitLab-specific configuration +func (f *ProviderFactory) validateGitLabConfig(config *repository.Config) error { + if config.AccessToken == "" { + return fmt.Errorf("accessToken is required for GitLab provider") + } + + // GitLab requires either owner/repository or project_id in settings + if config.Owner != "" && config.Repository != "" { + return nil // owner/repo provided + } + + if config.Settings != nil { + if projectID, ok := config.Settings["project_id"].(string); ok && projectID != "" { + return nil // project_id provided + } + } + + return fmt.Errorf("either owner/repository or project_id in settings is required for GitLab provider") +} + +// GetProviderInfo returns information about a specific provider +func (f *ProviderFactory) GetProviderInfo(providerType string) (*ProviderInfo, error) { + providerType = strings.ToLower(providerType) + + if _, exists := f.supportedProviders[providerType]; !exists { + return nil, fmt.Errorf("unsupported provider type: %s", providerType) + } + + switch providerType { + case "gitea": + return &ProviderInfo{ + Name: "Gitea", + Type: "gitea", + Description: "Gitea self-hosted Git service provider", + RequiredFields: []string{"baseURL", "accessToken", "owner", "repository"}, + OptionalFields: []string{"taskLabel", "inProgressLabel", "completedLabel", "baseBranch", "branchPrefix"}, + SupportedFeatures: []string{"issues", "labels", "comments", "assignments"}, + APIDocumentation: "https://docs.gitea.io/en-us/api-usage/", + }, nil + + case "github": + return &ProviderInfo{ + Name: "GitHub", + Type: "github", + Description: "GitHub cloud and enterprise Git service provider", + RequiredFields: []string{"accessToken", "owner", "repository"}, + OptionalFields: []string{"taskLabel", "inProgressLabel", "completedLabel", "baseBranch", "branchPrefix"}, + SupportedFeatures: []string{"issues", "labels", "comments", "assignments", "projects"}, + APIDocumentation: "https://docs.github.com/en/rest", + }, nil + + case "gitlab": + return &ProviderInfo{ + Name: "GitLab", + Type: "gitlab", + Description: "GitLab cloud and self-hosted Git service provider", + RequiredFields: []string{"accessToken", "owner/repository OR project_id"}, + OptionalFields: []string{"baseURL", "taskLabel", "inProgressLabel", "completedLabel", "baseBranch", "branchPrefix"}, + SupportedFeatures: []string{"issues", "labels", "notes", "assignments", "time_tracking", "milestones"}, + APIDocumentation: "https://docs.gitlab.com/ee/api/", + }, nil + + case "mock": + return &ProviderInfo{ + Name: "Mock Provider", + Type: "mock", + Description: "Mock provider for testing and development", + RequiredFields: []string{}, + OptionalFields: []string{}, + SupportedFeatures: []string{"basic_operations"}, + APIDocumentation: "Built-in mock for testing purposes", + }, nil + + default: + return nil, fmt.Errorf("provider info not available for: %s", providerType) + } +} + +// ProviderInfo contains metadata about a provider +type ProviderInfo struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + RequiredFields []string `json:"required_fields"` + OptionalFields []string `json:"optional_fields"` + SupportedFeatures []string `json:"supported_features"` + APIDocumentation string `json:"api_documentation"` +} + +// ListProviders returns detailed information about all supported providers +func (f *ProviderFactory) ListProviders() ([]*ProviderInfo, error) { + providers := make([]*ProviderInfo, 0, len(f.supportedProviders)) + + for providerType := range f.supportedProviders { + info, err := f.GetProviderInfo(providerType) + if err != nil { + continue // Skip providers without info + } + providers = append(providers, info) + } + + return providers, nil +} \ No newline at end of file diff --git a/pkg/providers/gitea.go b/pkg/providers/gitea.go new file mode 100644 index 0000000..aa78e3b --- /dev/null +++ b/pkg/providers/gitea.go @@ -0,0 +1,617 @@ +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 +} \ No newline at end of file diff --git a/pkg/providers/github.go b/pkg/providers/github.go new file mode 100644 index 0000000..542be7b --- /dev/null +++ b/pkg/providers/github.go @@ -0,0 +1,732 @@ +package providers + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "chorus/pkg/repository" +) + +// GitHubProvider implements TaskProvider for GitHub API +type GitHubProvider struct { + config *repository.Config + httpClient *http.Client + token string + owner string + repo string +} + +// NewGitHubProvider creates a new GitHub provider +func NewGitHubProvider(config *repository.Config) (*GitHubProvider, error) { + if config.AccessToken == "" { + return nil, fmt.Errorf("access token is required for GitHub provider") + } + if config.Owner == "" { + return nil, fmt.Errorf("owner is required for GitHub provider") + } + if config.Repository == "" { + return nil, fmt.Errorf("repository name is required for GitHub provider") + } + + return &GitHubProvider{ + config: config, + token: config.AccessToken, + owner: config.Owner, + repo: config.Repository, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + }, nil +} + +// GitHubIssue represents a GitHub issue +type GitHubIssue struct { + ID int64 `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Labels []GitHubLabel `json:"labels"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Repository *GitHubRepository `json:"repository,omitempty"` + Assignee *GitHubUser `json:"assignee"` + Assignees []GitHubUser `json:"assignees"` + User *GitHubUser `json:"user"` + PullRequest *GitHubPullRequestRef `json:"pull_request,omitempty"` +} + +// GitHubLabel represents a GitHub label +type GitHubLabel struct { + ID int64 `json:"id"` + Name string `json:"name"` + Color string `json:"color"` +} + +// GitHubRepository represents a GitHub repository +type GitHubRepository struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Owner *GitHubUser `json:"owner"` +} + +// GitHubUser represents a GitHub user +type GitHubUser struct { + ID int64 `json:"id"` + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` +} + +// GitHubPullRequestRef indicates if issue is a PR +type GitHubPullRequestRef struct { + URL string `json:"url"` +} + +// GitHubComment represents a GitHub issue comment +type GitHubComment struct { + ID int64 `json:"id"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` + User *GitHubUser `json:"user"` +} + +// makeRequest makes an HTTP request to the GitHub API +func (g *GitHubProvider) 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("https://api.github.com%s", 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/vnd.github.v3+json") + req.Header.Set("User-Agent", "CHORUS-Agent/1.0") + + 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 GitHub repository +func (g *GitHubProvider) GetTasks(projectID int) ([]*repository.Task, error) { + // Build query parameters + params := url.Values{} + params.Add("state", "open") + params.Add("sort", "created") + params.Add("direction", "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 []GitHubIssue + if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { + return nil, fmt.Errorf("failed to decode issues: %w", err) + } + + // Filter out pull requests (GitHub API includes PRs in issues endpoint) + tasks := make([]*repository.Task, 0, len(issues)) + for _, issue := range issues { + // Skip pull requests + if issue.PullRequest != nil { + continue + } + + 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 *GitHubProvider) 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 GitHubIssue + 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.Login + } else if len(issue.Assignees) > 0 { + assigneeName = issue.Assignees[0].Login + } + 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 comment 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.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 *GitHubProvider) 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\n\n---\n*Updated by CHORUS Agent*", 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 *GitHubProvider) 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 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 *GitHubProvider) 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 GitHubIssue + if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { + return nil, fmt.Errorf("failed to decode issue: %w", err) + } + + // Skip pull requests + if issue.PullRequest != nil { + return nil, fmt.Errorf("pull requests are not supported as tasks") + } + + return g.issueToTask(&issue), nil +} + +// ListAvailableTasks lists all available (unassigned) tasks +func (g *GitHubProvider) ListAvailableTasks(projectID int) ([]*repository.Task, error) { + // GitHub doesn't have a direct "unassigned" filter, so we get open issues and filter + params := url.Values{} + params.Add("state", "open") + params.Add("sort", "created") + params.Add("direction", "desc") + + 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 []GitHubIssue + if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { + return nil, fmt.Errorf("failed to decode issues: %w", err) + } + + // Filter out assigned issues and PRs + tasks := make([]*repository.Task, 0, len(issues)) + for _, issue := range issues { + // Skip pull requests + if issue.PullRequest != nil { + continue + } + + // 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 GitHub issue to a repository Task +func (g *GitHubProvider) issueToTask(issue *GitHubIssue) *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{}{ + "github_id": issue.ID, + "provider": "github", + "repository": issue.Repository, + "assignee": issue.Assignee, + "assignees": issue.Assignees, + "user": issue.User, + }, + } +} + +// calculatePriority determines task priority from labels and content +func (g *GitHubProvider) 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 labelLower == "good first issue": + priority = max(priority, 3) + } + } + + // Boost priority for urgent keywords in title + titleLower := strings.ToLower(title) + urgentKeywords := []string{"urgent", "critical", "hotfix", "security", "broken", "crash"} + 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 *GitHubProvider) 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 labelLower == "good first issue" || labelLower == "beginner": + 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"} + 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 *GitHubProvider) 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", + + // 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 *GitHubProvider) 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"}, + + // 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 *GitHubProvider) addLabelToIssue(issueNumber int, labelName string) error { + endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d/labels", g.owner, g.repo, issueNumber) + + body := []string{labelName} + + resp, err := g.makeRequest("POST", 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 *GitHubProvider) 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.StatusOK && resp.StatusCode != http.StatusNoContent { + 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 *GitHubProvider) 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 *GitHubProvider) 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.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to close issue (status %d): %s", resp.StatusCode, string(respBody)) + } + + return nil +} \ No newline at end of file diff --git a/pkg/providers/gitlab.go b/pkg/providers/gitlab.go new file mode 100644 index 0000000..398abd5 --- /dev/null +++ b/pkg/providers/gitlab.go @@ -0,0 +1,781 @@ +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 +} \ No newline at end of file diff --git a/pkg/providers/provider_test.go b/pkg/providers/provider_test.go new file mode 100644 index 0000000..0f06c82 --- /dev/null +++ b/pkg/providers/provider_test.go @@ -0,0 +1,698 @@ +package providers + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "chorus/pkg/repository" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test Gitea Provider +func TestGiteaProvider_NewGiteaProvider(t *testing.T) { + tests := []struct { + name string + config *repository.Config + expectError bool + errorMsg string + }{ + { + name: "valid config", + config: &repository.Config{ + BaseURL: "https://gitea.example.com", + AccessToken: "test-token", + Owner: "testowner", + Repository: "testrepo", + }, + expectError: false, + }, + { + name: "missing base URL", + config: &repository.Config{ + AccessToken: "test-token", + Owner: "testowner", + Repository: "testrepo", + }, + expectError: true, + errorMsg: "base URL is required", + }, + { + name: "missing access token", + config: &repository.Config{ + BaseURL: "https://gitea.example.com", + Owner: "testowner", + Repository: "testrepo", + }, + expectError: true, + errorMsg: "access token is required", + }, + { + name: "missing owner", + config: &repository.Config{ + BaseURL: "https://gitea.example.com", + AccessToken: "test-token", + Repository: "testrepo", + }, + expectError: true, + errorMsg: "owner is required", + }, + { + name: "missing repository", + config: &repository.Config{ + BaseURL: "https://gitea.example.com", + AccessToken: "test-token", + Owner: "testowner", + }, + expectError: true, + errorMsg: "repository name is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, err := NewGiteaProvider(tt.config) + + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + assert.Nil(t, provider) + } else { + assert.NoError(t, err) + assert.NotNil(t, provider) + assert.Equal(t, tt.config.AccessToken, provider.token) + assert.Equal(t, tt.config.Owner, provider.owner) + assert.Equal(t, tt.config.Repository, provider.repo) + } + }) + } +} + +func TestGiteaProvider_GetTasks(t *testing.T) { + // Create a mock Gitea server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Contains(t, r.URL.Path, "/api/v1/repos/testowner/testrepo/issues") + assert.Equal(t, "token test-token", r.Header.Get("Authorization")) + + // Mock response + issues := []map[string]interface{}{ + { + "id": 1, + "number": 42, + "title": "Test Issue 1", + "body": "This is a test issue", + "state": "open", + "labels": []map[string]interface{}{ + {"id": 1, "name": "bug", "color": "d73a4a"}, + }, + "created_at": "2023-01-01T12:00:00Z", + "updated_at": "2023-01-01T12:00:00Z", + "repository": map[string]interface{}{ + "id": 1, + "name": "testrepo", + "full_name": "testowner/testrepo", + }, + "assignee": nil, + "assignees": []interface{}{}, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(issues) + })) + defer server.Close() + + config := &repository.Config{ + BaseURL: server.URL, + AccessToken: "test-token", + Owner: "testowner", + Repository: "testrepo", + } + + provider, err := NewGiteaProvider(config) + require.NoError(t, err) + + tasks, err := provider.GetTasks(1) + require.NoError(t, err) + + assert.Len(t, tasks, 1) + assert.Equal(t, 42, tasks[0].Number) + assert.Equal(t, "Test Issue 1", tasks[0].Title) + assert.Equal(t, "This is a test issue", tasks[0].Body) + assert.Equal(t, "testowner/testrepo", tasks[0].Repository) + assert.Equal(t, []string{"bug"}, tasks[0].Labels) +} + +// Test GitHub Provider +func TestGitHubProvider_NewGitHubProvider(t *testing.T) { + tests := []struct { + name string + config *repository.Config + expectError bool + errorMsg string + }{ + { + name: "valid config", + config: &repository.Config{ + AccessToken: "test-token", + Owner: "testowner", + Repository: "testrepo", + }, + expectError: false, + }, + { + name: "missing access token", + config: &repository.Config{ + Owner: "testowner", + Repository: "testrepo", + }, + expectError: true, + errorMsg: "access token is required", + }, + { + name: "missing owner", + config: &repository.Config{ + AccessToken: "test-token", + Repository: "testrepo", + }, + expectError: true, + errorMsg: "owner is required", + }, + { + name: "missing repository", + config: &repository.Config{ + AccessToken: "test-token", + Owner: "testowner", + }, + expectError: true, + errorMsg: "repository name is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, err := NewGitHubProvider(tt.config) + + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + assert.Nil(t, provider) + } else { + assert.NoError(t, err) + assert.NotNil(t, provider) + assert.Equal(t, tt.config.AccessToken, provider.token) + assert.Equal(t, tt.config.Owner, provider.owner) + assert.Equal(t, tt.config.Repository, provider.repo) + } + }) + } +} + +func TestGitHubProvider_GetTasks(t *testing.T) { + // Create a mock GitHub server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Contains(t, r.URL.Path, "/repos/testowner/testrepo/issues") + assert.Equal(t, "token test-token", r.Header.Get("Authorization")) + + // Mock response (GitHub API format) + issues := []map[string]interface{}{ + { + "id": 123456789, + "number": 42, + "title": "Test GitHub Issue", + "body": "This is a test GitHub issue", + "state": "open", + "labels": []map[string]interface{}{ + {"id": 1, "name": "enhancement", "color": "a2eeef"}, + }, + "created_at": "2023-01-01T12:00:00Z", + "updated_at": "2023-01-01T12:00:00Z", + "assignee": nil, + "assignees": []interface{}{}, + "user": map[string]interface{}{ + "id": 1, + "login": "testuser", + "name": "Test User", + }, + "pull_request": nil, // Not a PR + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(issues) + })) + defer server.Close() + + // Override the GitHub API URL for testing + config := &repository.Config{ + AccessToken: "test-token", + Owner: "testowner", + Repository: "testrepo", + BaseURL: server.URL, // This won't be used in real GitHub provider, but for testing we modify the URL in the provider + } + + provider, err := NewGitHubProvider(config) + require.NoError(t, err) + + // For testing, we need to create a modified provider that uses our test server + testProvider := &GitHubProvider{ + config: config, + token: config.AccessToken, + owner: config.Owner, + repo: config.Repository, + httpClient: provider.httpClient, + } + + // We can't easily test GitHub provider without modifying the URL, so we'll test the factory instead + assert.Equal(t, "test-token", provider.token) + assert.Equal(t, "testowner", provider.owner) + assert.Equal(t, "testrepo", provider.repo) +} + +// Test GitLab Provider +func TestGitLabProvider_NewGitLabProvider(t *testing.T) { + tests := []struct { + name string + config *repository.Config + expectError bool + errorMsg string + }{ + { + name: "valid config with owner/repo", + config: &repository.Config{ + AccessToken: "test-token", + Owner: "testowner", + Repository: "testrepo", + }, + expectError: false, + }, + { + name: "valid config with project ID", + config: &repository.Config{ + AccessToken: "test-token", + Settings: map[string]interface{}{ + "project_id": "123", + }, + }, + expectError: false, + }, + { + name: "missing access token", + config: &repository.Config{ + Owner: "testowner", + Repository: "testrepo", + }, + expectError: true, + errorMsg: "access token is required", + }, + { + name: "missing owner/repo and project_id", + config: &repository.Config{ + AccessToken: "test-token", + }, + expectError: true, + errorMsg: "either owner/repository or project_id", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, err := NewGitLabProvider(tt.config) + + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + assert.Nil(t, provider) + } else { + assert.NoError(t, err) + assert.NotNil(t, provider) + assert.Equal(t, tt.config.AccessToken, provider.token) + } + }) + } +} + +// Test Provider Factory +func TestProviderFactory_CreateProvider(t *testing.T) { + factory := NewProviderFactory() + + tests := []struct { + name string + config *repository.Config + expectedType string + expectError bool + }{ + { + name: "create gitea provider", + config: &repository.Config{ + Provider: "gitea", + BaseURL: "https://gitea.example.com", + AccessToken: "test-token", + Owner: "testowner", + Repository: "testrepo", + }, + expectedType: "*providers.GiteaProvider", + expectError: false, + }, + { + name: "create github provider", + config: &repository.Config{ + Provider: "github", + AccessToken: "test-token", + Owner: "testowner", + Repository: "testrepo", + }, + expectedType: "*providers.GitHubProvider", + expectError: false, + }, + { + name: "create gitlab provider", + config: &repository.Config{ + Provider: "gitlab", + AccessToken: "test-token", + Owner: "testowner", + Repository: "testrepo", + }, + expectedType: "*providers.GitLabProvider", + expectError: false, + }, + { + name: "create mock provider", + config: &repository.Config{ + Provider: "mock", + }, + expectedType: "*repository.MockTaskProvider", + expectError: false, + }, + { + name: "unsupported provider", + config: &repository.Config{ + Provider: "unsupported", + }, + expectError: true, + }, + { + name: "nil config", + config: nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, err := factory.CreateProvider(nil, tt.config) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, provider) + } else { + assert.NoError(t, err) + assert.NotNil(t, provider) + // Note: We can't easily test exact type without reflection, so we just ensure it's not nil + } + }) + } +} + +func TestProviderFactory_ValidateConfig(t *testing.T) { + factory := NewProviderFactory() + + tests := []struct { + name string + config *repository.Config + expectError bool + }{ + { + name: "valid gitea config", + config: &repository.Config{ + Provider: "gitea", + BaseURL: "https://gitea.example.com", + AccessToken: "test-token", + Owner: "testowner", + Repository: "testrepo", + }, + expectError: false, + }, + { + name: "invalid gitea config - missing baseURL", + config: &repository.Config{ + Provider: "gitea", + AccessToken: "test-token", + Owner: "testowner", + Repository: "testrepo", + }, + expectError: true, + }, + { + name: "valid github config", + config: &repository.Config{ + Provider: "github", + AccessToken: "test-token", + Owner: "testowner", + Repository: "testrepo", + }, + expectError: false, + }, + { + name: "invalid github config - missing token", + config: &repository.Config{ + Provider: "github", + Owner: "testowner", + Repository: "testrepo", + }, + expectError: true, + }, + { + name: "valid mock config", + config: &repository.Config{ + Provider: "mock", + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := factory.ValidateConfig(tt.config) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestProviderFactory_GetSupportedTypes(t *testing.T) { + factory := NewProviderFactory() + types := factory.GetSupportedTypes() + + assert.Contains(t, types, "gitea") + assert.Contains(t, types, "github") + assert.Contains(t, types, "gitlab") + assert.Contains(t, types, "mock") + assert.Len(t, types, 4) +} + +func TestProviderFactory_GetProviderInfo(t *testing.T) { + factory := NewProviderFactory() + + info, err := factory.GetProviderInfo("gitea") + require.NoError(t, err) + assert.Equal(t, "Gitea", info.Name) + assert.Equal(t, "gitea", info.Type) + assert.Contains(t, info.RequiredFields, "baseURL") + assert.Contains(t, info.RequiredFields, "accessToken") + + // Test unsupported provider + _, err = factory.GetProviderInfo("unsupported") + assert.Error(t, err) +} + +// Test priority and complexity calculation +func TestPriorityComplexityCalculation(t *testing.T) { + provider := &GiteaProvider{} // We can test these methods with any provider + + tests := []struct { + name string + labels []string + title string + body string + expectedPriority int + expectedComplexity int + }{ + { + name: "critical bug", + labels: []string{"critical", "bug"}, + title: "Critical security vulnerability", + body: "This is a critical security issue that needs immediate attention", + expectedPriority: 10, + expectedComplexity: 7, + }, + { + name: "simple enhancement", + labels: []string{"enhancement", "good first issue"}, + title: "Add help text to button", + body: "Small UI improvement", + expectedPriority: 5, + expectedComplexity: 2, + }, + { + name: "complex refactor", + labels: []string{"refactor", "epic"}, + title: "Refactor authentication system", + body: string(make([]byte, 1000)), // Long body + expectedPriority: 5, + expectedComplexity: 8, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + priority := provider.calculatePriority(tt.labels, tt.title, tt.body) + complexity := provider.calculateComplexity(tt.labels, tt.title, tt.body) + + assert.Equal(t, tt.expectedPriority, priority) + assert.Equal(t, tt.expectedComplexity, complexity) + }) + } +} + +// Test role determination +func TestRoleDetermination(t *testing.T) { + provider := &GiteaProvider{} + + tests := []struct { + name string + labels []string + expectedRole string + }{ + { + name: "frontend task", + labels: []string{"frontend", "ui"}, + expectedRole: "frontend-developer", + }, + { + name: "backend task", + labels: []string{"backend", "api"}, + expectedRole: "backend-developer", + }, + { + name: "devops task", + labels: []string{"devops", "deployment"}, + expectedRole: "devops-engineer", + }, + { + name: "security task", + labels: []string{"security", "vulnerability"}, + expectedRole: "security-engineer", + }, + { + name: "testing task", + labels: []string{"testing", "qa"}, + expectedRole: "tester", + }, + { + name: "documentation task", + labels: []string{"documentation"}, + expectedRole: "technical-writer", + }, + { + name: "design task", + labels: []string{"design", "mockup"}, + expectedRole: "ui-ux-designer", + }, + { + name: "generic task", + labels: []string{"bug"}, + expectedRole: "developer", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + role := provider.determineRequiredRole(tt.labels) + assert.Equal(t, tt.expectedRole, role) + }) + } +} + +// Test expertise determination +func TestExpertiseDetermination(t *testing.T) { + provider := &GiteaProvider{} + + tests := []struct { + name string + labels []string + expectedExpertise []string + }{ + { + name: "go programming", + labels: []string{"go", "backend"}, + expectedExpertise: []string{"backend"}, + }, + { + name: "react frontend", + labels: []string{"react", "javascript"}, + expectedExpertise: []string{"javascript"}, + }, + { + name: "docker devops", + labels: []string{"docker", "kubernetes"}, + expectedExpertise: []string{"docker", "kubernetes"}, + }, + { + name: "no specific labels", + labels: []string{"bug", "minor"}, + expectedExpertise: []string{"development", "programming"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expertise := provider.determineRequiredExpertise(tt.labels) + // Check if all expected expertise areas are present + for _, expected := range tt.expectedExpertise { + assert.Contains(t, expertise, expected) + } + }) + } +} + +// Benchmark tests +func BenchmarkGiteaProvider_CalculatePriority(b *testing.B) { + provider := &GiteaProvider{} + labels := []string{"critical", "bug", "security"} + title := "Critical security vulnerability in authentication" + body := "This is a detailed description of a critical security vulnerability that affects user authentication and needs immediate attention." + + b.ResetTimer() + for i := 0; i < b.N; i++ { + provider.calculatePriority(labels, title, body) + } +} + +func BenchmarkProviderFactory_CreateProvider(b *testing.B) { + factory := NewProviderFactory() + config := &repository.Config{ + Provider: "mock", + AccessToken: "test-token", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + provider, err := factory.CreateProvider(nil, config) + if err != nil { + b.Fatalf("Failed to create provider: %v", err) + } + _ = provider + } +} \ No newline at end of file diff --git a/pkg/repository/types.go b/pkg/repository/types.go index 22831d3..90c515c 100644 --- a/pkg/repository/types.go +++ b/pkg/repository/types.go @@ -147,17 +147,28 @@ func (m *DefaultTaskMatcher) ScoreTaskForAgent(task *Task, agentInfo *AgentInfo) } // DefaultProviderFactory provides a default implementation of ProviderFactory -type DefaultProviderFactory struct{} +// This is now a wrapper around the real provider factory +type DefaultProviderFactory struct { + factory ProviderFactory +} -// CreateProvider creates a task provider (stub implementation) +// NewDefaultProviderFactory creates a new default provider factory +func NewDefaultProviderFactory() *DefaultProviderFactory { + // This will be replaced by importing the providers factory + // For now, return a stub that creates mock providers + return &DefaultProviderFactory{} +} + +// CreateProvider creates a task provider func (f *DefaultProviderFactory) CreateProvider(ctx interface{}, config *Config) (TaskProvider, error) { - // In a real implementation, this would create GitHub, GitLab, etc. providers + // For backward compatibility, fall back to mock if no real factory is available + // In production, this should be replaced with the real provider factory return &MockTaskProvider{}, nil } // GetSupportedTypes returns supported repository types func (f *DefaultProviderFactory) GetSupportedTypes() []string { - return []string{"github", "gitlab", "mock"} + return []string{"github", "gitlab", "gitea", "mock"} } // SupportedProviders returns list of supported providers