Initial commit.

This commit is contained in:
2025-12-10 21:09:25 +00:00
commit 9d4de0e6b6
73 changed files with 15219 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
package config
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// Load reads and parses a configuration file
func Load(path string) (*Config, error) {
cleanPath := filepath.Clean(path)
data, err := os.ReadFile(cleanPath) // #nosec G304 -- path is user-provided config file
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// Expand environment variables
expanded := expandEnvVars(string(data))
// Start with defaults
cfg := DefaultConfig()
// Parse YAML
if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
// Validate configuration
if err := Validate(cfg); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return cfg, nil
}
// expandEnvVars replaces ${VAR} patterns with environment variable values
func expandEnvVars(input string) string {
re := regexp.MustCompile(`\$\{([^}]+)\}`)
return re.ReplaceAllStringFunc(input, func(match string) string {
// Extract variable name
varName := strings.TrimPrefix(strings.TrimSuffix(match, "}"), "${")
return os.Getenv(varName)
})
}
// parseRelativeDate parses relative date strings like "-90d", "-2w", "-3m"
// Returns the parsed time or nil if not a relative format
func parseRelativeDate(s string) *time.Time {
if !strings.HasPrefix(s, "-") && !strings.HasPrefix(s, "+") {
return nil
}
// Parse the number and unit
s = strings.TrimSpace(s)
if len(s) < 2 {
return nil
}
unit := s[len(s)-1]
numStr := s[1 : len(s)-1] // Skip the +/- prefix and unit suffix
num := 0
for _, c := range numStr {
if c < '0' || c > '9' {
return nil
}
num = num*10 + int(c-'0')
}
if s[0] == '-' {
num = -num
}
now := time.Now()
var result time.Time
switch unit {
case 'd': // days
result = now.AddDate(0, 0, num)
case 'w': // weeks
result = now.AddDate(0, 0, num*7)
case 'm': // months
result = now.AddDate(0, num, 0)
case 'y': // years
result = now.AddDate(num, 0, 0)
default:
return nil
}
// Normalize to start of day
result = time.Date(result.Year(), result.Month(), result.Day(), 0, 0, 0, 0, result.Location())
return &result
}
// GetParsedDateRange parses and returns the date range with defaults
// Supports both absolute dates (2024-01-01) and relative dates (-90d, -2w, -3m, -1y)
func (c *Config) GetParsedDateRange() (*ParsedDateRange, error) {
result := &ParsedDateRange{}
if c.DateRange.Start != "" {
// Try relative date first
if t := parseRelativeDate(c.DateRange.Start); t != nil {
result.Start = t
} else {
// Try absolute date
t, err := time.Parse("2006-01-02", c.DateRange.Start)
if err != nil {
return nil, fmt.Errorf("invalid start date format (use YYYY-MM-DD or -Nd/-Nw/-Nm/-Ny): %w", err)
}
result.Start = &t
}
}
if c.DateRange.End != "" {
// Try relative date first
if t := parseRelativeDate(c.DateRange.End); t != nil {
// Set end to end of day
endOfDay := t.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
result.End = &endOfDay
} else {
// Try absolute date
t, err := time.Parse("2006-01-02", c.DateRange.End)
if err != nil {
return nil, fmt.Errorf("invalid end date format (use YYYY-MM-DD or -Nd/-Nw/-Nm/-Ny): %w", err)
}
// Set end to end of day
t = t.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
result.End = &t
}
} else {
// Default to now
now := time.Now()
result.End = &now
}
return result, nil
}
// GetCacheTTL returns the cache TTL as a time.Duration
func (c *Config) GetCacheTTL() (time.Duration, error) {
if c.Cache.TTL == "" {
return 24 * time.Hour, nil
}
return time.ParseDuration(c.Cache.TTL)
}
// HasGithubToken returns true if token authentication is configured
func (c *Config) HasGithubToken() bool {
return c.Auth.GithubToken != ""
}
// HasGithubApp returns true if GitHub App authentication is configured
func (c *Config) HasGithubApp() bool {
return c.Auth.GithubApp != nil &&
c.Auth.GithubApp.AppID > 0 &&
c.Auth.GithubApp.InstallationID > 0 &&
(c.Auth.GithubApp.PrivateKey != "" || c.Auth.GithubApp.PrivateKeyPath != "")
}
// GetGithubAppPrivateKey returns the GitHub App private key content
func (c *Config) GetGithubAppPrivateKey() ([]byte, error) {
if c.Auth.GithubApp == nil {
return nil, fmt.Errorf("GitHub App not configured")
}
if c.Auth.GithubApp.PrivateKey != "" {
return []byte(c.Auth.GithubApp.PrivateKey), nil
}
if c.Auth.GithubApp.PrivateKeyPath != "" {
cleanPath := filepath.Clean(c.Auth.GithubApp.PrivateKeyPath)
return os.ReadFile(cleanPath) // #nosec G304 -- path is user-provided config value
}
return nil, fmt.Errorf("no private key configured")
}
// GetTeamForUser returns the team configuration for a given username
func (c *Config) GetTeamForUser(username string) *TeamConfig {
for i := range c.Teams {
for _, member := range c.Teams[i].Members {
if strings.EqualFold(member, username) {
return &c.Teams[i]
}
}
}
return nil
}
// IsBot checks if a username matches bot patterns
func (c *Config) IsBot(username string) bool {
if c.Options.IncludeBots {
return false
}
lower := strings.ToLower(username)
for _, pattern := range c.Options.BotPatterns {
pattern = strings.ToLower(pattern)
if matchPattern(lower, pattern) {
return true
}
}
return false
}
// matchPattern performs simple glob-style pattern matching
func matchPattern(s, pattern string) bool {
// Handle exact match
if !strings.Contains(pattern, "*") {
return s == pattern
}
// Handle prefix match (pattern*)
if strings.HasSuffix(pattern, "*") && !strings.HasPrefix(pattern, "*") {
return strings.HasPrefix(s, strings.TrimSuffix(pattern, "*"))
}
// Handle suffix match (*pattern)
if strings.HasPrefix(pattern, "*") && !strings.HasSuffix(pattern, "*") {
return strings.HasSuffix(s, strings.TrimPrefix(pattern, "*"))
}
// Handle contains match (*pattern*)
if strings.HasPrefix(pattern, "*") && strings.HasSuffix(pattern, "*") {
inner := strings.TrimPrefix(strings.TrimSuffix(pattern, "*"), "*")
return strings.Contains(s, inner)
}
return false
}
// GetCustomPeriods returns parsed custom periods
func (c *Config) GetCustomPeriods() ([]ParsedCustomPeriod, error) {
var periods []ParsedCustomPeriod
for _, cp := range c.CustomPeriods {
start, err := time.Parse("2006-01-02", cp.Start)
if err != nil {
return nil, fmt.Errorf("invalid start date for period %s: %w", cp.Name, err)
}
end, err := time.Parse("2006-01-02", cp.End)
if err != nil {
return nil, fmt.Errorf("invalid end date for period %s: %w", cp.Name, err)
}
// Set end to end of day
end = end.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
periods = append(periods, ParsedCustomPeriod{
Name: cp.Name,
Start: start,
End: end,
})
}
return periods, nil
}
// ParsedCustomPeriod represents a parsed custom time period
type ParsedCustomPeriod struct {
Name string
Start time.Time
End time.Time
}
+920
View File
@@ -0,0 +1,920 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoad(t *testing.T) {
tests := []struct {
name string
configYAML string
envVars map[string]string
expectError bool
validate func(t *testing.T, cfg *Config)
}{
{
name: "valid config with token",
configYAML: `
version: "1.0"
auth:
github_token: "ghp_test123"
repositories:
- owner: "testorg"
name: "testrepo"
`,
expectError: false,
validate: func(t *testing.T, cfg *Config) {
assert.Equal(t, "1.0", cfg.Version)
assert.Equal(t, "ghp_test123", cfg.Auth.GithubToken)
assert.Len(t, cfg.Repositories, 1)
assert.Equal(t, "testorg", cfg.Repositories[0].Owner)
assert.Equal(t, "testrepo", cfg.Repositories[0].Name)
},
},
{
name: "config with env var substitution",
configYAML: `
version: "1.0"
auth:
github_token: "${TEST_GITHUB_TOKEN_LOAD}"
repositories:
- owner: "testorg"
name: "testrepo"
`,
envVars: map[string]string{
"TEST_GITHUB_TOKEN_LOAD": "ghp_from_env",
},
expectError: false,
validate: func(t *testing.T, cfg *Config) {
assert.Equal(t, "ghp_from_env", cfg.Auth.GithubToken)
},
},
{
name: "config with date range",
configYAML: `
version: "1.0"
auth:
github_token: "ghp_test123"
repositories:
- owner: "testorg"
name: "testrepo"
date_range:
start: "2024-01-01"
end: "2024-12-31"
`,
expectError: false,
validate: func(t *testing.T, cfg *Config) {
dateRange, err := cfg.GetParsedDateRange()
require.NoError(t, err)
assert.NotNil(t, dateRange.Start)
assert.NotNil(t, dateRange.End)
assert.Equal(t, 2024, dateRange.Start.Year())
assert.Equal(t, time.January, dateRange.Start.Month())
assert.Equal(t, 1, dateRange.Start.Day())
},
},
{
name: "config with teams",
configYAML: `
version: "1.0"
auth:
github_token: "ghp_test123"
repositories:
- owner: "testorg"
name: "testrepo"
teams:
- name: "Backend"
members:
- "user1"
- "user2"
color: "#3b82f6"
- name: "Frontend"
members:
- "user3"
`,
expectError: false,
validate: func(t *testing.T, cfg *Config) {
assert.Len(t, cfg.Teams, 2)
assert.Equal(t, "Backend", cfg.Teams[0].Name)
assert.Contains(t, cfg.Teams[0].Members, "user1")
assert.Equal(t, "#3b82f6", cfg.Teams[0].Color)
},
},
{
name: "config with custom scoring",
configYAML: `
version: "1.0"
auth:
github_token: "ghp_test123"
repositories:
- owner: "testorg"
name: "testrepo"
scoring:
enabled: true
points:
commit: 20
pr_merged: 100
`,
expectError: false,
validate: func(t *testing.T, cfg *Config) {
assert.True(t, cfg.Scoring.Enabled)
assert.Equal(t, 20, cfg.Scoring.Points.Commit)
assert.Equal(t, 100, cfg.Scoring.Points.PRMerged)
},
},
{
name: "config with github app",
configYAML: `
version: "1.0"
auth:
github_app:
app_id: 12345
installation_id: 67890
private_key: "test-key-content"
repositories:
- owner: "testorg"
name: "testrepo"
`,
expectError: false,
validate: func(t *testing.T, cfg *Config) {
assert.True(t, cfg.HasGithubApp())
assert.Equal(t, int64(12345), cfg.Auth.GithubApp.AppID)
assert.Equal(t, int64(67890), cfg.Auth.GithubApp.InstallationID)
},
},
{
name: "invalid config - no auth",
configYAML: `
version: "1.0"
repositories:
- owner: "testorg"
name: "testrepo"
`,
expectError: true,
},
{
name: "invalid config - no repositories",
configYAML: `
version: "1.0"
auth:
github_token: "ghp_test123"
`,
expectError: true,
},
{
name: "invalid config - invalid date format",
configYAML: `
version: "1.0"
auth:
github_token: "ghp_test123"
repositories:
- owner: "testorg"
name: "testrepo"
date_range:
start: "not-a-date"
`,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set up environment variables (sequential test due to env var usage)
for k, v := range tt.envVars {
t.Setenv(k, v)
}
// Create temp config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte(tt.configYAML), 0644)
require.NoError(t, err)
// Load config
cfg, err := Load(configPath)
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, cfg)
if tt.validate != nil {
tt.validate(t, cfg)
}
})
}
}
func TestExpandEnvVars(t *testing.T) {
// Note: Tests that use t.Setenv cannot use t.Parallel in subtests
tests := []struct {
name string
input string
envVars map[string]string
expected string
}{
{
name: "simple substitution",
input: "token: ${TEST_TOKEN_SIMPLE}",
envVars: map[string]string{"TEST_TOKEN_SIMPLE": "secret123"},
expected: "token: secret123",
},
{
name: "multiple substitutions",
input: "user: ${TEST_USER_MULTI}, pass: ${TEST_PASS_MULTI}",
envVars: map[string]string{"TEST_USER_MULTI": "admin", "TEST_PASS_MULTI": "123"},
expected: "user: admin, pass: 123",
},
{
name: "missing env var returns empty",
input: "token: ${TEST_MISSING_VAR_12345}",
envVars: map[string]string{},
expected: "token: ",
},
{
name: "no substitution needed",
input: "token: plaintext",
envVars: map[string]string{},
expected: "token: plaintext",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Sequential test due to env var usage
for k, v := range tt.envVars {
t.Setenv(k, v)
}
result := expandEnvVars(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestConfig_GetParsedDateRange(t *testing.T) {
t.Parallel()
tests := []struct {
name string
dateRange DateRangeConfig
expectError bool
validate func(t *testing.T, result *ParsedDateRange)
}{
{
name: "valid date range",
dateRange: DateRangeConfig{
Start: "2024-01-01",
End: "2024-12-31",
},
expectError: false,
validate: func(t *testing.T, result *ParsedDateRange) {
assert.NotNil(t, result.Start)
assert.NotNil(t, result.End)
assert.Equal(t, 2024, result.Start.Year())
assert.Equal(t, time.January, result.Start.Month())
assert.Equal(t, 2024, result.End.Year())
assert.Equal(t, time.December, result.End.Month())
},
},
{
name: "only start date",
dateRange: DateRangeConfig{
Start: "2024-06-15",
},
expectError: false,
validate: func(t *testing.T, result *ParsedDateRange) {
assert.NotNil(t, result.Start)
assert.NotNil(t, result.End) // Should default to now
assert.Equal(t, 2024, result.Start.Year())
assert.Equal(t, time.June, result.Start.Month())
},
},
{
name: "empty date range defaults to now",
dateRange: DateRangeConfig{},
expectError: false,
validate: func(t *testing.T, result *ParsedDateRange) {
assert.Nil(t, result.Start)
assert.NotNil(t, result.End)
},
},
{
name: "invalid start date",
dateRange: DateRangeConfig{
Start: "invalid",
},
expectError: true,
},
{
name: "invalid end date",
dateRange: DateRangeConfig{
Start: "2024-01-01",
End: "invalid",
},
expectError: true,
},
{
name: "relative date - 90 days ago",
dateRange: DateRangeConfig{
Start: "-90d",
},
expectError: false,
validate: func(t *testing.T, result *ParsedDateRange) {
assert.NotNil(t, result.Start)
assert.NotNil(t, result.End)
// Start should be approximately 90 days ago
expected := time.Now().AddDate(0, 0, -90)
assert.Equal(t, expected.Year(), result.Start.Year())
assert.Equal(t, expected.Month(), result.Start.Month())
assert.Equal(t, expected.Day(), result.Start.Day())
},
},
{
name: "relative date - 2 weeks ago",
dateRange: DateRangeConfig{
Start: "-2w",
},
expectError: false,
validate: func(t *testing.T, result *ParsedDateRange) {
assert.NotNil(t, result.Start)
expected := time.Now().AddDate(0, 0, -14)
assert.Equal(t, expected.Year(), result.Start.Year())
assert.Equal(t, expected.Month(), result.Start.Month())
assert.Equal(t, expected.Day(), result.Start.Day())
},
},
{
name: "relative date - 3 months ago",
dateRange: DateRangeConfig{
Start: "-3m",
},
expectError: false,
validate: func(t *testing.T, result *ParsedDateRange) {
assert.NotNil(t, result.Start)
expected := time.Now().AddDate(0, -3, 0)
assert.Equal(t, expected.Year(), result.Start.Year())
assert.Equal(t, expected.Month(), result.Start.Month())
},
},
{
name: "relative date - 1 year ago",
dateRange: DateRangeConfig{
Start: "-1y",
},
expectError: false,
validate: func(t *testing.T, result *ParsedDateRange) {
assert.NotNil(t, result.Start)
expected := time.Now().AddDate(-1, 0, 0)
assert.Equal(t, expected.Year(), result.Start.Year())
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := &Config{DateRange: tt.dateRange}
result, err := cfg.GetParsedDateRange()
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
if tt.validate != nil {
tt.validate(t, result)
}
})
}
}
func TestConfig_GetCacheTTL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
ttl string
expected time.Duration
expectError bool
}{
{
name: "24 hours",
ttl: "24h",
expected: 24 * time.Hour,
},
{
name: "1 hour",
ttl: "1h",
expected: 1 * time.Hour,
},
{
name: "30 minutes",
ttl: "30m",
expected: 30 * time.Minute,
},
{
name: "empty defaults to 24h",
ttl: "",
expected: 24 * time.Hour,
},
{
name: "invalid duration",
ttl: "invalid",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := &Config{Cache: CacheConfig{TTL: tt.ttl}}
result, err := cfg.GetCacheTTL()
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
func TestConfig_HasGithubToken(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
expected bool
}{
{
name: "has token",
token: "ghp_test123",
expected: true,
},
{
name: "empty token",
token: "",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := &Config{Auth: AuthConfig{GithubToken: tt.token}}
assert.Equal(t, tt.expected, cfg.HasGithubToken())
})
}
}
func TestConfig_HasGithubApp(t *testing.T) {
t.Parallel()
tests := []struct {
name string
appCfg *GithubAppConfig
expected bool
}{
{
name: "valid github app config",
appCfg: &GithubAppConfig{
AppID: 12345,
InstallationID: 67890,
PrivateKey: "key-content",
},
expected: true,
},
{
name: "valid github app config with path",
appCfg: &GithubAppConfig{
AppID: 12345,
InstallationID: 67890,
PrivateKeyPath: "/path/to/key.pem",
},
expected: true,
},
{
name: "nil github app config",
appCfg: nil,
expected: false,
},
{
name: "missing app id",
appCfg: &GithubAppConfig{
InstallationID: 67890,
PrivateKey: "key-content",
},
expected: false,
},
{
name: "missing installation id",
appCfg: &GithubAppConfig{
AppID: 12345,
PrivateKey: "key-content",
},
expected: false,
},
{
name: "missing private key",
appCfg: &GithubAppConfig{
AppID: 12345,
InstallationID: 67890,
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := &Config{Auth: AuthConfig{GithubApp: tt.appCfg}}
assert.Equal(t, tt.expected, cfg.HasGithubApp())
})
}
}
func TestConfig_GetTeamForUser(t *testing.T) {
t.Parallel()
cfg := &Config{
Teams: []TeamConfig{
{
Name: "Backend",
Members: []string{"alice", "bob"},
Color: "#blue",
},
{
Name: "Frontend",
Members: []string{"charlie", "dave"},
Color: "#green",
},
},
}
tests := []struct {
name string
username string
expectedTeam string
expectNil bool
}{
{
name: "user in first team",
username: "alice",
expectedTeam: "Backend",
},
{
name: "user in second team",
username: "charlie",
expectedTeam: "Frontend",
},
{
name: "case insensitive match",
username: "ALICE",
expectedTeam: "Backend",
},
{
name: "user not in any team",
username: "unknown",
expectNil: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
team := cfg.GetTeamForUser(tt.username)
if tt.expectNil {
assert.Nil(t, team)
} else {
require.NotNil(t, team)
assert.Equal(t, tt.expectedTeam, team.Name)
}
})
}
}
func TestConfig_IsBot(t *testing.T) {
t.Parallel()
cfg := &Config{
Options: OptionsConfig{
IncludeBots: false,
BotPatterns: []string{
"*[bot]",
"dependabot*",
"renovate*",
"github-actions*",
},
},
}
tests := []struct {
name string
username string
expected bool
}{
{
name: "bot suffix pattern",
username: "my-app[bot]",
expected: true,
},
{
name: "dependabot prefix pattern",
username: "dependabot-preview",
expected: true,
},
{
name: "renovate prefix pattern",
username: "renovate[bot]",
expected: true,
},
{
name: "github-actions prefix pattern",
username: "github-actions[bot]",
expected: true,
},
{
name: "regular user",
username: "alice",
expected: false,
},
{
name: "user with bot in name",
username: "robotics-engineer",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := cfg.IsBot(tt.username)
assert.Equal(t, tt.expected, result)
})
}
}
func TestConfig_IsBot_IncludeBots(t *testing.T) {
t.Parallel()
cfg := &Config{
Options: OptionsConfig{
IncludeBots: true,
BotPatterns: []string{"*[bot]"},
},
}
// When IncludeBots is true, nothing should be considered a bot
assert.False(t, cfg.IsBot("my-app[bot]"))
assert.False(t, cfg.IsBot("dependabot"))
}
func TestMatchPattern(t *testing.T) {
t.Parallel()
tests := []struct {
name string
s string
pattern string
expected bool
}{
{
name: "exact match",
s: "hello",
pattern: "hello",
expected: true,
},
{
name: "prefix match",
s: "hello-world",
pattern: "hello*",
expected: true,
},
{
name: "suffix match",
s: "hello-world",
pattern: "*world",
expected: true,
},
{
name: "contains match",
s: "hello-world-test",
pattern: "*world*",
expected: true,
},
{
name: "no match",
s: "hello",
pattern: "world",
expected: false,
},
{
name: "prefix no match",
s: "hello",
pattern: "world*",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := matchPattern(tt.s, tt.pattern)
assert.Equal(t, tt.expected, result)
})
}
}
func TestConfig_GetCustomPeriods(t *testing.T) {
t.Parallel()
tests := []struct {
name string
customPeriods []CustomPeriod
expectError bool
validate func(t *testing.T, periods []ParsedCustomPeriod)
}{
{
name: "valid custom periods",
customPeriods: []CustomPeriod{
{Name: "Q1", Start: "2024-01-01", End: "2024-03-31"},
{Name: "Q2", Start: "2024-04-01", End: "2024-06-30"},
},
expectError: false,
validate: func(t *testing.T, periods []ParsedCustomPeriod) {
assert.Len(t, periods, 2)
assert.Equal(t, "Q1", periods[0].Name)
assert.Equal(t, time.January, periods[0].Start.Month())
assert.Equal(t, time.March, periods[0].End.Month())
},
},
{
name: "empty custom periods",
customPeriods: []CustomPeriod{},
expectError: false,
validate: func(t *testing.T, periods []ParsedCustomPeriod) {
assert.Empty(t, periods)
},
},
{
name: "invalid start date",
customPeriods: []CustomPeriod{
{Name: "Bad", Start: "invalid", End: "2024-03-31"},
},
expectError: true,
},
{
name: "invalid end date",
customPeriods: []CustomPeriod{
{Name: "Bad", Start: "2024-01-01", End: "invalid"},
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := &Config{CustomPeriods: tt.customPeriods}
periods, err := cfg.GetCustomPeriods()
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
if tt.validate != nil {
tt.validate(t, periods)
}
})
}
}
func TestDefaultConfig(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
assert.Equal(t, "1.0", cfg.Version)
assert.Contains(t, cfg.Granularity, "daily")
assert.Contains(t, cfg.Granularity, "weekly")
assert.Contains(t, cfg.Granularity, "monthly")
assert.True(t, cfg.Scoring.Enabled)
assert.Equal(t, 10, cfg.Scoring.Points.Commit)
assert.Equal(t, 50, cfg.Scoring.Points.PRMerged)
assert.NotEmpty(t, cfg.Scoring.Achievements)
assert.Equal(t, "./dist", cfg.Output.Directory)
assert.True(t, cfg.Cache.Enabled)
assert.Equal(t, "./.cache", cfg.Cache.Directory)
assert.Equal(t, "24h", cfg.Cache.TTL)
assert.Equal(t, 5, cfg.Options.ConcurrentRequests)
assert.False(t, cfg.Options.IncludeBots)
}
func TestConfig_GetGithubAppPrivateKey(t *testing.T) {
t.Parallel()
t.Run("returns inline key", func(t *testing.T) {
t.Parallel()
cfg := &Config{
Auth: AuthConfig{
GithubApp: &GithubAppConfig{
PrivateKey: "inline-key-content",
},
},
}
key, err := cfg.GetGithubAppPrivateKey()
require.NoError(t, err)
assert.Equal(t, []byte("inline-key-content"), key)
})
t.Run("returns key from file", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "key.pem")
err := os.WriteFile(keyPath, []byte("file-key-content"), 0600)
require.NoError(t, err)
cfg := &Config{
Auth: AuthConfig{
GithubApp: &GithubAppConfig{
PrivateKeyPath: keyPath,
},
},
}
key, err := cfg.GetGithubAppPrivateKey()
require.NoError(t, err)
assert.Equal(t, []byte("file-key-content"), key)
})
t.Run("error when no github app configured", func(t *testing.T) {
t.Parallel()
cfg := &Config{}
_, err := cfg.GetGithubAppPrivateKey()
assert.Error(t, err)
})
t.Run("error when no key configured", func(t *testing.T) {
t.Parallel()
cfg := &Config{
Auth: AuthConfig{
GithubApp: &GithubAppConfig{
AppID: 12345,
InstallationID: 67890,
},
},
}
_, err := cfg.GetGithubAppPrivateKey()
assert.Error(t, err)
})
}
func TestLoad_FileNotFound(t *testing.T) {
t.Parallel()
_, err := Load("/nonexistent/path/config.yaml")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to read config file")
}
func TestLoad_InvalidYAML(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte("invalid: yaml: content: ["), 0644)
require.NoError(t, err)
_, err = Load(configPath)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse config file")
}
+454
View File
@@ -0,0 +1,454 @@
package config
import "time"
// Config represents the main configuration structure
type Config struct {
Version string `yaml:"version"`
Auth AuthConfig `yaml:"auth"`
Repositories []RepositoryConfig `yaml:"repositories"`
DateRange DateRangeConfig `yaml:"date_range"`
Granularity []string `yaml:"granularity"`
CustomPeriods []CustomPeriod `yaml:"custom_periods,omitempty"`
Teams []TeamConfig `yaml:"teams,omitempty"`
Scoring ScoringConfig `yaml:"scoring"`
Output OutputConfig `yaml:"output"`
Cache CacheConfig `yaml:"cache"`
Options OptionsConfig `yaml:"options"`
}
// AuthConfig holds authentication configuration
type AuthConfig struct {
// Token-based authentication
GithubToken string `yaml:"github_token,omitempty"`
// GitHub App authentication
GithubApp *GithubAppConfig `yaml:"github_app,omitempty"`
}
// GithubAppConfig holds GitHub App authentication details
type GithubAppConfig struct {
AppID int64 `yaml:"app_id"`
InstallationID int64 `yaml:"installation_id"`
PrivateKeyPath string `yaml:"private_key_path,omitempty"`
PrivateKey string `yaml:"private_key,omitempty"`
}
// RepositoryConfig defines a repository to analyze
type RepositoryConfig struct {
Owner string `yaml:"owner"`
Name string `yaml:"name,omitempty"`
Pattern string `yaml:"pattern,omitempty"` // For wildcard matching
}
// DateRangeConfig specifies the analysis time range
type DateRangeConfig struct {
Start string `yaml:"start,omitempty"` // ISO 8601 format
End string `yaml:"end,omitempty"` // ISO 8601 format
}
// CustomPeriod defines a custom time period for analysis
type CustomPeriod struct {
Name string `yaml:"name"`
Start string `yaml:"start"`
End string `yaml:"end"`
}
// TeamConfig defines a team and its members
type TeamConfig struct {
Name string `yaml:"name"`
Members []string `yaml:"members"`
Color string `yaml:"color,omitempty"`
}
// ScoringConfig holds gamification scoring configuration
type ScoringConfig struct {
Enabled bool `yaml:"enabled"`
Points PointsConfig `yaml:"points"`
Achievements []AchievementConfig `yaml:"achievements,omitempty"`
}
// PointsConfig defines point values for various activities
type PointsConfig struct {
Commit int `yaml:"commit"`
CommitWithTests int `yaml:"commit_with_tests"`
LinesAdded float64 `yaml:"lines_added"`
LinesDeleted float64 `yaml:"lines_deleted"`
PROpened int `yaml:"pr_opened"`
PRMerged int `yaml:"pr_merged"`
PRReviewed int `yaml:"pr_reviewed"`
ReviewComment int `yaml:"review_comment"` // PR review comments (not code comments)
IssueOpened int `yaml:"issue_opened"`
IssueClosed int `yaml:"issue_closed"`
FastReview1h int `yaml:"fast_review_1h"`
FastReview4h int `yaml:"fast_review_4h"`
FastReview24h int `yaml:"fast_review_24h"`
}
// AchievementConfig defines an achievement badge
type AchievementConfig struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Icon string `yaml:"icon"`
Condition AchievementCondition `yaml:"condition"`
}
// AchievementCondition defines when an achievement is earned
type AchievementCondition struct {
Type string `yaml:"type"` // commit_count, pr_count, review_count, avg_review_time, etc.
Threshold float64 `yaml:"threshold"`
}
// TierFromThreshold returns the tier level (1-11) based on threshold value
// Tiers: 1=1, 2=10, 3=25, 4=50, 5=100, 6=250, 7=500, 8=1000, 9=5000, 10=10000, 11=25000+
func TierFromThreshold(threshold float64) int {
tiers := []float64{1, 10, 25, 50, 100, 250, 500, 1000, 5000, 10000, 25000}
for i := len(tiers) - 1; i >= 0; i-- {
if threshold >= tiers[i] {
return i + 1
}
}
return 1
}
// OutputConfig specifies output generation settings
type OutputConfig struct {
Directory string `yaml:"directory"`
Format []string `yaml:"format"` // html, json
Deploy DeployConfig `yaml:"deploy"`
}
// DeployConfig specifies deployment options
type DeployConfig struct {
GHPages bool `yaml:"gh_pages"`
Artifact bool `yaml:"artifact"`
}
// CacheConfig holds caching configuration
type CacheConfig struct {
Enabled bool `yaml:"enabled"`
Directory string `yaml:"directory"`
TTL string `yaml:"ttl"` // Duration string like "24h"
}
// OptionsConfig holds advanced options
type OptionsConfig struct {
ConcurrentRequests int `yaml:"concurrent_requests"`
IncludeBots bool `yaml:"include_bots"`
BotPatterns []string `yaml:"bot_patterns"`
CloneDirectory string `yaml:"clone_directory"` // Directory for local git clones
UseLocalGit bool `yaml:"use_local_git"` // Use local git for commits (faster)
UserAliases []UserAlias `yaml:"user_aliases,omitempty"` // Manual email/name to login mappings
}
// UserAlias maps git emails or names to a GitHub login
type UserAlias struct {
GithubLogin string `yaml:"github_login"` // The canonical GitHub username
Emails []string `yaml:"emails,omitempty"` // Git commit emails to map
Names []string `yaml:"names,omitempty"` // Git commit author names to map
}
// ParsedDateRange holds parsed date range values
type ParsedDateRange struct {
Start *time.Time
End *time.Time
}
// DefaultConfig returns a configuration with sensible defaults
func DefaultConfig() *Config {
return &Config{
Version: "1.0",
Granularity: []string{"daily", "weekly", "monthly"},
Scoring: ScoringConfig{
Enabled: true,
Points: PointsConfig{
Commit: 10,
CommitWithTests: 15,
LinesAdded: 0.1,
LinesDeleted: 0.05,
PROpened: 25,
PRMerged: 50,
PRReviewed: 30,
ReviewComment: 5,
IssueOpened: 15,
IssueClosed: 20,
FastReview1h: 50,
FastReview4h: 25,
FastReview24h: 10,
},
Achievements: defaultAchievements(),
},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html", "json"},
Deploy: DeployConfig{
GHPages: true,
Artifact: true,
},
},
Cache: CacheConfig{
Enabled: true,
Directory: "./.cache",
TTL: "24h",
},
Options: OptionsConfig{
ConcurrentRequests: 5,
IncludeBots: false,
BotPatterns: []string{
"*[bot]",
"dependabot*",
"renovate*",
"github-actions*",
},
CloneDirectory: "./.repos",
UseLocalGit: true, // Default to faster local git analysis
},
}
}
// defaultAchievements returns the default achievement badges
func defaultAchievements() []AchievementConfig {
return []AchievementConfig{
{
ID: "first-commit",
Name: "First Steps",
Description: "Made your first commit",
Icon: "fa-baby",
Condition: AchievementCondition{Type: "commit_count", Threshold: 1},
},
{
ID: "commit-10",
Name: "Getting Started",
Description: "Made 10 commits",
Icon: "fa-seedling",
Condition: AchievementCondition{Type: "commit_count", Threshold: 10},
},
{
ID: "commit-100",
Name: "Committed",
Description: "Made 100 commits",
Icon: "fa-fire",
Condition: AchievementCondition{Type: "commit_count", Threshold: 100},
},
{
ID: "commit-500",
Name: "Code Machine",
Description: "Made 500 commits",
Icon: "fa-robot",
Condition: AchievementCondition{Type: "commit_count", Threshold: 500},
},
{
ID: "commit-1000",
Name: "Code Warrior",
Description: "Made 1000 commits",
Icon: "fa-crown",
Condition: AchievementCondition{Type: "commit_count", Threshold: 1000},
},
{
ID: "pr-opener",
Name: "PR Pioneer",
Description: "Opened your first pull request",
Icon: "fa-code-pull-request",
Condition: AchievementCondition{Type: "pr_opened_count", Threshold: 1},
},
{
ID: "pr-10",
Name: "Pull Request Pro",
Description: "Opened 10 pull requests",
Icon: "fa-code-branch",
Condition: AchievementCondition{Type: "pr_opened_count", Threshold: 10},
},
{
ID: "pr-50",
Name: "Merge Master",
Description: "Opened 50 pull requests",
Icon: "fa-code-merge",
Condition: AchievementCondition{Type: "pr_opened_count", Threshold: 50},
},
{
ID: "reviewer",
Name: "Code Reviewer",
Description: "Reviewed your first pull request",
Icon: "fa-magnifying-glass-chart",
Condition: AchievementCondition{Type: "review_count", Threshold: 1},
},
{
ID: "reviewer-25",
Name: "Review Regular",
Description: "Reviewed 25 pull requests",
Icon: "fa-eye",
Condition: AchievementCondition{Type: "review_count", Threshold: 25},
},
{
ID: "reviewer-100",
Name: "Review Guru",
Description: "Reviewed 100 pull requests",
Icon: "fa-user-graduate",
Condition: AchievementCondition{Type: "review_count", Threshold: 100},
},
{
ID: "speed-demon",
Name: "Speed Demon",
Description: "Average review response under 1 hour",
Icon: "fa-bolt",
Condition: AchievementCondition{Type: "avg_review_time_hours", Threshold: 1},
},
{
ID: "quick-responder",
Name: "Quick Responder",
Description: "Average review response under 4 hours",
Icon: "fa-clock",
Condition: AchievementCondition{Type: "avg_review_time_hours", Threshold: 4},
},
{
ID: "commentator",
Name: "Commentator",
Description: "Left 50 PR review comments",
Icon: "fa-comments",
Condition: AchievementCondition{Type: "comment_count", Threshold: 50},
},
{
ID: "lines-1000",
Name: "Thousand Lines",
Description: "Added 1000 lines of code",
Icon: "fa-layer-group",
Condition: AchievementCondition{Type: "lines_added", Threshold: 1000},
},
{
ID: "lines-10000",
Name: "Ten Thousand",
Description: "Added 10000 lines of code",
Icon: "fa-mountain",
Condition: AchievementCondition{Type: "lines_added", Threshold: 10000},
},
{
ID: "cleaner",
Name: "Code Cleaner",
Description: "Deleted 1000 lines of code",
Icon: "fa-broom",
Condition: AchievementCondition{Type: "lines_deleted", Threshold: 1000},
},
{
ID: "refactorer",
Name: "Refactoring Champion",
Description: "Deleted 10000 lines of code",
Icon: "fa-recycle",
Condition: AchievementCondition{Type: "lines_deleted", Threshold: 10000},
},
{
ID: "multi-repo",
Name: "Multi-Repo Master",
Description: "Contributed to 5 repositories",
Icon: "fa-folder-tree",
Condition: AchievementCondition{Type: "repo_count", Threshold: 5},
},
{
ID: "team-player",
Name: "Team Player",
Description: "Reviewed PRs from 10 different contributors",
Icon: "fa-people-group",
Condition: AchievementCondition{Type: "unique_reviewees", Threshold: 10},
},
// PR Quality achievements
{
ID: "big-pr",
Name: "Heavy Lifter",
Description: "Merged a PR with 1000+ lines changed",
Icon: "fa-weight-hanging",
Condition: AchievementCondition{Type: "largest_pr_size", Threshold: 1000},
},
{
ID: "mega-pr",
Name: "Mega Merge",
Description: "Merged a PR with 5000+ lines changed",
Icon: "fa-dumbbell",
Condition: AchievementCondition{Type: "largest_pr_size", Threshold: 5000},
},
{
ID: "small-pr-10",
Name: "Small PR Advocate",
Description: "Merged 10 PRs under 100 lines",
Icon: "fa-compress",
Condition: AchievementCondition{Type: "small_pr_count", Threshold: 10},
},
{
ID: "small-pr-50",
Name: "Atomic Commits Hero",
Description: "Merged 50 PRs under 100 lines",
Icon: "fa-atom",
Condition: AchievementCondition{Type: "small_pr_count", Threshold: 50},
},
{
ID: "perfect-pr-5",
Name: "Clean Code",
Description: "5 PRs merged without changes requested",
Icon: "fa-check-double",
Condition: AchievementCondition{Type: "perfect_prs", Threshold: 5},
},
{
ID: "perfect-pr-25",
Name: "Flawless",
Description: "25 PRs merged without changes requested",
Icon: "fa-gem",
Condition: AchievementCondition{Type: "perfect_prs", Threshold: 25},
},
// Activity pattern achievements
{
ID: "streak-7",
Name: "Week Warrior",
Description: "7 day contribution streak",
Icon: "fa-calendar-week",
Condition: AchievementCondition{Type: "longest_streak", Threshold: 7},
},
{
ID: "streak-30",
Name: "Month Master",
Description: "30 day contribution streak",
Icon: "fa-calendar-check",
Condition: AchievementCondition{Type: "longest_streak", Threshold: 30},
},
{
ID: "early-bird",
Name: "Early Bird",
Description: "50 commits before 9am",
Icon: "fa-sun",
Condition: AchievementCondition{Type: "early_bird_count", Threshold: 50},
},
{
ID: "night-owl",
Name: "Night Owl",
Description: "50 commits after 9pm",
Icon: "fa-moon",
Condition: AchievementCondition{Type: "night_owl_count", Threshold: 50},
},
{
ID: "nosferatu",
Name: "Nosferatu",
Description: "25 commits between midnight and 4am",
Icon: "fa-skull",
Condition: AchievementCondition{Type: "midnight_count", Threshold: 25},
},
{
ID: "weekend-warrior",
Name: "Weekend Warrior",
Description: "25 weekend commits",
Icon: "fa-couch",
Condition: AchievementCondition{Type: "weekend_warrior", Threshold: 25},
},
{
ID: "active-30",
Name: "Consistent Contributor",
Description: "Active on 30 different days",
Icon: "fa-chart-line",
Condition: AchievementCondition{Type: "active_days", Threshold: 30},
},
{
ID: "active-100",
Name: "Dedicated Developer",
Description: "Active on 100 different days",
Icon: "fa-fire-flame-curved",
Condition: AchievementCondition{Type: "active_days", Threshold: 100},
},
}
}
+227
View File
@@ -0,0 +1,227 @@
package config
import (
"fmt"
"strings"
)
// ValidationError represents a configuration validation error
type ValidationError struct {
Field string
Message string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
// ValidationErrors is a collection of validation errors
type ValidationErrors []ValidationError
func (e ValidationErrors) Error() string {
if len(e) == 0 {
return ""
}
var msgs []string
for _, err := range e {
msgs = append(msgs, err.Error())
}
return strings.Join(msgs, "; ")
}
// Validate checks the configuration for errors
func Validate(cfg *Config) error {
var errs ValidationErrors
// Validate authentication
if !cfg.HasGithubToken() && !cfg.HasGithubApp() {
errs = append(errs, ValidationError{
Field: "auth",
Message: "either github_token or github_app must be configured",
})
}
// Validate repositories
if len(cfg.Repositories) == 0 {
errs = append(errs, ValidationError{
Field: "repositories",
Message: "at least one repository must be specified",
})
}
for i, repo := range cfg.Repositories {
if repo.Owner == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("repositories[%d].owner", i),
Message: "owner is required",
})
}
if repo.Name == "" && repo.Pattern == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("repositories[%d]", i),
Message: "either name or pattern must be specified",
})
}
}
// Validate date range
if cfg.DateRange.Start != "" {
if _, err := cfg.GetParsedDateRange(); err != nil {
errs = append(errs, ValidationError{
Field: "date_range",
Message: err.Error(),
})
}
}
// Validate granularity
validGranularities := map[string]bool{
"daily": true,
"weekly": true,
"monthly": true,
}
for _, g := range cfg.Granularity {
if !validGranularities[g] {
errs = append(errs, ValidationError{
Field: "granularity",
Message: fmt.Sprintf("invalid granularity: %s (must be daily, weekly, or monthly)", g),
})
}
}
// Validate teams
for i, team := range cfg.Teams {
if team.Name == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("teams[%d].name", i),
Message: "team name is required",
})
}
if len(team.Members) == 0 {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("teams[%d].members", i),
Message: "team must have at least one member",
})
}
}
// Validate scoring
if cfg.Scoring.Enabled {
if cfg.Scoring.Points.Commit < 0 {
errs = append(errs, ValidationError{
Field: "scoring.points.commit",
Message: "point values cannot be negative",
})
}
// Additional point validations can be added here
}
// Validate achievements
achievementIDs := make(map[string]bool)
for i, achievement := range cfg.Scoring.Achievements {
if achievement.ID == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("scoring.achievements[%d].id", i),
Message: "achievement ID is required",
})
}
if achievementIDs[achievement.ID] {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("scoring.achievements[%d].id", i),
Message: fmt.Sprintf("duplicate achievement ID: %s", achievement.ID),
})
}
achievementIDs[achievement.ID] = true
if achievement.Name == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("scoring.achievements[%d].name", i),
Message: "achievement name is required",
})
}
validConditionTypes := map[string]bool{
"commit_count": true,
"pr_opened_count": true,
"pr_merged_count": true,
"review_count": true,
"comment_count": true,
"lines_added": true,
"lines_deleted": true,
"avg_review_time_hours": true,
"repo_count": true,
"unique_reviewees": true,
// PR quality metrics
"largest_pr_size": true,
"small_pr_count": true,
"perfect_prs": true,
// Activity pattern metrics
"active_days": true,
"longest_streak": true,
"early_bird_count": true,
"night_owl_count": true,
"midnight_count": true,
"weekend_warrior": true,
}
if !validConditionTypes[achievement.Condition.Type] {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("scoring.achievements[%d].condition.type", i),
Message: fmt.Sprintf("invalid condition type: %s", achievement.Condition.Type),
})
}
}
// Validate output
if cfg.Output.Directory == "" {
errs = append(errs, ValidationError{
Field: "output.directory",
Message: "output directory is required",
})
}
validFormats := map[string]bool{"html": true, "json": true}
for _, format := range cfg.Output.Format {
if !validFormats[format] {
errs = append(errs, ValidationError{
Field: "output.format",
Message: fmt.Sprintf("invalid format: %s (must be html or json)", format),
})
}
}
// Validate cache
if cfg.Cache.Enabled {
if cfg.Cache.Directory == "" {
errs = append(errs, ValidationError{
Field: "cache.directory",
Message: "cache directory is required when caching is enabled",
})
}
if _, err := cfg.GetCacheTTL(); err != nil {
errs = append(errs, ValidationError{
Field: "cache.ttl",
Message: fmt.Sprintf("invalid TTL duration: %v", err),
})
}
}
// Validate options
if cfg.Options.ConcurrentRequests < 1 {
errs = append(errs, ValidationError{
Field: "options.concurrent_requests",
Message: "must be at least 1",
})
}
if cfg.Options.ConcurrentRequests > 20 {
errs = append(errs, ValidationError{
Field: "options.concurrent_requests",
Message: "should not exceed 20 to avoid rate limiting",
})
}
if len(errs) > 0 {
return errs
}
return nil
}
+493
View File
@@ -0,0 +1,493 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config *Config
expectError bool
errorField string
}{
{
name: "valid config with token",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily", "weekly"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html", "json"},
},
Cache: CacheConfig{
Enabled: true,
Directory: "./.cache",
TTL: "24h",
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: false,
},
{
name: "valid config with github app",
config: &Config{
Auth: AuthConfig{
GithubApp: &GithubAppConfig{
AppID: 12345,
InstallationID: 67890,
PrivateKey: "key-content",
},
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: false,
},
{
name: "missing authentication",
config: &Config{
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "auth",
},
{
name: "no repositories",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "repositories",
},
{
name: "repository missing owner",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "repositories[0].owner",
},
{
name: "repository missing name and pattern",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "repositories[0]",
},
{
name: "repository with pattern instead of name is valid",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Pattern: "*"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: false,
},
{
name: "invalid granularity",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"invalid"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "granularity",
},
{
name: "team without name",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Teams: []TeamConfig{
{Members: []string{"user1"}},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "teams[0].name",
},
{
name: "team without members",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Teams: []TeamConfig{
{Name: "Backend", Members: []string{}},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "teams[0].members",
},
{
name: "duplicate achievement id",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Scoring: ScoringConfig{
Enabled: true,
Achievements: []AchievementConfig{
{ID: "test-achievement", Name: "Test 1", Condition: AchievementCondition{Type: "commit_count", Threshold: 10}},
{ID: "test-achievement", Name: "Test 2", Condition: AchievementCondition{Type: "commit_count", Threshold: 20}},
},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "scoring.achievements[1].id",
},
{
name: "invalid achievement condition type",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Scoring: ScoringConfig{
Enabled: true,
Achievements: []AchievementConfig{
{ID: "test", Name: "Test", Condition: AchievementCondition{Type: "invalid_type", Threshold: 10}},
},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "scoring.achievements[0].condition.type",
},
{
name: "missing output directory",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "output.directory",
},
{
name: "invalid output format",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"invalid"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "output.format",
},
{
name: "cache enabled but no directory",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Cache: CacheConfig{
Enabled: true,
Directory: "",
TTL: "24h",
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "cache.directory",
},
{
name: "invalid cache TTL",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Cache: CacheConfig{
Enabled: true,
Directory: "./.cache",
TTL: "invalid",
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "cache.ttl",
},
{
name: "concurrent requests too low",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 0,
},
},
expectError: true,
errorField: "options.concurrent_requests",
},
{
name: "concurrent requests too high",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 100,
},
},
expectError: true,
errorField: "options.concurrent_requests",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := Validate(tt.config)
if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errorField)
} else {
assert.NoError(t, err)
}
})
}
}
func TestValidationError_Error(t *testing.T) {
t.Parallel()
err := ValidationError{
Field: "test.field",
Message: "test error message",
}
assert.Equal(t, "test.field: test error message", err.Error())
}
func TestValidationErrors_Error(t *testing.T) {
t.Parallel()
tests := []struct {
name string
errs ValidationErrors
expected string
}{
{
name: "empty errors",
errs: ValidationErrors{},
expected: "",
},
{
name: "single error",
errs: ValidationErrors{
{Field: "field1", Message: "error1"},
},
expected: "field1: error1",
},
{
name: "multiple errors",
errs: ValidationErrors{
{Field: "field1", Message: "error1"},
{Field: "field2", Message: "error2"},
},
expected: "field1: error1; field2: error2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.expected, tt.errs.Error())
})
}
}