@goal: WHOOSH-LABELS-004, WSH-CONSISTENCY - Ecosystem standardization Replace custom label set with standardized CHORUS ecosystem labels: - Add all 8 GitHub-standard labels (bug, duplicate, enhancement, etc.) - Fix bzzz-task color from #ff6b6b to #5319e7 for consistency - Remove custom priority labels and whoosh-monitored label - Maintain backward compatibility and error handling Changes: - Updated EnsureRequiredLabels() in internal/gitea/client.go - Added requirement traceability comments throughout - Updated integration points in internal/server/server.go - Created comprehensive decision record Benefits: - Consistent labeling across WHOOSH, CHORUS, KACHING repositories - Familiar GitHub-standard labels for better developer experience - Improved tool integration and issue management - Preserved bzzz-task automation for CHORUS workflow Fixes: WHOOSH issue #4 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
512 lines
14 KiB
Go
512 lines
14 KiB
Go
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
|
|
}
|
|
|
|
// @goal: WHOOSH-LABELS-004, WSH-CONSISTENCY - Ecosystem standardization
|
|
// WHY: Ensures all CHORUS ecosystem repositories have consistent GitHub-standard labels
|
|
// EnsureRequiredLabels ensures that required labels exist in the repository
|
|
func (c *Client) EnsureRequiredLabels(ctx context.Context, owner, repo string) error {
|
|
// @goal: WHOOSH-LABELS-004 - Standardized label set matching WHOOSH repository conventions
|
|
// WHY: Provides consistent issue categorization across all CHORUS ecosystem repositories
|
|
requiredLabels := []CreateLabelRequest{
|
|
{
|
|
Name: "bug",
|
|
Color: "ee0701",
|
|
Description: "Something is not working",
|
|
},
|
|
{
|
|
Name: "bzzz-task",
|
|
Color: "5319e7", // @goal: WHOOSH-LABELS-004 - Corrected color to match ecosystem standard
|
|
Description: "CHORUS task for auto ingestion.",
|
|
},
|
|
{
|
|
Name: "duplicate",
|
|
Color: "cccccc",
|
|
Description: "This issue or pull request already exists",
|
|
},
|
|
{
|
|
Name: "enhancement",
|
|
Color: "84b6eb",
|
|
Description: "New feature",
|
|
},
|
|
{
|
|
Name: "help wanted",
|
|
Color: "128a0c",
|
|
Description: "Need some help",
|
|
},
|
|
{
|
|
Name: "invalid",
|
|
Color: "e6e6e6",
|
|
Description: "Something is wrong",
|
|
},
|
|
{
|
|
Name: "question",
|
|
Color: "cc317c",
|
|
Description: "More information is needed",
|
|
},
|
|
{
|
|
Name: "wontfix",
|
|
Color: "ffffff",
|
|
Description: "This won't be fixed",
|
|
},
|
|
}
|
|
|
|
// @goal: WHOOSH-LABELS-004 - Check existing labels to avoid duplicates
|
|
// WHY: Prevents API errors from attempting to create labels that already exist
|
|
existingLabels, err := c.GetLabels(ctx, owner, repo)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get existing labels: %w", err)
|
|
}
|
|
|
|
// @goal: WHOOSH-LABELS-004 - Build lookup map for efficient duplicate checking
|
|
// WHY: O(1) lookup performance for label existence checking
|
|
existingLabelNames := make(map[string]bool)
|
|
for _, label := range existingLabels {
|
|
existingLabelNames[label.Name] = true
|
|
}
|
|
|
|
// @goal: WHOOSH-LABELS-004 - Create only missing standardized labels
|
|
// WHY: Ensures all repositories have consistent labeling without overwriting existing 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
|
|
}
|