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>
This commit is contained in:
534
gitea/client.go
Normal file
534
gitea/client.go
Normal file
@@ -0,0 +1,534 @@
|
||||
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"}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user