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:
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user