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