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