feat: Production readiness improvements for WHOOSH council formation
Major security, observability, and configuration improvements:
## Security Hardening
- Implemented configurable CORS (no more wildcards)
- Added comprehensive auth middleware for admin endpoints
- Enhanced webhook HMAC validation
- Added input validation and rate limiting
- Security headers and CSP policies
## Configuration Management
- Made N8N webhook URL configurable (WHOOSH_N8N_BASE_URL)
- Replaced all hardcoded endpoints with environment variables
- Added feature flags for LLM vs heuristic composition
- Gitea fetch hardening with EAGER_FILTER and FULL_RESCAN options
## API Completeness
- Implemented GetCouncilComposition function
- Added GET /api/v1/councils/{id} endpoint
- Council artifacts API (POST/GET /api/v1/councils/{id}/artifacts)
- /admin/health/details endpoint with component status
- Database lookup for repository URLs (no hardcoded fallbacks)
## Observability & Performance
- Added OpenTelemetry distributed tracing with goal/pulse correlation
- Performance optimization database indexes
- Comprehensive health monitoring
- Enhanced logging and error handling
## Infrastructure
- Production-ready P2P discovery (replaces mock implementation)
- Removed unused Redis configuration
- Enhanced Docker Swarm integration
- Added migration files for performance indexes
## Code Quality
- Comprehensive input validation
- Graceful error handling and failsafe fallbacks
- Backwards compatibility maintained
- Following security best practices
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/chorus-services/whoosh/internal/config"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Client represents a Gitea API client
|
||||
@@ -18,6 +19,7 @@ type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
client *http.Client
|
||||
config config.GITEAConfig
|
||||
}
|
||||
|
||||
// Issue represents a Gitea issue
|
||||
@@ -84,38 +86,87 @@ func NewClient(cfg config.GITEAConfig) *Client {
|
||||
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
|
||||
// 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)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
if c.config.DebugURLs {
|
||||
log.Debug().
|
||||
Str("method", method).
|
||||
Str("url", url).
|
||||
Msg("Making Gitea API request")
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
defer resp.Body.Close()
|
||||
return nil, fmt.Errorf("API request failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
return nil, fmt.Errorf("max retries exceeded: %w", lastErr)
|
||||
}
|
||||
|
||||
// GetRepository retrieves repository information
|
||||
@@ -136,7 +187,7 @@ func (c *Client) GetRepository(ctx context.Context, owner, repo string) (*Reposi
|
||||
return &repository, nil
|
||||
}
|
||||
|
||||
// GetIssues retrieves issues from a repository
|
||||
// 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))
|
||||
|
||||
@@ -145,17 +196,39 @@ func (c *Client) GetIssues(ctx context.Context, owner, repo string, opts IssueLi
|
||||
if opts.State != "" {
|
||||
params.Set("state", opts.State)
|
||||
}
|
||||
if opts.Labels != "" {
|
||||
|
||||
// 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))
|
||||
}
|
||||
if !opts.Since.IsZero() {
|
||||
|
||||
// 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 {
|
||||
@@ -173,6 +246,18 @@ func (c *Client) GetIssues(ctx context.Context, owner, repo string, opts IssueLi
|
||||
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{
|
||||
@@ -182,9 +267,55 @@ func (c *Client) GetIssues(ctx context.Context, owner, repo string, opts IssueLi
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user