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 }