package tasks import ( "context" "encoding/json" "fmt" "strconv" "strings" "time" "github.com/chorus-services/whoosh/internal/gitea" "github.com/rs/zerolog/log" ) // GiteaIntegration handles synchronization with GITEA issues type GiteaIntegration struct { taskService *Service giteaClient *gitea.Client config *GiteaConfig } // GiteaConfig contains GITEA integration configuration type GiteaConfig struct { BaseURL string `json:"base_url"` TaskLabel string `json:"task_label"` // e.g., "bzzz-task" Repositories []string `json:"repositories"` // repositories to monitor TeamMapping map[string]string `json:"team_mapping"` // label -> team mapping } // NewGiteaIntegration creates a new GITEA integration func NewGiteaIntegration(taskService *Service, giteaClient *gitea.Client, config *GiteaConfig) *GiteaIntegration { if config == nil { config = &GiteaConfig{ TaskLabel: "bzzz-task", Repositories: []string{}, TeamMapping: make(map[string]string), } } return &GiteaIntegration{ taskService: taskService, giteaClient: giteaClient, config: config, } } // GiteaIssue represents a GITEA issue response type GiteaIssue struct { ID int `json:"id"` Number int `json:"number"` Title string `json:"title"` Body string `json:"body"` State string `json:"state"` // "open", "closed" URL string `json:"html_url"` Labels []GiteaLabel `json:"labels"` Repository GiteaRepo `json:"repository"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Assignees []GiteaUser `json:"assignees"` } type GiteaLabel struct { Name string `json:"name"` Color string `json:"color"` Description string `json:"description"` } type GiteaRepo struct { FullName string `json:"full_name"` HTMLURL string `json:"html_url"` } type GiteaUser struct { ID int `json:"id"` Login string `json:"login"` FullName string `json:"full_name"` } // SyncIssuesFromGitea fetches issues from GITEA and creates/updates tasks func (g *GiteaIntegration) SyncIssuesFromGitea(ctx context.Context, repository string) error { log.Info(). Str("repository", repository). Msg("Starting GITEA issue sync") // Fetch issues from GITEA API issues, err := g.fetchIssuesFromGitea(ctx, repository) if err != nil { return fmt.Errorf("failed to fetch GITEA issues: %w", err) } syncedCount := 0 errorCount := 0 for _, issue := range issues { // Check if issue has task label if !g.hasTaskLabel(issue) { continue } err := g.syncIssue(ctx, issue) if err != nil { log.Error().Err(err). Int("issue_id", issue.ID). Str("repository", repository). Msg("Failed to sync issue") errorCount++ continue } syncedCount++ } log.Info(). Str("repository", repository). Int("synced", syncedCount). Int("errors", errorCount). Msg("GITEA issue sync completed") return nil } // SyncIssue synchronizes a single GITEA issue with the task system func (g *GiteaIntegration) syncIssue(ctx context.Context, issue GiteaIssue) error { externalID := fmt.Sprintf("%d", issue.ID) // Check if task already exists existingTask, err := g.taskService.GetTaskByExternalID(ctx, externalID, SourceTypeGitea) if err != nil && !strings.Contains(err.Error(), "not found") { return fmt.Errorf("failed to check existing task: %w", err) } if existingTask != nil { // Update existing task return g.updateTaskFromIssue(ctx, existingTask, issue) } else { // Create new task return g.createTaskFromIssue(ctx, issue) } } // createTaskFromIssue creates a new task from a GITEA issue func (g *GiteaIntegration) createTaskFromIssue(ctx context.Context, issue GiteaIssue) error { labels := make([]string, len(issue.Labels)) for i, label := range issue.Labels { labels[i] = label.Name } // Determine priority from labels priority := g.determinePriorityFromLabels(labels) // Extract estimated hours from issue body (look for patterns like "Estimated: 4 hours") estimatedHours := g.extractEstimatedHours(issue.Body) input := &CreateTaskInput{ ExternalID: fmt.Sprintf("%d", issue.ID), ExternalURL: issue.URL, SourceType: SourceTypeGitea, SourceConfig: map[string]interface{}{ "gitea_number": issue.Number, "repository": issue.Repository.FullName, "assignees": issue.Assignees, }, Title: issue.Title, Description: issue.Body, Priority: priority, Repository: issue.Repository.FullName, Labels: labels, EstimatedHours: estimatedHours, ExternalCreatedAt: &issue.CreatedAt, ExternalUpdatedAt: &issue.UpdatedAt, } task, err := g.taskService.CreateTask(ctx, input) if err != nil { return fmt.Errorf("failed to create task from GITEA issue: %w", err) } log.Info(). Str("task_id", task.ID.String()). Int("gitea_issue_id", issue.ID). Str("repository", issue.Repository.FullName). Msg("Created task from GITEA issue") return nil } // updateTaskFromIssue updates an existing task from a GITEA issue func (g *GiteaIntegration) updateTaskFromIssue(ctx context.Context, task *Task, issue GiteaIssue) error { // Check if issue was updated since last sync if task.ExternalUpdatedAt != nil && !issue.UpdatedAt.After(*task.ExternalUpdatedAt) { return nil // No updates needed } // Determine new status based on GITEA state var newStatus TaskStatus switch issue.State { case "open": if task.Status == TaskStatusClosed { newStatus = TaskStatusOpen } case "closed": if task.Status != TaskStatusClosed { newStatus = TaskStatusClosed } } // Update status if changed if newStatus != "" && newStatus != task.Status { update := &TaskStatusUpdate{ TaskID: task.ID, Status: newStatus, Reason: fmt.Sprintf("GITEA issue state changed to %s", issue.State), } err := g.taskService.UpdateTaskStatus(ctx, update) if err != nil { return fmt.Errorf("failed to update task status: %w", err) } log.Info(). Str("task_id", task.ID.String()). Int("gitea_issue_id", issue.ID). Str("old_status", string(task.Status)). Str("new_status", string(newStatus)). Msg("Updated task status from GITEA issue") } // TODO: Update other fields like title, description, labels if needed // This would require additional database operations return nil } // ProcessGiteaWebhook processes a GITEA webhook payload func (g *GiteaIntegration) ProcessGiteaWebhook(ctx context.Context, payload []byte) error { var webhookData struct { Action string `json:"action"` Issue GiteaIssue `json:"issue"` Repository GiteaRepo `json:"repository"` } if err := json.Unmarshal(payload, &webhookData); err != nil { return fmt.Errorf("failed to parse GITEA webhook payload: %w", err) } // Only process issues with task label if !g.hasTaskLabel(webhookData.Issue) { log.Debug(). Int("issue_id", webhookData.Issue.ID). Str("action", webhookData.Action). Msg("Ignoring GITEA issue without task label") return nil } log.Info(). Str("action", webhookData.Action). Int("issue_id", webhookData.Issue.ID). Str("repository", webhookData.Repository.FullName). Msg("Processing GITEA webhook") switch webhookData.Action { case "opened", "edited", "reopened", "closed": return g.syncIssue(ctx, webhookData.Issue) case "labeled", "unlabeled": // Re-sync to update task labels and tech stack return g.syncIssue(ctx, webhookData.Issue) default: log.Debug(). Str("action", webhookData.Action). Msg("Ignoring GITEA webhook action") return nil } } // Helper methods func (g *GiteaIntegration) fetchIssuesFromGitea(ctx context.Context, repository string) ([]GiteaIssue, error) { // This would make actual HTTP calls to GITEA API // For MVP, we'll return mock data based on known structure // In production, this would be: // url := fmt.Sprintf("%s/repos/%s/issues", g.config.BaseURL, repository) // resp, err := g.giteaClient.Get(url) // ... parse response // Mock issues for testing mockIssues := []GiteaIssue{ { ID: 123, Number: 1, Title: "Implement user authentication system", Body: "Add JWT-based authentication with login and registration endpoints\n\n- JWT token generation\n- User registration\n- Password hashing\n\nEstimated: 8 hours", State: "open", URL: fmt.Sprintf("https://gitea.chorus.services/%s/issues/1", repository), Labels: []GiteaLabel{ {Name: "bzzz-task", Color: "0052cc"}, {Name: "backend", Color: "1d76db"}, {Name: "high-priority", Color: "d93f0b"}, }, Repository: GiteaRepo{FullName: repository}, CreatedAt: time.Now().Add(-24 * time.Hour), UpdatedAt: time.Now().Add(-2 * time.Hour), }, { ID: 124, Number: 2, Title: "Fix database connection pooling", Body: "Connection pool is not releasing connections properly under high load\n\nSteps to reproduce:\n1. Start application\n2. Generate high load\n3. Monitor connection count", State: "open", URL: fmt.Sprintf("https://gitea.chorus.services/%s/issues/2", repository), Labels: []GiteaLabel{ {Name: "bzzz-task", Color: "0052cc"}, {Name: "database", Color: "5319e7"}, {Name: "bug", Color: "d93f0b"}, }, Repository: GiteaRepo{FullName: repository}, CreatedAt: time.Now().Add(-12 * time.Hour), UpdatedAt: time.Now().Add(-1 * time.Hour), }, } log.Debug(). Str("repository", repository). Int("mock_issues", len(mockIssues)). Msg("Returning mock GITEA issues for MVP") return mockIssues, nil } func (g *GiteaIntegration) hasTaskLabel(issue GiteaIssue) bool { for _, label := range issue.Labels { if label.Name == g.config.TaskLabel { return true } } return false } func (g *GiteaIntegration) determinePriorityFromLabels(labels []string) TaskPriority { for _, label := range labels { switch strings.ToLower(label) { case "critical", "urgent", "critical-priority": return TaskPriorityCritical case "high", "high-priority", "important": return TaskPriorityHigh case "low", "low-priority", "minor": return TaskPriorityLow } } return TaskPriorityMedium } func (g *GiteaIntegration) extractEstimatedHours(body string) int { // Look for patterns like "Estimated: 4 hours", "Est: 8h", etc. lines := strings.Split(strings.ToLower(body), "\n") for _, line := range lines { if strings.Contains(line, "estimated:") || strings.Contains(line, "est:") { // Extract number from line words := strings.Fields(line) for i, word := range words { if (word == "estimated:" || word == "est:") && i+1 < len(words) { if hours, err := strconv.Atoi(strings.TrimSuffix(words[i+1], "h")); err == nil { return hours } } } } } return 0 }