mirror of
https://github.com/lukaszraczylo/git-velocity.git
synced 2026-06-18 03:43:56 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f338e23caf |
@@ -562,6 +562,7 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
|||||||
return &models.GlobalMetrics{
|
return &models.GlobalMetrics{
|
||||||
Period: period,
|
Period: period,
|
||||||
Repositories: repositories,
|
Repositories: repositories,
|
||||||
|
Contributors: contributors,
|
||||||
Teams: teams,
|
Teams: teams,
|
||||||
TotalContributors: len(contributors),
|
TotalContributors: len(contributors),
|
||||||
TotalCommits: totalCommits,
|
TotalCommits: totalCommits,
|
||||||
|
|||||||
@@ -125,11 +125,12 @@ type TeamMetrics struct {
|
|||||||
|
|
||||||
// GlobalMetrics holds metrics aggregated across all repositories
|
// GlobalMetrics holds metrics aggregated across all repositories
|
||||||
type GlobalMetrics struct {
|
type GlobalMetrics struct {
|
||||||
Period Period `json:"period"`
|
Period Period `json:"period"`
|
||||||
Repositories []RepositoryMetrics `json:"repositories"`
|
Repositories []RepositoryMetrics `json:"repositories"`
|
||||||
Teams []TeamMetrics `json:"teams"`
|
Contributors []ContributorMetrics `json:"contributors"` // Aggregated across all repos
|
||||||
Leaderboard []LeaderboardEntry `json:"leaderboard"`
|
Teams []TeamMetrics `json:"teams"`
|
||||||
TopAchievers map[string]string `json:"top_achievers"` // category -> login
|
Leaderboard []LeaderboardEntry `json:"leaderboard"`
|
||||||
|
TopAchievers map[string]string `json:"top_achievers"` // category -> login
|
||||||
|
|
||||||
// Summary stats
|
// Summary stats
|
||||||
TotalContributors int `json:"total_contributors"`
|
TotalContributors int `json:"total_contributors"`
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ func (c *Calculator) Calculate(metrics *models.GlobalMetrics) *models.GlobalMetr
|
|||||||
// Update the metrics
|
// Update the metrics
|
||||||
metrics.Leaderboard = leaderboard
|
metrics.Leaderboard = leaderboard
|
||||||
metrics.TopAchievers = topAchievers
|
metrics.TopAchievers = topAchievers
|
||||||
|
metrics.Contributors = contributors // Update global contributors with scored data
|
||||||
|
|
||||||
// Calculate per-repository scores (based on repo-specific metrics, not global)
|
// Calculate per-repository scores (based on repo-specific metrics, not global)
|
||||||
for i := range metrics.Repositories {
|
for i := range metrics.Repositories {
|
||||||
|
|||||||
@@ -98,6 +98,108 @@ func TestCalculator_BasicScoring(t *testing.T) {
|
|||||||
assert.Equal(t, 840, entry.Score)
|
assert.Equal(t, 840, entry.Score)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCalculator_GlobalContributorsPopulatedWithScores(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)
|
||||||
|
|
||||||
|
// Contributor appears in multiple repos with different stats
|
||||||
|
metrics := &models.GlobalMetrics{
|
||||||
|
Repositories: []models.RepositoryMetrics{
|
||||||
|
{
|
||||||
|
FullName: "owner/repo1",
|
||||||
|
Contributors: []models.ContributorMetrics{
|
||||||
|
{
|
||||||
|
Login: "alice",
|
||||||
|
Name: "Alice",
|
||||||
|
CommitCount: 50,
|
||||||
|
PRsOpened: 5,
|
||||||
|
PRsMerged: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Login: "bob",
|
||||||
|
Name: "Bob",
|
||||||
|
CommitCount: 20,
|
||||||
|
PRsOpened: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FullName: "owner/repo2",
|
||||||
|
Contributors: []models.ContributorMetrics{
|
||||||
|
{
|
||||||
|
Login: "alice",
|
||||||
|
Name: "Alice",
|
||||||
|
CommitCount: 30, // Additional commits in second repo
|
||||||
|
PRsOpened: 3,
|
||||||
|
PRsMerged: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := calc.Calculate(metrics)
|
||||||
|
|
||||||
|
// Verify metrics.Contributors is populated
|
||||||
|
require.NotEmpty(t, result.Contributors, "metrics.Contributors should be populated")
|
||||||
|
require.Len(t, result.Contributors, 2, "Should have 2 unique contributors")
|
||||||
|
|
||||||
|
// Find alice in Contributors
|
||||||
|
var alice *models.ContributorMetrics
|
||||||
|
for i := range result.Contributors {
|
||||||
|
if result.Contributors[i].Login == "alice" {
|
||||||
|
alice = &result.Contributors[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, alice, "Alice should be in Contributors")
|
||||||
|
|
||||||
|
// Verify alice has AGGREGATED stats
|
||||||
|
assert.Equal(t, 80, alice.CommitCount, "Alice should have aggregated commits (50+30)")
|
||||||
|
assert.Equal(t, 8, alice.PRsOpened, "Alice should have aggregated PRs opened (5+3)")
|
||||||
|
assert.Equal(t, 5, alice.PRsMerged, "Alice should have aggregated PRs merged (3+2)")
|
||||||
|
|
||||||
|
// Verify alice has a calculated score with breakdown
|
||||||
|
assert.Greater(t, alice.Score.Total, 0, "Alice should have a calculated score")
|
||||||
|
assert.Greater(t, alice.Score.Breakdown.Commits, 0, "Score breakdown should have commits")
|
||||||
|
assert.Greater(t, alice.Score.Breakdown.PRs, 0, "Score breakdown should have PRs")
|
||||||
|
|
||||||
|
// Verify score calculation:
|
||||||
|
// Commits: 80 * 10 = 800
|
||||||
|
// PRs: 8 * 25 + 5 * 50 = 200 + 250 = 450
|
||||||
|
// Total: 800 + 450 = 1250
|
||||||
|
assert.Equal(t, 800, alice.Score.Breakdown.Commits, "Commit points should be 80 * 10 = 800")
|
||||||
|
assert.Equal(t, 450, alice.Score.Breakdown.PRs, "PR points should be 8*25 + 5*50 = 450")
|
||||||
|
assert.Equal(t, 1250, alice.Score.Total, "Total score should be 1250")
|
||||||
|
|
||||||
|
// Verify rank is assigned
|
||||||
|
assert.Equal(t, 1, alice.Score.Rank, "Alice should be rank 1 (highest scorer)")
|
||||||
|
|
||||||
|
// Verify bob also has scores
|
||||||
|
var bob *models.ContributorMetrics
|
||||||
|
for i := range result.Contributors {
|
||||||
|
if result.Contributors[i].Login == "bob" {
|
||||||
|
bob = &result.Contributors[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, bob, "Bob should be in Contributors")
|
||||||
|
assert.Greater(t, bob.Score.Total, 0, "Bob should have a calculated score")
|
||||||
|
assert.Equal(t, 2, bob.Score.Rank, "Bob should be rank 2")
|
||||||
|
}
|
||||||
|
|
||||||
func TestCalculator_FastReviewBonus(t *testing.T) {
|
func TestCalculator_FastReviewBonus(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
<script type="module" crossorigin src="./assets/index-R3eb927Q.js"></script>
|
<script type="module" crossorigin src="./assets/index-LBN7XWrH.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="./assets/chart-Bcjh2pZL.js">
|
<link rel="modulepreload" crossorigin href="./assets/chart-Bcjh2pZL.js">
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-8XjWwD9J.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-8XjWwD9J.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -106,23 +106,15 @@ func (g *Generator) generateDataFiles(metrics *models.GlobalMetrics) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-contributor data
|
// Per-contributor data (use aggregated global contributors, not per-repo)
|
||||||
contributorsSeen := make(map[string]bool)
|
|
||||||
contributorDir := filepath.Join(dataDir, "contributors")
|
contributorDir := filepath.Join(dataDir, "contributors")
|
||||||
if err := os.MkdirAll(contributorDir, 0750); err != nil {
|
if err := os.MkdirAll(contributorDir, 0750); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, repo := range metrics.Repositories {
|
for _, contributor := range metrics.Contributors {
|
||||||
for _, contributor := range repo.Contributors {
|
if err := writeJSON(filepath.Join(contributorDir, contributor.Login+".json"), contributor); err != nil {
|
||||||
if contributorsSeen[contributor.Login] {
|
return err
|
||||||
continue
|
|
||||||
}
|
|
||||||
contributorsSeen[contributor.Login] = true
|
|
||||||
|
|
||||||
if err := writeJSON(filepath.Join(contributorDir, contributor.Login+".json"), contributor); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -254,17 +254,14 @@ func TestGenerator_GenerateContributorJSON(t *testing.T) {
|
|||||||
gen, err := NewGenerator(tempDir, cfg)
|
gen, err := NewGenerator(tempDir, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Generator now uses global Contributors, not per-repo Contributors
|
||||||
metrics := &models.GlobalMetrics{
|
metrics := &models.GlobalMetrics{
|
||||||
Repositories: []models.RepositoryMetrics{
|
Contributors: []models.ContributorMetrics{
|
||||||
{
|
{
|
||||||
Contributors: []models.ContributorMetrics{
|
Login: "john-doe",
|
||||||
{
|
Name: "John Doe",
|
||||||
Login: "john-doe",
|
CommitCount: 50,
|
||||||
Name: "John Doe",
|
PRsOpened: 10,
|
||||||
CommitCount: 50,
|
|
||||||
PRsOpened: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -287,33 +284,43 @@ func TestGenerator_GenerateContributorJSON(t *testing.T) {
|
|||||||
assert.Equal(t, 10, result.PRsOpened)
|
assert.Equal(t, 10, result.PRsOpened)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_ContributorDeduplication(t *testing.T) {
|
func TestGenerator_UsesGlobalContributorsNotPerRepo(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
gen, err := NewGenerator(tempDir, cfg)
|
gen, err := NewGenerator(tempDir, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Same contributor in multiple repos
|
// Same contributor in multiple repos with different per-repo stats
|
||||||
|
// But GlobalMetrics.Contributors should have AGGREGATED stats
|
||||||
metrics := &models.GlobalMetrics{
|
metrics := &models.GlobalMetrics{
|
||||||
|
// Per-repo data (used for repository-specific pages)
|
||||||
Repositories: []models.RepositoryMetrics{
|
Repositories: []models.RepositoryMetrics{
|
||||||
{
|
{
|
||||||
|
Owner: "org",
|
||||||
|
Name: "repo1",
|
||||||
Contributors: []models.ContributorMetrics{
|
Contributors: []models.ContributorMetrics{
|
||||||
{Login: "user1", CommitCount: 50},
|
{Login: "user1", CommitCount: 50, PRsOpened: 5},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
Owner: "org",
|
||||||
|
Name: "repo2",
|
||||||
Contributors: []models.ContributorMetrics{
|
Contributors: []models.ContributorMetrics{
|
||||||
{Login: "user1", CommitCount: 75}, // Same user, different count
|
{Login: "user1", CommitCount: 75, PRsOpened: 10}, // Same user, different count
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Global aggregated data (this is what the generator should use for contributor files)
|
||||||
|
Contributors: []models.ContributorMetrics{
|
||||||
|
{Login: "user1", CommitCount: 125, PRsOpened: 15}, // Sum: 50+75=125, 5+10=15
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
err = gen.Generate(metrics)
|
err = gen.Generate(metrics)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should only have one contributor file (first one seen)
|
// Contributor file should have AGGREGATED data from GlobalMetrics.Contributors
|
||||||
contributorPath := filepath.Join(tempDir, "data", "contributors", "user1.json")
|
contributorPath := filepath.Join(tempDir, "data", "contributors", "user1.json")
|
||||||
data, err := os.ReadFile(contributorPath)
|
data, err := os.ReadFile(contributorPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -322,8 +329,84 @@ func TestGenerator_ContributorDeduplication(t *testing.T) {
|
|||||||
err = json.Unmarshal(data, &result)
|
err = json.Unmarshal(data, &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should be the first one (50 commits)
|
// Should be the aggregated count (125 commits, 15 PRs), NOT 50 or 75
|
||||||
assert.Equal(t, 50, result.CommitCount)
|
assert.Equal(t, 125, result.CommitCount, "Should use aggregated commits from GlobalMetrics.Contributors")
|
||||||
|
assert.Equal(t, 15, result.PRsOpened, "Should use aggregated PRs from GlobalMetrics.Contributors")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerator_MultipleContributorsAcrossRepos(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
gen, err := NewGenerator(tempDir, cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Multiple contributors across multiple repos with aggregated global data
|
||||||
|
metrics := &models.GlobalMetrics{
|
||||||
|
Repositories: []models.RepositoryMetrics{
|
||||||
|
{
|
||||||
|
Owner: "org",
|
||||||
|
Name: "repo1",
|
||||||
|
Contributors: []models.ContributorMetrics{
|
||||||
|
{Login: "alice", CommitCount: 100, LinesAdded: 5000},
|
||||||
|
{Login: "bob", CommitCount: 50, LinesAdded: 2000},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Owner: "org",
|
||||||
|
Name: "repo2",
|
||||||
|
Contributors: []models.ContributorMetrics{
|
||||||
|
{Login: "alice", CommitCount: 50, LinesAdded: 3000},
|
||||||
|
{Login: "charlie", CommitCount: 75, LinesAdded: 4000},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Aggregated global contributors
|
||||||
|
Contributors: []models.ContributorMetrics{
|
||||||
|
{Login: "alice", CommitCount: 150, LinesAdded: 8000}, // 100+50, 5000+3000
|
||||||
|
{Login: "bob", CommitCount: 50, LinesAdded: 2000}, // Only in repo1
|
||||||
|
{Login: "charlie", CommitCount: 75, LinesAdded: 4000}, // Only in repo2
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = gen.Generate(metrics)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify alice has aggregated data
|
||||||
|
alicePath := filepath.Join(tempDir, "data", "contributors", "alice.json")
|
||||||
|
aliceData, err := os.ReadFile(alicePath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var aliceResult models.ContributorMetrics
|
||||||
|
err = json.Unmarshal(aliceData, &aliceResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 150, aliceResult.CommitCount, "Alice should have aggregated commits")
|
||||||
|
assert.Equal(t, 8000, aliceResult.LinesAdded, "Alice should have aggregated lines added")
|
||||||
|
|
||||||
|
// Verify bob exists with his data
|
||||||
|
bobPath := filepath.Join(tempDir, "data", "contributors", "bob.json")
|
||||||
|
bobData, err := os.ReadFile(bobPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var bobResult models.ContributorMetrics
|
||||||
|
err = json.Unmarshal(bobData, &bobResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 50, bobResult.CommitCount)
|
||||||
|
assert.Equal(t, 2000, bobResult.LinesAdded)
|
||||||
|
|
||||||
|
// Verify charlie exists with his data
|
||||||
|
charliePath := filepath.Join(tempDir, "data", "contributors", "charlie.json")
|
||||||
|
charlieData, err := os.ReadFile(charliePath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var charlieResult models.ContributorMetrics
|
||||||
|
err = json.Unmarshal(charlieData, &charlieResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 75, charlieResult.CommitCount)
|
||||||
|
assert.Equal(t, 4000, charlieResult.LinesAdded)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_NoTeamsDoesNotCreateTeamDir(t *testing.T) {
|
func TestGenerator_NoTeamsDoesNotCreateTeamDir(t *testing.T) {
|
||||||
@@ -466,6 +549,12 @@ func TestGenerator_GenerateWithFullMetrics(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Global aggregated contributors (used for individual contributor files)
|
||||||
|
Contributors: []models.ContributorMetrics{
|
||||||
|
{Login: "alice", Name: "Alice", CommitCount: 150}, // 100+50 aggregated
|
||||||
|
{Login: "bob", Name: "Bob", CommitCount: 200}, // Only in repo1
|
||||||
|
{Login: "charlie", Name: "Charlie", CommitCount: 150}, // Only in repo2
|
||||||
|
},
|
||||||
Teams: []models.TeamMetrics{
|
Teams: []models.TeamMetrics{
|
||||||
{
|
{
|
||||||
Name: "Core Team",
|
Name: "Core Team",
|
||||||
@@ -499,4 +588,15 @@ func TestGenerator_GenerateWithFullMetrics(t *testing.T) {
|
|||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
assert.NoError(t, err, "Expected file to exist: %s", path)
|
assert.NoError(t, err, "Expected file to exist: %s", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify alice's file has aggregated data (150 commits, not 100 from first repo)
|
||||||
|
alicePath := filepath.Join(tempDir, "data", "contributors", "alice.json")
|
||||||
|
aliceData, err := os.ReadFile(alicePath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var aliceResult models.ContributorMetrics
|
||||||
|
err = json.Unmarshal(aliceData, &aliceResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 150, aliceResult.CommitCount, "Alice should have aggregated commits from global Contributors")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,10 +226,10 @@ const progressItems = computed(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by progress (closest to completion first)
|
// Sort by progress descending (closest to next tier first - highest % complete)
|
||||||
results.sort((a, b) => b.progress - a.progress)
|
results.sort((a, b) => b.progress - a.progress)
|
||||||
|
|
||||||
return results.slice(0, props.maxDisplay)
|
return results
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get count of remaining achievements (all unearned across all types)
|
// Get count of remaining achievements (all unearned across all types)
|
||||||
|
|||||||
Reference in New Issue
Block a user