package gitea import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "time" "github.com/chorus-services/whoosh/internal/config" "github.com/rs/zerolog/log" ) // Client represents a Gitea API client type Client struct { baseURL string token string client *http.Client config config.GITEAConfig } // Issue represents a Gitea issue type Issue struct { ID int64 `json:"id"` Number int64 `json:"number"` Title string `json:"title"` Body string `json:"body"` State string `json:"state"` Labels []Label `json:"labels"` Assignees []User `json:"assignees"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` ClosedAt *time.Time `json:"closed_at"` HTMLURL string `json:"html_url"` User User `json:"user"` Repository IssueRepository `json:"repository,omitempty"` } // Label represents a Gitea issue label type Label struct { ID int64 `json:"id"` Name string `json:"name"` Color string `json:"color"` Description string `json:"description"` } // User represents a Gitea user type User struct { ID int64 `json:"id"` Login string `json:"login"` FullName string `json:"full_name"` Email string `json:"email"` AvatarURL string `json:"avatar_url"` } // Repository represents a Gitea repository type Repository struct { ID int64 `json:"id"` Name string `json:"name"` FullName string `json:"full_name"` Owner User `json:"owner"` Description string `json:"description"` Private bool `json:"private"` HTMLURL string `json:"html_url"` CloneURL string `json:"clone_url"` SSHURL string `json:"ssh_url"` Language string `json:"language"` } // IssueRepository represents the simplified repository info in issue responses type IssueRepository struct { ID int64 `json:"id"` Name string `json:"name"` FullName string `json:"full_name"` Owner string `json:"owner"` // Note: This is a string, not a User object } // NewClient creates a new Gitea API client func NewClient(cfg config.GITEAConfig) *Client { token := cfg.Token // TODO: Handle TokenFile if needed return &Client{ baseURL: cfg.BaseURL, token: token, config: cfg, client: &http.Client{ Timeout: 30 * time.Second, }, } } // makeRequest makes an authenticated request to the Gitea API with retry logic func (c *Client) makeRequest(ctx context.Context, method, endpoint string) (*http.Response, error) { url := fmt.Sprintf("%s/api/v1%s", c.baseURL, endpoint) if c.config.DebugURLs { log.Debug(). Str("method", method). Str("url", url). Msg("Making Gitea API request") } var lastErr error for attempt := 0; attempt <= c.config.MaxRetries; attempt++ { if attempt > 0 { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(c.config.RetryDelay): // Continue with retry } if c.config.DebugURLs { log.Debug(). Int("attempt", attempt). Str("url", url). Msg("Retrying Gitea API request") } } req, err := http.NewRequestWithContext(ctx, method, url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } if c.token != "" { req.Header.Set("Authorization", "token "+c.token) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := c.client.Do(req) if err != nil { lastErr = fmt.Errorf("failed to make request: %w", err) log.Warn(). Err(err). Str("url", url). Int("attempt", attempt). Msg("Gitea API request failed") continue } if resp.StatusCode >= 400 { defer resp.Body.Close() lastErr = fmt.Errorf("API request failed with status %d", resp.StatusCode) // Only retry on specific status codes (5xx errors, rate limiting) if resp.StatusCode >= 500 || resp.StatusCode == 429 { log.Warn(). Int("status_code", resp.StatusCode). Str("url", url). Int("attempt", attempt). Msg("Retryable Gitea API error") continue } // Don't retry on 4xx errors (client errors) return nil, lastErr } // Success return resp, nil } return nil, fmt.Errorf("max retries exceeded: %w", lastErr) } // GetRepository retrieves repository information func (c *Client) GetRepository(ctx context.Context, owner, repo string) (*Repository, error) { endpoint := fmt.Sprintf("/repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo)) resp, err := c.makeRequest(ctx, "GET", endpoint) if err != nil { return nil, fmt.Errorf("failed to get repository: %w", err) } defer resp.Body.Close() var repository Repository if err := json.NewDecoder(resp.Body).Decode(&repository); err != nil { return nil, fmt.Errorf("failed to decode repository: %w", err) } return &repository, nil } // GetIssues retrieves issues from a repository with hardening features func (c *Client) GetIssues(ctx context.Context, owner, repo string, opts IssueListOptions) ([]Issue, error) { endpoint := fmt.Sprintf("/repos/%s/%s/issues", url.PathEscape(owner), url.PathEscape(repo)) // Add query parameters params := url.Values{} if opts.State != "" { params.Set("state", opts.State) } // EAGER_FILTER: Apply label pre-filtering at the API level for efficiency if c.config.EagerFilter && opts.Labels != "" { params.Set("labels", opts.Labels) if c.config.DebugURLs { log.Debug(). Str("labels", opts.Labels). Bool("eager_filter", true). Msg("Applying eager label filtering") } } if opts.Page > 0 { params.Set("page", strconv.Itoa(opts.Page)) } if opts.Limit > 0 { params.Set("limit", strconv.Itoa(opts.Limit)) } // FULL_RESCAN: Optionally ignore since parameter for complete rescan if !c.config.FullRescan && !opts.Since.IsZero() { params.Set("since", opts.Since.Format(time.RFC3339)) if c.config.DebugURLs { log.Debug(). Time("since", opts.Since). Msg("Using since parameter for incremental fetch") } } else if c.config.FullRescan { if c.config.DebugURLs { log.Debug(). Bool("full_rescan", true). Msg("Performing full rescan (ignoring since parameter)") } } if len(params) > 0 { endpoint += "?" + params.Encode() } resp, err := c.makeRequest(ctx, "GET", endpoint) if err != nil { return nil, fmt.Errorf("failed to get issues: %w", err) } defer resp.Body.Close() var issues []Issue if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { return nil, fmt.Errorf("failed to decode issues: %w", err) } // Apply in-code filtering when EAGER_FILTER is disabled if !c.config.EagerFilter && opts.Labels != "" { issues = c.filterIssuesByLabels(issues, opts.Labels) if c.config.DebugURLs { log.Debug(). Str("labels", opts.Labels). Bool("eager_filter", false). Int("filtered_count", len(issues)). Msg("Applied in-code label filtering") } } // Set repository information on each issue for context for i := range issues { issues[i].Repository = IssueRepository{ Name: repo, FullName: fmt.Sprintf("%s/%s", owner, repo), Owner: owner, // Now a string instead of User object } } if c.config.DebugURLs { log.Debug(). Str("owner", owner). Str("repo", repo). Int("issue_count", len(issues)). Msg("Gitea issues fetched successfully") } return issues, nil } // filterIssuesByLabels filters issues by label names (in-code filtering when eager filter is disabled) func (c *Client) filterIssuesByLabels(issues []Issue, labelFilter string) []Issue { if labelFilter == "" { return issues } // Parse comma-separated label names requiredLabels := strings.Split(labelFilter, ",") for i, label := range requiredLabels { requiredLabels[i] = strings.TrimSpace(label) } var filtered []Issue for _, issue := range issues { hasRequiredLabels := true for _, requiredLabel := range requiredLabels { found := false for _, issueLabel := range issue.Labels { if issueLabel.Name == requiredLabel { found = true break } } if !found { hasRequiredLabels = false break } } if hasRequiredLabels { filtered = append(filtered, issue) } } return filtered } // GetIssue retrieves a specific issue func (c *Client) GetIssue(ctx context.Context, owner, repo string, issueNumber int64) (*Issue, error) { endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d", url.PathEscape(owner), url.PathEscape(repo), issueNumber) resp, err := c.makeRequest(ctx, "GET", endpoint) if err != nil { return nil, fmt.Errorf("failed to get issue: %w", err) } defer resp.Body.Close() var issue Issue if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { return nil, fmt.Errorf("failed to decode issue: %w", err) } // Set repository information issue.Repository = IssueRepository{ Name: repo, FullName: fmt.Sprintf("%s/%s", owner, repo), Owner: owner, // Now a string instead of User object } return &issue, nil } // IssueListOptions contains options for listing issues type IssueListOptions struct { State string // "open", "closed", "all" Labels string // Comma-separated list of label names Page int // Page number (1-based) Limit int // Number of items per page (default: 20, max: 100) Since time.Time // Only show issues updated after this time } // TestConnection tests the connection to Gitea API func (c *Client) TestConnection(ctx context.Context) error { resp, err := c.makeRequest(ctx, "GET", "/user") if err != nil { return fmt.Errorf("connection test failed: %w", err) } defer resp.Body.Close() return nil } // WebhookPayload represents a Gitea webhook payload type WebhookPayload struct { Action string `json:"action"` Number int64 `json:"number,omitempty"` Issue *Issue `json:"issue,omitempty"` Repository Repository `json:"repository"` Sender User `json:"sender"` } // CreateLabelRequest represents the request to create a new label type CreateLabelRequest struct { Name string `json:"name"` Color string `json:"color"` Description string `json:"description"` } // CreateLabel creates a new label in a repository func (c *Client) CreateLabel(ctx context.Context, owner, repo string, label CreateLabelRequest) (*Label, error) { endpoint := fmt.Sprintf("/repos/%s/%s/labels", url.PathEscape(owner), url.PathEscape(repo)) jsonData, err := json.Marshal(label) if err != nil { return nil, fmt.Errorf("failed to marshal label data: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/api/v1%s", c.baseURL, endpoint), strings.NewReader(string(jsonData))) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } if c.token != "" { req.Header.Set("Authorization", "token "+c.token) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := c.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return nil, fmt.Errorf("API request failed with status %d", resp.StatusCode) } var createdLabel Label if err := json.NewDecoder(resp.Body).Decode(&createdLabel); err != nil { return nil, fmt.Errorf("failed to decode label: %w", err) } return &createdLabel, nil } // GetLabels retrieves all labels from a repository func (c *Client) GetLabels(ctx context.Context, owner, repo string) ([]Label, error) { endpoint := fmt.Sprintf("/repos/%s/%s/labels", url.PathEscape(owner), url.PathEscape(repo)) resp, err := c.makeRequest(ctx, "GET", endpoint) if err != nil { return nil, fmt.Errorf("failed to get labels: %w", err) } defer resp.Body.Close() var labels []Label if err := json.NewDecoder(resp.Body).Decode(&labels); err != nil { return nil, fmt.Errorf("failed to decode labels: %w", err) } return labels, nil } // EnsureRequiredLabels ensures that required labels exist in the repository func (c *Client) EnsureRequiredLabels(ctx context.Context, owner, repo string) error { requiredLabels := []CreateLabelRequest{ { Name: "bzzz-task", Color: "ff6b6b", Description: "Issues that should be converted to BZZZ tasks for CHORUS", }, { Name: "whoosh-monitored", Color: "4ecdc4", Description: "Repository is monitored by WHOOSH", }, { Name: "priority-high", Color: "e74c3c", Description: "High priority task for immediate attention", }, { Name: "priority-medium", Color: "f39c12", Description: "Medium priority task", }, { Name: "priority-low", Color: "95a5a6", Description: "Low priority task", }, } // Get existing labels existingLabels, err := c.GetLabels(ctx, owner, repo) if err != nil { return fmt.Errorf("failed to get existing labels: %w", err) } // Create a map of existing label names for quick lookup existingLabelNames := make(map[string]bool) for _, label := range existingLabels { existingLabelNames[label.Name] = true } // Create missing required labels for _, requiredLabel := range requiredLabels { if !existingLabelNames[requiredLabel.Name] { _, err := c.CreateLabel(ctx, owner, repo, requiredLabel) if err != nil { return fmt.Errorf("failed to create label %s: %w", requiredLabel.Name, err) } } } return nil }