Compare commits

..

11 Commits

35 changed files with 2899 additions and 784 deletions
-14
View File
@@ -73,17 +73,3 @@ dockers_v2:
extra_files:
- config.example.yaml
brews:
- name: git-velocity
repository:
owner: lukaszraczylo
name: homebrew-tap
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
directory: Formula
homepage: https://github.com/lukaszraczylo/git-velocity
description: "Developer velocity metrics analyzer with gamification dashboards"
license: MIT
install: |
bin.install "git-velocity"
test: |
system "#{bin}/git-velocity", "version"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Lukasz Raczylo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+134 -59
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/git-velocity-logo.png" alt="Git Velocity Logo" width="200"/>
<img src="docs/git-velocity-logo.png" alt="Git Velocity Logo" width="400"/>
</p>
<h1 align="center">Git Velocity</h1>
@@ -47,12 +47,16 @@ $ git-velocity serve --port 8080
- **Pull Requests**: Opened, merged, closed, average size, time to merge
- **Code Reviews**: Reviews given, comments, approvals, response time
- **Issues**: Opened, closed, comments
- **Meaningful Lines**: Filter out comments, whitespace, and documentation changes from line counts
### 🎮 Gamification Engine
- **Scoring System**: Earn points for every contribution
- **34 Achievements**: From "First Steps" to "Code Warrior"
- **95 Achievements**: Tiered progression from "First Steps" to "Code Warrior"
- **Leaderboards**: Compete with your team
- **Tier Progression**: Bronze → Silver → Gold → Diamond
- **Tier Progression**: Multiple tiers per achievement category
- **Activity Patterns**: Track early bird, night owl, weekend, and out-of-hours commits
- **Streak Tracking**: Daily streaks and work-week streaks (weekends don't break it!)
- **General velocity chart**: Visualize your velocity over time
### 👥 Team Analytics
- Configure teams and see aggregated metrics
@@ -63,7 +67,7 @@ $ git-velocity serve --port 8080
- **Local Git Analysis**: Clone repos locally for 10x faster commit analysis
- **Smart Caching**: File-based caching with configurable TTL
- **Concurrent Requests**: Parallel API calls for faster data fetching
- **Bot Filtering**: Automatically excludes Dependabot, Renovate, and other bots
- **Bot Filtering**: Hardcoded patterns automatically exclude common bots (Dependabot, Renovate, GitHub Actions, etc.) with optional custom patterns
### 🎨 Beautiful Dashboard
- Modern Vue.js SPA with dark/light mode
@@ -153,6 +157,11 @@ on:
- cron: '0 0 * * 1' # Weekly on Monday
workflow_dispatch: # Manual trigger
permissions:
contents: read
pages: write
id-token: write
jobs:
analyze:
runs-on: ubuntu-latest
@@ -161,22 +170,35 @@ jobs:
- name: Run Git Velocity Analysis
uses: lukaszraczylo/git-velocity@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
config_file: '.git-velocity.yaml'
output_dir: './velocity-report'
# Fix permissions - Docker container runs as root
- name: Fix permissions
run: sudo chown -R $USER:$USER ./velocity-report
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-pages-artifact@v3
with:
name: velocity-dashboard
path: ./velocity-report
deploy:
runs-on: ubuntu-latest
needs: analyze
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./velocity-report
id: deployment
uses: actions/deploy-pages@v4
```
### Action Inputs
@@ -194,58 +216,57 @@ jobs:
|--------|-------------|
| `output_dir` | Path to the generated dashboard |
> **Note**: The action runs as a Docker container for fast execution. Use separate steps for artifact upload and GitHub Pages deployment as shown in the example above.
> **Important**: The action runs as a Docker container. Note the following:
> - Your config file **must** include the `auth` section with `github_token: "${GITHUB_TOKEN}"` - the action input does not automatically populate the config
> - You must set the `GITHUB_TOKEN` environment variable on the action step (in addition to the `github_token` input)
> - The "Fix permissions" step is required because the Docker container runs as root, which causes permission errors when uploading artifacts
## 🏆 Achievements
Git Velocity includes 34 unlockable achievements:
Git Velocity includes **95 hardcoded achievements** across 20 categories with multiple progression tiers. Achievements cannot be modified via configuration to prevent manipulation.
### Commit Achievements
| Achievement | Description | Threshold |
|-------------|-------------|-----------|
| 🍼 First Steps | Made your first commit | 1 commit |
| 🌱 Getting Started | Made 10 commits | 10 commits |
| 🔥 Committed | Made 100 commits | 100 commits |
| 🤖 Code Machine | Made 500 commits | 500 commits |
| 👑 Code Warrior | Made 1000 commits | 1000 commits |
### Achievement Categories
### Pull Request Achievements
| Achievement | Description | Threshold |
|-------------|-------------|-----------|
| 🔀 PR Pioneer | Opened your first PR | 1 PR |
| 🌿 Pull Request Pro | Opened 10 PRs | 10 PRs |
| 🔀 Merge Master | Opened 50 PRs | 50 PRs |
| Category | Tiers | Description |
|----------|-------|-------------|
| **Commits** | 1, 10, 50, 100, 500, 1000 | Track total commits made |
| **PRs Opened** | 1, 10, 25, 50, 100, 250 | Track pull requests created |
| **Reviews** | 1, 10, 25, 50, 100, 250 | Track code reviews performed |
| **Comments** | 10, 50, 100, 250, 500 | Track PR review comments |
| **Lines Added** | 100, 1K, 5K, 10K, 50K | Track code additions |
| **Lines Deleted** | 100, 500, 1K, 5K, 10K | Track code cleanup |
| **Review Time** | 24h, 4h, 1h | Fast review response times |
| **Multi-Repo** | 2, 5, 10 | Contribution across repositories |
| **Unique Reviewees** | 3, 10, 25 | Reviewing different contributors |
| **Large PRs** | 500, 1K, 5K lines | Big changes merged |
| **Small PRs** | 5, 10, 25, 50 | Atomic commits under 100 lines |
| **Perfect PRs** | 1, 5, 10, 25 | Merged without changes requested |
| **Active Days** | 7, 30, 60, 100 | Unique days with activity |
| **Streaks** | 3, 7, 14, 30 days | Consecutive day contributions |
| **Work Week Streak** | 3, 5, 10, 20 days | Weekday streaks (weekends don't break it!) |
| **Early Bird** | 10, 25, 50, 100 | Commits before 9am |
| **Night Owl** | 10, 25, 50, 100 | Commits after 9pm |
| **Midnight** | 5, 10, 25, 50 | Commits between midnight-4am |
| **Weekend** | 5, 10, 25, 50 | Weekend commits |
| **Out of Hours** | 10, 25, 50, 100 | Commits outside 9am-5pm |
| **Documentation** | 100, 500, 1K, 2.5K, 5K | Comment/doc lines added |
| **Comment Cleanup** | 50, 200, 500, 1K, 2.5K | Outdated comments removed |
### Review Achievements
| Achievement | Description | Threshold |
|-------------|-------------|-----------|
| 🔍 Code Reviewer | Reviewed your first PR | 1 review |
| 👁️ Review Regular | Reviewed 25 PRs | 25 reviews |
| 🎓 Review Guru | Reviewed 100 PRs | 100 reviews |
### Example Achievements
### Speed Achievements
| Achievement | Description | Threshold |
|-------------|-------------|-----------|
| ⚡ Speed Demon | Avg review response < 1 hour | < 1h |
| ⏰ Quick Responder | Avg review response < 4 hours | < 4h |
### Activity Pattern Achievements
| Achievement | Description | Threshold |
|-------------|-------------|-----------|
| 📅 Week Warrior | 7 day contribution streak | 7 days |
| 📆 Month Master | 30 day contribution streak | 30 days |
| 🌅 Early Bird | 50 commits before 9am | 50 commits |
| 🌙 Night Owl | 50 commits after 9pm | 50 commits |
| 💀 Nosferatu | 25 commits between midnight-4am | 25 commits |
| 🛋️ Weekend Warrior | 25 weekend commits | 25 commits |
### Code Quality Achievements
| Achievement | Description | Threshold |
|-------------|-------------|-----------|
| 🗜️ Small PR Advocate | 10 PRs under 100 lines | 10 PRs |
| ⚛️ Atomic Commits Hero | 50 PRs under 100 lines | 50 PRs |
| ✅ Clean Code | 5 PRs merged without changes requested | 5 PRs |
| 💎 Flawless | 25 PRs merged without changes requested | 25 PRs |
| Achievement | Description |
|-------------|-------------|
| 🍼 First Steps | Made your first commit |
| 👑 Code Warrior | Made 1000 commits |
| ⚡ Speed Demon | Average review response under 1 hour |
| 💎 Flawless | 25 PRs merged without changes requested |
| 🏢 Full Work Week | 5 consecutive weekday streak |
| 🌙 Night Owl | 50 commits after 9pm |
| ♾️ Time Bender | 100 commits outside 9am-5pm |
| 📚 Documentation Hero | Added 1000 lines of comments/docs |
| 🏛️ Code Historian | Added 5000 lines of comments/docs |
| ✂️ Comment Trimmer | Removed 50 outdated comment lines |
| 💀 Dead Code Hunter | Removed 500 outdated comment lines |
## ⚙️ Configuration
@@ -291,6 +312,7 @@ scoring:
commit_with_tests: 15
lines_added: 0.1
lines_deleted: 0.05
use_meaningful_lines: true # Exclude comments/whitespace from line scoring
pr_opened: 25
pr_merged: 50
pr_reviewed: 30
@@ -300,6 +322,7 @@ scoring:
fast_review_1h: 50
fast_review_4h: 25
fast_review_24h: 10
out_of_hours: 2 # Bonus per commit outside 9am-5pm
output:
directory: "./dist"
@@ -316,10 +339,10 @@ cache:
options:
concurrent_requests: 5
include_bots: false
bot_patterns:
- "*[bot]"
- "dependabot*"
- "renovate*"
# Add custom bot patterns (hardcoded defaults always apply)
additional_bot_patterns:
- "my-org-bot"
- "jenkins*"
use_local_git: true
clone_directory: "./.repos"
user_aliases:
@@ -344,6 +367,58 @@ options:
- "JD"
```
### Bot Filtering
Bot filtering uses **hardcoded default patterns** that always apply when `include_bots: false`. These cannot be disabled to ensure consistent filtering:
**Default Bot Patterns (always applied):**
- `*[bot]` - GitHub App bots (dependabot[bot], renovate[bot], etc.)
- `dependabot*` - Dependabot variants
- `renovate*` - Renovate bot variants
- `github-actions*` - GitHub Actions
- `codecov*` - Codecov bot
- `snyk*` - Snyk security bot
- `greenkeeper*` - Greenkeeper (legacy)
- `imgbot*` - Image optimization bot
- `allcontributors*` - All Contributors bot
- `semantic-release*` - Semantic release bot
**Add custom patterns** for your organization's bots:
```yaml
options:
include_bots: false # When false, hardcoded + additional patterns apply
additional_bot_patterns:
- "my-org-bot" # Exact match
- "jenkins*" # Prefix match
- "*-ci" # Suffix match
```
### Meaningful Lines Filtering
By default, Git Velocity filters out non-meaningful code changes when scoring line additions and deletions. This provides a more accurate measure of actual code contributions.
**What's filtered out:**
- **Comments**: Single-line (`//`, `#`, `--`), block (`/* */`, `<!-- -->`), docstrings (`"""`, `'''`)
- **Whitespace**: Empty lines, whitespace-only lines
- **Documentation files**: `.md`, `.rst`, `.txt`, `README`, `CHANGELOG`, `LICENSE`, files in `docs/` directories
**Supported comment styles:**
- C-style: `//`, `/* */`, `*` (block continuation)
- Python/Shell: `#`, `"""`, `'''`
- SQL/Lua/Haskell: `--`
- Assembly/Lisp/INI: `;`
- VB: `'`
- HTML/XML: `<!-- -->`
To disable this filtering and score raw line counts:
```yaml
scoring:
points:
use_meaningful_lines: false # Score all lines including comments/whitespace
```
### Environment Variables
All configuration values support environment variable expansion:
+1 -1
View File
@@ -26,7 +26,7 @@ outputs:
description: 'Path to the generated dashboard'
runs:
using: 'docker'
image: 'docker://ghcr.io/lukaszraczylo/git-velocity:latest'
image: 'docker://ghcr.io/lukaszraczylo/git-velocity:v1'
args:
- analyze
- --config
+15 -14
View File
@@ -89,6 +89,8 @@ scoring:
commit_with_tests: 15
lines_added: 0.1
lines_deleted: 0.05
# Use meaningful lines (excludes comments/whitespace) for scoring
use_meaningful_lines: true
pr_opened: 25
pr_merged: 50
pr_reviewed: 30
@@ -98,16 +100,10 @@ scoring:
fast_review_1h: 50 # Review response under 1 hour
fast_review_4h: 25 # Review response under 4 hours
fast_review_24h: 10 # Review response under 24 hours
out_of_hours: 2 # Bonus per commit outside 9am-5pm
# Achievement badges (optional, uses defaults if not specified)
# achievements:
# - id: "custom-achievement"
# name: "Custom Badge"
# description: "Earned for custom condition"
# icon: "fa-star"
# condition:
# type: "commit_count" # commit_count, pr_opened_count, review_count, etc.
# threshold: 100
# Note: Achievements are hardcoded (93 achievements across 18 categories)
# They cannot be configured to prevent manipulation
# Output configuration
output:
@@ -129,8 +125,13 @@ cache:
options:
concurrent_requests: 5 # Max parallel API requests (1-20)
include_bots: false # Include bot accounts in metrics
bot_patterns: # Patterns to identify bot accounts
- "*[bot]"
- "dependabot*"
- "renovate*"
- "github-actions*"
# Bot filtering uses hardcoded default patterns that always apply:
# *[bot], dependabot*, renovate*, github-actions*, codecov*,
# snyk*, greenkeeper*, imgbot*, allcontributors*, semantic-release*
#
# Add your own custom patterns here (in addition to defaults):
additional_bot_patterns: []
# - "my-org-bot" # Exact match
# - "jenkins*" # Prefix match
# - "*-ci" # Suffix match
+100 -10
View File
@@ -137,6 +137,10 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
cm.CommitCount++
cm.LinesAdded += commit.Additions
cm.LinesDeleted += commit.Deletions
cm.MeaningfulLinesAdded += commit.MeaningfulAdditions
cm.MeaningfulLinesDeleted += commit.MeaningfulDeletions
cm.CommentLinesAdded += commit.CommentAdditions
cm.CommentLinesDeleted += commit.CommentDeletions
cm.FilesChanged += commit.FilesChanged
// Update per-repo contributor stats
@@ -144,6 +148,10 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
rcm.CommitCount++
rcm.LinesAdded += commit.Additions
rcm.LinesDeleted += commit.Deletions
rcm.MeaningfulLinesAdded += commit.MeaningfulAdditions
rcm.MeaningfulLinesDeleted += commit.MeaningfulDeletions
rcm.CommentLinesAdded += commit.CommentAdditions
rcm.CommentLinesDeleted += commit.CommentDeletions
rcm.FilesChanged += commit.FilesChanged
// Track activity patterns based on commit time
@@ -170,6 +178,11 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
cm.WeekendWarrior++
rcm.WeekendWarrior++
}
// Out of hours: commits outside 9am-5pm (before 9am OR after 5pm)
if hour < 9 || hour >= 17 {
cm.OutOfHoursCount++
rcm.OutOfHoursCount++
}
// Track activity days (global)
if activityDays[login] == nil {
@@ -198,6 +211,8 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
rm.TotalCommits++
rm.TotalLinesAdded += commit.Additions
rm.TotalLinesDeleted += commit.Deletions
rm.TotalMeaningfulLinesAdded += commit.MeaningfulAdditions
rm.TotalMeaningfulLinesDeleted += commit.MeaningfulDeletions
}
// Calculate active days and streaks for each contributor
@@ -205,6 +220,7 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
if cm, ok := contributorMap[login]; ok {
cm.ActiveDays = len(days)
cm.LongestStreak, cm.CurrentStreak = calculateStreaks(days)
cm.WorkWeekStreak = calculateWorkWeekStreak(days)
}
}
@@ -440,6 +456,7 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
if rcm, ok := repoContribs[login]; ok {
rcm.ActiveDays = len(days)
rcm.LongestStreak, rcm.CurrentStreak = calculateStreaks(days)
rcm.WorkWeekStreak = calculateWorkWeekStreak(days)
}
}
}
@@ -528,28 +545,34 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
// Calculate totals
var totalCommits, totalPRs, totalReviews, totalLinesAdded, totalLinesDeleted int
var totalMeaningfulLinesAdded, totalMeaningfulLinesDeleted int
for _, rm := range repositories {
totalCommits += rm.TotalCommits
totalPRs += rm.TotalPRs
totalReviews += rm.TotalReviews
totalLinesAdded += rm.TotalLinesAdded
totalLinesDeleted += rm.TotalLinesDeleted
totalMeaningfulLinesAdded += rm.TotalMeaningfulLinesAdded
totalMeaningfulLinesDeleted += rm.TotalMeaningfulLinesDeleted
}
// Build velocity timeline (weekly aggregation)
velocityTimeline := buildVelocityTimeline(data, period, a.config.Scoring)
return &models.GlobalMetrics{
Period: period,
Repositories: repositories,
Teams: teams,
TotalContributors: len(contributors),
TotalCommits: totalCommits,
TotalPRs: totalPRs,
TotalReviews: totalReviews,
TotalLinesAdded: totalLinesAdded,
TotalLinesDeleted: totalLinesDeleted,
VelocityTimeline: velocityTimeline,
Period: period,
Repositories: repositories,
Contributors: contributors,
Teams: teams,
TotalContributors: len(contributors),
TotalCommits: totalCommits,
TotalPRs: totalPRs,
TotalReviews: totalReviews,
TotalLinesAdded: totalLinesAdded,
TotalLinesDeleted: totalLinesDeleted,
TotalMeaningfulLinesAdded: totalMeaningfulLinesAdded,
TotalMeaningfulLinesDeleted: totalMeaningfulLinesDeleted,
VelocityTimeline: velocityTimeline,
}, nil
}
@@ -1156,6 +1179,73 @@ func buildVelocityTimeline(data *models.RawData, period models.Period, scoringCo
}
}
// calculateWorkWeekStreak calculates the longest streak of consecutive weekdays
// Weekends (Sat/Sun) don't break the streak - they're simply skipped
func calculateWorkWeekStreak(days map[string]bool) int {
if len(days) == 0 {
return 0
}
// Convert to sorted slice of dates
dates := make([]time.Time, 0, len(days))
for dateStr := range days {
t, err := time.Parse("2006-01-02", dateStr)
if err == nil {
dates = append(dates, t)
}
}
if len(dates) == 0 {
return 0
}
// Sort dates
sort.Slice(dates, func(i, j int) bool {
return dates[i].Before(dates[j])
})
// Filter to only weekdays (Mon-Fri)
weekdays := make([]time.Time, 0, len(dates))
for _, d := range dates {
if d.Weekday() != time.Saturday && d.Weekday() != time.Sunday {
weekdays = append(weekdays, d)
}
}
if len(weekdays) == 0 {
return 0
}
// Calculate longest consecutive weekday streak
// Two weekdays are consecutive if there's no weekday between them
longest := 1
streak := 1
for i := 1; i < len(weekdays); i++ {
prev := weekdays[i-1]
curr := weekdays[i]
// Calculate expected next weekday
expectedNext := prev.AddDate(0, 0, 1)
// Skip over weekend days
for expectedNext.Weekday() == time.Saturday || expectedNext.Weekday() == time.Sunday {
expectedNext = expectedNext.AddDate(0, 0, 1)
}
// Check if current date matches expected next weekday
if curr.Year() == expectedNext.Year() && curr.YearDay() == expectedNext.YearDay() {
streak++
if streak > longest {
longest = streak
}
} else {
streak = 1
}
}
return longest
}
// calculateStreaks calculates the longest and current streak of consecutive days
func calculateStreaks(days map[string]bool) (longest, current int) {
if len(days) == 0 {
+495
View File
@@ -381,3 +381,498 @@ func TestParseRepoName(t *testing.T) {
assert.Equal(t, tt.expectedName, name, "name mismatch for %s", tt.fullName)
}
}
func TestSetUserProfiles(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
profiles := map[string]UserProfile{
"user1": {Login: "user1", Email: "user1@example.com", Name: "User One", ID: 12345},
"user2": {Login: "user2", Email: "user2@example.com", Name: "User Two", ID: 67890},
}
agg.SetUserProfiles(profiles)
assert.Equal(t, profiles, agg.userProfiles)
}
func TestNormalizeForComparison(t *testing.T) {
t.Parallel()
tests := []struct {
input string
expected string
}{
{"John Doe", "johndoe"},
{"john-doe", "johndoe"},
{"john_doe", "johndoe"},
{"john.doe", "johndoe"},
{"JOHN DOE", "johndoe"},
{"John123Doe", "johndoe"},
{"123", ""},
{"", ""},
{"ABC xyz 123", "abcxyz"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := normalizeForComparison(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestBuildEmailToLoginMapping_NoReplyEmails(t *testing.T) {
t.Parallel()
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "", Email: "12345+johndoe@users.noreply.github.com", Name: "John Doe"},
Repository: "owner/repo",
},
},
PullRequests: []models.PullRequest{
{
Number: 1,
Author: models.Author{Login: "johndoe", ID: 12345},
},
},
}
mapping := buildEmailToLoginMapping(data, nil)
// Should map via the ID
assert.Equal(t, "johndoe", mapping["12345+johndoe@users.noreply.github.com"])
}
func TestBuildEmailToLoginMapping_ProfileEmails(t *testing.T) {
t.Parallel()
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "", Email: "john@company.com", Name: "John Doe"},
Repository: "owner/repo",
},
},
}
profiles := map[string]UserProfile{
"johndoe": {Login: "johndoe", Email: "john@company.com", Name: "John Doe", ID: 12345},
}
mapping := buildEmailToLoginMapping(data, profiles)
// Should map via profile email
assert.Equal(t, "johndoe", mapping["john@company.com"])
}
func TestBuildEmailToLoginMapping_NameMatching(t *testing.T) {
t.Parallel()
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "", Email: "john@somewhere.com", Name: "John Doe"},
Repository: "owner/repo",
},
},
PullRequests: []models.PullRequest{
{
Number: 1,
Author: models.Author{Login: "johndoe", Name: "John Doe"},
},
},
}
mapping := buildEmailToLoginMapping(data, nil)
// Should map via name matching
assert.Equal(t, "johndoe", mapping["john@somewhere.com"])
}
func TestCalculateWorkWeekStreak(t *testing.T) {
t.Parallel()
tests := []struct {
name string
dates map[string]bool
expectedStreak int
}{
{
name: "empty dates",
dates: map[string]bool{},
expectedStreak: 0,
},
{
name: "single weekday",
dates: map[string]bool{
"2024-01-08": true, // Monday
},
expectedStreak: 1,
},
{
name: "consecutive weekdays",
dates: map[string]bool{
"2024-01-08": true, // Monday
"2024-01-09": true, // Tuesday
"2024-01-10": true, // Wednesday
},
expectedStreak: 3,
},
{
name: "weekdays with weekend gap",
dates: map[string]bool{
"2024-01-12": true, // Friday
"2024-01-15": true, // Monday
"2024-01-16": true, // Tuesday
},
expectedStreak: 3, // Weekend doesn't break streak
},
{
name: "broken streak on weekday",
dates: map[string]bool{
"2024-01-08": true, // Monday
"2024-01-10": true, // Wednesday (skipped Tuesday)
},
expectedStreak: 1,
},
{
name: "weekend only",
dates: map[string]bool{
"2024-01-13": true, // Saturday
"2024-01-14": true, // Sunday
},
expectedStreak: 0, // Weekends don't count
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := calculateWorkWeekStreak(tt.dates)
assert.Equal(t, tt.expectedStreak, result)
})
}
}
func TestCalculateWorkWeekStreak_LongestStreak(t *testing.T) {
t.Parallel()
// Multiple streaks - should return longest
dates := map[string]bool{
"2024-01-08": true, // Monday
"2024-01-09": true, // Tuesday
"2024-01-15": true, // Monday (gap - breaks streak)
"2024-01-16": true, // Tuesday
"2024-01-17": true, // Wednesday
"2024-01-18": true, // Thursday
"2024-01-19": true, // Friday
"2024-01-22": true, // Monday (weekend doesn't break)
}
result := calculateWorkWeekStreak(dates)
assert.Equal(t, 6, result) // Mon-Fri + Mon = 6 weekdays in a row
}
func TestAggregator_OutOfHoursTracking(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "user1"},
Date: time.Date(2024, 1, 15, 7, 0, 0, 0, time.UTC), // 7am - before 9am
Repository: "owner/repo",
},
{
SHA: "def456",
Author: models.Author{Login: "user1"},
Date: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), // 10am - work hours
Repository: "owner/repo",
},
{
SHA: "ghi789",
Author: models.Author{Login: "user1"},
Date: time.Date(2024, 1, 15, 18, 0, 0, 0, time.UTC), // 6pm - after 5pm
Repository: "owner/repo",
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
require.Len(t, metrics.Repositories, 1)
require.Len(t, metrics.Repositories[0].Contributors, 1)
contrib := metrics.Repositories[0].Contributors[0]
assert.Equal(t, 2, contrib.OutOfHoursCount) // 7am and 6pm are out of hours
}
func TestAggregator_WorkWeekStreakTracking(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "user1"},
Date: time.Date(2024, 1, 8, 10, 0, 0, 0, time.UTC), // Monday
Repository: "owner/repo",
},
{
SHA: "def456",
Author: models.Author{Login: "user1"},
Date: time.Date(2024, 1, 9, 10, 0, 0, 0, time.UTC), // Tuesday
Repository: "owner/repo",
},
{
SHA: "ghi789",
Author: models.Author{Login: "user1"},
Date: time.Date(2024, 1, 10, 10, 0, 0, 0, time.UTC), // Wednesday
Repository: "owner/repo",
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
require.Len(t, metrics.Repositories, 1)
require.Len(t, metrics.Repositories[0].Contributors, 1)
contrib := metrics.Repositories[0].Contributors[0]
assert.Equal(t, 3, contrib.WorkWeekStreak)
}
// Note: Bot filtering tests removed - bot filtering happens in app.go before data reaches aggregator
// The aggregator receives already filtered data
func TestAggregator_EarlyBirdTracking(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "user1"},
Date: time.Date(2024, 1, 15, 6, 0, 0, 0, time.UTC), // 6am
Repository: "owner/repo",
},
{
SHA: "def456",
Author: models.Author{Login: "user1"},
Date: time.Date(2024, 1, 16, 8, 30, 0, 0, time.UTC), // 8:30am
Repository: "owner/repo",
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
require.Len(t, metrics.Repositories, 1)
require.Len(t, metrics.Repositories[0].Contributors, 1)
contrib := metrics.Repositories[0].Contributors[0]
assert.Equal(t, 2, contrib.EarlyBirdCount) // Both before 9am
}
func TestAggregator_NightOwlTracking(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "user1"},
Date: time.Date(2024, 1, 15, 21, 0, 0, 0, time.UTC), // 9pm
Repository: "owner/repo",
},
{
SHA: "def456",
Author: models.Author{Login: "user1"},
Date: time.Date(2024, 1, 16, 23, 30, 0, 0, time.UTC), // 11:30pm
Repository: "owner/repo",
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
require.Len(t, metrics.Repositories, 1)
require.Len(t, metrics.Repositories[0].Contributors, 1)
contrib := metrics.Repositories[0].Contributors[0]
assert.Equal(t, 2, contrib.NightOwlCount) // Both after 9pm
}
func TestAggregator_MidnightTracking(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "user1"},
Date: time.Date(2024, 1, 15, 0, 30, 0, 0, time.UTC), // 12:30am
Repository: "owner/repo",
},
{
SHA: "def456",
Author: models.Author{Login: "user1"},
Date: time.Date(2024, 1, 16, 3, 0, 0, 0, time.UTC), // 3am
Repository: "owner/repo",
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
require.Len(t, metrics.Repositories, 1)
require.Len(t, metrics.Repositories[0].Contributors, 1)
contrib := metrics.Repositories[0].Contributors[0]
assert.Equal(t, 2, contrib.MidnightCount) // Both between 0-4am
}
func TestAggregator_WeekendWarriorTracking(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "user1"},
Date: time.Date(2024, 1, 13, 10, 0, 0, 0, time.UTC), // Saturday
Repository: "owner/repo",
},
{
SHA: "def456",
Author: models.Author{Login: "user1"},
Date: time.Date(2024, 1, 14, 15, 0, 0, 0, time.UTC), // Sunday
Repository: "owner/repo",
},
{
SHA: "ghi789",
Author: models.Author{Login: "user1"},
Date: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), // Monday (not weekend)
Repository: "owner/repo",
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
require.Len(t, metrics.Repositories, 1)
require.Len(t, metrics.Repositories[0].Contributors, 1)
contrib := metrics.Repositories[0].Contributors[0]
assert.Equal(t, 2, contrib.WeekendWarrior) // Saturday and Sunday only
}
func TestAggregator_MultiRepoContributions(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "user1"},
Repository: "owner/repo1",
},
{
SHA: "def456",
Author: models.Author{Login: "user1"},
Repository: "owner/repo2",
},
{
SHA: "ghi789",
Author: models.Author{Login: "user1"},
Repository: "owner/repo3",
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
// MultiRepoCount is tracked in the global leaderboard entries, not repo contributors
// The leaderboard entry should show 3 repos for user1
require.Len(t, metrics.Repositories, 3)
assert.Equal(t, 1, metrics.TotalContributors)
}
func TestBuildEmailToLoginMapping_EmptyData(t *testing.T) {
t.Parallel()
data := &models.RawData{}
mapping := buildEmailToLoginMapping(data, nil)
assert.Empty(t, mapping)
}
func TestBuildEmailToLoginMapping_NoReplyEmailWithoutID(t *testing.T) {
t.Parallel()
// When the email is just "username@users.noreply.github.com" (without ID+),
// the mapping only happens if there's a matching PR author (via name matching later)
// The direct extraction only works for "ID+username@" format
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "", Email: "johndoe@users.noreply.github.com", Name: "John Doe"},
Repository: "owner/repo",
},
},
// Add a PR to enable name matching
PullRequests: []models.PullRequest{
{
Number: 1,
Author: models.Author{Login: "johndoe", Name: "John Doe"},
},
},
}
mapping := buildEmailToLoginMapping(data, nil)
// Should map via name matching since there's a PR author with the same name
assert.Equal(t, "johndoe", mapping["johndoe@users.noreply.github.com"])
}
+13 -2
View File
@@ -192,19 +192,30 @@ func (c *Config) GetTeamForUser(username string) *TeamConfig {
return nil
}
// IsBot checks if a username matches bot patterns
// IsBot checks if a username matches bot patterns (hardcoded defaults + user-defined)
func (c *Config) IsBot(username string) bool {
if c.Options.IncludeBots {
return false
}
lower := strings.ToLower(username)
for _, pattern := range c.Options.BotPatterns {
// Check hardcoded default patterns first
for _, pattern := range DefaultBotPatterns() {
pattern = strings.ToLower(pattern)
if matchPattern(lower, pattern) {
return true
}
}
// Check user-defined additional patterns
for _, pattern := range c.Options.AdditionalBotPatterns {
pattern = strings.ToLower(pattern)
if matchPattern(lower, pattern) {
return true
}
}
return false
}
+37 -8
View File
@@ -615,15 +615,10 @@ func TestConfig_GetTeamForUser(t *testing.T) {
func TestConfig_IsBot(t *testing.T) {
t.Parallel()
// Bot patterns are now hardcoded, so we just need IncludeBots: false
cfg := &Config{
Options: OptionsConfig{
IncludeBots: false,
BotPatterns: []string{
"*[bot]",
"dependabot*",
"renovate*",
"github-actions*",
},
},
}
@@ -652,6 +647,16 @@ func TestConfig_IsBot(t *testing.T) {
username: "github-actions[bot]",
expected: true,
},
{
name: "codecov bot (hardcoded)",
username: "codecov[bot]",
expected: true,
},
{
name: "snyk bot (hardcoded)",
username: "snyk-bot",
expected: true,
},
{
name: "regular user",
username: "alice",
@@ -674,19 +679,43 @@ func TestConfig_IsBot(t *testing.T) {
}
}
func TestConfig_IsBot_AdditionalPatterns(t *testing.T) {
t.Parallel()
cfg := &Config{
Options: OptionsConfig{
IncludeBots: false,
AdditionalBotPatterns: []string{"my-custom-bot", "ci-*"},
},
}
// Custom patterns should work
assert.True(t, cfg.IsBot("my-custom-bot"))
assert.True(t, cfg.IsBot("ci-runner"))
assert.True(t, cfg.IsBot("ci-bot"))
// Hardcoded patterns should still work
assert.True(t, cfg.IsBot("dependabot[bot]"))
assert.True(t, cfg.IsBot("renovate[bot]"))
// Regular users should not match
assert.False(t, cfg.IsBot("alice"))
}
func TestConfig_IsBot_IncludeBots(t *testing.T) {
t.Parallel()
cfg := &Config{
Options: OptionsConfig{
IncludeBots: true,
BotPatterns: []string{"*[bot]"},
},
}
// When IncludeBots is true, nothing should be considered a bot
// (even hardcoded patterns are bypassed)
assert.False(t, cfg.IsBot("my-app[bot]"))
assert.False(t, cfg.IsBot("dependabot"))
assert.False(t, cfg.IsBot("renovate[bot]"))
}
func TestMatchPattern(t *testing.T) {
@@ -825,7 +854,7 @@ func TestDefaultConfig(t *testing.T) {
assert.True(t, cfg.Scoring.Enabled)
assert.Equal(t, 10, cfg.Scoring.Points.Commit)
assert.Equal(t, 50, cfg.Scoring.Points.PRMerged)
assert.NotEmpty(t, cfg.Scoring.Achievements)
assert.NotEmpty(t, cfg.Scoring.GetAchievements())
assert.Equal(t, "./dist", cfg.Output.Directory)
assert.True(t, cfg.Cache.Enabled)
assert.Equal(t, "./.cache", cfg.Cache.Directory)
+195 -274
View File
@@ -63,9 +63,13 @@ type TeamConfig struct {
// ScoringConfig holds gamification scoring configuration
type ScoringConfig struct {
Enabled bool `yaml:"enabled"`
Points PointsConfig `yaml:"points"`
Achievements []AchievementConfig `yaml:"achievements,omitempty"`
Enabled bool `yaml:"enabled"`
Points PointsConfig `yaml:"points"`
}
// GetAchievements returns the hardcoded achievements (not configurable to prevent manipulation)
func (s *ScoringConfig) GetAchievements() []AchievementConfig {
return defaultAchievements()
}
// PointsConfig defines point values for various activities
@@ -83,6 +87,11 @@ type PointsConfig struct {
FastReview1h int `yaml:"fast_review_1h"`
FastReview4h int `yaml:"fast_review_4h"`
FastReview24h int `yaml:"fast_review_24h"`
OutOfHours int `yaml:"out_of_hours"` // Bonus per commit outside 9am-5pm
// UseMeaningfulLines determines whether scoring uses meaningful lines (excluding comments/whitespace)
// or raw line counts. Default is true for more accurate contribution scoring.
UseMeaningfulLines bool `yaml:"use_meaningful_lines"`
}
// AchievementConfig defines an achievement badge
@@ -134,12 +143,29 @@ type CacheConfig struct {
// OptionsConfig holds advanced options
type OptionsConfig struct {
ConcurrentRequests int `yaml:"concurrent_requests"`
IncludeBots bool `yaml:"include_bots"`
BotPatterns []string `yaml:"bot_patterns"`
CloneDirectory string `yaml:"clone_directory"` // Directory for local git clones
UseLocalGit bool `yaml:"use_local_git"` // Use local git for commits (faster)
UserAliases []UserAlias `yaml:"user_aliases,omitempty"` // Manual email/name to login mappings
ConcurrentRequests int `yaml:"concurrent_requests"`
IncludeBots bool `yaml:"include_bots"`
AdditionalBotPatterns []string `yaml:"additional_bot_patterns"` // User-defined patterns (added to hardcoded defaults)
CloneDirectory string `yaml:"clone_directory"` // Directory for local git clones
UseLocalGit bool `yaml:"use_local_git"` // Use local git for commits (faster)
UserAliases []UserAlias `yaml:"user_aliases,omitempty"` // Manual email/name to login mappings
}
// DefaultBotPatterns returns the hardcoded bot patterns that are always applied
// These cannot be overridden by users to ensure consistent bot filtering
func DefaultBotPatterns() []string {
return []string{
"*[bot]", // GitHub App bots: dependabot[bot], renovate[bot], etc.
"dependabot*", // Dependabot variants
"renovate*", // Renovate bot variants
"github-actions*", // GitHub Actions
"codecov*", // Codecov bot
"snyk*", // Snyk security bot
"greenkeeper*", // Greenkeeper (legacy)
"imgbot*", // Image optimization bot
"allcontributors*", // All Contributors bot
"semantic-release*", // Semantic release bot
}
}
// UserAlias maps git emails or names to a GitHub login
@@ -163,21 +189,22 @@ func DefaultConfig() *Config {
Scoring: ScoringConfig{
Enabled: true,
Points: PointsConfig{
Commit: 10,
CommitWithTests: 15,
LinesAdded: 0.1,
LinesDeleted: 0.05,
PROpened: 25,
PRMerged: 50,
PRReviewed: 30,
ReviewComment: 5,
IssueOpened: 15,
IssueClosed: 20,
FastReview1h: 50,
FastReview4h: 25,
FastReview24h: 10,
Commit: 10,
CommitWithTests: 15,
LinesAdded: 0.1,
LinesDeleted: 0.05,
PROpened: 25,
PRMerged: 50,
PRReviewed: 30,
ReviewComment: 5,
IssueOpened: 15,
IssueClosed: 20,
FastReview1h: 50,
FastReview4h: 25,
FastReview24h: 10,
OutOfHours: 2,
UseMeaningfulLines: true, // Default to meaningful lines for accurate contribution scoring
},
Achievements: defaultAchievements(),
},
Output: OutputConfig{
Directory: "./dist",
@@ -193,262 +220,156 @@ func DefaultConfig() *Config {
TTL: "24h",
},
Options: OptionsConfig{
ConcurrentRequests: 5,
IncludeBots: false,
BotPatterns: []string{
"*[bot]",
"dependabot*",
"renovate*",
"github-actions*",
},
CloneDirectory: "./.repos",
UseLocalGit: true, // Default to faster local git analysis
ConcurrentRequests: 5,
IncludeBots: false,
AdditionalBotPatterns: []string{}, // Users can add custom patterns here
CloneDirectory: "./.repos",
UseLocalGit: true, // Default to faster local git analysis
},
}
}
// defaultAchievements returns the default achievement badges
// defaultAchievements returns the hardcoded achievement badges with proper tiers
// Achievements are not user-configurable to prevent manipulation
func defaultAchievements() []AchievementConfig {
return []AchievementConfig{
{
ID: "first-commit",
Name: "First Steps",
Description: "Made your first commit",
Icon: "fa-baby",
Condition: AchievementCondition{Type: "commit_count", Threshold: 1},
},
{
ID: "commit-10",
Name: "Getting Started",
Description: "Made 10 commits",
Icon: "fa-seedling",
Condition: AchievementCondition{Type: "commit_count", Threshold: 10},
},
{
ID: "commit-100",
Name: "Committed",
Description: "Made 100 commits",
Icon: "fa-fire",
Condition: AchievementCondition{Type: "commit_count", Threshold: 100},
},
{
ID: "commit-500",
Name: "Code Machine",
Description: "Made 500 commits",
Icon: "fa-robot",
Condition: AchievementCondition{Type: "commit_count", Threshold: 500},
},
{
ID: "commit-1000",
Name: "Code Warrior",
Description: "Made 1000 commits",
Icon: "fa-crown",
Condition: AchievementCondition{Type: "commit_count", Threshold: 1000},
},
{
ID: "pr-opener",
Name: "PR Pioneer",
Description: "Opened your first pull request",
Icon: "fa-code-pull-request",
Condition: AchievementCondition{Type: "pr_opened_count", Threshold: 1},
},
{
ID: "pr-10",
Name: "Pull Request Pro",
Description: "Opened 10 pull requests",
Icon: "fa-code-branch",
Condition: AchievementCondition{Type: "pr_opened_count", Threshold: 10},
},
{
ID: "pr-50",
Name: "Merge Master",
Description: "Opened 50 pull requests",
Icon: "fa-code-merge",
Condition: AchievementCondition{Type: "pr_opened_count", Threshold: 50},
},
{
ID: "reviewer",
Name: "Code Reviewer",
Description: "Reviewed your first pull request",
Icon: "fa-magnifying-glass-chart",
Condition: AchievementCondition{Type: "review_count", Threshold: 1},
},
{
ID: "reviewer-25",
Name: "Review Regular",
Description: "Reviewed 25 pull requests",
Icon: "fa-eye",
Condition: AchievementCondition{Type: "review_count", Threshold: 25},
},
{
ID: "reviewer-100",
Name: "Review Guru",
Description: "Reviewed 100 pull requests",
Icon: "fa-user-graduate",
Condition: AchievementCondition{Type: "review_count", Threshold: 100},
},
{
ID: "speed-demon",
Name: "Speed Demon",
Description: "Average review response under 1 hour",
Icon: "fa-bolt",
Condition: AchievementCondition{Type: "avg_review_time_hours", Threshold: 1},
},
{
ID: "quick-responder",
Name: "Quick Responder",
Description: "Average review response under 4 hours",
Icon: "fa-clock",
Condition: AchievementCondition{Type: "avg_review_time_hours", Threshold: 4},
},
{
ID: "commentator",
Name: "Commentator",
Description: "Left 50 PR review comments",
Icon: "fa-comments",
Condition: AchievementCondition{Type: "comment_count", Threshold: 50},
},
{
ID: "lines-1000",
Name: "Thousand Lines",
Description: "Added 1000 lines of code",
Icon: "fa-layer-group",
Condition: AchievementCondition{Type: "lines_added", Threshold: 1000},
},
{
ID: "lines-10000",
Name: "Ten Thousand",
Description: "Added 10000 lines of code",
Icon: "fa-mountain",
Condition: AchievementCondition{Type: "lines_added", Threshold: 10000},
},
{
ID: "cleaner",
Name: "Code Cleaner",
Description: "Deleted 1000 lines of code",
Icon: "fa-broom",
Condition: AchievementCondition{Type: "lines_deleted", Threshold: 1000},
},
{
ID: "refactorer",
Name: "Refactoring Champion",
Description: "Deleted 10000 lines of code",
Icon: "fa-recycle",
Condition: AchievementCondition{Type: "lines_deleted", Threshold: 10000},
},
{
ID: "multi-repo",
Name: "Multi-Repo Master",
Description: "Contributed to 5 repositories",
Icon: "fa-folder-tree",
Condition: AchievementCondition{Type: "repo_count", Threshold: 5},
},
{
ID: "team-player",
Name: "Team Player",
Description: "Reviewed PRs from 10 different contributors",
Icon: "fa-people-group",
Condition: AchievementCondition{Type: "unique_reviewees", Threshold: 10},
},
// PR Quality achievements
{
ID: "big-pr",
Name: "Heavy Lifter",
Description: "Merged a PR with 1000+ lines changed",
Icon: "fa-weight-hanging",
Condition: AchievementCondition{Type: "largest_pr_size", Threshold: 1000},
},
{
ID: "mega-pr",
Name: "Mega Merge",
Description: "Merged a PR with 5000+ lines changed",
Icon: "fa-dumbbell",
Condition: AchievementCondition{Type: "largest_pr_size", Threshold: 5000},
},
{
ID: "small-pr-10",
Name: "Small PR Advocate",
Description: "Merged 10 PRs under 100 lines",
Icon: "fa-compress",
Condition: AchievementCondition{Type: "small_pr_count", Threshold: 10},
},
{
ID: "small-pr-50",
Name: "Atomic Commits Hero",
Description: "Merged 50 PRs under 100 lines",
Icon: "fa-atom",
Condition: AchievementCondition{Type: "small_pr_count", Threshold: 50},
},
{
ID: "perfect-pr-5",
Name: "Clean Code",
Description: "5 PRs merged without changes requested",
Icon: "fa-check-double",
Condition: AchievementCondition{Type: "perfect_prs", Threshold: 5},
},
{
ID: "perfect-pr-25",
Name: "Flawless",
Description: "25 PRs merged without changes requested",
Icon: "fa-gem",
Condition: AchievementCondition{Type: "perfect_prs", Threshold: 25},
},
// Activity pattern achievements
{
ID: "streak-7",
Name: "Week Warrior",
Description: "7 day contribution streak",
Icon: "fa-calendar-week",
Condition: AchievementCondition{Type: "longest_streak", Threshold: 7},
},
{
ID: "streak-30",
Name: "Month Master",
Description: "30 day contribution streak",
Icon: "fa-calendar-check",
Condition: AchievementCondition{Type: "longest_streak", Threshold: 30},
},
{
ID: "early-bird",
Name: "Early Bird",
Description: "50 commits before 9am",
Icon: "fa-sun",
Condition: AchievementCondition{Type: "early_bird_count", Threshold: 50},
},
{
ID: "night-owl",
Name: "Night Owl",
Description: "50 commits after 9pm",
Icon: "fa-moon",
Condition: AchievementCondition{Type: "night_owl_count", Threshold: 50},
},
{
ID: "nosferatu",
Name: "Nosferatu",
Description: "25 commits between midnight and 4am",
Icon: "fa-skull",
Condition: AchievementCondition{Type: "midnight_count", Threshold: 25},
},
{
ID: "weekend-warrior",
Name: "Weekend Warrior",
Description: "25 weekend commits",
Icon: "fa-couch",
Condition: AchievementCondition{Type: "weekend_warrior", Threshold: 25},
},
{
ID: "active-30",
Name: "Consistent Contributor",
Description: "Active on 30 different days",
Icon: "fa-chart-line",
Condition: AchievementCondition{Type: "active_days", Threshold: 30},
},
{
ID: "active-100",
Name: "Dedicated Developer",
Description: "Active on 100 different days",
Icon: "fa-fire-flame-curved",
Condition: AchievementCondition{Type: "active_days", Threshold: 100},
},
// ===== COMMIT COUNT (Tiers: 1, 10, 50, 100, 500, 1000) =====
{ID: "commit-1", Name: "First Steps", Description: "Made your first commit", Icon: "fa-baby", Condition: AchievementCondition{Type: "commit_count", Threshold: 1}},
{ID: "commit-10", Name: "Getting Started", Description: "Made 10 commits", Icon: "fa-seedling", Condition: AchievementCondition{Type: "commit_count", Threshold: 10}},
{ID: "commit-50", Name: "Contributor", Description: "Made 50 commits", Icon: "fa-code", Condition: AchievementCondition{Type: "commit_count", Threshold: 50}},
{ID: "commit-100", Name: "Committed", Description: "Made 100 commits", Icon: "fa-fire", Condition: AchievementCondition{Type: "commit_count", Threshold: 100}},
{ID: "commit-500", Name: "Code Machine", Description: "Made 500 commits", Icon: "fa-robot", Condition: AchievementCondition{Type: "commit_count", Threshold: 500}},
{ID: "commit-1000", Name: "Code Warrior", Description: "Made 1000 commits", Icon: "fa-crown", Condition: AchievementCondition{Type: "commit_count", Threshold: 1000}},
// ===== PR OPENED (Tiers: 1, 10, 25, 50, 100, 250) =====
{ID: "pr-1", Name: "PR Pioneer", Description: "Opened your first pull request", Icon: "fa-code-pull-request", Condition: AchievementCondition{Type: "pr_opened_count", Threshold: 1}},
{ID: "pr-10", Name: "PR Regular", Description: "Opened 10 pull requests", Icon: "fa-code-branch", Condition: AchievementCondition{Type: "pr_opened_count", Threshold: 10}},
{ID: "pr-25", Name: "PR Pro", Description: "Opened 25 pull requests", Icon: "fa-code-compare", Condition: AchievementCondition{Type: "pr_opened_count", Threshold: 25}},
{ID: "pr-50", Name: "Merge Master", Description: "Opened 50 pull requests", Icon: "fa-code-merge", Condition: AchievementCondition{Type: "pr_opened_count", Threshold: 50}},
{ID: "pr-100", Name: "PR Champion", Description: "Opened 100 pull requests", Icon: "fa-trophy", Condition: AchievementCondition{Type: "pr_opened_count", Threshold: 100}},
{ID: "pr-250", Name: "PR Legend", Description: "Opened 250 pull requests", Icon: "fa-medal", Condition: AchievementCondition{Type: "pr_opened_count", Threshold: 250}},
// ===== REVIEWS (Tiers: 1, 10, 25, 50, 100, 250) =====
{ID: "review-1", Name: "First Review", Description: "Reviewed your first pull request", Icon: "fa-magnifying-glass", Condition: AchievementCondition{Type: "review_count", Threshold: 1}},
{ID: "review-10", Name: "Reviewer", Description: "Reviewed 10 pull requests", Icon: "fa-eye", Condition: AchievementCondition{Type: "review_count", Threshold: 10}},
{ID: "review-25", Name: "Review Regular", Description: "Reviewed 25 pull requests", Icon: "fa-glasses", Condition: AchievementCondition{Type: "review_count", Threshold: 25}},
{ID: "review-50", Name: "Review Expert", Description: "Reviewed 50 pull requests", Icon: "fa-user-check", Condition: AchievementCondition{Type: "review_count", Threshold: 50}},
{ID: "review-100", Name: "Review Guru", Description: "Reviewed 100 pull requests", Icon: "fa-user-graduate", Condition: AchievementCondition{Type: "review_count", Threshold: 100}},
{ID: "review-250", Name: "Review Master", Description: "Reviewed 250 pull requests", Icon: "fa-award", Condition: AchievementCondition{Type: "review_count", Threshold: 250}},
// ===== REVIEW COMMENTS (Tiers: 10, 50, 100, 250, 500) =====
{ID: "comment-10", Name: "Commentator", Description: "Left 10 PR review comments", Icon: "fa-comment", Condition: AchievementCondition{Type: "comment_count", Threshold: 10}},
{ID: "comment-50", Name: "Feedback Giver", Description: "Left 50 PR review comments", Icon: "fa-comments", Condition: AchievementCondition{Type: "comment_count", Threshold: 50}},
{ID: "comment-100", Name: "Code Critic", Description: "Left 100 PR review comments", Icon: "fa-comment-dots", Condition: AchievementCondition{Type: "comment_count", Threshold: 100}},
{ID: "comment-250", Name: "Feedback Expert", Description: "Left 250 PR review comments", Icon: "fa-message", Condition: AchievementCondition{Type: "comment_count", Threshold: 250}},
{ID: "comment-500", Name: "Comment Champion", Description: "Left 500 PR review comments", Icon: "fa-scroll", Condition: AchievementCondition{Type: "comment_count", Threshold: 500}},
// ===== LINES ADDED (Tiers: 100, 1000, 5000, 10000, 50000) =====
{ID: "lines-added-100", Name: "First Hundred", Description: "Added 100 lines of code", Icon: "fa-plus", Condition: AchievementCondition{Type: "lines_added", Threshold: 100}},
{ID: "lines-added-1000", Name: "Thousand Lines", Description: "Added 1000 lines of code", Icon: "fa-layer-group", Condition: AchievementCondition{Type: "lines_added", Threshold: 1000}},
{ID: "lines-added-5000", Name: "Five Thousand", Description: "Added 5000 lines of code", Icon: "fa-cubes", Condition: AchievementCondition{Type: "lines_added", Threshold: 5000}},
{ID: "lines-added-10000", Name: "Ten Thousand", Description: "Added 10000 lines of code", Icon: "fa-mountain", Condition: AchievementCondition{Type: "lines_added", Threshold: 10000}},
{ID: "lines-added-50000", Name: "Code Mountain", Description: "Added 50000 lines of code", Icon: "fa-mountain-sun", Condition: AchievementCondition{Type: "lines_added", Threshold: 50000}},
// ===== LINES DELETED (Tiers: 100, 500, 1000, 5000, 10000) =====
{ID: "lines-deleted-100", Name: "Tidying Up", Description: "Deleted 100 lines of code", Icon: "fa-eraser", Condition: AchievementCondition{Type: "lines_deleted", Threshold: 100}},
{ID: "lines-deleted-500", Name: "Spring Cleaning", Description: "Deleted 500 lines of code", Icon: "fa-broom", Condition: AchievementCondition{Type: "lines_deleted", Threshold: 500}},
{ID: "lines-deleted-1000", Name: "Code Cleaner", Description: "Deleted 1000 lines of code", Icon: "fa-trash-can", Condition: AchievementCondition{Type: "lines_deleted", Threshold: 1000}},
{ID: "lines-deleted-5000", Name: "Refactoring Hero", Description: "Deleted 5000 lines of code", Icon: "fa-recycle", Condition: AchievementCondition{Type: "lines_deleted", Threshold: 5000}},
{ID: "lines-deleted-10000", Name: "Deletion Master", Description: "Deleted 10000 lines of code", Icon: "fa-dumpster-fire", Condition: AchievementCondition{Type: "lines_deleted", Threshold: 10000}},
// ===== REVIEW RESPONSE TIME (Tiers: 24h, 4h, 1h - lower is better) =====
{ID: "review-time-24h", Name: "Same Day Reviewer", Description: "Average review response under 24 hours", Icon: "fa-clock", Condition: AchievementCondition{Type: "avg_review_time_hours", Threshold: 24}},
{ID: "review-time-4h", Name: "Quick Responder", Description: "Average review response under 4 hours", Icon: "fa-stopwatch", Condition: AchievementCondition{Type: "avg_review_time_hours", Threshold: 4}},
{ID: "review-time-1h", Name: "Speed Demon", Description: "Average review response under 1 hour", Icon: "fa-bolt", Condition: AchievementCondition{Type: "avg_review_time_hours", Threshold: 1}},
// ===== MULTI-REPO (Tiers: 2, 5, 10) =====
{ID: "repo-2", Name: "Multi-Repo", Description: "Contributed to 2 repositories", Icon: "fa-folder", Condition: AchievementCondition{Type: "repo_count", Threshold: 2}},
{ID: "repo-5", Name: "Repo Explorer", Description: "Contributed to 5 repositories", Icon: "fa-folder-tree", Condition: AchievementCondition{Type: "repo_count", Threshold: 5}},
{ID: "repo-10", Name: "Repo Master", Description: "Contributed to 10 repositories", Icon: "fa-network-wired", Condition: AchievementCondition{Type: "repo_count", Threshold: 10}},
// ===== UNIQUE REVIEWEES (Tiers: 3, 10, 25) =====
{ID: "reviewees-3", Name: "Helpful Colleague", Description: "Reviewed PRs from 3 different contributors", Icon: "fa-user-group", Condition: AchievementCondition{Type: "unique_reviewees", Threshold: 3}},
{ID: "reviewees-10", Name: "Team Player", Description: "Reviewed PRs from 10 different contributors", Icon: "fa-people-group", Condition: AchievementCondition{Type: "unique_reviewees", Threshold: 10}},
{ID: "reviewees-25", Name: "Community Pillar", Description: "Reviewed PRs from 25 different contributors", Icon: "fa-people-roof", Condition: AchievementCondition{Type: "unique_reviewees", Threshold: 25}},
// ===== PR SIZE - LARGE (Tiers: 500, 1000, 5000) =====
{ID: "large-pr-500", Name: "Big Change", Description: "Merged a PR with 500+ lines changed", Icon: "fa-expand", Condition: AchievementCondition{Type: "largest_pr_size", Threshold: 500}},
{ID: "large-pr-1000", Name: "Heavy Lifter", Description: "Merged a PR with 1000+ lines changed", Icon: "fa-weight-hanging", Condition: AchievementCondition{Type: "largest_pr_size", Threshold: 1000}},
{ID: "large-pr-5000", Name: "Mega Merge", Description: "Merged a PR with 5000+ lines changed", Icon: "fa-dumbbell", Condition: AchievementCondition{Type: "largest_pr_size", Threshold: 5000}},
// ===== SMALL PRs (Tiers: 5, 10, 25, 50) =====
{ID: "small-pr-5", Name: "Small Changes", Description: "Merged 5 PRs under 100 lines", Icon: "fa-compress", Condition: AchievementCondition{Type: "small_pr_count", Threshold: 5}},
{ID: "small-pr-10", Name: "Small PR Advocate", Description: "Merged 10 PRs under 100 lines", Icon: "fa-minimize", Condition: AchievementCondition{Type: "small_pr_count", Threshold: 10}},
{ID: "small-pr-25", Name: "Atomic Commits", Description: "Merged 25 PRs under 100 lines", Icon: "fa-atom", Condition: AchievementCondition{Type: "small_pr_count", Threshold: 25}},
{ID: "small-pr-50", Name: "Micro PR Master", Description: "Merged 50 PRs under 100 lines", Icon: "fa-microchip", Condition: AchievementCondition{Type: "small_pr_count", Threshold: 50}},
// ===== PERFECT PRs (Tiers: 1, 5, 10, 25) =====
{ID: "perfect-pr-1", Name: "First Try", Description: "1 PR merged without changes requested", Icon: "fa-check", Condition: AchievementCondition{Type: "perfect_prs", Threshold: 1}},
{ID: "perfect-pr-5", Name: "Clean Code", Description: "5 PRs merged without changes requested", Icon: "fa-check-double", Condition: AchievementCondition{Type: "perfect_prs", Threshold: 5}},
{ID: "perfect-pr-10", Name: "Quality Author", Description: "10 PRs merged without changes requested", Icon: "fa-circle-check", Condition: AchievementCondition{Type: "perfect_prs", Threshold: 10}},
{ID: "perfect-pr-25", Name: "Flawless", Description: "25 PRs merged without changes requested", Icon: "fa-gem", Condition: AchievementCondition{Type: "perfect_prs", Threshold: 25}},
// ===== ACTIVE DAYS (Tiers: 7, 30, 60, 100) =====
{ID: "active-7", Name: "Week Active", Description: "Active on 7 different days", Icon: "fa-calendar-day", Condition: AchievementCondition{Type: "active_days", Threshold: 7}},
{ID: "active-30", Name: "Month Active", Description: "Active on 30 different days", Icon: "fa-calendar-week", Condition: AchievementCondition{Type: "active_days", Threshold: 30}},
{ID: "active-60", Name: "Consistent Contributor", Description: "Active on 60 different days", Icon: "fa-chart-line", Condition: AchievementCondition{Type: "active_days", Threshold: 60}},
{ID: "active-100", Name: "Dedicated Developer", Description: "Active on 100 different days", Icon: "fa-fire-flame-curved", Condition: AchievementCondition{Type: "active_days", Threshold: 100}},
// ===== LONGEST STREAK (Tiers: 3, 7, 14, 30) =====
{ID: "streak-3", Name: "Getting Rolling", Description: "3 day contribution streak", Icon: "fa-forward", Condition: AchievementCondition{Type: "longest_streak", Threshold: 3}},
{ID: "streak-7", Name: "Week Warrior", Description: "7 day contribution streak", Icon: "fa-calendar-week", Condition: AchievementCondition{Type: "longest_streak", Threshold: 7}},
{ID: "streak-14", Name: "Two Week Streak", Description: "14 day contribution streak", Icon: "fa-fire", Condition: AchievementCondition{Type: "longest_streak", Threshold: 14}},
{ID: "streak-30", Name: "Month Master", Description: "30 day contribution streak", Icon: "fa-calendar-check", Condition: AchievementCondition{Type: "longest_streak", Threshold: 30}},
// ===== WORK WEEK STREAK (Tiers: 3, 5, 10, 20) =====
{ID: "workweek-3", Name: "Work Week Start", Description: "3 consecutive weekday streak", Icon: "fa-briefcase", Condition: AchievementCondition{Type: "work_week_streak", Threshold: 3}},
{ID: "workweek-5", Name: "Full Work Week", Description: "5 consecutive weekday streak", Icon: "fa-building", Condition: AchievementCondition{Type: "work_week_streak", Threshold: 5}},
{ID: "workweek-10", Name: "Two Week Grind", Description: "10 consecutive weekday streak", Icon: "fa-business-time", Condition: AchievementCondition{Type: "work_week_streak", Threshold: 10}},
{ID: "workweek-20", Name: "Month of Mondays", Description: "20 consecutive weekday streak", Icon: "fa-landmark", Condition: AchievementCondition{Type: "work_week_streak", Threshold: 20}},
// ===== EARLY BIRD (Tiers: 10, 25, 50, 100) =====
{ID: "earlybird-10", Name: "Early Riser", Description: "10 commits before 9am", Icon: "fa-mug-hot", Condition: AchievementCondition{Type: "early_bird_count", Threshold: 10}},
{ID: "earlybird-25", Name: "Morning Person", Description: "25 commits before 9am", Icon: "fa-cloud-sun", Condition: AchievementCondition{Type: "early_bird_count", Threshold: 25}},
{ID: "earlybird-50", Name: "Early Bird", Description: "50 commits before 9am", Icon: "fa-sun", Condition: AchievementCondition{Type: "early_bird_count", Threshold: 50}},
{ID: "earlybird-100", Name: "Dawn Warrior", Description: "100 commits before 9am", Icon: "fa-sunrise", Condition: AchievementCondition{Type: "early_bird_count", Threshold: 100}},
// ===== NIGHT OWL (Tiers: 10, 25, 50, 100) =====
{ID: "nightowl-10", Name: "Late Worker", Description: "10 commits after 9pm", Icon: "fa-cloud-moon", Condition: AchievementCondition{Type: "night_owl_count", Threshold: 10}},
{ID: "nightowl-25", Name: "Evening Coder", Description: "25 commits after 9pm", Icon: "fa-moon", Condition: AchievementCondition{Type: "night_owl_count", Threshold: 25}},
{ID: "nightowl-50", Name: "Night Owl", Description: "50 commits after 9pm", Icon: "fa-star", Condition: AchievementCondition{Type: "night_owl_count", Threshold: 50}},
{ID: "nightowl-100", Name: "Nocturnal", Description: "100 commits after 9pm", Icon: "fa-star-and-crescent", Condition: AchievementCondition{Type: "night_owl_count", Threshold: 100}},
// ===== MIDNIGHT CODER (Tiers: 5, 10, 25, 50) =====
{ID: "midnight-5", Name: "Night Shift", Description: "5 commits between midnight and 4am", Icon: "fa-ghost", Condition: AchievementCondition{Type: "midnight_count", Threshold: 5}},
{ID: "midnight-10", Name: "Insomniac", Description: "10 commits between midnight and 4am", Icon: "fa-bed", Condition: AchievementCondition{Type: "midnight_count", Threshold: 10}},
{ID: "midnight-25", Name: "Nosferatu", Description: "25 commits between midnight and 4am", Icon: "fa-skull", Condition: AchievementCondition{Type: "midnight_count", Threshold: 25}},
{ID: "midnight-50", Name: "Vampire Coder", Description: "50 commits between midnight and 4am", Icon: "fa-skull-crossbones", Condition: AchievementCondition{Type: "midnight_count", Threshold: 50}},
// ===== WEEKEND WARRIOR (Tiers: 5, 10, 25, 50) =====
{ID: "weekend-5", Name: "Weekend Work", Description: "5 weekend commits", Icon: "fa-couch", Condition: AchievementCondition{Type: "weekend_warrior", Threshold: 5}},
{ID: "weekend-10", Name: "Weekend Regular", Description: "10 weekend commits", Icon: "fa-house-laptop", Condition: AchievementCondition{Type: "weekend_warrior", Threshold: 10}},
{ID: "weekend-25", Name: "Weekend Warrior", Description: "25 weekend commits", Icon: "fa-gamepad", Condition: AchievementCondition{Type: "weekend_warrior", Threshold: 25}},
{ID: "weekend-50", Name: "No Days Off", Description: "50 weekend commits", Icon: "fa-person-running", Condition: AchievementCondition{Type: "weekend_warrior", Threshold: 50}},
// ===== OUT OF HOURS (Tiers: 10, 25, 50, 100) =====
{ID: "ooh-10", Name: "Extra Hours", Description: "10 commits outside 9am-5pm", Icon: "fa-clock-rotate-left", Condition: AchievementCondition{Type: "out_of_hours_count", Threshold: 10}},
{ID: "ooh-25", Name: "Flexible Schedule", Description: "25 commits outside 9am-5pm", Icon: "fa-user-clock", Condition: AchievementCondition{Type: "out_of_hours_count", Threshold: 25}},
{ID: "ooh-50", Name: "Off-Hours Hero", Description: "50 commits outside 9am-5pm", Icon: "fa-hourglass-half", Condition: AchievementCondition{Type: "out_of_hours_count", Threshold: 50}},
{ID: "ooh-100", Name: "Time Bender", Description: "100 commits outside 9am-5pm", Icon: "fa-infinity", Condition: AchievementCondition{Type: "out_of_hours_count", Threshold: 100}},
// ===== DOCUMENTATION & COMMENTS ADDED (Tiers: 100, 500, 1000, 2500, 5000) =====
{ID: "docs-100", Name: "Documenter", Description: "Added 100 lines of comments/docs", Icon: "fa-file-lines", Condition: AchievementCondition{Type: "comment_lines_added", Threshold: 100}},
{ID: "docs-500", Name: "Technical Writer", Description: "Added 500 lines of comments/docs", Icon: "fa-book", Condition: AchievementCondition{Type: "comment_lines_added", Threshold: 500}},
{ID: "docs-1000", Name: "Documentation Hero", Description: "Added 1000 lines of comments/docs", Icon: "fa-book-open", Condition: AchievementCondition{Type: "comment_lines_added", Threshold: 1000}},
{ID: "docs-2500", Name: "Knowledge Keeper", Description: "Added 2500 lines of comments/docs", Icon: "fa-scroll", Condition: AchievementCondition{Type: "comment_lines_added", Threshold: 2500}},
{ID: "docs-5000", Name: "Code Historian", Description: "Added 5000 lines of comments/docs", Icon: "fa-landmark", Condition: AchievementCondition{Type: "comment_lines_added", Threshold: 5000}},
// ===== COMMENT CLEANUP (Tiers: 50, 200, 500, 1000, 2500) =====
{ID: "docs-del-50", Name: "Comment Trimmer", Description: "Removed 50 lines of outdated comments", Icon: "fa-scissors", Condition: AchievementCondition{Type: "comment_lines_deleted", Threshold: 50}},
{ID: "docs-del-200", Name: "Cleanup Crew", Description: "Removed 200 lines of outdated comments", Icon: "fa-broom", Condition: AchievementCondition{Type: "comment_lines_deleted", Threshold: 200}},
{ID: "docs-del-500", Name: "Dead Code Hunter", Description: "Removed 500 lines of outdated comments", Icon: "fa-skull-crossbones", Condition: AchievementCondition{Type: "comment_lines_deleted", Threshold: 500}},
{ID: "docs-del-1000", Name: "Comment Surgeon", Description: "Removed 1000 lines of outdated comments", Icon: "fa-scalpel", Condition: AchievementCondition{Type: "comment_lines_deleted", Threshold: 1000}},
{ID: "docs-del-2500", Name: "Noise Eliminator", Description: "Removed 2500 lines of outdated comments", Icon: "fa-volume-xmark", Condition: AchievementCondition{Type: "comment_lines_deleted", Threshold: 2500}},
}
}
+1 -54
View File
@@ -117,60 +117,7 @@ func Validate(cfg *Config) error {
// Additional point validations can be added here
}
// Validate achievements
achievementIDs := make(map[string]bool)
for i, achievement := range cfg.Scoring.Achievements {
if achievement.ID == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("scoring.achievements[%d].id", i),
Message: "achievement ID is required",
})
}
if achievementIDs[achievement.ID] {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("scoring.achievements[%d].id", i),
Message: fmt.Sprintf("duplicate achievement ID: %s", achievement.ID),
})
}
achievementIDs[achievement.ID] = true
if achievement.Name == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("scoring.achievements[%d].name", i),
Message: "achievement name is required",
})
}
validConditionTypes := map[string]bool{
"commit_count": true,
"pr_opened_count": true,
"pr_merged_count": true,
"review_count": true,
"comment_count": true,
"lines_added": true,
"lines_deleted": true,
"avg_review_time_hours": true,
"repo_count": true,
"unique_reviewees": true,
// PR quality metrics
"largest_pr_size": true,
"small_pr_count": true,
"perfect_prs": true,
// Activity pattern metrics
"active_days": true,
"longest_streak": true,
"early_bird_count": true,
"night_owl_count": true,
"midnight_count": true,
"weekend_warrior": true,
}
if !validConditionTypes[achievement.Condition.Type] {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("scoring.achievements[%d].condition.type", i),
Message: fmt.Sprintf("invalid condition type: %s", achievement.Condition.Type),
})
}
}
// Note: Achievements are hardcoded and not user-configurable to prevent manipulation
// Validate output
if cfg.Output.Directory == "" {
+2 -55
View File
@@ -233,61 +233,8 @@ func TestValidate(t *testing.T) {
expectError: true,
errorField: "teams[0].members",
},
{
name: "duplicate achievement id",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Scoring: ScoringConfig{
Enabled: true,
Achievements: []AchievementConfig{
{ID: "test-achievement", Name: "Test 1", Condition: AchievementCondition{Type: "commit_count", Threshold: 10}},
{ID: "test-achievement", Name: "Test 2", Condition: AchievementCondition{Type: "commit_count", Threshold: 20}},
},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "scoring.achievements[1].id",
},
{
name: "invalid achievement condition type",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Scoring: ScoringConfig{
Enabled: true,
Achievements: []AchievementConfig{
{ID: "test", Name: "Test", Condition: AchievementCondition{Type: "invalid_type", Threshold: 10}},
},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "scoring.achievements[0].condition.type",
},
// Note: Achievement validation tests removed because achievements are now hardcoded
// and not user-configurable to prevent manipulation
{
name: "missing output directory",
config: &Config{
+137
View File
@@ -0,0 +1,137 @@
package diff
import (
"strings"
)
// IsCommentLine checks if a line is a code comment (should not count as meaningful contribution)
func IsCommentLine(line string) bool {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return true // Empty lines don't count
}
// Common comment patterns across languages
commentPrefixes := []string{
"//", // C, C++, Java, Go, JS, TS, Swift, Kotlin, etc.
"#", // Python, Ruby, Shell, YAML, Perl, etc.
"/*", // C-style block comment start
"*/", // C-style block comment end
"*", // C-style block comment continuation
"<!--", // HTML/XML comment
"-->", // HTML/XML comment end
"--", // SQL, Lua, Haskell
";", // Assembly, Lisp, INI files
"'", // VB comment
"\"\"\"", // Python docstring
"'''", // Python docstring
}
for _, prefix := range commentPrefixes {
if strings.HasPrefix(trimmed, prefix) {
return true
}
}
return false
}
// IsWhitespaceLine checks if a line contains only whitespace characters
func IsWhitespaceLine(line string) bool {
return strings.TrimSpace(line) == ""
}
// IsDocumentationFile checks if a file is documentation-only
func IsDocumentationFile(filename string) bool {
// Documentation file extensions and patterns
docPatterns := []string{
".md", ".markdown", ".rst", ".txt", ".adoc",
"README", "CHANGELOG", "LICENSE", "CONTRIBUTING",
"docs/", "documentation/", "/doc/",
}
lowerFilename := strings.ToLower(filename)
for _, pattern := range docPatterns {
if strings.Contains(lowerFilename, strings.ToLower(pattern)) {
return true
}
}
return false
}
// PatchStats holds the results of analyzing a diff patch
type PatchStats struct {
TotalAdditions int
TotalDeletions int
MeaningfulAdditions int
MeaningfulDeletions int
CommentAdditions int
CommentDeletions int
WhitespaceAdditions int
WhitespaceDeletions int
}
// AnalyzePatch analyzes a unified diff patch and returns both raw and meaningful line counts.
// It parses diff hunks and categorizes each changed line as meaningful, comment, or whitespace.
func AnalyzePatch(patch string) PatchStats {
stats := PatchStats{}
lines := strings.Split(patch, "\n")
for _, line := range lines {
if len(line) == 0 {
continue
}
// Check if this is an addition or deletion line
isAddition := strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++")
isDeletion := strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---")
if !isAddition && !isDeletion {
continue // Context line or header
}
// Remove the diff prefix to get actual content
content := line[1:]
// Categorize the line
if IsWhitespaceLine(content) {
if isAddition {
stats.TotalAdditions++
stats.WhitespaceAdditions++
} else {
stats.TotalDeletions++
stats.WhitespaceDeletions++
}
} else if IsCommentLine(content) {
if isAddition {
stats.TotalAdditions++
stats.CommentAdditions++
} else {
stats.TotalDeletions++
stats.CommentDeletions++
}
} else {
// Meaningful code line
if isAddition {
stats.TotalAdditions++
stats.MeaningfulAdditions++
} else {
stats.TotalDeletions++
stats.MeaningfulDeletions++
}
}
}
return stats
}
// AnalyzePatchSimple returns just the meaningful additions and deletions
func AnalyzePatchSimple(patch string) (meaningfulAdds, meaningfulDels int) {
stats := AnalyzePatch(patch)
return stats.MeaningfulAdditions, stats.MeaningfulDeletions
}
// IsMeaningfulLine checks if a line of code is meaningful (not a comment or whitespace)
func IsMeaningfulLine(line string) bool {
return !IsWhitespaceLine(line) && !IsCommentLine(line)
}
+431
View File
@@ -0,0 +1,431 @@
package diff
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsCommentLine(t *testing.T) {
tests := []struct {
name string
line string
expected bool
}{
// Empty and whitespace
{"empty string", "", true},
{"whitespace only", " ", true},
{"tab only", "\t", true},
{"mixed whitespace", " \t ", true},
// C-style comments (Go, Java, JS, C++, etc.)
{"C single line comment", "// this is a comment", true},
{"C single line with leading space", " // this is a comment", true},
{"C block start", "/* block comment", true},
{"C block end", "*/", true},
{"C block continuation", "* continuation", true},
{"C block continuation with space", " * continuation", true},
// Python/Shell comments
{"Python comment", "# python comment", true},
{"Shell comment", "#!/bin/bash", true},
{"Python comment with space", " # comment", true},
// Python docstrings
{"Python docstring double", "\"\"\"docstring", true},
{"Python docstring single", "'''docstring", true},
// SQL/Lua/Haskell comments
{"SQL comment", "-- SQL comment", true},
// Assembly/Lisp/INI comments
{"Assembly comment", "; assembly comment", true},
{"INI comment", "; ini comment", true},
// VB comments
{"VB comment", "' VB comment", true},
// HTML/XML comments
{"HTML comment start", "<!-- html comment", true},
{"HTML comment end", "-->", true},
// Actual code - NOT comments
{"Go code", "func main() {", false},
{"Python code", "def main():", false},
{"JS code", "const x = 5;", false},
{"Variable assignment", "x = 10", false},
{"Return statement", "return nil", false},
{"Import statement", "import fmt", false},
{"Package declaration", "package main", false},
{"Struct field", "Name string", false},
{"Function call", "fmt.Println(x)", false},
{"String with slash", `"http://example.com"`, false},
{"Code after whitespace", " x := 5", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsCommentLine(tt.line)
assert.Equal(t, tt.expected, result, "IsCommentLine(%q)", tt.line)
})
}
}
func TestIsWhitespaceLine(t *testing.T) {
tests := []struct {
name string
line string
expected bool
}{
{"empty string", "", true},
{"single space", " ", true},
{"multiple spaces", " ", true},
{"single tab", "\t", true},
{"multiple tabs", "\t\t\t", true},
{"mixed whitespace", " \t \t ", true},
{"newline only", "\n", true},
{"carriage return", "\r", true},
{"code line", "x := 5", false},
{"code with leading whitespace", " x := 5", false},
{"comment line", "// comment", false},
{"single character", "x", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsWhitespaceLine(tt.line)
assert.Equal(t, tt.expected, result, "IsWhitespaceLine(%q)", tt.line)
})
}
}
func TestIsDocumentationFile(t *testing.T) {
tests := []struct {
name string
filename string
expected bool
}{
// Documentation files
{"readme markdown", "README.md", true},
{"readme uppercase", "README", true},
{"readme lowercase", "readme.md", true},
{"changelog", "CHANGELOG.md", true},
{"license", "LICENSE", true},
{"license txt", "LICENSE.txt", true},
{"contributing", "CONTRIBUTING.md", true},
{"markdown file", "docs.md", true},
{"rst file", "index.rst", true},
{"txt file", "notes.txt", true},
{"adoc file", "guide.adoc", true},
{"docs directory", "docs/api.md", true},
{"documentation directory", "documentation/guide.md", true},
{"doc directory", "/doc/api.md", true},
// Code files - NOT documentation
{"go file", "main.go", false},
{"python file", "app.py", false},
{"js file", "index.js", false},
{"ts file", "app.ts", false},
{"java file", "App.java", false},
{"c file", "main.c", false},
{"cpp file", "main.cpp", false},
{"rust file", "main.rs", false},
{"yaml file", "config.yaml", false},
{"json file", "package.json", false},
{"html file", "index.html", false},
{"css file", "style.css", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsDocumentationFile(tt.filename)
assert.Equal(t, tt.expected, result, "IsDocumentationFile(%q)", tt.filename)
})
}
}
func TestAnalyzePatch(t *testing.T) {
tests := []struct {
name string
patch string
expected PatchStats
}{
{
name: "simple additions",
patch: `@@ -1,3 +1,5 @@
context line
+func main() {
+ x := 5
+}`,
expected: PatchStats{
TotalAdditions: 3,
MeaningfulAdditions: 3,
},
},
{
name: "simple deletions",
patch: `@@ -1,5 +1,3 @@
context line
-func main() {
- x := 5
-}`,
expected: PatchStats{
TotalDeletions: 3,
MeaningfulDeletions: 3,
},
},
{
name: "mixed additions and deletions",
patch: `@@ -1,3 +1,3 @@
-old code
+new code`,
expected: PatchStats{
TotalAdditions: 1,
TotalDeletions: 1,
MeaningfulAdditions: 1,
MeaningfulDeletions: 1,
},
},
{
name: "comment only changes",
patch: `@@ -1,3 +1,5 @@
func main() {
+// This is a comment
+// Another comment
}`,
expected: PatchStats{
TotalAdditions: 2,
CommentAdditions: 2,
},
},
{
name: "whitespace only changes",
patch: `@@ -1,3 +1,5 @@
func main() {
+
+
}`,
expected: PatchStats{
TotalAdditions: 2,
WhitespaceAdditions: 2,
},
},
{
name: "mixed meaningful and non-meaningful",
patch: `@@ -1,5 +1,10 @@
func main() {
+// Add logging
+ x := 5
+
+ // Calculate result
+ result := x * 2
+
}`,
expected: PatchStats{
TotalAdditions: 6,
MeaningfulAdditions: 2, // x := 5 and result := x * 2
CommentAdditions: 2, // two comments
WhitespaceAdditions: 2, // two empty lines
},
},
{
name: "deleted comments",
patch: `@@ -1,5 +1,2 @@
func main() {
-// Old comment
-/* Block comment */
}`,
expected: PatchStats{
TotalDeletions: 2,
CommentDeletions: 2,
},
},
{
name: "python style comments",
patch: `@@ -1,3 +1,6 @@
def main():
+# This is a python comment
+"""This is a docstring"""
+ x = 5`,
expected: PatchStats{
TotalAdditions: 3,
MeaningfulAdditions: 1, // x = 5
CommentAdditions: 2, // # comment and docstring
},
},
{
name: "sql comments",
patch: `@@ -1,2 +1,4 @@
SELECT * FROM users
+-- This is a SQL comment
+WHERE id = 1`,
expected: PatchStats{
TotalAdditions: 2,
MeaningfulAdditions: 1, // WHERE clause
CommentAdditions: 1, // SQL comment
},
},
{
name: "empty patch",
patch: "",
expected: PatchStats{
TotalAdditions: 0,
TotalDeletions: 0,
MeaningfulAdditions: 0,
MeaningfulDeletions: 0,
},
},
{
name: "context only patch",
patch: `@@ -1,3 +1,3 @@
line 1
line 2
line 3`,
expected: PatchStats{
TotalAdditions: 0,
TotalDeletions: 0,
MeaningfulAdditions: 0,
MeaningfulDeletions: 0,
},
},
{
name: "header lines should be ignored",
patch: `--- a/file.go
+++ b/file.go
@@ -1,3 +1,4 @@
context
+new line`,
expected: PatchStats{
TotalAdditions: 1,
MeaningfulAdditions: 1,
},
},
{
name: "c-style block comment continuation",
patch: `@@ -1,2 +1,5 @@
code
+/*
+ * Block comment
+ */`,
expected: PatchStats{
TotalAdditions: 3,
CommentAdditions: 3,
},
},
{
name: "html comments",
patch: `@@ -1,2 +1,4 @@
<div>
+<!-- This is an HTML comment -->
+<p>Content</p>`,
expected: PatchStats{
TotalAdditions: 2,
MeaningfulAdditions: 1, // <p> tag
CommentAdditions: 1, // HTML comment
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := AnalyzePatch(tt.patch)
assert.Equal(t, tt.expected.TotalAdditions, result.TotalAdditions, "TotalAdditions")
assert.Equal(t, tt.expected.TotalDeletions, result.TotalDeletions, "TotalDeletions")
assert.Equal(t, tt.expected.MeaningfulAdditions, result.MeaningfulAdditions, "MeaningfulAdditions")
assert.Equal(t, tt.expected.MeaningfulDeletions, result.MeaningfulDeletions, "MeaningfulDeletions")
assert.Equal(t, tt.expected.CommentAdditions, result.CommentAdditions, "CommentAdditions")
assert.Equal(t, tt.expected.CommentDeletions, result.CommentDeletions, "CommentDeletions")
assert.Equal(t, tt.expected.WhitespaceAdditions, result.WhitespaceAdditions, "WhitespaceAdditions")
assert.Equal(t, tt.expected.WhitespaceDeletions, result.WhitespaceDeletions, "WhitespaceDeletions")
})
}
}
func TestAnalyzePatchSimple(t *testing.T) {
patch := `@@ -1,3 +1,6 @@
func main() {
+// comment
+ x := 5
+
+ y := 10
}`
adds, dels := AnalyzePatchSimple(patch)
assert.Equal(t, 2, adds, "meaningful additions (x := 5 and y := 10)")
assert.Equal(t, 0, dels, "meaningful deletions")
}
func TestIsMeaningfulLine(t *testing.T) {
tests := []struct {
name string
line string
expected bool
}{
{"code line", "x := 5", true},
{"function definition", "func main() {", true},
{"return statement", "return nil", true},
{"comment line", "// comment", false},
{"empty line", "", false},
{"whitespace line", " ", false},
{"python comment", "# comment", false},
{"code with leading whitespace", " x := 5", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsMeaningfulLine(tt.line)
assert.Equal(t, tt.expected, result, "IsMeaningfulLine(%q)", tt.line)
})
}
}
func TestAnalyzePatch_RealWorldExample(t *testing.T) {
// Simulate a real-world Go file change
patch := `diff --git a/main.go b/main.go
index 1234567..abcdefg 100644
--- a/main.go
+++ b/main.go
@@ -10,6 +10,15 @@ package main
import "fmt"
+// ProcessData handles data processing
+// It takes input and returns processed output
func ProcessData(input string) string {
+ // Validate input
+ if input == "" {
+ return ""
+ }
+
+ // Transform the data
+ result := strings.ToUpper(input)
- return input
+ return result
}`
stats := AnalyzePatch(patch)
// Count what's actually in the patch:
// Additions (lines starting with +, not +++):
// 1. +// ProcessData handles data processing -> comment
// 2. +// It takes input and returns processed output -> comment
// 3. + // Validate input -> comment
// 4. + if input == "" -> meaningful
// 5. + return "" -> meaningful
// 6. + } -> meaningful
// 7. + (empty line) -> whitespace
// 8. + // Transform the data -> comment
// 9. + result := strings.ToUpper(input) -> meaningful
// 10. + return result -> meaningful
// Total: 10 additions, 5 meaningful, 4 comments, 1 whitespace
// Deletions (lines starting with -, not ---):
// 1. - return input -> meaningful
// Total: 1 deletion, 1 meaningful
assert.Equal(t, 10, stats.TotalAdditions, "Total additions")
assert.Equal(t, 1, stats.TotalDeletions, "Total deletions")
assert.Equal(t, 5, stats.MeaningfulAdditions, "Meaningful additions")
assert.Equal(t, 1, stats.MeaningfulDeletions, "Meaningful deletions")
assert.Equal(t, 4, stats.CommentAdditions, "Comment additions")
assert.Equal(t, 1, stats.WhitespaceAdditions, "Whitespace additions")
}
+8
View File
@@ -15,6 +15,14 @@ type Commit struct {
Repository string `json:"repository"` // owner/repo format
URL string `json:"url"`
// Meaningful line counts (excludes comments and whitespace)
MeaningfulAdditions int `json:"meaningful_additions"`
MeaningfulDeletions int `json:"meaningful_deletions"`
// Comment line counts
CommentAdditions int `json:"comment_additions"`
CommentDeletions int `json:"comment_deletions"`
// Derived fields
HasTests bool `json:"has_tests"`
}
+32 -12
View File
@@ -23,6 +23,14 @@ type ContributorMetrics struct {
LinesDeleted int `json:"lines_deleted"`
FilesChanged int `json:"files_changed"`
// Meaningful line counts (excludes comments and whitespace)
MeaningfulLinesAdded int `json:"meaningful_lines_added"`
MeaningfulLinesDeleted int `json:"meaningful_lines_deleted"`
// Comment and documentation line counts
CommentLinesAdded int `json:"comment_lines_added"`
CommentLinesDeleted int `json:"comment_lines_deleted"`
// PR metrics
PRsOpened int `json:"prs_opened"`
PRsMerged int `json:"prs_merged"`
@@ -46,13 +54,15 @@ type ContributorMetrics struct {
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
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
WorkWeekStreak int `json:"work_week_streak"` // Longest consecutive weekdays (Mon-Fri, weekends don't break streak)
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
OutOfHoursCount int `json:"out_of_hours_count"` // Commits outside 9am-5pm
// Repository participation
RepositoriesContributed []string `json:"repositories_contributed,omitempty"`
@@ -79,6 +89,7 @@ type ScoreBreakdown struct {
Comments int `json:"comments"` // PR review comments (not code comments)
ResponseBonus int `json:"response_bonus"`
LineChanges int `json:"line_changes"`
OutOfHours int `json:"out_of_hours"` // Bonus for out-of-hours commits
}
// RepositoryMetrics holds aggregated metrics for a single repository
@@ -94,6 +105,10 @@ type RepositoryMetrics struct {
ActiveContributors int `json:"active_contributors"`
TotalLinesAdded int `json:"total_lines_added"`
TotalLinesDeleted int `json:"total_lines_deleted"`
// Meaningful line counts (excludes comments and whitespace)
TotalMeaningfulLinesAdded int `json:"total_meaningful_lines_added"`
TotalMeaningfulLinesDeleted int `json:"total_meaningful_lines_deleted"`
}
// TeamMetrics holds aggregated metrics for a team
@@ -110,11 +125,12 @@ type TeamMetrics struct {
// 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
Period Period `json:"period"`
Repositories []RepositoryMetrics `json:"repositories"`
Contributors []ContributorMetrics `json:"contributors"` // Aggregated across all repos
Teams []TeamMetrics `json:"teams"`
Leaderboard []LeaderboardEntry `json:"leaderboard"`
TopAchievers map[string]string `json:"top_achievers"` // category -> login
// Summary stats
TotalContributors int `json:"total_contributors"`
@@ -124,6 +140,10 @@ type GlobalMetrics struct {
TotalLinesAdded int `json:"total_lines_added"`
TotalLinesDeleted int `json:"total_lines_deleted"`
// Meaningful line counts (excludes comments and whitespace)
TotalMeaningfulLinesAdded int `json:"total_meaningful_lines_added"`
TotalMeaningfulLinesDeleted int `json:"total_meaningful_lines_deleted"`
// Velocity timeline (weekly granularity)
VelocityTimeline *VelocityTimeline `json:"velocity_timeline,omitempty"`
}
+4
View File
@@ -32,6 +32,10 @@ type PullRequest struct {
Reviews []Review `json:"reviews,omitempty"`
URL string `json:"url"`
// Meaningful line counts (excludes comments and whitespace)
MeaningfulAdditions int `json:"meaningful_additions"`
MeaningfulDeletions int `json:"meaningful_deletions"`
// Derived fields
TimeToMerge *time.Duration `json:"time_to_merge,omitempty"`
TimeToFirstReview *time.Duration `json:"time_to_first_review,omitempty"`
+33 -8
View File
@@ -40,6 +40,10 @@ func (c *Calculator) Calculate(metrics *models.GlobalMetrics) *models.GlobalMetr
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
@@ -114,6 +118,7 @@ func (c *Calculator) Calculate(metrics *models.GlobalMetrics) *models.GlobalMetr
// 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 {
@@ -157,16 +162,24 @@ func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score
// Commit points
breakdown.Commits = cm.CommitCount * points.Commit
// Line change points
breakdown.LineChanges = int(float64(cm.LinesAdded)*points.LinesAdded +
float64(cm.LinesDeleted)*points.LinesDeleted)
// Line change points - use meaningful lines if configured, otherwise raw counts
linesAdded := cm.LinesAdded
linesDeleted := cm.LinesDeleted
if points.UseMeaningfulLines {
linesAdded = cm.MeaningfulLinesAdded
linesDeleted = cm.MeaningfulLinesDeleted
}
breakdown.LineChanges = int(float64(linesAdded)*points.LinesAdded +
float64(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
// Review points (PR reviews)
breakdown.Reviews = cm.ReviewsGiven * points.PRReviewed
// Comment points (PR review comments)
breakdown.Comments = cm.ReviewComments * points.ReviewComment
// Response time bonus
if cm.ReviewsGiven > 0 && cm.AvgReviewTime > 0 {
@@ -179,9 +192,12 @@ func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score
}
}
// Out of hours bonus (commits outside 9am-5pm)
breakdown.OutOfHours = cm.OutOfHoursCount * points.OutOfHours
// Calculate total
total := breakdown.Commits + breakdown.LineChanges + breakdown.PRs +
breakdown.Reviews + breakdown.ResponseBonus + breakdown.Comments
breakdown.Reviews + breakdown.ResponseBonus + breakdown.Comments + breakdown.OutOfHours
return models.Score{
Total: total,
@@ -193,7 +209,7 @@ 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 {
for _, ach := range c.config.Scoring.GetAchievements() {
earned := false
switch ach.Condition.Type {
@@ -240,6 +256,15 @@ func (c *Calculator) checkAchievements(cm *models.ContributorMetrics) []string {
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
}
if earned {
+459 -67
View File
@@ -98,6 +98,108 @@ func TestCalculator_BasicScoring(t *testing.T) {
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) {
t.Parallel()
@@ -290,40 +392,7 @@ func TestCalculator_Achievements(t *testing.T) {
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,
},
},
}
// Achievements are now hardcoded, no need to set them
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
@@ -333,10 +402,10 @@ func TestCalculator_Achievements(t *testing.T) {
Contributors: []models.ContributorMetrics{
{
Login: "user1",
CommitCount: 15,
PRsOpened: 6,
ReviewsGiven: 5,
AvgReviewTime: 0.5,
CommitCount: 15, // Should earn commit-1, commit-10
PRsOpened: 6, // Should earn pr-1
ReviewsGiven: 5, // Should earn review-1
AvgReviewTime: 0.5, // Should earn review-time-1h, review-time-4h, review-time-24h
RepositoriesContributed: []string{"owner/repo"},
},
},
@@ -347,12 +416,16 @@ func TestCalculator_Achievements(t *testing.T) {
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)
// Should have hardcoded achievements based on thresholds
assert.Contains(t, contributor.Achievements, "commit-1")
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")
assert.Contains(t, contributor.Achievements, "pr-1")
assert.Contains(t, contributor.Achievements, "review-1")
assert.Contains(t, contributor.Achievements, "review-time-1h") // 0.5h < 1h threshold
// Should NOT have commit-50 (only 15 commits)
assert.NotContains(t, contributor.Achievements, "commit-50")
// Should NOT have review-10 (only 5 reviews)
assert.NotContains(t, contributor.Achievements, "review-10")
}
func TestCalculator_AllAchievementTypes(t *testing.T) {
@@ -360,18 +433,7 @@ func TestCalculator_AllAchievementTypes(t *testing.T) {
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}},
}
// Achievements are now hardcoded
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
@@ -400,18 +462,23 @@ func TestCalculator_AllAchievementTypes(t *testing.T) {
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")
// Should have various hardcoded achievements based on thresholds
// Check some key achievements are earned
assert.Contains(t, contributor.Achievements, "commit-1")
assert.Contains(t, contributor.Achievements, "commit-10")
assert.Contains(t, contributor.Achievements, "pr-1")
assert.Contains(t, contributor.Achievements, "review-1")
assert.Contains(t, contributor.Achievements, "review-10")
assert.Contains(t, contributor.Achievements, "comment-10")
assert.Contains(t, contributor.Achievements, "lines-added-100")
assert.Contains(t, contributor.Achievements, "lines-added-1000")
assert.Contains(t, contributor.Achievements, "lines-deleted-100")
assert.Contains(t, contributor.Achievements, "lines-deleted-500")
assert.Contains(t, contributor.Achievements, "review-time-4h") // 1.5h < 4h
assert.Contains(t, contributor.Achievements, "repo-2") // 2 repos
assert.Contains(t, contributor.Achievements, "reviewees-3") // 7 reviewees >= 3
// Should have earned multiple achievements (more than 10)
assert.Greater(t, len(contributor.Achievements), 10)
}
func TestCalculator_TopAchievers(t *testing.T) {
@@ -702,6 +769,76 @@ func TestCalculator_NoReviewsNoBonus(t *testing.T) {
assert.Equal(t, 0, contributor.Score.Breakdown.ResponseBonus)
}
func TestCalculator_OutOfHoursScoring(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
Commit: 10,
OutOfHours: 5, // 5 points per out-of-hours commit
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "night-owl",
CommitCount: 10,
OutOfHoursCount: 8, // 8 commits outside 9am-5pm
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
// Commits: 10 * 10 = 100
// OutOfHours: 8 * 5 = 40
// Total: 140
assert.Equal(t, 100, contributor.Score.Breakdown.Commits)
assert.Equal(t, 40, contributor.Score.Breakdown.OutOfHours)
assert.Equal(t, 140, contributor.Score.Total)
}
func TestCalculator_WorkWeekStreakAchievement(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
// Achievements are now hardcoded
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "consistent-worker",
CommitCount: 20,
WorkWeekStreak: 5, // 5-day work week streak
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
// Should have earned work week streak achievements for 3 and 5 days
assert.Contains(t, contributor.Achievements, "workweek-3")
assert.Contains(t, contributor.Achievements, "workweek-5")
}
func TestContains(t *testing.T) {
t.Parallel()
@@ -713,3 +850,258 @@ func TestContains(t *testing.T) {
assert.False(t, contains(slice, "d"))
assert.False(t, contains([]string{}, "a"))
}
func TestCalculator_MeaningfulLinesScoring(t *testing.T) {
t.Parallel()
t.Run("uses meaningful lines when enabled", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
Commit: 10,
LinesAdded: 0.1,
LinesDeleted: 0.05,
UseMeaningfulLines: true, // Use meaningful lines
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "user1",
CommitCount: 10,
LinesAdded: 1000, // Raw lines
LinesDeleted: 500,
MeaningfulLinesAdded: 800, // Meaningful lines (excluding comments/whitespace)
MeaningfulLinesDeleted: 400,
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
// Line change points should use meaningful lines:
// Meaningful: 800 * 0.1 + 400 * 0.05 = 80 + 20 = 100
// (Not raw: 1000 * 0.1 + 500 * 0.05 = 100 + 25 = 125)
assert.Equal(t, 100, contributor.Score.Breakdown.LineChanges)
// Total: Commits (10 * 10 = 100) + Lines (100) = 200
assert.Equal(t, 200, contributor.Score.Total)
})
t.Run("uses raw lines when disabled", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
Commit: 10,
LinesAdded: 0.1,
LinesDeleted: 0.05,
UseMeaningfulLines: false, // Use raw lines
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "user1",
CommitCount: 10,
LinesAdded: 1000, // Raw lines
LinesDeleted: 500,
MeaningfulLinesAdded: 800, // Meaningful lines (should be ignored)
MeaningfulLinesDeleted: 400,
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
// Line change points should use raw lines:
// Raw: 1000 * 0.1 + 500 * 0.05 = 100 + 25 = 125
assert.Equal(t, 125, contributor.Score.Breakdown.LineChanges)
// Total: Commits (10 * 10 = 100) + Lines (125) = 225
assert.Equal(t, 225, contributor.Score.Total)
})
t.Run("comment-only changes score zero meaningful lines", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
Commit: 10,
LinesAdded: 0.1,
LinesDeleted: 0.05,
UseMeaningfulLines: true,
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "commenter",
CommitCount: 5,
LinesAdded: 100, // All comment lines
LinesDeleted: 50,
MeaningfulLinesAdded: 0, // No meaningful code
MeaningfulLinesDeleted: 0,
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
// Line change points should be 0 since all lines were comments
assert.Equal(t, 0, contributor.Score.Breakdown.LineChanges)
// Total: Commits (5 * 10 = 50) + Lines (0) = 50
assert.Equal(t, 50, contributor.Score.Total)
})
}
func TestCalculator_CommentLinesAchievements(t *testing.T) {
t.Parallel()
t.Run("earns documentation achievements for adding comments", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "documenter",
CommitCount: 10,
CommentLinesAdded: 1500, // Should earn docs-100, docs-500, docs-1000
CommentLinesDeleted: 100, // Should earn docs-del-50
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
// Should have documentation achievements
assert.Contains(t, contributor.Achievements, "docs-100", "Should earn docs-100 for 100+ comment lines")
assert.Contains(t, contributor.Achievements, "docs-500", "Should earn docs-500 for 500+ comment lines")
assert.Contains(t, contributor.Achievements, "docs-1000", "Should earn docs-1000 for 1000+ comment lines")
assert.NotContains(t, contributor.Achievements, "docs-2500", "Should not earn docs-2500 for <2500 comment lines")
// Should have comment cleanup achievement
assert.Contains(t, contributor.Achievements, "docs-del-50", "Should earn docs-del-50 for 50+ comment deletions")
assert.NotContains(t, contributor.Achievements, "docs-del-200", "Should not earn docs-del-200 for <200 deletions")
})
t.Run("earns all documentation deletion achievements", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "cleanup-expert",
CommitCount: 50,
CommentLinesAdded: 100,
CommentLinesDeleted: 3000, // Should earn all deletion tiers
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
// Should have all comment cleanup achievements
assert.Contains(t, contributor.Achievements, "docs-del-50")
assert.Contains(t, contributor.Achievements, "docs-del-200")
assert.Contains(t, contributor.Achievements, "docs-del-500")
assert.Contains(t, contributor.Achievements, "docs-del-1000")
assert.Contains(t, contributor.Achievements, "docs-del-2500")
})
t.Run("aggregates comment lines across multiple repositories", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo1",
Contributors: []models.ContributorMetrics{
{
Login: "multi-repo-doc",
CommitCount: 5,
CommentLinesAdded: 300,
CommentLinesDeleted: 30,
RepositoriesContributed: []string{"owner/repo1"},
},
},
},
{
FullName: "owner/repo2",
Contributors: []models.ContributorMetrics{
{
Login: "multi-repo-doc",
CommitCount: 5,
CommentLinesAdded: 300,
CommentLinesDeleted: 30,
RepositoriesContributed: []string{"owner/repo2"},
},
},
},
},
}
result := calc.Calculate(metrics)
// Check leaderboard entry (aggregated)
require.Len(t, result.Leaderboard, 1)
entry := result.Leaderboard[0]
// Aggregated: 300 + 300 = 600 comment lines added, 30 + 30 = 60 deleted
assert.Contains(t, entry.Achievements, "docs-100")
assert.Contains(t, entry.Achievements, "docs-500")
assert.NotContains(t, entry.Achievements, "docs-1000", "600 < 1000")
assert.Contains(t, entry.Achievements, "docs-del-50")
assert.NotContains(t, entry.Achievements, "docs-del-200", "60 < 200")
})
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,9 +8,9 @@
<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 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-C2QviOxm.js"></script>
<script type="module" crossorigin src="./assets/index-LBN7XWrH.js"></script>
<link rel="modulepreload" crossorigin href="./assets/chart-Bcjh2pZL.js">
<link rel="stylesheet" crossorigin href="./assets/index-CmyGiR94.css">
<link rel="stylesheet" crossorigin href="./assets/index-8XjWwD9J.css">
</head>
<body class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 font-sans transition-colors duration-300">
<div id="app"></div>
+4 -12
View File
@@ -106,23 +106,15 @@ func (g *Generator) generateDataFiles(metrics *models.GlobalMetrics) error {
}
}
// Per-contributor data
contributorsSeen := make(map[string]bool)
// Per-contributor data (use aggregated global contributors, not per-repo)
contributorDir := filepath.Join(dataDir, "contributors")
if err := os.MkdirAll(contributorDir, 0750); err != nil {
return err
}
for _, repo := range metrics.Repositories {
for _, contributor := range repo.Contributors {
if contributorsSeen[contributor.Login] {
continue
}
contributorsSeen[contributor.Login] = true
if err := writeJSON(filepath.Join(contributorDir, contributor.Login+".json"), contributor); err != nil {
return err
}
for _, contributor := range metrics.Contributors {
if err := writeJSON(filepath.Join(contributorDir, contributor.Login+".json"), contributor); err != nil {
return err
}
}
+116 -16
View File
@@ -254,17 +254,14 @@ func TestGenerator_GenerateContributorJSON(t *testing.T) {
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
// Generator now uses global Contributors, not per-repo Contributors
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
Contributors: []models.ContributorMetrics{
{
Contributors: []models.ContributorMetrics{
{
Login: "john-doe",
Name: "John Doe",
CommitCount: 50,
PRsOpened: 10,
},
},
Login: "john-doe",
Name: "John Doe",
CommitCount: 50,
PRsOpened: 10,
},
},
}
@@ -287,33 +284,43 @@ func TestGenerator_GenerateContributorJSON(t *testing.T) {
assert.Equal(t, 10, result.PRsOpened)
}
func TestGenerator_ContributorDeduplication(t *testing.T) {
func TestGenerator_UsesGlobalContributorsNotPerRepo(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
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{
// Per-repo data (used for repository-specific pages)
Repositories: []models.RepositoryMetrics{
{
Owner: "org",
Name: "repo1",
Contributors: []models.ContributorMetrics{
{Login: "user1", CommitCount: 50},
{Login: "user1", CommitCount: 50, PRsOpened: 5},
},
},
{
Owner: "org",
Name: "repo2",
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)
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")
data, err := os.ReadFile(contributorPath)
require.NoError(t, err)
@@ -322,8 +329,84 @@ func TestGenerator_ContributorDeduplication(t *testing.T) {
err = json.Unmarshal(data, &result)
require.NoError(t, err)
// Should be the first one (50 commits)
assert.Equal(t, 50, result.CommitCount)
// Should be the aggregated count (125 commits, 15 PRs), NOT 50 or 75
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) {
@@ -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{
{
Name: "Core Team",
@@ -499,4 +588,15 @@ func TestGenerator_GenerateWithFullMetrics(t *testing.T) {
_, err := os.Stat(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")
}
+44 -69
View File
@@ -14,6 +14,7 @@ import (
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/lukaszraczylo/git-velocity/internal/diff"
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
)
@@ -128,56 +129,6 @@ func (r *Repository) fetch(ctx context.Context, repoPath, token string) error {
return nil
}
// isCommentLine checks if a line is a code comment (should not count as contribution)
func isCommentLine(line string) bool {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return true // Empty lines don't count
}
// Common comment patterns across languages
commentPrefixes := []string{
"//", // C, C++, Java, Go, JS, etc.
"#", // Python, Ruby, Shell, YAML
"/*", // C-style block comment start
"*/", // C-style block comment end
"*", // C-style block comment continuation
"<!--", // HTML/XML comment
"-->", // HTML/XML comment end
"--", // SQL, Lua, Haskell
";", // Assembly, Lisp, INI files
"'", // VB comment
"\"\"\"", // Python docstring
"'''", // Python docstring
}
for _, prefix := range commentPrefixes {
if strings.HasPrefix(trimmed, prefix) {
return true
}
}
return false
}
// isDocumentationFile checks if a file is documentation-only
func isDocumentationFile(filename string) bool {
// Documentation file extensions and patterns
docPatterns := []string{
".md", ".markdown", ".rst", ".txt", ".adoc",
"README", "CHANGELOG", "LICENSE", "CONTRIBUTING",
"docs/", "documentation/", "/doc/",
}
lowerFilename := strings.ToLower(filename)
for _, pattern := range docPatterns {
if strings.Contains(lowerFilename, strings.ToLower(pattern)) {
return true
}
}
return false
}
// FetchCommits retrieves commits from the local repository using go-git
func (r *Repository) FetchCommits(ctx context.Context, owner, name string, since, until *time.Time) ([]models.Commit, error) {
repoPath := r.repoPath(owner, name)
@@ -242,7 +193,7 @@ func (r *Repository) FetchCommits(ctx context.Context, owner, name string, since
}
// Get file stats for this commit
additions, deletions, filesChanged, hasTests := r.getCommitStats(c, testPatterns)
stats := r.getCommitStats(c, testPatterns)
// Extract login from email
authorLogin := extractLoginFromEmail(c.Author.Email, c.Author.Name)
@@ -261,13 +212,17 @@ func (r *Repository) FetchCommits(ctx context.Context, owner, name string, since
Name: c.Committer.Name,
Email: c.Committer.Email,
},
Date: commitTime,
Additions: additions,
Deletions: deletions,
FilesChanged: filesChanged,
Repository: fmt.Sprintf("%s/%s", owner, name),
URL: fmt.Sprintf("https://github.com/%s/%s/commit/%s", owner, name, c.Hash.String()),
HasTests: hasTests,
Date: commitTime,
Additions: stats.Additions,
Deletions: stats.Deletions,
MeaningfulAdditions: stats.MeaningfulAdditions,
MeaningfulDeletions: stats.MeaningfulDeletions,
CommentAdditions: stats.CommentAdditions,
CommentDeletions: stats.CommentDeletions,
FilesChanged: stats.FilesChanged,
Repository: fmt.Sprintf("%s/%s", owner, name),
URL: fmt.Sprintf("https://github.com/%s/%s/commit/%s", owner, name, c.Hash.String()),
HasTests: stats.HasTests,
}
commits = append(commits, commit)
@@ -286,8 +241,22 @@ func (r *Repository) FetchCommits(ctx context.Context, owner, name string, since
return commits, nil
}
// commitStats holds the statistics for a commit
type commitStats struct {
Additions int
Deletions int
MeaningfulAdditions int
MeaningfulDeletions int
CommentAdditions int
CommentDeletions int
FilesChanged int
HasTests bool
}
// getCommitStats calculates additions, deletions, files changed for a commit
func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) (additions, deletions, filesChanged int, hasTests bool) {
func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) commitStats {
stats := commitStats{}
// Get parent commit for diff
parentIter := c.Parents()
parent, err := parentIter.Next()
@@ -299,7 +268,7 @@ func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) (ad
currentTree, err := c.Tree()
if err != nil {
return 0, 0, 0, false
return stats
}
// Get changes between parent and current
@@ -312,7 +281,7 @@ func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) (ad
}
if err != nil {
return 0, 0, 0, false
return stats
}
filesSet := make(map[string]bool)
@@ -327,19 +296,19 @@ func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) (ad
}
// Skip documentation files
if isDocumentationFile(filePath) {
if diff.IsDocumentationFile(filePath) {
continue
}
// Count unique files
if !filesSet[filePath] {
filesSet[filePath] = true
filesChanged++
stats.FilesChanged++
// Check for test files
for _, pattern := range testPatterns {
if strings.Contains(filePath, pattern) {
hasTests = true
stats.HasTests = true
break
}
}
@@ -359,14 +328,20 @@ func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) (ad
switch chunk.Type() {
case 1: // Add
for _, line := range lines {
if !isCommentLine(line) {
additions++
stats.Additions++
if diff.IsMeaningfulLine(line) {
stats.MeaningfulAdditions++
} else if diff.IsCommentLine(line) && !diff.IsWhitespaceLine(line) {
stats.CommentAdditions++
}
}
case 2: // Delete
for _, line := range lines {
if !isCommentLine(line) {
deletions++
stats.Deletions++
if diff.IsMeaningfulLine(line) {
stats.MeaningfulDeletions++
} else if diff.IsCommentLine(line) && !diff.IsWhitespaceLine(line) {
stats.CommentDeletions++
}
}
}
@@ -374,7 +349,7 @@ func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) (ad
}
}
return additions, deletions, filesChanged, hasTests
return stats
}
// extractLoginFromEmail tries to extract GitHub login from email
+37 -13
View File
@@ -13,6 +13,7 @@ import (
"github.com/google/go-github/v68/github"
"github.com/lukaszraczylo/git-velocity/internal/config"
"github.com/lukaszraczylo/git-velocity/internal/diff"
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
"github.com/lukaszraczylo/git-velocity/internal/github/cache"
)
@@ -726,10 +727,15 @@ func convertCommit(c *github.RepositoryCommit, owner, repo string) models.Commit
}
filesChanged = len(c.Files)
// Detect if commit includes tests
// Detect if commit includes tests and calculate meaningful/comment line counts
hasTests := false
var meaningfulAdditions, meaningfulDeletions int
var commentAdditions, commentDeletions int
for _, f := range c.Files {
filename := f.GetFilename()
// Check for test files
if strings.Contains(filename, "_test.go") ||
strings.Contains(filename, ".test.") ||
strings.Contains(filename, ".spec.") ||
@@ -737,7 +743,21 @@ func convertCommit(c *github.RepositoryCommit, owner, repo string) models.Commit
strings.Contains(filename, "/test/") ||
strings.Contains(filename, "__tests__") {
hasTests = true
break
}
// Skip documentation files for meaningful line calculation
if diff.IsDocumentationFile(filename) {
continue
}
// Analyze file patch to get meaningful and comment line counts
patch := f.GetPatch()
if patch != "" {
stats := diff.AnalyzePatch(patch)
meaningfulAdditions += stats.MeaningfulAdditions
meaningfulDeletions += stats.MeaningfulDeletions
commentAdditions += stats.CommentAdditions
commentDeletions += stats.CommentDeletions
}
}
@@ -747,17 +767,21 @@ func convertCommit(c *github.RepositoryCommit, owner, repo string) models.Commit
}
return models.Commit{
SHA: c.GetSHA(),
Message: message,
Author: author,
Committer: committer,
Date: date,
Additions: additions,
Deletions: deletions,
FilesChanged: filesChanged,
Repository: fmt.Sprintf("%s/%s", owner, repo),
URL: c.GetHTMLURL(),
HasTests: hasTests,
SHA: c.GetSHA(),
Message: message,
Author: author,
Committer: committer,
Date: date,
Additions: additions,
Deletions: deletions,
MeaningfulAdditions: meaningfulAdditions,
MeaningfulDeletions: meaningfulDeletions,
CommentAdditions: commentAdditions,
CommentDeletions: commentDeletions,
FilesChanged: filesChanged,
Repository: fmt.Sprintf("%s/%s", owner, repo),
URL: c.GetHTMLURL(),
HasTests: hasTests,
}
}
+34 -16
View File
@@ -24,26 +24,13 @@ func New(directory, port string) *Server {
// Start starts the HTTP server
func (s *Server) Start() error {
// Check if directory exists
if _, err := os.Stat(s.directory); os.IsNotExist(err) {
return fmt.Errorf("directory does not exist: %s", s.directory)
}
// Get absolute path
absPath, err := filepath.Abs(s.directory)
handler, err := s.CreateHandler()
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
return err
}
// Create file server with directory listing disabled for security
fs := http.FileServer(http.Dir(absPath))
// Wrap with middleware
handler := s.loggingMiddleware(s.cacheMiddleware(fs))
addr := fmt.Sprintf(":%s", s.port)
srv := &http.Server{
Addr: addr,
Addr: s.GetAddress(),
Handler: handler,
ReadTimeout: 15 * time.Second,
ReadHeaderTimeout: 15 * time.Second,
@@ -75,3 +62,34 @@ func (s *Server) cacheMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
// CreateHandler creates and returns the HTTP handler without starting the server.
// This is useful for testing and for embedding the server in other applications.
func (s *Server) CreateHandler() (http.Handler, error) {
// Check if directory exists
if _, err := os.Stat(s.directory); os.IsNotExist(err) {
return nil, fmt.Errorf("directory does not exist: %s", s.directory)
}
// Get absolute path
absPath, err := filepath.Abs(s.directory)
if err != nil {
return nil, fmt.Errorf("failed to get absolute path: %w", err)
}
// Create file server with directory listing disabled for security
fs := http.FileServer(http.Dir(absPath))
// Wrap with middleware
return s.loggingMiddleware(s.cacheMiddleware(fs)), nil
}
// GetAddress returns the server address in the format :port
func (s *Server) GetAddress() string {
return fmt.Sprintf(":%s", s.port)
}
// GetDirectory returns the directory being served
func (s *Server) GetDirectory() string {
return s.directory
}
+228
View File
@@ -207,3 +207,231 @@ func TestServer_ServesIndexHtml(t *testing.T) {
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "Test Page")
}
func TestServer_CreateHandler(t *testing.T) {
tempDir := t.TempDir()
// Create an index.html
indexFile := filepath.Join(tempDir, "index.html")
err := os.WriteFile(indexFile, []byte("<html><body>Handler Test</body></html>"), 0644)
require.NoError(t, err)
s := New(tempDir, "8080")
handler, err := s.CreateHandler()
require.NoError(t, err)
ts := httptest.NewServer(handler)
defer ts.Close()
resp, err := http.Get(ts.URL + "/")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "Handler Test")
// Check middleware headers are applied
assert.Equal(t, "no-cache, no-store, must-revalidate", resp.Header.Get("Cache-Control"))
assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin"))
}
func TestServer_CreateHandlerWithNonExistentDirectory(t *testing.T) {
t.Parallel()
s := New("/this/directory/does/not/exist", "8080")
handler, err := s.CreateHandler()
assert.Error(t, err)
assert.Nil(t, handler)
assert.Contains(t, err.Error(), "directory does not exist")
}
func TestServer_GetAddress(t *testing.T) {
t.Parallel()
tests := []struct {
name string
port string
expected string
}{
{"standard port", "8080", ":8080"},
{"different port", "3000", ":3000"},
{"port 0 for random", "0", ":0"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := New(".", tt.port)
assert.Equal(t, tt.expected, s.GetAddress())
})
}
}
func TestServer_GetDirectory(t *testing.T) {
t.Parallel()
s := New("/some/path", "8080")
assert.Equal(t, "/some/path", s.GetDirectory())
}
func TestServer_ServesJSONWithCorrectContentType(t *testing.T) {
tempDir := t.TempDir()
// Create a JSON file
jsonFile := filepath.Join(tempDir, "data.json")
err := os.WriteFile(jsonFile, []byte(`{"status": "ok"}`), 0644)
require.NoError(t, err)
s := New(tempDir, "0")
handler, err := s.CreateHandler()
require.NoError(t, err)
ts := httptest.NewServer(handler)
defer ts.Close()
resp, err := http.Get(ts.URL + "/data.json")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Check content type is JSON
contentType := resp.Header.Get("Content-Type")
assert.Contains(t, contentType, "application/json")
}
func TestServer_ServesHTMLWithCorrectContentType(t *testing.T) {
tempDir := t.TempDir()
// Create an HTML file
htmlFile := filepath.Join(tempDir, "page.html")
err := os.WriteFile(htmlFile, []byte("<html><body>HTML Page</body></html>"), 0644)
require.NoError(t, err)
s := New(tempDir, "0")
handler, err := s.CreateHandler()
require.NoError(t, err)
ts := httptest.NewServer(handler)
defer ts.Close()
resp, err := http.Get(ts.URL + "/page.html")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Check content type is HTML
contentType := resp.Header.Get("Content-Type")
assert.Contains(t, contentType, "text/html")
}
func TestServer_CORSHeaders(t *testing.T) {
tempDir := t.TempDir()
// Create a test file
testFile := filepath.Join(tempDir, "test.txt")
err := os.WriteFile(testFile, []byte("test content"), 0644)
require.NoError(t, err)
s := New(tempDir, "0")
handler, err := s.CreateHandler()
require.NoError(t, err)
ts := httptest.NewServer(handler)
defer ts.Close()
resp, err := http.Get(ts.URL + "/test.txt")
require.NoError(t, err)
defer resp.Body.Close()
// Check CORS header
assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin"))
}
func TestServer_CacheDisabledHeaders(t *testing.T) {
tempDir := t.TempDir()
// Create a test file
testFile := filepath.Join(tempDir, "test.txt")
err := os.WriteFile(testFile, []byte("test content"), 0644)
require.NoError(t, err)
s := New(tempDir, "0")
handler, err := s.CreateHandler()
require.NoError(t, err)
ts := httptest.NewServer(handler)
defer ts.Close()
resp, err := http.Get(ts.URL + "/test.txt")
require.NoError(t, err)
defer resp.Body.Close()
// Check cache headers are disabled for development
assert.Equal(t, "no-cache, no-store, must-revalidate", resp.Header.Get("Cache-Control"))
assert.Equal(t, "no-cache", resp.Header.Get("Pragma"))
assert.Equal(t, "0", resp.Header.Get("Expires"))
}
func TestServer_LoggingMiddlewareWithDifferentMethods(t *testing.T) {
t.Parallel()
s := New(".", "8080")
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
wrapped := s.loggingMiddleware(handler)
methods := []string{"GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"}
for _, method := range methods {
t.Run(method, func(t *testing.T) {
req := httptest.NewRequest(method, "/test-path", nil)
rr := httptest.NewRecorder()
wrapped.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
})
}
}
func TestServer_CacheMiddlewarePreservesResponseBody(t *testing.T) {
t.Parallel()
s := New(".", "8080")
expectedBody := "This is the response body content"
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(expectedBody))
})
wrapped := s.cacheMiddleware(handler)
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
wrapped.ServeHTTP(rr, req)
body, _ := io.ReadAll(rr.Body)
assert.Equal(t, expectedBody, string(body))
}
func TestNew_WithEmptyValues(t *testing.T) {
t.Parallel()
s := New("", "")
assert.Equal(t, "", s.directory)
assert.Equal(t, "", s.port)
}
func TestNew_WithSpecialCharactersInPath(t *testing.T) {
t.Parallel()
path := "/path/with spaces/and-dashes/and_underscores"
s := New(path, "8080")
assert.Equal(t, path, s.directory)
}
+126 -70
View File
@@ -29,97 +29,153 @@ const getTierFromThreshold = (threshold) => {
return 1
}
// Extract threshold from achievement ID (e.g., "commit-100" -> 100)
// Extract threshold from achievement ID (e.g., "commit-100" -> 100, "docs-del-50" -> 50)
const extractThreshold = (id) => {
const match = id.match(/(\d+)$/)
if (match) return parseInt(match[1], 10)
// Special cases for non-numeric achievements
if (id === 'first-commit' || id === 'pr-opener' || id === 'reviewer') return 1
return 50 // Default for special achievements
}
// Achievement definitions matching the Go backend
// Achievement definitions matching the Go backend (internal/config/schema.go)
const achievements = {
// Commit achievements - Journey from apprentice to legend
'first-commit': { name: 'Hello World', description: 'Made your first commit', icon: 'fa-baby' },
'commit-10': { name: 'Seedling', description: 'Made 10 commits', icon: 'fa-seedling' },
'commit-25': { name: 'Momentum', description: 'Made 25 commits', icon: 'fa-wind' },
'commit-50': { name: 'Trailblazer', description: 'Made 50 commits', icon: 'fa-hiking' },
'commit-100': { name: 'Centurion', description: 'Made 100 commits', icon: 'fa-shield-halved' },
'commit-250': { name: 'Relentless', description: 'Made 250 commits', icon: 'fa-bolt-lightning' },
'commit-500': { name: 'Unstoppable', description: 'Made 500 commits', icon: 'fa-meteor' },
'commit-1000': { name: 'Grandmaster', description: 'Made 1000 commits', icon: 'fa-chess-king' },
'commit-5000': { name: 'Titan', description: 'Made 5000 commits', icon: 'fa-mountain-sun' },
'commit-10000': { name: 'Immortal', description: 'Made 10000 commits', icon: 'fa-dragon' },
'commit-25000': { name: 'Ascended', description: 'Made 25000 commits', icon: 'fa-infinity' },
// ===== COMMIT COUNT (Tiers: 1, 10, 50, 100, 500, 1000) =====
'commit-1': { name: 'First Steps', description: 'Made your first commit', icon: 'fa-baby' },
'commit-10': { name: 'Getting Started', description: 'Made 10 commits', icon: 'fa-seedling' },
'commit-50': { name: 'Contributor', description: 'Made 50 commits', icon: 'fa-code' },
'commit-100': { name: 'Committed', description: 'Made 100 commits', icon: 'fa-fire' },
'commit-500': { name: 'Code Machine', description: 'Made 500 commits', icon: 'fa-robot' },
'commit-1000': { name: 'Code Warrior', description: 'Made 1000 commits', icon: 'fa-crown' },
// PR achievements - The art of collaboration
'pr-opener': { name: 'First Blood', description: 'Opened your first pull request', icon: 'fa-flag-checkered' },
'pr-10': { name: 'Collaborator', description: 'Opened 10 pull requests', icon: 'fa-handshake' },
'pr-25': { name: 'Integrator', description: 'Opened 25 pull requests', icon: 'fa-code-branch' },
'pr-50': { name: 'Architect', description: 'Opened 50 pull requests', icon: 'fa-building' },
'pr-100': { name: 'Vanguard', description: 'Opened 100 pull requests', icon: 'fa-rocket' },
// ===== PR OPENED (Tiers: 1, 10, 25, 50, 100, 250) =====
'pr-1': { name: 'PR Pioneer', description: 'Opened your first pull request', icon: 'fa-code-pull-request' },
'pr-10': { name: 'PR Regular', description: 'Opened 10 pull requests', icon: 'fa-code-branch' },
'pr-25': { name: 'PR Pro', description: 'Opened 25 pull requests', icon: 'fa-code-compare' },
'pr-50': { name: 'Merge Master', description: 'Opened 50 pull requests', icon: 'fa-code-merge' },
'pr-100': { name: 'PR Champion', description: 'Opened 100 pull requests', icon: 'fa-trophy' },
'pr-250': { name: 'PR Legend', description: 'Opened 250 pull requests', icon: 'fa-medal' },
// Review achievements - The guardian path
'reviewer': { name: 'Watchful Eye', description: 'Reviewed your first pull request', icon: 'fa-eye' },
'reviewer-10': { name: 'Sentinel', description: 'Reviewed 10 pull requests', icon: 'fa-shield' },
'reviewer-25': { name: 'Gatekeeper', description: 'Reviewed 25 pull requests', icon: 'fa-dungeon' },
'reviewer-50': { name: 'Oracle', description: 'Reviewed 50 pull requests', icon: 'fa-hat-wizard' },
'reviewer-100': { name: 'Sage', description: 'Reviewed 100 pull requests', icon: 'fa-book-skull' },
// ===== REVIEWS (Tiers: 1, 10, 25, 50, 100, 250) =====
'review-1': { name: 'First Review', description: 'Reviewed your first pull request', icon: 'fa-magnifying-glass' },
'review-10': { name: 'Reviewer', description: 'Reviewed 10 pull requests', icon: 'fa-eye' },
'review-25': { name: 'Review Regular', description: 'Reviewed 25 pull requests', icon: 'fa-glasses' },
'review-50': { name: 'Review Expert', description: 'Reviewed 50 pull requests', icon: 'fa-user-check' },
'review-100': { name: 'Review Guru', description: 'Reviewed 100 pull requests', icon: 'fa-user-graduate' },
'review-250': { name: 'Review Master', description: 'Reviewed 250 pull requests', icon: 'fa-award' },
// Speed achievements - Time is of the essence
'speed-demon': { name: 'Lightning Rod', description: 'Average review response under 1 hour', icon: 'fa-bolt' },
'quick-responder': { name: 'Flash', description: 'Average review response under 4 hours', icon: 'fa-gauge-high' },
// ===== REVIEW COMMENTS (Tiers: 10, 50, 100, 250, 500) =====
'comment-10': { name: 'Commentator', description: 'Left 10 PR review comments', icon: 'fa-comment' },
'comment-50': { name: 'Feedback Giver', description: 'Left 50 PR review comments', icon: 'fa-comments' },
'comment-100': { name: 'Code Critic', description: 'Left 100 PR review comments', icon: 'fa-comment-dots' },
'comment-250': { name: 'Feedback Expert', description: 'Left 250 PR review comments', icon: 'fa-message' },
'comment-500': { name: 'Comment Champion', description: 'Left 500 PR review comments', icon: 'fa-scroll' },
// Comment achievements
'commentator': { name: 'Wordsmith', description: 'Left 50 PR review comments', icon: 'fa-feather-pointed' },
// ===== LINES ADDED (Tiers: 100, 1000, 5000, 10000, 50000) =====
'lines-added-100': { name: 'First Hundred', description: 'Added 100 lines of code', icon: 'fa-plus' },
'lines-added-1000': { name: 'Thousand Lines', description: 'Added 1000 lines of code', icon: 'fa-layer-group' },
'lines-added-5000': { name: 'Five Thousand', description: 'Added 5000 lines of code', icon: 'fa-cubes' },
'lines-added-10000': { name: 'Ten Thousand', description: 'Added 10000 lines of code', icon: 'fa-mountain' },
'lines-added-50000': { name: 'Code Mountain', description: 'Added 50000 lines of code', icon: 'fa-mountain-sun' },
// Lines of code achievements - Volume mastery
'lines-1000': { name: 'Scribe', description: 'Added 1000 lines of code', icon: 'fa-scroll' },
'lines-10000': { name: 'Novelist', description: 'Added 10000 lines of code', icon: 'fa-book' },
'lines-100000': { name: 'Encyclopedia', description: 'Added 100000 lines of code', icon: 'fa-landmark' },
// ===== LINES DELETED (Tiers: 100, 500, 1000, 5000, 10000) =====
'lines-deleted-100': { name: 'Tidying Up', description: 'Deleted 100 lines of code', icon: 'fa-eraser' },
'lines-deleted-500': { name: 'Spring Cleaning', description: 'Deleted 500 lines of code', icon: 'fa-broom' },
'lines-deleted-1000': { name: 'Code Cleaner', description: 'Deleted 1000 lines of code', icon: 'fa-trash-can' },
'lines-deleted-5000': { name: 'Refactoring Hero', description: 'Deleted 5000 lines of code', icon: 'fa-recycle' },
'lines-deleted-10000': { name: 'Deletion Master', description: 'Deleted 10000 lines of code', icon: 'fa-dumpster-fire' },
// Deletion achievements - The minimalist way
'cleaner': { name: 'Pruner', description: 'Deleted 1000 lines of code', icon: 'fa-scissors' },
'refactorer': { name: 'Surgeon', description: 'Deleted 10000 lines of code', icon: 'fa-syringe' },
'annihilator': { name: 'Annihilator', description: 'Deleted 100000 lines of code', icon: 'fa-explosion' },
// ===== REVIEW RESPONSE TIME (Tiers: 24h, 4h, 1h) =====
'review-time-24h': { name: 'Same Day Reviewer', description: 'Average review response under 24 hours', icon: 'fa-clock' },
'review-time-4h': { name: 'Quick Responder', description: 'Average review response under 4 hours', icon: 'fa-stopwatch' },
'review-time-1h': { name: 'Speed Demon', description: 'Average review response under 1 hour', icon: 'fa-bolt' },
// Multi-repo achievements - The wanderer
'multi-repo': { name: 'Nomad', description: 'Contributed to 5 repositories', icon: 'fa-compass' },
'multi-repo-10': { name: 'Explorer', description: 'Contributed to 10 repositories', icon: 'fa-map' },
// ===== MULTI-REPO (Tiers: 2, 5, 10) =====
'repo-2': { name: 'Multi-Repo', description: 'Contributed to 2 repositories', icon: 'fa-folder' },
'repo-5': { name: 'Repo Explorer', description: 'Contributed to 5 repositories', icon: 'fa-folder-tree' },
'repo-10': { name: 'Repo Master', description: 'Contributed to 10 repositories', icon: 'fa-network-wired' },
// Team collaboration - Social butterfly
'team-player': { name: 'Ambassador', description: 'Reviewed PRs from 10 different contributors', icon: 'fa-users' },
'team-player-25': { name: 'Diplomat', description: 'Reviewed PRs from 25 different contributors', icon: 'fa-globe' },
// ===== UNIQUE REVIEWEES (Tiers: 3, 10, 25) =====
'reviewees-3': { name: 'Helpful Colleague', description: 'Reviewed PRs from 3 different contributors', icon: 'fa-user-group' },
'reviewees-10': { name: 'Team Player', description: 'Reviewed PRs from 10 different contributors', icon: 'fa-people-group' },
'reviewees-25': { name: 'Community Pillar', description: 'Reviewed PRs from 25 different contributors', icon: 'fa-people-roof' },
// PR size achievements - Go big or go home
'big-pr': { name: 'Heavyweight', description: 'Merged a PR with 1000+ lines', icon: 'fa-dumbbell' },
'mega-pr': { name: 'Colossus', description: 'Merged a PR with 5000+ lines', icon: 'fa-monument' },
// ===== PR SIZE - LARGE (Tiers: 500, 1000, 5000) =====
'large-pr-500': { name: 'Big Change', description: 'Merged a PR with 500+ lines changed', icon: 'fa-expand' },
'large-pr-1000': { name: 'Heavy Lifter', description: 'Merged a PR with 1000+ lines changed', icon: 'fa-weight-hanging' },
'large-pr-5000': { name: 'Mega Merge', description: 'Merged a PR with 5000+ lines changed', icon: 'fa-dumbbell' },
// Small PR achievements - Precision strikes
'small-pr-10': { name: 'Minimalist', description: 'Merged 10 PRs under 100 lines', icon: 'fa-compress' },
'small-pr-50': { name: 'Atomic', description: 'Merged 50 PRs under 100 lines', icon: 'fa-atom' },
// ===== SMALL PRs (Tiers: 5, 10, 25, 50) =====
'small-pr-5': { name: 'Small Changes', description: 'Merged 5 PRs under 100 lines', icon: 'fa-compress' },
'small-pr-10': { name: 'Small PR Advocate', description: 'Merged 10 PRs under 100 lines', icon: 'fa-minimize' },
'small-pr-25': { name: 'Atomic Commits', description: 'Merged 25 PRs under 100 lines', icon: 'fa-atom' },
'small-pr-50': { name: 'Micro PR Master', description: 'Merged 50 PRs under 100 lines', icon: 'fa-microchip' },
// Perfect PR achievements - Flawless execution
'perfect-pr-5': { name: 'Sharpshooter', description: '5 PRs merged without changes requested', icon: 'fa-bullseye' },
'perfect-pr-25': { name: 'Perfectionist', description: '25 PRs merged without changes requested', icon: 'fa-gem' },
'perfect-pr-100': { name: 'Immaculate', description: '100 PRs merged without changes requested', icon: 'fa-crown' },
// ===== PERFECT PRs (Tiers: 1, 5, 10, 25) =====
'perfect-pr-1': { name: 'First Try', description: '1 PR merged without changes requested', icon: 'fa-check' },
'perfect-pr-5': { name: 'Clean Code', description: '5 PRs merged without changes requested', icon: 'fa-check-double' },
'perfect-pr-10': { name: 'Quality Author', description: '10 PRs merged without changes requested', icon: 'fa-circle-check' },
'perfect-pr-25': { name: 'Flawless', description: '25 PRs merged without changes requested', icon: 'fa-gem' },
// Streak achievements - Consistency is key
'streak-7': { name: 'Hot Streak', description: '7 day contribution streak', icon: 'fa-fire' },
'streak-30': { name: 'Ironclad', description: '30 day contribution streak', icon: 'fa-link' },
'streak-90': { name: 'Unbreakable', description: '90 day contribution streak', icon: 'fa-diamond' },
// ===== ACTIVE DAYS (Tiers: 7, 30, 60, 100) =====
'active-7': { name: 'Week Active', description: 'Active on 7 different days', icon: 'fa-calendar-day' },
'active-30': { name: 'Month Active', description: 'Active on 30 different days', icon: 'fa-calendar-week' },
'active-60': { name: 'Consistent Contributor', description: 'Active on 60 different days', icon: 'fa-chart-line' },
'active-100': { name: 'Dedicated Developer', description: 'Active on 100 different days', icon: 'fa-fire-flame-curved' },
// Time-based achievements - When you code matters
'early-bird': { name: 'Dawn Patrol', description: '50 commits before 9am', icon: 'fa-sun' },
'night-owl': { name: 'Nighthawk', description: '50 commits after 9pm', icon: 'fa-moon' },
'nosferatu': { name: 'Vampire', description: '25 commits between midnight and 4am', icon: 'fa-ghost' },
'weekend-warrior': { name: 'No Days Off', description: '25 weekend commits', icon: 'fa-calendar-xmark' },
// ===== LONGEST STREAK (Tiers: 3, 7, 14, 30) =====
'streak-3': { name: 'Getting Rolling', description: '3 day contribution streak', icon: 'fa-forward' },
'streak-7': { name: 'Week Warrior', description: '7 day contribution streak', icon: 'fa-calendar-week' },
'streak-14': { name: 'Two Week Streak', description: '14 day contribution streak', icon: 'fa-fire' },
'streak-30': { name: 'Month Master', description: '30 day contribution streak', icon: 'fa-calendar-check' },
// Activity achievements - Showing up matters
'active-30': { name: 'Reliable', description: 'Active on 30 different days', icon: 'fa-calendar-check' },
'active-100': { name: 'Stalwart', description: 'Active on 100 different days', icon: 'fa-tower-observation' },
'active-365': { name: 'Eternal', description: 'Active on 365 different days', icon: 'fa-sun-plant-wilt' }
// ===== WORK WEEK STREAK (Tiers: 3, 5, 10, 20) =====
'workweek-3': { name: 'Work Week Start', description: '3 consecutive weekday streak', icon: 'fa-briefcase' },
'workweek-5': { name: 'Full Work Week', description: '5 consecutive weekday streak', icon: 'fa-building' },
'workweek-10': { name: 'Two Week Grind', description: '10 consecutive weekday streak', icon: 'fa-business-time' },
'workweek-20': { name: 'Month of Mondays', description: '20 consecutive weekday streak', icon: 'fa-landmark' },
// ===== EARLY BIRD (Tiers: 10, 25, 50, 100) =====
'earlybird-10': { name: 'Early Riser', description: '10 commits before 9am', icon: 'fa-mug-hot' },
'earlybird-25': { name: 'Morning Person', description: '25 commits before 9am', icon: 'fa-cloud-sun' },
'earlybird-50': { name: 'Early Bird', description: '50 commits before 9am', icon: 'fa-sun' },
'earlybird-100': { name: 'Dawn Warrior', description: '100 commits before 9am', icon: 'fa-sunrise' },
// ===== NIGHT OWL (Tiers: 10, 25, 50, 100) =====
'nightowl-10': { name: 'Late Worker', description: '10 commits after 9pm', icon: 'fa-cloud-moon' },
'nightowl-25': { name: 'Evening Coder', description: '25 commits after 9pm', icon: 'fa-moon' },
'nightowl-50': { name: 'Night Owl', description: '50 commits after 9pm', icon: 'fa-star' },
'nightowl-100': { name: 'Nocturnal', description: '100 commits after 9pm', icon: 'fa-star-and-crescent' },
// ===== MIDNIGHT CODER (Tiers: 5, 10, 25, 50) =====
'midnight-5': { name: 'Night Shift', description: '5 commits between midnight and 4am', icon: 'fa-ghost' },
'midnight-10': { name: 'Insomniac', description: '10 commits between midnight and 4am', icon: 'fa-bed' },
'midnight-25': { name: 'Nosferatu', description: '25 commits between midnight and 4am', icon: 'fa-skull' },
'midnight-50': { name: 'Vampire Coder', description: '50 commits between midnight and 4am', icon: 'fa-skull-crossbones' },
// ===== WEEKEND WARRIOR (Tiers: 5, 10, 25, 50) =====
'weekend-5': { name: 'Weekend Work', description: '5 weekend commits', icon: 'fa-couch' },
'weekend-10': { name: 'Weekend Regular', description: '10 weekend commits', icon: 'fa-house-laptop' },
'weekend-25': { name: 'Weekend Warrior', description: '25 weekend commits', icon: 'fa-gamepad' },
'weekend-50': { name: 'No Days Off', description: '50 weekend commits', icon: 'fa-person-running' },
// ===== OUT OF HOURS (Tiers: 10, 25, 50, 100) =====
'ooh-10': { name: 'Extra Hours', description: '10 commits outside 9am-5pm', icon: 'fa-clock-rotate-left' },
'ooh-25': { name: 'Flexible Schedule', description: '25 commits outside 9am-5pm', icon: 'fa-user-clock' },
'ooh-50': { name: 'Off-Hours Hero', description: '50 commits outside 9am-5pm', icon: 'fa-hourglass-half' },
'ooh-100': { name: 'Time Bender', description: '100 commits outside 9am-5pm', icon: 'fa-infinity' },
// ===== DOCUMENTATION & COMMENTS ADDED (Tiers: 100, 500, 1000, 2500, 5000) =====
'docs-100': { name: 'Documenter', description: 'Added 100 lines of comments/docs', icon: 'fa-file-lines' },
'docs-500': { name: 'Technical Writer', description: 'Added 500 lines of comments/docs', icon: 'fa-book' },
'docs-1000': { name: 'Documentation Hero', description: 'Added 1000 lines of comments/docs', icon: 'fa-book-open' },
'docs-2500': { name: 'Knowledge Keeper', description: 'Added 2500 lines of comments/docs', icon: 'fa-scroll' },
'docs-5000': { name: 'Code Historian', description: 'Added 5000 lines of comments/docs', icon: 'fa-landmark' },
// ===== COMMENT CLEANUP (Tiers: 50, 200, 500, 1000, 2500) =====
'docs-del-50': { name: 'Comment Trimmer', description: 'Removed 50 lines of outdated comments', icon: 'fa-scissors' },
'docs-del-200': { name: 'Cleanup Crew', description: 'Removed 200 lines of outdated comments', icon: 'fa-broom' },
'docs-del-500': { name: 'Dead Code Hunter', description: 'Removed 500 lines of outdated comments', icon: 'fa-skull-crossbones' },
'docs-del-1000': { name: 'Comment Surgeon', description: 'Removed 1000 lines of outdated comments', icon: 'fa-user-doctor' },
'docs-del-2500': { name: 'Noise Eliminator', description: 'Removed 2500 lines of outdated comments', icon: 'fa-volume-xmark' },
}
const getAchievement = (id) => {
+2 -2
View File
@@ -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)
return results.slice(0, props.maxDisplay)
return results
})
// Get count of remaining achievements (all unearned across all types)
+137
View File
@@ -0,0 +1,137 @@
// Achievement category mappings and utilities
// Define achievement categories and their tier ordering (highest tier last)
const achievementCategories = {
// Commits
'commit': ['commit-1', 'commit-10', 'commit-50', 'commit-100', 'commit-500', 'commit-1000'],
// PRs opened
'pr': ['pr-1', 'pr-10', 'pr-25', 'pr-50', 'pr-100', 'pr-250'],
// Reviews
'review': ['review-1', 'review-10', 'review-25', 'review-50', 'review-100', 'review-250'],
// Review comments
'comment': ['comment-10', 'comment-50', 'comment-100', 'comment-250', 'comment-500'],
// Lines added
'lines-added': ['lines-added-100', 'lines-added-1000', 'lines-added-5000', 'lines-added-10000', 'lines-added-50000'],
// Lines deleted
'lines-deleted': ['lines-deleted-100', 'lines-deleted-500', 'lines-deleted-1000', 'lines-deleted-5000', 'lines-deleted-10000'],
// Review time
'review-time': ['review-time-24h', 'review-time-4h', 'review-time-1h'],
// Multi-repo
'repo': ['repo-2', 'repo-5', 'repo-10'],
// Unique reviewees
'reviewees': ['reviewees-3', 'reviewees-10', 'reviewees-25'],
// Large PRs
'large-pr': ['large-pr-500', 'large-pr-1000', 'large-pr-5000'],
// Small PRs
'small-pr': ['small-pr-5', 'small-pr-10', 'small-pr-25', 'small-pr-50'],
// Perfect PRs
'perfect-pr': ['perfect-pr-1', 'perfect-pr-5', 'perfect-pr-10', 'perfect-pr-25'],
// Active days
'active': ['active-7', 'active-30', 'active-60', 'active-100'],
// Streaks
'streak': ['streak-3', 'streak-7', 'streak-14', 'streak-30'],
// Work week streaks
'workweek': ['workweek-3', 'workweek-5', 'workweek-10', 'workweek-20'],
// Early bird
'earlybird': ['earlybird-10', 'earlybird-25', 'earlybird-50', 'earlybird-100'],
// Night owl
'nightowl': ['nightowl-10', 'nightowl-25', 'nightowl-50', 'nightowl-100'],
// Midnight coder
'midnight': ['midnight-5', 'midnight-10', 'midnight-25', 'midnight-50'],
// Weekend warrior
'weekend': ['weekend-5', 'weekend-10', 'weekend-25', 'weekend-50'],
// Out of hours
'ooh': ['ooh-10', 'ooh-25', 'ooh-50', 'ooh-100'],
// Documentation added
'docs': ['docs-100', 'docs-500', 'docs-1000', 'docs-2500', 'docs-5000'],
// Documentation deleted
'docs-del': ['docs-del-50', 'docs-del-200', 'docs-del-500', 'docs-del-1000', 'docs-del-2500'],
}
// Get the category for an achievement ID
export function getAchievementCategory(achievementId) {
for (const [category, tiers] of Object.entries(achievementCategories)) {
if (tiers.includes(achievementId)) {
return category
}
}
return null
}
// Get the tier index within a category (higher = better)
export function getAchievementTier(achievementId) {
const category = getAchievementCategory(achievementId)
if (!category) return -1
return achievementCategories[category].indexOf(achievementId)
}
/**
* Filter achievements to show only the highest tier in each category
* @param {string[]} achievements - Array of achievement IDs
* @returns {string[]} - Filtered array with only highest tier per category
*/
export function getHighestTierAchievements(achievements) {
if (!achievements || !achievements.length) return []
// Group achievements by category, keeping only the highest tier
const highestByCategory = {}
for (const achievementId of achievements) {
const category = getAchievementCategory(achievementId)
if (!category) {
// Unknown achievement, keep it
highestByCategory[achievementId] = { id: achievementId, tier: -1 }
continue
}
const tier = getAchievementTier(achievementId)
if (!highestByCategory[category] || tier > highestByCategory[category].tier) {
highestByCategory[category] = { id: achievementId, tier }
}
}
// Return just the achievement IDs, sorted by tier (highest first)
return Object.values(highestByCategory)
.sort((a, b) => b.tier - a.tier)
.map(item => item.id)
}
/**
* Get a priority score for sorting achievements (higher = more impressive)
* Categories are weighted to show most impressive achievements first
*/
const categoryPriority = {
'commit': 10,
'pr': 9,
'review': 8,
'lines-added': 7,
'perfect-pr': 6,
'streak': 5,
'active': 4,
'review-time': 3,
'docs': 2,
}
export function getAchievementPriority(achievementId) {
const category = getAchievementCategory(achievementId)
const basePriority = categoryPriority[category] || 0
const tier = getAchievementTier(achievementId)
// Combine category priority with tier (tier adds 0.1 per level)
return basePriority + (tier * 0.1)
}
/**
* Get highest tier achievements, sorted by importance
* @param {string[]} achievements - Array of achievement IDs
* @param {number} limit - Maximum number to return
* @returns {string[]} - Filtered and sorted array
*/
export function getTopAchievements(achievements, limit = 6) {
const highest = getHighestTierAchievements(achievements)
// Sort by priority (most impressive first)
highest.sort((a, b) => getAchievementPriority(b) - getAchievementPriority(a))
return highest.slice(0, limit)
}
+46 -2
View File
@@ -11,6 +11,7 @@ import AchievementProgress from '../components/AchievementProgress.vue'
import SectionHeader from '../components/SectionHeader.vue'
import GithubLink from '../components/GithubLink.vue'
import { formatNumber, formatPercent, formatDuration } from '../composables/formatters'
import { getHighestTierAchievements } from '../composables/achievements'
const route = useRoute()
const globalData = inject('globalData')
@@ -127,7 +128,7 @@ watch(globalData, loadContributor)
<div v-if="contributor.achievements?.length" class="mt-6 flex flex-wrap justify-center md:justify-start gap-3">
<AchievementBadge
v-for="achievement in contributor.achievements"
v-for="achievement in getHighestTierAchievements(contributor.achievements)"
:key="achievement"
:achievement-id="achievement"
size="lg"
@@ -194,6 +195,30 @@ watch(globalData, loadContributor)
-{{ formatNumber(contributor.lines_deleted || 0) }}
</span>
</div>
<div v-if="contributor.meaningful_lines_added !== undefined" class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-300">Meaningful Lines Added</span>
<span class="text-emerald-500 font-semibold">
+{{ formatNumber(contributor.meaningful_lines_added || 0) }}
</span>
</div>
<div v-if="contributor.meaningful_lines_deleted !== undefined" class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-300">Meaningful Lines Deleted</span>
<span class="text-rose-500 font-semibold">
-{{ formatNumber(contributor.meaningful_lines_deleted || 0) }}
</span>
</div>
<div v-if="contributor.comment_lines_added !== undefined" class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-300">Comment Lines Added</span>
<span class="text-cyan-500 font-semibold">
+{{ formatNumber(contributor.comment_lines_added || 0) }}
</span>
</div>
<div v-if="contributor.comment_lines_deleted !== undefined" class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-300">Comment Lines Deleted</span>
<span class="text-amber-500 font-semibold">
-{{ formatNumber(contributor.comment_lines_deleted || 0) }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-300">Files Changed</span>
<span class="text-gray-800 dark:text-white font-semibold">
@@ -260,36 +285,55 @@ watch(globalData, loadContributor)
<i class="fas fa-chart-pie gradient-text mr-2"></i>Score Breakdown
</h3>
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4">
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<div class="text-2xl font-bold text-green-500">
{{ formatNumber(contributor.score.breakdown.commits || 0) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Commits</div>
<div class="text-xs text-gray-400 dark:text-gray-500">{{ contributor.commit_count || 0 }} × 10 pts</div>
</div>
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<div class="text-2xl font-bold text-blue-500">
{{ formatNumber(contributor.score.breakdown.prs || 0) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">PRs</div>
<div class="text-xs text-gray-400 dark:text-gray-500">{{ contributor.prs_opened || 0 }} opened + {{ contributor.prs_merged || 0 }} merged</div>
</div>
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<div class="text-2xl font-bold text-purple-500">
{{ formatNumber(contributor.score.breakdown.reviews || 0) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Reviews</div>
<div class="text-xs text-gray-400 dark:text-gray-500">{{ contributor.reviews_given || 0 }} × 30 pts</div>
</div>
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<div class="text-2xl font-bold text-pink-500">
{{ formatNumber(contributor.score.breakdown.comments || 0) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Comments</div>
<div class="text-xs text-gray-400 dark:text-gray-500">{{ contributor.review_comments || 0 }} × 5 pts</div>
</div>
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<div class="text-2xl font-bold text-orange-500">
{{ formatNumber(contributor.score.breakdown.line_changes || 0) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Line Changes</div>
<div class="text-xs text-gray-400 dark:text-gray-500">meaningful lines × 0.1 pts</div>
</div>
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<div class="text-2xl font-bold text-yellow-500">
{{ formatNumber(contributor.score.breakdown.response_bonus || 0) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Response Bonus</div>
<div class="text-xs text-gray-400 dark:text-gray-500">fast review bonus</div>
</div>
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<div class="text-2xl font-bold text-indigo-500">
{{ formatNumber(contributor.score.breakdown.out_of_hours || 0) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Out of Hours</div>
<div class="text-xs text-gray-400 dark:text-gray-500">{{ contributor.out_of_hours_count || 0 }} × 2 pts</div>
</div>
</div>
</div>
+3 -2
View File
@@ -6,6 +6,7 @@ import ContributorRow from '../components/ContributorRow.vue'
import RankBadge from '../components/RankBadge.vue'
import AchievementBadge from '../components/AchievementBadge.vue'
import { formatNumber } from '../composables/formatters'
import { getHighestTierAchievements } from '../composables/achievements'
const globalData = inject('globalData')
const leaderboard = computed(() => globalData.value?.leaderboard || [])
@@ -59,9 +60,9 @@ const categoryIcon = (category) => {
</template>
<template #achievements="{ item }">
<div class="flex flex-wrap gap-1.5 max-w-[180px]">
<div class="flex flex-wrap gap-1.5 max-w-[280px]">
<AchievementBadge
v-for="achievement in (item.achievements || []).slice(0, 6)"
v-for="achievement in getHighestTierAchievements(item.achievements)"
:key="achievement"
:achievement-id="achievement"
size="sm"