Files
bzzz/gitea/client.go
anthonyrawlins 5978a0b8f5 WIP: Save agent roles integration work before CHORUS rebrand
- Agent roles and coordination features
- Chat API integration testing
- New configuration and workspace management

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-01 02:21:11 +10:00

534 lines
16 KiB
Go

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"}
}
}