Implement comprehensive task management system with GITEA integration

Replace mock endpoints with real database-backed task management:
- Add tasks table with full relationships and indexes
- Create generic task management service supporting multiple sources
- Implement GITEA integration service for issue synchronization
- Add task creation, retrieval, assignment, and status updates

Database schema changes:
- New tasks table with external_id mapping for GITEA/GitHub/Jira
- Foreign key relationships to teams and agents
- Task workflow tracking (claimed_at, started_at, completed_at)
- JSONB fields for labels, tech_stack, requirements

Task management features:
- Generic TaskFilter with pagination and multi-field filtering
- Automatic tech stack inference from labels and descriptions
- Complexity scoring based on multiple factors
- Real task assignment to teams and agents
- GITEA webhook integration for automated task sync

API endpoints now use real database operations:
- GET /api/v1/tasks (real filtering and pagination)
- GET /api/v1/tasks/{id} (database lookup)
- POST /api/v1/tasks/ingest (creates actual task records)
- POST /api/v1/tasks/{id}/claim (real assignment operations)

GITEA integration includes:
- Issue-to-task synchronization with configurable task labels
- Priority mapping from issue labels
- Estimated hours extraction from issue descriptions
- Webhook processing for real-time updates

This removes the major mocked components and provides
a foundation for genuine E2E testing with real data.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Code
2025-09-08 12:21:33 +10:00
parent 3a351305e9
commit 69e812826e
6 changed files with 1204 additions and 100 deletions

View File

