Files
git-velocity/internal/domain/scoring/calculator.go
T
lukaszraczylo 7ba4d438dd improvements jan2025 (#9)
* feat(scoring): add tests bonus and fix average calculations

- [x] Add CommitsWithTests metric to track commits with test file changes
- [x] Add TestsBonus to score breakdown (15 points per commit with tests)
- [x] Fix AvgTimeToMerge calculation to use count of PRs with valid data
- [x] Fix AvgReviewTime calculation to use count of reviews with valid data
- [x] Fix AvgPRSize calculation to only include merged PRs
- [x] Add trackActivityDay helper to deduplicate activity tracking code
- [x] Track activity days for PR creation, reviews, and issue comments
- [x] Separate issue close tracking from issue open tracking
- [x] Update early bird window from 5am-9am to 6am-9am
- [x] Add time-based multipliers to velocity timeline scoring
- [x] Update GraphQL query to fetch OPEN, MERGED, CLOSED PRs
- [x] Fix PR filtering logic to handle all PR states correctly
- [x] Improve watch handlers in Vue components to prevent double-loading
- [x] Fix formatDuration to handle zero and negative values
- [x] Update scoring documentation to include Tests component

* refactor: use standard library and consolidate constants

- [x] Replace custom contains function with slices.Contains
- [x] Remove duplicate contains function implementations
- [x] Extract magic numbers to named constants in formatters
- [x] Create constants composable for app-wide values
- [x] Add ESLint configuration with browser globals
- [x] Add lint npm scripts to package.json
- [x] Reorder Vue template attributes for consistency
- [x] Remove unused variable in AchievementProgress
- [x] Add pnpm lock file
2026-01-13 11:39:35 +00:00

434 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package scoring
import (
"slices"
"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
}
// Build contributor map for scoring
// IMPORTANT: Prefer metrics.Contributors if populated (from aggregator) since it contains
// properly calculated values that can't be reconstructed from per-repo data:
// - Weighted average times (AvgReviewTime, AvgTimeToMerge)
// - Cross-repo streaks (ActiveDays, LongestStreak, WorkWeekStreak)
// - Max values (LargestPRSize)
// - Deduplicated counts (UniqueReviewees, FilesChanged)
// - Summed counts (SmallPRCount, PerfectPRs)
// Fall back to aggregating from repos only for tests that don't use the full pipeline.
contributorMap := make(map[string]*models.ContributorMetrics)
if len(metrics.Contributors) > 0 {
// Use already-aggregated global contributors (production path)
for i := range metrics.Contributors {
login := metrics.Contributors[i].Login
cm := metrics.Contributors[i]
contributorMap[login] = &cm
}
} else {
// Fallback: aggregate from per-repo contributors (test compatibility path)
// Note: This path cannot properly aggregate computed fields like AvgReviewTime,
// LongestStreak, etc. - it only sums count-based metrics.
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.CommitsWithTests += cm.CommitsWithTests
existing.LinesAdded += cm.LinesAdded
existing.LinesDeleted += cm.LinesDeleted
existing.MeaningfulLinesAdded += cm.MeaningfulLinesAdded
existing.MeaningfulLinesDeleted += cm.MeaningfulLinesDeleted
existing.CommentLinesAdded += cm.CommentLinesAdded
existing.CommentLinesDeleted += cm.CommentLinesDeleted
existing.PRsOpened += cm.PRsOpened
existing.PRsMerged += cm.PRsMerged
existing.ReviewsGiven += cm.ReviewsGiven
existing.ReviewComments += cm.ReviewComments
// Issue metrics
existing.IssuesOpened += cm.IssuesOpened
existing.IssuesClosed += cm.IssuesClosed
existing.IssueComments += cm.IssueComments
existing.IssueReferencesInCommits += cm.IssueReferencesInCommits
// Activity pattern metrics (for achievements)
existing.EarlyBirdCount += cm.EarlyBirdCount
existing.NightOwlCount += cm.NightOwlCount
existing.MidnightCount += cm.MidnightCount
existing.WeekendWarrior += cm.WeekendWarrior
existing.OutOfHoursCount += cm.OutOfHoursCount
// Time-based commit counts (for multiplier scoring)
existing.RegularHoursCount += cm.RegularHoursCount
existing.EveningCount += cm.EveningCount
existing.LateNightCount += cm.LateNightCount
existing.OvernightCount += cm.OvernightCount
existing.EarlyMorningCount += cm.EarlyMorningCount
// Combine unique repositories
for _, r := range cm.RepositoriesContributed {
if !slices.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 (guard against empty slice for percentile calculation)
numContributors := len(contributors)
for i := range contributors {
contributors[i].Score.Rank = i + 1
if numContributors > 0 {
contributors[i].Score.PercentileRank = float64(numContributors-i) / float64(numContributors) * 100
} else {
contributors[i].Score.PercentileRank = 0
}
}
// 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
metrics.Contributors = contributors // Update global contributors with scored data
// 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{}
// Get multipliers with defaults if not set
multRegular := points.MultiplierRegularHours
if multRegular == 0 {
multRegular = 1.0
}
multEvening := points.MultiplierEvening
if multEvening == 0 {
multEvening = 2.0
}
multLateNight := points.MultiplierLateNight
if multLateNight == 0 {
multLateNight = 2.5
}
multOvernight := points.MultiplierOvernight
if multOvernight == 0 {
multOvernight = 5.0
}
multEarlyMorning := points.MultiplierEarlyMorning
if multEarlyMorning == 0 {
multEarlyMorning = 2.0
}
// Commit points with time-based multipliers:
// - 9am-5pm: base × 1.0
// - 5pm-9pm: base × 2.0
// - 9pm-midnight: base × 2.5
// - midnight-6am: base × 5.0
// - 6am-9am: base × 2.0
baseCommitPoints := float64(points.Commit)
// Check if we have time-based breakdown data
timeBasedTotal := cm.RegularHoursCount + cm.EveningCount + cm.LateNightCount +
cm.OvernightCount + cm.EarlyMorningCount
var commitScore float64
if timeBasedTotal > 0 {
// Use time-based multipliers
commitScore = float64(cm.RegularHoursCount)*baseCommitPoints*multRegular +
float64(cm.EveningCount)*baseCommitPoints*multEvening +
float64(cm.LateNightCount)*baseCommitPoints*multLateNight +
float64(cm.OvernightCount)*baseCommitPoints*multOvernight +
float64(cm.EarlyMorningCount)*baseCommitPoints*multEarlyMorning
} else {
// Fallback: use CommitCount with regular hours multiplier (backwards compatibility)
commitScore = float64(cm.CommitCount) * baseCommitPoints * multRegular
}
breakdown.Commits = int(commitScore)
// Line change points - always use meaningful lines (excluding comments/whitespace)
// to accurately reflect actual code contribution
breakdown.LineChanges = int(float64(cm.MeaningfulLinesAdded)*points.LinesAdded +
float64(cm.MeaningfulLinesDeleted)*points.LinesDeleted)
// PR points
breakdown.PRs = cm.PRsOpened*points.PROpened + cm.PRsMerged*points.PRMerged
// Review points (PR reviews)
breakdown.Reviews = cm.ReviewsGiven * points.PRReviewed
// Comment points (PR review comments)
breakdown.Comments = cm.ReviewComments * points.ReviewComment
// Issue points
breakdown.Issues = cm.IssuesOpened*points.IssueOpened +
cm.IssuesClosed*points.IssueClosed +
cm.IssueComments*points.IssueComment +
cm.IssueReferencesInCommits*points.IssueReference
// 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
}
}
// Tests bonus - bonus points for commits that include test files
breakdown.TestsBonus = cm.CommitsWithTests * points.CommitWithTests
// Out of hours bonus (legacy - kept for backwards compatibility but default is 0)
breakdown.OutOfHours = cm.OutOfHoursCount * points.OutOfHours
// Calculate total
total := breakdown.Commits + breakdown.LineChanges + breakdown.PRs +
breakdown.Reviews + breakdown.ResponseBonus + breakdown.Comments +
breakdown.Issues + breakdown.TestsBonus + breakdown.OutOfHours
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.GetAchievements() {
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":
// Use meaningful lines to match scoring calculation (excludes comments/whitespace)
earned = float64(cm.MeaningfulLinesAdded) >= ach.Condition.Threshold
case "lines_deleted":
// Use meaningful lines to match scoring calculation (excludes comments/whitespace)
earned = float64(cm.MeaningfulLinesDeleted) >= 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
case "out_of_hours_count":
earned = float64(cm.OutOfHoursCount) >= ach.Condition.Threshold
case "work_week_streak":
earned = float64(cm.WorkWeekStreak) >= ach.Condition.Threshold
// Documentation & comments
case "comment_lines_added":
earned = float64(cm.CommentLinesAdded) >= ach.Condition.Threshold
case "comment_lines_deleted":
earned = float64(cm.CommentLinesDeleted) >= ach.Condition.Threshold
// Issue metrics
case "issues_opened":
earned = float64(cm.IssuesOpened) >= ach.Condition.Threshold
case "issues_closed":
earned = float64(cm.IssuesClosed) >= ach.Condition.Threshold
case "issue_comments":
earned = float64(cm.IssueComments) >= ach.Condition.Threshold
case "issue_references":
earned = float64(cm.IssueReferencesInCommits) >= 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
}
}