 4173c0c8c8
			
		
	
	4173c0c8c8
	
	
	
		
			
			- Implement automatic label creation when registering repositories:
  • bzzz-task (red) - Issues for CHORUS BZZZ task assignments
  • whoosh-monitored (teal) - Repository monitoring indicator
  • priority-high/medium/low labels for task prioritization
- Add repository edit modal with full configuration options
- Add manual "Labels" button to ensure labels for existing repos
- Enhance Gitea client with CreateLabel, GetLabels, EnsureRequiredLabels methods
- Add POST /api/v1/repositories/{id}/ensure-labels endpoint
- Fix label creation error handling with graceful degradation
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
		
	
		
			
				
	
	
		
			3529 lines
		
	
	
		
			134 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			3529 lines
		
	
	
		
			134 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| package server
 | ||
| 
 | ||
| import (
 | ||
| 	"bytes"
 | ||
| 	"context"
 | ||
| 	"encoding/json"
 | ||
| 	"fmt"
 | ||
| 	"io"
 | ||
| 	"net/http"
 | ||
| 	"strconv"
 | ||
| 	"strings"
 | ||
| 	"time"
 | ||
| 
 | ||
| 	"github.com/chorus-services/whoosh/internal/backbeat"
 | ||
| 	"github.com/chorus-services/whoosh/internal/composer"
 | ||
| 	"github.com/chorus-services/whoosh/internal/config"
 | ||
| 	"github.com/chorus-services/whoosh/internal/database"
 | ||
| 	"github.com/chorus-services/whoosh/internal/gitea"
 | ||
| 	"github.com/chorus-services/whoosh/internal/monitor"
 | ||
| 	"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"
 | ||
| 	"github.com/go-chi/render"
 | ||
| 	"github.com/google/uuid"
 | ||
| 	"github.com/rs/zerolog/log"
 | ||
| )
 | ||
| 
 | ||
| type Server struct {
 | ||
| 	config         *config.Config
 | ||
| 	db             *database.DB
 | ||
| 	httpServer     *http.Server
 | ||
| 	router         chi.Router
 | ||
| 	giteaClient    *gitea.Client
 | ||
| 	webhookHandler *gitea.WebhookHandler
 | ||
| 	p2pDiscovery   *p2p.Discovery
 | ||
| 	backbeat       *backbeat.Integration
 | ||
| 	teamComposer   *composer.Service
 | ||
| 	taskService    *tasks.Service
 | ||
| 	giteaIntegration *tasks.GiteaIntegration
 | ||
| 	repoMonitor    *monitor.Monitor
 | ||
| }
 | ||
| 
 | ||
| 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)
 | ||
| 	
 | ||
| 	// Initialize repository monitor
 | ||
| 	repoMonitor := monitor.NewMonitor(db.Pool, cfg.GITEA)
 | ||
| 
 | ||
| 	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
 | ||
| 		taskService:      taskService,
 | ||
| 		giteaIntegration: giteaIntegration,
 | ||
| 		repoMonitor:      repoMonitor,
 | ||
| 	}
 | ||
| 
 | ||
| 	// Initialize BACKBEAT integration if enabled
 | ||
| 	if cfg.BACKBEAT.Enabled {
 | ||
| 		backbeatIntegration, err := backbeat.NewIntegration(&cfg.BACKBEAT)
 | ||
| 		if err != nil {
 | ||
| 			return nil, fmt.Errorf("failed to create BACKBEAT integration: %w", err)
 | ||
| 		}
 | ||
| 		s.backbeat = backbeatIntegration
 | ||
| 	}
 | ||
| 
 | ||
| 	s.setupRouter()
 | ||
| 	s.setupRoutes()
 | ||
| 
 | ||
| 	s.httpServer = &http.Server{
 | ||
| 		Addr:         cfg.Server.ListenAddr,
 | ||
| 		Handler:      s.router,
 | ||
| 		ReadTimeout:  cfg.Server.ReadTimeout,
 | ||
| 		WriteTimeout: cfg.Server.WriteTimeout,
 | ||
| 	}
 | ||
| 
 | ||
