mirror of
https://github.com/lukaszraczylo/git-velocity.git
synced 2026-07-06 06:15:21 +00:00
Initial commit.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user