@@ -17,6 +17,7 @@ import (
"github.com/chorus-services/whoosh/internal/database"
"github.com/chorus-services/whoosh/internal/gitea"
"github.com/chorus-services/whoosh/internal/p2p"
"github.com/chorus-services/whoosh/internal/tasks"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
@@ -35,16 +36,24 @@ type Server struct {
p2pDiscovery *p2p.Discovery
backbeat *backbeat.Integration
teamComposer *composer.Service
taskService *tasks.Service
giteaIntegration *tasks.GiteaIntegration
}
func NewServer(cfg *config.Config, db *database.DB) (*Server, error) {
// Initialize core services
taskService := tasks.NewService(db.Pool)
giteaIntegration := tasks.NewGiteaIntegration(taskService, gitea.NewClient(cfg.GITEA), nil)
s := &Server{
config: cfg,
db: db,
giteaClient: gitea.NewClient(cfg.GITEA),
webhookHandler: gitea.NewWebhookHandler(cfg.GITEA.WebhookToken),
p2pDiscovery: p2p.NewDiscovery(),
teamComposer: composer.NewService(db.Pool, nil), // Use default config
config: cfg,
db: db,
giteaClient: gitea.NewClient(cfg.GITEA),
webhookHandler: gitea.NewWebhookHandler(cfg.GITEA.WebhookToken),
p2pDiscovery: p2p.NewDiscovery(),
teamComposer: composer.NewService(db.Pool, nil), // Use default config
taskService: taskService,
giteaIntegration: giteaIntegration,
}
// Initialize BACKBEAT integration if enabled
@@ -496,58 +505,53 @@ func (s *Server) analyzeTeamCompositionHandler(w http.ResponseWriter, r *http.Re
}
func (s *Server) listTasksHandler(w http.ResponseWriter, r *http.Request) {
// Get query parameters
status := r.URL.Query().Get("status") // active, queued, completed
if status == "" {
status = "all"
// Parse query parameters
statusParam := r.URL.Query().Get("status")
priorityParam := r.URL.Query().Get("priority")
repositoryParam := r.URL.Query().Get("repository")
limitStr := r.URL.Query().Get("limit")
offsetStr := r.URL.Query().Get("offset")
// Build filter
filter := &tasks.TaskFilter{}
if statusParam != "" && statusParam != "all" {
filter.Status = []tasks.TaskStatus{tasks.TaskStatus(statusParam)}
}
// For MVP, we'll simulate task data that would come from GITEA issues
// In full implementation, this would query GITEA API for bzzz-task issues
tasks := []map[string]interface{}{
{
"id": "task-001",
"title": "Implement user authentication system",
"description": "Add JWT-based authentication with login and registration endpoints",
"status": "active",
"priority": "high",
"repository": "example/backend-api",
"issue_url": "https://gitea.chorus.services/example/backend-api/issues/1",
"assigned_to": "team-001",
"created_at": "2025-09-03T20:00:00Z",
"updated_at": "2025-09-04T00:00:00Z",
"labels": []string{"bzzz-task", "backend", "security"},
},
{
"id": "task-002",
"title": "Fix database connection pooling",
"description": "Connection pool is not releasing connections properly under high load",
"status": "queued",
"priority": "medium",
"repository": "example/backend-api",
"issue_url": "https://gitea.chorus.services/example/backend-api/issues/2",
"assigned_to": nil,
"created_at": "2025-09-04T00:15:00Z",
"updated_at": "2025-09-04T00:15:00Z",
"labels": []string{"bzzz-task", "database", "performance"},
},
if priorityParam != "" {
filter.Priority = []tasks.TaskPriority{tasks.TaskPriority(priorityParam)}
}
// Filter tasks by status if specified
if status != "all" {
filtered := []map[string]interface{}{}
for _, task := range tasks {
if task["status"] == status {
filtered = append(filtered, task)
}
if repositoryParam != "" {
filter.Repository = repositoryParam
}
if limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 && limit <= 100 {
filter.Limit = limit
}
tasks = filtered
}
if offsetStr != "" {
if offset, err := strconv.Atoi(offsetStr); err == nil && offset >= 0 {
filter.Offset = offset
}
}
// Get tasks from database
taskList, total, err := s.taskService.ListTasks(r.Context(), filter)
if err != nil {
log.Error().Err(err).Msg("Failed to list tasks")
render.Status(r, http.StatusInternalServerError)
render.JSON(w, r, map[string]string{"error": "failed to retrieve tasks"})
return
}
render.JSON(w, r, map[string]interface{}{
"tasks": tasks,
"total": len(tasks),
"status": status,
"tasks": taskList,
"total": total,
"filter": filter,
})
}
@@ -603,18 +607,39 @@ func (s *Server) ingestTaskHandler(w http.ResponseWriter, r *http.Request) {
// For MVP, we'll create the task record and attempt team composition
// In production, this would persist to a tasks table and queue for processing
// Convert to TaskAnalysisInput for team composition
taskInput := &composer.TaskAnalysisInput{
// Create task in database first
createInput := &tasks.CreateTaskInput{
ExternalID: taskID, // Use generated ID as external ID for manual tasks
ExternalURL: taskData.IssueURL,
SourceType: tasks.SourceType(taskData.Source),
Title: taskData.Title,
Description: taskData.Description,
Priority: tasks.TaskPriority(taskData.Priority),
Repository: taskData.Repository,
Requirements: []string{}, // Could parse from description or labels
Priority: composer.TaskPriority(taskData.Priority),
TechStack: s.inferTechStackFromLabels(taskData.Labels),
Labels: taskData.Labels,
}
createdTask, err := s.taskService.CreateTask(r.Context(), createInput)
if err != nil {
log.Error().Err(err).Str("task_id", taskID).Msg("Failed to create task")
render.Status(r, http.StatusInternalServerError)
render.JSON(w, r, map[string]string{"error": "failed to create task"})
return
}
// Convert to TaskAnalysisInput for team composition
taskInput := &composer.TaskAnalysisInput{
Title: createdTask.Title,
Description: createdTask.Description,
Repository: createdTask.Repository,
Requirements: createdTask.Requirements,
Priority: composer.TaskPriority(createdTask.Priority),
TechStack: createdTask.TechStack,
Metadata: map[string]interface{}{
"task_id": createdTask.ID.String(),
"source": taskData.Source,
"issue_url": taskData.IssueURL,
"labels": taskData.Labels,
"labels": createdTask.Labels,
},
}
@@ -672,46 +697,31 @@ func (s *Server) ingestTaskHandler(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) getTaskHandler(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskID")
// For MVP, we'll simulate task retrieval since we don't have a tasks table yet
// In production, this would query the database for the task details
log.Info().
Str("task_id", taskID).
Msg("Retrieving task details")
// Mock task data for demonstration
// In production, this would query: SELECT * FROM tasks WHERE id = $1
task := map[string]interface{}{
"id": taskID,
"title": "Sample Task",
"description": "This is a mock task for MVP demonstration",
"status": "active",
"priority": "medium",
"repository": "example/project",
"source": "manual",
"created_at": time.Now().Add(-2 * time.Hour).Format(time.RFC3339),
"updated_at": time.Now().Add(-30 * time.Minute).Format(time.RFC3339),
"labels": []string{"backend", "api", "go"},
taskIDStr := chi.URLParam(r, "taskID")
taskID, err := uuid.Parse(taskIDStr)
if err != nil {
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]string{"error": "invalid task ID format"})
return
}
// Try to find associated team (search teams by task metadata)
teams, _, err := s.teamComposer.ListTeams(r.Context(), 10, 0)
var assignedTeam *composer.Team
if err == nil {
// In a real implementation, we'd have proper task-to-team relationships
// For MVP, we'll return the most recent team as a placeholder
if len(teams) > 0 {
assignedTeam = teams[0]
task["assigned_team"] = assignedTeam
// Get task from database
task, err := s.taskService.GetTask(r.Context(), taskID)
if err != nil {
if strings.Contains(err.Error(), "not found") {
render.Status(r, http.StatusNotFound)
render.JSON(w, r, map[string]string{"error": "task not found"})
return
}
log.Error().Err(err).Str("task_id", taskIDStr).Msg("Failed to get task")
render.Status(r, http.StatusInternalServerError)
render.JSON(w, r, map[string]string{"error": "failed to retrieve task"})
return
}
render.JSON(w, r, map[string]interface{}{
"task": task,
"message": "Task details retrieved (MVP mock data)",
"task": task,
})
}
@@ -998,7 +1008,13 @@ func (s *Server) getProjectTaskHandler(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) claimTaskHandler(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskID")
taskIDStr := chi.URLParam(r, "taskID")
taskID, err := uuid.Parse(taskIDStr)
if err != nil {
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]string{"error": "invalid task ID format"})
return
}
var claimData struct {
TeamID string `json:"team_id"`
@@ -1041,21 +1057,47 @@ func (s *Server) claimTaskHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Parse agent ID if provided
var agentUUID *uuid.UUID
if claimData.AgentID != "" {
agentID, err := uuid.Parse(claimData.AgentID)
if err != nil {
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]string{"error": "invalid agent_id format"})
return
}
agentUUID = &agentID
}
// Assign task to team/agent
assignment := &tasks.TaskAssignment{
TaskID: taskID,
TeamID: &teamUUID,
AgentID: agentUUID,
Reason: claimData.Reason,
}
err = s.taskService.AssignTask(r.Context(), assignment)
if err != nil {
log.Error().Err(err).Str("task_id", taskIDStr).Msg("Failed to assign task")
render.Status(r, http.StatusInternalServerError)
render.JSON(w, r, map[string]string{"error": "failed to assign task"})
return
}
log.Info().
Str("task_id", taskID).
Str("task_id", taskIDStr).
Str("team_id", claimData.TeamID).
Str("agent_id", claimData.AgentID).
Msg("Task claimed by team")
Msg("Task assigned to team")
// For MVP, we'll just return success
// In production, this would update task assignment in database
render.JSON(w, r, map[string]interface{}{
"task_id": taskID,
"task_id": taskIDStr,
"team_id": claimData.TeamID,
"agent_id": claimData.AgentID,
"status": "claimed",
"claimed_at": time.Now().Format(time.RFC3339),
"message": "Task claimed successfully (MVP mode)",
"message": "Task assigned successfully",
})
}