| 	return s, nil
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) setupRouter() {
 | ||
| 	r := chi.NewRouter()
 | ||
| 
 | ||
| 	// Middleware
 | ||
| 	r.Use(middleware.RequestID)
 | ||
| 	r.Use(middleware.RealIP)
 | ||
| 	r.Use(middleware.Logger)
 | ||
| 	r.Use(middleware.Recoverer)
 | ||
| 	r.Use(middleware.Timeout(30 * time.Second))
 | ||
| 
 | ||
| 	// CORS configuration
 | ||
| 	r.Use(cors.Handler(cors.Options{
 | ||
| 		AllowedOrigins:   []string{"*"},
 | ||
| 		AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
 | ||
| 		AllowedHeaders:   []string{"*"},
 | ||
| 		ExposedHeaders:   []string{"Link"},
 | ||
| 		AllowCredentials: true,
 | ||
| 		MaxAge:           300,
 | ||
| 	}))
 | ||
| 
 | ||
| 	// Content-Type handling
 | ||
| 	r.Use(render.SetContentType(render.ContentTypeJSON))
 | ||
| 
 | ||
| 	s.router = r
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) setupRoutes() {
 | ||
| 	// Root route - serve basic dashboard
 | ||
| 	s.router.Get("/", s.dashboardHandler)
 | ||
| 	
 | ||
| 	// Health check endpoints
 | ||
| 	s.router.Get("/health", s.healthHandler)
 | ||
| 	s.router.Get("/health/ready", s.readinessHandler)
 | ||
| 
 | ||
| 	// API v1 routes
 | ||
| 	s.router.Route("/api/v1", func(r chi.Router) {
 | ||
| 		// MVP endpoints - minimal team management
 | ||
| 		r.Route("/teams", func(r chi.Router) {
 | ||
| 			r.Get("/", s.listTeamsHandler)
 | ||
| 			r.Post("/", s.createTeamHandler)
 | ||
| 			r.Get("/{teamID}", s.getTeamHandler)
 | ||
| 			r.Put("/{teamID}/status", s.updateTeamStatusHandler)
 | ||
| 			r.Post("/analyze", s.analyzeTeamCompositionHandler)
 | ||
| 		})
 | ||
| 
 | ||
| 		// Task ingestion from GITEA
 | ||
| 		r.Route("/tasks", func(r chi.Router) {
 | ||
| 			r.Get("/", s.listTasksHandler)
 | ||
| 			r.Post("/ingest", s.ingestTaskHandler)
 | ||
| 			r.Get("/{taskID}", s.getTaskHandler)
 | ||
| 		})
 | ||
| 
 | ||
| 		// Project management endpoints
 | ||
| 		r.Route("/projects", func(r chi.Router) {
 | ||
| 			r.Get("/", s.listProjectsHandler)
 | ||
| 			r.Post("/", s.createProjectHandler)
 | ||
| 			r.Delete("/{projectID}", s.deleteProjectHandler)
 | ||
| 			
 | ||
| 			r.Route("/{projectID}", func(r chi.Router) {
 | ||
| 				r.Get("/", s.getProjectHandler)
 | ||
| 				r.Get("/tasks", s.listProjectTasksHandler)
 | ||
| 				r.Get("/tasks/available", s.listAvailableTasksHandler)
 | ||
| 				r.Get("/repository", s.getProjectRepositoryHandler)
 | ||
| 				r.Post("/analyze", s.analyzeProjectHandler)
 | ||
| 				
 | ||
| 				r.Route("/tasks/{taskNumber}", func(r chi.Router) {
 | ||
| 					r.Get("/", s.getProjectTaskHandler)
 | ||
| 					r.Post("/claim", s.claimTaskHandler)
 | ||
| 					r.Put("/status", s.updateTaskStatusHandler)
 | ||
| 					r.Post("/complete", s.completeTaskHandler)
 | ||
| 				})
 | ||
| 			})
 | ||
| 		})
 | ||
| 
 | ||
| 		// Agent registration endpoints
 | ||
| 		r.Route("/agents", func(r chi.Router) {
 | ||
| 			r.Get("/", s.listAgentsHandler)
 | ||
| 			r.Post("/register", s.registerAgentHandler)
 | ||
| 			r.Put("/{agentID}/status", s.updateAgentStatusHandler)
 | ||
| 		})
 | ||
| 
 | ||
| 		// SLURP proxy endpoints
 | ||
| 		r.Route("/slurp", func(r chi.Router) {
 | ||
| 			r.Post("/submit", s.slurpSubmitHandler)
 | ||
| 			r.Get("/artifacts/{ucxlAddr}", s.slurpRetrieveHandler)
 | ||
| 		})
 | ||
| 		
 | ||
| 		// Repository monitoring endpoints
 | ||
| 		r.Route("/repositories", func(r chi.Router) {
 | ||
| 			r.Get("/", s.listRepositoriesHandler)
 | ||
| 			r.Post("/", s.createRepositoryHandler)
 | ||
| 			r.Get("/{repoID}", s.getRepositoryHandler)
 | ||
| 			r.Put("/{repoID}", s.updateRepositoryHandler)
 | ||
| 			r.Delete("/{repoID}", s.deleteRepositoryHandler)
 | ||
| 			r.Post("/{repoID}/sync", s.syncRepositoryHandler)
 | ||
| 			r.Post("/{repoID}/ensure-labels", s.ensureRepositoryLabelsHandler)
 | ||
| 			r.Get("/{repoID}/logs", s.getRepositorySyncLogsHandler)
 | ||
| 		})
 | ||
| 
 | ||
| 		// BACKBEAT monitoring endpoints
 | ||
| 		r.Route("/backbeat", func(r chi.Router) {
 | ||
| 			r.Get("/status", s.backbeatStatusHandler)
 | ||
| 		})
 | ||
| 	})
 | ||
| 
 | ||
| 	// GITEA webhook endpoint
 | ||
| 	s.router.Post(s.config.GITEA.WebhookPath, s.giteaWebhookHandler)
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) Start(ctx context.Context) error {
 | ||
| 	// Start BACKBEAT integration if enabled
 | ||
| 	if s.backbeat != nil {
 | ||
| 		if err := s.backbeat.Start(ctx); err != nil {
 | ||
| 			return fmt.Errorf("failed to start BACKBEAT integration: %w", err)
 | ||
| 		}
 | ||
| 	}
 | ||
| 
 | ||
| 	// Start P2P discovery service
 | ||
| 	if err := s.p2pDiscovery.Start(); err != nil {
 | ||
| 		return fmt.Errorf("failed to start P2P discovery: %w", err)
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Start repository monitoring service
 | ||
| 	if s.repoMonitor != nil {
 | ||
| 		go func() {
 | ||
| 			if err := s.repoMonitor.Start(ctx); err != nil && err != context.Canceled {
 | ||
| 				log.Error().Err(err).Msg("Repository monitoring service failed")
 | ||
| 			}
 | ||
| 		}()
 | ||
| 		log.Info().Msg("🔍 Repository monitoring service started")
 | ||
| 	}
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("addr", s.httpServer.Addr).
 | ||
| 		Msg("HTTP server starting")
 | ||
| 
 | ||
| 	if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
 | ||
| 		return fmt.Errorf("server failed to start: %w", err)
 | ||
| 	}
 | ||
| 
 | ||
| 	return nil
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) Shutdown(ctx context.Context) error {
 | ||
| 	log.Info().Msg("HTTP server shutting down")
 | ||
| 
 | ||
| 	// Stop BACKBEAT integration
 | ||
| 	if s.backbeat != nil {
 | ||
| 		if err := s.backbeat.Stop(); err != nil {
 | ||
| 			log.Error().Err(err).Msg("Failed to stop BACKBEAT integration")
 | ||
| 		}
 | ||
| 	}
 | ||
| 
 | ||
| 	// Stop P2P discovery service
 | ||
| 	if err := s.p2pDiscovery.Stop(); err != nil {
 | ||
| 		log.Error().Err(err).Msg("Failed to stop P2P discovery service")
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Stop repository monitoring service
 | ||
| 	if s.repoMonitor != nil {
 | ||
| 		s.repoMonitor.Stop()
 | ||
| 		log.Info().Msg("🛑 Repository monitoring service stopped")
 | ||
| 	}
 | ||
| 
 | ||
| 	if err := s.httpServer.Shutdown(ctx); err != nil {
 | ||
| 		return fmt.Errorf("server shutdown failed: %w", err)
 | ||
| 	}
 | ||
| 
 | ||
| 	return nil
 | ||
| }
 | ||
| 
 | ||
| // Health check handlers
 | ||
| func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	response := map[string]interface{}{
 | ||
| 		"status":  "ok",
 | ||
| 		"service": "whoosh",
 | ||
| 		"version": "0.1.0-mvp",
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Include BACKBEAT health information if available
 | ||
| 	if s.backbeat != nil {
 | ||
| 		response["backbeat"] = s.backbeat.GetHealth()
 | ||
| 	}
 | ||
| 	
 | ||
| 	render.JSON(w, r, response)
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) readinessHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
 | ||
| 	defer cancel()
 | ||
| 
 | ||
| 	// Check database connection
 | ||
| 	if err := s.db.Health(ctx); err != nil {
 | ||
| 		log.Error().Err(err).Msg("Database health check failed")
 | ||
| 		render.Status(r, http.StatusServiceUnavailable)
 | ||
| 		render.JSON(w, r, map[string]string{
 | ||
| 			"status": "unavailable",
 | ||
| 			"error":  "database connection failed",
 | ||
| 		})
 | ||
| 		return
 | ||
| 	}
 | ||
| 
 | ||
| 	render.JSON(w, r, map[string]string{
 | ||
| 		"status":   "ready",
 | ||
| 		"database": "connected",
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| // MVP handlers for team and task management
 | ||
| func (s *Server) listTeamsHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	// Parse pagination parameters
 | ||
| 	limitStr := r.URL.Query().Get("limit")
 | ||
| 	offsetStr := r.URL.Query().Get("offset")
 | ||
| 	
 | ||
| 	limit := 20 // Default limit
 | ||
| 	offset := 0 // Default offset
 | ||
| 	
 | ||
| 	if limitStr != "" {
 | ||
| 		if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
 | ||
| 			limit = l
 | ||
| 		}
 | ||
| 	}
 | ||
| 	
 | ||
| 	if offsetStr != "" {
 | ||
| 		if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
 | ||
| 			offset = o
 | ||
| 		}
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Get teams from database
 | ||
| 	teams, total, err := s.teamComposer.ListTeams(r.Context(), limit, offset)
 | ||
| 	if err != nil {
 | ||
| 		log.Error().Err(err).Msg("Failed to list teams")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to retrieve teams"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	render.JSON(w, r, map[string]interface{}{
 | ||
| 		"teams":  teams,
 | ||
| 		"total":  total,
 | ||
| 		"limit":  limit,
 | ||
| 		"offset": offset,
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) createTeamHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	var taskInput composer.TaskAnalysisInput
 | ||
| 	
 | ||
| 	if err := json.NewDecoder(r.Body).Decode(&taskInput); err != nil {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid request body"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Validate required fields
 | ||
| 	if taskInput.Title == "" {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "title is required"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	if taskInput.Description == "" {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "description is required"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Set defaults if not provided
 | ||
| 	if taskInput.Priority == "" {
 | ||
| 		taskInput.Priority = composer.PriorityMedium
 | ||
| 	}
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("task_title", taskInput.Title).
 | ||
| 		Str("priority", string(taskInput.Priority)).
 | ||
| 		Msg("Starting team composition for new task")
 | ||
| 	
 | ||
| 	// Analyze task and compose team
 | ||
| 	result, err := s.teamComposer.AnalyzeAndComposeTeam(r.Context(), &taskInput)
 | ||
| 	if err != nil {
 | ||
| 		log.Error().Err(err).Msg("Team composition failed")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "team composition failed"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Create the team in database
 | ||
| 	team, err := s.teamComposer.CreateTeam(r.Context(), result.TeamComposition, &taskInput)
 | ||
| 	if err != nil {
 | ||
| 		log.Error().Err(err).Msg("Failed to create team")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to create team"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("team_id", team.ID.String()).
 | ||
| 		Str("team_name", team.Name).
 | ||
| 		Float64("confidence_score", result.TeamComposition.ConfidenceScore).
 | ||
| 		Msg("Team created successfully")
 | ||
| 	
 | ||
| 	// Return both the team and the composition analysis
 | ||
| 	response := map[string]interface{}{
 | ||
| 		"team":               team,
 | ||
| 		"composition_result": result,
 | ||
| 		"message":           "Team created successfully",
 | ||
| 	}
 | ||
| 	
 | ||
| 	render.Status(r, http.StatusCreated)
 | ||
| 	render.JSON(w, r, response)
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) getTeamHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	teamIDStr := chi.URLParam(r, "teamID")
 | ||
| 	teamID, err := uuid.Parse(teamIDStr)
 | ||
| 	if err != nil {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid team ID"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	team, assignments, err := s.teamComposer.GetTeam(r.Context(), teamID)
 | ||
| 	if err != nil {
 | ||
| 		if err.Error() == "team not found" {
 | ||
| 			render.Status(r, http.StatusNotFound)
 | ||
| 			render.JSON(w, r, map[string]string{"error": "team not found"})
 | ||
| 			return
 | ||
| 		}
 | ||
| 		
 | ||
| 		log.Error().Err(err).Str("team_id", teamIDStr).Msg("Failed to get team")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to retrieve team"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	response := map[string]interface{}{
 | ||
| 		"team":        team,
 | ||
| 		"assignments": assignments,
 | ||
| 	}
 | ||
| 	
 | ||
| 	render.JSON(w, r, response)
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) updateTeamStatusHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	teamIDStr := chi.URLParam(r, "teamID")
 | ||
| 	teamID, err := uuid.Parse(teamIDStr)
 | ||
| 	if err != nil {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid team ID"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	var statusUpdate struct {
 | ||
| 		Status string `json:"status"`
 | ||
| 		Reason string `json:"reason,omitempty"`
 | ||
| 	}
 | ||
| 	
 | ||
| 	if err := json.NewDecoder(r.Body).Decode(&statusUpdate); err != nil {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid request body"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Validate status values
 | ||
| 	validStatuses := map[string]bool{
 | ||
| 		"forming":    true,
 | ||
| 		"active":     true,
 | ||
| 		"completed":  true,
 | ||
| 		"disbanded":  true,
 | ||
| 	}
 | ||
| 	
 | ||
| 	if !validStatuses[statusUpdate.Status] {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid status. Valid values: forming, active, completed, disbanded"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Update team status in database
 | ||
| 	updateQuery := `UPDATE teams SET status = $1, updated_at = $2 WHERE id = $3`
 | ||
| 	
 | ||
| 	if statusUpdate.Status == "completed" {
 | ||
| 		updateQuery = `UPDATE teams SET status = $1, updated_at = $2, completed_at = $2 WHERE id = $3`
 | ||
| 	}
 | ||
| 	
 | ||
| 	_, err = s.db.Pool.Exec(r.Context(), updateQuery, statusUpdate.Status, time.Now(), teamID)
 | ||
| 	if err != nil {
 | ||
| 		log.Error().Err(err).
 | ||
| 			Str("team_id", teamIDStr).
 | ||
| 			Str("status", statusUpdate.Status).
 | ||
| 			Msg("Failed to update team status")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to update team status"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("team_id", teamIDStr).
 | ||
| 		Str("status", statusUpdate.Status).
 | ||
| 		Str("reason", statusUpdate.Reason).
 | ||
| 		Msg("Team status updated")
 | ||
| 	
 | ||
| 	render.JSON(w, r, map[string]interface{}{
 | ||
| 		"team_id": teamIDStr,
 | ||
| 		"status":  statusUpdate.Status,
 | ||
| 		"message": "Team status updated successfully",
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) analyzeTeamCompositionHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	var taskInput composer.TaskAnalysisInput
 | ||
| 	
 | ||
| 	if err := json.NewDecoder(r.Body).Decode(&taskInput); err != nil {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid request body"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Validate required fields
 | ||
| 	if taskInput.Title == "" {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "title is required"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	if taskInput.Description == "" {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "description is required"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Set defaults if not provided
 | ||
| 	if taskInput.Priority == "" {
 | ||
| 		taskInput.Priority = composer.PriorityMedium
 | ||
| 	}
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("task_title", taskInput.Title).
 | ||
| 		Str("priority", string(taskInput.Priority)).
 | ||
| 		Msg("Analyzing team composition requirements")
 | ||
| 	
 | ||
| 	// Analyze task and compose team (without creating it)
 | ||
| 	result, err := s.teamComposer.AnalyzeAndComposeTeam(r.Context(), &taskInput)
 | ||
| 	if err != nil {
 | ||
| 		log.Error().Err(err).Msg("Team composition analysis failed")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "team composition analysis failed"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("analysis_id", result.AnalysisID.String()).
 | ||
| 		Float64("confidence_score", result.TeamComposition.ConfidenceScore).
 | ||
| 		Int("recommended_team_size", result.TeamComposition.EstimatedSize).
 | ||
| 		Msg("Team composition analysis completed")
 | ||
| 	
 | ||
| 	render.JSON(w, r, result)
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) listTasksHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	// 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)}
 | ||
| 	}
 | ||
| 	
 | ||
| 	if priorityParam != "" {
 | ||
| 		filter.Priority = []tasks.TaskPriority{tasks.TaskPriority(priorityParam)}
 | ||
| 	}
 | ||
| 	
 | ||
| 	if repositoryParam != "" {
 | ||
| 		filter.Repository = repositoryParam
 | ||
| 	}
 | ||
| 	
 | ||
| 	if limitStr != "" {
 | ||
| 		if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 && limit <= 100 {
 | ||
| 			filter.Limit = limit
 | ||
| 		}
 | ||
| 	}
 | ||
| 	
 | ||
| 	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":  taskList,
 | ||
| 		"total":  total,
 | ||
| 		"filter": filter,
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) ingestTaskHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	var taskData struct {
 | ||
| 		Title       string   `json:"title"`
 | ||
| 		Description string   `json:"description"`
 | ||
| 		Repository  string   `json:"repository"`
 | ||
| 		IssueURL    string   `json:"issue_url,omitempty"`
 | ||
| 		Priority    string   `json:"priority,omitempty"`
 | ||
| 		Labels      []string `json:"labels,omitempty"`
 | ||
| 		Source      string   `json:"source,omitempty"` // "manual", "gitea", "webhook"
 | ||
| 	}
 | ||
| 	
 | ||
| 	if err := json.NewDecoder(r.Body).Decode(&taskData); err != nil {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid request body"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Validate required fields
 | ||
| 	if taskData.Title == "" {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "title is required"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	if taskData.Description == "" {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "description is required"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Set defaults
 | ||
| 	if taskData.Priority == "" {
 | ||
| 		taskData.Priority = "medium"
 | ||
| 	}
 | ||
| 	
 | ||
| 	if taskData.Source == "" {
 | ||
| 		taskData.Source = "manual"
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Create task ID
 | ||
| 	taskID := uuid.New().String()
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("task_id", taskID).
 | ||
| 		Str("title", taskData.Title).
 | ||
| 		Str("repository", taskData.Repository).
 | ||
| 		Str("source", taskData.Source).
 | ||
| 		Msg("Ingesting new task")
 | ||
| 	
 | ||
| 	// 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
 | ||
| 	
 | ||
| 	// 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,
 | ||
| 		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":    createdTask.Labels,
 | ||
| 		},
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Start team composition analysis in background for complex tasks
 | ||
| 	// For simple tasks, we can process synchronously
 | ||
| 	isComplex := len(taskData.Description) > 200 || 
 | ||
| 		len(taskData.Labels) > 3 || 
 | ||
| 		taskData.Priority == "high" || 
 | ||
| 		taskData.Priority == "critical"
 | ||
| 	
 | ||
| 	if isComplex {
 | ||
| 		// For complex tasks, start async team composition
 | ||
| 		go s.processTaskAsync(taskID, taskInput)
 | ||
| 		
 | ||
| 		// Return immediate response
 | ||
| 		render.Status(r, http.StatusAccepted)
 | ||
| 		render.JSON(w, r, map[string]interface{}{
 | ||
| 			"task_id": taskID,
 | ||
| 			"status":  "queued",
 | ||
| 			"message": "Task queued for team composition analysis",
 | ||
| 		})
 | ||
| 	} else {
 | ||
| 		// For simple tasks, process synchronously
 | ||
| 		result, err := s.teamComposer.AnalyzeAndComposeTeam(r.Context(), taskInput)
 | ||
| 		if err != nil {
 | ||
| 			log.Error().Err(err).Str("task_id", taskID).Msg("Task analysis failed")
 | ||
| 			render.Status(r, http.StatusInternalServerError)
 | ||
| 			render.JSON(w, r, map[string]string{"error": "task analysis failed"})
 | ||
| 			return
 | ||
| 		}
 | ||
| 		
 | ||
| 		// Create the team
 | ||
| 		team, err := s.teamComposer.CreateTeam(r.Context(), result.TeamComposition, taskInput)
 | ||
| 		if err != nil {
 | ||
| 			log.Error().Err(err).Str("task_id", taskID).Msg("Team creation failed")
 | ||
| 			render.Status(r, http.StatusInternalServerError)
 | ||
| 			render.JSON(w, r, map[string]string{"error": "team creation failed"})
 | ||
| 			return
 | ||
| 		}
 | ||
| 		
 | ||
| 		log.Info().
 | ||
| 			Str("task_id", taskID).
 | ||
| 			Str("team_id", team.ID.String()).
 | ||
| 			Msg("Task ingested and team created")
 | ||
| 		
 | ||
| 		render.Status(r, http.StatusCreated)
 | ||
| 		render.JSON(w, r, map[string]interface{}{
 | ||
| 			"task_id":            taskID,
 | ||
| 			"team":               team,
 | ||
| 			"composition_result": result,
 | ||
| 			"status":            "completed",
 | ||
| 			"message":           "Task ingested and team created successfully",
 | ||
| 		})
 | ||
| 	}
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) getTaskHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	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
 | ||
| 	}
 | ||
| 	
 | ||
| 	// 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,
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) slurpSubmitHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	// Parse the submission request
 | ||
| 	var submission struct {
 | ||
| 		TeamID       string                 `json:"team_id"`
 | ||
| 		ArtifactType string                 `json:"artifact_type"`
 | ||
| 		Content      map[string]interface{} `json:"content"`
 | ||
| 		Metadata     map[string]interface{} `json:"metadata,omitempty"`
 | ||
| 	}
 | ||
| 	
 | ||
| 	if err := json.NewDecoder(r.Body).Decode(&submission); err != nil {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid request body"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Validate required fields
 | ||
| 	if submission.TeamID == "" || submission.ArtifactType == "" {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "team_id and artifact_type are required"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Generate UCXL address for the submission
 | ||
| 	ucxlAddr := fmt.Sprintf("ucxl://%s/%s/%d", 
 | ||
| 		submission.TeamID, 
 | ||
| 		submission.ArtifactType, 
 | ||
| 		time.Now().Unix())
 | ||
| 	
 | ||
| 	// For MVP, we'll store basic metadata in the database
 | ||
| 	// In production, this would proxy to actual SLURP service
 | ||
| 	teamUUID, err := uuid.Parse(submission.TeamID)
 | ||
| 	if err != nil {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid team_id format"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Store submission record
 | ||
| 	submissionID := uuid.New()
 | ||
| 	metadataJSON, _ := json.Marshal(submission.Metadata)
 | ||
| 	
 | ||
| 	insertQuery := `
 | ||
| 		INSERT INTO slurp_submissions (id, team_id, ucxl_address, artifact_type, metadata, submitted_at, status)
 | ||
| 		VALUES ($1, $2, $3, $4, $5, $6, $7)
 | ||
| 	`
 | ||
| 	
 | ||
| 	_, err = s.db.Pool.Exec(r.Context(), insertQuery,
 | ||
| 		submissionID, teamUUID, ucxlAddr, submission.ArtifactType,
 | ||
| 		metadataJSON, time.Now(), "submitted")
 | ||
| 	
 | ||
| 	if err != nil {
 | ||
| 		log.Error().Err(err).
 | ||
| 			Str("team_id", submission.TeamID).
 | ||
| 			Str("artifact_type", submission.ArtifactType).
 | ||
| 			Msg("Failed to store SLURP submission")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to store submission"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("team_id", submission.TeamID).
 | ||
| 		Str("artifact_type", submission.ArtifactType).
 | ||
| 		Str("ucxl_address", ucxlAddr).
 | ||
| 		Msg("SLURP submission stored")
 | ||
| 	
 | ||
| 	render.Status(r, http.StatusCreated)
 | ||
| 	render.JSON(w, r, map[string]interface{}{
 | ||
| 		"submission_id": submissionID,
 | ||
| 		"ucxl_address":  ucxlAddr,
 | ||
| 		"status":        "submitted",
 | ||
| 		"message":       "Artifact submitted to SLURP successfully (MVP mode)",
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) slurpRetrieveHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	ucxlAddress := r.URL.Query().Get("ucxl_address")
 | ||
| 	if ucxlAddress == "" {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "ucxl_address query parameter is required"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("ucxl_address", ucxlAddress).
 | ||
| 		Msg("Retrieving SLURP submission")
 | ||
| 	
 | ||
| 	// Query the submission from database
 | ||
| 	query := `
 | ||
| 		SELECT id, team_id, ucxl_address, artifact_type, metadata, submitted_at, status
 | ||
| 		FROM slurp_submissions 
 | ||
| 		WHERE ucxl_address = $1
 | ||
| 	`
 | ||
| 	
 | ||
| 	row := s.db.Pool.QueryRow(r.Context(), query, ucxlAddress)
 | ||
| 	
 | ||
| 	var (
 | ||
| 		id           uuid.UUID
 | ||
| 		teamID       uuid.UUID  
 | ||
| 		retrievedAddr string
 | ||
| 		artifactType string
 | ||
| 		metadataJSON []byte
 | ||
| 		submittedAt  time.Time
 | ||
| 		status       string
 | ||
| 	)
 | ||
| 	
 | ||
| 	err := row.Scan(&id, &teamID, &retrievedAddr, &artifactType, &metadataJSON, &submittedAt, &status)
 | ||
| 	if err != nil {
 | ||
| 		if err.Error() == "no rows in result set" {
 | ||
| 			render.Status(r, http.StatusNotFound)
 | ||
| 			render.JSON(w, r, map[string]string{"error": "SLURP submission not found"})
 | ||
| 			return
 | ||
| 		}
 | ||
| 		
 | ||
| 		log.Error().Err(err).
 | ||
| 			Str("ucxl_address", ucxlAddress).
 | ||
| 			Msg("Failed to retrieve SLURP submission")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to retrieve submission"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Parse metadata
 | ||
| 	var metadata map[string]interface{}
 | ||
| 	if len(metadataJSON) > 0 {
 | ||
| 		json.Unmarshal(metadataJSON, &metadata)
 | ||
| 	}
 | ||
| 	
 | ||
| 	submission := map[string]interface{}{
 | ||
| 		"id":            id,
 | ||
| 		"team_id":       teamID,
 | ||
| 		"ucxl_address":  retrievedAddr,
 | ||
| 		"artifact_type": artifactType,
 | ||
| 		"metadata":      metadata,
 | ||
| 		"submitted_at":  submittedAt.Format(time.RFC3339),
 | ||
| 		"status":        status,
 | ||
| 	}
 | ||
| 	
 | ||
| 	// For MVP, we return the metadata. In production, this would
 | ||
| 	// proxy to SLURP service to retrieve actual artifact content
 | ||
| 	render.JSON(w, r, map[string]interface{}{
 | ||
| 		"submission": submission,
 | ||
| 		"message":    "SLURP submission retrieved (MVP mode - metadata only)",
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| // CHORUS Integration Handlers
 | ||
| 
 | ||
| func (s *Server) listProjectTasksHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	projectID := chi.URLParam(r, "projectID")
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("project_id", projectID).
 | ||
| 		Msg("Listing tasks for project")
 | ||
| 	
 | ||
| 	// For MVP, return mock tasks associated with the project
 | ||
| 	// In production, this would query actual tasks from database
 | ||
| 	tasks := []map[string]interface{}{
 | ||
| 		{
 | ||
| 			"id":          "task-001",
 | ||
| 			"project_id":  projectID,
 | ||
| 			"title":       "Setup project infrastructure",
 | ||
| 			"description": "Initialize Docker, CI/CD, and database setup",
 | ||
| 			"status":      "completed",
 | ||
| 			"priority":    "high",
 | ||
| 			"assigned_team": nil,
 | ||
| 			"created_at":  time.Now().Add(-48 * time.Hour).Format(time.RFC3339),
 | ||
| 			"completed_at": time.Now().Add(-12 * time.Hour).Format(time.RFC3339),
 | ||
| 		},
 | ||
| 		{
 | ||
| 			"id":          "task-002",
 | ||
| 			"project_id":  projectID,
 | ||
| 			"title":       "Implement authentication system",
 | ||
| 			"description": "JWT-based authentication with user management",
 | ||
| 			"status":      "active",
 | ||
| 			"priority":    "high",
 | ||
| 			"assigned_team": "team-001",
 | ||
| 			"created_at":  time.Now().Add(-24 * time.Hour).Format(time.RFC3339),
 | ||
| 			"updated_at":  time.Now().Add(-2 * time.Hour).Format(time.RFC3339),
 | ||
| 		},
 | ||
| 		{
 | ||
| 			"id":          "task-003",
 | ||
| 			"project_id":  projectID,
 | ||
| 			"title":       "Create API documentation", 
 | ||
| 			"description": "OpenAPI/Swagger documentation for all endpoints",
 | ||
| 			"status":      "queued",
 | ||
| 			"priority":    "medium",
 | ||
| 			"assigned_team": nil,
 | ||
| 			"created_at":  time.Now().Add(-6 * time.Hour).Format(time.RFC3339),
 | ||
| 		},
 | ||
| 	}
 | ||
| 	
 | ||
| 	render.JSON(w, r, map[string]interface{}{
 | ||
| 		"project_id": projectID,
 | ||
| 		"tasks":      tasks,
 | ||
| 		"total":      len(tasks),
 | ||
| 		"message":    "Project tasks retrieved (MVP mock data)",
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) listAvailableTasksHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	// Get query parameters for filtering
 | ||
| 	skillFilter := r.URL.Query().Get("skills")
 | ||
| 	priorityFilter := r.URL.Query().Get("priority") 
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("skill_filter", skillFilter).
 | ||
| 		Str("priority_filter", priorityFilter).
 | ||
| 		Msg("Listing available tasks")
 | ||
| 	
 | ||
| 	// For MVP, return mock available tasks that agents can claim
 | ||
| 	// In production, this would query unassigned tasks from database
 | ||
| 	availableTasks := []map[string]interface{}{
 | ||
| 		{
 | ||
| 			"id":           "task-004",
 | ||
| 			"title":        "Fix memory leak in user service",
 | ||
| 			"description":  "Investigate and fix memory leak causing high memory usage",
 | ||
| 			"status":       "available",
 | ||
| 			"priority":     "high",
 | ||
| 			"skills_required": []string{"go", "debugging", "performance"},
 | ||
| 			"estimated_hours": 8,
 | ||
| 			"repository":   "example/user-service",
 | ||
| 			"created_at":   time.Now().Add(-3 * time.Hour).Format(time.RFC3339),
 | ||
| 		},
 | ||
| 		{
 | ||
| 			"id":           "task-005", 
 | ||
| 			"title":        "Add rate limiting to API",
 | ||
| 			"description":  "Implement rate limiting middleware for API endpoints",
 | ||
| 			"status":       "available",
 | ||
| 			"priority":     "medium",
 | ||
| 			"skills_required": []string{"go", "middleware", "api"},
 | ||
| 			"estimated_hours": 4,
 | ||
| 			"repository":   "example/api-gateway",
 | ||
| 			"created_at":   time.Now().Add(-1 * time.Hour).Format(time.RFC3339),
 | ||
| 		},
 | ||
| 		{
 | ||
| 			"id":           "task-006",
 | ||
| 			"title":        "Update React components",
 | ||
| 			"description":  "Migrate legacy class components to functional components",
 | ||
| 			"status":       "available", 
 | ||
| 			"priority":     "low",
 | ||
| 			"skills_required": []string{"react", "javascript", "frontend"},
 | ||
| 			"estimated_hours": 12,
 | ||
| 			"repository":   "example/web-ui",
 | ||
| 			"created_at":   time.Now().Add(-30 * time.Minute).Format(time.RFC3339),
 | ||
| 		},
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Apply filtering if specified
 | ||
| 	filteredTasks := availableTasks
 | ||
| 	
 | ||
| 	if priorityFilter != "" {
 | ||
| 		filtered := []map[string]interface{}{}
 | ||
| 		for _, task := range availableTasks {
 | ||
| 			if task["priority"] == priorityFilter {
 | ||
| 				filtered = append(filtered, task)
 | ||
| 			}
 | ||
| 		}
 | ||
| 		filteredTasks = filtered
 | ||
| 	}
 | ||
| 	
 | ||
| 	render.JSON(w, r, map[string]interface{}{
 | ||
| 		"available_tasks": filteredTasks,
 | ||
| 		"total":          len(filteredTasks),
 | ||
| 		"filters": map[string]string{
 | ||
| 			"skills":   skillFilter,
 | ||
| 			"priority": priorityFilter,
 | ||
| 		},
 | ||
| 		"message": "Available tasks retrieved (MVP mock data)",
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) getProjectRepositoryHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	render.Status(r, http.StatusNotImplemented)
 | ||
| 	render.JSON(w, r, map[string]string{"error": "not implemented"})
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) getProjectTaskHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	render.Status(r, http.StatusNotImplemented)
 | ||
| 	render.JSON(w, r, map[string]string{"error": "not implemented"})
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) claimTaskHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	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"`
 | ||
| 		AgentID string `json:"agent_id,omitempty"`
 | ||
| 		Reason  string `json:"reason,omitempty"`
 | ||
| 	}
 | ||
| 	
 | ||
| 	if err := json.NewDecoder(r.Body).Decode(&claimData); err != nil {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid request body"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	if claimData.TeamID == "" {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "team_id is required"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Validate team exists
 | ||
| 	teamUUID, err := uuid.Parse(claimData.TeamID)
 | ||
| 	if err != nil {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid team_id format"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Check if team exists
 | ||
| 	_, _, err = s.teamComposer.GetTeam(r.Context(), teamUUID)
 | ||
| 	if err != nil {
 | ||
| 		if strings.Contains(err.Error(), "not found") {
 | ||
| 			render.Status(r, http.StatusNotFound)
 | ||
| 			render.JSON(w, r, map[string]string{"error": "team not found"})
 | ||
| 			return
 | ||
| 		}
 | ||
| 		
 | ||
| 		log.Error().Err(err).Str("team_id", claimData.TeamID).Msg("Failed to validate team")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to validate team"})
 | ||
| 		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", taskIDStr).
 | ||
| 		Str("team_id", claimData.TeamID).
 | ||
| 		Str("agent_id", claimData.AgentID).
 | ||
| 		Msg("Task assigned to team")
 | ||
| 	
 | ||
| 	render.JSON(w, r, map[string]interface{}{
 | ||
| 		"task_id":    taskIDStr,
 | ||
| 		"team_id":    claimData.TeamID,
 | ||
| 		"agent_id":   claimData.AgentID,
 | ||
| 		"status":     "claimed",
 | ||
| 		"claimed_at": time.Now().Format(time.RFC3339),
 | ||
| 		"message":    "Task assigned successfully",
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) updateTaskStatusHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	render.Status(r, http.StatusNotImplemented)
 | ||
| 	render.JSON(w, r, map[string]string{"error": "not implemented"})
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) completeTaskHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	render.Status(r, http.StatusNotImplemented)
 | ||
| 	render.JSON(w, r, map[string]string{"error": "not implemented"})
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) listAgentsHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	// Get discovered CHORUS agents from P2P discovery
 | ||
| 	discoveredAgents := s.p2pDiscovery.GetAgents()
 | ||
| 	
 | ||
| 	// Convert to API format
 | ||
| 	agents := make([]map[string]interface{}, 0, len(discoveredAgents))
 | ||
| 	onlineCount := 0
 | ||
| 	idleCount := 0
 | ||
| 	offlineCount := 0
 | ||
| 	
 | ||
| 	for _, agent := range discoveredAgents {
 | ||
| 		agentData := map[string]interface{}{
 | ||
| 			"id":           agent.ID,
 | ||
| 			"name":         agent.Name,
 | ||
| 			"status":       agent.Status,
 | ||
| 			"capabilities": agent.Capabilities,
 | ||
| 			"model":        agent.Model,
 | ||
| 			"endpoint":     agent.Endpoint,
 | ||
| 			"last_seen":    agent.LastSeen.Format(time.RFC3339),
 | ||
| 			"tasks_completed": agent.TasksCompleted,
 | ||
| 			"p2p_addr":     agent.P2PAddr,
 | ||
| 			"cluster_id":   agent.ClusterID,
 | ||
| 		}
 | ||
| 		
 | ||
| 		// Add current team if present
 | ||
| 		if agent.CurrentTeam != "" {
 | ||
| 			agentData["current_team"] = agent.CurrentTeam
 | ||
| 		} else {
 | ||
| 			agentData["current_team"] = nil
 | ||
| 		}
 | ||
| 		
 | ||
| 		agents = append(agents, agentData)
 | ||
| 		
 | ||
| 		// Count status
 | ||
| 		switch agent.Status {
 | ||
| 		case "online":
 | ||
| 			onlineCount++
 | ||
| 		case "idle":
 | ||
| 			idleCount++
 | ||
| 		case "working":
 | ||
| 			onlineCount++ // Working agents are considered online
 | ||
| 		default:
 | ||
| 			offlineCount++
 | ||
| 		}
 | ||
| 	}
 | ||
| 
 | ||
| 	render.JSON(w, r, map[string]interface{}{
 | ||
| 		"agents":  agents,
 | ||
| 		"total":   len(agents),
 | ||
| 		"online":  onlineCount,
 | ||
| 		"idle":    idleCount,
 | ||
| 		"offline": offlineCount,
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) registerAgentHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	var agentData struct {
 | ||
| 		Name         string                 `json:"name"`
 | ||
| 		EndpointURL  string                 `json:"endpoint_url"`
 | ||
| 		Capabilities map[string]interface{} `json:"capabilities"`
 | ||
| 	}
 | ||
| 	
 | ||
| 	if err := json.NewDecoder(r.Body).Decode(&agentData); err != nil {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid request body"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Validate required fields
 | ||
| 	if agentData.Name == "" || agentData.EndpointURL == "" {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "name and endpoint_url are required"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Create agent record
 | ||
| 	agent := &composer.Agent{
 | ||
| 		ID:                 uuid.New(),
 | ||
| 		Name:               agentData.Name,
 | ||
| 		EndpointURL:        agentData.EndpointURL,
 | ||
| 		Capabilities:       agentData.Capabilities,
 | ||
| 		Status:             composer.AgentStatusAvailable,
 | ||
| 		LastSeen:           time.Now(),
 | ||
| 		PerformanceMetrics: make(map[string]interface{}),
 | ||
| 		CreatedAt:          time.Now(),
 | ||
| 		UpdatedAt:          time.Now(),
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Initialize empty capabilities if none provided
 | ||
| 	if agent.Capabilities == nil {
 | ||
| 		agent.Capabilities = make(map[string]interface{})
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Insert into database
 | ||
| 	capabilitiesJSON, _ := json.Marshal(agent.Capabilities)
 | ||
| 	metricsJSON, _ := json.Marshal(agent.PerformanceMetrics)
 | ||
| 	
 | ||
| 	query := `
 | ||
| 		INSERT INTO agents (id, name, endpoint_url, capabilities, status, last_seen, performance_metrics, created_at, updated_at)
 | ||
| 		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
 | ||
| 	`
 | ||
| 	
 | ||
| 	_, err := s.db.Pool.Exec(r.Context(), query,
 | ||
| 		agent.ID, agent.Name, agent.EndpointURL, capabilitiesJSON,
 | ||
| 		agent.Status, agent.LastSeen, metricsJSON,
 | ||
| 		agent.CreatedAt, agent.UpdatedAt)
 | ||
| 		
 | ||
| 	if err != nil {
 | ||
| 		log.Error().Err(err).Str("agent_name", agent.Name).Msg("Failed to register agent")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to register agent"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("agent_id", agent.ID.String()).
 | ||
| 		Str("agent_name", agent.Name).
 | ||
| 		Str("endpoint", agent.EndpointURL).
 | ||
| 		Msg("Agent registered successfully")
 | ||
| 	
 | ||
| 	render.Status(r, http.StatusCreated)
 | ||
| 	render.JSON(w, r, map[string]interface{}{
 | ||
| 		"agent":   agent,
 | ||
| 		"message": "Agent registered successfully",
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) updateAgentStatusHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	agentIDStr := chi.URLParam(r, "agentID")
 | ||
| 	agentID, err := uuid.Parse(agentIDStr)
 | ||
| 	if err != nil {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid agent ID"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	var statusUpdate struct {
 | ||
| 		Status             string                 `json:"status"`
 | ||
| 		PerformanceMetrics map[string]interface{} `json:"performance_metrics,omitempty"`
 | ||
| 		Reason             string                 `json:"reason,omitempty"`
 | ||
| 	}
 | ||
| 	
 | ||
| 	if err := json.NewDecoder(r.Body).Decode(&statusUpdate); err != nil {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid request body"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Validate status values
 | ||
| 	validStatuses := map[string]bool{
 | ||
| 		"available": true,
 | ||
| 		"busy":      true,
 | ||
| 		"idle":      true,
 | ||
| 		"offline":   true,
 | ||
| 	}
 | ||
| 	
 | ||
| 	if !validStatuses[statusUpdate.Status] {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid status. Valid values: available, busy, idle, offline"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Update agent status and last_seen timestamp
 | ||
| 	updateQuery := `
 | ||
| 		UPDATE agents 
 | ||
| 		SET status = $1, last_seen = $2, updated_at = $2
 | ||
| 		WHERE id = $3
 | ||
| 	`
 | ||
| 	
 | ||
| 	_, err = s.db.Pool.Exec(r.Context(), updateQuery, statusUpdate.Status, time.Now(), agentID)
 | ||
| 	if err != nil {
 | ||
| 		log.Error().Err(err).
 | ||
| 			Str("agent_id", agentIDStr).
 | ||
| 			Str("status", statusUpdate.Status).
 | ||
| 			Msg("Failed to update agent status")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to update agent status"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Update performance metrics if provided
 | ||
| 	if statusUpdate.PerformanceMetrics != nil {
 | ||
| 		metricsJSON, _ := json.Marshal(statusUpdate.PerformanceMetrics)
 | ||
| 		_, err = s.db.Pool.Exec(r.Context(), 
 | ||
| 			`UPDATE agents SET performance_metrics = $1 WHERE id = $2`,
 | ||
| 			metricsJSON, agentID)
 | ||
| 		
 | ||
| 		if err != nil {
 | ||
| 			log.Warn().Err(err).
 | ||
| 				Str("agent_id", agentIDStr).
 | ||
| 				Msg("Failed to update agent performance metrics")
 | ||
| 		}
 | ||
| 	}
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("agent_id", agentIDStr).
 | ||
| 		Str("status", statusUpdate.Status).
 | ||
| 		Str("reason", statusUpdate.Reason).
 | ||
| 		Msg("Agent status updated")
 | ||
| 	
 | ||
| 	render.JSON(w, r, map[string]interface{}{
 | ||
| 		"agent_id": agentIDStr,
 | ||
| 		"status":   statusUpdate.Status,
 | ||
| 		"message":  "Agent status updated successfully",
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| // Project Management Handlers
 | ||
| 
 | ||
| func (s *Server) listProjectsHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	// For MVP, return hardcoded projects list
 | ||
| 	// In full implementation, this would query database
 | ||
| 	projects := []map[string]interface{}{
 | ||
| 		{
 | ||
| 			"id":          "whoosh-001",
 | ||
| 			"name":        "WHOOSH",
 | ||
| 			"repo_url":    "https://gitea.chorus.services/tony/WHOOSH",
 | ||
| 			"description": "Autonomous AI Development Teams Architecture",
 | ||
| 			"tech_stack":  []string{"Go", "Docker", "PostgreSQL"},
 | ||
| 			"status":      "active",
 | ||
| 			"created_at":  "2025-09-04T00:00:00Z",
 | ||
| 			"team_size":   3,
 | ||
| 		},
 | ||
| 		{
 | ||
| 			"id":          "chorus-001", 
 | ||
| 			"name":        "CHORUS",
 | ||
| 			"repo_url":    "https://gitea.chorus.services/tony/CHORUS",
 | ||
| 			"description": "AI Agent P2P Coordination System",
 | ||
| 			"tech_stack":  []string{"Go", "P2P", "LibP2P"},
 | ||
| 			"status":      "active",
 | ||
| 			"created_at":  "2025-09-03T00:00:00Z",
 | ||
| 			"team_size":   2,
 | ||
| 		},
 | ||
| 	}
 | ||
| 
 | ||
| 	render.JSON(w, r, map[string]interface{}{
 | ||
| 		"projects": projects,
 | ||
| 		"total":    len(projects),
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| // createProjectHandler handles POST /api/projects requests to add new GITEA repositories
 | ||
| // for team composition analysis. This is the core MVP functionality that allows users
 | ||
| // to register repositories that will be analyzed by the N8N workflow.
 | ||
| //
 | ||
| // Implementation decision: We use an anonymous struct for the request payload rather than
 | ||
| // a named struct because this is a simple, internal API that doesn't need to be shared
 | ||
| // across packages. This reduces complexity while maintaining type safety.
 | ||
| //
 | ||
| // TODO: In production, this would persist to PostgreSQL database rather than just
 | ||
| // returning in-memory data. The database integration is prepared in the docker-compose
 | ||
| // but not yet implemented in the handlers.
 | ||
| func (s *Server) createProjectHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	// Anonymous struct for request payload - simpler than defining a separate type
 | ||
| 	// for this single-use case. Contains the minimal required fields for MVP.
 | ||
| 	var req struct {
 | ||
| 		Name        string `json:"name"`        // User-friendly project name
 | ||
| 		RepoURL     string `json:"repo_url"`    // GITEA repository URL for analysis
 | ||
| 		Description string `json:"description"` // Optional project description
 | ||
| 	}
 | ||
| 
 | ||
| 	// Use json.NewDecoder instead of render.Bind because render.Bind requires
 | ||
| 	// implementing the render.Binder interface, which adds unnecessary complexity
 | ||
| 	// for simple JSON parsing. Direct JSON decoding is more straightforward.
 | ||
| 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid request"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 
 | ||
| 	// Basic validation - both name and repo_url are required for meaningful analysis.
 | ||
| 	// The N8N workflow needs the repo URL to fetch files, and we need a name for UI display.
 | ||
| 	if req.RepoURL == "" || req.Name == "" {
 | ||
| 		render.Status(r, http.StatusBadRequest) 
 | ||
| 		render.JSON(w, r, map[string]string{"error": "name and repo_url are required"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 
 | ||
| 	// Generate unique project ID using Unix timestamp. In production, this would be
 | ||
| 	// a proper UUID or database auto-increment, but for MVP simplicity, timestamp-based
 | ||
| 	// IDs are sufficient and provide natural ordering.
 | ||
| 	projectID := fmt.Sprintf("proj-%d", time.Now().Unix())
 | ||
| 	
 | ||
| 	// Project data structure matches the expected format for the frontend UI.
 | ||
| 	// Status "created" indicates the project is registered but not yet analyzed.
 | ||
| 	// This will be updated to "analyzing" -> "completed" by the N8N workflow.
 | ||
| 	project := map[string]interface{}{
 | ||
| 		"id":          projectID,
 | ||
| 		"name":        req.Name,
 | ||
| 		"repo_url":    req.RepoURL,
 | ||
| 		"description": req.Description,
 | ||
| 		"status":      "created",
 | ||
| 		"created_at":  time.Now().Format(time.RFC3339),
 | ||
| 		"team_size":   0, // Will be populated after N8N analysis
 | ||
| 	}
 | ||
| 
 | ||
| 	// Structured logging with zerolog provides excellent performance and
 | ||
| 	// searchability in production environments. Include key identifiers
 | ||
| 	// for debugging and audit trails.
 | ||
| 	log.Info().
 | ||
| 		Str("project_id", projectID).
 | ||
| 		Str("repo_url", req.RepoURL).
 | ||
| 		Msg("Created new project")
 | ||
| 
 | ||
| 	// Return 201 Created with the project data. The frontend will use this
 | ||
| 	// response to update the UI and potentially trigger immediate analysis.
 | ||
| 	render.Status(r, http.StatusCreated)
 | ||
| 	render.JSON(w, r, project)
 | ||
| }
 | ||
| 
 | ||
| // deleteProjectHandler handles DELETE /api/projects/{projectID} requests to remove
 | ||
| // repositories from management. This allows users to clean up their project list
 | ||
| // and stop monitoring repositories that are no longer relevant.
 | ||
| //
 | ||
| // Implementation decision: We use chi.URLParam to extract the project ID from the
 | ||
| // URL path rather than query parameters, following REST conventions where the
 | ||
| // resource identifier is part of the path structure.
 | ||
| func (s *Server) deleteProjectHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	// Extract project ID from URL path parameter. Chi router handles the parsing
 | ||
| 	// and validation of the URL structure, so we can safely assume this exists
 | ||
| 	// if the route matched.
 | ||
| 	projectID := chi.URLParam(r, "projectID")
 | ||
| 	
 | ||
| 	// Log the deletion for audit purposes. In a production system, you'd want
 | ||
| 	// to track who deleted what and when for compliance and debugging.
 | ||
| 	log.Info().
 | ||
| 		Str("project_id", projectID).
 | ||
| 		Msg("Deleted project")
 | ||
| 	
 | ||
| 	render.JSON(w, r, map[string]string{"message": "project deleted"})
 | ||
| }
 | ||
| 
 | ||
| // getProjectHandler handles GET /api/projects/{projectID} requests to retrieve
 | ||
| // detailed information about a specific project, including its analysis results
 | ||
| // and team formation recommendations from the N8N workflow.
 | ||
| //
 | ||
| // Implementation decision: We return mock data for now since database persistence
 | ||
| // isn't implemented yet. In production, this would query PostgreSQL for the
 | ||
| // actual project record and its associated analysis results.
 | ||
| func (s *Server) getProjectHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	projectID := chi.URLParam(r, "projectID")
 | ||
| 	
 | ||
| 	// TODO: Replace with database query - this mock data demonstrates the expected
 | ||
| 	// response structure that the frontend UI will consume. The tech_stack and
 | ||
| 	// team_size fields would be populated by N8N workflow analysis results.
 | ||
| 	project := map[string]interface{}{
 | ||
| 		"id":          projectID,
 | ||
| 		"name":        "Sample Project",
 | ||
| 		"repo_url":    "https://gitea.chorus.services/tony/" + projectID,
 | ||
| 		"description": "Sample project description",
 | ||
| 		"tech_stack":  []string{"Go", "JavaScript"}, // From N8N analysis
 | ||
| 		"status":      "active",
 | ||
| 		"created_at":  "2025-09-04T00:00:00Z",
 | ||
| 		"team_size":   2, // From N8N team formation recommendations
 | ||
| 	}
 | ||
| 
 | ||
| 	render.JSON(w, r, project)
 | ||
| }
 | ||
| 
 | ||
| // analyzeProjectHandler handles POST /api/projects/{projectID}/analyze requests to
 | ||
| // trigger the N8N Team Formation Analysis workflow. This is the core integration point
 | ||
| // that connects WHOOSH to the AI-powered repository analysis system.
 | ||
| //
 | ||
| // Implementation decisions:
 | ||
| // 1. 60-second timeout for N8N requests because LLM analysis can be slow
 | ||
| // 2. Direct HTTP client rather than a service layer for simplicity in MVP
 | ||
| // 3. Graceful fallback to mock data when request body is empty
 | ||
| // 4. Comprehensive error handling with structured logging for debugging
 | ||
| //
 | ||
| // This handler represents the "missing link" that was identified as the core need:
 | ||
| // WHOOSH UI → N8N workflow → LLM analysis → team formation recommendations
 | ||
| func (s *Server) analyzeProjectHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	projectID := chi.URLParam(r, "projectID")
 | ||
| 	
 | ||
| 	// Project data structure for N8N payload. In production, this would be fetched
 | ||
| 	// from the database using the projectID, but for MVP we allow it to be provided
 | ||
| 	// in the request body or fall back to predictable mock data.
 | ||
| 	var projectData struct {
 | ||
| 		RepoURL string `json:"repo_url"`
 | ||
| 		Name    string `json:"name"`
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Handle both scenarios: explicit project data in request body (for testing)
 | ||
| 	// and implicit data fetching (for production UI). This flexibility makes the
 | ||
| 	// API easier to test manually while supporting the intended UI workflow.
 | ||
| 	if r.Body != http.NoBody {
 | ||
| 		if err := json.NewDecoder(r.Body).Decode(&projectData); err != nil {
 | ||
| 			// Fallback to predictable mock data based on projectID for testing
 | ||
| 			projectData.RepoURL = "https://gitea.chorus.services/tony/" + projectID
 | ||
| 			projectData.Name = projectID
 | ||
| 		}
 | ||
| 	} else {
 | ||
| 		// No body provided - use mock data (in production, would query database)
 | ||
| 		projectData.RepoURL = "https://gitea.chorus.services/tony/" + projectID
 | ||
| 		projectData.Name = projectID
 | ||
| 	}
 | ||
| 
 | ||
| 	// Start BACKBEAT search tracking if available
 | ||
| 	searchID := fmt.Sprintf("analyze-%s", projectID)
 | ||
| 	if s.backbeat != nil {
 | ||
| 		if err := s.backbeat.StartSearch(searchID, fmt.Sprintf("Analyzing project %s (%s)", projectID, projectData.RepoURL), 4); err != nil {
 | ||
| 			log.Warn().Err(err).Msg("Failed to start BACKBEAT search tracking")
 | ||
| 		}
 | ||
| 	}
 | ||
| 
 | ||
| 	// Log the analysis initiation for debugging and audit trails. Repository URL
 | ||
| 	// is crucial for troubleshooting N8N workflow issues.
 | ||
| 	log.Info().
 | ||
| 		Str("project_id", projectID).
 | ||
| 		Str("repo_url", projectData.RepoURL).
 | ||
| 		Msg("🔍 Starting project analysis via N8N workflow with BACKBEAT tracking")
 | ||
| 
 | ||
| 	// Execute analysis within BACKBEAT beat budget (4 beats = 2 minutes at 2 BPM)
 | ||
| 	var analysisResult map[string]interface{}
 | ||
| 	analysisFunc := func() error {
 | ||
| 		// Update BACKBEAT phase to querying
 | ||
| 		if s.backbeat != nil {
 | ||
| 			s.backbeat.UpdateSearchPhase(searchID, backbeat.PhaseQuerying, 0)
 | ||
| 		}
 | ||
| 		
 | ||
| 		// HTTP client with generous timeout because:
 | ||
| 		// 1. N8N workflow fetches multiple files from repository
 | ||
| 		// 2. LLM analysis (Ollama) can take 10-30 seconds depending on model size
 | ||
| 		// 3. Network latency between services in Docker Swarm
 | ||
| 		// 60 seconds provides buffer while still failing fast for real issues
 | ||
| 		client := &http.Client{Timeout: 60 * time.Second}
 | ||
| 		
 | ||
| 		// Payload structure matches the N8N workflow webhook expectations.
 | ||
| 		// The workflow expects these exact field names to properly route data
 | ||
| 		// through the file fetching and analysis nodes.
 | ||
| 		payload := map[string]interface{}{
 | ||
| 			"repo_url":     projectData.RepoURL,  // Primary input for file fetching
 | ||
| 			"project_name": projectData.Name,     // Used in LLM analysis context
 | ||
| 		}
 | ||
| 		
 | ||
| 		// JSON marshaling without error checking is acceptable here because we control
 | ||
| 		// the payload structure and know it will always be valid JSON.
 | ||
| 		payloadBytes, _ := json.Marshal(payload)
 | ||
| 		
 | ||
| 		// Direct call to production N8N instance. In a more complex system, this URL
 | ||
| 		// would be configurable, but for MVP we can hardcode the known endpoint.
 | ||
| 		// The webhook URL was configured when we created the N8N workflow.
 | ||
| 		resp, err := client.Post(
 | ||
| 			"https://n8n.home.deepblack.cloud/webhook/team-formation",
 | ||
| 			"application/json",
 | ||
| 			bytes.NewBuffer(payloadBytes),
 | ||
| 		)
 | ||
| 		
 | ||
| 		// Network-level error handling (connection refused, timeout, DNS issues)
 | ||
| 		if err != nil {
 | ||
| 			log.Error().Err(err).Msg("Failed to trigger N8N workflow")
 | ||
| 			return fmt.Errorf("failed to trigger N8N workflow: %w", err)
 | ||
| 		}
 | ||
| 		defer resp.Body.Close()
 | ||
| 		
 | ||
| 		// HTTP-level error handling (N8N returned an error status)
 | ||
| 		if resp.StatusCode != http.StatusOK {
 | ||
| 			log.Error().
 | ||
| 				Int("status", resp.StatusCode).
 | ||
| 				Msg("N8N workflow returned error")
 | ||
| 			return fmt.Errorf("N8N workflow returned status %d", resp.StatusCode)
 | ||
| 		}
 | ||
| 		
 | ||
| 		// Update BACKBEAT phase to ranking
 | ||
| 		if s.backbeat != nil {
 | ||
| 			s.backbeat.UpdateSearchPhase(searchID, backbeat.PhaseRanking, 0)
 | ||
| 		}
 | ||
| 		
 | ||
| 		// Read the N8N workflow response, which contains the team formation analysis
 | ||
| 		// results including detected technologies, complexity scores, and agent assignments.
 | ||
| 		body, err := io.ReadAll(resp.Body)
 | ||
| 		if err != nil {
 | ||
| 			log.Error().Err(err).Msg("Failed to read N8N response")
 | ||
| 			return fmt.Errorf("failed to read N8N response: %w", err)
 | ||
| 		}
 | ||
| 		
 | ||
| 		// Parse and return N8N response
 | ||
| 		if err := json.Unmarshal(body, &analysisResult); err != nil {
 | ||
| 			log.Error().Err(err).Msg("Failed to parse N8N response")
 | ||
| 			return fmt.Errorf("failed to parse N8N response: %w", err)
 | ||
| 		}
 | ||
| 		
 | ||
| 		return nil
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Execute analysis with BACKBEAT beat budget or fallback to direct execution
 | ||
| 	var analysisErr error
 | ||
| 	if s.backbeat != nil {
 | ||
| 		analysisErr = s.backbeat.ExecuteWithBeatBudget(4, analysisFunc)
 | ||
| 		if analysisErr != nil {
 | ||
| 			s.backbeat.FailSearch(searchID, analysisErr.Error())
 | ||
| 		}
 | ||
| 	} else {
 | ||
| 		analysisErr = analysisFunc()
 | ||
| 	}
 | ||
| 	
 | ||
| 	if analysisErr != nil {
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": analysisErr.Error()})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Complete BACKBEAT search tracking
 | ||
| 	if s.backbeat != nil {
 | ||
| 		s.backbeat.CompleteSearch(searchID, 1)
 | ||
| 	}
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("project_id", projectID).
 | ||
| 		Msg("🔍 Project analysis completed successfully with BACKBEAT tracking")
 | ||
| 	
 | ||
| 	render.JSON(w, r, analysisResult)
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) giteaWebhookHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	// Parse webhook payload
 | ||
| 	payload, err := s.webhookHandler.ParsePayload(r)
 | ||
| 	if err != nil {
 | ||
| 		log.Error().Err(err).Msg("Failed to parse webhook payload")
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid payload"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 
 | ||
| 	log.Info().
 | ||
| 		Str("action", payload.Action).
 | ||
| 		Str("repository", payload.Repository.FullName).
 | ||
| 		Str("sender", payload.Sender.Login).
 | ||
| 		Msg("Received GITEA webhook")
 | ||
| 
 | ||
| 	// Process webhook event
 | ||
| 	event := s.webhookHandler.ProcessWebhook(payload)
 | ||
| 
 | ||
| 	// Handle task-related webhooks
 | ||
| 	if event.TaskInfo != nil {
 | ||
| 		log.Info().
 | ||
| 			Interface("task_info", event.TaskInfo).
 | ||
| 			Msg("Processing task issue")
 | ||
| 
 | ||
| 		// MVP: Store basic task info for future team assignment
 | ||
| 		// In full implementation, this would trigger Team Composer
 | ||
| 		s.handleTaskWebhook(r.Context(), event)
 | ||
| 	}
 | ||
| 
 | ||
| 	render.JSON(w, r, map[string]interface{}{
 | ||
| 		"status":    "received",
 | ||
| 		"event_id":  event.Timestamp,
 | ||
| 		"processed": event.TaskInfo != nil,
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) handleTaskWebhook(ctx context.Context, event *gitea.WebhookEvent) {
 | ||
| 	// MVP implementation: Log task details
 | ||
| 	// In full version, this would:
 | ||
| 	// 1. Analyze task complexity
 | ||
| 	// 2. Determine required team composition
 | ||
| 	// 3. Create team and assign agents
 | ||
| 	// 4. Set up P2P communication channels
 | ||
| 
 | ||
| 	log.Info().
 | ||
| 		Str("action", event.Action).
 | ||
| 		Str("repository", event.Repository).
 | ||
| 		Interface("task_info", event.TaskInfo).
 | ||
| 		Msg("Task webhook received - MVP logging")
 | ||
| 
 | ||
| 	// For MVP, we'll just acknowledge task detection
 | ||
| 	if event.Action == "opened" || event.Action == "reopened" {
 | ||
| 		taskType := event.TaskInfo["task_type"].(string)
 | ||
| 		priority := event.TaskInfo["priority"].(string)
 | ||
| 		
 | ||
| 		log.Info().
 | ||
| 			Str("task_type", taskType).
 | ||
| 			Str("priority", priority).
 | ||
| 			Msg("New task detected - ready for team assignment")
 | ||
| 	}
 | ||
| }
 | ||
| 
 | ||
| func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	html := `<!DOCTYPE html>
 | ||
| <html lang="en">
 | ||
| <head>
 | ||
|     <meta charset="UTF-8">
 | ||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | ||
|     <title>WHOOSH - AI Team Orchestration</title>
 | ||
|     <style>
 | ||
|         body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 0; background: #f8fafc; }
 | ||
|         .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px 0; }
 | ||
|         .header-content { max-width: 1200px; margin: 0 auto; padding: 0 20px; display: flex; justify-content: space-between; align-items: center; }
 | ||
|         .logo { font-size: 24px; font-weight: bold; }
 | ||
|         .status-dot { width: 12px; height: 12px; border-radius: 50%; background: #27ae60; margin-right: 8px; display: inline-block; }
 | ||
|         .nav-tabs { max-width: 1200px; margin: 0 auto; padding: 0 20px; display: flex; border-bottom: 1px solid #e2e8f0; background: white; }
 | ||
|         .nav-tab { padding: 15px 25px; cursor: pointer; border-bottom: 3px solid transparent; font-weight: 500; transition: all 0.2s; }
 | ||
|         .nav-tab.active { border-bottom-color: #667eea; color: #667eea; background: #f8fafc; }
 | ||
|         .nav-tab:hover { background: #f1f5f9; }
 | ||
|         .content { max-width: 1200px; margin: 0 auto; padding: 20px; }
 | ||
|         .tab-content { display: none; }
 | ||
|         .tab-content.active { display: block; }
 | ||
|         .dashboard-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 20px; margin-bottom: 30px; }
 | ||
|         .card { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); border: 1px solid #e2e8f0; }
 | ||
|         .card h3 { margin: 0 0 16px 0; color: #1a202c; font-size: 18px; display: flex; align-items: center; }
 | ||
|         .card-icon { width: 20px; height: 20px; margin-right: 12px; }
 | ||
|         .metric { display: flex; justify-content: space-between; margin: 8px 0; padding: 8px 0; }
 | ||
|         .metric:not(:last-child) { border-bottom: 1px solid #f7fafc; }
 | ||
|         .metric-label { color: #718096; }
 | ||
|         .metric-value { font-weight: 600; color: #2d3748; }
 | ||
|         .task-item { background: #f8fafc; border-radius: 8px; padding: 16px; margin-bottom: 12px; border-left: 4px solid #e2e8f0; }
 | ||
|         .task-item.priority-high { border-left-color: #e53e3e; }
 | ||
|         .task-item.priority-medium { border-left-color: #dd6b20; }
 | ||
|         .task-item.priority-low { border-left-color: #38a169; }
 | ||
|         .task-title { font-weight: 600; color: #2d3748; margin-bottom: 8px; }
 | ||
|         .task-meta { display: flex; justify-content: space-between; color: #718096; font-size: 14px; }
 | ||
|         .agent-card { background: #f8fafc; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
 | ||
|         .agent-status { width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; display: inline-block; }
 | ||
|         .agent-status.online { background: #38a169; }
 | ||
|         .agent-status.offline { background: #a0aec0; }
 | ||
|         .team-member { display: flex; align-items: center; padding: 8px; background: white; border-radius: 6px; margin-bottom: 8px; }
 | ||
|         .btn { padding: 8px 16px; border-radius: 6px; border: none; font-weight: 500; cursor: pointer; transition: all 0.2s; }
 | ||
|         .btn-primary { background: #667eea; color: white; }
 | ||
|         .btn-primary:hover { background: #5a6fd8; }
 | ||
|         .btn-secondary { background: #e2e8f0; color: #4a5568; }
 | ||
|         .btn-secondary:hover { background: #cbd5e0; }
 | ||
|         .empty-state { text-align: center; padding: 40px 20px; color: #718096; }
 | ||
|         .empty-state-icon { font-size: 48px; margin-bottom: 16px; }
 | ||
|     </style>
 | ||
| </head>
 | ||
| <body>
 | ||
|     <div class="header">
 | ||
|         <div class="header-content">
 | ||
|             <div class="logo">🎭 WHOOSH</div>
 | ||
|             <div style="display: flex; align-items: center;">
 | ||
|                 <span class="status-dot"></span>
 | ||
|                 <span>System Online</span>
 | ||
|             </div>
 | ||
|         </div>
 | ||
|     </div>
 | ||
|     
 | ||
|     <div class="nav-tabs">
 | ||
|         <div class="nav-tab active" onclick="showTab('overview')">Overview</div>
 | ||
|         <div class="nav-tab" onclick="showTab('tasks')">Tasks</div>
 | ||
|         <div class="nav-tab" onclick="showTab('teams')">Teams</div>
 | ||
|         <div class="nav-tab" onclick="showTab('agents')">Agents</div>
 | ||
|         <div class="nav-tab" onclick="showTab('repositories')">Repositories</div>
 | ||
|         <div class="nav-tab" onclick="showTab('settings')">Settings</div>
 | ||
|     </div>
 | ||
|     
 | ||
|     <div class="content">
 | ||
|         <!-- Overview Tab -->
 | ||
|         <div id="overview" class="tab-content active">
 | ||
|             <div class="dashboard-grid">
 | ||
|                 <div class="card">
 | ||
|                     <h3>📊 System Metrics</h3>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Active Teams</span>
 | ||
|                         <span class="metric-value">0</span>
 | ||
|                     </div>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Pending Tasks</span>
 | ||
|                         <span class="metric-value" data-metric="pending-tasks">0</span>
 | ||
|                     </div>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Registered Agents</span>
 | ||
|                         <span class="metric-value" data-metric="registered-agents">0</span>
 | ||
|                     </div>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Tasks Completed Today</span>
 | ||
|                         <span class="metric-value">0</span>
 | ||
|                     </div>
 | ||
|                 </div>
 | ||
|                 
 | ||
|                 <div class="card">
 | ||
|                     <h3>🔄 Recent Activity</h3>
 | ||
|                     <div class="empty-state">
 | ||
|                         <div class="empty-state-icon">📭</div>
 | ||
|                         <p>No recent activity</p>
 | ||
|                         <p style="font-size: 14px;">Task activity will appear here once agents start working</p>
 | ||
|                     </div>
 | ||
|                 </div>
 | ||
|                 
 | ||
|                 <div class="card">
 | ||
|                     <h3>🎯 System Status</h3>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Service Health</span>
 | ||
|                         <span class="metric-value" style="color: #38a169;">✅ Healthy</span>
 | ||
|                     </div>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Database</span>
 | ||
|                         <span class="metric-value" style="color: #38a169;">✅ Connected</span>
 | ||
|                     </div>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">GITEA Integration</span>
 | ||
|                         <span class="metric-value" style="color: #38a169;">✅ Active</span>
 | ||
|                     </div>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Redis Cache</span>
 | ||
|                         <span class="metric-value" style="color: #38a169;">✅ Running</span>
 | ||
|                     </div>
 | ||
|                 </div>
 | ||
|                 
 | ||
|                 <div class="card">
 | ||
|                     <h3>🥁 BACKBEAT Clock</h3>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Current Beat</span>
 | ||
|                         <span class="metric-value" id="current-beat" style="color: #667eea;">--</span>
 | ||
|                     </div>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Downbeat</span>
 | ||
|                         <span class="metric-value" id="current-downbeat" style="color: #e53e3e;">--</span>
 | ||
|                     </div>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Avg Interval</span>
 | ||
|                         <span class="metric-value" id="avg-interval" style="color: #38a169;">--ms</span>
 | ||
|                     </div>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Phase</span>
 | ||
|                         <span class="metric-value" id="beat-phase" style="color: #dd6b20;">--</span>
 | ||
|                     </div>
 | ||
|                     <div style="margin-top: 16px; height: 60px; background: #f8fafc; border-radius: 6px; position: relative; overflow: hidden;">
 | ||
|                         <canvas id="pulse-trace" width="100%" height="60" style="width: 100%; height: 60px;"></canvas>
 | ||
|                     </div>
 | ||
|                     <div style="text-align: center; margin-top: 8px; font-size: 12px; color: #718096;">
 | ||
|                         Live BACKBEAT Pulse
 | ||
|                     </div>
 | ||
|                 </div>
 | ||
|             </div>
 | ||
|         </div>
 | ||
|         
 | ||
|         <!-- Tasks Tab -->
 | ||
|         <div id="tasks" class="tab-content">
 | ||
|             <div style="display: flex; justify-content: between; align-items: center; margin-bottom: 20px;">
 | ||
|                 <h2>📋 Task Management</h2>
 | ||
|                 <button class="btn btn-primary" onclick="refreshTasks()">🔄 Refresh Tasks</button>
 | ||
|             </div>
 | ||
|             
 | ||
|             <div class="dashboard-grid">
 | ||
|                 <div class="card">
 | ||
|                     <h3>🎯 Active Tasks</h3>
 | ||
|                     <div id="active-tasks">
 | ||
|                         <div class="empty-state">
 | ||
|                             <div class="empty-state-icon">📝</div>
 | ||
|                             <p>No active tasks</p>
 | ||
|                             <p style="font-size: 14px;">bzzz-task issues will appear here from GITEA</p>
 | ||
|                         </div>
 | ||
|                     </div>
 | ||
|                 </div>
 | ||
|                 
 | ||
|                 <div class="card">
 | ||
|                     <h3>⏳ Task Queue</h3>
 | ||
|                     <div id="queued-tasks">
 | ||
|                         <div class="empty-state">
 | ||
|                             <div class="empty-state-icon">⏱️</div>
 | ||
|                             <p>No queued tasks</p>
 | ||
|                             <p style="font-size: 14px;">Tasks awaiting team assignment</p>
 | ||
|                         </div>
 | ||
|                     </div>
 | ||
|                 </div>
 | ||
|             </div>
 | ||
|         </div>
 | ||
|         
 | ||
|         <!-- Teams Tab -->
 | ||
|         <div id="teams" class="tab-content">
 | ||
|             <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
 | ||
|                 <h2>👥 Team Management</h2>
 | ||
|                 <button class="btn btn-primary" onclick="loadTeams()">🔄 Refresh Teams</button>
 | ||
|             </div>
 | ||
|             
 | ||
|             <div id="teams-grid" class="dashboard-grid">
 | ||
|                 <div class="card">
 | ||
|                     <div class="empty-state">
 | ||
|                         <div class="empty-state-icon">👥</div>
 | ||
|                         <p>No assembled teams</p>
 | ||
|                         <p style="font-size: 14px;">Teams are automatically assembled when tasks are assigned to agents</p>
 | ||
|                     </div>
 | ||
|                 </div>
 | ||
|             </div>
 | ||
|         </div>
 | ||
|         
 | ||
|         <!-- Agents Tab -->
 | ||
|         <div id="agents" class="tab-content">
 | ||
|             <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
 | ||
|                 <h2>🤖 Agent Management</h2>
 | ||
|                 <button class="btn btn-primary" onclick="loadAgents()">🔄 Refresh Agents</button>
 | ||
|             </div>
 | ||
|             
 | ||
|             <div id="agents-grid" class="dashboard-grid">
 | ||
|                 <div class="card">
 | ||
|                     <div class="empty-state">
 | ||
|                         <div class="empty-state-icon">🤖</div>
 | ||
|                         <p>No agents discovered</p>
 | ||
|                         <p style="font-size: 14px;">CHORUS agents are discovered organically and their personas tracked here</p>
 | ||
|                     </div>
 | ||
|                 </div>
 | ||
|             </div>
 | ||
|         </div>
 | ||
|         
 | ||
|         <!-- Settings Tab -->
 | ||
|         <div id="settings" class="tab-content">
 | ||
|             <h2>⚙️ System Configuration</h2>
 | ||
|             <div class="dashboard-grid">
 | ||
|                 <div class="card">
 | ||
|                     <h3>🔗 GITEA Integration</h3>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Base URL</span>
 | ||
|                         <span class="metric-value">gitea.chorus.services</span>
 | ||
|                     </div>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Webhook Path</span>
 | ||
|                         <span class="metric-value">/webhooks/gitea</span>
 | ||
|                     </div>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Token Status</span>
 | ||
|                         <span class="metric-value" style="color: #38a169;">✅ Valid</span>
 | ||
|                     </div>
 | ||
|                 </div>
 | ||
|                 
 | ||
|                 <div class="card">
 | ||
|                     <h3>🗄️ Database Configuration</h3>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Host</span>
 | ||
|                         <span class="metric-value">postgres:5432</span>
 | ||
|                     </div>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">SSL Mode</span>
 | ||
|                         <span class="metric-value">disabled</span>
 | ||
|                     </div>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Auto-Migrate</span>
 | ||
|                         <span class="metric-value">enabled</span>
 | ||
|                     </div>
 | ||
|                 </div>
 | ||
|             </div>
 | ||
|         </div>
 | ||
|         
 | ||
|         <!-- Repositories Tab -->
 | ||
|         <div id="repositories" class="tab-content">
 | ||
|             <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
 | ||
|                 <h2>📚 Repository Management</h2>
 | ||
|                 <button onclick="showAddRepositoryForm()" style="background: #667eea; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-weight: 500;">+ Add Repository</button>
 | ||
|             </div>
 | ||
|             
 | ||
|             <div id="repository-stats" class="dashboard-grid" style="margin-bottom: 20px;">
 | ||
|                 <div class="card">
 | ||
|                     <h3>📊 Repository Stats</h3>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Total Repositories</span>
 | ||
|                         <span class="metric-value" id="total-repositories">--</span>
 | ||
|                     </div>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Active Monitoring</span>
 | ||
|                         <span class="metric-value" id="active-repositories">--</span>
 | ||
|                     </div>
 | ||
|                     <div class="metric">
 | ||
|                         <span class="metric-label">Tasks Created</span>
 | ||
|                         <span class="metric-value" id="total-tasks-from-repos">--</span>
 | ||
|                     </div>
 | ||
|                 </div>
 | ||
|             </div>
 | ||
|             
 | ||
|             <div id="add-repository-form" style="display: none; margin-bottom: 20px;">
 | ||
|                 <div class="card">
 | ||
|                     <h3>➕ Add New Repository</h3>
 | ||
|                     <form onsubmit="addRepository(event)">
 | ||
|                         <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px;">
 | ||
|                             <div>
 | ||
|                                 <label style="display: block; margin-bottom: 5px; font-weight: 500;">Repository Name</label>
 | ||
|                                 <input type="text" id="repo-name" required style="width: 100%; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px;" placeholder="e.g., WHOOSH">
 | ||
|                             </div>
 | ||
|                             <div>
 | ||
|                                 <label style="display: block; margin-bottom: 5px; font-weight: 500;">Owner</label>
 | ||
|                                 <input type="text" id="repo-owner" required style="width: 100%; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px;" placeholder="e.g., tony">
 | ||
|                             </div>
 | ||
|                         </div>
 | ||
|                         
 | ||
|                         <div style="margin-bottom: 15px;">
 | ||
|                             <label style="display: block; margin-bottom: 5px; font-weight: 500;">Repository URL</label>
 | ||
|                             <input type="url" id="repo-url" required style="width: 100%; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px;" placeholder="https://gitea.chorus.services/tony/WHOOSH">
 | ||
|                         </div>
 | ||
|                         
 | ||
|                         <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px;">
 | ||
|                             <div>
 | ||
|                                 <label style="display: block; margin-bottom: 5px; font-weight: 500;">Source Type</label>
 | ||
|                                 <select id="repo-source-type" style="width: 100%; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px;">
 | ||
|                                     <option value="gitea">Gitea</option>
 | ||
|                                     <option value="github">GitHub</option>
 | ||
|                                     <option value="gitlab">GitLab</option>
 | ||
|                                 </select>
 | ||
|                             </div>
 | ||
|                             <div>
 | ||
|                                 <label style="display: block; margin-bottom: 5px; font-weight: 500;">Default Branch</label>
 | ||
|                                 <input type="text" id="repo-branch" value="main" style="width: 100%; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px;">
 | ||
|                             </div>
 | ||
|                         </div>
 | ||
|                         
 | ||
|                         <div style="margin-bottom: 15px;">
 | ||
|                             <label style="display: block; margin-bottom: 5px; font-weight: 500;">Description (Optional)</label>
 | ||
|                             <textarea id="repo-description" rows="2" style="width: 100%; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px;" placeholder="Brief description of this repository..."></textarea>
 | ||
|                         </div>
 | ||
|                         
 | ||
|                         <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 15px; margin-bottom: 15px;">
 | ||
|                             <label style="display: flex; align-items: center; font-weight: 500;">
 | ||
|                                 <input type="checkbox" id="monitor-issues" checked style="margin-right: 8px;">
 | ||
|                                 Monitor Issues
 | ||
|                             </label>
 | ||
|                             <label style="display: flex; align-items: center; font-weight: 500;">
 | ||
|                                 <input type="checkbox" id="monitor-prs" style="margin-right: 8px;">
 | ||
|                                 Monitor Pull Requests
 | ||
|                             </label>
 | ||
|                             <label style="display: flex; align-items: center; font-weight: 500;">
 | ||
|                                 <input type="checkbox" id="enable-chorus" checked style="margin-right: 8px;">
 | ||
|                                 Enable CHORUS Integration
 | ||
|                             </label>
 | ||
|                         </div>
 | ||
|                         
 | ||
|                         <div style="text-align: right;">
 | ||
|                             <button type="button" onclick="hideAddRepositoryForm()" style="background: #e2e8f0; color: #4a5568; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; margin-right: 10px;">Cancel</button>
 | ||
|                             <button type="submit" style="background: #38a169; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-weight: 500;">Add Repository</button>
 | ||
|                         </div>
 | ||
|                     </form>
 | ||
|                 </div>
 | ||
|             </div>
 | ||
|             
 | ||
|             <div class="card">
 | ||
|                 <h3>📋 Monitored Repositories</h3>
 | ||
|                 <div id="repositories-list">
 | ||
|                     <p style="text-align: center; color: #718096; padding: 20px;">Loading repositories...</p>
 | ||
|                 </div>
 | ||
|             </div>
 | ||
|         </div>
 | ||
|     </div>
 | ||
| 
 | ||
|     <script>
 | ||
|         function showTab(tabName) {
 | ||
|             // Hide all tab contents
 | ||
|             document.querySelectorAll('.tab-content').forEach(content => {
 | ||
|                 content.classList.remove('active');
 | ||
|             });
 | ||
|             
 | ||
|             // Remove active class from all tabs
 | ||
|             document.querySelectorAll('.nav-tab').forEach(tab => {
 | ||
|                 tab.classList.remove('active');
 | ||
|             });
 | ||
|             
 | ||
|             // Show selected tab content
 | ||
|             document.getElementById(tabName).classList.add('active');
 | ||
|             
 | ||
|             // Add active class to clicked tab
 | ||
|             event.target.classList.add('active');
 | ||
|             
 | ||
|             // Load data for specific tabs
 | ||
|             if (tabName === 'tasks') {
 | ||
|                 loadTasks();
 | ||
|             } else if (tabName === 'teams') {
 | ||
|                 loadTeams();
 | ||
|             } else if (tabName === 'agents') {
 | ||
|                 loadAgents();
 | ||
|             } else if (tabName === 'repositories') {
 | ||
|                 loadRepositories();
 | ||
|             }
 | ||
|         }
 | ||
|         
 | ||
|         function refreshTasks() {
 | ||
|             // Implement task refresh logic
 | ||
|             console.log('Refreshing tasks...');
 | ||
|         }
 | ||
|         
 | ||
|         
 | ||
|         function loadTasks() {
 | ||
|             // Load tasks from API
 | ||
|             fetch('/api/v1/tasks')
 | ||
|                 .then(response => response.json())
 | ||
|                 .then(data => {
 | ||
|                     console.log('Tasks loaded:', data);
 | ||
|                     updateTasksUI(data);
 | ||
|                 })
 | ||
|                 .catch(error => {
 | ||
|                     console.error('Error loading tasks:', error);
 | ||
|                 });
 | ||
|         }
 | ||
|         
 | ||
|         function updateTasksUI(data) {
 | ||
|             const activeTasks = data.tasks.filter(task => task.status === 'active');
 | ||
|             const queuedTasks = data.tasks.filter(task => task.status === 'queued');
 | ||
|             
 | ||
|             // Update active tasks
 | ||
|             const activeTasksContainer = document.getElementById('active-tasks');
 | ||
|             if (activeTasks.length > 0) {
 | ||
|                 activeTasksContainer.innerHTML = activeTasks.map(task => 
 | ||
|                     '<div class="task-item priority-' + task.priority + '">' +
 | ||
|                         '<div class="task-title">' + task.title + '</div>' +
 | ||
|                         '<div class="task-meta">' +
 | ||
|                             '<span>' + task.repository + '</span>' +
 | ||
|                             '<span class="badge ' + (task.priority === 'high' ? 'working' : task.priority === 'medium' ? 'stub' : 'working') + '">' + task.priority.toUpperCase() + '</span>' +
 | ||
|                         '</div>' +
 | ||
|                         '<p style="margin: 8px 0; color: #718096; font-size: 14px;">' + task.description + '</p>' +
 | ||
|                         '<div class="task-meta">' +
 | ||
|                             '<span>Team: ' + (task.assigned_to || 'Unassigned') + '</span>' +
 | ||
|                             '<a href="' + task.issue_url + '" target="_blank" style="color: #667eea;">View Issue</a>' +
 | ||
|                         '</div>' +
 | ||
|                     '</div>'
 | ||
|                 ).join('');
 | ||
|             }
 | ||
|             
 | ||
|             // Update queued tasks
 | ||
|             const queuedTasksContainer = document.getElementById('queued-tasks');
 | ||
|             if (queuedTasks.length > 0) {
 | ||
|                 queuedTasksContainer.innerHTML = queuedTasks.map(task => 
 | ||
|                     '<div class="task-item priority-' + task.priority + '">' +
 | ||
|                         '<div class="task-title">' + task.title + '</div>' +
 | ||
|                         '<div class="task-meta">' +
 | ||
|                             '<span>' + task.repository + '</span>' +
 | ||
|                             '<span class="badge ' + (task.priority === 'high' ? 'working' : task.priority === 'medium' ? 'stub' : 'working') + '">' + task.priority.toUpperCase() + '</span>' +
 | ||
|                         '</div>' +
 | ||
|                         '<p style="margin: 8px 0; color: #718096; font-size: 14px;">' + task.description + '</p>' +
 | ||
|                         '<div class="task-meta">' +
 | ||
|                             '<span>Awaiting team assignment</span>' +
 | ||
|                             '<a href="' + task.issue_url + '" target="_blank" style="color: #667eea;">View Issue</a>' +
 | ||
|                         '</div>' +
 | ||
|                     '</div>'
 | ||
|                 ).join('');
 | ||
|             }
 | ||
|             
 | ||
|             // Update overview metrics
 | ||
|             updateOverviewMetrics(data);
 | ||
|         }
 | ||
|         
 | ||
|         function loadTeams() {
 | ||
|             // Load teams from API
 | ||
|             fetch('/api/v1/teams')
 | ||
|                 .then(response => response.json())
 | ||
|                 .then(data => {
 | ||
|                     console.log('Teams loaded:', data);
 | ||
|                     updateTeamsUI(data);
 | ||
|                 })
 | ||
|                 .catch(error => {
 | ||
|                     console.error('Error loading teams:', error);
 | ||
|                 });
 | ||
|         }
 | ||
|         
 | ||
|         function updateTeamsUI(data) {
 | ||
|             const teamsContainer = document.getElementById('teams-grid');
 | ||
|             
 | ||
|             if (data.teams && data.teams.length > 0) {
 | ||
|                 teamsContainer.innerHTML = data.teams.map(team => 
 | ||
|                     '<div class="card">' +
 | ||
|                         '<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">' +
 | ||
|                             '<h4 style="margin: 0; color: #2d3748;">' + team.name + '</h4>' +
 | ||
|                             '<span class="badge ' + (team.status === 'active' ? 'working' : team.status === 'idle' ? 'stub' : 'working') + '">' + (team.status || 'ACTIVE').toUpperCase() + '</span>' +
 | ||
|                         '</div>' +
 | ||
|                         '<div class="metric">' +
 | ||
|                             '<span class="metric-label">Current Task</span>' +
 | ||
|                             '<span class="metric-value">' + (team.current_task || 'No active task') + '</span>' +
 | ||
|                         '</div>' +
 | ||
|                         '<div class="metric">' +
 | ||
|                             '<span class="metric-label">Team Size</span>' +
 | ||
|                             '<span class="metric-value">' + (team.members ? team.members.length : 0) + ' agents</span>' +
 | ||
|                         '</div>' +
 | ||
|                         '<div style="margin-top: 16px;">' +
 | ||
|                             '<strong style="font-size: 14px; color: #4a5568;">Team Members:</strong>' +
 | ||
|                             '<div style="margin-top: 8px;">' +
 | ||
|                                 (team.members || []).map(member => 
 | ||
|                                     '<div class="team-member">' +
 | ||
|                                         '<span class="agent-status ' + (member.status || 'online') + '"></span>' +
 | ||
|                                         '<span style="font-weight: 500;">' + member.name + '</span>' +
 | ||
|                                         '<span style="margin-left: auto; color: #718096; font-size: 12px;">' + (member.role || member.persona || 'General Agent') + '</span>' +
 | ||
|                                     '</div>'
 | ||
|                                 ).join('') +
 | ||
|                             '</div>' +
 | ||
|                         '</div>' +
 | ||
|                         (team.created_at ? '<div class="metric" style="margin-top: 12px;"><span class="metric-label">Assembled</span><span class="metric-value">' + new Date(team.created_at).toLocaleDateString() + '</span></div>' : '') +
 | ||
|                     '</div>'
 | ||
|                 ).join('');
 | ||
|             }
 | ||
|         }
 | ||
|         
 | ||
|         function loadAgents() {
 | ||
|             // Load agents from API
 | ||
|             fetch('/api/v1/agents')
 | ||
|                 .then(response => response.json())
 | ||
|                 .then(data => {
 | ||
|                     console.log('Agents loaded:', data);
 | ||
|                     updateAgentsUI(data);
 | ||
|                 })
 | ||
|                 .catch(error => {
 | ||
|                     console.error('Error loading agents:', error);
 | ||
|                 });
 | ||
|         }
 | ||
|         
 | ||
|         function updateAgentsUI(data) {
 | ||
|             const agentsContainer = document.getElementById('agents-grid');
 | ||
|             
 | ||
|             if (data.agents && data.agents.length > 0) {
 | ||
|                 agentsContainer.innerHTML = data.agents.map(agent => 
 | ||
|                     '<div class="card">' +
 | ||
|                         '<div class="agent-card">' +
 | ||
|                             '<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">' +
 | ||
|                                 '<h4 style="margin: 0; color: #2d3748;">' + agent.name + '</h4>' +
 | ||
|                                 '<span class="agent-status ' + (agent.status || 'offline') + '"></span>' +
 | ||
|                             '</div>' +
 | ||
|                             '<div class="metric">' +
 | ||
|                                 '<span class="metric-label">Status</span>' +
 | ||
|                                 '<span class="metric-value" style="color: ' + (agent.status === 'online' ? '#38a169' : agent.status === 'idle' ? '#dd6b20' : '#a0aec0') + ';">' + (agent.status || 'OFFLINE').toUpperCase() + '</span>' +
 | ||
|                             '</div>' +
 | ||
|                             '<div class="metric">' +
 | ||
|                                 '<span class="metric-label">Current Persona</span>' +
 | ||
|                                 '<span class="metric-value">' + (agent.persona || agent.role || 'General Agent') + '</span>' +
 | ||
|                             '</div>' +
 | ||
|                             '<div class="metric">' +
 | ||
|                                 '<span class="metric-label">Model/Engine</span>' +
 | ||
|                                 '<span class="metric-value">' + (agent.model || agent.engine || 'Unknown') + '</span>' +
 | ||
|                             '</div>' +
 | ||
|                             '<div class="metric">' +
 | ||
|                                 '<span class="metric-label">Current Team</span>' +
 | ||
|                                 '<span class="metric-value">' + (agent.current_team || 'Unassigned') + '</span>' +
 | ||
|                             '</div>' +
 | ||
|                             '<div class="metric">' +
 | ||
|                                 '<span class="metric-label">Tasks Completed</span>' +
 | ||
|                                 '<span class="metric-value">' + (agent.tasks_completed || 0) + '</span>' +
 | ||
|                             '</div>' +
 | ||
|                             '<div style="margin-top: 12px;">' +
 | ||
|                                 '<strong style="font-size: 14px; color: #4a5568;">Capabilities:</strong>' +
 | ||
|                                 '<div style="margin-top: 4px;">' +
 | ||
|                                     (agent.capabilities || []).map(cap => '<span class="badge working" style="margin: 2px;">' + cap + '</span>').join('') +
 | ||
|                                 '</div>' +
 | ||
|                             '</div>' +
 | ||
|                             (agent.last_seen ? '<div class="metric" style="margin-top: 12px;"><span class="metric-label">Last Seen</span><span class="metric-value">' + new Date(agent.last_seen).toLocaleString() + '</span></div>' : '') +
 | ||
|                         '</div>' +
 | ||
|                     '</div>'
 | ||
|                 ).join('');
 | ||
|             }
 | ||
|         }
 | ||
|         
 | ||
|         function updateOverviewMetrics(taskData, agentData) {
 | ||
|             if (taskData) {
 | ||
|                 const activeTasks = taskData.tasks.filter(task => task.status === 'active').length;
 | ||
|                 const queuedTasks = taskData.tasks.filter(task => task.status === 'queued').length;
 | ||
|                 
 | ||
|                 // Update the metric values
 | ||
|                 const pendingElement = document.querySelector('[data-metric="pending-tasks"]');
 | ||
|                 if (pendingElement) pendingElement.textContent = queuedTasks;
 | ||
|             }
 | ||
|             
 | ||
|             if (agentData) {
 | ||
|                 const registeredAgents = agentData.total || 0;
 | ||
|                 const agentsElement = document.querySelector('[data-metric="registered-agents"]');
 | ||
|                 if (agentsElement) agentsElement.textContent = registeredAgents;
 | ||
|             }
 | ||
|         }
 | ||
|         
 | ||
|         function loadOverviewData() {
 | ||
|             // Load both tasks and agents data for overview
 | ||
|             Promise.all([
 | ||
|                 fetch('/api/v1/tasks').then(r => r.json()),
 | ||
|                 fetch('/api/v1/agents').then(r => r.json())
 | ||
|             ]).then(([taskData, agentData]) => {
 | ||
|                 updateOverviewMetrics(taskData, agentData);
 | ||
|             }).catch(error => {
 | ||
|                 console.error('Error loading overview data:', error);
 | ||
|             });
 | ||
|         }
 | ||
|         
 | ||
|         // Auto-refresh overview metrics every 30 seconds
 | ||
|         setInterval(() => {
 | ||
|             if (document.getElementById('overview').classList.contains('active')) {
 | ||
|                 loadOverviewData();
 | ||
|             }
 | ||
|         }, 30000);
 | ||
|         
 | ||
|         // BACKBEAT Clock functionality
 | ||
|         let beatHistory = [];
 | ||
|         let canvas = null;
 | ||
|         let ctx = null;
 | ||
|         let lastDownbeat = null;
 | ||
|         
 | ||
|         function initializeBackbeatClock() {
 | ||
|             canvas = document.getElementById('pulse-trace');
 | ||
|             if (canvas) {
 | ||
|                 canvas.width = canvas.offsetWidth;
 | ||
|                 canvas.height = 60;
 | ||
|                 ctx = canvas.getContext('2d');
 | ||
|                 drawPulseTrace();
 | ||
|             }
 | ||
|         }
 | ||
|         
 | ||
|         function drawPulseTrace() {
 | ||
|             if (!ctx) return;
 | ||
|             
 | ||
|             const width = canvas.width;
 | ||
|             const height = canvas.height;
 | ||
|             
 | ||
|             // Clear canvas
 | ||
|             ctx.clearRect(0, 0, width, height);
 | ||
|             
 | ||
|             // Draw background grid
 | ||
|             ctx.strokeStyle = '#e2e8f0';
 | ||
|             ctx.lineWidth = 1;
 | ||
|             for (let i = 0; i < width; i += 20) {
 | ||
|                 ctx.beginPath();
 | ||
|                 ctx.moveTo(i, 0);
 | ||
|                 ctx.lineTo(i, height);
 | ||
|                 ctx.stroke();
 | ||
|             }
 | ||
|             for (let i = 0; i < height; i += 15) {
 | ||
|                 ctx.beginPath();
 | ||
|                 ctx.moveTo(0, i);
 | ||
|                 ctx.lineTo(width, i);
 | ||
|                 ctx.stroke();
 | ||
|             }
 | ||
|             
 | ||
|             if (beatHistory.length < 2) return;
 | ||
|             
 | ||
|             // Draw ECG-like trace
 | ||
|             ctx.strokeStyle = '#667eea';
 | ||
|             ctx.lineWidth = 2;
 | ||
|             ctx.beginPath();
 | ||
|             
 | ||
|             const timeWindow = 10000; // 10 seconds
 | ||
|             const now = Date.now();
 | ||
|             const startTime = now - timeWindow;
 | ||
|             
 | ||
|             beatHistory.forEach((beat, index) => {
 | ||
|                 if (beat.timestamp < startTime) return;
 | ||
|                 
 | ||
|                 const x = ((beat.timestamp - startTime) / timeWindow) * width;
 | ||
|                 const y = beat.isDownbeat ? height * 0.1 : height * 0.7;
 | ||
|                 
 | ||
|                 if (index === 0) {
 | ||
|                     ctx.moveTo(x, y);
 | ||
|                 } else {
 | ||
|                     // Create ECG-like spike
 | ||
|                     const prevX = index > 0 ? ((beatHistory[index-1].timestamp - startTime) / timeWindow) * width : x;
 | ||
|                     ctx.lineTo(x - 2, height * 0.5);
 | ||
|                     ctx.lineTo(x, y);
 | ||
|                     ctx.lineTo(x + 2, height * 0.5);
 | ||
|                 }
 | ||
|             });
 | ||
|             
 | ||
|             ctx.stroke();
 | ||
|             
 | ||
|             // Draw heartbeat markers
 | ||
|             ctx.fillStyle = '#e53e3e';
 | ||
|             beatHistory.forEach(beat => {
 | ||
|                 if (beat.timestamp < startTime) return;
 | ||
|                 const x = ((beat.timestamp - startTime) / timeWindow) * width;
 | ||
|                 if (beat.isDownbeat) {
 | ||
|                     ctx.fillRect(x - 1, 5, 2, height - 10);
 | ||
|                 }
 | ||
|             });
 | ||
|         }
 | ||
|         
 | ||
|         function loadBackbeatData() {
 | ||
|             fetch('/api/v1/backbeat/status')
 | ||
|                 .then(response => response.json())
 | ||
|                 .then(data => {
 | ||
|                     updateBackbeatUI(data);
 | ||
|                 })
 | ||
|                 .catch(error => {
 | ||
|                     console.error('Error loading BACKBEAT data:', error);
 | ||
|                 });
 | ||
|         }
 | ||
|         
 | ||
|         function updateBackbeatUI(data) {
 | ||
|             if (data.current_beat !== undefined) {
 | ||
|                 document.getElementById('current-beat').textContent = data.current_beat;
 | ||
|             }
 | ||
|             
 | ||
|             if (data.current_downbeat !== undefined) {
 | ||
|                 document.getElementById('current-downbeat').textContent = data.current_downbeat;
 | ||
|                 lastDownbeat = data.current_downbeat;
 | ||
|             }
 | ||
|             
 | ||
|             if (data.average_interval !== undefined) {
 | ||
|                 document.getElementById('avg-interval').textContent = Math.round(data.average_interval) + 'ms';
 | ||
|             }
 | ||
|             
 | ||
|             if (data.phase !== undefined) {
 | ||
|                 document.getElementById('beat-phase').textContent = data.phase;
 | ||
|             }
 | ||
|             
 | ||
|             // Add to beat history for visualization
 | ||
|             if (data.current_beat !== undefined) {
 | ||
|                 const now = Date.now();
 | ||
|                 const isDownbeat = data.is_downbeat || false;
 | ||
|                 
 | ||
|                 beatHistory.push({
 | ||
|                     beat: data.current_beat,
 | ||
|                     timestamp: now,
 | ||
|                     isDownbeat: isDownbeat,
 | ||
|                     phase: data.phase
 | ||
|                 });
 | ||
|                 
 | ||
|                 // Keep only recent beats (last 10 seconds)
 | ||
|                 const cutoff = now - 10000;
 | ||
|                 beatHistory = beatHistory.filter(b => b.timestamp > cutoff);
 | ||
|                 
 | ||
|                 drawPulseTrace();
 | ||
|             }
 | ||
|         }
 | ||
|         
 | ||
|         // Load initial data
 | ||
|         document.addEventListener('DOMContentLoaded', function() {
 | ||
|             loadOverviewData();
 | ||
|             loadTasks();
 | ||
|             loadTeams();
 | ||
|             loadAgents();
 | ||
|             initializeBackbeatClock();
 | ||
|             loadBackbeatData();
 | ||
|         });
 | ||
|         
 | ||
|         // Auto-refresh BACKBEAT data more frequently
 | ||
|         setInterval(() => {
 | ||
|             if (document.getElementById('overview').classList.contains('active')) {
 | ||
|                 loadBackbeatData();
 | ||
|             }
 | ||
|         }, 1000); // Update every second for real-time feel
 | ||
|         
 | ||
|         // Repository Management Functions
 | ||
|         function showAddRepositoryForm() {
 | ||
|             document.getElementById('add-repository-form').style.display = 'block';
 | ||
|         }
 | ||
|         
 | ||
|         function hideAddRepositoryForm() {
 | ||
|             document.getElementById('add-repository-form').style.display = 'none';
 | ||
|             // Clear form
 | ||
|             document.getElementById('repo-name').value = '';
 | ||
|             document.getElementById('repo-owner').value = '';
 | ||
|             document.getElementById('repo-url').value = '';
 | ||
|             document.getElementById('repo-description').value = '';
 | ||
|             document.getElementById('repo-branch').value = 'main';
 | ||
|             document.getElementById('repo-source-type').value = 'gitea';
 | ||
|             document.getElementById('monitor-issues').checked = true;
 | ||
|             document.getElementById('monitor-prs').checked = false;
 | ||
|             document.getElementById('enable-chorus').checked = true;
 | ||
|         }
 | ||
|         
 | ||
|         function addRepository(event) {
 | ||
|             event.preventDefault();
 | ||
|             
 | ||
|             const formData = {
 | ||
|                 name: document.getElementById('repo-name').value,
 | ||
|                 owner: document.getElementById('repo-owner').value,
 | ||
|                 url: document.getElementById('repo-url').value,
 | ||
|                 source_type: document.getElementById('repo-source-type').value,
 | ||
|                 description: document.getElementById('repo-description').value || null,
 | ||
|                 default_branch: document.getElementById('repo-branch').value,
 | ||
|                 monitor_issues: document.getElementById('monitor-issues').checked,
 | ||
|                 monitor_pull_requests: document.getElementById('monitor-prs').checked,
 | ||
|                 enable_chorus_integration: document.getElementById('enable-chorus').checked
 | ||
|             };
 | ||
|             
 | ||
|             fetch('/api/v1/repositories', {
 | ||
|                 method: 'POST',
 | ||
|                 headers: {
 | ||
|                     'Content-Type': 'application/json'
 | ||
|                 },
 | ||
|                 body: JSON.stringify(formData)
 | ||
|             })
 | ||
|             .then(response => response.json())
 | ||
|             .then(data => {
 | ||
|                 if (data.error) {
 | ||
|                     alert('Error adding repository: ' + data.error);
 | ||
|                 } else {
 | ||
|                     alert('Repository added successfully!');
 | ||
|                     hideAddRepositoryForm();
 | ||
|                     loadRepositories();
 | ||
|                 }
 | ||
|             })
 | ||
|             .catch(error => {
 | ||
|                 console.error('Error adding repository:', error);
 | ||
|                 alert('Error adding repository. Please try again.');
 | ||
|             });
 | ||
|         }
 | ||
|         
 | ||
|         function loadRepositories() {
 | ||
|             fetch('/api/v1/repositories')
 | ||
|                 .then(response => response.json())
 | ||
|                 .then(data => {
 | ||
|                     updateRepositoryStats(data);
 | ||
|                     displayRepositories(data.repositories);
 | ||
|                 })
 | ||
|                 .catch(error => {
 | ||
|                     console.error('Error loading repositories:', error);
 | ||
|                     document.getElementById('repositories-list').innerHTML = 
 | ||
|                         '<p style="text-align: center; color: #e53e3e; padding: 20px;">Error loading repositories</p>';
 | ||
|                 });
 | ||
|         }
 | ||
|         
 | ||
|         function updateRepositoryStats(data) {
 | ||
|             const repositories = data.repositories || [];
 | ||
|             const totalRepos = repositories.length;
 | ||
|             const activeRepos = repositories.filter(repo => repo.sync_status === 'active').length;
 | ||
|             const totalTasks = repositories.reduce((sum, repo) => sum + (repo.total_tasks_created || 0), 0);
 | ||
|             
 | ||
|             document.getElementById('total-repositories').textContent = totalRepos;
 | ||
|             document.getElementById('active-repositories').textContent = activeRepos;
 | ||
|             document.getElementById('total-tasks-from-repos').textContent = totalTasks;
 | ||
|         }
 | ||
|         
 | ||
|         function displayRepositories(repositories) {
 | ||
|             const container = document.getElementById('repositories-list');
 | ||
|             
 | ||
|             if (!repositories || repositories.length === 0) {
 | ||
|                 container.innerHTML = '<p style="text-align: center; color: #718096; padding: 20px;">No repositories configured yet. Click "Add Repository" to get started.</p>';
 | ||
|                 return;
 | ||
|             }
 | ||
|             
 | ||
|             const html = repositories.map(repo => 
 | ||
|                 '<div style="border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center;">' +
 | ||
|                     '<div style="flex: 1;">' +
 | ||
|                         '<div style="display: flex; align-items: center; margin-bottom: 8px;">' +
 | ||
|                             '<h4 style="margin: 0; color: #2d3748;"><a href="' + repo.url + '" target="_blank" style="color: #2d3748; text-decoration: none; cursor: pointer;" onmouseover="this.style.color=\'#4299e1\'" onmouseout="this.style.color=\'#2d3748\'">' + repo.full_name + '</a></h4>' +
 | ||
|                             '<span style="margin-left: 12px; padding: 2px 8px; background: ' + getStatusColor(repo.sync_status) + '; color: white; border-radius: 12px; font-size: 12px; font-weight: 500;">' +
 | ||
|                                 repo.sync_status +
 | ||
|                             '</span>' +
 | ||
|                             (repo.monitor_issues ? '<span style="margin-left: 8px; padding: 2px 8px; background: #38a169; color: white; border-radius: 12px; font-size: 11px;">Issues</span>' : '') +
 | ||
|                             (repo.enable_chorus_integration ? '<span style="margin-left: 8px; padding: 2px 8px; background: #667eea; color: white; border-radius: 12px; font-size: 11px;">CHORUS</span>' : '') +
 | ||
|                         '</div>' +
 | ||
|                         '<div style="color: #718096; font-size: 14px;">' +
 | ||
|                             (repo.description || 'No description') +
 | ||
|                         '</div>' +
 | ||
|                         '<div style="color: #a0aec0; font-size: 12px; margin-top: 4px;">' +
 | ||
|                             repo.open_issues_count + ' open issues • ' + repo.total_tasks_created + ' tasks created • ' + repo.source_type +
 | ||
|                         '</div>' +
 | ||
|                     '</div>' +
 | ||
|                     '<div style="display: flex; gap: 8px;">' +
 | ||
|                         '<button onclick="syncRepository(\'' + repo.id + '\')" style="background: #4299e1; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">' +
 | ||
|                             'Sync' +
 | ||
|                         '</button>' +
 | ||
|                         '<button onclick="ensureLabels(\'' + repo.id + '\')" style="background: #38a169; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">' +
 | ||
|                             'Labels' +
 | ||
|                         '</button>' +
 | ||
|                         '<button onclick="editRepository(\'' + repo.id + '\')" style="background: #ed8936; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">' +
 | ||
|                             'Edit' +
 | ||
|                         '</button>' +
 | ||
|                         '<button onclick="deleteRepository(\'' + repo.id + '\', \'' + repo.full_name + '\')" style="background: #e53e3e; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">' +
 | ||
|                             'Delete' +
 | ||
|                         '</button>' +
 | ||
|                     '</div>' +
 | ||
|                 '</div>'
 | ||
|             ).join('');
 | ||
|             
 | ||
|             container.innerHTML = html;
 | ||
|         }
 | ||
|         
 | ||
|         function getStatusColor(status) {
 | ||
|             switch(status) {
 | ||
|                 case 'active': return '#38a169';
 | ||
|                 case 'pending': return '#ed8936';
 | ||
|                 case 'error': return '#e53e3e';
 | ||
|                 case 'disabled': return '#a0aec0';
 | ||
|                 default: return '#718096';
 | ||
|             }
 | ||
|         }
 | ||
|         
 | ||
|         function syncRepository(repoId) {
 | ||
|             fetch('/api/v1/repositories/' + repoId + '/sync', {
 | ||
|                 method: 'POST'
 | ||
|             })
 | ||
|             .then(response => response.json())
 | ||
|             .then(data => {
 | ||
|                 alert('Repository sync triggered: ' + data.message);
 | ||
|                 loadRepositories(); // Reload to show updated status
 | ||
|             })
 | ||
|             .catch(error => {
 | ||
|                 console.error('Error syncing repository:', error);
 | ||
|                 alert('Error syncing repository');
 | ||
|             });
 | ||
|         }
 | ||
|         
 | ||
|         function ensureLabels(repoId) {
 | ||
|             fetch('/api/v1/repositories/' + repoId + '/ensure-labels', {
 | ||
|                 method: 'POST'
 | ||
|             })
 | ||
|             .then(response => response.json())
 | ||
|             .then(data => {
 | ||
|                 if (data.error) {
 | ||
|                     alert('Error ensuring labels: ' + data.error);
 | ||
|                 } else {
 | ||
|                     alert('Labels ensured successfully for ' + data.owner + '/' + data.name + '\\n\\nRequired labels created:\\n• bzzz-task\\n• whoosh-monitored\\n• priority-high\\n• priority-medium\\n• priority-low');
 | ||
|                 }
 | ||
|             })
 | ||
|             .catch(error => {
 | ||
|                 console.error('Error ensuring labels:', error);
 | ||
|                 alert('Error ensuring labels');
 | ||
|             });
 | ||
|         }
 | ||
|         
 | ||
|         function editRepository(repoId) {
 | ||
|             // Fetch repository details first
 | ||
|             fetch('/api/v1/repositories/' + repoId)
 | ||
|             .then(response => response.json())
 | ||
|             .then(repo => {
 | ||
|                 showEditModal(repo);
 | ||
|             })
 | ||
|             .catch(error => {
 | ||
|                 console.error('Error fetching repository:', error);
 | ||
|                 alert('Error fetching repository details');
 | ||
|             });
 | ||
|         }
 | ||
|         
 | ||
|         function showEditModal(repo) {
 | ||
|             // Create modal overlay
 | ||
|             const overlay = document.createElement('div');
 | ||
|             overlay.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; ' +
 | ||
|                 'background: rgba(0,0,0,0.5); display: flex; align-items: center; ' +
 | ||
|                 'justify-content: center; z-index: 1000;';
 | ||
|             
 | ||
|             // Create modal content
 | ||
|             const modal = document.createElement('div');
 | ||
|             modal.style.cssText = 'background: white; border-radius: 8px; padding: 24px; ' +
 | ||
|                 'max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto;';
 | ||
|             
 | ||
|             modal.innerHTML = 
 | ||
|                 '<h3 style="margin: 0 0 20px 0; color: #2d3748;">Edit Repository</h3>' +
 | ||
|                 '<div style="margin-bottom: 16px;">' +
 | ||
|                     '<strong>' + repo.full_name + '</strong>' +
 | ||
|                     '<div style="font-size: 12px; color: #718096;">ID: ' + repo.id + '</div>' +
 | ||
|                 '</div>' +
 | ||
|                 
 | ||
|                 '<form id="editRepoForm">' +
 | ||
|                     '<div style="margin-bottom: 16px;">' +
 | ||
|                         '<label style="display: block; margin-bottom: 4px; font-weight: bold;">Description:</label>' +
 | ||
|                         '<input type="text" id="description" value="' + (repo.description || '') + '" ' +
 | ||
|                                'style="width: 100%; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px;">' +
 | ||
|                     '</div>' +
 | ||
|                     
 | ||
|                     '<div style="margin-bottom: 16px;">' +
 | ||
|                         '<label style="display: block; margin-bottom: 4px; font-weight: bold;">Default Branch:</label>' +
 | ||
|                         '<input type="text" id="defaultBranch" value="' + (repo.default_branch || 'main') + '" ' +
 | ||
|                                'style="width: 100%; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px;">' +
 | ||
|                     '</div>' +
 | ||
|                     
 | ||
|                     '<div style="margin-bottom: 16px;">' +
 | ||
|                         '<label style="display: block; margin-bottom: 4px; font-weight: bold;">Language:</label>' +
 | ||
|                         '<input type="text" id="language" value="' + (repo.language || '') + '" ' +
 | ||
|                                'style="width: 100%; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px;">' +
 | ||
|                     '</div>' +
 | ||
|                     
 | ||
|                     '<div style="margin-bottom: 16px;">' +
 | ||
|                         '<h4 style="margin: 0 0 8px 0;">Monitoring Options:</h4>' +
 | ||
|                         '<div style="margin-bottom: 8px;">' +
 | ||
|                             '<label style="display: flex; align-items: center;">' +
 | ||
|                                 '<input type="checkbox" id="monitorIssues" ' + (repo.monitor_issues ? 'checked' : '') + ' style="margin-right: 8px;">' +
 | ||
|                                 'Monitor Issues' +
 | ||
|                             '</label>' +
 | ||
|                         '</div>' +
 | ||
|                         '<div style="margin-bottom: 8px;">' +
 | ||
|                             '<label style="display: flex; align-items: center;">' +
 | ||
|                                 '<input type="checkbox" id="monitorPRs" ' + (repo.monitor_pull_requests ? 'checked' : '') + ' style="margin-right: 8px;">' +
 | ||
|                                 'Monitor Pull Requests' +
 | ||
|                             '</label>' +
 | ||
|                         '</div>' +
 | ||
|                         '<div style="margin-bottom: 8px;">' +
 | ||
|                             '<label style="display: flex; align-items: center;">' +
 | ||
|                                 '<input type="checkbox" id="monitorReleases" ' + (repo.monitor_releases ? 'checked' : '') + ' style="margin-right: 8px;">' +
 | ||
|                                 'Monitor Releases' +
 | ||
|                             '</label>' +
 | ||
|                         '</div>' +
 | ||
|                     '</div>' +
 | ||
|                     
 | ||
|                     '<div style="margin-bottom: 16px;">' +
 | ||
|                         '<h4 style="margin: 0 0 8px 0;">CHORUS Integration:</h4>' +
 | ||
|                         '<div style="margin-bottom: 8px;">' +
 | ||
|                             '<label style="display: flex; align-items: center;">' +
 | ||
|                                 '<input type="checkbox" id="enableChorus" ' + (repo.enable_chorus_integration ? 'checked' : '') + ' style="margin-right: 8px;">' +
 | ||
|                                 'Enable CHORUS Integration' +
 | ||
|                             '</label>' +
 | ||
|                         '</div>' +
 | ||
|                         '<div style="margin-bottom: 8px;">' +
 | ||
|                             '<label style="display: flex; align-items: center;">' +
 | ||
|                                 '<input type="checkbox" id="autoAssignTeams" ' + (repo.auto_assign_teams ? 'checked' : '') + ' style="margin-right: 8px;">' +
 | ||
|                                 'Auto-assign Teams' +
 | ||
|                             '</label>' +
 | ||
|                         '</div>' +
 | ||
|                     '</div>' +
 | ||
|                     
 | ||
|                     '<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 24px;">' +
 | ||
|                         '<button type="button" onclick="closeEditModal()" ' +
 | ||
|                                 'style="background: #e2e8f0; color: #4a5568; border: none; padding: 10px 16px; border-radius: 4px; cursor: pointer;">' +
 | ||
|                             'Cancel' +
 | ||
|                         '</button>' +
 | ||
|                         '<button type="submit" ' +
 | ||
|                                 'style="background: #4299e1; color: white; border: none; padding: 10px 16px; border-radius: 4px; cursor: pointer;">' +
 | ||
|                             'Save Changes' +
 | ||
|                         '</button>' +
 | ||
|                     '</div>' +
 | ||
|                 '</form>';
 | ||
|             
 | ||
|             overlay.appendChild(modal);
 | ||
|             document.body.appendChild(overlay);
 | ||
|             
 | ||
|             // Store modal reference globally so we can close it
 | ||
|             window.currentEditModal = overlay;
 | ||
|             window.currentRepoId = repo.id;
 | ||
|             
 | ||
|             // Handle form submission
 | ||
|             document.getElementById('editRepoForm').addEventListener('submit', function(e) {
 | ||
|                 e.preventDefault();
 | ||
|                 saveRepositoryChanges();
 | ||
|             });
 | ||
|             
 | ||
|             // Close modal on overlay click
 | ||
|             overlay.addEventListener('click', function(e) {
 | ||
|                 if (e.target === overlay) {
 | ||
|                     closeEditModal();
 | ||
|                 }
 | ||
|             });
 | ||
|         }
 | ||
|         
 | ||
|         function closeEditModal() {
 | ||
|             if (window.currentEditModal) {
 | ||
|                 document.body.removeChild(window.currentEditModal);
 | ||
|                 window.currentEditModal = null;
 | ||
|                 window.currentRepoId = null;
 | ||
|             }
 | ||
|         }
 | ||
|         
 | ||
|         function saveRepositoryChanges() {
 | ||
|             const formData = {
 | ||
|                 description: document.getElementById('description').value.trim() || null,
 | ||
|                 default_branch: document.getElementById('defaultBranch').value.trim() || null,
 | ||
|                 language: document.getElementById('language').value.trim() || null,
 | ||
|                 monitor_issues: document.getElementById('monitorIssues').checked,
 | ||
|                 monitor_pull_requests: document.getElementById('monitorPRs').checked,
 | ||
|                 monitor_releases: document.getElementById('monitorReleases').checked,
 | ||
|                 enable_chorus_integration: document.getElementById('enableChorus').checked,
 | ||
|                 auto_assign_teams: document.getElementById('autoAssignTeams').checked
 | ||
|             };
 | ||
|             
 | ||
|             fetch('/api/v1/repositories/' + window.currentRepoId, {
 | ||
|                 method: 'PUT',
 | ||
|                 headers: {
 | ||
|                     'Content-Type': 'application/json',
 | ||
|                 },
 | ||
|                 body: JSON.stringify(formData)
 | ||
|             })
 | ||
|             .then(response => response.json())
 | ||
|             .then(data => {
 | ||
|                 alert('Repository updated successfully!');
 | ||
|                 closeEditModal();
 | ||
|                 loadRepositories(); // Reload the list to show changes
 | ||
|             })
 | ||
|             .catch(error => {
 | ||
|                 console.error('Error updating repository:', error);
 | ||
|                 alert('Error updating repository');
 | ||
|             });
 | ||
|         }
 | ||
|         
 | ||
|         function deleteRepository(repoId, fullName) {
 | ||
|             if (confirm('Are you sure you want to delete repository "' + fullName + '"? This will stop monitoring and cannot be undone.')) {
 | ||
|                 fetch('/api/v1/repositories/' + repoId, {
 | ||
|                     method: 'DELETE'
 | ||
|                 })
 | ||
|                 .then(response => response.json())
 | ||
|                 .then(data => {
 | ||
|                     alert('Repository deleted: ' + data.message);
 | ||
|                     loadRepositories(); // Reload the list
 | ||
|                 })
 | ||
|                 .catch(error => {
 | ||
|                     console.error('Error deleting repository:', error);
 | ||
|                     alert('Error deleting repository');
 | ||
|                 });
 | ||
|             }
 | ||
|         }
 | ||
|     </script>
 | ||
| </body>
 | ||
| </html>`
 | ||
| 
 | ||
| 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 | ||
| 	w.Write([]byte(html))
 | ||
| }
 | ||
| 
 | ||
| // backbeatStatusHandler provides real-time BACKBEAT pulse data
 | ||
| func (s *Server) backbeatStatusHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	now := time.Now()
 | ||
| 	
 | ||
| 	// Get real BACKBEAT data if integration is available and started
 | ||
| 	if s.backbeat != nil {
 | ||
| 		health := s.backbeat.GetHealth()
 | ||
| 		
 | ||
| 		// Extract real BACKBEAT data
 | ||
| 		currentBeat := int64(0)
 | ||
| 		if beatVal, ok := health["current_beat"]; ok {
 | ||
| 			if beat, ok := beatVal.(int64); ok {
 | ||
| 				currentBeat = beat
 | ||
| 			}
 | ||
| 		}
 | ||
| 		
 | ||
| 		currentTempo := 2 // Default fallback
 | ||
| 		if tempoVal, ok := health["current_tempo"]; ok {
 | ||
| 			if tempo, ok := tempoVal.(int); ok {
 | ||
| 				currentTempo = tempo
 | ||
| 			}
 | ||
| 		}
 | ||
| 		
 | ||
| 		connected := false
 | ||
| 		if connVal, ok := health["connected"]; ok {
 | ||
| 			if conn, ok := connVal.(bool); ok {
 | ||
| 				connected = conn
 | ||
| 			}
 | ||
| 		}
 | ||
| 		
 | ||
| 		// Determine phase based on BACKBEAT health
 | ||
| 		phase := "normal"
 | ||
| 		if degradationVal, ok := health["local_degradation"]; ok {
 | ||
| 			if degraded, ok := degradationVal.(bool); ok && degraded {
 | ||
| 				phase = "degraded"
 | ||
| 			}
 | ||
| 		}
 | ||
| 		
 | ||
| 		// Calculate average interval based on tempo (BPM to milliseconds)
 | ||
| 		averageInterval := 60000 / currentTempo // Convert BPM to milliseconds between beats
 | ||
| 		
 | ||
| 		// Determine if current beat is a downbeat (every 4th beat)
 | ||
| 		isDownbeat := currentBeat%4 == 1
 | ||
| 		currentDownbeat := (currentBeat / 4) + 1
 | ||
| 		
 | ||
| 		response := map[string]interface{}{
 | ||
| 			"current_beat":     currentBeat,
 | ||
| 			"current_downbeat": currentDownbeat,
 | ||
| 			"average_interval": averageInterval,
 | ||
| 			"phase":           phase,
 | ||
| 			"is_downbeat":     isDownbeat,
 | ||
| 			"tempo":           currentTempo,
 | ||
| 			"connected":       connected,
 | ||
| 			"timestamp":       now.Unix(),
 | ||
| 			"status":          "live",
 | ||
| 			"backbeat_health": health,
 | ||
| 		}
 | ||
| 		
 | ||
| 		w.Header().Set("Content-Type", "application/json")
 | ||
| 		if err := json.NewEncoder(w).Encode(response); err != nil {
 | ||
| 			http.Error(w, "Failed to encode response", http.StatusInternalServerError)
 | ||
| 			return
 | ||
| 		}
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Fallback to basic data if BACKBEAT integration is not available
 | ||
| 	response := map[string]interface{}{
 | ||
| 		"current_beat":     0,
 | ||
| 		"current_downbeat": 0,
 | ||
| 		"average_interval": 0,
 | ||
| 		"phase":           "disconnected",
 | ||
| 		"is_downbeat":     false,
 | ||
| 		"tempo":           0,
 | ||
| 		"connected":       false,
 | ||
| 		"timestamp":       now.Unix(),
 | ||
| 		"status":          "no_backbeat",
 | ||
| 		"error":           "BACKBEAT integration not available",
 | ||
| 	}
 | ||
| 	
 | ||
| 	w.Header().Set("Content-Type", "application/json")
 | ||
| 	if err := json.NewEncoder(w).Encode(response); err != nil {
 | ||
| 		http.Error(w, "Failed to encode response", http.StatusInternalServerError)
 | ||
| 		return
 | ||
| 	}
 | ||
| }
 | ||
| 
 | ||
| // Repository Management Handlers
 | ||
| 
 | ||
| // listRepositoriesHandler returns all monitored repositories
 | ||
| func (s *Server) listRepositoriesHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	log.Info().Msg("Listing monitored repositories")
 | ||
| 	
 | ||
| 	query := `
 | ||
| 		SELECT id, name, owner, full_name, url, clone_url, ssh_url, source_type, 
 | ||
| 		       monitor_issues, monitor_pull_requests, enable_chorus_integration,
 | ||
| 		       description, default_branch, is_private, language, topics,
 | ||
| 		       last_sync_at, sync_status, sync_error, open_issues_count, 
 | ||
| 		       closed_issues_count, total_tasks_created, created_at, updated_at
 | ||
| 		FROM repositories 
 | ||
| 		ORDER BY created_at DESC`
 | ||
| 	
 | ||
| 	rows, err := s.db.Pool.Query(context.Background(), query)
 | ||
| 	if err != nil {
 | ||
| 		log.Error().Err(err).Msg("Failed to query repositories")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to query repositories"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	defer rows.Close()
 | ||
| 	
 | ||
| 	repositories := []map[string]interface{}{}
 | ||
| 	for rows.Next() {
 | ||
| 		var id, name, owner, fullName, url, sourceType, defaultBranch, syncStatus string
 | ||
| 		var cloneURL, sshURL, description, syncError, language *string
 | ||
| 		var monitorIssues, monitorPRs, enableChorus, isPrivate bool
 | ||
| 		var topicsJSON []byte
 | ||
| 		var lastSyncAt *time.Time
 | ||
| 		var createdAt, updatedAt time.Time
 | ||
| 		var openIssues, closedIssues, totalTasks int
 | ||
| 		
 | ||
| 		err := rows.Scan(&id, &name, &owner, &fullName, &url, &cloneURL, &sshURL, &sourceType,
 | ||
| 			&monitorIssues, &monitorPRs, &enableChorus, &description, &defaultBranch, 
 | ||
| 			&isPrivate, &language, &topicsJSON, &lastSyncAt, &syncStatus, &syncError, 
 | ||
| 			&openIssues, &closedIssues, &totalTasks, &createdAt, &updatedAt)
 | ||
| 		if err != nil {
 | ||
| 			log.Error().Err(err).Msg("Failed to scan repository row")
 | ||
| 			continue
 | ||
| 		}
 | ||
| 		
 | ||
| 		// Parse topics from JSONB
 | ||
| 		var topics []string
 | ||
| 		if err := json.Unmarshal(topicsJSON, &topics); err != nil {
 | ||
| 			log.Error().Err(err).Msg("Failed to unmarshal topics")
 | ||
| 			topics = []string{} // Default to empty slice
 | ||
| 		}
 | ||
| 		
 | ||
| 		// Handle nullable lastSyncAt
 | ||
| 		var lastSyncFormatted *string
 | ||
| 		if lastSyncAt != nil {
 | ||
| 			formatted := lastSyncAt.Format(time.RFC3339)
 | ||
| 			lastSyncFormatted = &formatted
 | ||
| 		}
 | ||
| 		
 | ||
| 		repo := map[string]interface{}{
 | ||
| 			"id":                       id,
 | ||
| 			"name":                     name,
 | ||
| 			"owner":                    owner,
 | ||
| 			"full_name":                fullName,
 | ||
| 			"url":                      url,
 | ||
| 			"clone_url":                cloneURL,
 | ||
| 			"ssh_url":                  sshURL,
 | ||
| 			"source_type":              sourceType,
 | ||
| 			"monitor_issues":           monitorIssues,
 | ||
| 			"monitor_pull_requests":    monitorPRs,
 | ||
| 			"enable_chorus_integration": enableChorus,
 | ||
| 			"description":              description,
 | ||
| 			"default_branch":           defaultBranch,
 | ||
| 			"is_private":               isPrivate,
 | ||
| 			"language":                 language,
 | ||
| 			"topics":                   topics,
 | ||
| 			"last_sync_at":             lastSyncFormatted,
 | ||
| 			"sync_status":              syncStatus,
 | ||
| 			"sync_error":               syncError,
 | ||
| 			"open_issues_count":        openIssues,
 | ||
| 			"closed_issues_count":      closedIssues,
 | ||
| 			"total_tasks_created":      totalTasks,
 | ||
| 			"created_at":               createdAt.Format(time.RFC3339),
 | ||
| 			"updated_at":               updatedAt.Format(time.RFC3339),
 | ||
| 		}
 | ||
| 		repositories = append(repositories, repo)
 | ||
| 	}
 | ||
| 	
 | ||
| 	render.JSON(w, r, map[string]interface{}{
 | ||
| 		"repositories": repositories,
 | ||
| 		"count":        len(repositories),
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| // createRepositoryHandler adds a new repository to monitor
 | ||
| func (s *Server) createRepositoryHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	var req struct {
 | ||
| 		Name                    string   `json:"name"`
 | ||
| 		Owner                   string   `json:"owner"`
 | ||
| 		URL                     string   `json:"url"`
 | ||
| 		SourceType             string   `json:"source_type"`
 | ||
| 		MonitorIssues          bool     `json:"monitor_issues"`
 | ||
| 		MonitorPullRequests    bool     `json:"monitor_pull_requests"`
 | ||
| 		EnableChorusIntegration bool     `json:"enable_chorus_integration"`
 | ||
| 		Description            *string  `json:"description"`
 | ||
| 		DefaultBranch          string   `json:"default_branch"`
 | ||
| 		IsPrivate              bool     `json:"is_private"`
 | ||
| 		Language               *string  `json:"language"`
 | ||
| 		Topics                 []string `json:"topics"`
 | ||
| 	}
 | ||
| 	
 | ||
| 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid request body"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Validate required fields
 | ||
| 	if req.Name == "" || req.Owner == "" || req.URL == "" {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "name, owner, and url are required"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Set defaults
 | ||
| 	if req.SourceType == "" {
 | ||
| 		req.SourceType = "gitea"
 | ||
| 	}
 | ||
| 	if req.DefaultBranch == "" {
 | ||
| 		req.DefaultBranch = "main"
 | ||
| 	}
 | ||
| 	if req.Topics == nil {
 | ||
| 		req.Topics = []string{}
 | ||
| 	}
 | ||
| 	
 | ||
| 	fullName := req.Owner + "/" + req.Name
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("repository", fullName).
 | ||
| 		Str("url", req.URL).
 | ||
| 		Msg("Creating new repository monitor")
 | ||
| 	
 | ||
| 	query := `
 | ||
| 		INSERT INTO repositories (
 | ||
| 			name, owner, full_name, url, source_type, monitor_issues, 
 | ||
| 			monitor_pull_requests, enable_chorus_integration, description, 
 | ||
| 			default_branch, is_private, language, topics
 | ||
| 		) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
 | ||
| 		RETURNING id, created_at`
 | ||
| 	
 | ||
| 	// Convert topics slice to JSON for JSONB column
 | ||
| 	topicsJSON, err := json.Marshal(req.Topics)
 | ||
| 	if err != nil {
 | ||
| 		log.Error().Err(err).Msg("Failed to marshal topics")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to process topics"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 
 | ||
| 	var id string
 | ||
| 	var createdAt time.Time
 | ||
| 	err = s.db.Pool.QueryRow(context.Background(), query,
 | ||
| 		req.Name, req.Owner, fullName, req.URL, req.SourceType,
 | ||
| 		req.MonitorIssues, req.MonitorPullRequests, req.EnableChorusIntegration,
 | ||
| 		req.Description, req.DefaultBranch, req.IsPrivate, req.Language, topicsJSON).
 | ||
| 		Scan(&id, &createdAt)
 | ||
| 	
 | ||
| 	if err != nil {
 | ||
| 		log.Error().Err(err).Msg("Failed to create repository")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to create repository"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Automatically create required labels in the Gitea repository
 | ||
| 	if req.SourceType == "gitea" && s.repoMonitor != nil && s.repoMonitor.GetGiteaClient() != nil {
 | ||
| 		log.Info().
 | ||
| 			Str("repository", fullName).
 | ||
| 			Msg("Creating required labels in Gitea repository")
 | ||
| 		
 | ||
| 		err := s.repoMonitor.GetGiteaClient().EnsureRequiredLabels(context.Background(), req.Owner, req.Name)
 | ||
| 		if err != nil {
 | ||
| 			log.Warn().
 | ||
| 				Err(err).
 | ||
| 				Str("repository", fullName).
 | ||
| 				Msg("Failed to create labels in Gitea repository - repository monitoring will still work")
 | ||
| 			// Don't fail the entire request if label creation fails
 | ||
| 		} else {
 | ||
| 			log.Info().
 | ||
| 				Str("repository", fullName).
 | ||
| 				Msg("Successfully created required labels in Gitea repository")
 | ||
| 		}
 | ||
| 	}
 | ||
| 	
 | ||
| 	render.Status(r, http.StatusCreated)
 | ||
| 	render.JSON(w, r, map[string]interface{}{
 | ||
| 		"id":         id,
 | ||
| 		"full_name":  fullName,
 | ||
| 		"created_at": createdAt.Format(time.RFC3339),
 | ||
| 		"message":    "Repository monitor created successfully",
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| // getRepositoryHandler returns a specific repository
 | ||
| func (s *Server) getRepositoryHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	repoID := chi.URLParam(r, "repoID")
 | ||
| 	
 | ||
| 	log.Info().Str("repository_id", repoID).Msg("Getting repository details")
 | ||
| 	
 | ||
| 	query := `
 | ||
| 		SELECT id, name, owner, full_name, url, clone_url, ssh_url, source_type,
 | ||
| 		       source_config, monitor_issues, monitor_pull_requests, monitor_releases,
 | ||
| 		       enable_chorus_integration, chorus_task_labels, auto_assign_teams,
 | ||
| 		       description, default_branch, is_private, language, topics,
 | ||
| 		       last_sync_at, last_issue_sync, sync_status, sync_error,
 | ||
| 		       open_issues_count, closed_issues_count, total_tasks_created,
 | ||
| 		       created_at, updated_at
 | ||
| 		FROM repositories WHERE id = $1`
 | ||
| 	
 | ||
| 	var repo struct {
 | ||
| 		ID                      string     `json:"id"`
 | ||
| 		Name                    string     `json:"name"`
 | ||
| 		Owner                   string     `json:"owner"`
 | ||
| 		FullName                string     `json:"full_name"`
 | ||
| 		URL                     string     `json:"url"`
 | ||
| 		CloneURL                *string    `json:"clone_url"`
 | ||
| 		SSHURL                  *string    `json:"ssh_url"`
 | ||
| 		SourceType              string     `json:"source_type"`
 | ||
| 		SourceConfig            []byte     `json:"source_config"`
 | ||
| 		MonitorIssues          bool       `json:"monitor_issues"`
 | ||
| 		MonitorPullRequests    bool       `json:"monitor_pull_requests"`
 | ||
| 		MonitorReleases        bool       `json:"monitor_releases"`
 | ||
| 		EnableChorusIntegration bool       `json:"enable_chorus_integration"`
 | ||
| 		ChorusTaskLabels       []string   `json:"chorus_task_labels"`
 | ||
| 		AutoAssignTeams        bool       `json:"auto_assign_teams"`
 | ||
| 		Description            *string    `json:"description"`
 | ||
| 		DefaultBranch          string     `json:"default_branch"`
 | ||
| 		IsPrivate              bool       `json:"is_private"`
 | ||
| 		Language               *string    `json:"language"`
 | ||
| 		Topics                 []string   `json:"topics"`
 | ||
| 		LastSyncAt             *time.Time `json:"last_sync_at"`
 | ||
| 		LastIssueSyncAt        *time.Time `json:"last_issue_sync"`
 | ||
| 		SyncStatus             string     `json:"sync_status"`
 | ||
| 		SyncError              *string    `json:"sync_error"`
 | ||
| 		OpenIssuesCount        int        `json:"open_issues_count"`
 | ||
| 		ClosedIssuesCount      int        `json:"closed_issues_count"`
 | ||
| 		TotalTasksCreated      int        `json:"total_tasks_created"`
 | ||
| 		CreatedAt              time.Time  `json:"created_at"`
 | ||
| 		UpdatedAt              time.Time  `json:"updated_at"`
 | ||
| 	}
 | ||
| 	
 | ||
| 	err := s.db.Pool.QueryRow(context.Background(), query, repoID).Scan(
 | ||
| 		&repo.ID, &repo.Name, &repo.Owner, &repo.FullName, &repo.URL,
 | ||
| 		&repo.CloneURL, &repo.SSHURL, &repo.SourceType, &repo.SourceConfig,
 | ||
| 		&repo.MonitorIssues, &repo.MonitorPullRequests, &repo.MonitorReleases,
 | ||
| 		&repo.EnableChorusIntegration, &repo.ChorusTaskLabels, &repo.AutoAssignTeams,
 | ||
| 		&repo.Description, &repo.DefaultBranch, &repo.IsPrivate, &repo.Language,
 | ||
| 		&repo.Topics, &repo.LastSyncAt, &repo.LastIssueSyncAt, &repo.SyncStatus,
 | ||
| 		&repo.SyncError, &repo.OpenIssuesCount, &repo.ClosedIssuesCount,
 | ||
| 		&repo.TotalTasksCreated, &repo.CreatedAt, &repo.UpdatedAt)
 | ||
| 	
 | ||
| 	if err != nil {
 | ||
| 		if err.Error() == "no rows in result set" {
 | ||
| 			render.Status(r, http.StatusNotFound)
 | ||
| 			render.JSON(w, r, map[string]string{"error": "repository not found"})
 | ||
| 			return
 | ||
| 		}
 | ||
| 		log.Error().Err(err).Msg("Failed to get repository")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to get repository"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	render.JSON(w, r, repo)
 | ||
| }
 | ||
| 
 | ||
| // updateRepositoryHandler updates repository settings
 | ||
| func (s *Server) updateRepositoryHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	repoID := chi.URLParam(r, "repoID")
 | ||
| 	
 | ||
| 	var req struct {
 | ||
| 		MonitorIssues          *bool     `json:"monitor_issues"`
 | ||
| 		MonitorPullRequests    *bool     `json:"monitor_pull_requests"`
 | ||
| 		MonitorReleases        *bool     `json:"monitor_releases"`
 | ||
| 		EnableChorusIntegration *bool     `json:"enable_chorus_integration"`
 | ||
| 		AutoAssignTeams        *bool     `json:"auto_assign_teams"`
 | ||
| 		Description            *string   `json:"description"`
 | ||
| 		DefaultBranch          *string   `json:"default_branch"`
 | ||
| 		Language               *string   `json:"language"`
 | ||
| 		Topics                 []string  `json:"topics"`
 | ||
| 	}
 | ||
| 	
 | ||
| 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "invalid request body"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	log.Info().Str("repository_id", repoID).Msg("Updating repository settings")
 | ||
| 	
 | ||
| 	// Build dynamic update query
 | ||
| 	updates := []string{}
 | ||
| 	args := []interface{}{repoID}
 | ||
| 	argIndex := 2
 | ||
| 	
 | ||
| 	if req.MonitorIssues != nil {
 | ||
| 		updates = append(updates, fmt.Sprintf("monitor_issues = $%d", argIndex))
 | ||
| 		args = append(args, *req.MonitorIssues)
 | ||
| 		argIndex++
 | ||
| 	}
 | ||
| 	if req.MonitorPullRequests != nil {
 | ||
| 		updates = append(updates, fmt.Sprintf("monitor_pull_requests = $%d", argIndex))
 | ||
| 		args = append(args, *req.MonitorPullRequests)
 | ||
| 		argIndex++
 | ||
| 	}
 | ||
| 	if req.MonitorReleases != nil {
 | ||
| 		updates = append(updates, fmt.Sprintf("monitor_releases = $%d", argIndex))
 | ||
| 		args = append(args, *req.MonitorReleases)
 | ||
| 		argIndex++
 | ||
| 	}
 | ||
| 	if req.EnableChorusIntegration != nil {
 | ||
| 		updates = append(updates, fmt.Sprintf("enable_chorus_integration = $%d", argIndex))
 | ||
| 		args = append(args, *req.EnableChorusIntegration)
 | ||
| 		argIndex++
 | ||
| 	}
 | ||
| 	if req.AutoAssignTeams != nil {
 | ||
| 		updates = append(updates, fmt.Sprintf("auto_assign_teams = $%d", argIndex))
 | ||
| 		args = append(args, *req.AutoAssignTeams)
 | ||
| 		argIndex++
 | ||
| 	}
 | ||
| 	if req.Description != nil {
 | ||
| 		updates = append(updates, fmt.Sprintf("description = $%d", argIndex))
 | ||
| 		args = append(args, *req.Description)
 | ||
| 		argIndex++
 | ||
| 	}
 | ||
| 	if req.DefaultBranch != nil {
 | ||
| 		updates = append(updates, fmt.Sprintf("default_branch = $%d", argIndex))
 | ||
| 		args = append(args, *req.DefaultBranch)
 | ||
| 		argIndex++
 | ||
| 	}
 | ||
| 	if req.Language != nil {
 | ||
| 		updates = append(updates, fmt.Sprintf("language = $%d", argIndex))
 | ||
| 		args = append(args, *req.Language)
 | ||
| 		argIndex++
 | ||
| 	}
 | ||
| 	if req.Topics != nil {
 | ||
| 		updates = append(updates, fmt.Sprintf("topics = $%d", argIndex))
 | ||
| 		args = append(args, req.Topics)
 | ||
| 		argIndex++
 | ||
| 	}
 | ||
| 	
 | ||
| 	if len(updates) == 0 {
 | ||
| 		render.Status(r, http.StatusBadRequest)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "no fields to update"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	updates = append(updates, fmt.Sprintf("updated_at = $%d", argIndex))
 | ||
| 	args = append(args, time.Now())
 | ||
| 	
 | ||
| 	query := fmt.Sprintf("UPDATE repositories SET %s WHERE id = $1", strings.Join(updates, ", "))
 | ||
| 	
 | ||
| 	_, err := s.db.Pool.Exec(context.Background(), query, args...)
 | ||
| 	if err != nil {
 | ||
| 		log.Error().Err(err).Msg("Failed to update repository")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to update repository"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	render.JSON(w, r, map[string]string{"message": "Repository updated successfully"})
 | ||
| }
 | ||
| 
 | ||
| // deleteRepositoryHandler removes a repository from monitoring
 | ||
| func (s *Server) deleteRepositoryHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	repoID := chi.URLParam(r, "repoID")
 | ||
| 	
 | ||
| 	log.Info().Str("repository_id", repoID).Msg("Deleting repository monitor")
 | ||
| 	
 | ||
| 	query := "DELETE FROM repositories WHERE id = $1"
 | ||
| 	result, err := s.db.Pool.Exec(context.Background(), query, repoID)
 | ||
| 	if err != nil {
 | ||
| 		log.Error().Err(err).Msg("Failed to delete repository")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to delete repository"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	if result.RowsAffected() == 0 {
 | ||
| 		render.Status(r, http.StatusNotFound)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "repository not found"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	render.JSON(w, r, map[string]string{"message": "Repository deleted successfully"})
 | ||
| }
 | ||
| 
 | ||
| // syncRepositoryHandler triggers a manual sync of repository issues
 | ||
| func (s *Server) syncRepositoryHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	repoID := chi.URLParam(r, "repoID")
 | ||
| 	
 | ||
| 	log.Info().Str("repository_id", repoID).Msg("Manual repository sync triggered")
 | ||
| 	
 | ||
| 	if s.repoMonitor == nil {
 | ||
| 		render.Status(r, http.StatusServiceUnavailable)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "repository monitoring service not available"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Trigger repository sync in background
 | ||
| 	go func() {
 | ||
| 		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
 | ||
| 		defer cancel()
 | ||
| 		
 | ||
| 		if err := s.repoMonitor.SyncRepository(ctx, repoID); err != nil {
 | ||
| 			log.Error().
 | ||
| 				Err(err).
 | ||
| 				Str("repository_id", repoID).
 | ||
| 				Msg("Manual repository sync failed")
 | ||
| 		}
 | ||
| 	}()
 | ||
| 	
 | ||
| 	render.JSON(w, r, map[string]interface{}{
 | ||
| 		"message":       "Repository sync triggered",
 | ||
| 		"repository_id": repoID,
 | ||
| 		"status":        "started",
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| // ensureRepositoryLabelsHandler ensures required labels exist in the Gitea repository
 | ||
| func (s *Server) ensureRepositoryLabelsHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	repoID := chi.URLParam(r, "repoID")
 | ||
| 	
 | ||
| 	log.Info().Str("repository_id", repoID).Msg("Ensuring repository labels")
 | ||
| 	
 | ||
| 	if s.repoMonitor == nil || s.repoMonitor.GetGiteaClient() == nil {
 | ||
| 		render.Status(r, http.StatusServiceUnavailable)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "repository monitoring service not available"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Get repository details first
 | ||
| 	query := "SELECT owner, name FROM repositories WHERE id = $1"
 | ||
| 	var owner, name string
 | ||
| 	err := s.db.Pool.QueryRow(context.Background(), query, repoID).Scan(&owner, &name)
 | ||
| 	if err != nil {
 | ||
| 		if err.Error() == "no rows in result set" {
 | ||
| 			render.Status(r, http.StatusNotFound)
 | ||
| 			render.JSON(w, r, map[string]string{"error": "repository not found"})
 | ||
| 			return
 | ||
| 		}
 | ||
| 		log.Error().Err(err).Msg("Failed to get repository")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to get repository"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	// Ensure required labels exist
 | ||
| 	err = s.repoMonitor.GetGiteaClient().EnsureRequiredLabels(context.Background(), owner, name)
 | ||
| 	if err != nil {
 | ||
| 		log.Error().
 | ||
| 			Err(err).
 | ||
| 			Str("repository_id", repoID).
 | ||
| 			Str("owner", owner).
 | ||
| 			Str("name", name).
 | ||
| 			Msg("Failed to ensure repository labels")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to create labels: " + err.Error()})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("repository_id", repoID).
 | ||
| 		Str("owner", owner).
 | ||
| 		Str("name", name).
 | ||
| 		Msg("Successfully ensured repository labels")
 | ||
| 	
 | ||
| 	render.JSON(w, r, map[string]interface{}{
 | ||
| 		"message":       "Repository labels ensured successfully",
 | ||
| 		"repository_id": repoID,
 | ||
| 		"owner":         owner,
 | ||
| 		"name":          name,
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| // getRepositorySyncLogsHandler returns sync logs for a repository
 | ||
| func (s *Server) getRepositorySyncLogsHandler(w http.ResponseWriter, r *http.Request) {
 | ||
| 	repoID := chi.URLParam(r, "repoID")
 | ||
| 	limit := 50
 | ||
| 	
 | ||
| 	if limitParam := r.URL.Query().Get("limit"); limitParam != "" {
 | ||
| 		if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 1000 {
 | ||
| 			limit = l
 | ||
| 		}
 | ||
| 	}
 | ||
| 	
 | ||
| 	log.Info().Str("repository_id", repoID).Int("limit", limit).Msg("Getting repository sync logs")
 | ||
| 	
 | ||
| 	query := `
 | ||
| 		SELECT id, sync_type, operation, status, message, error_details,
 | ||
| 		       items_processed, items_created, items_updated, duration_ms,
 | ||
| 		       external_id, external_url, created_at
 | ||
| 		FROM repository_sync_logs 
 | ||
| 		WHERE repository_id = $1 
 | ||
| 		ORDER BY created_at DESC 
 | ||
| 		LIMIT $2`
 | ||
| 	
 | ||
| 	rows, err := s.db.Pool.Query(context.Background(), query, repoID, limit)
 | ||
| 	if err != nil {
 | ||
| 		log.Error().Err(err).Msg("Failed to query sync logs")
 | ||
| 		render.Status(r, http.StatusInternalServerError)
 | ||
| 		render.JSON(w, r, map[string]string{"error": "failed to query sync logs"})
 | ||
| 		return
 | ||
| 	}
 | ||
| 	defer rows.Close()
 | ||
| 	
 | ||
| 	logs := []map[string]interface{}{}
 | ||
| 	for rows.Next() {
 | ||
| 		var id, syncType, operation, status, message string
 | ||
| 		var errorDetails []byte
 | ||
| 		var itemsProcessed, itemsCreated, itemsUpdated, durationMs int
 | ||
| 		var externalID, externalURL *string
 | ||
| 		var createdAt time.Time
 | ||
| 		
 | ||
| 		err := rows.Scan(&id, &syncType, &operation, &status, &message, &errorDetails,
 | ||
| 			&itemsProcessed, &itemsCreated, &itemsUpdated, &durationMs,
 | ||
| 			&externalID, &externalURL, &createdAt)
 | ||
| 		if err != nil {
 | ||
| 			log.Error().Err(err).Msg("Failed to scan sync log row")
 | ||
| 			continue
 | ||
| 		}
 | ||
| 		
 | ||
| 		logEntry := map[string]interface{}{
 | ||
| 			"id":               id,
 | ||
| 			"sync_type":        syncType,
 | ||
| 			"operation":        operation,
 | ||
| 			"status":           status,
 | ||
| 			"message":          message,
 | ||
| 			"error_details":    string(errorDetails),
 | ||
| 			"items_processed":  itemsProcessed,
 | ||
| 			"items_created":    itemsCreated,
 | ||
| 			"items_updated":    itemsUpdated,
 | ||
| 			"duration_ms":      durationMs,
 | ||
| 			"external_id":      externalID,
 | ||
| 			"external_url":     externalURL,
 | ||
| 			"created_at":       createdAt.Format(time.RFC3339),
 | ||
| 		}
 | ||
| 		logs = append(logs, logEntry)
 | ||
| 	}
 | ||
| 	
 | ||
| 	render.JSON(w, r, map[string]interface{}{
 | ||
| 		"logs":  logs,
 | ||
| 		"count": len(logs),
 | ||
| 	})
 | ||
| }
 | ||
| 
 | ||
| // Helper methods for task processing
 | ||
| 
 | ||
| // inferTechStackFromLabels extracts technology information from labels
 | ||
| func (s *Server) inferTechStackFromLabels(labels []string) []string {
 | ||
| 	techMap := map[string]bool{
 | ||
| 		"go":         true,
 | ||
| 		"golang":     true,
 | ||
| 		"javascript": true,
 | ||
| 		"react":      true,
 | ||
| 		"node":       true,
 | ||
| 		"python":     true,
 | ||
| 		"java":       true,
 | ||
| 		"rust":       true,
 | ||
| 		"docker":     true,
 | ||
| 		"postgres":   true,
 | ||
| 		"mysql":      true,
 | ||
| 		"redis":      true,
 | ||
| 		"api":        true,
 | ||
| 		"backend":    true,
 | ||
| 		"frontend":   true,
 | ||
| 		"database":   true,
 | ||
| 	}
 | ||
| 	
 | ||
| 	var techStack []string
 | ||
| 	for _, label := range labels {
 | ||
| 		if techMap[strings.ToLower(label)] {
 | ||
| 			techStack = append(techStack, strings.ToLower(label))
 | ||
| 		}
 | ||
| 	}
 | ||
| 	
 | ||
| 	return techStack
 | ||
| }
 | ||
| 
 | ||
| // processTaskAsync handles complex task processing in background
 | ||
| func (s *Server) processTaskAsync(taskID string, taskInput *composer.TaskAnalysisInput) {
 | ||
| 	ctx := context.Background()
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("task_id", taskID).
 | ||
| 		Msg("Starting async task processing")
 | ||
| 	
 | ||
| 	result, err := s.teamComposer.AnalyzeAndComposeTeam(ctx, taskInput)
 | ||
| 	if err != nil {
 | ||
| 		log.Error().Err(err).
 | ||
| 			Str("task_id", taskID).
 | ||
| 			Msg("Async task analysis failed")
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	team, err := s.teamComposer.CreateTeam(ctx, result.TeamComposition, taskInput)
 | ||
| 	if err != nil {
 | ||
| 		log.Error().Err(err).
 | ||
| 			Str("task_id", taskID).
 | ||
| 			Msg("Async team creation failed")
 | ||
| 		return
 | ||
| 	}
 | ||
| 	
 | ||
| 	log.Info().
 | ||
| 		Str("task_id", taskID).
 | ||
| 		Str("team_id", team.ID.String()).
 | ||
| 		Float64("confidence", result.TeamComposition.ConfidenceScore).
 | ||
| 		Msg("Async task processing completed")
 | ||
| 	
 | ||
| 	// In production, this would update task status in database
 | ||
| 	// and potentially notify clients via websockets or webhooks
 | ||
| } |