Fix P2P Connectivity Regression + Dynamic Versioning System #12
2
Makefile
2
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')
|
||||
|
||||
|
||||
261
pkg/providers/factory.go
Normal file
261
pkg/providers/factory.go
Normal file
@@ -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
|
||||
}
|
||||
617
pkg/providers/gitea.go
Normal file
617
pkg/providers/gitea.go
Normal file
@@ -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
|
||||
}
|
||||
732
pkg/providers/github.go
Normal file
732
pkg/providers/github.go
Normal file
@@ -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
|
||||
}
|
||||
781
pkg/providers/gitlab.go
Normal file
781
pkg/providers/gitlab.go
Normal file
@@ -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
|
||||
}
|
||||
698
pkg/providers/provider_test.go
Normal file
698
pkg/providers/provider_test.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user