mirror of
https://github.com/lukaszraczylo/git-velocity.git
synced 2026-06-10 23:09:00 +00:00
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
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package aggregator
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -72,11 +73,38 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
// Per-repo activity days
|
||||
repoActivityDays := make(map[string]map[string]map[string]bool) // repo -> login -> set of date strings
|
||||
|
||||
// Helper to track activity day for a contributor
|
||||
trackActivityDay := func(login, repo string, date time.Time) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
// Global activity tracking
|
||||
if activityDays[login] == nil {
|
||||
activityDays[login] = make(map[string]bool)
|
||||
}
|
||||
activityDays[login][dateStr] = true
|
||||
// Per-repo activity tracking
|
||||
if repo != "" {
|
||||
if repoActivityDays[repo] == nil {
|
||||
repoActivityDays[repo] = make(map[string]map[string]bool)
|
||||
}
|
||||
if repoActivityDays[repo][login] == nil {
|
||||
repoActivityDays[repo][login] = make(map[string]bool)
|
||||
}
|
||||
repoActivityDays[repo][login][dateStr] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Track unique files per contributor for accurate FilesChanged count
|
||||
contributorFiles := make(map[string]map[string]bool) // login -> set of file paths
|
||||
// Per-repo unique files per contributor
|
||||
repoContributorFiles := make(map[string]map[string]map[string]bool) // repo -> login -> set of file paths
|
||||
|
||||
// Track counts of items with valid time data (for accurate average calculations)
|
||||
// These track only PRs/reviews that have valid time data, not total counts
|
||||
reviewsWithResponseTime := make(map[string]int) // login -> count of reviews with valid ResponseTime
|
||||
repoReviewsWithResponseTime := make(map[string]map[string]int) // repo -> login -> count
|
||||
prsWithTimeToMerge := make(map[string]int) // login -> count of PRs with valid TimeToMerge
|
||||
repoPRsWithTimeToMerge := make(map[string]map[string]int) // repo -> login -> count
|
||||
|
||||
// Helper to get or create per-repo contributor
|
||||
getRepoContributor := func(repo, login, name, avatarURL string) *models.ContributorMetrics {
|
||||
if repoContributorMap[repo] == nil {
|
||||
@@ -140,6 +168,9 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
|
||||
cm := contributorMap[login]
|
||||
cm.CommitCount++
|
||||
if commit.HasTests {
|
||||
cm.CommitsWithTests++
|
||||
}
|
||||
cm.LinesAdded += commit.Additions
|
||||
cm.LinesDeleted += commit.Deletions
|
||||
cm.MeaningfulLinesAdded += commit.MeaningfulAdditions
|
||||
@@ -157,6 +188,9 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
// Update per-repo contributor stats
|
||||
rcm := getRepoContributor(commit.Repository, login, cm.Name, cm.AvatarURL)
|
||||
rcm.CommitCount++
|
||||
if commit.HasTests {
|
||||
rcm.CommitsWithTests++
|
||||
}
|
||||
rcm.LinesAdded += commit.Additions
|
||||
rcm.LinesDeleted += commit.Deletions
|
||||
rcm.MeaningfulLinesAdded += commit.MeaningfulAdditions
|
||||
@@ -178,8 +212,9 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
hour := commit.Date.Hour()
|
||||
weekday := commit.Date.Weekday()
|
||||
|
||||
// Early bird: commits before 9am (for achievements)
|
||||
if hour >= 5 && hour < 9 {
|
||||
// Early bird: commits between 6am-9am (for achievements)
|
||||
// Aligned with the early morning multiplier range
|
||||
if hour >= 6 && hour < 9 {
|
||||
cm.EarlyBirdCount++
|
||||
rcm.EarlyBirdCount++
|
||||
}
|
||||
@@ -233,24 +268,11 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
rcm.EarlyMorningCount++
|
||||
}
|
||||
|
||||
// Track activity days (global)
|
||||
if activityDays[login] == nil {
|
||||
activityDays[login] = make(map[string]bool)
|
||||
}
|
||||
dateStr := commit.Date.Format("2006-01-02")
|
||||
activityDays[login][dateStr] = true
|
||||
|
||||
// Track activity days (per-repo)
|
||||
if repoActivityDays[commit.Repository] == nil {
|
||||
repoActivityDays[commit.Repository] = make(map[string]map[string]bool)
|
||||
}
|
||||
if repoActivityDays[commit.Repository][login] == nil {
|
||||
repoActivityDays[commit.Repository][login] = make(map[string]bool)
|
||||
}
|
||||
repoActivityDays[commit.Repository][login][dateStr] = true
|
||||
// Track activity day for this commit
|
||||
trackActivityDay(login, commit.Repository, commit.Date)
|
||||
|
||||
// Track repository participation
|
||||
if !contains(cm.RepositoriesContributed, commit.Repository) {
|
||||
if !slices.Contains(cm.RepositoriesContributed, commit.Repository) {
|
||||
cm.RepositoriesContributed = append(cm.RepositoriesContributed, commit.Repository)
|
||||
}
|
||||
|
||||
@@ -307,6 +329,9 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
rcm := getRepoContributor(pr.Repository, login, cm.Name, cm.AvatarURL)
|
||||
rcm.PRsOpened++
|
||||
|
||||
// Track activity day for PR creation
|
||||
trackActivityDay(login, pr.Repository, pr.CreatedAt)
|
||||
|
||||
prSize := pr.Additions + pr.Deletions
|
||||
|
||||
if pr.IsMerged() {
|
||||
@@ -316,6 +341,12 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
// Accumulate for average calculation
|
||||
cm.AvgTimeToMerge += pr.TimeToMerge.Hours()
|
||||
rcm.AvgTimeToMerge += pr.TimeToMerge.Hours()
|
||||
// Track count of PRs with valid time data for accurate average
|
||||
prsWithTimeToMerge[login]++
|
||||
if repoPRsWithTimeToMerge[pr.Repository] == nil {
|
||||
repoPRsWithTimeToMerge[pr.Repository] = make(map[string]int)
|
||||
}
|
||||
repoPRsWithTimeToMerge[pr.Repository][login]++
|
||||
}
|
||||
|
||||
// Track largest PR
|
||||
@@ -337,7 +368,7 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
}
|
||||
|
||||
// Track repository participation
|
||||
if !contains(cm.RepositoriesContributed, pr.Repository) {
|
||||
if !slices.Contains(cm.RepositoriesContributed, pr.Repository) {
|
||||
cm.RepositoriesContributed = append(cm.RepositoriesContributed, pr.Repository)
|
||||
}
|
||||
|
||||
@@ -372,6 +403,9 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
rcm.ReviewsGiven++
|
||||
rcm.ReviewComments += review.CommentsCount
|
||||
|
||||
// Track activity day for review submission
|
||||
trackActivityDay(login, review.Repository, review.SubmittedAt)
|
||||
|
||||
if review.IsApproval() {
|
||||
cm.ApprovalsGiven++
|
||||
rcm.ApprovalsGiven++
|
||||
@@ -395,6 +429,12 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
if review.ResponseTime != nil {
|
||||
cm.AvgReviewTime += review.ResponseTime.Hours()
|
||||
rcm.AvgReviewTime += review.ResponseTime.Hours()
|
||||
// Track count of reviews with valid time data for accurate average
|
||||
reviewsWithResponseTime[login]++
|
||||
if repoReviewsWithResponseTime[review.Repository] == nil {
|
||||
repoReviewsWithResponseTime[review.Repository] = make(map[string]int)
|
||||
}
|
||||
repoReviewsWithResponseTime[review.Repository][login]++
|
||||
}
|
||||
|
||||
// Track unique reviewees
|
||||
@@ -452,21 +492,47 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
cm := contributorMap[login]
|
||||
cm.IssuesOpened++
|
||||
|
||||
if issue.IsClosed() && issue.ClosedBy != nil && issue.ClosedBy.Login == login {
|
||||
cm.IssuesClosed++
|
||||
}
|
||||
// Track activity day for issue creation
|
||||
trackActivityDay(login, issue.Repository, issue.CreatedAt)
|
||||
|
||||
// Track repository participation
|
||||
if !contains(cm.RepositoriesContributed, issue.Repository) {
|
||||
if !slices.Contains(cm.RepositoriesContributed, issue.Repository) {
|
||||
cm.RepositoriesContributed = append(cm.RepositoriesContributed, issue.Repository)
|
||||
}
|
||||
|
||||
// Update per-repo contributor metrics
|
||||
rcm := getRepoContributor(issue.Repository, login, cm.Name, cm.AvatarURL)
|
||||
rcm.IssuesOpened++
|
||||
if issue.IsClosed() && issue.ClosedBy != nil && issue.ClosedBy.Login == login {
|
||||
rcm.IssuesClosed++
|
||||
}
|
||||
|
||||
// Count issues closed by each contributor (separate from who opened them)
|
||||
// This gives credit to whoever closed the issue, even if they didn't open it
|
||||
for _, issue := range data.Issues {
|
||||
if !issue.IsClosed() || issue.ClosedBy == nil || issue.ClosedBy.Login == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
closerLogin := issue.ClosedBy.Login
|
||||
|
||||
// Initialize contributor if needed (someone who closes issues but didn't open any)
|
||||
if _, ok := contributorMap[closerLogin]; !ok {
|
||||
contributorMap[closerLogin] = &models.ContributorMetrics{
|
||||
Login: closerLogin,
|
||||
Period: period,
|
||||
}
|
||||
}
|
||||
|
||||
cm := contributorMap[closerLogin]
|
||||
cm.IssuesClosed++
|
||||
|
||||
// Track repository participation for the closer
|
||||
if !slices.Contains(cm.RepositoriesContributed, issue.Repository) {
|
||||
cm.RepositoriesContributed = append(cm.RepositoriesContributed, issue.Repository)
|
||||
}
|
||||
|
||||
// Update per-repo contributor metrics for the closer
|
||||
rcm := getRepoContributor(issue.Repository, closerLogin, cm.Name, cm.AvatarURL)
|
||||
rcm.IssuesClosed++
|
||||
}
|
||||
|
||||
// Process issue comments
|
||||
@@ -487,8 +553,11 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
cm := contributorMap[login]
|
||||
cm.IssueComments++
|
||||
|
||||
// Track activity day for issue comment
|
||||
trackActivityDay(login, comment.Repository, comment.CreatedAt)
|
||||
|
||||
// Track repository participation
|
||||
if !contains(cm.RepositoriesContributed, comment.Repository) {
|
||||
if !slices.Contains(cm.RepositoriesContributed, comment.Repository) {
|
||||
cm.RepositoriesContributed = append(cm.RepositoriesContributed, comment.Repository)
|
||||
}
|
||||
|
||||
@@ -550,20 +619,23 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
|
||||
// Calculate averages and finalize contributor metrics
|
||||
for login, cm := range contributorMap {
|
||||
// Calculate average time to merge
|
||||
// Calculate average time to merge (only from PRs that have TimeToMerge data)
|
||||
if count := prsWithTimeToMerge[login]; count > 0 {
|
||||
cm.AvgTimeToMerge = cm.AvgTimeToMerge / float64(count)
|
||||
}
|
||||
|
||||
// Calculate average review time (only from reviews that have ResponseTime data)
|
||||
if count := reviewsWithResponseTime[login]; count > 0 {
|
||||
cm.AvgReviewTime = cm.AvgReviewTime / float64(count)
|
||||
}
|
||||
|
||||
// Calculate average PR size (only for merged PRs to exclude abandoned PRs)
|
||||
if cm.PRsMerged > 0 {
|
||||
cm.AvgTimeToMerge = cm.AvgTimeToMerge / float64(cm.PRsMerged)
|
||||
}
|
||||
|
||||
// Calculate average review time
|
||||
if cm.ReviewsGiven > 0 {
|
||||
cm.AvgReviewTime = cm.AvgReviewTime / float64(cm.ReviewsGiven)
|
||||
}
|
||||
|
||||
// Calculate average PR size
|
||||
if cm.PRsOpened > 0 {
|
||||
totalPRLines := 0
|
||||
for _, pr := range data.PullRequests {
|
||||
if !pr.IsMerged() {
|
||||
continue // Only count merged PRs
|
||||
}
|
||||
// Normalize PR author login before comparison
|
||||
prLogin := pr.Author.Login
|
||||
if normalized, ok := prAuthorToNormalizedLogin[prLogin]; ok {
|
||||
@@ -573,7 +645,7 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
totalPRLines += pr.TotalChanges()
|
||||
}
|
||||
}
|
||||
cm.AvgPRSize = float64(totalPRLines) / float64(cm.PRsOpened)
|
||||
cm.AvgPRSize = float64(totalPRLines) / float64(cm.PRsMerged)
|
||||
}
|
||||
|
||||
// Set unique reviewees count
|
||||
@@ -617,17 +689,26 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
|
||||
// Calculate averages for per-repo contributors
|
||||
for login, rcm := range repoContribs {
|
||||
if rcm.PRsMerged > 0 {
|
||||
rcm.AvgTimeToMerge = rcm.AvgTimeToMerge / float64(rcm.PRsMerged)
|
||||
// Use count of PRs with valid time data for accurate average
|
||||
if repoPRCounts, ok := repoPRsWithTimeToMerge[repo]; ok {
|
||||
if count := repoPRCounts[login]; count > 0 {
|
||||
rcm.AvgTimeToMerge = rcm.AvgTimeToMerge / float64(count)
|
||||
}
|
||||
}
|
||||
if rcm.ReviewsGiven > 0 {
|
||||
rcm.AvgReviewTime = rcm.AvgReviewTime / float64(rcm.ReviewsGiven)
|
||||
// Use count of reviews with valid time data for accurate average
|
||||
if repoReviewCounts, ok := repoReviewsWithResponseTime[repo]; ok {
|
||||
if count := repoReviewCounts[login]; count > 0 {
|
||||
rcm.AvgReviewTime = rcm.AvgReviewTime / float64(count)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate average PR size for this repo
|
||||
if rcm.PRsOpened > 0 {
|
||||
// Calculate average PR size for this repo (only for merged PRs to exclude abandoned PRs)
|
||||
if rcm.PRsMerged > 0 {
|
||||
totalPRLines := 0
|
||||
for _, pr := range data.PullRequests {
|
||||
if !pr.IsMerged() {
|
||||
continue // Only count merged PRs
|
||||
}
|
||||
// Normalize PR author login before comparison
|
||||
prLogin := pr.Author.Login
|
||||
if mapped, ok := loginToLogin[prLogin]; ok {
|
||||
@@ -637,7 +718,7 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
totalPRLines += pr.TotalChanges()
|
||||
}
|
||||
}
|
||||
rcm.AvgPRSize = float64(totalPRLines) / float64(rcm.PRsOpened)
|
||||
rcm.AvgPRSize = float64(totalPRLines) / float64(rcm.PRsMerged)
|
||||
}
|
||||
|
||||
// Calculate perfect PRs for this repo
|
||||
@@ -761,15 +842,6 @@ func parseRepoName(fullName string) (owner, name string) {
|
||||
return fullName, ""
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeForComparison normalizes a string for fuzzy comparison
|
||||
// by lowercasing and removing spaces, hyphens, underscores, dots, and digits
|
||||
func normalizeForComparison(s string) string {
|
||||
@@ -1282,7 +1354,47 @@ func buildVelocityTimeline(data *models.RawData, period models.Period, scoringCo
|
||||
pointsReview = 30
|
||||
}
|
||||
|
||||
// Aggregate commits by week
|
||||
// Get time-based multipliers with defaults
|
||||
multRegular := scoringConfig.Points.MultiplierRegularHours
|
||||
if multRegular == 0 {
|
||||
multRegular = 1.0
|
||||
}
|
||||
multEvening := scoringConfig.Points.MultiplierEvening
|
||||
if multEvening == 0 {
|
||||
multEvening = 2.0
|
||||
}
|
||||
multLateNight := scoringConfig.Points.MultiplierLateNight
|
||||
if multLateNight == 0 {
|
||||
multLateNight = 2.5
|
||||
}
|
||||
multOvernight := scoringConfig.Points.MultiplierOvernight
|
||||
if multOvernight == 0 {
|
||||
multOvernight = 5.0
|
||||
}
|
||||
multEarlyMorning := scoringConfig.Points.MultiplierEarlyMorning
|
||||
if multEarlyMorning == 0 {
|
||||
multEarlyMorning = 2.0
|
||||
}
|
||||
|
||||
// Helper to get time-based multiplier for a commit
|
||||
getTimeMultiplier := func(hour int) float64 {
|
||||
switch {
|
||||
case hour >= 9 && hour < 17:
|
||||
return multRegular // Regular hours: 9am-5pm
|
||||
case hour >= 17 && hour < 21:
|
||||
return multEvening // Evening: 5pm-9pm
|
||||
case hour >= 21 && hour <= 23:
|
||||
return multLateNight // Late night: 9pm-midnight
|
||||
case hour >= 0 && hour < 6:
|
||||
return multOvernight // Overnight: midnight-6am
|
||||
case hour >= 6 && hour < 9:
|
||||
return multEarlyMorning // Early morning: 6am-9am
|
||||
default:
|
||||
return multRegular
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate commits by week (with time-based multipliers)
|
||||
for _, commit := range data.Commits {
|
||||
if commit.Date.Before(start) || commit.Date.After(end) {
|
||||
continue
|
||||
@@ -1290,7 +1402,9 @@ func buildVelocityTimeline(data *models.RawData, period models.Period, scoringCo
|
||||
idx := findWeekIndex(commit.Date)
|
||||
if idx >= 0 && idx < len(weeks) {
|
||||
weekCommits[idx]++
|
||||
weekScore[idx] += float64(pointsCommit)
|
||||
// Apply time-based multiplier to commit score
|
||||
multiplier := getTimeMultiplier(commit.Date.Hour())
|
||||
weekScore[idx] += float64(pointsCommit) * multiplier
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -349,18 +349,6 @@ func TestAggregator_MultipleRepositories(t *testing.T) {
|
||||
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()
|
||||
|
||||
|
||||
@@ -18,10 +18,11 @@ type ContributorMetrics struct {
|
||||
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"`
|
||||
CommitCount int `json:"commit_count"`
|
||||
CommitsWithTests int `json:"commits_with_tests"` // Commits that include test files
|
||||
LinesAdded int `json:"lines_added"`
|
||||
LinesDeleted int `json:"lines_deleted"`
|
||||
FilesChanged int `json:"files_changed"`
|
||||
|
||||
// Meaningful line counts (excludes comments and whitespace)
|
||||
MeaningfulLinesAdded int `json:"meaningful_lines_added"`
|
||||
@@ -98,7 +99,8 @@ type ScoreBreakdown struct {
|
||||
Issues int `json:"issues"` // Issue-related points (opened, closed, comments, references)
|
||||
ResponseBonus int `json:"response_bonus"`
|
||||
LineChanges int `json:"line_changes"`
|
||||
OutOfHours int `json:"out_of_hours"` // Bonus for out-of-hours commits
|
||||
TestsBonus int `json:"tests_bonus"` // Bonus for commits that include test files
|
||||
OutOfHours int `json:"out_of_hours"` // Bonus for out-of-hours commits
|
||||
}
|
||||
|
||||
// RepositoryMetrics holds aggregated metrics for a single repository
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package scoring
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"github.com/lukaszraczylo/git-velocity/internal/config"
|
||||
@@ -23,52 +24,73 @@ func (c *Calculator) Calculate(metrics *models.GlobalMetrics) *models.GlobalMetr
|
||||
return metrics
|
||||
}
|
||||
|
||||
// Collect all contributor metrics across repositories
|
||||
// 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)
|
||||
|
||||
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.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 !contains(existing.RepositoriesContributed, r) {
|
||||
existing.RepositoriesContributed = append(existing.RepositoriesContributed, r)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,13 +282,16 @@ func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score
|
||||
}
|
||||
}
|
||||
|
||||
// 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.OutOfHours
|
||||
breakdown.Issues + breakdown.TestsBonus + breakdown.OutOfHours
|
||||
|
||||
return models.Score{
|
||||
Total: total,
|
||||
@@ -406,12 +431,3 @@ func (c *Calculator) findTopAchievers(contributors []models.ContributorMetrics,
|
||||
topAchievers["pull_requests"] = topPRAuthor
|
||||
}
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -843,18 +843,6 @@ func TestCalculator_WorkWeekStreakAchievement(t *testing.T) {
|
||||
assert.Contains(t, contributor.Achievements, "workweek-5")
|
||||
}
|
||||
|
||||
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 TestCalculator_MeaningfulLinesScoring(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ type gqlPRQuery struct {
|
||||
TotalCount int
|
||||
PageInfo PageInfo
|
||||
Nodes []gqlPRNode
|
||||
} `graphql:"pullRequests(first: 100, after: $cursor, states: [MERGED], orderBy: {field: UPDATED_AT, direction: DESC})"`
|
||||
} `graphql:"pullRequests(first: 100, after: $cursor, states: [OPEN, MERGED, CLOSED], orderBy: {field: UPDATED_AT, direction: DESC})"`
|
||||
} `graphql:"repository(owner: $owner, name: $repo)"`
|
||||
}
|
||||
|
||||
@@ -329,23 +329,29 @@ func (g *GraphQLClient) FetchPRsWithReviews(ctx context.Context, owner, repo str
|
||||
}
|
||||
},
|
||||
ProcessNode: func(node gqlPRNode, repoName string) ([]prWithReviews, bool, bool) {
|
||||
// Skip if not merged - not counted as "old"
|
||||
if node.MergedAt == nil {
|
||||
return nil, false, false
|
||||
// Determine the relevant date for filtering:
|
||||
// - For merged PRs: use MergedAt
|
||||
// - For closed PRs: use ClosedAt
|
||||
// - For open PRs: use CreatedAt (they're still active)
|
||||
var relevantDate time.Time
|
||||
if node.MergedAt != nil {
|
||||
relevantDate = *node.MergedAt
|
||||
} else if node.ClosedAt != nil {
|
||||
relevantDate = *node.ClosedAt
|
||||
} else {
|
||||
relevantDate = node.CreatedAt
|
||||
}
|
||||
|
||||
mergedAt := *node.MergedAt
|
||||
|
||||
// Hard cutoff check - stop entirely if past this date
|
||||
if hardCutoff != nil && mergedAt.Before(*hardCutoff) {
|
||||
if hardCutoff != nil && relevantDate.Before(*hardCutoff) {
|
||||
return nil, true, true // Hard stop
|
||||
}
|
||||
|
||||
// Check date range - skip if outside range
|
||||
if until != nil && mergedAt.After(*until) {
|
||||
if until != nil && relevantDate.After(*until) {
|
||||
return nil, false, false // Too new, not "old"
|
||||
}
|
||||
if since != nil && mergedAt.Before(*since) {
|
||||
if since != nil && relevantDate.Before(*since) {
|
||||
return nil, true, false // Too old - signal for early termination tracking
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user