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
File diff suppressed because it is too large Load Diff
+383
View File
@@ -0,0 +1,383 @@
package aggregator
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/git-velocity/internal/config"
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
)
func TestNew(t *testing.T) {
t.Parallel()
cfg := &config.Config{}
agg := New(cfg)
assert.NotNil(t, agg)
assert.Equal(t, cfg, agg.config)
}
func TestAggregator_AggregateEmptyData(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
assert.NotNil(t, metrics)
assert.Equal(t, 0, metrics.TotalContributors)
assert.Equal(t, 0, metrics.TotalCommits)
assert.Equal(t, 0, metrics.TotalPRs)
assert.Equal(t, 0, metrics.TotalReviews)
}
func TestAggregator_AggregateCommits(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Message: "Test commit",
Author: models.Author{Login: "user1", Name: "User One"},
Date: time.Now(),
Additions: 100,
Deletions: 50,
FilesChanged: 5,
Repository: "owner/repo",
},
{
SHA: "def456",
Message: "Another commit",
Author: models.Author{Login: "user1", Name: "User One"},
Date: time.Now(),
Additions: 200,
Deletions: 75,
FilesChanged: 3,
Repository: "owner/repo",
},
{
SHA: "ghi789",
Message: "User2 commit",
Author: models.Author{Login: "user2", Name: "User Two"},
Date: time.Now(),
Additions: 50,
Deletions: 25,
FilesChanged: 2,
Repository: "owner/repo",
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
assert.Equal(t, 2, metrics.TotalContributors)
assert.Equal(t, 3, metrics.TotalCommits)
assert.Equal(t, 350, metrics.TotalLinesAdded)
assert.Equal(t, 150, metrics.TotalLinesDeleted)
// Check repository metrics
require.Len(t, metrics.Repositories, 1)
repo := metrics.Repositories[0]
assert.Equal(t, "owner", repo.Owner)
assert.Equal(t, "repo", repo.Name)
assert.Equal(t, 3, repo.TotalCommits)
}
func TestAggregator_AggregatePullRequests(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
mergedAt := time.Now()
data := &models.RawData{
PullRequests: []models.PullRequest{
{
Number: 1,
Title: "PR 1",
State: models.PRStateMerged,
Author: models.Author{Login: "user1", Name: "User One"},
Repository: "owner/repo",
CreatedAt: time.Now().Add(-time.Hour),
MergedAt: &mergedAt,
Additions: 100,
Deletions: 50,
},
{
Number: 2,
Title: "PR 2",
State: models.PRStateOpen,
Author: models.Author{Login: "user2", Name: "User Two"},
Repository: "owner/repo",
CreatedAt: time.Now().Add(-30 * time.Minute),
Additions: 200,
Deletions: 75,
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
assert.Equal(t, 2, metrics.TotalContributors)
assert.Equal(t, 2, metrics.TotalPRs)
// Check repository metrics
require.Len(t, metrics.Repositories, 1)
repo := metrics.Repositories[0]
assert.Equal(t, 2, repo.TotalPRs)
}
func TestAggregator_AggregateReviews(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{
PullRequests: []models.PullRequest{
{
Number: 1,
Title: "PR 1",
State: models.PRStateOpen,
Author: models.Author{Login: "user1"},
Repository: "owner/repo",
CreatedAt: time.Now(),
},
},
Reviews: []models.Review{
{
ID: 1,
PullRequest: 1,
Repository: "owner/repo",
Author: models.Author{Login: "reviewer1"},
State: models.ReviewApproved,
SubmittedAt: time.Now(),
},
{
ID: 2,
PullRequest: 1,
Repository: "owner/repo",
Author: models.Author{Login: "reviewer2"},
State: models.ReviewChangesRequested,
SubmittedAt: time.Now(),
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
assert.Equal(t, 3, metrics.TotalContributors) // user1, reviewer1, reviewer2
assert.Equal(t, 2, metrics.TotalReviews)
}
func TestAggregator_AggregateIssues(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
closedAt := time.Now()
data := &models.RawData{
// Need a commit to create the repository
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "user1"},
Repository: "owner/repo",
},
},
Issues: []models.Issue{
{
Number: 1,
Title: "Issue 1",
State: models.IssueStateOpen,
Author: models.Author{Login: "user1"},
Repository: "owner/repo",
CreatedAt: time.Now(),
},
{
Number: 2,
Title: "Issue 2",
State: models.IssueStateClosed,
Author: models.Author{Login: "user1"},
Repository: "owner/repo",
CreatedAt: time.Now().Add(-time.Hour),
ClosedAt: &closedAt,
ClosedBy: &models.Author{Login: "user1"},
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
assert.Equal(t, 1, metrics.TotalContributors)
// Find user1 in repository contributors
require.Len(t, metrics.Repositories, 1)
repo := metrics.Repositories[0]
require.Len(t, repo.Contributors, 1)
assert.Equal(t, 2, repo.Contributors[0].IssuesOpened)
assert.Equal(t, 1, repo.Contributors[0].IssuesClosed)
}
func TestAggregator_AggregateTeams(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Teams = []config.TeamConfig{
{
Name: "Backend Team",
Members: []string{"user1", "user2"},
Color: "#ff0000",
},
}
agg := New(cfg)
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "user1"},
Repository: "owner/repo",
Additions: 100,
Deletions: 50,
},
{
SHA: "def456",
Author: models.Author{Login: "user2"},
Repository: "owner/repo",
Additions: 200,
Deletions: 75,
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
require.Len(t, metrics.Teams, 1)
team := metrics.Teams[0]
assert.Equal(t, "Backend Team", team.Name)
assert.Equal(t, "#ff0000", team.Color)
assert.Len(t, team.MemberMetrics, 2)
assert.Equal(t, 2, team.AggregatedMetrics.CommitCount)
}
func TestAggregator_DateRange(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC)
data := &models.RawData{}
dateRange := &config.ParsedDateRange{
Start: &start,
End: &end,
}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
assert.Equal(t, start, metrics.Period.Start)
assert.Equal(t, end, metrics.Period.End)
}
func TestAggregator_MultipleRepositories(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "user1"},
Repository: "owner/repo1",
Additions: 100,
},
{
SHA: "def456",
Author: models.Author{Login: "user1"},
Repository: "owner/repo2",
Additions: 200,
},
{
SHA: "ghi789",
Author: models.Author{Login: "user2"},
Repository: "owner/repo1",
Additions: 50,
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
assert.Equal(t, 2, metrics.TotalContributors)
assert.Len(t, metrics.Repositories, 2)
}
func TestContains(t *testing.T) {
t.Parallel()
slice := []string{"a", "b", "c"}
assert.True(t, contains(slice, "a"))
assert.True(t, contains(slice, "b"))
assert.True(t, contains(slice, "c"))
assert.False(t, contains(slice, "d"))
assert.False(t, contains([]string{}, "a"))
}
func TestParseRepoName(t *testing.T) {
t.Parallel()
tests := []struct {
fullName string
expectedOwner string
expectedName string
}{
{"owner/repo", "owner", "repo"},
{"org/project-name", "org", "project-name"},
{"user/repo-with-dashes", "user", "repo-with-dashes"},
{"single", "single", ""},
}
for _, tt := range tests {
owner, name := parseRepoName(tt.fullName)
assert.Equal(t, tt.expectedOwner, owner, "owner mismatch for %s", tt.fullName)
assert.Equal(t, tt.expectedName, name, "name mismatch for %s", tt.fullName)
}
}
+326
View File
@@ -0,0 +1,326 @@
package app
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/lukaszraczylo/git-velocity/internal/aggregator"
"github.com/lukaszraczylo/git-velocity/internal/config"
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
"github.com/lukaszraczylo/git-velocity/internal/domain/scoring"
"github.com/lukaszraczylo/git-velocity/internal/generator/site"
"github.com/lukaszraczylo/git-velocity/internal/git"
"github.com/lukaszraczylo/git-velocity/internal/github"
)
// App is the main application orchestrator
type App struct {
config *config.Config
outputDir string
verbose bool
client *github.Client
gitRepo *git.Repository
}
// New creates a new application instance
func New(configPath, outputDir string, verbose bool) (*App, error) {
// Load configuration
cfg, err := config.Load(configPath)
if err != nil {
return nil, fmt.Errorf("failed to load configuration: %w", err)
}
return &App{
config: cfg,
outputDir: outputDir,
verbose: verbose,
}, nil
}
// Run executes the main application workflow
func (a *App) Run(ctx context.Context) error {
startTime := time.Now()
a.log("Starting Git Velocity analysis...")
// Initialize GitHub client
a.log("Initializing GitHub client...")
client, err := github.NewClient(ctx, a.config)
if err != nil {
return fmt.Errorf("failed to create GitHub client: %w", err)
}
a.client = client
// Set up progress callback
client.SetProgressCallback(func(msg string) {
a.log("%s", msg)
})
// Initialize local git repository manager if using local git
if a.config.Options.UseLocalGit {
a.log("Initializing local git repository manager...")
gitRepo, err := git.NewRepository(a.config.Options.CloneDirectory)
if err != nil {
return fmt.Errorf("failed to create git repository manager: %w", err)
}
gitRepo.SetProgressCallback(func(msg string) {
a.log("%s", msg)
})
a.gitRepo = gitRepo
}
// Parse date range
dateRange, err := a.config.GetParsedDateRange()
if err != nil {
return fmt.Errorf("failed to parse date range: %w", err)
}
// Collect data from all repositories
a.log("Fetching data from repositories...")
rawData, err := a.collectData(ctx, dateRange)
if err != nil {
return fmt.Errorf("failed to collect data: %w", err)
}
a.log("Collected %d commits, %d PRs, %d reviews, %d issues",
len(rawData.Commits), len(rawData.PullRequests), len(rawData.Reviews), len(rawData.Issues))
// Fetch user profiles for better deduplication
// This gets public emails and names from GitHub profiles to help match commit authors
a.log("Fetching user profiles for deduplication...")
userProfiles, err := a.fetchUserProfiles(ctx, rawData)
if err != nil {
a.log("Warning: failed to fetch some user profiles: %v", err)
// Continue anyway, deduplication will still work with other methods
}
a.log("Fetched %d user profiles", len(userProfiles))
// Aggregate metrics
a.log("Aggregating metrics...")
agg := aggregator.New(a.config)
agg.SetUserProfiles(userProfiles)
globalMetrics, err := agg.Aggregate(rawData, dateRange)
if err != nil {
return fmt.Errorf("failed to aggregate metrics: %w", err)
}
// Calculate scores
if a.config.Scoring.Enabled {
a.log("Calculating scores and achievements...")
scorer := scoring.NewCalculator(a.config)
globalMetrics = scorer.Calculate(globalMetrics)
}
// Generate the site
a.log("Generating static site...")
gen, err := site.NewGenerator(a.outputDir, a.config)
if err != nil {
return fmt.Errorf("failed to create site generator: %w", err)
}
if err := gen.Generate(globalMetrics); err != nil {
return fmt.Errorf("failed to generate site: %w", err)
}
duration := time.Since(startTime)
a.log("Analysis complete! Dashboard generated in %s", a.outputDir)
a.log("Total time: %s", duration.Round(time.Millisecond))
return nil
}
func (a *App) collectData(ctx context.Context, dateRange *config.ParsedDateRange) (*models.RawData, error) {
data := &models.RawData{}
for _, repo := range a.config.Repositories {
if repo.Pattern != "" {
// Pattern-based repository selection (e.g., "org/*")
repos, err := a.client.ListOrgRepos(ctx, repo.Owner, repo.Pattern)
if err != nil {
return nil, fmt.Errorf("failed to list repos for %s/%s: %w", repo.Owner, repo.Pattern, err)
}
for _, r := range repos {
if err := a.collectRepoData(ctx, repo.Owner, r, dateRange, data); err != nil {
a.log("Warning: failed to collect data for %s/%s: %v", repo.Owner, r, err)
// Continue with other repos
}
}
} else {
// Single repository
if err := a.collectRepoData(ctx, repo.Owner, repo.Name, dateRange, data); err != nil {
return nil, fmt.Errorf("failed to collect data for %s/%s: %w", repo.Owner, repo.Name, err)
}
}
}
return data, nil
}
func (a *App) collectRepoData(ctx context.Context, owner, name string, dateRange *config.ParsedDateRange, data *models.RawData) error {
repoName := fmt.Sprintf("%s/%s", owner, name)
a.log(" Fetching data from %s...", repoName)
// Fetch commits - use local git if enabled (much faster)
var commits []models.Commit
var err error
if a.gitRepo != nil {
// Clone/update repository locally
token := a.config.Auth.GithubToken
cloneErr := a.gitRepo.EnsureCloned(ctx, owner, name, token)
if cloneErr != nil {
a.log(" Warning: failed to clone repository locally, falling back to API: %v", cloneErr)
// Fallback to API
commits, err = a.client.FetchCommits(ctx, owner, name, dateRange.Start, dateRange.End)
} else {
// Use local git for commits
commits, err = a.gitRepo.FetchCommits(ctx, owner, name, dateRange.Start, dateRange.End)
}
} else {
// Use API for commits
commits, err = a.client.FetchCommits(ctx, owner, name, dateRange.Start, dateRange.End)
}
if err != nil {
return fmt.Errorf("failed to fetch commits: %w", err)
}
a.log(" Found %d commits", len(commits))
// Filter out bots
for _, c := range commits {
if !a.config.IsBot(c.Author.Login) {
data.Commits = append(data.Commits, c)
}
}
// Fetch pull requests
prs, err := a.client.FetchPullRequests(ctx, owner, name, dateRange.Start, dateRange.End)
if err != nil {
return fmt.Errorf("failed to fetch pull requests: %w", err)
}
a.log(" Found %d pull requests", len(prs))
for _, pr := range prs {
if !a.config.IsBot(pr.Author.Login) {
data.PullRequests = append(data.PullRequests, pr)
}
}
// Fetch reviews in parallel for all PRs (already filtered by FetchPullRequests)
if len(prs) > 0 {
a.log(" Fetching reviews for %d PRs in parallel...", len(prs))
type reviewResult struct {
reviews []models.Review
err error
}
// Use worker pool to limit concurrent requests
concurrency := a.config.Options.ConcurrentRequests
if concurrency <= 0 {
concurrency = 5
}
results := make(chan reviewResult, len(prs))
sem := make(chan struct{}, concurrency)
for _, pr := range prs {
go func(prNum int) {
sem <- struct{}{} // Acquire
defer func() { <-sem }() // Release
reviews, err := a.client.FetchReviews(ctx, owner, name, prNum)
results <- reviewResult{reviews: reviews, err: err}
}(pr.Number)
}
// Collect results
reviewCount := 0
for i := 0; i < len(prs); i++ {
result := <-results
if result.err != nil {
continue
}
for _, r := range result.reviews {
if !a.config.IsBot(r.Author.Login) {
data.Reviews = append(data.Reviews, r)
reviewCount++
}
}
}
a.log(" Found %d reviews across %d PRs", reviewCount, len(prs))
}
// Fetch issues
issues, err := a.client.FetchIssues(ctx, owner, name, dateRange.Start, dateRange.End)
if err != nil {
return fmt.Errorf("failed to fetch issues: %w", err)
}
a.log(" Found %d issues", len(issues))
for _, issue := range issues {
if !a.config.IsBot(issue.Author.Login) {
data.Issues = append(data.Issues, issue)
}
}
return nil
}
func (a *App) log(format string, args ...interface{}) {
if a.verbose {
log.Printf(format, args...)
} else {
fmt.Fprintf(os.Stderr, format+"\n", args...)
}
}
// fetchUserProfiles collects unique GitHub logins from PR/review data and fetches their profiles
// The profiles contain public emails and names that help with commit author deduplication
func (a *App) fetchUserProfiles(ctx context.Context, data *models.RawData) (map[string]aggregator.UserProfile, error) {
// Collect unique logins from PRs and reviews
loginSet := make(map[string]bool)
for _, pr := range data.PullRequests {
if pr.Author.Login != "" {
loginSet[pr.Author.Login] = true
}
}
for _, review := range data.Reviews {
if review.Author.Login != "" {
loginSet[review.Author.Login] = true
}
}
// Convert to slice
logins := make([]string, 0, len(loginSet))
for login := range loginSet {
logins = append(logins, login)
}
if len(logins) == 0 {
return make(map[string]aggregator.UserProfile), nil
}
// Fetch profiles from GitHub (uses cache)
ghProfiles, err := a.client.FetchUserProfiles(ctx, logins)
if err != nil {
return nil, err
}
// Convert to aggregator.UserProfile
profiles := make(map[string]aggregator.UserProfile)
for login, p := range ghProfiles {
profiles[login] = aggregator.UserProfile{
ID: p.ID,
Login: p.Login,
Name: p.Name,
Email: p.Email,
AvatarURL: p.AvatarURL,
}
}
return profiles, nil
}
+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())
})
}
}
+24
View File
@@ -0,0 +1,24 @@
package models
// Author represents a Git/GitHub author
type Author struct {
ID int64 `json:"id,omitempty"`
Login string `json:"login"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
}
// DisplayName returns the best available name for display
func (a *Author) DisplayName() string {
if a.Name != "" {
return a.Name
}
if a.Login != "" {
return a.Login
}
if a.Email != "" {
return a.Email
}
return "Unknown"
}
+43
View File
@@ -0,0 +1,43 @@
package models
import "time"
// Commit represents a Git commit
type Commit struct {
SHA string `json:"sha"`
Message string `json:"message"`
Author Author `json:"author"`
Committer Author `json:"committer"`
Date time.Time `json:"date"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
FilesChanged int `json:"files_changed"`
Repository string `json:"repository"` // owner/repo format
URL string `json:"url"`
// Derived fields
HasTests bool `json:"has_tests"`
}
// TotalChanges returns the total lines changed (additions + deletions)
func (c *Commit) TotalChanges() int {
return c.Additions + c.Deletions
}
// ShortSHA returns the first 7 characters of the SHA
func (c *Commit) ShortSHA() string {
if len(c.SHA) >= 7 {
return c.SHA[:7]
}
return c.SHA
}
// ShortMessage returns the first line of the commit message
func (c *Commit) ShortMessage() string {
for i, r := range c.Message {
if r == '\n' {
return c.Message[:i]
}
}
return c.Message
}
+54
View File
@@ -0,0 +1,54 @@
package models
import "time"
// IssueState represents the state of an issue
type IssueState string
const (
IssueStateOpen IssueState = "open"
IssueStateClosed IssueState = "closed"
)
// Issue represents a GitHub issue
type Issue struct {
Number int `json:"number"`
Title string `json:"title"`
State IssueState `json:"state"`
Author Author `json:"author"`
Repository string `json:"repository"` // owner/repo format
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
ClosedBy *Author `json:"closed_by,omitempty"`
Comments int `json:"comments"`
Labels []string `json:"labels,omitempty"`
URL string `json:"url"`
// Derived fields
TimeToClose *time.Duration `json:"time_to_close,omitempty"`
}
// IsClosed returns true if the issue is closed
func (i *Issue) IsClosed() bool {
return i.State == IssueStateClosed
}
// CalculateTimeToClose calculates the time from issue creation to close
func (i *Issue) CalculateTimeToClose() *time.Duration {
if i.ClosedAt == nil {
return nil
}
d := i.ClosedAt.Sub(i.CreatedAt)
return &d
}
// IssueComment represents a comment on an issue
type IssueComment struct {
ID int64 `json:"id"`
Issue int `json:"issue"`
Repository string `json:"repository"`
Author Author `json:"author"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
}
+208
View File
@@ -0,0 +1,208 @@
package models
import "time"
// Period represents a time period for metrics aggregation
type Period struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
Granularity string `json:"granularity"` // daily, weekly, monthly, custom
Label string `json:"label"` // e.g., "Week 42", "December 2024", "Q1 2024"
}
// ContributorMetrics holds aggregated metrics for a single contributor
type ContributorMetrics struct {
Login string `json:"login"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
Period Period `json:"period"`
// Commit metrics
CommitCount int `json:"commit_count"`
LinesAdded int `json:"lines_added"`
LinesDeleted int `json:"lines_deleted"`
FilesChanged int `json:"files_changed"`
// PR metrics
PRsOpened int `json:"prs_opened"`
PRsMerged int `json:"prs_merged"`
PRsClosed int `json:"prs_closed"`
AvgPRSize float64 `json:"avg_pr_size"`
AvgTimeToMerge float64 `json:"avg_time_to_merge_hours"`
LargestPRSize int `json:"largest_pr_size"` // Biggest single PR by lines changed
SmallPRCount int `json:"small_pr_count"` // PRs under 100 lines (good practice)
PerfectPRs int `json:"perfect_prs"` // PRs merged without changes requested
// Review metrics
ReviewsGiven int `json:"reviews_given"`
ReviewComments int `json:"review_comments"`
ApprovalsGiven int `json:"approvals_given"`
ChangesRequested int `json:"changes_requested"`
AvgReviewTime float64 `json:"avg_review_time_hours"`
// Issue metrics
IssuesOpened int `json:"issues_opened"`
IssuesClosed int `json:"issues_closed"`
IssueComments int `json:"issue_comments"`
// Activity patterns
ActiveDays int `json:"active_days"` // Unique days with activity
CurrentStreak int `json:"current_streak"` // Current consecutive days
LongestStreak int `json:"longest_streak"` // Longest consecutive days
EarlyBirdCount int `json:"early_bird_count"` // Commits before 9am
NightOwlCount int `json:"night_owl_count"` // Commits after 9pm
MidnightCount int `json:"midnight_count"` // Commits between midnight and 4am
WeekendWarrior int `json:"weekend_warrior"` // Weekend commits
// Repository participation
RepositoriesContributed []string `json:"repositories_contributed,omitempty"`
UniqueReviewees int `json:"unique_reviewees"`
// Scoring
Score Score `json:"score"`
Achievements []string `json:"achievements"` // Achievement IDs
}
// Score holds the calculated score and breakdown
type Score struct {
Total int `json:"total"`
Breakdown ScoreBreakdown `json:"breakdown"`
Rank int `json:"rank"`
PercentileRank float64 `json:"percentile_rank"`
}
// ScoreBreakdown shows how the score was calculated
type ScoreBreakdown struct {
Commits int `json:"commits"`
PRs int `json:"prs"`
Reviews int `json:"reviews"`
Comments int `json:"comments"` // PR review comments (not code comments)
ResponseBonus int `json:"response_bonus"`
LineChanges int `json:"line_changes"`
}
// RepositoryMetrics holds aggregated metrics for a single repository
type RepositoryMetrics struct {
Owner string `json:"owner"`
Name string `json:"name"`
FullName string `json:"full_name"` // owner/name
Period Period `json:"period"`
Contributors []ContributorMetrics `json:"contributors"`
TotalCommits int `json:"total_commits"`
TotalPRs int `json:"total_prs"`
TotalReviews int `json:"total_reviews"`
ActiveContributors int `json:"active_contributors"`
TotalLinesAdded int `json:"total_lines_added"`
TotalLinesDeleted int `json:"total_lines_deleted"`
}
// TeamMetrics holds aggregated metrics for a team
type TeamMetrics struct {
Name string `json:"name"`
Color string `json:"color"`
Members []string `json:"members"`
Period Period `json:"period"`
AggregatedMetrics ContributorMetrics `json:"aggregated_metrics"`
MemberMetrics []ContributorMetrics `json:"member_metrics"`
TotalScore int `json:"total_score"`
AvgScore float64 `json:"avg_score"`
}
// GlobalMetrics holds metrics aggregated across all repositories
type GlobalMetrics struct {
Period Period `json:"period"`
Repositories []RepositoryMetrics `json:"repositories"`
Teams []TeamMetrics `json:"teams"`
Leaderboard []LeaderboardEntry `json:"leaderboard"`
TopAchievers map[string]string `json:"top_achievers"` // category -> login
// Summary stats
TotalContributors int `json:"total_contributors"`
TotalCommits int `json:"total_commits"`
TotalPRs int `json:"total_prs"`
TotalReviews int `json:"total_reviews"`
TotalLinesAdded int `json:"total_lines_added"`
TotalLinesDeleted int `json:"total_lines_deleted"`
// Velocity timeline (weekly granularity)
VelocityTimeline *VelocityTimeline `json:"velocity_timeline,omitempty"`
}
// VelocityTimeline holds weekly velocity data for trend visualization
type VelocityTimeline struct {
Labels []string `json:"labels"` // Week labels (e.g., "Dec 2", "Dec 9")
Series []VelocityTimelineSeries `json:"series"` // Data series (commits, PRs, reviews, score)
}
// VelocityTimelineSeries represents a single data series in the velocity timeline
type VelocityTimelineSeries struct {
Name string `json:"name"` // Series name (e.g., "Commits", "PRs", "Score")
Color string `json:"color"` // Series color
Data []float64 `json:"data"` // Values for each week
}
// LeaderboardEntry represents a single entry in the leaderboard
type LeaderboardEntry struct {
Rank int `json:"rank"`
Login string `json:"login"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
Score int `json:"score"`
Team string `json:"team,omitempty"`
TopCategory string `json:"top_category,omitempty"` // What they're best at
Achievements []string `json:"achievements,omitempty"` // Achievement IDs earned
}
// TimeSeriesPoint represents a single data point in a time series
type TimeSeriesPoint struct {
Date time.Time `json:"date"`
Label string `json:"label"`
Value float64 `json:"value"`
}
// TimeSeries represents a series of data points over time
type TimeSeries struct {
Name string `json:"name"`
Color string `json:"color,omitempty"`
Points []TimeSeriesPoint `json:"points"`
}
// ChartData holds data formatted for charts
type ChartData struct {
Title string `json:"title"`
Description string `json:"description,omitempty"`
Type string `json:"type"` // line, bar, pie, doughnut
Labels []string `json:"labels"`
Series []TimeSeries `json:"series"`
}
// DashboardData holds all data needed for the dashboard
type DashboardData struct {
GeneratedAt time.Time `json:"generated_at"`
Period Period `json:"period"`
GlobalMetrics GlobalMetrics `json:"global_metrics"`
Charts []ChartData `json:"charts"`
Achievements []Achievement `json:"achievements"`
Configuration DashboardConfig `json:"configuration"`
}
// DashboardConfig holds UI configuration
type DashboardConfig struct {
Title string `json:"title"`
Description string `json:"description,omitempty"`
Repositories []string `json:"repositories"`
Teams []string `json:"teams,omitempty"`
Granularities []string `json:"granularities"`
ScoringEnabled bool `json:"scoring_enabled"`
ShowAchievements bool `json:"show_achievements"`
}
// Achievement represents an earned achievement badge
type Achievement struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
EarnedBy string `json:"earned_by"` // Login of user who earned it
EarnedAt string `json:"earned_at"` // When it was earned (period label)
}
+398
View File
@@ -0,0 +1,398 @@
package models
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestAuthor_DisplayName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
author Author
expected string
}{
{
name: "prefers name over login",
author: Author{Login: "johndoe", Name: "John Doe", Email: "john@example.com"},
expected: "John Doe",
},
{
name: "falls back to login",
author: Author{Login: "johndoe", Email: "john@example.com"},
expected: "johndoe",
},
{
name: "falls back to email",
author: Author{Email: "john@example.com"},
expected: "john@example.com",
},
{
name: "returns Unknown when empty",
author: Author{},
expected: "Unknown",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.expected, tt.author.DisplayName())
})
}
}
func TestCommit_TotalChanges(t *testing.T) {
t.Parallel()
commit := Commit{Additions: 100, Deletions: 50}
assert.Equal(t, 150, commit.TotalChanges())
}
func TestCommit_ShortSHA(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sha string
expected string
}{
{
name: "full SHA",
sha: "abc123456789def",
expected: "abc1234",
},
{
name: "short SHA",
sha: "abc",
expected: "abc",
},
{
name: "exactly 7 chars",
sha: "abc1234",
expected: "abc1234",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
commit := Commit{SHA: tt.sha}
assert.Equal(t, tt.expected, commit.ShortSHA())
})
}
}
func TestCommit_ShortMessage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
message string
expected string
}{
{
name: "single line",
message: "Fix bug in login",
expected: "Fix bug in login",
},
{
name: "multiline",
message: "Fix bug in login\n\nThis fixes the issue where users couldn't log in.",
expected: "Fix bug in login",
},
{
name: "empty",
message: "",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
commit := Commit{Message: tt.message}
assert.Equal(t, tt.expected, commit.ShortMessage())
})
}
}
func TestPullRequest_IsMerged(t *testing.T) {
t.Parallel()
now := time.Now()
tests := []struct {
name string
pr PullRequest
expected bool
}{
{
name: "merged state",
pr: PullRequest{State: PRStateMerged},
expected: true,
},
{
name: "has merged_at",
pr: PullRequest{State: PRStateClosed, MergedAt: &now},
expected: true,
},
{
name: "open PR",
pr: PullRequest{State: PRStateOpen},
expected: false,
},
{
name: "closed without merge",
pr: PullRequest{State: PRStateClosed},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.expected, tt.pr.IsMerged())
})
}
}
func TestPullRequest_Size(t *testing.T) {
t.Parallel()
tests := []struct {
name string
additions int
deletions int
expected PRSize
}{
{
name: "xs",
additions: 5,
deletions: 3,
expected: PRSizeXS,
},
{
name: "s",
additions: 30,
deletions: 15,
expected: PRSizeS,
},
{
name: "m",
additions: 100,
deletions: 50,
expected: PRSizeM,
},
{
name: "l",
additions: 300,
deletions: 100,
expected: PRSizeL,
},
{
name: "xl",
additions: 400,
deletions: 200,
expected: PRSizeXL,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
pr := PullRequest{Additions: tt.additions, Deletions: tt.deletions}
assert.Equal(t, tt.expected, pr.Size())
})
}
}
func TestPullRequest_CalculateTimeToMerge(t *testing.T) {
t.Parallel()
t.Run("returns duration when merged", func(t *testing.T) {
t.Parallel()
created := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
merged := time.Date(2024, 1, 1, 14, 0, 0, 0, time.UTC)
pr := PullRequest{CreatedAt: created, MergedAt: &merged}
result := pr.CalculateTimeToMerge()
assert.NotNil(t, result)
assert.Equal(t, 4*time.Hour, *result)
})
t.Run("returns nil when not merged", func(t *testing.T) {
t.Parallel()
pr := PullRequest{CreatedAt: time.Now()}
assert.Nil(t, pr.CalculateTimeToMerge())
})
}
func TestPullRequest_CalculateTimeToFirstReview(t *testing.T) {
t.Parallel()
t.Run("returns duration to first review", func(t *testing.T) {
t.Parallel()
created := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
review1 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
review2 := time.Date(2024, 1, 1, 14, 0, 0, 0, time.UTC)
pr := PullRequest{
CreatedAt: created,
Reviews: []Review{
{SubmittedAt: review2},
{SubmittedAt: review1}, // Earlier review
},
}
result := pr.CalculateTimeToFirstReview()
assert.NotNil(t, result)
assert.Equal(t, 2*time.Hour, *result)
})
t.Run("returns nil when no reviews", func(t *testing.T) {
t.Parallel()
pr := PullRequest{CreatedAt: time.Now(), Reviews: []Review{}}
assert.Nil(t, pr.CalculateTimeToFirstReview())
})
}
func TestReview_IsApproval(t *testing.T) {
t.Parallel()
tests := []struct {
name string
state ReviewState
expected bool
}{
{name: "approved", state: ReviewApproved, expected: true},
{name: "changes requested", state: ReviewChangesRequested, expected: false},
{name: "commented", state: ReviewCommented, expected: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := Review{State: tt.state}
assert.Equal(t, tt.expected, r.IsApproval())
})
}
}
func TestReview_RequestsChanges(t *testing.T) {
t.Parallel()
tests := []struct {
name string
state ReviewState
expected bool
}{
{name: "approved", state: ReviewApproved, expected: false},
{name: "changes requested", state: ReviewChangesRequested, expected: true},
{name: "commented", state: ReviewCommented, expected: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := Review{State: tt.state}
assert.Equal(t, tt.expected, r.RequestsChanges())
})
}
}
func TestReview_IsSubstantive(t *testing.T) {
t.Parallel()
tests := []struct {
name string
review Review
expected bool
}{
{
name: "has body",
review: Review{Body: "Good work!"},
expected: true,
},
{
name: "has comments",
review: Review{CommentsCount: 3},
expected: true,
},
{
name: "requests changes",
review: Review{State: ReviewChangesRequested},
expected: true,
},
{
name: "empty approval",
review: Review{State: ReviewApproved},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.expected, tt.review.IsSubstantive())
})
}
}
func TestIssue_IsClosed(t *testing.T) {
t.Parallel()
tests := []struct {
name string
state IssueState
expected bool
}{
{name: "open", state: IssueStateOpen, expected: false},
{name: "closed", state: IssueStateClosed, expected: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
issue := Issue{State: tt.state}
assert.Equal(t, tt.expected, issue.IsClosed())
})
}
}
func TestIssue_CalculateTimeToClose(t *testing.T) {
t.Parallel()
t.Run("returns duration when closed", func(t *testing.T) {
t.Parallel()
created := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
closed := time.Date(2024, 1, 3, 10, 0, 0, 0, time.UTC)
issue := Issue{CreatedAt: created, ClosedAt: &closed}
result := issue.CalculateTimeToClose()
assert.NotNil(t, result)
assert.Equal(t, 48*time.Hour, *result)
})
t.Run("returns nil when not closed", func(t *testing.T) {
t.Parallel()
issue := Issue{CreatedAt: time.Now()}
assert.Nil(t, issue.CalculateTimeToClose())
})
}
func TestPullRequest_TotalChanges(t *testing.T) {
t.Parallel()
pr := PullRequest{Additions: 200, Deletions: 100}
assert.Equal(t, 300, pr.TotalChanges())
}
+107
View File
@@ -0,0 +1,107 @@
package models
import "time"
// PRState represents the state of a pull request
type PRState string
const (
PRStateOpen PRState = "open"
PRStateClosed PRState = "closed"
PRStateMerged PRState = "merged"
)
// PullRequest represents a GitHub pull request
type PullRequest struct {
Number int `json:"number"`
Title string `json:"title"`
State PRState `json:"state"`
Author Author `json:"author"`
Repository string `json:"repository"` // owner/repo format
BaseBranch string `json:"base_branch"` // Target branch (e.g., main, master)
HeadBranch string `json:"head_branch"` // Source branch
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MergedAt *time.Time `json:"merged_at,omitempty"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
FilesChanged int `json:"files_changed"`
CommitCount int `json:"commit_count"`
Comments int `json:"comments"`
Reviews []Review `json:"reviews,omitempty"`
URL string `json:"url"`
// Derived fields
TimeToMerge *time.Duration `json:"time_to_merge,omitempty"`
TimeToFirstReview *time.Duration `json:"time_to_first_review,omitempty"`
}
// IsMerged returns true if the PR has been merged
func (pr *PullRequest) IsMerged() bool {
return pr.State == PRStateMerged || pr.MergedAt != nil
}
// TotalChanges returns the total lines changed (additions + deletions)
func (pr *PullRequest) TotalChanges() int {
return pr.Additions + pr.Deletions
}
// CalculateTimeToMerge calculates the time from PR creation to merge
func (pr *PullRequest) CalculateTimeToMerge() *time.Duration {
if pr.MergedAt == nil {
return nil
}
d := pr.MergedAt.Sub(pr.CreatedAt)
return &d
}
// CalculateTimeToFirstReview calculates the time from PR creation to first review
func (pr *PullRequest) CalculateTimeToFirstReview() *time.Duration {
if len(pr.Reviews) == 0 {
return nil
}
var firstReview *time.Time
for _, review := range pr.Reviews {
if firstReview == nil || review.SubmittedAt.Before(*firstReview) {
t := review.SubmittedAt
firstReview = &t
}
}
if firstReview == nil {
return nil
}
d := firstReview.Sub(pr.CreatedAt)
return &d
}
// PRSize represents the size category of a pull request
type PRSize string
const (
PRSizeXS PRSize = "xs" // < 10 lines
PRSizeS PRSize = "s" // 10-50 lines
PRSizeM PRSize = "m" // 50-200 lines
PRSizeL PRSize = "l" // 200-500 lines
PRSizeXL PRSize = "xl" // > 500 lines
)
// Size returns the size category of the PR based on total changes
func (pr *PullRequest) Size() PRSize {
total := pr.TotalChanges()
switch {
case total < 10:
return PRSizeXS
case total < 50:
return PRSizeS
case total < 200:
return PRSizeM
case total < 500:
return PRSizeL
default:
return PRSizeXL
}
}
+9
View File
@@ -0,0 +1,9 @@
package models
// RawData holds the raw collected data from GitHub
type RawData struct {
Commits []Commit
PullRequests []PullRequest
Reviews []Review
Issues []Issue
}
+57
View File
@@ -0,0 +1,57 @@
package models
import "time"
// ReviewState represents the state of a review
type ReviewState string
const (
ReviewApproved ReviewState = "APPROVED"
ReviewChangesRequested ReviewState = "CHANGES_REQUESTED"
ReviewCommented ReviewState = "COMMENTED"
ReviewPending ReviewState = "PENDING"
ReviewDismissed ReviewState = "DISMISSED"
)
// Review represents a GitHub pull request review
type Review struct {
ID int64 `json:"id"`
PullRequest int `json:"pull_request"`
Repository string `json:"repository"` // owner/repo format
Author Author `json:"author"`
State ReviewState `json:"state"`
SubmittedAt time.Time `json:"submitted_at"`
Body string `json:"body,omitempty"`
CommentsCount int `json:"comments_count"`
// Derived fields
ResponseTime *time.Duration `json:"response_time,omitempty"` // Time from PR creation or review request to review
}
// IsApproval returns true if the review is an approval
func (r *Review) IsApproval() bool {
return r.State == ReviewApproved
}
// RequestsChanges returns true if the review requests changes
func (r *Review) RequestsChanges() bool {
return r.State == ReviewChangesRequested
}
// IsSubstantive returns true if the review has meaningful content (not just a simple approval)
func (r *Review) IsSubstantive() bool {
return r.Body != "" || r.CommentsCount > 0 || r.State == ReviewChangesRequested
}
// ReviewComment represents a comment on a pull request review
type ReviewComment struct {
ID int64 `json:"id"`
ReviewID int64 `json:"review_id"`
PullRequest int `json:"pull_request"`
Repository string `json:"repository"`
Author Author `json:"author"`
Body string `json:"body"`
Path string `json:"path,omitempty"`
Line int `json:"line,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
+312
View File
@@ -0,0 +1,312 @@
package scoring
import (
"sort"
"github.com/lukaszraczylo/git-velocity/internal/config"
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
)
// Calculator handles score and achievement calculations
type Calculator struct {
config *config.Config
}
// NewCalculator creates a new scoring calculator
func NewCalculator(cfg *config.Config) *Calculator {
return &Calculator{config: cfg}
}
// Calculate computes scores and achievements for all metrics
func (c *Calculator) Calculate(metrics *models.GlobalMetrics) *models.GlobalMetrics {
if !c.config.Scoring.Enabled {
return metrics
}
// Collect all contributor metrics across repositories
contributorMap := make(map[string]*models.ContributorMetrics)
for _, repo := range metrics.Repositories {
for i := range repo.Contributors {
login := repo.Contributors[i].Login
if _, ok := contributorMap[login]; !ok {
// Copy the contributor metrics
cm := repo.Contributors[i]
contributorMap[login] = &cm
} else {
// Aggregate metrics from multiple repos
existing := contributorMap[login]
cm := repo.Contributors[i]
existing.CommitCount += cm.CommitCount
existing.LinesAdded += cm.LinesAdded
existing.LinesDeleted += cm.LinesDeleted
existing.PRsOpened += cm.PRsOpened
existing.PRsMerged += cm.PRsMerged
existing.ReviewsGiven += cm.ReviewsGiven
existing.ReviewComments += cm.ReviewComments
// Combine unique repositories
for _, r := range cm.RepositoriesContributed {
if !contains(existing.RepositoriesContributed, r) {
existing.RepositoriesContributed = append(existing.RepositoriesContributed, r)
}
}
}
}
}
// Calculate scores for each contributor
for _, cm := range contributorMap {
cm.Score = c.calculateScore(cm)
// Check achievements
cm.Achievements = c.checkAchievements(cm)
}
// Convert to slice and sort by score
var contributors []models.ContributorMetrics
for _, cm := range contributorMap {
contributors = append(contributors, *cm)
}
sort.Slice(contributors, func(i, j int) bool {
return contributors[i].Score.Total > contributors[j].Score.Total
})
// Assign ranks
for i := range contributors {
contributors[i].Score.Rank = i + 1
contributors[i].Score.PercentileRank = float64(len(contributors)-i) / float64(len(contributors)) * 100
}
// Build leaderboard
leaderboard := make([]models.LeaderboardEntry, len(contributors))
topAchievers := make(map[string]string)
for i, cm := range contributors {
// Find team for user
team := ""
if teamCfg := c.config.GetTeamForUser(cm.Login); teamCfg != nil {
team = teamCfg.Name
}
// Determine top category
topCategory := c.determineTopCategory(&cm)
leaderboard[i] = models.LeaderboardEntry{
Rank: i + 1,
Login: cm.Login,
Name: cm.Name,
AvatarURL: cm.AvatarURL,
Score: cm.Score.Total,
Team: team,
TopCategory: topCategory,
Achievements: cm.Achievements,
}
// Track top achievers
if i == 0 {
topAchievers["overall"] = cm.Login
}
}
// Find top achievers in each category
c.findTopAchievers(contributors, topAchievers)
// Update the metrics
metrics.Leaderboard = leaderboard
metrics.TopAchievers = topAchievers
// Calculate per-repository scores (based on repo-specific metrics, not global)
for i := range metrics.Repositories {
for j := range metrics.Repositories[i].Contributors {
repoContrib := &metrics.Repositories[i].Contributors[j]
repoContrib.Score = c.calculateScore(repoContrib)
// Achievements are based on repo-specific activity
repoContrib.Achievements = c.checkAchievements(repoContrib)
}
// Re-sort by score after calculation
sort.Slice(metrics.Repositories[i].Contributors, func(a, b int) bool {
return metrics.Repositories[i].Contributors[a].Score.Total > metrics.Repositories[i].Contributors[b].Score.Total
})
}
// Update team scores
for i := range metrics.Teams {
var totalScore int
for j := range metrics.Teams[i].MemberMetrics {
login := metrics.Teams[i].MemberMetrics[j].Login
if cm, ok := contributorMap[login]; ok {
metrics.Teams[i].MemberMetrics[j].Score = cm.Score
metrics.Teams[i].MemberMetrics[j].Achievements = cm.Achievements
totalScore += cm.Score.Total
}
}
metrics.Teams[i].TotalScore = totalScore
if len(metrics.Teams[i].MemberMetrics) > 0 {
metrics.Teams[i].AvgScore = float64(totalScore) / float64(len(metrics.Teams[i].MemberMetrics))
}
}
return metrics
}
// calculateScore computes the score for a contributor based on their metrics
func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score {
points := c.config.Scoring.Points
breakdown := models.ScoreBreakdown{}
// Commit points
breakdown.Commits = cm.CommitCount * points.Commit
// Line change points
breakdown.LineChanges = int(float64(cm.LinesAdded)*points.LinesAdded +
float64(cm.LinesDeleted)*points.LinesDeleted)
// PR points
breakdown.PRs = cm.PRsOpened*points.PROpened + cm.PRsMerged*points.PRMerged
// Review points (PR reviews and PR review comments)
breakdown.Reviews = cm.ReviewsGiven*points.PRReviewed +
cm.ReviewComments*points.ReviewComment
// Response time bonus
if cm.ReviewsGiven > 0 && cm.AvgReviewTime > 0 {
if cm.AvgReviewTime <= 1 {
breakdown.ResponseBonus = points.FastReview1h
} else if cm.AvgReviewTime <= 4 {
breakdown.ResponseBonus = points.FastReview4h
} else if cm.AvgReviewTime <= 24 {
breakdown.ResponseBonus = points.FastReview24h
}
}
// Calculate total
total := breakdown.Commits + breakdown.LineChanges + breakdown.PRs +
breakdown.Reviews + breakdown.ResponseBonus + breakdown.Comments
return models.Score{
Total: total,
Breakdown: breakdown,
}
}
func (c *Calculator) checkAchievements(cm *models.ContributorMetrics) []string {
// Collect ALL earned achievements (including all tiers)
var achievements []string
for _, ach := range c.config.Scoring.Achievements {
earned := false
switch ach.Condition.Type {
case "commit_count":
earned = float64(cm.CommitCount) >= ach.Condition.Threshold
case "pr_opened_count":
earned = float64(cm.PRsOpened) >= ach.Condition.Threshold
case "pr_merged_count":
earned = float64(cm.PRsMerged) >= ach.Condition.Threshold
case "review_count":
earned = float64(cm.ReviewsGiven) >= ach.Condition.Threshold
case "comment_count":
earned = float64(cm.ReviewComments) >= ach.Condition.Threshold
case "lines_added":
earned = float64(cm.LinesAdded) >= ach.Condition.Threshold
case "lines_deleted":
earned = float64(cm.LinesDeleted) >= ach.Condition.Threshold
case "avg_review_time_hours":
// For avg review time, lower is better, so lower threshold = harder achievement
if cm.AvgReviewTime > 0 && cm.AvgReviewTime <= ach.Condition.Threshold {
earned = true
}
case "repo_count":
earned = float64(len(cm.RepositoriesContributed)) >= ach.Condition.Threshold
case "unique_reviewees":
earned = float64(cm.UniqueReviewees) >= ach.Condition.Threshold
// New PR quality metrics
case "largest_pr_size":
earned = float64(cm.LargestPRSize) >= ach.Condition.Threshold
case "small_pr_count":
earned = float64(cm.SmallPRCount) >= ach.Condition.Threshold
case "perfect_prs":
earned = float64(cm.PerfectPRs) >= ach.Condition.Threshold
// Activity pattern metrics
case "active_days":
earned = float64(cm.ActiveDays) >= ach.Condition.Threshold
case "longest_streak":
earned = float64(cm.LongestStreak) >= ach.Condition.Threshold
case "early_bird_count":
earned = float64(cm.EarlyBirdCount) >= ach.Condition.Threshold
case "night_owl_count":
earned = float64(cm.NightOwlCount) >= ach.Condition.Threshold
case "midnight_count":
earned = float64(cm.MidnightCount) >= ach.Condition.Threshold
case "weekend_warrior":
earned = float64(cm.WeekendWarrior) >= ach.Condition.Threshold
}
if earned {
achievements = append(achievements, ach.ID)
}
}
return achievements
}
func (c *Calculator) determineTopCategory(cm *models.ContributorMetrics) string {
// Determine what the contributor is best at
categories := map[string]int{
"Commits": cm.CommitCount,
"PRs": cm.PRsOpened,
"Reviews": cm.ReviewsGiven,
"Comments": cm.ReviewComments,
}
topCategory := ""
topValue := 0
for category, value := range categories {
if value > topValue {
topValue = value
topCategory = category
}
}
return topCategory
}
func (c *Calculator) findTopAchievers(contributors []models.ContributorMetrics, topAchievers map[string]string) {
var topCommitter, topReviewer, topPRAuthor string
var maxCommits, maxReviews, maxPRs int
for _, cm := range contributors {
if cm.CommitCount > maxCommits {
maxCommits = cm.CommitCount
topCommitter = cm.Login
}
if cm.ReviewsGiven > maxReviews {
maxReviews = cm.ReviewsGiven
topReviewer = cm.Login
}
if cm.PRsOpened > maxPRs {
maxPRs = cm.PRsOpened
topPRAuthor = cm.Login
}
}
if topCommitter != "" {
topAchievers["commits"] = topCommitter
}
if topReviewer != "" {
topAchievers["reviews"] = topReviewer
}
if topPRAuthor != "" {
topAchievers["pull_requests"] = topPRAuthor
}
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
+714
View File
@@ -0,0 +1,714 @@
package scoring
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/git-velocity/internal/config"
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
)
func TestNewCalculator(t *testing.T) {
t.Parallel()
cfg := &config.Config{}
calc := NewCalculator(cfg)
assert.NotNil(t, calc)
assert.Equal(t, cfg, calc.config)
}
func TestCalculator_ScoringDisabled(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = false
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
Contributors: []models.ContributorMetrics{
{Login: "user1", CommitCount: 100},
},
},
},
}
result := calc.Calculate(metrics)
// Should return unchanged metrics when scoring is disabled
assert.Equal(t, 0, result.Repositories[0].Contributors[0].Score.Total)
assert.Empty(t, result.Leaderboard)
}
func TestCalculator_BasicScoring(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
Commit: 10,
PROpened: 25,
PRMerged: 50,
PRReviewed: 30,
ReviewComment: 5,
LinesAdded: 0.1,
LinesDeleted: 0.05,
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "user1",
Name: "User One",
CommitCount: 10,
LinesAdded: 1000,
LinesDeleted: 500,
PRsOpened: 5,
PRsMerged: 3,
ReviewsGiven: 8,
ReviewComments: 20,
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
require.Len(t, result.Leaderboard, 1)
entry := result.Leaderboard[0]
assert.Equal(t, "user1", entry.Login)
assert.Equal(t, 1, entry.Rank)
// Verify score breakdown:
// Commits: 10 * 10 = 100
// Lines: 1000 * 0.1 + 500 * 0.05 = 100 + 25 = 125
// PRs: 5 * 25 + 3 * 50 = 125 + 150 = 275
// Reviews: 8 * 30 + 20 * 5 = 240 + 100 = 340
// Total: 100 + 125 + 275 + 340 = 840
assert.Equal(t, 840, entry.Score)
}
func TestCalculator_FastReviewBonus(t *testing.T) {
t.Parallel()
tests := []struct {
name string
avgReviewTime float64
expectedBonus int
expectedPoints config.PointsConfig
}{
{
name: "1 hour review gets 1h bonus",
avgReviewTime: 0.5,
expectedBonus: 50,
expectedPoints: config.PointsConfig{
FastReview1h: 50,
FastReview4h: 30,
FastReview24h: 10,
},
},
{
name: "3 hour review gets 4h bonus",
avgReviewTime: 3.0,
expectedBonus: 30,
expectedPoints: config.PointsConfig{
FastReview1h: 50,
FastReview4h: 30,
FastReview24h: 10,
},
},
{
name: "12 hour review gets 24h bonus",
avgReviewTime: 12.0,
expectedBonus: 10,
expectedPoints: config.PointsConfig{
FastReview1h: 50,
FastReview4h: 30,
FastReview24h: 10,
},
},
{
name: "48 hour review gets no bonus",
avgReviewTime: 48.0,
expectedBonus: 0,
expectedPoints: config.PointsConfig{
FastReview1h: 50,
FastReview4h: 30,
FastReview24h: 10,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = tt.expectedPoints
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "user1",
ReviewsGiven: 5,
AvgReviewTime: tt.avgReviewTime,
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
require.Len(t, result.Leaderboard, 1)
// Get the contributor from the repository to check breakdown
contributor := result.Repositories[0].Contributors[0]
assert.Equal(t, tt.expectedBonus, contributor.Score.Breakdown.ResponseBonus)
})
}
}
func TestCalculator_MultipleContributorsRanking(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
Commit: 10,
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "user1",
CommitCount: 100,
RepositoriesContributed: []string{"owner/repo"},
},
{
Login: "user2",
CommitCount: 50,
RepositoriesContributed: []string{"owner/repo"},
},
{
Login: "user3",
CommitCount: 200,
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
require.Len(t, result.Leaderboard, 3)
// Should be sorted by score (highest first)
assert.Equal(t, "user3", result.Leaderboard[0].Login)
assert.Equal(t, 1, result.Leaderboard[0].Rank)
assert.Equal(t, 2000, result.Leaderboard[0].Score)
assert.Equal(t, "user1", result.Leaderboard[1].Login)
assert.Equal(t, 2, result.Leaderboard[1].Rank)
assert.Equal(t, 1000, result.Leaderboard[1].Score)
assert.Equal(t, "user2", result.Leaderboard[2].Login)
assert.Equal(t, 3, result.Leaderboard[2].Rank)
assert.Equal(t, 500, result.Leaderboard[2].Score)
}
func TestCalculator_PercentileRank(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{Commit: 10}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{Login: "user1", CommitCount: 100, RepositoriesContributed: []string{"owner/repo"}},
{Login: "user2", CommitCount: 80, RepositoriesContributed: []string{"owner/repo"}},
{Login: "user3", CommitCount: 60, RepositoriesContributed: []string{"owner/repo"}},
{Login: "user4", CommitCount: 40, RepositoriesContributed: []string{"owner/repo"}},
},
},
},
}
result := calc.Calculate(metrics)
require.Len(t, result.Leaderboard, 4)
// Leaderboard should be sorted by score (highest first)
// user1: 100 commits * 10 = 1000, rank 1
// user2: 80 commits * 10 = 800, rank 2
// user3: 60 commits * 10 = 600, rank 3
// user4: 40 commits * 10 = 400, rank 4
assert.Equal(t, "user1", result.Leaderboard[0].Login)
assert.Equal(t, 1, result.Leaderboard[0].Rank)
assert.Equal(t, 1000, result.Leaderboard[0].Score)
assert.Equal(t, "user2", result.Leaderboard[1].Login)
assert.Equal(t, 2, result.Leaderboard[1].Rank)
assert.Equal(t, 800, result.Leaderboard[1].Score)
assert.Equal(t, "user3", result.Leaderboard[2].Login)
assert.Equal(t, 3, result.Leaderboard[2].Rank)
assert.Equal(t, 600, result.Leaderboard[2].Score)
assert.Equal(t, "user4", result.Leaderboard[3].Login)
assert.Equal(t, 4, result.Leaderboard[3].Rank)
assert.Equal(t, 400, result.Leaderboard[3].Score)
}
func TestCalculator_Achievements(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Achievements = []config.AchievementConfig{
{
ID: "commit-10",
Name: "10 Commits",
Condition: config.AchievementCondition{
Type: "commit_count",
Threshold: 10,
},
},
{
ID: "pr-master",
Name: "PR Master",
Condition: config.AchievementCondition{
Type: "pr_opened_count",
Threshold: 5,
},
},
{
ID: "reviewer",
Name: "Reviewer",
Condition: config.AchievementCondition{
Type: "review_count",
Threshold: 10,
},
},
{
ID: "speed-demon",
Name: "Speed Demon",
Condition: config.AchievementCondition{
Type: "avg_review_time_hours",
Threshold: 1.0,
},
},
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "user1",
CommitCount: 15,
PRsOpened: 6,
ReviewsGiven: 5,
AvgReviewTime: 0.5,
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
// Should have commit-10, pr-master, and speed-demon
// Should NOT have reviewer (only 5 reviews, need 10)
assert.Contains(t, contributor.Achievements, "commit-10")
assert.Contains(t, contributor.Achievements, "pr-master")
assert.Contains(t, contributor.Achievements, "speed-demon")
assert.NotContains(t, contributor.Achievements, "reviewer")
}
func TestCalculator_AllAchievementTypes(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Achievements = []config.AchievementConfig{
{ID: "commits", Condition: config.AchievementCondition{Type: "commit_count", Threshold: 10}},
{ID: "prs-opened", Condition: config.AchievementCondition{Type: "pr_opened_count", Threshold: 5}},
{ID: "prs-merged", Condition: config.AchievementCondition{Type: "pr_merged_count", Threshold: 3}},
{ID: "reviews", Condition: config.AchievementCondition{Type: "review_count", Threshold: 8}},
{ID: "comments", Condition: config.AchievementCondition{Type: "comment_count", Threshold: 20}},
{ID: "lines-added", Condition: config.AchievementCondition{Type: "lines_added", Threshold: 1000}},
{ID: "lines-deleted", Condition: config.AchievementCondition{Type: "lines_deleted", Threshold: 500}},
{ID: "fast-review", Condition: config.AchievementCondition{Type: "avg_review_time_hours", Threshold: 2}},
{ID: "multi-repo", Condition: config.AchievementCondition{Type: "repo_count", Threshold: 2}},
{ID: "team-player", Condition: config.AchievementCondition{Type: "unique_reviewees", Threshold: 5}},
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo1",
Contributors: []models.ContributorMetrics{
{
Login: "user1",
CommitCount: 15,
PRsOpened: 6,
PRsMerged: 4,
ReviewsGiven: 10,
ReviewComments: 25,
LinesAdded: 1500,
LinesDeleted: 600,
AvgReviewTime: 1.5,
UniqueReviewees: 7,
RepositoriesContributed: []string{"owner/repo1", "owner/repo2"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
// Should have all achievements
assert.Len(t, contributor.Achievements, 10)
assert.Contains(t, contributor.Achievements, "commits")
assert.Contains(t, contributor.Achievements, "prs-opened")
assert.Contains(t, contributor.Achievements, "prs-merged")
assert.Contains(t, contributor.Achievements, "reviews")
assert.Contains(t, contributor.Achievements, "comments")
assert.Contains(t, contributor.Achievements, "lines-added")
assert.Contains(t, contributor.Achievements, "lines-deleted")
assert.Contains(t, contributor.Achievements, "fast-review")
assert.Contains(t, contributor.Achievements, "multi-repo")
assert.Contains(t, contributor.Achievements, "team-player")
}
func TestCalculator_TopAchievers(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
Commit: 10,
PROpened: 25,
PRReviewed: 30,
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "committer",
CommitCount: 100,
PRsOpened: 5,
ReviewsGiven: 2,
RepositoriesContributed: []string{"owner/repo"},
},
{
Login: "pr-author",
CommitCount: 10,
PRsOpened: 50,
ReviewsGiven: 3,
RepositoriesContributed: []string{"owner/repo"},
},
{
Login: "reviewer",
CommitCount: 5,
PRsOpened: 2,
ReviewsGiven: 100,
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
assert.Equal(t, "committer", result.TopAchievers["commits"])
assert.Equal(t, "pr-author", result.TopAchievers["pull_requests"])
assert.Equal(t, "reviewer", result.TopAchievers["reviews"])
// Overall top achiever has highest score
assert.NotEmpty(t, result.TopAchievers["overall"])
}
func TestCalculator_TeamScoring(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{Commit: 10}
cfg.Teams = []config.TeamConfig{
{
Name: "Backend Team",
Members: []string{"user1", "user2"},
Color: "#ff0000",
},
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{Login: "user1", CommitCount: 50, RepositoriesContributed: []string{"owner/repo"}},
{Login: "user2", CommitCount: 30, RepositoriesContributed: []string{"owner/repo"}},
},
},
},
Teams: []models.TeamMetrics{
{
Name: "Backend Team",
Members: []string{"user1", "user2"},
MemberMetrics: []models.ContributorMetrics{
{Login: "user1"},
{Login: "user2"},
},
},
},
}
result := calc.Calculate(metrics)
require.Len(t, result.Teams, 1)
team := result.Teams[0]
// Total: 500 + 300 = 800
assert.Equal(t, 800, team.TotalScore)
// Avg: 800 / 2 = 400
assert.Equal(t, 400.0, team.AvgScore)
// Check individual member scores
assert.Equal(t, 500, team.MemberMetrics[0].Score.Total)
assert.Equal(t, 300, team.MemberMetrics[1].Score.Total)
}
func TestCalculator_TeamInLeaderboard(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{Commit: 10}
cfg.Teams = []config.TeamConfig{
{
Name: "Backend Team",
Members: []string{"user1"},
},
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{Login: "user1", CommitCount: 50, RepositoriesContributed: []string{"owner/repo"}},
{Login: "user2", CommitCount: 30, RepositoriesContributed: []string{"owner/repo"}},
},
},
},
}
result := calc.Calculate(metrics)
// user1 should have team name in leaderboard
assert.Equal(t, "Backend Team", result.Leaderboard[0].Team)
// user2 should not have a team
assert.Empty(t, result.Leaderboard[1].Team)
}
func TestCalculator_DetermineTopCategory(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
calc := NewCalculator(cfg)
tests := []struct {
name string
contributor models.ContributorMetrics
expectedCategory string
}{
{
name: "Top committer",
contributor: models.ContributorMetrics{
CommitCount: 100,
PRsOpened: 10,
ReviewsGiven: 5,
ReviewComments: 20,
},
expectedCategory: "Commits",
},
{
name: "Top PR author",
contributor: models.ContributorMetrics{
CommitCount: 10,
PRsOpened: 100,
ReviewsGiven: 5,
ReviewComments: 20,
},
expectedCategory: "PRs",
},
{
name: "Top reviewer",
contributor: models.ContributorMetrics{
CommitCount: 10,
PRsOpened: 5,
ReviewsGiven: 100,
ReviewComments: 20,
},
expectedCategory: "Reviews",
},
{
name: "Top commenter",
contributor: models.ContributorMetrics{
CommitCount: 10,
PRsOpened: 5,
ReviewsGiven: 20,
ReviewComments: 100,
},
expectedCategory: "Comments",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := calc.determineTopCategory(&tt.contributor)
assert.Equal(t, tt.expectedCategory, result)
})
}
}
func TestCalculator_MultipleRepositories(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{Commit: 10}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo1",
Contributors: []models.ContributorMetrics{
{Login: "user1", CommitCount: 50, RepositoriesContributed: []string{"owner/repo1"}},
},
},
{
FullName: "owner/repo2",
Contributors: []models.ContributorMetrics{
{Login: "user1", CommitCount: 30, RepositoriesContributed: []string{"owner/repo2"}},
},
},
},
}
result := calc.Calculate(metrics)
// Should aggregate commits from both repos
require.Len(t, result.Leaderboard, 1)
// 50 + 30 = 80 commits * 10 = 800
assert.Equal(t, 800, result.Leaderboard[0].Score)
// Both repos should be tracked
contributor := result.Repositories[0].Contributors[0]
assert.Equal(t, 800, contributor.Score.Total)
}
func TestCalculator_EmptyMetrics(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{},
}
result := calc.Calculate(metrics)
assert.Empty(t, result.Leaderboard)
assert.Empty(t, result.TopAchievers)
}
func TestCalculator_NoReviewsNoBonus(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
FastReview1h: 50,
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "user1",
ReviewsGiven: 0,
AvgReviewTime: 0.5, // Fast but no reviews
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
// Should not get bonus if no reviews given
assert.Equal(t, 0, contributor.Score.Breakdown.ResponseBonus)
}
func TestContains(t *testing.T) {
t.Parallel()
slice := []string{"a", "b", "c"}
assert.True(t, contains(slice, "a"))
assert.True(t, contains(slice, "b"))
assert.True(t, contains(slice, "c"))
assert.False(t, contains(slice, "d"))
assert.False(t, contains([]string{}, "a"))
}
+182
View File
@@ -0,0 +1,182 @@
package site
import (
"embed"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
json "github.com/goccy/go-json"
"github.com/lukaszraczylo/git-velocity/internal/config"
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
)
//go:embed dist/*
var spaFS embed.FS
// Generator handles static site generation
type Generator struct {
outputDir string
config *config.Config
}
// NewGenerator creates a new site generator
func NewGenerator(outputDir string, cfg *config.Config) (*Generator, error) {
return &Generator{
outputDir: outputDir,
config: cfg,
}, nil
}
// Generate creates the static site from metrics
func (g *Generator) Generate(metrics *models.GlobalMetrics) error {
// Create output directory
if err := os.MkdirAll(g.outputDir, 0750); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
// Generate data files
if err := g.generateDataFiles(metrics); err != nil {
return fmt.Errorf("failed to generate data files: %w", err)
}
// Copy Vue SPA files
if err := g.copySPAFiles(); err != nil {
return fmt.Errorf("failed to copy SPA files: %w", err)
}
return nil
}
func (g *Generator) generateDataFiles(metrics *models.GlobalMetrics) error {
dataDir := filepath.Join(g.outputDir, "data")
// Clean old data directory to ensure fresh state
if err := os.RemoveAll(dataDir); err != nil {
return fmt.Errorf("failed to clean data directory: %w", err)
}
if err := os.MkdirAll(dataDir, 0750); err != nil {
return err
}
// Prepare global data with timestamp
globalData := struct {
*models.GlobalMetrics
GeneratedAt time.Time `json:"generated_at"`
}{
GlobalMetrics: metrics,
GeneratedAt: time.Now(),
}
// Global metrics
if err := writeJSON(filepath.Join(dataDir, "global.json"), globalData); err != nil {
return err
}
// Leaderboard
if err := writeJSON(filepath.Join(dataDir, "leaderboard.json"), metrics.Leaderboard); err != nil {
return err
}
// Per-repository data
for _, repo := range metrics.Repositories {
repoDir := filepath.Join(dataDir, "repos", repo.Owner, repo.Name)
if err := os.MkdirAll(repoDir, 0750); err != nil {
return err
}
if err := writeJSON(filepath.Join(repoDir, "metrics.json"), repo); err != nil {
return err
}
}
// Per-team data
if len(metrics.Teams) > 0 {
teamDir := filepath.Join(dataDir, "teams")
if err := os.MkdirAll(teamDir, 0750); err != nil {
return err
}
for _, team := range metrics.Teams {
if err := writeJSON(filepath.Join(teamDir, slugify(team.Name)+".json"), team); err != nil {
return err
}
}
}
// Per-contributor data
contributorsSeen := make(map[string]bool)
contributorDir := filepath.Join(dataDir, "contributors")
if err := os.MkdirAll(contributorDir, 0750); err != nil {
return err
}
for _, repo := range metrics.Repositories {
for _, contributor := range repo.Contributors {
if contributorsSeen[contributor.Login] {
continue
}
contributorsSeen[contributor.Login] = true
if err := writeJSON(filepath.Join(contributorDir, contributor.Login+".json"), contributor); err != nil {
return err
}
}
}
return nil
}
func (g *Generator) copySPAFiles() error {
return fs.WalkDir(spaFS, "dist", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip the root dist directory itself
if path == "dist" {
return nil
}
// Calculate the relative path from "dist/"
relPath := strings.TrimPrefix(path, "dist/")
destPath := filepath.Join(g.outputDir, relPath)
if d.IsDir() {
return os.MkdirAll(destPath, 0750)
}
// Read file from embedded FS
content, err := spaFS.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read embedded file %s: %w", path, err)
}
// Write to destination
return os.WriteFile(destPath, content, 0600)
})
}
// Helper functions
func writeJSON(path string, data interface{}) error {
cleanPath := filepath.Clean(path)
file, err := os.OpenFile(cleanPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) // #nosec G304 -- path is constructed internally
if err != nil {
return err
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
return encoder.Encode(data)
}
func slugify(s string) string {
s = strings.ToLower(s)
s = strings.ReplaceAll(s, " ", "-")
s = strings.ReplaceAll(s, "_", "-")
return s
}
+502
View File
@@ -0,0 +1,502 @@
package site
import (
"os"
"path/filepath"
"testing"
"time"
json "github.com/goccy/go-json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/git-velocity/internal/config"
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
)
func TestNewGenerator(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
gen, err := NewGenerator("/tmp/output", cfg)
require.NoError(t, err)
assert.NotNil(t, gen)
assert.Equal(t, "/tmp/output", gen.outputDir)
assert.Equal(t, cfg, gen.config)
}
func TestGenerator_GenerateCreatesOutputDir(t *testing.T) {
tempDir := t.TempDir()
outputDir := filepath.Join(tempDir, "new-output")
cfg := config.DefaultConfig()
gen, err := NewGenerator(outputDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{
Period: models.Period{
Start: time.Now().Add(-24 * time.Hour),
End: time.Now(),
},
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Verify output directory was created
info, err := os.Stat(outputDir)
require.NoError(t, err)
assert.True(t, info.IsDir())
}
func TestGenerator_GenerateCreatesDataDir(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{}
err = gen.Generate(metrics)
require.NoError(t, err)
// Verify data directory was created
dataDir := filepath.Join(tempDir, "data")
info, err := os.Stat(dataDir)
require.NoError(t, err)
assert.True(t, info.IsDir())
}
func TestGenerator_GenerateGlobalJSON(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{
TotalContributors: 5,
TotalCommits: 100,
TotalPRs: 50,
TotalReviews: 75,
TotalLinesAdded: 10000,
TotalLinesDeleted: 5000,
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Read and verify global.json
globalPath := filepath.Join(tempDir, "data", "global.json")
data, err := os.ReadFile(globalPath)
require.NoError(t, err)
var result struct {
TotalContributors int `json:"total_contributors"`
TotalCommits int `json:"total_commits"`
TotalPRs int `json:"total_prs"`
TotalReviews int `json:"total_reviews"`
TotalLinesAdded int `json:"total_lines_added"`
TotalLinesDeleted int `json:"total_lines_deleted"`
GeneratedAt time.Time `json:"GeneratedAt"`
}
err = json.Unmarshal(data, &result)
require.NoError(t, err)
assert.Equal(t, 5, result.TotalContributors)
assert.Equal(t, 100, result.TotalCommits)
assert.Equal(t, 50, result.TotalPRs)
assert.Equal(t, 75, result.TotalReviews)
assert.Equal(t, 10000, result.TotalLinesAdded)
assert.Equal(t, 5000, result.TotalLinesDeleted)
assert.False(t, result.GeneratedAt.IsZero())
}
func TestGenerator_GenerateLeaderboardJSON(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{
Leaderboard: []models.LeaderboardEntry{
{Rank: 1, Login: "user1", Score: 1000},
{Rank: 2, Login: "user2", Score: 800},
{Rank: 3, Login: "user3", Score: 600},
},
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Read and verify leaderboard.json
leaderboardPath := filepath.Join(tempDir, "data", "leaderboard.json")
data, err := os.ReadFile(leaderboardPath)
require.NoError(t, err)
var result []models.LeaderboardEntry
err = json.Unmarshal(data, &result)
require.NoError(t, err)
require.Len(t, result, 3)
assert.Equal(t, "user1", result[0].Login)
assert.Equal(t, 1000, result[0].Score)
assert.Equal(t, "user2", result[1].Login)
assert.Equal(t, 800, result[1].Score)
}
func TestGenerator_GenerateRepositoryJSON(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
Owner: "myorg",
Name: "myrepo",
TotalCommits: 42,
TotalPRs: 10,
},
},
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Read and verify repository metrics
repoPath := filepath.Join(tempDir, "data", "repos", "myorg", "myrepo", "metrics.json")
data, err := os.ReadFile(repoPath)
require.NoError(t, err)
var result models.RepositoryMetrics
err = json.Unmarshal(data, &result)
require.NoError(t, err)
assert.Equal(t, "myorg", result.Owner)
assert.Equal(t, "myrepo", result.Name)
assert.Equal(t, 42, result.TotalCommits)
assert.Equal(t, 10, result.TotalPRs)
}
func TestGenerator_GenerateMultipleRepositories(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{Owner: "org1", Name: "repo1", TotalCommits: 100},
{Owner: "org1", Name: "repo2", TotalCommits: 200},
{Owner: "org2", Name: "repo3", TotalCommits: 300},
},
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Verify all repository files exist
_, err = os.Stat(filepath.Join(tempDir, "data", "repos", "org1", "repo1", "metrics.json"))
assert.NoError(t, err)
_, err = os.Stat(filepath.Join(tempDir, "data", "repos", "org1", "repo2", "metrics.json"))
assert.NoError(t, err)
_, err = os.Stat(filepath.Join(tempDir, "data", "repos", "org2", "repo3", "metrics.json"))
assert.NoError(t, err)
}
func TestGenerator_GenerateTeamJSON(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{
Teams: []models.TeamMetrics{
{
Name: "Backend Team",
Color: "#ff0000",
Members: []string{"user1", "user2"},
TotalScore: 1500,
},
},
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Read and verify team JSON (slugified name)
teamPath := filepath.Join(tempDir, "data", "teams", "backend-team.json")
data, err := os.ReadFile(teamPath)
require.NoError(t, err)
var result models.TeamMetrics
err = json.Unmarshal(data, &result)
require.NoError(t, err)
assert.Equal(t, "Backend Team", result.Name)
assert.Equal(t, "#ff0000", result.Color)
assert.Equal(t, 1500, result.TotalScore)
assert.Len(t, result.Members, 2)
}
func TestGenerator_GenerateContributorJSON(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
Contributors: []models.ContributorMetrics{
{
Login: "john-doe",
Name: "John Doe",
CommitCount: 50,
PRsOpened: 10,
},
},
},
},
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Read and verify contributor JSON
contributorPath := filepath.Join(tempDir, "data", "contributors", "john-doe.json")
data, err := os.ReadFile(contributorPath)
require.NoError(t, err)
var result models.ContributorMetrics
err = json.Unmarshal(data, &result)
require.NoError(t, err)
assert.Equal(t, "john-doe", result.Login)
assert.Equal(t, "John Doe", result.Name)
assert.Equal(t, 50, result.CommitCount)
assert.Equal(t, 10, result.PRsOpened)
}
func TestGenerator_ContributorDeduplication(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
// Same contributor in multiple repos
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
Contributors: []models.ContributorMetrics{
{Login: "user1", CommitCount: 50},
},
},
{
Contributors: []models.ContributorMetrics{
{Login: "user1", CommitCount: 75}, // Same user, different count
},
},
},
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Should only have one contributor file (first one seen)
contributorPath := filepath.Join(tempDir, "data", "contributors", "user1.json")
data, err := os.ReadFile(contributorPath)
require.NoError(t, err)
var result models.ContributorMetrics
err = json.Unmarshal(data, &result)
require.NoError(t, err)
// Should be the first one (50 commits)
assert.Equal(t, 50, result.CommitCount)
}
func TestGenerator_NoTeamsDoesNotCreateTeamDir(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{
Teams: []models.TeamMetrics{}, // Empty teams
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Team directory should not exist
teamDir := filepath.Join(tempDir, "data", "teams")
_, err = os.Stat(teamDir)
assert.True(t, os.IsNotExist(err))
}
func TestSlugify(t *testing.T) {
t.Parallel()
tests := []struct {
input string
expected string
}{
{"Backend Team", "backend-team"},
{"Frontend_Team", "frontend-team"},
{"UPPER CASE", "upper-case"},
{"already-slug", "already-slug"},
{"Multiple Spaces", "multiple---spaces"},
{"Mixed_And Spaced", "mixed-and-spaced"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
result := slugify(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestWriteJSON(t *testing.T) {
tempDir := t.TempDir()
testData := map[string]interface{}{
"key": "value",
"number": 42,
"nested": map[string]string{
"inner": "data",
},
}
path := filepath.Join(tempDir, "test.json")
err := writeJSON(path, testData)
require.NoError(t, err)
// Verify file was created and is valid JSON
data, err := os.ReadFile(path)
require.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(data, &result)
require.NoError(t, err)
assert.Equal(t, "value", result["key"])
assert.Equal(t, float64(42), result["number"]) // JSON numbers are float64
}
func TestWriteJSON_Indented(t *testing.T) {
tempDir := t.TempDir()
testData := map[string]string{"key": "value"}
path := filepath.Join(tempDir, "test.json")
err := writeJSON(path, testData)
require.NoError(t, err)
data, err := os.ReadFile(path)
require.NoError(t, err)
// Should be formatted with indentation
assert.Contains(t, string(data), "\n")
assert.Contains(t, string(data), " ") // 2-space indent
}
func TestWriteJSON_ErrorOnInvalidPath(t *testing.T) {
// Try to write to a path that doesn't exist
path := "/nonexistent/directory/test.json"
err := writeJSON(path, "data")
assert.Error(t, err)
}
func TestGenerator_GenerateWithFullMetrics(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
// Create comprehensive metrics
metrics := &models.GlobalMetrics{
Period: models.Period{
Start: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
End: time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC),
Granularity: "monthly",
Label: "2024",
},
TotalContributors: 10,
TotalCommits: 500,
TotalPRs: 100,
TotalReviews: 200,
TotalLinesAdded: 50000,
TotalLinesDeleted: 25000,
Repositories: []models.RepositoryMetrics{
{
Owner: "org",
Name: "repo1",
TotalCommits: 300,
TotalPRs: 60,
ActiveContributors: 5,
Contributors: []models.ContributorMetrics{
{Login: "alice", Name: "Alice", CommitCount: 100},
{Login: "bob", Name: "Bob", CommitCount: 200},
},
},
{
Owner: "org",
Name: "repo2",
TotalCommits: 200,
TotalPRs: 40,
ActiveContributors: 5,
Contributors: []models.ContributorMetrics{
{Login: "alice", Name: "Alice", CommitCount: 50},
{Login: "charlie", Name: "Charlie", CommitCount: 150},
},
},
},
Teams: []models.TeamMetrics{
{
Name: "Core Team",
Members: []string{"alice", "bob"},
TotalScore: 5000,
},
},
Leaderboard: []models.LeaderboardEntry{
{Rank: 1, Login: "alice", Score: 3000},
{Rank: 2, Login: "bob", Score: 2000},
{Rank: 3, Login: "charlie", Score: 1500},
},
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Verify all expected files exist
expectedPaths := []string{
filepath.Join(tempDir, "data", "global.json"),
filepath.Join(tempDir, "data", "leaderboard.json"),
filepath.Join(tempDir, "data", "repos", "org", "repo1", "metrics.json"),
filepath.Join(tempDir, "data", "repos", "org", "repo2", "metrics.json"),
filepath.Join(tempDir, "data", "teams", "core-team.json"),
filepath.Join(tempDir, "data", "contributors", "alice.json"),
filepath.Join(tempDir, "data", "contributors", "bob.json"),
filepath.Join(tempDir, "data", "contributors", "charlie.json"),
}
for _, path := range expectedPaths {
_, err := os.Stat(path)
assert.NoError(t, err, "Expected file to exist: %s", path)
}
}
+440
View File
@@ -0,0 +1,440 @@
package git
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
)
// ProgressCallback is called to report progress during git operations
type ProgressCallback func(message string)
// Repository manages local git repository operations using go-git
type Repository struct {
baseDir string
progress ProgressCallback
}
// NewRepository creates a new repository manager
func NewRepository(baseDir string) (*Repository, error) {
// Create base directory if it doesn't exist
if err := os.MkdirAll(baseDir, 0750); err != nil {
return nil, fmt.Errorf("failed to create base directory: %w", err)
}
return &Repository{
baseDir: baseDir,
progress: func(string) {}, // no-op by default
}, nil
}
// SetProgressCallback sets the callback function for progress reporting
func (r *Repository) SetProgressCallback(cb ProgressCallback) {
if cb != nil {
r.progress = cb
}
}
// repoPath returns the local path for a repository
func (r *Repository) repoPath(owner, name string) string {
return filepath.Join(r.baseDir, owner, name)
}
// EnsureCloned ensures a repository is cloned and up to date
func (r *Repository) EnsureCloned(ctx context.Context, owner, name, token string) error {
repoPath := r.repoPath(owner, name)
// Check if already cloned
gitDir := filepath.Join(repoPath, ".git")
if _, err := os.Stat(gitDir); err == nil {
// Repository exists, fetch latest
r.progress(fmt.Sprintf(" Updating local clone of %s/%s...", owner, name))
return r.fetch(ctx, repoPath, token)
}
// Clone the repository
r.progress(fmt.Sprintf(" Cloning %s/%s...", owner, name))
return r.clone(ctx, owner, name, token, repoPath)
}
// clone clones a repository using go-git
func (r *Repository) clone(ctx context.Context, owner, name, token, destPath string) error {
// Create parent directory
if err := os.MkdirAll(filepath.Dir(destPath), 0750); err != nil {
return fmt.Errorf("failed to create parent directory: %w", err)
}
cloneURL := fmt.Sprintf("https://github.com/%s/%s.git", owner, name)
cloneOpts := &git.CloneOptions{
URL: cloneURL,
Progress: nil, // Could add progress writer here
}
// Add authentication if token provided
if token != "" {
cloneOpts.Auth = &http.BasicAuth{
Username: "x-access-token",
Password: token,
}
}
_, err := git.PlainCloneContext(ctx, destPath, false, cloneOpts)
if err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
return nil
}
// fetch fetches latest changes from remote using go-git
func (r *Repository) fetch(ctx context.Context, repoPath, token string) error {
repo, err := git.PlainOpen(repoPath)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}
fetchOpts := &git.FetchOptions{
RemoteName: "origin",
Force: true,
Prune: true,
RefSpecs: []config.RefSpec{"+refs/*:refs/*"},
}
// Add authentication if token provided
if token != "" {
fetchOpts.Auth = &http.BasicAuth{
Username: "x-access-token",
Password: token,
}
}
err = repo.FetchContext(ctx, fetchOpts)
if err != nil && err != git.NoErrAlreadyUpToDate {
return fmt.Errorf("failed to fetch: %w", err)
}
return nil
}
// isCommentLine checks if a line is a code comment (should not count as contribution)
func isCommentLine(line string) bool {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return true // Empty lines don't count
}
// Common comment patterns across languages
commentPrefixes := []string{
"//", // C, C++, Java, Go, JS, etc.
"#", // Python, Ruby, Shell, YAML
"/*", // C-style block comment start
"*/", // C-style block comment end
"*", // C-style block comment continuation
"<!--", // HTML/XML comment
"-->", // HTML/XML comment end
"--", // SQL, Lua, Haskell
";", // Assembly, Lisp, INI files
"'", // VB comment
"\"\"\"", // Python docstring
"'''", // Python docstring
}
for _, prefix := range commentPrefixes {
if strings.HasPrefix(trimmed, prefix) {
return true
}
}
return false
}
// isDocumentationFile checks if a file is documentation-only
func isDocumentationFile(filename string) bool {
// Documentation file extensions and patterns
docPatterns := []string{
".md", ".markdown", ".rst", ".txt", ".adoc",
"README", "CHANGELOG", "LICENSE", "CONTRIBUTING",
"docs/", "documentation/", "/doc/",
}
lowerFilename := strings.ToLower(filename)
for _, pattern := range docPatterns {
if strings.Contains(lowerFilename, strings.ToLower(pattern)) {
return true
}
}
return false
}
// FetchCommits retrieves commits from the local repository using go-git
func (r *Repository) FetchCommits(ctx context.Context, owner, name string, since, until *time.Time) ([]models.Commit, error) {
repoPath := r.repoPath(owner, name)
repo, err := git.PlainOpen(repoPath)
if err != nil {
return nil, fmt.Errorf("failed to open repository: %w", err)
}
r.progress(" Iterating commits with go-git...")
// Get all references to iterate all branches
refs, err := repo.References()
if err != nil {
return nil, fmt.Errorf("failed to get references: %w", err)
}
// Collect all commit hashes from all branches
seenCommits := make(map[plumbing.Hash]bool)
var commits []models.Commit
testPatterns := []string{"_test.go", ".test.", ".spec.", "/tests/", "/test/", "__tests__"}
err = refs.ForEach(func(ref *plumbing.Reference) error {
// Skip non-branch references
if !ref.Name().IsBranch() && !ref.Name().IsRemote() && !ref.Name().IsTag() {
return nil
}
// Get commit iterator for this reference
commitIter, err := repo.Log(&git.LogOptions{
From: ref.Hash(),
Order: git.LogOrderCommitterTime,
All: false,
})
if err != nil {
// Skip refs that don't point to commits
return nil
}
err = commitIter.ForEach(func(c *object.Commit) error {
// Check context cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Skip already seen commits
if seenCommits[c.Hash] {
return nil
}
seenCommits[c.Hash] = true
commitTime := c.Author.When
// Filter by date range
if since != nil && commitTime.Before(*since) {
return nil
}
if until != nil && commitTime.After(*until) {
return nil
}
// Get file stats for this commit
additions, deletions, filesChanged, hasTests := r.getCommitStats(c, testPatterns)
// Extract login from email
authorLogin := extractLoginFromEmail(c.Author.Email, c.Author.Name)
committerLogin := extractLoginFromEmail(c.Committer.Email, c.Committer.Name)
commit := models.Commit{
SHA: c.Hash.String(),
Message: strings.Split(c.Message, "\n")[0], // First line only
Author: models.Author{
Login: authorLogin,
Name: c.Author.Name,
Email: c.Author.Email,
},
Committer: models.Author{
Login: committerLogin,
Name: c.Committer.Name,
Email: c.Committer.Email,
},
Date: commitTime,
Additions: additions,
Deletions: deletions,
FilesChanged: filesChanged,
Repository: fmt.Sprintf("%s/%s", owner, name),
URL: fmt.Sprintf("https://github.com/%s/%s/commit/%s", owner, name, c.Hash.String()),
HasTests: hasTests,
}
commits = append(commits, commit)
return nil
})
return err
})
if err != nil {
return nil, fmt.Errorf("failed to iterate commits: %w", err)
}
r.progress(fmt.Sprintf(" Found %d commits", len(commits)))
return commits, nil
}
// getCommitStats calculates additions, deletions, files changed for a commit
func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) (additions, deletions, filesChanged int, hasTests bool) {
// Get parent commit for diff
parentIter := c.Parents()
parent, err := parentIter.Next()
var parentTree *object.Tree
if err == nil {
parentTree, _ = parent.Tree()
}
currentTree, err := c.Tree()
if err != nil {
return 0, 0, 0, false
}
// Get changes between parent and current
var changes object.Changes
if parentTree != nil {
changes, err = parentTree.Diff(currentTree)
} else {
// Initial commit - all files are additions
changes, err = object.DiffTree(nil, currentTree)
}
if err != nil {
return 0, 0, 0, false
}
filesSet := make(map[string]bool)
for _, change := range changes {
// Get the file path
var filePath string
if change.To.Name != "" {
filePath = change.To.Name
} else if change.From.Name != "" {
filePath = change.From.Name
}
// Skip documentation files
if isDocumentationFile(filePath) {
continue
}
// Count unique files
if !filesSet[filePath] {
filesSet[filePath] = true
filesChanged++
// Check for test files
for _, pattern := range testPatterns {
if strings.Contains(filePath, pattern) {
hasTests = true
break
}
}
}
// Get patch to count lines
patch, err := change.Patch()
if err != nil {
continue
}
for _, filePatch := range patch.FilePatches() {
for _, chunk := range filePatch.Chunks() {
content := chunk.Content()
lines := strings.Split(content, "\n")
switch chunk.Type() {
case 1: // Add
for _, line := range lines {
if !isCommentLine(line) {
additions++
}
}
case 2: // Delete
for _, line := range lines {
if !isCommentLine(line) {
deletions++
}
}
}
}
}
}
return additions, deletions, filesChanged, hasTests
}
// extractLoginFromEmail tries to extract GitHub login from email
func extractLoginFromEmail(email, fallbackName string) string {
// Pattern: 12345678+username@users.noreply.github.com
// or: username@users.noreply.github.com
if strings.Contains(email, "@users.noreply.github.com") {
localPart := strings.Split(email, "@")[0]
// Remove numeric prefix if present (e.g., "12345678+username")
if idx := strings.Index(localPart, "+"); idx != -1 {
return localPart[idx+1:]
}
return localPart
}
// Fallback: use sanitized name as login
login := strings.ToLower(fallbackName)
login = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(login, "-")
return login
}
// GetAuthorMappings fetches author login mappings
// This helps map commit authors to GitHub usernames
func (r *Repository) GetAuthorMappings(ctx context.Context, owner, name string) (map[string]string, error) {
repoPath := r.repoPath(owner, name)
repo, err := git.PlainOpen(repoPath)
if err != nil {
return nil, fmt.Errorf("failed to open repository: %w", err)
}
mappings := make(map[string]string)
// Iterate all commits to collect author mappings
commitIter, err := repo.Log(&git.LogOptions{All: true})
if err != nil {
return nil, fmt.Errorf("failed to get commit log: %w", err)
}
err = commitIter.ForEach(func(c *object.Commit) error {
if _, exists := mappings[c.Author.Email]; !exists {
mappings[c.Author.Email] = extractLoginFromEmail(c.Author.Email, c.Author.Name)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to iterate commits: %w", err)
}
return mappings, nil
}
// Cleanup removes the local clone of a repository
func (r *Repository) Cleanup(owner, name string) error {
repoPath := r.repoPath(owner, name)
return os.RemoveAll(repoPath)
}
// CleanupAll removes all local clones
func (r *Repository) CleanupAll() error {
return os.RemoveAll(r.baseDir)
}
+217
View File
@@ -0,0 +1,217 @@
package cache
import (
"crypto/sha256"
"encoding/gob"
"encoding/hex"
"os"
"path/filepath"
"sync"
"time"
)
// Cache defines the interface for caching
type Cache interface {
Get(key string) (interface{}, bool)
Set(key string, value interface{})
Delete(key string)
Clear() error
}
// FileCache implements file-based caching
type FileCache struct {
directory string
ttl time.Duration
mu sync.RWMutex
}
// cacheEntry wraps a cached value with expiration
type cacheEntry struct {
Value interface{}
ExpiresAt time.Time
}
// NewFileCache creates a new file-based cache
func NewFileCache(directory string, ttl time.Duration) (*FileCache, error) {
// Create directory if it doesn't exist
if err := os.MkdirAll(directory, 0750); err != nil {
return nil, err
}
return &FileCache{
directory: directory,
ttl: ttl,
}, nil
}
// Get retrieves a value from the cache
func (c *FileCache) Get(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
path := c.keyToPath(key)
file, err := os.Open(path) // #nosec G304 -- path is internally generated hash
if err != nil {
return nil, false
}
defer file.Close()
var entry cacheEntry
decoder := gob.NewDecoder(file)
if err := decoder.Decode(&entry); err != nil {
return nil, false
}
// Check expiration
if time.Now().After(entry.ExpiresAt) {
_ = os.Remove(path)
return nil, false
}
return entry.Value, true
}
// Set stores a value in the cache
func (c *FileCache) Set(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
entry := cacheEntry{
Value: value,
ExpiresAt: time.Now().Add(c.ttl),
}
path := c.keyToPath(key)
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil {
return
}
file, err := os.Create(path) // #nosec G304 -- path is internally generated hash
if err != nil {
return
}
defer file.Close()
encoder := gob.NewEncoder(file)
_ = encoder.Encode(entry)
}
// Delete removes a value from the cache
func (c *FileCache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
path := c.keyToPath(key)
_ = os.Remove(path)
}
// Clear removes all cached values
func (c *FileCache) Clear() error {
c.mu.Lock()
defer c.mu.Unlock()
return os.RemoveAll(c.directory)
}
// keyToPath converts a cache key to a file path
func (c *FileCache) keyToPath(key string) string {
hash := sha256.Sum256([]byte(key))
filename := hex.EncodeToString(hash[:8]) + ".gob"
return filepath.Join(c.directory, filename)
}
// NoopCache is a cache that doesn't cache anything
type NoopCache struct{}
// NewNoopCache creates a new no-op cache
func NewNoopCache() *NoopCache {
return &NoopCache{}
}
// Get always returns false
func (c *NoopCache) Get(key string) (interface{}, bool) {
return nil, false
}
// Set does nothing
func (c *NoopCache) Set(key string, value interface{}) {}
// Delete does nothing
func (c *NoopCache) Delete(key string) {}
// Clear does nothing
func (c *NoopCache) Clear() error {
return nil
}
// MemoryCache implements in-memory caching (useful for testing)
type MemoryCache struct {
data map[string]cacheEntry
ttl time.Duration
mu sync.RWMutex
}
// NewMemoryCache creates a new in-memory cache
func NewMemoryCache(ttl time.Duration) *MemoryCache {
return &MemoryCache{
data: make(map[string]cacheEntry),
ttl: ttl,
}
}
// Get retrieves a value from the cache
func (c *MemoryCache) Get(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.data[key]
if !ok {
return nil, false
}
// Check expiration
if time.Now().After(entry.ExpiresAt) {
delete(c.data, key)
return nil, false
}
return entry.Value, true
}
// Set stores a value in the cache
func (c *MemoryCache) Set(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = cacheEntry{
Value: value,
ExpiresAt: time.Now().Add(c.ttl),
}
}
// Delete removes a value from the cache
func (c *MemoryCache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.data, key)
}
// Clear removes all cached values
func (c *MemoryCache) Clear() error {
c.mu.Lock()
defer c.mu.Unlock()
c.data = make(map[string]cacheEntry)
return nil
}
// Register types for gob encoding
func init() {
// Register common types that might be cached
gob.Register([]interface{}{})
gob.Register(map[string]interface{}{})
}
+290
View File
@@ -0,0 +1,290 @@
package cache
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFileCache_Basic(t *testing.T) {
// Create temp directory for cache
tempDir := t.TempDir()
cache, err := NewFileCache(tempDir, time.Hour)
require.NoError(t, err)
// Test Set and Get
cache.Set("test-key", "test-value")
value, ok := cache.Get("test-key")
assert.True(t, ok)
assert.Equal(t, "test-value", value)
}
func TestFileCache_GetNonExistent(t *testing.T) {
tempDir := t.TempDir()
cache, err := NewFileCache(tempDir, time.Hour)
require.NoError(t, err)
value, ok := cache.Get("non-existent")
assert.False(t, ok)
assert.Nil(t, value)
}
func TestFileCache_Expiration(t *testing.T) {
tempDir := t.TempDir()
// Use a very short TTL
cache, err := NewFileCache(tempDir, 50*time.Millisecond)
require.NoError(t, err)
cache.Set("expire-key", "expire-value")
// Should be available immediately
value, ok := cache.Get("expire-key")
assert.True(t, ok)
assert.Equal(t, "expire-value", value)
// Wait for expiration
time.Sleep(100 * time.Millisecond)
// Should be expired now
value, ok = cache.Get("expire-key")
assert.False(t, ok)
assert.Nil(t, value)
}
func TestFileCache_Delete(t *testing.T) {
tempDir := t.TempDir()
cache, err := NewFileCache(tempDir, time.Hour)
require.NoError(t, err)
cache.Set("delete-key", "delete-value")
// Verify it exists
_, ok := cache.Get("delete-key")
assert.True(t, ok)
// Delete it
cache.Delete("delete-key")
// Should be gone
value, ok := cache.Get("delete-key")
assert.False(t, ok)
assert.Nil(t, value)
}
func TestFileCache_Clear(t *testing.T) {
tempDir := t.TempDir()
cache, err := NewFileCache(tempDir, time.Hour)
require.NoError(t, err)
// Add multiple entries
cache.Set("key1", "value1")
cache.Set("key2", "value2")
cache.Set("key3", "value3")
// Clear the cache
err = cache.Clear()
require.NoError(t, err)
// All should be gone
_, ok := cache.Get("key1")
assert.False(t, ok)
_, ok = cache.Get("key2")
assert.False(t, ok)
_, ok = cache.Get("key3")
assert.False(t, ok)
}
func TestFileCache_ComplexValues(t *testing.T) {
tempDir := t.TempDir()
cache, err := NewFileCache(tempDir, time.Hour)
require.NoError(t, err)
// Test with map
mapValue := map[string]interface{}{
"key1": "value1",
"key2": 123,
}
cache.Set("map-key", mapValue)
retrieved, ok := cache.Get("map-key")
assert.True(t, ok)
assert.Equal(t, mapValue, retrieved)
// Test with slice
sliceValue := []interface{}{"a", "b", "c"}
cache.Set("slice-key", sliceValue)
retrieved, ok = cache.Get("slice-key")
assert.True(t, ok)
assert.Equal(t, sliceValue, retrieved)
}
func TestFileCache_CreateDirectory(t *testing.T) {
// Test that NewFileCache creates directory if it doesn't exist
tempDir := filepath.Join(t.TempDir(), "nested", "cache", "dir")
cache, err := NewFileCache(tempDir, time.Hour)
require.NoError(t, err)
// Verify directory was created
info, err := os.Stat(tempDir)
require.NoError(t, err)
assert.True(t, info.IsDir())
// Should be usable
cache.Set("key", "value")
value, ok := cache.Get("key")
assert.True(t, ok)
assert.Equal(t, "value", value)
}
func TestMemoryCache_Basic(t *testing.T) {
t.Parallel()
cache := NewMemoryCache(time.Hour)
// Test Set and Get
cache.Set("test-key", "test-value")
value, ok := cache.Get("test-key")
assert.True(t, ok)
assert.Equal(t, "test-value", value)
}
func TestMemoryCache_GetNonExistent(t *testing.T) {
t.Parallel()
cache := NewMemoryCache(time.Hour)
value, ok := cache.Get("non-existent")
assert.False(t, ok)
assert.Nil(t, value)
}
func TestMemoryCache_Expiration(t *testing.T) {
t.Parallel()
cache := NewMemoryCache(50 * time.Millisecond)
cache.Set("expire-key", "expire-value")
// Should be available immediately
value, ok := cache.Get("expire-key")
assert.True(t, ok)
assert.Equal(t, "expire-value", value)
// Wait for expiration
time.Sleep(100 * time.Millisecond)
// Should be expired now
value, ok = cache.Get("expire-key")
assert.False(t, ok)
assert.Nil(t, value)
}
func TestMemoryCache_Delete(t *testing.T) {
t.Parallel()
cache := NewMemoryCache(time.Hour)
cache.Set("delete-key", "delete-value")
// Verify it exists
_, ok := cache.Get("delete-key")
assert.True(t, ok)
// Delete it
cache.Delete("delete-key")
// Should be gone
value, ok := cache.Get("delete-key")
assert.False(t, ok)
assert.Nil(t, value)
}
func TestMemoryCache_Clear(t *testing.T) {
t.Parallel()
cache := NewMemoryCache(time.Hour)
// Add multiple entries
cache.Set("key1", "value1")
cache.Set("key2", "value2")
cache.Set("key3", "value3")
// Clear the cache
err := cache.Clear()
require.NoError(t, err)
// All should be gone
_, ok := cache.Get("key1")
assert.False(t, ok)
_, ok = cache.Get("key2")
assert.False(t, ok)
_, ok = cache.Get("key3")
assert.False(t, ok)
}
func TestNoopCache_AlwaysReturnsFalse(t *testing.T) {
t.Parallel()
cache := NewNoopCache()
// Set something
cache.Set("key", "value")
// Get should return false
value, ok := cache.Get("key")
assert.False(t, ok)
assert.Nil(t, value)
}
func TestNoopCache_DeleteAndClear(t *testing.T) {
t.Parallel()
cache := NewNoopCache()
// These should not panic or error
cache.Delete("key")
err := cache.Clear()
assert.NoError(t, err)
}
func TestFileCache_KeyToPath(t *testing.T) {
t.Parallel()
cache := &FileCache{directory: "/tmp/cache"}
path1 := cache.keyToPath("key1")
path2 := cache.keyToPath("key2")
path1Again := cache.keyToPath("key1")
// Different keys should produce different paths
assert.NotEqual(t, path1, path2)
// Same key should produce same path
assert.Equal(t, path1, path1Again)
// Path should end with .gob
assert.Contains(t, path1, ".gob")
}
func TestCacheInterface(t *testing.T) {
t.Parallel()
// Ensure all cache types implement the interface
var _ Cache = (*FileCache)(nil)
var _ Cache = (*MemoryCache)(nil)
var _ Cache = (*NoopCache)(nil)
}
+928
View File
@@ -0,0 +1,928 @@
package github
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/bradleyfalzon/ghinstallation/v2"
"github.com/google/go-github/v68/github"
"github.com/lukaszraczylo/git-velocity/internal/config"
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
"github.com/lukaszraczylo/git-velocity/internal/github/cache"
)
// ProgressCallback is called to report progress during API operations
type ProgressCallback func(message string)
// RetryConfig holds retry settings
type RetryConfig struct {
MaxRetries int
InitialBackoff time.Duration
MaxBackoff time.Duration
}
// DefaultRetryConfig returns the default retry configuration
func DefaultRetryConfig() RetryConfig {
return RetryConfig{
MaxRetries: 3,
InitialBackoff: 1 * time.Second,
MaxBackoff: 30 * time.Second,
}
}
// Client wraps the GitHub API client with rate limiting and caching
type Client struct {
gh *github.Client
config *config.Config
cache cache.Cache
retry RetryConfig
progress ProgressCallback
}
// NewClient creates a new GitHub client with the appropriate authentication
func NewClient(ctx context.Context, cfg *config.Config) (*Client, error) {
var gh *github.Client
// Determine authentication method
if cfg.HasGithubToken() {
gh = github.NewClient(nil).WithAuthToken(cfg.Auth.GithubToken)
} else if cfg.HasGithubApp() {
// GitHub App authentication
privateKey, err := cfg.GetGithubAppPrivateKey()
if err != nil {
return nil, fmt.Errorf("failed to get GitHub App private key: %w", err)
}
itr, err := ghinstallation.New(
http.DefaultTransport,
cfg.Auth.GithubApp.AppID,
cfg.Auth.GithubApp.InstallationID,
privateKey,
)
if err != nil {
return nil, fmt.Errorf("failed to create GitHub App transport: %w", err)
}
gh = github.NewClient(&http.Client{Transport: itr})
} else {
return nil, fmt.Errorf("no authentication method configured")
}
// Initialize cache
var c cache.Cache
if cfg.Cache.Enabled {
ttl, err := cfg.GetCacheTTL()
if err != nil {
return nil, fmt.Errorf("failed to parse cache TTL: %w", err)
}
c, err = cache.NewFileCache(cfg.Cache.Directory, ttl)
if err != nil {
return nil, fmt.Errorf("failed to initialize cache: %w", err)
}
} else {
c = cache.NewNoopCache()
}
return &Client{
gh: gh,
config: cfg,
cache: c,
retry: DefaultRetryConfig(),
progress: func(string) {}, // no-op by default
}, nil
}
// SetProgressCallback sets the callback function for progress reporting
func (c *Client) SetProgressCallback(cb ProgressCallback) {
if cb != nil {
c.progress = cb
}
}
// SetRetryConfig sets the retry configuration
func (c *Client) SetRetryConfig(rc RetryConfig) {
c.retry = rc
}
// retryWithBackoff executes a function with retry logic
// - For rate limit errors: waits until the limit resets (no retry count limit)
// - For network/transient errors: uses exponential backoff with MaxRetries limit
func (c *Client) retryWithBackoff(ctx context.Context, operation string, fn func() error) error {
var lastErr error
backoff := c.retry.InitialBackoff
networkRetries := 0
for {
lastErr = fn()
if lastErr == nil {
return nil
}
// Check if error is retryable at all
if !isRetryableError(lastErr) {
return lastErr
}
c.progress(fmt.Sprintf(" %s failed: %v", operation, lastErr))
// Determine wait strategy based on error type
if resetTime := getRateLimitResetTime(lastErr); resetTime != nil {
// Rate limit error - wait until reset, no retry count limit
waitDuration := time.Until(*resetTime) + time.Second // Add 1s buffer
if waitDuration < 0 {
waitDuration = time.Second
}
c.progress(fmt.Sprintf(" Rate limit hit. Waiting until %s (%s)...", resetTime.Format("15:04:05"), waitDuration.Round(time.Second)))
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(waitDuration):
}
// Reset network retry counter after successful rate limit wait
networkRetries = 0
backoff = c.retry.InitialBackoff
} else {
// Network/transient error - use exponential backoff with retry limit
networkRetries++
if networkRetries > c.retry.MaxRetries {
return fmt.Errorf("%s failed after %d retries: %w", operation, c.retry.MaxRetries, lastErr)
}
c.progress(fmt.Sprintf(" Retry %d/%d for %s (waiting %s)...", networkRetries, c.retry.MaxRetries, operation, backoff))
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
}
backoff *= 2
if backoff > c.retry.MaxBackoff {
backoff = c.retry.MaxBackoff
}
}
}
}
// getRateLimitResetTime extracts the reset time from rate limit errors
func getRateLimitResetTime(err error) *time.Time {
if err == nil {
return nil
}
var rateLimitErr *github.RateLimitError
if errors.As(err, &rateLimitErr) && rateLimitErr.Rate.Reset.Time.After(time.Now()) {
t := rateLimitErr.Rate.Reset.Time
return &t
}
var abuseErr *github.AbuseRateLimitError
if errors.As(err, &abuseErr) && abuseErr.RetryAfter != nil {
t := time.Now().Add(*abuseErr.RetryAfter)
return &t
}
return nil
}
// isRetryableError checks if an error is retryable
func isRetryableError(err error) bool {
if err == nil {
return false
}
// Network errors (timeout only - Temporary() is deprecated)
var netErr net.Error
if errors.As(err, &netErr) {
return netErr.Timeout()
}
// GitHub rate limit errors
var rateLimitErr *github.RateLimitError
if errors.As(err, &rateLimitErr) {
return true
}
// GitHub abuse rate limit errors
var abuseErr *github.AbuseRateLimitError
if errors.As(err, &abuseErr) {
return true
}
// Check error message for common transient errors
errStr := err.Error()
retryableMessages := []string{
"connection reset",
"connection refused",
"timeout",
"temporary failure",
"server error",
"502",
"503",
"504",
}
for _, msg := range retryableMessages {
if strings.Contains(strings.ToLower(errStr), msg) {
return true
}
}
return false
}
// ListOrgRepos lists repositories in an organization matching a pattern
func (c *Client) ListOrgRepos(ctx context.Context, org, pattern string) ([]string, error) {
var allRepos []string
opts := &github.RepositoryListByOrgOptions{
Type: "all",
ListOptions: github.ListOptions{
PerPage: 100,
},
}
for {
repos, resp, err := c.gh.Repositories.ListByOrg(ctx, org, opts)
if err != nil {
return nil, fmt.Errorf("failed to list org repos: %w", err)
}
for _, repo := range repos {
name := repo.GetName()
if matchPattern(name, pattern) {
allRepos = append(allRepos, name)
}
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
return allRepos, nil
}
// FetchCommits fetches commits from a repository within a date range
func (c *Client) FetchCommits(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.Commit, error) {
cacheKey := fmt.Sprintf("commits:%s/%s:%v:%v", owner, repo, since, until)
// Check cache
if cached, ok := c.cache.Get(cacheKey); ok {
if commits, ok := cached.([]models.Commit); ok {
c.progress(" Using cached commits data")
return commits, nil
}
}
var allCommits []models.Commit
opts := &github.CommitsListOptions{
ListOptions: github.ListOptions{PerPage: 100},
}
if since != nil {
opts.Since = *since
}
if until != nil {
opts.Until = *until
}
page := 1
for {
var commits []*github.RepositoryCommit
var resp *github.Response
err := c.retryWithBackoff(ctx, "list commits", func() error {
var err error
commits, resp, err = c.gh.Repositories.ListCommits(ctx, owner, repo, opts)
return err
})
if err != nil {
return nil, fmt.Errorf("failed to list commits: %w", err)
}
c.progress(fmt.Sprintf(" Fetching commits page %d (%d commits so far)...", page, len(allCommits)))
for i, commit := range commits {
// Fetch detailed commit info for stats
var detailed *github.RepositoryCommit
err := c.retryWithBackoff(ctx, fmt.Sprintf("get commit %s", commit.GetSHA()[:7]), func() error {
var err error
detailed, _, err = c.gh.Repositories.GetCommit(ctx, owner, repo, commit.GetSHA(), nil)
return err
})
if err != nil {
// Log and continue - we can still use basic info
c.progress(fmt.Sprintf(" Warning: failed to get commit details for %s: %v", commit.GetSHA()[:7], err))
continue
}
mc := convertCommit(detailed, owner, repo)
allCommits = append(allCommits, mc)
// Progress every 10 commits
if (i+1)%10 == 0 {
c.progress(fmt.Sprintf(" Processing commit %d/%d on page %d...", i+1, len(commits), page))
}
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
page++
}
// Cache results
c.cache.Set(cacheKey, allCommits)
return allCommits, nil
}
// mainBranches are the branches we consider as "main" branches
var mainBranches = []string{"main", "master", "develop", "dev"}
// FetchPullRequests fetches pull requests from a repository
// Fetches PRs targeting main branches, filters by merge date
func (c *Client) FetchPullRequests(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.PullRequest, error) {
cacheKey := fmt.Sprintf("prs:%s/%s:%v:%v", owner, repo, since, until)
// Check cache
if cached, ok := c.cache.Get(cacheKey); ok {
if prs, ok := cached.([]models.PullRequest); ok {
c.progress(" Using cached pull requests data")
return prs, nil
}
}
var allPRs []models.PullRequest
// Fetch PRs for each main branch separately (API supports base filter)
for _, baseBranch := range mainBranches {
prs, err := c.fetchPRsForBranch(ctx, owner, repo, baseBranch, since, until)
if err != nil {
// Branch might not exist, skip
continue
}
allPRs = append(allPRs, prs...)
}
c.progress(fmt.Sprintf(" Found %d merged PRs to main branches in date range", len(allPRs)))
// Cache results
c.cache.Set(cacheKey, allPRs)
return allPRs, nil
}
// fetchPRsForBranch fetches merged PRs for a specific base branch
func (c *Client) fetchPRsForBranch(ctx context.Context, owner, repo, baseBranch string, since, until *time.Time) ([]models.PullRequest, error) {
var branchPRs []models.PullRequest
opts := &github.PullRequestListOptions{
State: "closed",
Base: baseBranch, // Filter by base branch at API level
Sort: "updated",
Direction: "desc",
ListOptions: github.ListOptions{
PerPage: 100,
},
}
page := 1
consecutiveOldPages := 0
for {
var prs []*github.PullRequest
var resp *github.Response
err := c.retryWithBackoff(ctx, "list pull requests", func() error {
var err error
prs, resp, err = c.gh.PullRequests.List(ctx, owner, repo, opts)
return err
})
if err != nil {
return branchPRs, err
}
if page == 1 && len(prs) > 0 {
c.progress(fmt.Sprintf(" Fetching PRs for branch '%s'...", baseBranch))
}
matchedInPage := 0
oldInPage := 0
for _, pr := range prs {
// Only consider merged PRs (check MergedAt since Merged field isn't in list response)
if pr.MergedAt == nil {
continue
}
// Use merge date for filtering
mergedAt := pr.MergedAt.Time
// Skip items newer than our range
if until != nil && mergedAt.After(*until) {
continue
}
// If older than our range, track it
if since != nil && mergedAt.Before(*since) {
oldInPage++
continue
}
mpr := convertPullRequest(pr, owner, repo)
branchPRs = append(branchPRs, mpr)
matchedInPage++
}
// Early termination: if we got a page with only old PRs (or empty), increment counter
if matchedInPage == 0 && oldInPage > 0 {
consecutiveOldPages++
// Stop after 2 consecutive pages of only old PRs
if consecutiveOldPages >= 2 {
break
}
} else {
consecutiveOldPages = 0
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
page++
}
return branchPRs, nil
}
// FetchReviews fetches reviews for a specific pull request
func (c *Client) FetchReviews(ctx context.Context, owner, repo string, prNumber int) ([]models.Review, error) {
cacheKey := fmt.Sprintf("reviews:%s/%s:%d", owner, repo, prNumber)
// Check cache
if cached, ok := c.cache.Get(cacheKey); ok {
if reviews, ok := cached.([]models.Review); ok {
return reviews, nil
}
}
var allReviews []models.Review
opts := &github.ListOptions{PerPage: 100}
for {
var reviews []*github.PullRequestReview
var resp *github.Response
err := c.retryWithBackoff(ctx, fmt.Sprintf("list reviews for PR #%d", prNumber), func() error {
var err error
reviews, resp, err = c.gh.PullRequests.ListReviews(ctx, owner, repo, prNumber, opts)
return err
})
if err != nil {
return nil, fmt.Errorf("failed to list reviews: %w", err)
}
for _, review := range reviews {
mr := convertReview(review, owner, repo, prNumber)
allReviews = append(allReviews, mr)
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
// Cache results
c.cache.Set(cacheKey, allReviews)
return allReviews, nil
}
// FetchIssues fetches issues from a repository
// Uses early termination when sorted by date - stops when items are outside date range
func (c *Client) FetchIssues(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.Issue, error) {
cacheKey := fmt.Sprintf("issues:%s/%s:%v:%v", owner, repo, since, until)
// Check cache
if cached, ok := c.cache.Get(cacheKey); ok {
if issues, ok := cached.([]models.Issue); ok {
c.progress(" Using cached issues data")
return issues, nil
}
}
var allIssues []models.Issue
// Sort by created date descending - newest first
// This allows us to stop early when we hit items older than our date range
opts := &github.IssueListByRepoOptions{
State: "all",
Sort: "created",
Direction: "desc",
ListOptions: github.ListOptions{
PerPage: 100,
},
}
// Note: GitHub Issues API has a 'since' parameter but it filters by update time, not created time
// So we use our own filtering with early termination for better control
page := 1
reachedOldItems := false
for {
var issues []*github.Issue
var resp *github.Response
err := c.retryWithBackoff(ctx, "list issues", func() error {
var err error
issues, resp, err = c.gh.Issues.ListByRepo(ctx, owner, repo, opts)
return err
})
if err != nil {
return nil, fmt.Errorf("failed to list issues: %w", err)
}
c.progress(fmt.Sprintf(" Fetching issues page %d (%d issues so far)...", page, len(allIssues)))
oldItemsInPage := 0
totalNonPRItems := 0
for _, issue := range issues {
// Skip pull requests (they appear in issues API)
if issue.PullRequestLinks != nil {
continue
}
totalNonPRItems++
createdAt := issue.GetCreatedAt().Time
// Skip items newer than our range (when until is specified)
if until != nil && createdAt.After(*until) {
continue
}
// If we've gone past our date range (older than since), count it
if since != nil && createdAt.Before(*since) {
oldItemsInPage++
continue
}
mi := convertIssue(issue, owner, repo)
allIssues = append(allIssues, mi)
}
// If all non-PR items in this page are older than our range, we can stop
// (since results are sorted by created date descending)
if oldItemsInPage == totalNonPRItems && totalNonPRItems > 0 {
c.progress(fmt.Sprintf(" Reached issues older than date range, stopping early (page %d)", page))
reachedOldItems = true
break
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
page++
}
if !reachedOldItems && page > 1 {
c.progress(fmt.Sprintf(" Fetched all %d pages of issues", page))
}
// Cache results
c.cache.Set(cacheKey, allIssues)
return allIssues, nil
}
// UserProfile contains GitHub user profile information useful for deduplication
type UserProfile struct {
ID int64 // GitHub user ID
Login string // GitHub username
Name string // Display name
Email string // Public email (may be empty)
AvatarURL string
}
// FetchUserProfiles fetches GitHub profiles for a list of logins
// This is useful for deduplication by getting user IDs, names, and public emails
func (c *Client) FetchUserProfiles(ctx context.Context, logins []string) (map[string]UserProfile, error) {
profiles := make(map[string]UserProfile)
// Use semaphore to limit concurrent requests
sem := make(chan struct{}, 10)
results := make(chan struct {
login string
profile UserProfile
err error
}, len(logins))
for _, login := range logins {
go func(login string) {
sem <- struct{}{}
defer func() { <-sem }()
cacheKey := fmt.Sprintf("user_profile_%s", login)
if cached, ok := c.cache.Get(cacheKey); ok {
if profile, ok := cached.(UserProfile); ok {
results <- struct {
login string
profile UserProfile
err error
}{login, profile, nil}
return
}
}
var profile UserProfile
err := c.retryWithBackoff(ctx, "fetch user profile", func() error {
user, _, err := c.gh.Users.Get(ctx, login)
if err != nil {
return err
}
profile = UserProfile{
ID: user.GetID(),
Login: user.GetLogin(),
Name: user.GetName(),
Email: user.GetEmail(),
AvatarURL: user.GetAvatarURL(),
}
return nil
})
if err == nil {
c.cache.Set(cacheKey, profile)
}
results <- struct {
login string
profile UserProfile
err error
}{login, profile, err}
}(login)
}
// Collect results
for range logins {
r := <-results
if r.err == nil {
profiles[r.login] = r.profile
}
}
return profiles, nil
}
// Helper functions
func convertCommit(c *github.RepositoryCommit, owner, repo string) models.Commit {
var author models.Author
if c.Author != nil {
author = models.Author{
Login: c.Author.GetLogin(),
AvatarURL: c.Author.GetAvatarURL(),
}
}
if c.Commit != nil && c.Commit.Author != nil {
author.Name = c.Commit.Author.GetName()
author.Email = c.Commit.Author.GetEmail()
}
var committer models.Author
if c.Committer != nil {
committer = models.Author{
Login: c.Committer.GetLogin(),
AvatarURL: c.Committer.GetAvatarURL(),
}
}
if c.Commit != nil && c.Commit.Committer != nil {
committer.Name = c.Commit.Committer.GetName()
committer.Email = c.Commit.Committer.GetEmail()
}
var date time.Time
if c.Commit != nil && c.Commit.Author != nil {
date = c.Commit.Author.GetDate().Time
}
var additions, deletions, filesChanged int
if c.Stats != nil {
additions = c.Stats.GetAdditions()
deletions = c.Stats.GetDeletions()
}
filesChanged = len(c.Files)
// Detect if commit includes tests
hasTests := false
for _, f := range c.Files {
filename := f.GetFilename()
if strings.Contains(filename, "_test.go") ||
strings.Contains(filename, ".test.") ||
strings.Contains(filename, ".spec.") ||
strings.Contains(filename, "/tests/") ||
strings.Contains(filename, "/test/") ||
strings.Contains(filename, "__tests__") {
hasTests = true
break
}
}
message := ""
if c.Commit != nil {
message = c.Commit.GetMessage()
}
return models.Commit{
SHA: c.GetSHA(),
Message: message,
Author: author,
Committer: committer,
Date: date,
Additions: additions,
Deletions: deletions,
FilesChanged: filesChanged,
Repository: fmt.Sprintf("%s/%s", owner, repo),
URL: c.GetHTMLURL(),
HasTests: hasTests,
}
}
func convertPullRequest(pr *github.PullRequest, owner, repo string) models.PullRequest {
var author models.Author
if pr.User != nil {
author = models.Author{
ID: pr.User.GetID(),
Login: pr.User.GetLogin(),
Name: pr.User.GetName(),
AvatarURL: pr.User.GetAvatarURL(),
}
}
state := models.PRStateOpen
if pr.GetMerged() {
state = models.PRStateMerged
} else if pr.GetState() == "closed" {
state = models.PRStateClosed
}
var mergedAt, closedAt *time.Time
if pr.MergedAt != nil {
t := pr.MergedAt.Time
mergedAt = &t
}
if pr.ClosedAt != nil {
t := pr.ClosedAt.Time
closedAt = &t
}
var baseBranch, headBranch string
if pr.Base != nil {
baseBranch = pr.Base.GetRef()
}
if pr.Head != nil {
headBranch = pr.Head.GetRef()
}
return models.PullRequest{
Number: pr.GetNumber(),
Title: pr.GetTitle(),
State: state,
Author: author,
Repository: fmt.Sprintf("%s/%s", owner, repo),
BaseBranch: baseBranch,
HeadBranch: headBranch,
CreatedAt: pr.GetCreatedAt().Time,
UpdatedAt: pr.GetUpdatedAt().Time,
MergedAt: mergedAt,
ClosedAt: closedAt,
Additions: pr.GetAdditions(),
Deletions: pr.GetDeletions(),
FilesChanged: pr.GetChangedFiles(),
CommitCount: pr.GetCommits(),
Comments: pr.GetComments() + pr.GetReviewComments(),
URL: pr.GetHTMLURL(),
}
}
func convertReview(r *github.PullRequestReview, owner, repo string, prNumber int) models.Review {
var author models.Author
if r.User != nil {
author = models.Author{
ID: r.User.GetID(),
Login: r.User.GetLogin(),
Name: r.User.GetName(),
AvatarURL: r.User.GetAvatarURL(),
}
}
state := models.ReviewState(r.GetState())
submittedAt := time.Time{}
if r.SubmittedAt != nil {
submittedAt = r.SubmittedAt.Time
}
return models.Review{
ID: r.GetID(),
PullRequest: prNumber,
Repository: fmt.Sprintf("%s/%s", owner, repo),
Author: author,
State: state,
SubmittedAt: submittedAt,
Body: r.GetBody(),
}
}
func convertIssue(i *github.Issue, owner, repo string) models.Issue {
var author models.Author
if i.User != nil {
author = models.Author{
Login: i.User.GetLogin(),
Name: i.User.GetName(),
AvatarURL: i.User.GetAvatarURL(),
}
}
state := models.IssueStateOpen
if i.GetState() == "closed" {
state = models.IssueStateClosed
}
var closedAt *time.Time
var closedBy *models.Author
if i.ClosedAt != nil {
t := i.ClosedAt.Time
closedAt = &t
}
if i.ClosedBy != nil {
cb := models.Author{
Login: i.ClosedBy.GetLogin(),
AvatarURL: i.ClosedBy.GetAvatarURL(),
}
closedBy = &cb
}
var labels []string
for _, l := range i.Labels {
labels = append(labels, l.GetName())
}
return models.Issue{
Number: i.GetNumber(),
Title: i.GetTitle(),
State: state,
Author: author,
Repository: fmt.Sprintf("%s/%s", owner, repo),
CreatedAt: i.GetCreatedAt().Time,
UpdatedAt: i.GetUpdatedAt().Time,
ClosedAt: closedAt,
ClosedBy: closedBy,
Comments: i.GetComments(),
Labels: labels,
URL: i.GetHTMLURL(),
}
}
// matchPattern performs simple glob-style pattern matching
func matchPattern(s, pattern string) bool {
if pattern == "*" {
return true
}
// 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
}
+77
View File
@@ -0,0 +1,77 @@
package server
import (
"fmt"
"net/http"
"os"
"path/filepath"
"time"
)
// Server is a simple HTTP server for previewing the generated site
type Server struct {
directory string
port string
}
// New creates a new preview server
func New(directory, port string) *Server {
return &Server{
directory: directory,
port: port,
}
}
// Start starts the HTTP server
func (s *Server) Start() error {
// Check if directory exists
if _, err := os.Stat(s.directory); os.IsNotExist(err) {
return fmt.Errorf("directory does not exist: %s", s.directory)
}
// Get absolute path
absPath, err := filepath.Abs(s.directory)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
// Create file server with directory listing disabled for security
fs := http.FileServer(http.Dir(absPath))
// Wrap with middleware
handler := s.loggingMiddleware(s.cacheMiddleware(fs))
addr := fmt.Sprintf(":%s", s.port)
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: 15 * time.Second,
ReadHeaderTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
return srv.ListenAndServe()
}
// loggingMiddleware logs incoming requests
func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("%s %s\n", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
// cacheMiddleware adds cache headers for static assets
func (s *Server) cacheMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Disable caching for development
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
// Add CORS headers for local development
w.Header().Set("Access-Control-Allow-Origin", "*")
next.ServeHTTP(w, r)
})
}
+209
View File
@@ -0,0 +1,209 @@
package server
import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNew(t *testing.T) {
t.Parallel()
s := New("/tmp/test", "8080")
assert.Equal(t, "/tmp/test", s.directory)
assert.Equal(t, "8080", s.port)
}
func TestServer_StartWithNonExistentDirectory(t *testing.T) {
t.Parallel()
s := New("/this/directory/does/not/exist", "8080")
err := s.Start()
assert.Error(t, err)
assert.Contains(t, err.Error(), "directory does not exist")
}
func TestServer_CacheMiddleware(t *testing.T) {
t.Parallel()
s := New(".", "8080")
// Create a test handler
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
// Wrap with cache middleware
wrapped := s.cacheMiddleware(handler)
// Make a test request
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
wrapped.ServeHTTP(rr, req)
// Check cache headers are set correctly
assert.Equal(t, "no-cache, no-store, must-revalidate", rr.Header().Get("Cache-Control"))
assert.Equal(t, "no-cache", rr.Header().Get("Pragma"))
assert.Equal(t, "0", rr.Header().Get("Expires"))
assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin"))
}
func TestServer_LoggingMiddleware(t *testing.T) {
t.Parallel()
s := New(".", "8080")
// Create a test handler
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Wrap with logging middleware
wrapped := s.loggingMiddleware(handler)
// Make a test request
req := httptest.NewRequest("GET", "/test-path", nil)
rr := httptest.NewRecorder()
// This should not panic
wrapped.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
}
func TestServer_ServesStaticFiles(t *testing.T) {
// Create a temp directory with a test file
tempDir := t.TempDir()
// Create a test file with a simple name
testFile := filepath.Join(tempDir, "hello.txt")
err := os.WriteFile(testFile, []byte("Hello, World!"), 0644)
require.NoError(t, err)
s := New(tempDir, "0")
// Use http.StripPrefix with the file server to avoid redirect issues
absPath, _ := filepath.Abs(tempDir)
fs := http.FileServer(http.Dir(absPath))
// Create test server
ts := httptest.NewServer(fs)
defer ts.Close()
// Test serving the text file via HTTP
resp, err := http.Get(ts.URL + "/hello.txt")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, "Hello, World!", string(body))
// Verify the server object is set up correctly
assert.Equal(t, tempDir, s.directory)
}
func TestServer_404ForNonExistentFile(t *testing.T) {
tempDir := t.TempDir()
absPath, _ := filepath.Abs(tempDir)
fs := http.FileServer(http.Dir(absPath))
ts := httptest.NewServer(fs)
defer ts.Close()
resp, err := http.Get(ts.URL + "/nonexistent.txt")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestServer_ServesNestedDirectories(t *testing.T) {
tempDir := t.TempDir()
// Create nested directory structure
nestedDir := filepath.Join(tempDir, "data", "repos")
err := os.MkdirAll(nestedDir, 0755)
require.NoError(t, err)
// Create a file in nested directory
testFile := filepath.Join(nestedDir, "metrics.json")
err = os.WriteFile(testFile, []byte(`{"count": 42}`), 0644)
require.NoError(t, err)
absPath, _ := filepath.Abs(tempDir)
fs := http.FileServer(http.Dir(absPath))
ts := httptest.NewServer(fs)
defer ts.Close()
resp, err := http.Get(ts.URL + "/data/repos/metrics.json")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "42")
}
func TestServer_MiddlewareCombination(t *testing.T) {
t.Parallel()
s := New(".", "8080")
innerHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("response"))
})
// Combine middlewares like in the actual server
combined := s.loggingMiddleware(s.cacheMiddleware(innerHandler))
req := httptest.NewRequest("GET", "/any-path", nil)
rr := httptest.NewRecorder()
combined.ServeHTTP(rr, req)
// Check response
assert.Equal(t, http.StatusOK, rr.Code)
body, _ := io.ReadAll(rr.Body)
assert.Equal(t, "response", string(body))
// Check headers were set by cache middleware
assert.NotEmpty(t, rr.Header().Get("Cache-Control"))
}
func TestServer_ServesIndexHtml(t *testing.T) {
tempDir := t.TempDir()
// Create an index.html
indexFile := filepath.Join(tempDir, "index.html")
err := os.WriteFile(indexFile, []byte("<html><body>Test Page</body></html>"), 0644)
require.NoError(t, err)
absPath, _ := filepath.Abs(tempDir)
fs := http.FileServer(http.Dir(absPath))
ts := httptest.NewServer(fs)
defer ts.Close()
// Test serving index.html via root path
resp, err := http.Get(ts.URL + "/")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "Test Page")
}