🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
535 lines
16 KiB
Go
535 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 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("cannot access repository %s/%s: HTTP %d - %s (URL: %s)",
|
|
c.config.Owner, c.config.Repository, resp.StatusCode, string(body), url)
|
|
}
|
|
|
|
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"}
|
|
}
|
|
} |