package gitea import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" ) // Client wraps the Gitea API client for Bzzz task management type Client struct { httpClient *http.Client baseURL string token string ctx context.Context config *Config } // Config holds Gitea integration configuration type Config struct { BaseURL string // Gitea instance URL AccessToken string // Access token for API authentication Owner string // Gitea organization/user Repository string // Repository for task coordination // Task management settings TaskLabel string // Label for Bzzz tasks (default: "bzzz-task") InProgressLabel string // Label for tasks in progress (default: "in-progress") CompletedLabel string // Label for completed tasks (default: "completed") Assignee string // Gitea username for task assignment // Branch management BaseBranch string // Base branch for task branches (default: "main") BranchPrefix string // Prefix for task branches (default: "bzzz/task-") } // Task represents a Bzzz task as a Gitea issue type Task struct { ID int64 `json:"id"` Number int64 `json:"number"` Title string `json:"title"` Description string `json:"body"` State string `json:"state"` // open, closed Labels []Label `json:"labels"` Assignee *User `json:"assignee"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // Bzzz-specific fields (parsed from body or labels) TaskType string `json:"task_type"` Priority int `json:"priority"` Requirements []string `json:"requirements"` Deliverables []string `json:"deliverables"` Context map[string]interface{} `json:"context"` RequiredRole string `json:"required_role"` RequiredExpertise []string `json:"required_expertise"` } // Label represents a Gitea issue label type Label struct { ID int64 `json:"id"` Name string `json:"name"` Color string `json:"color"` Description string `json:"description"` } // User represents a Gitea user type User struct { ID int64 `json:"id"` Login string `json:"login"` FullName string `json:"full_name"` Email string `json:"email"` } // Issue represents a Gitea issue type Issue struct { ID int64 `json:"id"` Number int64 `json:"number"` Title string `json:"title"` Body string `json:"body"` State string `json:"state"` Labels []Label `json:"labels"` Assignee *User `json:"assignee"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // Comment represents a Gitea issue comment type Comment struct { ID int64 `json:"id"` Body string `json:"body"` User User `json:"user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // NewClient creates a new Gitea client for Bzzz integration func NewClient(ctx context.Context, config *Config) (*Client, error) { if config.BaseURL == "" { return nil, fmt.Errorf("Gitea base URL is required") } if config.AccessToken == "" { return nil, fmt.Errorf("Gitea access token is required") } if config.Owner == "" || config.Repository == "" { return nil, fmt.Errorf("Gitea owner and repository are required") } // Set defaults if config.TaskLabel == "" { config.TaskLabel = "bzzz-task" } if config.InProgressLabel == "" { config.InProgressLabel = "in-progress" } if config.CompletedLabel == "" { config.CompletedLabel = "completed" } if config.BaseBranch == "" { config.BaseBranch = "main" } if config.BranchPrefix == "" { config.BranchPrefix = "bzzz/task-" } client := &Client{ httpClient: &http.Client{Timeout: 30 * time.Second}, baseURL: config.BaseURL, token: config.AccessToken, ctx: ctx, config: config, } // Verify access to repository if err := client.verifyAccess(); err != nil { return nil, fmt.Errorf("failed to verify Gitea access: %w", err) } return client, nil } // verifyAccess checks if we can access the configured repository func (c *Client) verifyAccess() error { url := fmt.Sprintf("%s/api/v1/repos/%s/%s", c.baseURL, c.config.Owner, c.config.Repository) req, err := http.NewRequestWithContext(c.ctx, "GET", url, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "token "+c.token) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("cannot access repository %s/%s: HTTP %d", c.config.Owner, c.config.Repository, resp.StatusCode) } return nil } // ListAvailableTasks returns unassigned Bzzz tasks func (c *Client) ListAvailableTasks() ([]*Task, error) { apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", c.baseURL, c.config.Owner, c.config.Repository) // Add query parameters params := url.Values{} params.Add("state", "open") params.Add("labels", c.config.TaskLabel) params.Add("limit", "50") req, err := http.NewRequestWithContext(c.ctx, "GET", apiURL+"?"+params.Encode(), nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "token "+c.token) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to list issues: HTTP %d", resp.StatusCode) } var issues []Issue if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } // Filter for unassigned tasks and convert to Task format tasks := make([]*Task, 0, len(issues)) for _, issue := range issues { // Skip if already assigned if issue.Assignee != nil { continue } // Check if it has the bzzz-task label hasBzzzLabel := false for _, label := range issue.Labels { if label.Name == c.config.TaskLabel { hasBzzzLabel = true break } } if hasBzzzLabel { tasks = append(tasks, c.issueToTask(&issue)) } } return tasks, nil } // ClaimTask atomically assigns a task to an agent func (c *Client) ClaimTask(issueNumber int64, agentID string) (*Task, error) { // Get current issue state issue, err := c.getIssue(issueNumber) if err != nil { return nil, fmt.Errorf("failed to get issue: %w", err) } // Check if already assigned if issue.Assignee != nil { return nil, fmt.Errorf("task already assigned to %s", issue.Assignee.Login) } // Add in-progress label currentLabels := make([]string, 0, len(issue.Labels)+1) for _, label := range issue.Labels { currentLabels = append(currentLabels, label.Name) } currentLabels = append(currentLabels, c.config.InProgressLabel) // Update the issue with labels (assignment through API may require different approach) if err := c.updateIssueLabels(issueNumber, currentLabels); err != nil { return nil, fmt.Errorf("failed to update issue labels: %w", err) } // Add a comment to track which Bzzz agent claimed this task claimComment := fmt.Sprintf("🐝 **Task claimed by Bzzz agent:** `%s`\n\nThis task has been automatically claimed by the Bzzz P2P task coordination system.\n\n**Agent Details:**\n- Agent ID: `%s`\n- Claimed at: %s", agentID, agentID, time.Now().Format(time.RFC3339)) if err := c.addComment(issueNumber, claimComment); err != nil { // Log error but don't fail the claim fmt.Printf("⚠️ Failed to add claim comment: %v\n", err) } // Get updated issue updatedIssue, err := c.getIssue(issueNumber) if err != nil { return nil, fmt.Errorf("failed to get updated issue: %w", err) } return c.issueToTask(updatedIssue), nil } // CompleteTask marks a task as completed func (c *Client) CompleteTask(issueNumber int64, agentID string, results map[string]interface{}) error { // Get current issue issue, err := c.getIssue(issueNumber) if err != nil { return fmt.Errorf("failed to get issue: %w", err) } // Remove in-progress label, add completed label newLabels := make([]string, 0, len(issue.Labels)) for _, label := range issue.Labels { if label.Name != c.config.InProgressLabel { newLabels = append(newLabels, label.Name) } } newLabels = append(newLabels, c.config.CompletedLabel) // Update labels if err := c.updateIssueLabels(issueNumber, newLabels); err != nil { return fmt.Errorf("failed to update issue labels: %w", err) } // Add completion comment comment := c.formatCompletionComment(agentID, results) if err := c.addComment(issueNumber, comment); err != nil { return fmt.Errorf("failed to add completion comment: %w", err) } // Close the issue if err := c.closeIssue(issueNumber); err != nil { return fmt.Errorf("failed to close issue: %w", err) } return nil } // getIssue retrieves a single issue by number func (c *Client) getIssue(issueNumber int64) (*Issue, error) { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", c.baseURL, c.config.Owner, c.config.Repository, issueNumber) req, err := http.NewRequestWithContext(c.ctx, "GET", url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "token "+c.token) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to get issue: HTTP %d", resp.StatusCode) } var issue Issue if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } return &issue, nil } // updateIssueLabels updates the labels on an issue func (c *Client) updateIssueLabels(issueNumber int64, labels []string) error { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", c.baseURL, c.config.Owner, c.config.Repository, issueNumber) updateData := map[string]interface{}{ "labels": labels, } jsonData, err := json.Marshal(updateData) if err != nil { return fmt.Errorf("failed to marshal update data: %w", err) } req, err := http.NewRequestWithContext(c.ctx, "PATCH", url, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "token "+c.token) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("failed to update issue labels: HTTP %d - %s", resp.StatusCode, string(body)) } return nil } // closeIssue closes an issue func (c *Client) closeIssue(issueNumber int64) error { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", c.baseURL, c.config.Owner, c.config.Repository, issueNumber) updateData := map[string]interface{}{ "state": "closed", } jsonData, err := json.Marshal(updateData) if err != nil { return fmt.Errorf("failed to marshal update data: %w", err) } req, err := http.NewRequestWithContext(c.ctx, "PATCH", url, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "token "+c.token) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { return fmt.Errorf("failed to close issue: HTTP %d", resp.StatusCode) } return nil } // addComment adds a comment to an issue func (c *Client) addComment(issueNumber int64, body string) error { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", c.baseURL, c.config.Owner, c.config.Repository, issueNumber) commentData := map[string]interface{}{ "body": body, } jsonData, err := json.Marshal(commentData) if err != nil { return fmt.Errorf("failed to marshal comment data: %w", err) } req, err := http.NewRequestWithContext(c.ctx, "POST", url, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "token "+c.token) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { return fmt.Errorf("failed to add comment: HTTP %d", resp.StatusCode) } return nil } // formatCompletionComment formats task completion results func (c *Client) formatCompletionComment(agentID string, results map[string]interface{}) string { comment := fmt.Sprintf("✅ **Task completed by agent: %s**\n\n", agentID) comment += fmt.Sprintf("**Completion time:** %s\n\n", time.Now().Format(time.RFC3339)) if len(results) > 0 { comment += "**Results:**\n" for key, value := range results { comment += fmt.Sprintf("- **%s:** %v\n", key, value) } comment += "\n" } comment += "---\n*Completed by Bzzz P2P Task Coordination System*" return comment } // issueToTask converts a Gitea issue to a Bzzz task func (c *Client) issueToTask(issue *Issue) *Task { task := &Task{ ID: issue.ID, Number: issue.Number, Title: issue.Title, Description: issue.Body, State: issue.State, Labels: issue.Labels, Assignee: issue.Assignee, CreatedAt: issue.CreatedAt, UpdatedAt: issue.UpdatedAt, Priority: 5, // Default priority } // Parse task metadata from labels and body c.parseTaskMetadata(task, issue) return task } // parseTaskMetadata extracts Bzzz-specific metadata from issue labels and body func (c *Client) parseTaskMetadata(task *Task, issue *Issue) { // Parse labels for metadata for _, label := range issue.Labels { switch { case label.Name == "frontend": task.RequiredRole = "frontend_developer" task.RequiredExpertise = []string{"frontend", "ui_development"} case label.Name == "backend": task.RequiredRole = "backend_developer" task.RequiredExpertise = []string{"backend", "api_development"} case label.Name == "security": task.RequiredRole = "security_expert" task.RequiredExpertise = []string{"security", "vulnerability_analysis"} case label.Name == "design": task.RequiredRole = "ui_ux_designer" task.RequiredExpertise = []string{"design", "user_experience"} case label.Name == "devops": task.RequiredRole = "devops_engineer" task.RequiredExpertise = []string{"deployment", "infrastructure"} case label.Name == "documentation": task.RequiredRole = "technical_writer" task.RequiredExpertise = []string{"documentation", "technical_writing"} case label.Name == "bug": task.TaskType = "bug_fix" task.RequiredRole = "qa_engineer" task.RequiredExpertise = []string{"testing", "debugging"} case label.Name == "enhancement": task.TaskType = "feature" case label.Name == "architecture": task.RequiredRole = "senior_software_architect" task.RequiredExpertise = []string{"architecture", "system_design"} case label.Name == "priority-high": task.Priority = 8 case label.Name == "priority-urgent": task.Priority = 10 case label.Name == "priority-low": task.Priority = 3 } } // Set default task type if not set if task.TaskType == "" { task.TaskType = "general" } // Set default role if not set if task.RequiredRole == "" { task.RequiredRole = "full_stack_engineer" task.RequiredExpertise = []string{"general_development"} } }